Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions projects/ios-readplace/App/AffordancePresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,34 @@ extension Affordance {
return fields.allSatisfy { $0.value != nil }
}

/// Whether this affordance is a link carrying any structural navigation rel
/// (`self`/`root`/`prev`/`next`/`item`) — client plumbing the client follows for
/// its own pagination/identity/item resolution, never a user control. Tests every
/// rel, not just the presentation token, so a multi-rel link like
/// `["alternate", "next"]` can't slip through as a tappable control while the
/// client also follows it for paging.
var isStructuralLink: Bool {
guard case let .link(link) = invocation else { return false }
return link.rel.contains { Affordance.structuralRels.contains($0) }
}

/// Whether the row surfaces this link as a discrete control: a semantic link that
/// is neither structural plumbing nor the `read` rel (already the row's primary
/// tap target). Keeps a future item link (e.g. `share`) rendering as a control
/// instead of being discarded, without double-rendering `read`.
var isSemanticControlLink: Bool {
guard case let .link(link) = invocation else { return false }
return !link.rel.contains { Affordance.structuralRels.contains($0) || $0 == "read" }
}

/// Whether the toolbar should surface this affordance as a control: it must be
/// both presentable in the toolbar (a structural navigation link or a
/// capture-only save is excluded by its presentation) and actually invokable
/// presentable in the toolbar (a structural navigation link or a capture-only
/// save is excluded), not carry any structural rel, and be actually invokable
/// from a bare control (a field-requiring action with no server value is
/// excluded).
var isToolbarControl: Bool {
presentation.isToolbarControl && isInvokableByBareControl
guard !isStructuralLink else { return false }
return presentation.isToolbarControl && isInvokableByBareControl
}

