From 90a1ecc0685d4af9761b86d65e4d5418787784d8 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 4 Jul 2026 22:33:52 +1000 Subject: [PATCH 1/4] fix(ios-readplace): remove hard-coded server internals from the client Several places had the client hard-coding server internals instead of discovering or tolerating them, so a server-side change would break the app or force an App Store release. This removes them: - Inject every cookie the session-bootstrap response sets into the reader web view instead of selecting one by the hard-coded `hutch_sid` name, and scrub sign-out cookies by server host rather than by name. A server cookie rename no longer breaks the reader or needs an app release. - On a reader status change, re-read the collection and adopt the server's truth instead of inferring "read" and dropping the row locally. A toggle back to unread no longer wrongly removes the item from the list. - Decode a collection warning leniently and make `code` optional, so an evolving or malformed warning degrades to no banner instead of failing the whole collection decode and blanking the page. - Exclude a link from toolbar controls when ANY of its rels is structural, not just the presentation token, so a multi-rel structural link can't render as a tappable control while also driving pagination. - Surface an item's non-structural, non-`read` semantic links as row controls, so a future item link (e.g. `share`) renders instead of being silently discarded. - Drop comments naming concrete server routes/methods/symbols (`/queue`, `/auth/session`, `/oauth/token`, `SESSION_COOKIE_NAME`, `isFile`, `/login`, `/signup`) that rot silently and reintroduce the URL coupling the hypermedia contract removes. --- .../App/AffordancePresentation.swift | 42 ++++++++++--- projects/ios-readplace/App/AppSession.swift | 36 ++++++----- projects/ios-readplace/App/ReaderSheet.swift | 12 ++-- .../ios-readplace/App/ReaderWebView.swift | 14 +++-- .../ios-readplace/App/ReadingListView.swift | 2 +- .../App/ReadingListViewModel.swift | 25 ++++---- projects/ios-readplace/Shared/AppConfig.swift | 13 +--- .../ios-readplace/Shared/OAuthService.swift | 12 ++-- .../ios-readplace/Shared/ReadplaceAPI.swift | 60 +++++++++---------- .../ios-readplace/Shared/SirenModels.swift | 47 ++++++++++++--- .../ios-readplace/Shared/TokenStore.swift | 2 +- .../ios-readplace/Tests/LoginFlowTests.swift | 8 +-- .../Tests/ReadingListViewModelTests.swift | 25 ++++---- .../Tests/ReadplaceAPITests.swift | 16 ++--- .../ios-readplace/Tests/TestSupport.swift | 9 +-- 15 files changed, 191 insertions(+), 132 deletions(-) diff --git a/projects/ios-readplace/App/AffordancePresentation.swift b/projects/ios-readplace/App/AffordancePresentation.swift index e5e0ed211..3eacf0e55 100644 --- a/projects/ios-readplace/App/AffordancePresentation.swift +++ b/projects/ios-readplace/App/AffordancePresentation.swift @@ -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 @@ -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) + } } diff --git a/projects/ios-readplace/App/AppSession.swift b/projects/ios-readplace/App/AppSession.swift index 0cb0db6de..02f76fd2d 100644 --- a/projects/ios-readplace/App/AppSession.swift +++ b/projects/ios-readplace/App/AppSession.swift @@ -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. @@ -97,32 +97,30 @@ 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.contains(host) { + await store.httpCookieStore.delete(cookie) + } } let nonCookieTypes = WKWebsiteDataStore.allWebsiteDataTypes().subtracting([WKWebsiteDataTypeCookies]) await store.removeData(ofTypes: nonCookieTypes, modifiedSince: .distantPast) diff --git a/projects/ios-readplace/App/ReaderSheet.swift b/projects/ios-readplace/App/ReaderSheet.swift index 675d9393f..d7b3275d8 100644 --- a/projects/ios-readplace/App/ReaderSheet.swift +++ b/projects/ios-readplace/App/ReaderSheet.swift @@ -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 @@ -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 } diff --git a/projects/ios-readplace/App/ReaderWebView.swift b/projects/ios-readplace/App/ReaderWebView.swift index b9e85a25f..7fc096f91 100644 --- a/projects/ios-readplace/App/ReaderWebView.swift +++ b/projects/ios-readplace/App/ReaderWebView.swift @@ -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 @@ -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 diff --git a/projects/ios-readplace/App/ReadingListView.swift b/projects/ios-readplace/App/ReadingListView.swift index 278a0b4c9..466c243d2 100644 --- a/projects/ios-readplace/App/ReadingListView.swift +++ b/projects/ios-readplace/App/ReadingListView.swift @@ -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 } diff --git a/projects/ios-readplace/App/ReadingListViewModel.swift b/projects/ios-readplace/App/ReadingListViewModel.swift index bd5338d9d..676d78dbd 100644 --- a/projects/ios-readplace/App/ReadingListViewModel.swift +++ b/projects/ios-readplace/App/ReadingListViewModel.swift @@ -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 @@ -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 { diff --git a/projects/ios-readplace/Shared/AppConfig.swift b/projects/ios-readplace/Shared/AppConfig.swift index 9cae47f0b..ac09496c1 100644 --- a/projects/ios-readplace/Shared/AppConfig.swift +++ b/projects/ios-readplace/Shared/AppConfig.swift @@ -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. /// diff --git a/projects/ios-readplace/Shared/OAuthService.swift b/projects/ios-readplace/Shared/OAuthService.swift index db341e9a2..e5cf519e2 100644 --- a/projects/ios-readplace/Shared/OAuthService.swift +++ b/projects/ios-readplace/Shared/OAuthService.swift @@ -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") } diff --git a/projects/ios-readplace/Shared/ReadplaceAPI.swift b/projects/ios-readplace/Shared/ReadplaceAPI.swift index 10f5cb912..86ba68ba3 100644 --- a/projects/ios-readplace/Shared/ReadplaceAPI.swift +++ b/projects/ios-readplace/Shared/ReadplaceAPI.swift @@ -96,8 +96,8 @@ final class ReadplaceAPI { // Defaults to an ephemeral configuration so the session's cookie jar is its // own isolated, in-memory store rather than process-wide `HTTPCookieStorage.shared`: - // the `hutch_sid` cookie minted by `bootstrapSession` must not linger in the - // shared jar where it would outlive the session and leak across sign-outs. + // the session cookie minted by `bootstrapSession` must not linger in the shared + // jar where it would outlive the session and leak across sign-outs. init( baseURL: String, store: TokenStore, @@ -182,12 +182,15 @@ final class ReadplaceAPI { // MARK: - Reader session - /// Mints a browser session cookie from the current bearer token via the - /// session-bootstrap endpoint and returns it. The in-app reader injects the - /// cookie so the cookie-authenticated reader page (and its in-reader XHRs) - /// load without bouncing to a sign-in page. Reuses `send()`, so a stale bearer - /// is refreshed once before the cookie is minted. - func bootstrapSession() async throws -> HTTPCookie { + /// Mints a browser session from the current bearer token via the + /// session-bootstrap endpoint and returns every cookie the response set. The + /// in-app reader injects them so the cookie-authenticated reader page (and its + /// in-reader XHRs) load without bouncing to a sign-in page. The client never + /// selects a cookie by name — it forwards whatever the server set — so a server + /// cookie rename needs no app release. Reuses `send()`, so a stale bearer is + /// refreshed once before the session is minted; a response that sets no cookie + /// is a failed mint. + func bootstrapSession() async throws -> [HTTPCookie] { guard let url = URL(string: "\(baseURL)/auth/session") else { throw APIError.decoding } var request = URLRequest(url: url) request.httpMethod = "POST" @@ -195,28 +198,26 @@ final class ReadplaceAPI { guard (200...299).contains(http.statusCode) else { throw apiError(from: data, status: http.statusCode) } - guard let cookie = sessionCookie(from: http, url: url) else { throw APIError.decoding } - return cookie + let cookies = sessionCookies(from: http, url: url) + guard !cookies.isEmpty else { throw APIError.decoding } + return cookies } - /// Reads the session cookie by name from the session's own cookie jar, which - /// the configuration isolates (an ephemeral store, not `HTTPCookieStorage.shared`) - /// so the cookie URLSession just parsed never touches the process-wide jar. - /// The cookie spec forbids folding repeated `Set-Cookie` headers into one - /// comma-joined value, so re-splitting `allHeaderFields` is unsafe once a - /// response sets more than one cookie; reading the already-parsed cookie back - /// by name sidesteps that. Falls back to the response's own `Set-Cookie` - /// header (reliable for a single cookie) for environments that don't populate - /// the store. - private func sessionCookie(from response: HTTPURLResponse, url: URL) -> HTTPCookie? { - if let stored = session.configuration.httpCookieStorage? - .cookies(for: url)? - .first(where: { $0.name == AppConfig.sessionCookieName }) { + /// Reads every cookie the bootstrap response set from the session's own cookie + /// jar, which the configuration isolates (an ephemeral store, not + /// `HTTPCookieStorage.shared`) so the cookies URLSession just parsed never touch + /// the process-wide jar. The cookie spec forbids folding repeated `Set-Cookie` + /// headers into one comma-joined value, so re-splitting `allHeaderFields` is + /// unsafe once a response sets more than one cookie; reading the already-parsed + /// cookies back from the store sidesteps that. Falls back to parsing the + /// response's own `Set-Cookie` header for environments that don't populate the + /// store. + private func sessionCookies(from response: HTTPURLResponse, url: URL) -> [HTTPCookie] { + if let stored = session.configuration.httpCookieStorage?.cookies(for: url), !stored.isEmpty { return stored } - guard let headers = response.allHeaderFields as? [String: String] else { return nil } + guard let headers = response.allHeaderFields as? [String: String] else { return [] } return HTTPCookie.cookies(withResponseHeaderFields: headers, for: url) - .first { $0.name == AppConfig.sessionCookieName } } // MARK: - Saving @@ -299,8 +300,8 @@ final class ReadplaceAPI { /// Builds a `multipart/form-data` request with a UUID boundary for /// `save-content`: the `url`, `mediaType` and (when non-empty) `title` text - /// parts, then a `content` file part whose `filename` attribute is what the - /// server keys `isFile` off — its per-part Content-Type is ignored. + /// parts, then a `content` file part. The content part carries a `filename` + /// attribute so the server treats it as a file rather than a text field. private func multipartRequest( _ url: URL, method: String, @@ -501,10 +502,9 @@ final class ReadplaceAPI { /// Re-attaches `Authorization`, `Accept` and `X-Readplace-Client` to redirected /// requests. URLSession strips `Authorization` on cross-origin redirects and may -/// drop custom headers generally; the server bounces the client from the entry +/// drop custom headers generally; the server redirects the client from the entry /// point to the collection, so the followed redirect must keep them to stay -/// authenticated and keep negotiating Siren (the client header so onboarding -/// step 1 is recorded on the post-redirect `/queue` load). +/// authenticated and keep negotiating Siren. private final class RedirectHeaderPreservingDelegate: NSObject, URLSessionTaskDelegate { func urlSession( _ session: URLSession, diff --git a/projects/ios-readplace/Shared/SirenModels.swift b/projects/ios-readplace/Shared/SirenModels.swift index e1a5b5ced..916f7b2dc 100644 --- a/projects/ios-readplace/Shared/SirenModels.swift +++ b/projects/ios-readplace/Shared/SirenModels.swift @@ -30,6 +30,17 @@ private extension KeyedDecodingContainer { } return elements } + + /// Decodes a single value under `key` leniently: a missing, null, or malformed + /// value yields `nil` instead of failing the surrounding decode. Used for a + /// property whose absence or evolution must degrade the one thing it feeds, not + /// blank the whole response. + func decodeLossyIfPresent( + _ type: Value.Type, + forKey key: Key + ) throws -> Value? { + (try decodeIfPresent(FailableDecodable.self, forKey: key))?.wrapped + } } // MARK: - Wire format (Siren) @@ -158,10 +169,24 @@ struct CollectionProperties: Decodable { let warning: SirenWarning? } +extension CollectionProperties { + private enum CodingKeys: String, CodingKey { case warning } + + /// Decodes the warning leniently so an evolving or malformed warning degrades to + /// no banner rather than failing the whole collection decode: the warning is a + /// non-fatal channel, so a change to it must never be the one thing that blanks + /// the page. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + warning = try container.decodeLossyIfPresent(SirenWarning.self, forKey: .warning) + } +} + /// A non-fatal reason the server attaches to a collection (e.g. a URL that -/// couldn't be saved). +/// couldn't be saved). `code` is optional: the client renders only `message`, so a +/// warning whose classifier the client doesn't read still surfaces its text. struct SirenWarning: Decodable { - let code: String + let code: String? let message: String } @@ -208,9 +233,8 @@ struct ServerMessage: Decodable, Equatable { extension ServerMessage { /// How a client should present a message. The wire `type` stays a `String` so /// an unknown future value still decodes; `kind` maps it for the UI and treats - /// any unrecognized value as `.warning` (the neutral default) — mirroring the - /// server's current `"warning" | "error"` union without hard-failing on a value - /// a newer server might add. + /// any unrecognized value as `.warning` (the neutral default), so a value a + /// newer server adds never hard-fails. enum Kind { case warning case error @@ -324,6 +348,11 @@ struct Article: Identifiable, Hashable { /// per actionable entry by iterating this — it never cherry-picks an action by /// name — so a newly-advertised item action renders with no client change. let actions: [SirenAction] + /// Every navigable link the server advertised on this item, in wire order. The + /// `read` link (the row's primary tap target) is surfaced through `readHref`; + /// every other non-structural semantic link becomes a discrete control, so a + /// future item link (e.g. `share`) renders instead of being discarded. + let links: [SirenLink] /// The href of the server-declared link for reading this item, followed when /// the row is tapped. Absent ⇒ the row is not openable. This follows the /// navigable `read` link (the row's primary tap target), distinct from the @@ -337,7 +366,10 @@ struct Article: Identifiable, Hashable { extension Article { /// The item's action controls, one per advertised action the client can /// invoke (an action with a usable href). Built by iterating — not by matching - /// a known name — so the loop renders whatever the server offered. + /// a known name — so the loop renders whatever the server offered. The row also + /// surfaces the item's semantic links (see `rowControls`), which is derived in + /// the presentation layer because a link's control-worthiness is a client + /// presentation concern. var affordances: [Affordance] { actions.compactMap(Affordance.init(action:)) } /// Builds a display model from a Siren entity, or returns nil when the @@ -358,7 +390,8 @@ extension Article { isRead = props.status == "read" || props.readAt != nil savedAt = props.savedAt.flatMap(SirenDate.parse) actions = entity.actions ?? [] - readHref = entity.links?.first { $0.rel.contains("read") }?.href + links = entity.links ?? [] + readHref = links.first { $0.rel.contains("read") }?.href } } diff --git a/projects/ios-readplace/Shared/TokenStore.swift b/projects/ios-readplace/Shared/TokenStore.swift index db057334d..ab7ef6c9d 100644 --- a/projects/ios-readplace/Shared/TokenStore.swift +++ b/projects/ios-readplace/Shared/TokenStore.swift @@ -1,6 +1,6 @@ import Foundation -/// OAuth tokens issued by the server's `/oauth/token` endpoint. +/// The OAuth token pair persisted for the app and share extension. struct OAuthTokens: Equatable { let accessToken: String let refreshToken: String diff --git a/projects/ios-readplace/Tests/LoginFlowTests.swift b/projects/ios-readplace/Tests/LoginFlowTests.swift index ebcd3534d..d79248417 100644 --- a/projects/ios-readplace/Tests/LoginFlowTests.swift +++ b/projects/ios-readplace/Tests/LoginFlowTests.swift @@ -98,8 +98,8 @@ final class LoginFlowTests: XCTestCase { XCTAssertFalse(session.isLoggedIn) XCTAssertNil( - config.httpCookieStorage?.cookies?.first { $0.name == AppConfig.sessionCookieName }, - "the minted hutch_sid cookie must not survive a forced sign-out" + config.httpCookieStorage?.cookies?.first { $0.name == "hutch_sid" }, + "the minted session cookie must not survive a forced sign-out" ) await readerWipe.value XCTAssertTrue( @@ -123,8 +123,8 @@ final class LoginFlowTests: XCTestCase { XCTAssertFalse(session.isLoggedIn) XCTAssertNil( - config.httpCookieStorage?.cookies?.first { $0.name == AppConfig.sessionCookieName }, - "the minted hutch_sid cookie must not survive sign-out" + config.httpCookieStorage?.cookies?.first { $0.name == "hutch_sid" }, + "the minted session cookie must not survive sign-out" ) XCTAssertTrue( readerWipeInvoked, diff --git a/projects/ios-readplace/Tests/ReadingListViewModelTests.swift b/projects/ios-readplace/Tests/ReadingListViewModelTests.swift index 9d4216593..43d086222 100644 --- a/projects/ios-readplace/Tests/ReadingListViewModelTests.swift +++ b/projects/ios-readplace/Tests/ReadingListViewModelTests.swift @@ -735,10 +735,12 @@ final class ReadingListViewModelTests: XCTestCase { XCTAssertNil(viewModel.errorText) } - func testReaderMarkedReadDropsTheRowAndConvergesWithTheServer() async { - // The reader's own POST already happened inside the webview, so the native - // side drops the row immediately and then converges with a fresh load — - // which also brings in an item marked unread on the website (w1). + func testReaderStatusChangedConvergesWithTheServerWithoutInferringDirection() async { + // The reader's own POST already happened inside the webview, but the client + // can't 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, which + // no longer lists the read item (a1) and brings in an item marked unread on + // the website (w1). let postAction = Fixtures.collection( entitiesJSON: [Fixtures.article(id: "a2"), Fixtures.article(id: "w1")], total: 2 ) @@ -746,11 +748,11 @@ final class ReadingListViewModelTests: XCTestCase { let viewModel = makeViewModel(store: TestSupport.loggedInStore()) await viewModel.refresh() - await viewModel.readerMarkedRead(id: "a1") + await viewModel.readerStatusChanged() XCTAssertEqual( viewModel.articles.map(\.id), ["a2", "w1"], - "the read row is dropped and the convergence load is adopted" + "the server's re-read collection is adopted as truth; no row is dropped by inference" ) XCTAssertTrue( StubURLProtocol.records.allSatisfy { $0.request.httpMethod != "POST" }, @@ -799,7 +801,7 @@ final class ReadingListViewModelTests: XCTestCase { Article( id: id, url: "https://example.com/x", title: "X", siteName: nil, excerpt: nil, imageURL: nil, readTimeMinutes: nil, isRead: false, savedAt: nil, - actions: [], readHref: readHref + actions: [], links: [], readHref: readHref ) } @@ -839,10 +841,9 @@ final class ReadingListViewModelTests: XCTestCase { } let viewModel = makeViewModel(store: TestSupport.loggedInStore()) - let cookie = await viewModel.mintReaderSession() + let cookies = await viewModel.mintReaderSession() - XCTAssertEqual(cookie?.name, "hutch_sid") - XCTAssertEqual(cookie?.value, "sess-xyz") + XCTAssertEqual(cookies?.first?.value, "sess-xyz") XCTAssertNil(viewModel.errorText) } @@ -852,9 +853,9 @@ final class ReadingListViewModelTests: XCTestCase { } let viewModel = makeViewModel(store: TestSupport.loggedInStore()) - let cookie = await viewModel.mintReaderSession() + let cookies = await viewModel.mintReaderSession() - XCTAssertNil(cookie, "a failed bootstrap mints no session, so the sheet shows its unavailable view") + XCTAssertNil(cookies, "a failed bootstrap mints no session, so the sheet shows its unavailable view") XCTAssertNotNil(viewModel.errorText) } } diff --git a/projects/ios-readplace/Tests/ReadplaceAPITests.swift b/projects/ios-readplace/Tests/ReadplaceAPITests.swift index 624de3cef..de7a224a9 100644 --- a/projects/ios-readplace/Tests/ReadplaceAPITests.swift +++ b/projects/ios-readplace/Tests/ReadplaceAPITests.swift @@ -671,10 +671,10 @@ final class ReadplaceAPITests: XCTestCase { ) } - let cookie = try await makeAPI(store: store).bootstrapSession() + let cookies = try await makeAPI(store: store).bootstrapSession() - XCTAssertEqual(cookie.name, "hutch_sid") - XCTAssertEqual(cookie.value, "sess-abc") + XCTAssertEqual(cookies.count, 1) + XCTAssertEqual(cookies.first?.value, "sess-abc") } func testBootstrapSessionRefreshesOnceWhenBearerExpired() async throws { @@ -693,16 +693,16 @@ final class ReadplaceAPITests: XCTestCase { } } - let cookie = try await makeAPI(store: store).bootstrapSession() + let cookies = try await makeAPI(store: store).bootstrapSession() - XCTAssertEqual(cookie.value, "fresh-sess") + XCTAssertEqual(cookies.first?.value, "fresh-sess") XCTAssertEqual(sessionAttempts, 2, "should retry once after refreshing the bearer") XCTAssertEqual(store.tokens?.accessToken, "fresh-access") } func testBootstrapSessionKeepsTheCookieOutOfTheSharedJar() async throws { let host = try XCTUnwrap(URL(string: AppConfig.serverBaseURL)?.host) - for stale in HTTPCookieStorage.shared.cookies?.filter({ $0.name == AppConfig.sessionCookieName }) ?? [] { + for stale in HTTPCookieStorage.shared.cookies?.filter({ $0.name == "hutch_sid" }) ?? [] { HTTPCookieStorage.shared.deleteCookie(stale) } StubURLProtocol.setHandler { _, _ in @@ -712,8 +712,8 @@ final class ReadplaceAPITests: XCTestCase { _ = try await makeAPI(store: TestSupport.loggedInStore()).bootstrapSession() XCTAssertNil( - HTTPCookieStorage.shared.cookies?.first { $0.name == AppConfig.sessionCookieName }, - "the minted hutch_sid cookie must never land in the process-wide shared jar" + HTTPCookieStorage.shared.cookies?.first { $0.name == "hutch_sid" }, + "the minted session cookie must never land in the process-wide shared jar" ) } } diff --git a/projects/ios-readplace/Tests/TestSupport.swift b/projects/ios-readplace/Tests/TestSupport.swift index 340825a27..506e9fc1b 100644 --- a/projects/ios-readplace/Tests/TestSupport.swift +++ b/projects/ios-readplace/Tests/TestSupport.swift @@ -16,11 +16,12 @@ enum TestSupport { return config } - /// A `hutch_sid` session cookie scoped to the server host, for seeding a - /// cookie jar before asserting sign-out clears it. - static func sessionCookie(value: String) -> HTTPCookie { + /// A session cookie scoped to the server host, for seeding a cookie jar before + /// asserting sign-out clears it. The name is arbitrary — the client no longer + /// selects the session cookie by name — so this uses a representative literal. + static func sessionCookie(value: String, name: String = "hutch_sid") -> HTTPCookie { HTTPCookie(properties: [ - .name: AppConfig.sessionCookieName, + .name: name, .value: value, .domain: URL(string: AppConfig.serverBaseURL)!.host!, .path: "/", From e469a72bc3a1e597af63e4de8db2c0f4eee75ad8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:05:10 +0000 Subject: [PATCH 2/4] test(ios-readplace): pin C3 row-control and C1 warning-degradation behaviors Addresses the medium-priority gap from the PR #919 review: the C3 row-control surfacing (isSemanticControlLink / rowControls) and the C1 warning degradation paths shipped without tests, and the iOS suite has no coverage gate to catch a regression. - C3: a row surfaces a non-structural item link (share) as a control, keeps read as the primary tap (readHref) without double-surfacing it, and excludes a structural link (item) and a multi-rel link carrying a structural rel (["alternate","next"]). - C1: a warning missing code still surfaces its message; a malformed warning (missing the required message) degrades to no banner while the rest of the collection still decodes. Co-authored-by: Fayner Brack Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/SirenDecodingTests.swift | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/projects/ios-readplace/Tests/SirenDecodingTests.swift b/projects/ios-readplace/Tests/SirenDecodingTests.swift index 499c1d5a9..5a2dfb0b8 100644 --- a/projects/ios-readplace/Tests/SirenDecodingTests.swift +++ b/projects/ios-readplace/Tests/SirenDecodingTests.swift @@ -73,6 +73,36 @@ final class SirenDecodingTests: XCTestCase { ) } + func testRowControlsSurfaceANonStructuralItemLinkButNeverReadOrStructuralLinks() throws { + // The row surfaces every non-structural, non-`read` entity link as a control, so + // a future item link (e.g. `share`) renders instead of being discarded. `read` + // stays the primary tap target (via `readHref`), never a duplicate control; a + // structural link (`item`) and a multi-rel link carrying a structural rel + // (`["alternate", "next"]`) are plumbing the client follows itself, never surfaced. + let json = """ + { "properties": { "id": "x", "url": "https://example.com/x" }, + "links": [ + { "rel": ["read"], "href": "/queue/x/view" }, + { "rel": ["share"], "href": "/queue/x/share", "title": "Share" }, + { "rel": ["item"], "href": "/queue/x" }, + { "rel": ["alternate", "next"], "href": "/queue?page=2" } + ] } + """ + let article = try XCTUnwrap(Article(entity: try decodeEntity(json))) + XCTAssertEqual( + article.rowControls.compactMap(\.link?.rel.first), ["share"], + "only the non-structural, non-read item link surfaces as a control" + ) + XCTAssertEqual( + article.rowControls.first?.label, "Share", + "the surfaced control uses the server's title verbatim" + ) + XCTAssertEqual( + article.readHref, "/queue/x/view", + "read stays the primary tap target, not a duplicated control" + ) + } + func testAffordanceLabelFallsBackToHumanizedTokenWhenServerSendsNoTitle() throws { let action = SirenAction(name: "update-status", href: "/x", method: "POST", title: nil, type: nil, fields: nil) let affordance = try XCTUnwrap(Affordance(action: action)) @@ -315,6 +345,36 @@ final class SirenDecodingTests: XCTestCase { XCTAssertEqual(page.warning?.message, "Cannot save that link.") } + func testCollectionWarningMissingCodeStillSurfacesItsMessage() throws { + // The client renders only `warning.message`; `code` is a classifier no client + // reads. A warning that omits `code` must still surface its message rather than + // failing the decode and blanking the page. + let json = """ + { "class": ["collection", "articles"], "properties": { "total": 0, "page": 1, "pageSize": 20, "warning": { "message": "Cannot save that link." } }, "links": [], "actions": [] } + """ + let page = QueuePage(collection: try decodeCollection(json)) + XCTAssertNil(page.warning?.code, "a warning without a code decodes with a nil code, not a failed decode") + XCTAssertEqual(page.warning?.message, "Cannot save that link.") + } + + func testMalformedCollectionWarningDegradesToNoBannerButKeepsTheCollection() throws { + // The warning is a non-fatal channel, so a malformed warning (here missing the + // still-required `message`) must degrade to no banner rather than be the one + // thing that blanks the page — the article alongside it still decodes. + let json = """ + { "class": ["collection", "articles"], + "properties": { "total": 1, "page": 1, "pageSize": 20, "warning": { "code": "not-saveable" } }, + "entities": [\(Fixtures.article(id: "good"))], + "links": [], "actions": [] } + """ + let page = QueuePage(collection: try decodeCollection(json)) + XCTAssertNil(page.warning, "a warning missing the required message degrades to no banner") + XCTAssertEqual( + page.articles.map(\.id), ["good"], + "the rest of the collection still decodes despite the malformed warning" + ) + } + func testSirenDateParsesWithAndWithoutFractionalSeconds() { XCTAssertNotNil(SirenDate.parse("2026-05-30T10:00:00.123Z")) XCTAssertNotNil(SirenDate.parse("2026-05-30T10:00:00Z")) From d2a5aed4d09d4d2d172491cce652b171139020d7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:21:08 +0000 Subject: [PATCH 3/4] fix(ios-readplace): scrub sign-out cookies by exact host or subdomain removeReaderWebStoreData matched the reader's WebKit cookies with cookie.domain.contains(host), which also matches any domain that merely contains the host as a substring (e.g. readplace.com.attacker.example). Match the exact host or a dot-prefixed subdomain instead, so the sign-out wipe still covers the host, the leading-dot domain cookie (.readplace.com), and legitimate subdomains (app.readplace.com) without reaching a spoofed foreign origin. On sign-out the old predicate only ever over-deleted, so this narrows the scrub to exactly the app's own origins. Co-authored-by: Fayner Brack Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/ios-readplace/App/AppSession.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/ios-readplace/App/AppSession.swift b/projects/ios-readplace/App/AppSession.swift index 02f76fd2d..5bb7f359f 100644 --- a/projects/ios-readplace/App/AppSession.swift +++ b/projects/ios-readplace/App/AppSession.swift @@ -118,7 +118,8 @@ final class AppSession: ObservableObject { private static func removeReaderWebStoreData() async { let store = WKWebsiteDataStore.default() if let host = URL(string: AppConfig.serverBaseURL)?.host { - for cookie in await store.httpCookieStore.allCookies() where cookie.domain.contains(host) { + for cookie in await store.httpCookieStore.allCookies() + where cookie.domain == host || cookie.domain.hasSuffix("." + host) { await store.httpCookieStore.delete(cookie) } } From f5dfe07a88089f806f3981a92649f6fe4791c9b4 Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sun, 5 Jul 2026 22:56:57 +1000 Subject: [PATCH 4/4] test(ios-readplace): add a per-file code-coverage gate to make test (#920) --- projects/ios-readplace/Makefile | 6 +- projects/ios-readplace/README.md | 8 +- .../ios-readplace/scripts/check-coverage.py | 104 ++++++++++++++++++ .../scripts/coverage-baseline.json | 36 ++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100755 projects/ios-readplace/scripts/check-coverage.py create mode 100644 projects/ios-readplace/scripts/coverage-baseline.json diff --git a/projects/ios-readplace/Makefile b/projects/ios-readplace/Makefile index efdcaed47..c95acb3f3 100644 --- a/projects/ios-readplace/Makefile +++ b/projects/ios-readplace/Makefile @@ -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 diff --git a/projects/ios-readplace/README.md b/projects/ios-readplace/README.md index aeafed724..e5ca66b69 100644 --- a/projects/ios-readplace/README.md +++ b/projects/ios-readplace/README.md @@ -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 diff --git a/projects/ios-readplace/scripts/check-coverage.py b/projects/ios-readplace/scripts/check-coverage.py new file mode 100755 index 000000000..2bfd2f1a4 --- /dev/null +++ b/projects/ios-readplace/scripts/check-coverage.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Enforce per-file line-coverage floors for the iOS app after `make test`. + +The rest of the repo enforces a 100% coverage gate; iOS had none. This reads the +`.xcresult` bundle `xcodebuild test -enableCodeCoverage YES` produced and fails +the build if any measured source file dropped below its recorded floor. + +Config (`coverage-baseline.json`, beside this script): + - `excluded`: files that are pure SwiftUI layout, UIKit shells, or WebKit / + OS-boundary glue — unit-uncoverable without UI tests. This is the Swift + analog of the repo's whole-file OS-boundary coverage exclusion. + - `floors`: the minimum whole-percent line coverage each remaining source file + must keep. A source file with neither an exclusion nor a floor must be 100%, + so a newly added logic file cannot land untested. Floors are a RATCHET: raise + them as coverage improves, never lower them. + +Uses only the system `python3` + `xcrun`, so the CI `ios-tests` job needs no +extra toolchain setup. +""" +import json +import subprocess +import sys +from pathlib import Path + +CONFIG_PATH = Path(__file__).with_name("coverage-baseline.json") + + +def load_report(xcresult: str) -> dict: + out = subprocess.run( + ["xcrun", "xccov", "view", "--report", "--json", xcresult], + capture_output=True, text=True, check=True, + ).stdout + return json.loads(out) + + +def measured_files(report: dict) -> dict[str, float]: + """Best line coverage per source basename across all non-test targets. + + A Shared file compiles into both the app and the extension target, so it + appears twice with identical coverage; keep the higher of the two. + """ + best: dict[str, float] = {} + for target in report.get("targets", []): + if "Tests" in target.get("name", ""): + continue + for f in target.get("files", []): + name = f.get("name", "") + if not name.endswith(".swift"): + continue + cov = float(f.get("lineCoverage", 0.0)) + best[name] = max(best.get(name, 0.0), cov) + return best + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: check-coverage.py ", file=sys.stderr) + return 2 + + config = json.loads(CONFIG_PATH.read_text()) + excluded = config.get("excluded", {}) + floors = config.get("floors", {}) + + report = load_report(sys.argv[1]) + measured = measured_files(report) + + failures: list[str] = [] + slack: list[str] = [] + for name, cov in sorted(measured.items()): + if name in excluded: + continue + percent = round(cov * 100) + floor = floors.get(name, 100) + if percent < floor: + failures.append(f" {name}: {percent}% < floor {floor}%") + elif percent >= floor + 2: + # A 1% headroom is a deliberate buffer against minor coverage drift; + # only nudge a ratchet once a file is comfortably above its floor. + slack.append(f" {name}: {percent}% (floor {floor}%)") + + stale = sorted({n for n in list(excluded) + list(floors) if n not in measured}) + if stale: + print("warning: coverage config references files no longer measured — prune them:") + for n in stale: + print(f" {n}") + print() + + if failures: + print("iOS coverage gate FAILED — files below their floor:") + print("\n".join(failures)) + print("\nRaise the file's coverage, or (only with approval) its floor in " + f"{CONFIG_PATH.name}.") + return 1 + + print(f"iOS coverage gate passed: {len(measured) - len(excluded)} files at " + f"or above floor, {len(excluded)} excluded (view / OS-boundary).") + if slack: + print("Ratchet candidates (coverage now exceeds the floor — raise it):") + print("\n".join(slack)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/projects/ios-readplace/scripts/coverage-baseline.json b/projects/ios-readplace/scripts/coverage-baseline.json new file mode 100644 index 000000000..823acd379 --- /dev/null +++ b/projects/ios-readplace/scripts/coverage-baseline.json @@ -0,0 +1,36 @@ +{ + "_comment": "Per-file line-coverage floors for the iOS app, enforced by check-coverage.py after `make test`. `excluded` lists files that are pure SwiftUI layout, UIKit shells, or WebKit/OS-boundary glue — unit-uncoverable without UI tests (the Swift analog of the repo's whole-file OS-boundary coverage exclusion). Every other source file must stay at or above its floor; a source file with no floor must be 100%, so a new logic file cannot land untested. Floors are a RATCHET: raise them as coverage improves (the E-series PRs walk the logic files toward 100% and move ShareURLExtractor out of `excluded`), never lower them without approval.", + "excluded": { + "WebAuthOpeners.swift": "UIApplication URL-opening glue (OS boundary)", + "WebPageView.swift": "WKWebView SwiftUI wrapper (OS boundary)", + "ReaderWebView.swift": "WKWebView + JS-bridge SwiftUI wrapper (OS boundary); the pure bridge parser is covered by ReaderBridgeTests", + "HTMLCaptor.swift": "WKWebView navigation/capture state machine (OS boundary); the pure navigationResponseDecision is covered by HTMLCaptorTests", + "AddLinkInstructionsView.swift": "pure SwiftUI layout", + "ReaderSheet.swift": "pure SwiftUI layout", + "ArticleRow.swift": "pure SwiftUI layout", + "ReadingListView.swift": "pure SwiftUI layout", + "ShareViewController.swift": "UIKit share-extension shell (OS boundary)", + "ShareURLExtractor.swift": "compiled only into the extension-only target, so no test can reach it yet; PR3 (E2) relocates it to Shared/ and covers it, then it moves to a 100% floor" + }, + "floors": { + "AppConfig.swift": 100, + "BrandColor.swift": 100, + "PendingAuthStore.swift": 100, + "ToolbarRoute.swift": 100, + "ReaderNavigation.swift": 100, + "SirenModels.swift": 99, + "WebAuthFlow.swift": 97, + "PKCE.swift": 95, + "ReadplaceAPI.swift": 94, + "URLDetection.swift": 94, + "ReadingListViewModel.swift": 93, + "SaveSharedPage.swift": 93, + "LoginView.swift": 91, + "AffordancePresentation.swift": 91, + "OAuthService.swift": 89, + "TokenStore.swift": 85, + "AppSession.swift": 79, + "ReadplaceApp.swift": 64, + "KeychainTokenStorage.swift": 57 + } +}