Skip to content
Open
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
10 changes: 10 additions & 0 deletions projects/hutch/src/runtime/web/api/article-siren.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("toArticleSubEntity", () => {
status: "unread",
savedAt: "2026-03-04T10:00:00.000Z",
readAt: null,
isRead: false,
},
links: [
{ rel: ["read"], title: "Read", href: `/queue/${ARTICLE_ID}/view` },
Expand All @@ -74,6 +75,15 @@ describe("toArticleSubEntity", () => {
expect(deleteAction).toBeUndefined();
});

it("emits isRead as an explicit boolean reflecting the read status", () => {
expect(toArticleSubEntity(makeArticle({ status: "unread" })).properties?.isRead).toBe(false);
expect(
toArticleSubEntity(
makeArticle({ status: "read", readAt: new Date("2026-03-04T12:00:00.000Z") }),
).properties?.isRead,
).toBe(true);
});

it("toggles an unread item to a server-driven Mark as read with the target value", () => {
const subEntity = toArticleSubEntity(makeArticle({ status: "unread" }));
const updateStatus = subEntity.actions?.find((a) => a.name === "update-status");
Expand Down
4 changes: 4 additions & 0 deletions projects/hutch/src/runtime/web/api/article-siren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export function toArticleSubEntity(article: SavedArticle): SirenSubEntity {
status: article.status,
savedAt: article.savedAt.toISOString(),
readAt: article.readAt?.toISOString() ?? null,
// An explicit presentational read-state so a client renders the read
// indicator from one server-authored boolean rather than re-deriving it
// from the `status` vocabulary it would otherwise have to hard-code.
isRead,
},
links,
actions: [
Expand Down
16 changes: 16 additions & 0 deletions projects/hutch/src/runtime/web/api/collection-siren.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("toArticleCollectionEntity", () => {
"status",
"savedAt",
"readAt",
"isRead",
]);
});

Expand Down Expand Up @@ -259,6 +260,21 @@ describe("toArticleCollectionEntity", () => {
expect(saveArticlesAction?.fields?.map((f) => f.name)).toEqual(["manifest", "content"]);
});

it("advertises a create-session action so a client discovers the reader session mint", () => {
const result: FindArticlesResult = {
articles: [],
total: 0,
page: 1,
pageSize: 20,
};

const entity = toArticleCollectionEntity(result, {});

const sessionAction = entity.actions?.find((a) => a.name === "create-session");
expect(sessionAction?.href).toBe("/auth/session");
expect(sessionAction?.method).toBe("POST");
});

it("includes search action with filter fields", () => {
const result: FindArticlesResult = {
articles: [],
Expand Down
11 changes: 11 additions & 0 deletions projects/hutch/src/runtime/web/api/collection-siren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ export function toArticleCollectionEntity(
{ name: "url", type: "url" },
],
},
// Mints a browser session cookie from the bearer token so a client's
// in-app reader can load the cookie-authenticated reader page. Advertised
// (rather than the client hard-coding the route + method) so it can move
// without a client release; a client that doesn't open an in-app reader
// simply skips it. No fields: the route reads the bearer from the
// Authorization header.
{
name: "create-session",
href: "/auth/session",
method: "POST",
},
],
};
}
60 changes: 48 additions & 12 deletions projects/hutch/src/runtime/web/pages/queue/queue.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,37 @@ const APP_BACK_LINK = {
label: "← Back to queue",
} as const;

/** Server-authored bridge for the iOS in-app reader: when the reader's mark-read
* htmx request completes, tell the WKWebView so the native app closes the sheet
* and reconciles its list. Guarded on the WKWebView message handler, so it is inert
* in a normal browser that renders this same reader. Keeping the htmx detail here —
* rather than injected by the app — keeps the htmx coupling on the server that owns
* htmx; the app only registers the `readplaceReader` handler and reacts to the
* `markedRead` message, with no knowledge of the front-end's event shape. */
const READER_MARK_READ_BRIDGE_SCRIPT = `<script>
(function () {
var handlers = window.webkit && window.webkit.messageHandlers;
if (!handlers || !handlers.readplaceReader) { return; }
function hasStatusField(params) {
if (!params) { return false; }
if (typeof params.has === "function") { return params.has("status"); }
if (typeof params.get === "function") { return params.get("status") != null; }
return Object.prototype.hasOwnProperty.call(params, "status");
}
function isStatusChange(detail) {
var cfg = (detail && detail.requestConfig) || {};
var verb = (cfg.verb || "").toString().toUpperCase();
var xhr = (detail && detail.xhr) || {};
return verb === "POST" && hasStatusField(cfg.parameters) && xhr.status >= 200 && xhr.status < 400;
}
document.body.addEventListener("htmx:beforeSwap", function (event) {
if (!isStatusChange(event.detail)) { return; }
event.detail.shouldSwap = false;
handlers.readplaceReader.postMessage({ type: "markedRead" });
});
})();
</script>`;

/** True when the client wants the app's chromeless reader rather than the full web
* shell — chosen by an explicit client signal, never a user-agent sniff. The app
* appends `?platform=ios` to the `read` link it loads in its WKWebView; the
Expand Down Expand Up @@ -468,20 +499,25 @@ export function initQueueRoutes(deps: QueueDependencies): Router {
const { article: ownedArticle, state, audioEnabled } = resolved;

if (isIosPlatform(req)) {
const readerBody = ReaderPage({ ...ownedArticle, content: state.content }, {
appOrigin: deps.appOrigin,
summary: state.summary,
summaryPollUrl: state.summaryPollUrl,
crawl: state.crawl,
readerPollUrl: state.readerPollUrl,
progress: state.progress,
audioEnabled,
extensionInstallUrl: undefined,
backLink: APP_BACK_LINK,
renderActions: deps.chromelessReader,
});
assert(readerBody.scripts, "the reader page always sets its scripts");
sendComponent(
req, res,
ChromelessPage(ReaderPage({ ...ownedArticle, content: state.content }, {
appOrigin: deps.appOrigin,
summary: state.summary,
summaryPollUrl: state.summaryPollUrl,
crawl: state.crawl,
readerPollUrl: state.readerPollUrl,
progress: state.progress,
audioEnabled,
extensionInstallUrl: undefined,
backLink: APP_BACK_LINK,
renderActions: deps.chromelessReader,
})),
ChromelessPage({
...readerBody,
scripts: readerBody.scripts + READER_MARK_READ_BRIDGE_SCRIPT,
}),
);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ describe("Queue reader chromeless switch (GET /queue/:id/view?platform=ios)", ()
expect(doc.querySelector(".article-body__actions--bottom")).toBe(null);
});

it("injects the server-owned mark-read bridge for the app, absent from the browser shell", async () => {
const harness = buildHarness();
const agent = await loginAgent(harness.server, harness.auth);
const articleId = await saveAndGetArticleId(agent, "https://example.com/app-bridge");

// The chromeless (app) render carries the bridge: the server owns the htmx
// detail and tells the WKWebView, so the app no longer sniffs htmx itself.
const iosText = (await agent.get(`/queue/${articleId}/view?platform=ios`)).text;
expect(iosText).toContain("window.webkit");
expect(iosText).toContain("readplaceReader");
expect(iosText).toContain("htmx:beforeSwap");

// The full web shell — served to a browser — must not carry the app bridge.
const shellText = (await agent.get(`/queue/${articleId}/view`)).text;
expect(shellText).not.toContain("readplaceReader");
});

it("marks the body chromeless so the reader CSS can pin the top actions", async () => {
const harness = buildHarness();
const agent = await loginAgent(harness.server, harness.auth);
Expand Down
51 changes: 43 additions & 8 deletions projects/ios-readplace/App/AffordancePresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ struct AffordancePresentation {
isDestructive = false
removesItem = false
isToolbarControl = false
case "create-session":
// Not a user control: the client invokes this bespoke to mint the reader
// session cookie (like a capture-only save), so it never renders in the
// toolbar even though it is advertised on the collection.
systemImage = "key"
tint = nil
isDestructive = false
removesItem = false
isToolbarControl = false
case "update-status":
systemImage = "checkmark.circle"
tint = .brandSuccess
Expand Down Expand Up @@ -127,13 +136,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 @@ -154,12 +184,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)
}
}
36 changes: 17 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,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)
Expand Down
30 changes: 30 additions & 0 deletions projects/ios-readplace/App/ItemRoute.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

/// The side effect a tapped per-item control resolves to, decided purely from the
/// affordance's advertised invocation and its presentation. The row's swipe /
/// accessibility twin of `ToolbarRoute`: keeping the decision in a pure value —
/// rather than inline in the view — lets the routing (open a link, confirm a
/// destructive action, or invoke) be unit-tested without a SwiftUI host, so a
/// link-only item control is opened rather than silently dropped and a destructive
/// control never invokes without a confirmation.
enum ItemRoute: Equatable {
/// Follow a navigable link by opening its href in the in-app web view.
case open(SirenLink)
/// A destructive action (no undo) awaits an explicit confirmation before it invokes.
case confirmDestructive(SirenAction)
/// Invoke a non-destructive action immediately through the generic invoker.
case invoke(SirenAction)

/// Routes an item affordance: a navigable link opens; an action whose
/// client-side presentation marks it destructive awaits confirmation; any other
/// action invokes immediately. Whether an action is destructive is the single
/// presentation mapping's call, never a per-name check in the view.
static func route(for affordance: Affordance) -> ItemRoute {
switch affordance.invocation {
case let .link(link):
return .open(link)
case let .action(action):
return affordance.presentation.isDestructive ? .confirmDestructive(action) : .invoke(action)
}
}
}
13 changes: 8 additions & 5 deletions projects/ios-readplace/App/LoginView.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import SwiftUI

struct LoginView: View {
@EnvironmentObject private var session: AppSession
let session: AppSession
/// Owned by `RootView` (which handles the OAuth deep link) so a failed Login or
/// Sign up can surface here while this view is still on screen.
@Binding var authErrorText: String?
/// Injected so the composition point wires the live browser-opening flow and
/// tests capture the started request; there is deliberately no internal default.
let makeFlow: @MainActor (AppSession) -> WebAuthFlow

var body: some View {
NavigationStack {
Expand Down Expand Up @@ -66,17 +69,17 @@ struct LoginView: View {
/// Opens `/oauth/authorize` for login in the external browser (Chrome if
/// installed, to reuse its session); the result returns via the deep link.
@MainActor
private func startLogin() {
func startLogin() {
authErrorText = nil
makeWebAuthFlow(session: session).start(session.makeOAuth().makeNativeLoginAuthorizationRequest())
makeFlow(session).start(session.makeOAuth().makeNativeLoginAuthorizationRequest())
}

/// Opens `/oauth/authorize` for sign up in the external browser. A fresh
/// `start` overwrites any prior pending record so an abandoned attempt can't
/// strand stale secrets.
@MainActor
private func startSignup() {
func startSignup() {
authErrorText = nil
makeWebAuthFlow(session: session).start(session.makeOAuth().makeSignupAuthorizationRequest())
makeFlow(session).start(session.makeOAuth().makeSignupAuthorizationRequest())
}
}
Loading
Loading