/// Whether invoking this affordance removes the item it acts on from the
Expand All @@ -148,12 +169,17 @@ extension Affordance {

extension Article {
/// The advertised item affordances a row surfaces as swipe and accessibility
/// controls, filtered to those a bare control can actually invoke. Like the
/// controls: every action a bare control can actually invoke, plus every semantic
/// link that is neither structural plumbing nor the `read` tap target — so a
/// future item link (e.g. `share`) renders instead of being discarded. Like the
/// toolbar, the row drops a field-requiring action with no server value so a
/// future such item action is never rendered as a swipe that errors on tap. The
/// selection lives here, beside the symmetric toolbar rule
/// (`Affordance.isToolbarControl`) and the shared predicate it reuses
/// (`isInvokableByBareControl`), so the row's choice of controls is unit-testable
/// without standing up a view.
var rowControls: [Affordance] { affordances.filter(\.isInvokableByBareControl) }
/// (`Affordance.isToolbarControl`) and the shared predicates it reuses
/// (`isInvokableByBareControl`, `isSemanticControlLink`), so the row's choice of
/// controls is unit-testable without standing up a view.
var rowControls: [Affordance] {
affordances.filter(\.isInvokableByBareControl)
+ links.compactMap(Affordance.init(link:)).filter(\.isSemanticControlLink)
}
}
37 changes: 18 additions & 19 deletions projects/ios-readplace/App/AppSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ final class AppSession: ObservableObject {

// Defaults to an ephemeral configuration so the API/OAuth sessions keep their
// cookie jar in an isolated, in-memory store rather than process-wide
// `HTTPCookieStorage.shared` — the minted `hutch_sid` reader cookie must not
// linger in the shared jar where it would outlive a sign-out.
// `HTTPCookieStorage.shared` — the minted reader session cookie must not linger
// in the shared jar where it would outlive a sign-out.
//
// `wipeReaderWebStore` defaults to the real WebKit deletion; it's the
// OS-boundary seam tests replace with a spy.
Expand Down Expand Up @@ -97,32 +97,31 @@ final class AppSession: ObservableObject {
return readerWipe
}

private static func isSessionCookie(_ cookie: HTTPCookie) -> Bool {
cookie.name == AppConfig.sessionCookieName
}

/// Drops the minted browser session cookie (`hutch_sid`) on sign-out so it
/// doesn't linger in the API session's cookie jar for the next sign-in in the
/// same process. The jar is the configuration's own isolated store (never
/// `HTTPCookieStorage.shared`), so this clears only this app's copy.
/// Clears the API session's isolated cookie jar on sign-out so the minted
/// browser session cookie doesn't linger for the next sign-in in the same
/// process. The jar is the configuration's own isolated store (never
/// `HTTPCookieStorage.shared`), so clearing it wholesale touches only this app's
/// copy and needs no knowledge of the server's cookie name.
private func clearSessionCookie() {
let storage = sessionConfiguration.httpCookieStorage
for cookie in storage?.cookies ?? [] where Self.isSessionCookie(cookie) {
for cookie in storage?.cookies ?? [] {
storage?.deleteCookie(cookie)
}
}

/// Removes the reader's authenticated traces from the process-wide WebKit
/// default store on sign-out. The session cookie is deleted by name — a
/// blanket cookie wipe would also drop server-set cookies a full-shell page
/// may have put in this store (e.g. a changelog dismissal set after a
/// session-expiry redirect) — while every non-cookie data type is cleared so
/// the signed-out account's reading history doesn't stay on disk, accepting
/// that the share hint's localStorage dismissal resets with it.
/// default store on sign-out: every cookie scoped to the app's own server host
/// (whatever the server named the session cookie), so a server cookie rename
/// needs no app release, plus every non-cookie data type so the signed-out
/// account's reading history doesn't stay on disk. Scoping the cookie wipe to the
/// server host leaves cookies for other origins untouched.
private static func removeReaderWebStoreData() async {
let store = WKWebsiteDataStore.default()
for cookie in await store.httpCookieStore.allCookies() where isSessionCookie(cookie) {
await store.httpCookieStore.delete(cookie)
if let host = URL(string: AppConfig.serverBaseURL)?.host {
for cookie in await store.httpCookieStore.allCookies()
where cookie.domain == host || cookie.domain.hasSuffix("." + host) {
await store.httpCookieStore.delete(cookie)
}
}
let nonCookieTypes = WKWebsiteDataStore.allWebsiteDataTypes().subtracting([WKWebsiteDataTypeCookies])
await store.removeData(ofTypes: nonCookieTypes, modifiedSince: .distantPast)
Expand Down
12 changes: 6 additions & 6 deletions projects/ios-readplace/App/ReaderSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ import SwiftUI
/// tap keeps tapping a row instantly responsive, like following a link.
struct ReaderSheet: View {
let presentation: ReaderPresentation
let mintSession: () async -> HTTPCookie?
let mintSession: () async -> [HTTPCookie]?
let onMarkedRead: () -> Void
let onClose: () -> Void

@State private var cookie: HTTPCookie?
@State private var cookies: [HTTPCookie]?
@State private var bootstrapFailed = false

var body: some View {
Group {
if let cookie {
if let cookies {
ReaderWebView(
url: presentation.readerURL,
cookie: cookie,
cookies: cookies,
onMarkedRead: onMarkedRead,
onClose: onClose,
externalBrowser: .system
Expand All @@ -32,9 +32,9 @@ struct ReaderSheet: View {
}
.tint(.brandAmber)
.task {
guard cookie == nil, !bootstrapFailed else { return }
guard cookies == nil, !bootstrapFailed else { return }
if let minted = await mintSession() {
cookie = minted
cookies = minted
} else {
bootstrapFailed = true
}
Expand Down
14 changes: 10 additions & 4 deletions projects/ios-readplace/App/ReaderWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import WebKit
/// existing precedent.
struct ReaderWebView: UIViewControllerRepresentable {
let url: URL
let cookie: HTTPCookie
let cookies: [HTTPCookie]
let onMarkedRead: () -> Void
let onClose: () -> Void
/// Injected so the composition point wires the live browser and tests inject
Expand Down Expand Up @@ -50,10 +50,16 @@ struct ReaderWebView: UIViewControllerRepresentable {
webView.navigationDelegate = context.coordinator
controller.view = webView

// Inject the prefetched session cookie into the web view's own store before
// Inject every prefetched session cookie into the web view's own store before
// the first navigation, so the reader and its in-reader XHRs are
// authenticated from the first request.
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) {
// authenticated from the first request. The client forwards whatever the
// bootstrap set rather than picking one by name, so a server cookie change
// needs no app release.
Task { @MainActor in
let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
for cookie in cookies {
await cookieStore.setCookie(cookie)
}
webView.load(URLRequest(url: url))
}
return controller
Expand Down
2 changes: 1 addition & 1 deletion projects/ios-readplace/App/ReadingListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ struct ReadingListView: View {
presentation: presentation,
mintSession: { await viewModel.mintReaderSession() },
onMarkedRead: {
if let id = presentation.articleId { Task { await viewModel.readerMarkedRead(id: id) } }
Task { await viewModel.readerStatusChanged() }
viewModel.readerPresentation = nil
},
onClose: { viewModel.readerPresentation = nil }
Expand Down
25 changes: 13 additions & 12 deletions projects/ios-readplace/App/ReadingListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,15 @@ final class ReadingListViewModel: ObservableObject {
}
}

/// Drops the row the reader just marked read — instantly, so the unread-only
/// list never shows it again behind the sheet — then converges with the server.
/// The reader's own POST answers inside the webview where no Siren body is
/// available, so a shallow list re-reads to bring in whatever changed elsewhere
/// (e.g. an item marked unread on the website); a deep-scrolled list keeps only
/// the local drop so the viewport never moves (`convergeWithServer`).
func readerMarkedRead(id: String) async {
articles.removeAll { $0.id == id }
await convergeWithServer(droppingId: id)
/// Reconciles the list after the reader reports a status change from inside the
/// webview. The reader's own POST answers where no Siren body is available and
/// the client cannot see which direction the toggle went, so it does not infer
/// "read" and drop a row — it re-reads the collection and adopts the server's
/// truth (a shallow list), which also brings in whatever changed elsewhere (e.g.
/// an item marked unread on the website). A deep-scrolled list holds its position
/// and reconciles on the next pull-to-refresh (`convergeWithServer`).
func readerStatusChanged() async {
await convergeWithServer(droppingId: nil)
}

/// Re-reads the list when the app returns to the foreground, so changes made
Expand Down Expand Up @@ -229,9 +229,10 @@ final class ReadingListViewModel: ObservableObject {
}

/// Mints the cookie session the reader webview needs from the current bearer.
/// Returns nil and surfaces the error when the bootstrap fails, so the reader
/// sheet can show its unavailable view instead of a blank page.
func mintReaderSession() async -> HTTPCookie? {
/// Returns the cookies the server set, or nil (surfacing the error) when the
/// bootstrap fails, so the reader sheet can show its unavailable view instead of
/// a blank page.
func mintReaderSession() async -> [HTTPCookie]? {
do {
return try await api.bootstrapSession()
} catch {
Expand Down
6 changes: 5 additions & 1 deletion projects/ios-readplace/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ open: generate
# someone builds `make ipa-staging` by hand.
SIM ?= platform=iOS Simulator,name=$(shell xcrun simctl list devices available | grep -m1 -oE 'iPhone [0-9]+')
SIM_DEVICE = $(shell printf '%s' '$(SIM)' | sed -E 's/.*=//')
COVERAGE_RESULT = build/TestResults.xcresult
test: generate
xcrun simctl bootstatus '$(SIM_DEVICE)' -b
xcodebuild test -project Readplace.xcodeproj -scheme Readplace -destination '$(SIM)'
rm -rf $(COVERAGE_RESULT)
xcodebuild test -project Readplace.xcodeproj -scheme Readplace -destination '$(SIM)' \
-enableCodeCoverage YES -resultBundlePath $(COVERAGE_RESULT)
python3 scripts/check-coverage.py $(COVERAGE_RESULT)
$(MAKE) test-staging SIM='$(SIM)'

# Compile the STAGING condition and smoke-test it. The full suite can't run under
Expand Down
8 changes: 6 additions & 2 deletions projects/ios-readplace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,12 @@ You need a Mac with **Xcode 15+** and an Apple ID (a free personal team is fine)

`make test` (or `Cmd+U` in Xcode) runs the XCTest suite. The network is stubbed
with a `URLProtocol`, so tests exercise the real client logic — headers, bodies,
redirects, retries — without a server. Coverage focuses on boundaries and edge
cases:
redirects, retries — without a server. `make test` then enforces a per-file
line-coverage gate (`scripts/check-coverage.py` against
`scripts/coverage-baseline.json`): pure SwiftUI views and WebKit/OS-boundary glue
are excluded, and every other source file must stay at or above its recorded
floor — a ratchet the logic files are walked toward 100%. Coverage focuses on
boundaries and edge cases:

- **Siren decoding**: rich vs. minimal entities, JSON `null` image/`readAt`,
read-state from `status`/`readAt`, title fallback to URL, entities without
Expand Down
13 changes: 3 additions & 10 deletions projects/ios-readplace/Shared/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,11 @@ enum AppConfig {
/// advertising it as a Siren link.
static let addLinksHelpPath = "/help/add-links"

/// Query item the in-app reader appends to the server `read` link so the same
/// `/queue/:id/view` route renders chromeless — bare of the web shell — with the
/// native reading list as its chrome. An explicit client-sent signal (never a
/// user-agent sniff); must match the server's `?platform=ios` switch.
/// Query item the in-app reader appends to the server `read` link so the reader
/// renders chromeless — bare of the web shell — with the native reading list as
/// its chrome. An explicit client-sent signal, never a user-agent sniff.
static let readerPlatformQueryItem = URLQueryItem(name: "platform", value: "ios")

/// Name of the server's browser session cookie (`hutch_sid`). Minted from a
/// bearer token via `POST /auth/session` and injected into the in-app reader
/// webview so its cookie-authenticated pages load. Must match the server's
/// `SESSION_COOKIE_NAME`.
static let sessionCookieName = "hutch_sid"

/// Shared container so the app (which signs in) and the share extension
/// (which saves) can both read the OAuth tokens.
///
Expand Down
12 changes: 6 additions & 6 deletions projects/ios-readplace/Shared/OAuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,17 @@ struct OAuthService {
var nativeRedirectURI: String { AppConfig.nativeCallbackURL }

/// Builds the external-browser Login `/oauth/authorize` URL: the native
/// custom-scheme callback plus `screen_hint=login`, which routes an
/// unauthenticated user to `/login` (an already-authenticated Chrome session
/// passes straight through to consent, ignoring the hint).
/// custom-scheme callback plus `screen_hint=login`, so the server shows an
/// unauthenticated user the sign-in screen (an already-authenticated Chrome
/// session passes straight through to consent, ignoring the hint).
func makeNativeLoginAuthorizationRequest() -> AuthorizationRequest {
makeAuthorizationRequest(redirectURI: nativeRedirectURI, screenHint: "login")
}

/// Builds the external-browser Sign up `/oauth/authorize` URL: the native
/// custom-scheme callback plus `screen_hint=signup`, which routes an
/// unauthenticated user to `/signup` (an already-authenticated Chrome session
/// passes straight through to consent, ignoring the hint).
/// custom-scheme callback plus `screen_hint=signup`, so the server shows an
/// unauthenticated user the sign-up screen (an already-authenticated Chrome
/// session passes straight through to consent, ignoring the hint).
func makeSignupAuthorizationRequest() -> AuthorizationRequest {
makeAuthorizationRequest(redirectURI: nativeRedirectURI, screenHint: "signup")
}
Expand Down
Loading
Loading