feat(mobile): register the session page route (OTA phase C, C7.7) (#21977)

* feat(mobile): switch the session route to the shell, still unregistered (OTA phase C, C7.7)

The review switch's shape, for its reasons. The session screen becomes
`MobileSessionRouteScreen` in `src/session` because `useMobileSessionController` is 32 hooks
deep and opens the terminal, chat and tab subscriptions: at the switch's top level it would
open every one of them behind the page as well as in front of it, since hooks cannot be
conditional. As an element passed for `fallback` it is built and not mounted.

Four query params carried rather than re-derived, each omitted when empty: `name` is a label
the screen otherwise derives from the workspace, `created` is the create flow's one-shot flag,
`warning` is the host's own text, and `paneKey` is a notification tap. `paneKey` is the one the
screen writes back — `use-notification-pane-navigation.ts` rewrites it to empty once it has
switched, through `setParams` on the handoff, which inside the page is the document's own
router — so it has to arrive in the page for that to happen at all.

Inert on its own. A switched route renders the shell only once `MOBILE_WEB_PAGE_ROUTES` lists
it; until then the flag is the only thing that changes and it is off.

Three censuses red without their rows, measured on this tree:
- `shell-screen-route-census.test.ts` `walks the route tree and finds them` named
  `session/[worktreeId].tsx` as a ninth switch the list did not have.
- `mobile-web-shell-flag-census.test.ts` `reaches the switched routes through that hook and no
  others` reds without `SESSION_ROUTE` in `SWITCHED_ROUTES`.
- `mobile-web-app-web-overrides.test.mjs` `lists exactly the .web.* files on disk` named the
  new sibling.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): root the session parity family at the screen the route mounts (OTA phase C, C7.7)

The extraction parity pin walks from a root function in `app/h/[hostId]/session/[worktreeId].tsx`,
which is now the flag switch: the walk found no `SessionScreen`, and the runtime-string count went
534 -> 542 on the switch's own param names and path literals.

Rooted at `MobileSessionRouteScreen` instead, which is the function that calls the controller. The
switch's business is which of the two screens renders, not what the session screen does, and its
literals have no place in a hash about the extraction.

Every pinned hash is unchanged, which is what says the body moved and nothing else did: 275 hooks,
77 callbacks, 24 effects, 534 runtime strings, 124 host and 61 leaf JSX facts, 172 style
references, all at the same SHA-256 they had before the move.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): give the page the session screen's stored preferences (OTA phase C, C7.7)

Ruling 7: nothing silently no-ops. The allowlist was one exact key and one prefix, so every
preference the session screen reads inside the page fell back to its default and kept working
outside it — a state the user cannot tell from a preference that does not exist.

The keys are derived from the route's own closure, not copied from design §6. Nine join the list:
`orca:terminal-accessory-layout`, `orca:custom-accessory-keys`, `orca:defaultSessionView`,
`orca:mobileStructuredSendOperations:v1`, the three terminal preferences ruling 7 names
(`orca:terminalTextScale`, `orca:terminalAutocompleteEnabled`, `orca:terminalLinkOpenMode`), and
two the design did not: `orca:hostDockWidth`, which `use-mobile-dock-resize.ts` drags on this
screen, and `orca:hostSidebarWidth`, which `app/h/_layout.tsx` reads above every page route and
which the manifest already names as the reason agent-history declares `storage` at all.

Two are per workspace, not per host. Design §6 has `orca:nativeChatTabs:<worktreeId>`; the module
builds `<prefix><enc(hostId)>:<enc(worktreeId)>`, and `orca:terminalLiveInputDisabled:` has the
same shape. So the narrowing goes one level in from C2.9's: `pageStorageKeysForRoute` and
`isPageStorageKeyForRoute` replace the host-scoped pair, and a session page opened on one workspace
can no more rewrite the tabs of the one beside it than it can another host's pins. Both sides read
the workspace off the route pathname, which is the one fact the shell and the page are each handed.

Every new key's writer notes the mirror before it persists, as `savePinnedIds` does: `init` is
built synchronously, so a write that only reached the store would be one `init` behind.

A refusal is a rejection, not a dropped write. The real AsyncStorage rejects when its store
refuses, and the caller that matters already catches: the durable send journal answers
"Message not sent" rather than putting a mutation on the wire with an operation id no store holds,
which after a crash would send the message twice. `PageStorageRefusedError` names the key and which
of the three refusals it was.

Measured on this tree, which is why the journal needed more than an allowlist entry: one journal
entry with no attachment serializes to 342 characters and 48 unsettled sends put the value past
`PAGE_STORAGE_MAX_VALUE_CHARS` (47 is under it), against a schema that admits 4,096. `init`'s own
`BridgeInitStorageSchema` refines on that bound, so handing the journal over whole refuses the
*frame* and the session screen never opens at all. `pageStorageEntriesForInit` drops such a value
and names it; the page reads a default, which is a degradation rather than a page that does not
start.

Red first, measured here:
- 21 cases across three files on the host-scoped helpers being gone.
- `leaves out a value the page would refuse the whole frame over` reds with the filter bypassed.
- The journal case reds without the rejection, with the operation claimed against a store that
  never took it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): register the session page route (OTA phase C, C7.7)

One entry in `MOBILE_WEB_PAGE_ROUTES` with ten grants, every one read off a call site in this
route's own closure rather than carried from design §1. Measured here:

  navigate                 7 handoff sites
  externalLink             6 openers
  haptics                 24 trigger sites
  native.clipboard.write   6 sites
  native.clipboard.read    3 sites
  native.media.*           2 sites, one seam (`useMediaPicker`)
  screencastBinary         1 site (`MobileBrowserPane.tsx`)
  storage                 10 exact keys and 2 workspace-scoped, the previous commit's

`pageRouteGrants` is derived from this list, so the row is a consequence of the entry and there is
no second table to edit. The design's list was exactly right; the counts are what say so.

The hop census goes 16 -> 23, measured. All seven new rows are `X -> /h/[hostId]/session/
[worktreeId]`, one from each other page route, and none goes the other way: the session's ten
grants are a strict superset of every other route's, so every hop into it is handed to the shell
and every one of its own targets stays in the document. That second half is asserted as grant
coverage rather than as the absence of seven rows — absent is also what an unregistered route
looks like, which is the shape C4 already had to correct once.

Two censuses gained the route and one is new:
- The haptics seam census, whose route-module map moves to
  `mobile-web-app-page-route-modules.mjs` so the new census below shares it rather than keeping a
  second copy that stops growing when the first one does.
- `page-served-back-control-a11y.test.ts`, which named two controls with no `accessibilityRole`:
  `MobileSessionHeader.tsx:64 role=none label=Back to worktrees` and
  `QuickCommandsSheet.tsx:160 role=none label=Back`. Both get the role. Inside the shell there is
  no native chrome behind them, so a bare Pressable is absent from the accessibility tree.
- `mobile-web-app-screencast-lane-grant.test.mjs` derives `screencastBinary` from the closures the
  way the haptics census derives its token. C6 could not write it: the pane is mounted by a route
  rather than registered as one, so there was no route to pin the grant against (C6 ruling 3).

The derivation census gains C6's half measured against this route rather than against a module
closure read on its own, which is the other half of C6 ruling 3. The composed row for the session
route's own families waits on C7.8's table, and on C4.5's split before it.

Numbers, both ends measured on this tree, never summed:
- Session route closure 4,328 -> 4,329 modules, 978 -> 979 local. The +1 is
  `MobileSessionRouteScreen.tsx`; the route file is one input either way, now the `.web.tsx`.
- Chunk count 65 before and 65 after, against the 72 the fence allows at 14 route keys. The fence
  is untouched: a `.web.tsx` sibling is not a new route key, and this route shared its split.
- Bundle 8,020,519 -> 8,022,202 bytes, 108 assets either side.

`mobile-web-app-route-chunk-closure.mjs` looked the route module up by its exact path, and
`resolveExtensions` puts `.web.tsx` first: the first route with a sibling to be asked for reached
"no output". It tries the sibling first now, which is what the build actually chunked.

Without the manifest entry these red on this tree: `pins every hop the handoff must take away from
the page`, `keeps every hop out of the session local`, `declares only routes the bundle has a
module for`, `reaches the built manifest`, `covers every page route and finds a control in each`,
both haptics-seam cases, and `declares the screencast lane on exactly the routes whose closure
asks for it`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): render-check the session route, and quiet the two things it found (OTA phase C, C7.7)

The render check mounts the registered route in a real browser on the built bundle, under the
header the shells send. It asserts the session screen paints rather than the Unmatched route, that
the Back control reaches the accessibility tree as a real `<button>` with its name, that the
route's own chunk arrives on a client-side navigation, that nothing it paints leaves the origin or
logs a policy violation, and that the three reads the screen makes carry the workspace the route
named — the precondition the rest needs, since a screen that mounted and asked for nothing would
paint the same chrome.

It also asserts, strictly, that the page and console errors are `[]`, which is what found both
fixes here. Measured on this tree before them: two console lines and one uncaught rejection on
every mount of the route, none of them visible natively.

- `use-mobile-session-markdown-actions.ts` registered `BackHandler.addEventListener` with no
  platform guard, and the effect re-registers whenever the dirty-draft list changes. React Native
  Web answers "BackHandler is not supported on web and should not be used." and hands back an inert
  subscription, so the guard was never armed on the page anyway. Gated on `Platform.OS`, as the
  right drawer, the bottom drawer and the file preview already are. There is no hardware back in a
  WebView; the shell owns the phone's, and the page's Back control is where the prompt lives.
- `use-mobile-session-diff-comments.ts` ran `void loadDiffComments()` in an effect with no catch.
  The loader returns on a *refused* `worktree.show` and nothing caught a *rejected* one, so a host
  that will not answer produced `Uncaught (in promise)` on every session mount. Caught at the
  effect rather than inside the loader, whose promise the golden recorder awaits; notes that did
  not arrive leave the ones on screen as they were, which is the module's own policy for a refusal.

**The terminal is not painted here and the file says so at both ends.** A terminal on screen needs
the host protocol handshake, a tab snapshot, a terminal inventory and a `terminal.subscribe`
stream — five hand-written fixtures against five Zod schemas inside a transport double, which is
what the harness's docstring refuses to become. Scripting `status.get` alone was measured here:
the protocol gate reads it and the page paints "Update Orca on your computer" instead of the
screen. What the terminal does under the shipped header is
`mobile-web-app-terminal-render.test.mjs`, on the same component and the same build options.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refresh the session parity pins for the two seam edits (OTA phase C, C7.7)

The previous commit's two fixes are inside the parity family, so three pins moved. Both edits are
one token each and neither changes what a phone renders:

- `'web'`, the `Platform.OS` guard the Markdown actions' `BackHandler` registration gained.
- `"button"`, the accessibility role the session header's Back control gained.

Runtime strings 534 -> 536, with the effect hash and the host-JSX hash moving for the same two.
Everything else is unchanged: 275 hooks, 77 callbacks, 24 effects, 61 leaf JSX facts, 172 style
references, all at the SHA-256 they had before. A separate commit because a reported head does not
move by amend, and because the moved hashes are worth reading on their own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report the diff-notes rejection instead of catching it (OTA phase C, C7.7)

The `.catch` the previous commit added to `loadDiffComments` moved a golden, which is a finding
rather than something to record over: `matrix-session.diff-notes-worktree.show-1` certifies the
unhandled rejection as an effect of its loaded checkpoint, so the corpus says the app raises it
today and a fix is a re-record and a review event.

Reverted to `void loadDiffComments()`, with the defect written where a reader of that effect will
find it. `family-recordings.test.ts > session.diff-notes: reply partitions at worktree.show#1` is
green again; it was the one failure in an otherwise clean 8,699-test run.

The render check keeps the observation rather than losing it. Its error assertion is now the exact
list `['RenderCheckShellDouble: the render check answers no RPC']` instead of `[]`, so a second
error reds it and so does this one going away — which makes the file the place the fix is noticed
when someone lands it with the re-record.

The defect, for that PR: the loader returns on a *refused* `worktree.show` and nothing catches a
*rejected* one, so a host that will not answer raises an unhandled rejection on every session
mount. It is not a page fault — the shell's `fault` notify comes from the React boundary and
nothing reaches it — so the generation is not dropped and the screen works; the cost is a
document-level error on every mount.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): repin the session effect hash after the diff-notes revert (OTA phase C, C7.7)

The effect pin was refreshed while `loadDiffComments` carried a `.catch`; reverting that (the fix
moves a golden, so it is a finding rather than a line) moves the same hash back off it. Repinned on
the uncaught `void` call, which is what the tree holds and what the corpus certifies.

Count unchanged at 24 effects; nothing else in the family moved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): reject a page storage write for size only, and log the rest (OTA phase C, C7.7 round 1)

Ruling 33.4. `PageStorageRefusedError` was raised for all three refusals, and two of them have no
catcher: a page-closure writer of an unlisted key awaits `setItem` with nothing around it —
`notification-delivery-preferences.ts:39` plainly, `preferences.ts` in several places — so a key
the page was never allowed to keep became an unhandled rejection in the document. That is a worse
failure than the silent drop it replaced, and it is the one the page can least afford, because an
uncaught rejection there is a document-level error on a screen that is otherwise working.

Scope is now one refusal. `too-large` rejects, because the caller that needs it is written for it:
the durable send journal's composer catches it and answers "Message not sent" rather than sending a
mutation whose operation id was never written down (ruling 7). `not-allowed` and `not-delivered`
resolve and are logged as `[page-bridge] storage-write-dropped`, which is the old behaviour plus
the line a device log needs — a preference that did not stick looks identical to one nobody set.

A batch applies every pair it can, logs every drop, and rejects only if one of them was oversize.

Red first, measured here: seven cases in `page-async-storage.test.ts` red on the rejection, among
them a `notificationDeliveryPreferences` write resolving, another host's pins, another workspace's
chat tabs, and a write the shell would not take. The oversize case is unchanged and still asserts
`PageStorageRefusedError` with the key and the character bound in its message, so the narrowing is
visible as the difference between the two.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): make every count in the session route say the same number (OTA phase C, C7.7 round 1)

Ruling 33.5. Three numbers were stated more than once and two of them had drifted when the merge
took the grant list from ten to fourteen.

- Grants. `mobile-web-page-routes.mjs:100` and `mobile-web-page-route-hop-coverage.test.mjs:57`
  both still said ten. Fourteen in both, and the manifest comment now names the audio verbs beside
  the media ones as things only this route asks for.
- Keys. The manifest said `storage` covers "the ten exact keys and two workspace-scoped ones",
  which counts `orca:last-visited-worktree` — a key this route did not add. Nine exact plus the
  two workspace-scoped, which is what C7.7 put in `page-storage-keys.ts`.
- The journal entry. 342 and 343 are both real and answer different questions, which is exactly
  why one number had to win: an entry serializes to 342 characters on its own and costs 343 in the
  array, the difference being the comma that joins it. 343 is the one that drives the threshold,
  so it is the one stated, with the 342 kept beside it as its derivation. Re-measured here rather
  than carried: 47 entries are 16,140 characters and 48 are 16,483, against the 16,384 cap.

Comments only; no behaviour and no assertion moved. The threshold case in
`mobile-structured-send-page-storage-refusal.test.ts` already asserted the boundary both ways and
still passes unchanged, which is what says the arithmetic above is the code's and not the prose's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): red-first for a pane request over a re-sent init (OTA phase C, C7.7 round 1)

Ruling 33.1's four cases plus the compatibility one, all red: `publishRoute`
is not a member of the host, `onRouteUpdate` is not a member of the page's
client, and `ready` carries no `accepts`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): deliver a pane request to the mounted page over a re-sent init (OTA phase C, C7.7 round 1)

Ruling 33.1. The session switch keyed on the whole route, so a notification
tap for another pane of the session on screen either remounted the shell (a
bridge teardown and a page reload for a tab switch) or, for the pane already
showing, moved nothing at all: the page cleared `paneKey` on its own router
and the native param kept it, so `SET_PARAMS` wrote the value already there.

`paneKey` leaves the key and travels as a route update. The page declares
`accepts: ['route-update']` on `ready`; the shell re-sends `init` for a
same-path param change only to a page that declared it, and treats a second
`init` for the session the page already holds as a route update rather than a
replacement -- in-flight requests, subscriptions, the storage snapshot (the
same object, asserted) and the generation all stay. The screen reports
delivery and the switch clears the native param, so no later `init` replays a
spent tap. `use-notification-pane-navigation.web.ts` reads the request off a
standing listener; the native file is unchanged.

Wire-compatible both ways without a version bump: `accepts` is optional, an
older page is never sent a second `init`, and an older shell never sends one.
Both degrade to today's lost repeat tap. `BRIDGE_PROTOCOL_VERSION` and every
released native RPC are untouched.

Two files were at their line cap, so two modules came out at their own
boundaries rather than a cap bump: `bridge-init-route.ts` (the route half of
`init`, wanted by the switches, the host and the page) and `bridge-host-route.ts`
(one host's held route and what it may publish).

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the session switch and its hardware-back gate get their own tests (OTA phase C, C7.7 round 1)

Ruling 33.2. `mobile-web-shell-session-route.test.tsx` mirrors the eight cases
the files switch has -- route built, native fallback while the flag settles,
repeated params, dot-segment refusal, segment encoding, flag off, remount on a
route change, remount on a param change -- plus the two pane cases: a repeat
tap for the same pane reaches the mounted page twice and a different pane
reaches it once, both with one mount in the lifecycle.

The `BackHandler` gate gets a unit test in the shape of its three siblings.
Reaching it meant the hook declaring the fourteen fields it reads instead of
taking all 268 of the session model, so a probe can render it without building
a session; `MobileSessionDiffCommentsModel` satisfies that by construction and
the one caller is unchanged. No pin in the session parity census moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): a call-site census for the six grants that had none (OTA phase C, C7.7 round 1)

Ruling 33.3. `navigate`, `storage`, `externalLink`, the two clipboard verbs
and the media three were pinned only by the list they were copied from, so
striking any of them out of a manifest entry reddened nothing. Each is now
derived from the route's own closure by parsing the call sites -- a call, not
a mention in a comment or a string, and not an import the module never calls
-- and each row has a named control case driven over the session entry with
that row's grants struck out.

It found one thing. `app/h/_layout.tsx` wraps every `/h` route in
`HostProtocolGate`, whose wall offers an Update Orca link through
`openExternalLink`, and two routes reach that without declaring
`externalLink`: on them the link posts a notify the shell refuses. Recorded
exactly as `KNOWN_UNDECLARED` rather than exempted, because widening two other
routes' grants is a capability decision and this is pre-existing on main.

`notificationPaneTab` moves to its own module. A `.web.ts` sibling cannot
import its native neighbour by the plain path: the bundler's
`resolveExtensions` answers with the `.web.ts` file, so that import was the
file itself and esbuild refused the page bundle with a cycle. The mobile suite
does not bundle, so only the closure walk saw it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): make a struck-out grant red a case named after it (OTA phase C, C7.7 round 1)

The first shape checked the whole manifest at once, so removing any one of
the eight reddened all seven cases and named none of them: the per-row control
read `session.grants` off the manifest the removal had just changed. Each row
now has its own manifest case, and each control is built from what the
session route's closure reaches rather than from what its entry declares, so
it stays green whatever the manifest says. Closures are memoised, which is
what pays for walking all eight once per row.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the pane request in a ref, not in state (OTA phase C, C7.7 round 1)

The changed-code quality gate's React Doctor found it:
`no-adjust-state-on-prop-change`. A tap can arrive before the terminals have
loaded, so the request has to wait; holding it in state meant the effect that
consumed it set state on a prop change, and the stale selection renders first.
The request waits in a ref now and a counter wakes the effect, so the effect
reads and clears rather than adjusting anything.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(config): re-measure the session closure and list the new web sibling (OTA phase C, C7.7 round 1)

The full `config/scripts` suite found both. The closure reads 4,326 modules
and 984 local, two more than the merge, and the two are named rather than
counted: `notification-pane-tab.ts` and `bridge-init-route.ts`. The pane
hook's web sibling replaces the native file rather than joining it, so it
costs nothing -- but it is a `.web.ts`, so it needs its row in
`web-overrides.json` saying why the native one cannot run on the page.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the page off a journal init could not carry (OTA phase C, C7.7 round 1)

Ruling 33.6, from pullfrog on `beda1cc384`. Dropping an over-cap value from
`init` did not revoke the page's write access to it: the key stays in
`pageStorageKeysForRoute`, so the page read no journal, `parseJournal(null)`
gave it an empty one, and its first send wrote a one-entry value over the
device's -- every native entry lost and a fresh `operationId` for an operation
the native journal already held, which is the duplicate send ruling 7 exists
to prevent.

`pageStorageEntriesForInit` now reports `oversize` beside `dropped`: only the
value-cap drops, because an entry-cap drop is a key that fits and the page's
own write of it is the size the shell would have carried anyway. The shell
sends those names as `init.storageOversize`, and a page write to one of them
rejects with `PageStorageRefusedError` under the size contract of 33.4, which
the composer already shows as "Message not sent". The native journal is
untouched until the user is back on native or it drains.

`storageOversize` is optional in both directions: an older shell sends none
and an older page ignores it, which is exactly today's behaviour. No version
bump; omitted rather than sent empty, so no golden moves.

Red first, with the two states replaced by ones the shell produces. The 47/48
case drives `pageStorageEntriesForInit` rather than publishing a journal value
the shell strips before `publishPageStorage` ever sees it, and the case that
used to assert a successful write now asserts the native entries survive: it
was the clobber, recorded as success.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): deliver a route update only when a param moved, and only after init went out (OTA phase C, C7.7 round 2)

Round 2, findings 1 and 2, both red first.

`onRouteUpdate` fired on every re-sent `init` for the session the page holds,
not only on one whose route moved. The shell answers every `ready` with the
route it holds and the page re-asks on its own backoff and again after a
refused `state` frame, so one tap reached the pane hook as `['', 'pane-1']`.
Both ends now read one definition of moved, `bridgeRouteMoved`, which is the
page's own `shellScreenRouteKey`: the host will not send an `init` for a route
that did not move and the page will not publish one it was sent anyway. The
`.web.ts` hook keeps its empty-pane guard and its comment now says why it is
load-bearing rather than defensive -- the shell's own clear arrives as a move.

`onRouteDelivered` ran on the `ready` path without checking that an `init` had
gone out. A refused route answers the ask with nothing, so the caller would
clear a one-shot param the page never received. `sendInit` reports whether a
frame left and `onPageReady` carries it. Unreachable from the session switch,
which parses the route before it mounts the shell; the prop's contract says it
anyway, and the publish path already honoured it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): attach the init-storage doc to its type, keep the overrides escape (OTA phase C, C7.7 round 2)

Round 2, findings 4 and 5, neither a behaviour change.

The block describing `pageStorageEntriesForInit` had `PageStorageForInit` and
its own one-line doc between it and the function, so it documented neither.
The type moves above it and the block sits on the function it describes.

`web-overrides.json` had an escaped em dash re-encoded as a literal one when
this branch added its rows through a JSON round trip, on a line about the
keyboard stub that has nothing to do with C7.7. Main's `—` is restored;
`oxfmt --check` accepts the file either way, so this is main's spelling kept
rather than a formatter's demand.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): catch the custom-key save the page store refuses (OTA phase C, C7.7 round 2)

Round 2 addendum. `addKey` awaited `saveCustomKeys` with no catch and both of
its callers are `void addKey(...)`, so the rejection had nowhere to go.
`orca:custom-accessory-keys` is in the session route's page allowlist and a
page write over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than drops (the
size contract of 33.4, extended by 33.6 to a key `init` could not carry), so
past ~16 KB of accessory keys this surfaced as an unhandled rejection in the
page -- which the fault boundary reports and which drops the generation.
Every other allowlisted writer in this closure already catches: the two write
chains in `TerminalShortcutSettings`, the live-input save and the session-view
preference.

Caught at the boundary and logged, and the drawer neither announces the key
nor closes: a row on the accessory bar that no store holds, gone at the next
load, is the failure the allowlist exists to avoid. Red first -- the case saw
the refusal escape with the page's own message -- and the control reds again
when the catch rethrows.

Belongs in `8b4c559e90` by the brief; it is its own commit because that one
was already made and amending is forbidden.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): reject a batch of oversize writes once, not once per pair (OTA phase C, C7.7 round 2)

CodeRabbit and pullfrog, same site. `settleBatch` called `settle` per refusal
and kept the first rejected promise, so a `multiSet` or `multiRemove` with two
over-cap pairs built a second rejected promise nobody held -- an unhandled
rejection in the page, the outcome ruling 33.4's rejection scope exists to
avoid. Two oversize keys is all it takes, and `storageOversize` made a second
way to reach it.

A refusal is now an error or nothing, and only the caller's one rejection ever
becomes a promise. Red first under an `unhandledRejection` listener with two
over-cap pairs: one orphan before, none after, and the caller still hears
about the first key.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): record a route key only once a frame carried it (OTA phase C, C7.7 round 2)

CodeRabbit on `MobileWebShellScreen.tsx:271`. The effect recorded the route's
key and then published, so a publish the hook refused for having no host was
remembered as though it had gone out. `publishRoute` is now keyed on
everything the host is built from rather than on the session alone, so the
render that brings the host re-runs the effect, and the key is written only
after a frame has left.

Reported honestly: this does not repair a lost tap, and the case beside it
says so. The host is built from the route the render holds, so a route that
moved before it existed rides the first `init` either way and `publishRoute`
then answers "did not move". What the change removes is a key recorded for a
frame nobody sent -- the same contract finding 2 fixed on the `ready` path.
The case pins the delivery count across the gap: nothing reported while there
is no host, nothing reported once there is one and it has sent nothing, and
exactly one report when the `init` answering the page's ask carries the route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refuse an oversize-dropped key at the shell, not only at the page (OTA phase C, C7.7 round 2)

pullfrog's rollout gap on ruling 33.6. `storageOversize` is honoured by a page
built with it, and the page is served from the desktop: a document from an
older bundle ignores the field and writes the key whole, which for the send
journal replaces every entry the device holds. The shell is the half that
updates with the app, so the shell is where the refusal has to live.

The host now refuses a `storage` notify for a key it could not hand the page,
answering it as the drop it already answers an unlisted key with. The page's
own rejection stays as the fast path -- it reaches the composer as
"Message not sent" with no round trip -- and the schema comment says the field
is advisory and the shell enforces it.

Red first: a host holding the journal as oversize received a page write for it
and posted it to native storage; now it posts nothing and the entries survive,
while a key it did hand over is still writable.

The three refusals became one predicate in `page-storage-keys.ts`, where the
keys are, because inlining the third put `bridge-host.ts` over its line cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): the page's pane hook gets its own test (OTA phase C, C7.7 round 2)

pullfrog: `use-notification-pane-navigation.web.ts` had no cover. The native
file's test mounts the native file, and `bridge-route-update.test.ts` stops at
the client, so the half that turns a route update into a tab switch was
untested.

Seven cases: the seed from the route the page was opened on, a request held
until the terminals load, a repeat tap on the pane already showing, a
different pane, the clear the shell posts after each delivery, a pane that has
since closed, and a page opened on no pane at all.

Two controls, so the cases are not all satisfied by one behaviour. Dropping
the seed reds the two that read the first `init`. Deduplicating by value
instead of counting deliveries reds the repeat tap, which is the case the
counter exists for.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): roll the custom-keys mirror back when the store refuses (OTA phase C, C7.7 round 2)

CodeRabbit. `saveCustomKeys` notes the write in the mirror before it persists,
because a reader is answered from the map rather than from the store and the
shell builds `init` synchronously from that map. On a refused write the note
stood: the page's next `init` carried the value native had rejected, and every
native reader of the key saw it too.

The previous mirrored value is captured and put back on the failure path, and
the error still goes to the caller so `addKey` keeps withholding the key.

Red first: with the store refusing, the mirror held the rejected value where
the pre-save value belonged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report a route delivered only after its frame was posted (OTA phase C, C7.7 round 2)

CodeRabbit on the send path. `sendInit` answered "sent" the moment it handed
the JSON to `post`, and a rejected post was reported a turn later as a
diagnostic -- so the screen spent the one-shot `paneKey` on a frame the page
never received, cleared the native param, and the tap was gone. `publishRoute`
was fire-and-forget the same way.

Delivery is a promise now, settled after `options.post` resolves and false on
either throw or reject. Readiness stays separate: `onPageReady` fires on the
ask, as the shell's wait needs, and carries the delivery promise beside it.
The screen records the route key and calls `onRouteDelivered` only when that
promise answers true, and a refusal leaves nothing recorded so the next render
that can carry the route tries again.

Red first: with the view refusing what it was handed, the frame was built and
posted and the screen reported delivery anyway. Now it reports none while the
page's ask is still reported, and a host-level case pins the same split.

`bridge-host.ts` was at 299 of 300 lines, so the send half came out as
`bridge-host-frames.ts` rather than growing it; the file now measures 280.
The screen's own test harness never attached the view handle, so every post in
it rejected unobserved -- it attaches one now, which is what let the case see
the frame at all.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): fold two doc blocks back onto what they describe (OTA phase C, C7.7 round 2)

pullfrog's two nits, no behaviour change. `page-async-storage.ts` kept the old
`settle` block above `refusalError` when the function it described moved down
with a one-liner of its own; the orphan goes. `bridge-host.ts` had two stacked
blocks on `sendInit` after it grew a return value; they are one, and it now
says the frame is still built synchronously and only the post is awaited --
which is the property the golden recorder depends on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the host own a pending route until its frame lands

A route the page has not received is now the host's, not the screen's. `publish` keeps it
pending until a post resolves true, marks it delivered only then, and reports that through a
callback registered once per host. Movement is measured against what a frame actually reached
the page with rather than against what the host holds, so a refused frame leaves the route owed
instead of reading as one that did not move.

Three things the old shape lost, each a case here: a frame the view refused was never retried,
because only another render could try and a mounted page has none coming; a render while a post
was in flight cancelled the report the switch spends to clear the param; and a repeat tap for
the same pane was held, because the host had already moved its held route on the attempt that
failed. The retries are the moments delivery becomes possible again — the next `ready`, and a
view handle the host regains — and one frame goes out at a time.

Also folds round 4's doc nits: the stale delivery comment the screen no longer has a ref for,
and a leftover `an` in the `ready` branch.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): roll the journal mirror back when persistence fails

`writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in
the same turn current — but it kept the note when the store refused. The page then received a
journal the device never wrote and resumed operations nothing was holding.

Restored on the error path, the same shape as the custom-keys save, and on both halves: the
removal that empties the journal had the same gap as the write that fills it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep publishing until the held route is the delivered one

Two halves of the same gap, both found by the bots on `fba8cb3f3d`.

A route that moved while a frame was out was held for the turn and then had nothing to wake it:
the post settling only cleared the in-flight flag, and on a mounted page no `ready`, handle or
tap need ever come along. A landing is now itself a moment to publish again, while what the host
holds is not what the page has. Only on a landing — a refused post that re-attempted itself
would spin, and that one still waits for whatever makes delivery possible again.

And the report carries the route a frame reached the page with, which the session switch was
ignoring: the older pane landing wiped the `paneKey` naming the newer one, so the page stayed
where it was and the second tap was gone. The switch now spends the param only for the pane that
was delivered.

The delivery cases render through one helper rather than six copies of the same setup, which is
what keeps the file under its cap.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): drop the second block describing a parameter onPageReady no longer takes

The field is documented by the block above it; this one still described the `delivered` promise
the handler was handed before the host took ownership of the pending route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): let the page erase the route param it was handed

Ruling 34, step one: one page-to-shell frame that asks the shell to clear a one-shot route param,
naming the value the page applied.

Closed at both ends. The param is an enum of what the shell hands over, so a page cannot edit a
route it was never given; the notify name is a member of the closed union, so it gets a row in
the grant table by compilation rather than by memory, and rides no grant because it can only
spend something this shell put there. The shell declares it in `init`, the mirror of
`ready.accepts`: no shipped shell serves a page, so nothing needs negotiating today and the
page's check exists from the first version that can post one.

The comparison belongs to whoever holds the param, which is the session switch: a tap that moved
on while the page was applying the one before it leaves a newer key, and a clear naming the older
one is not for it.

`bridge-envelope.ts` went over its cap, so the page-to-shell union moved to
`bridge-notify-envelope.ts` and the fields both halves spell to `bridge-frame-fields.ts`, which
the envelope re-exports. No cap was raised.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): re-send init on a route change and track nothing else

Ruling 34, step two: the tracked handoff is gone. Deleted, not patched — the pending route, the
delivered route, the in-flight flag, the landing callback, `retryPendingRoute`, the delivery
promise `onPageReady` used to carry, and the `onRouteDelivered` that ran from the host through
the hook and the screen to the switch.

What is left is the rule in one line: `publish` sends one `init` when the route moved and the
page said it takes one, and every `ready` is answered with the route the shell holds then. A
frame the view refused is repaired by the next ask, not by a retry; the request it carried is
spent by the page.

The cases that tested the deleted mechanism go with it. The outcomes they protected are pinned
where they now live: one frame per move and none for a render that moved nothing, a lost frame
repaired by the next ask, no second `init` to a page that never said it takes one, and the
repeat tap measured through the page's erase rather than through a delivery report.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the page applies a pane and erases the request that carried it

Ruling 34, step three. The page hook applies the pane an `init` names and asks the shell to erase
the param it came on, naming what it applied.

Two rules, and both are the page's because the shell has none. The erase is asked for on every
`init` that carries a pane rather than only on the one that changed something: a clear that never
reached the shell leaves the param in place, and the next frame carrying it is the repair. The
switch happens once per value: a re-asked `ready` is answered with the route the shell still
holds, and applying that again would drag the page off a tab the user has since moved to.

A repeat tap for the same pane still arrives as a request, because the erase went through in
between and the tap wrote the param back. The client refuses to post the frame to a shell that
did not declare it takes one, which is every shell older than the field.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the page bundle's readers pointed at the module that declares each name

The envelope split left three source-text readers and one import pointed at a file that now
re-exports what they read.

`shell-screen-route.ts` read the route schema back through the envelope, which reaches that file
again through the page-to-shell union: a cycle esbuild resolves to `undefined`, so every page
route mounted onto a schema that was not there yet and the browser render suite failed on twelve
files with a TypeError rather than on a build error. It reads the declaring module now.

The render harness read `BRIDGE_PROTOCOL_VERSION` and `BRIDGE_FAULT_GRANT` out of the envelope by
regex; both moved, and a regex over a re-export answers for whichever file the last split left
them in. Both point at `bridge-frame-fields.ts`, and the throw names it.

The session route's page closure is re-measured on this tree at 4,330 / 988 and the four new
modules are named, not inferred: the two halves of the split envelope, and the route-update
module and route-key reader the page-to-shell union now reaches through it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count posted inits with the page's own reader, not a cast

The changed-code quality gate refuses a type assertion, and it is right to here: a frame the
page's reader would refuse is not an `init` the page ever saw, so a case counting them must not
count one either.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): put every mirrored write on one path that notes what the store took

Ruling 35. Fourteen call sites in six files noted the shell's mirror before persisting, and on
the page a persist can be refused: twelve left the map holding a value no store had taken, and
the next `init` handed the page exactly that. Two undid it by hand.

`persistMirrored` is the one path now, and it seats the map from what the store holds after the
write rather than from what it was handed. That is what makes the note follow acceptance without
a second opinion about it: the page's adapter resolves a `not-allowed` write and logs it, so a
rejection is not the only refusal there is, and reading back is the only answer that covers both.
The cost is one store read per mirrored write on the device, where the store refuses nothing.

`writeMirroredStorage` keeps its note-then-persist order and loses every caller but one: the
shell taking a value the page has already applied, into the device store, which has no allowlist
and no frame cap to refuse against. It builds the next `init` synchronously in the same turn, so
noting on the store's reply there would hand the page back the value it just changed. The
last-visited key moved off it, because that module is in the page's own closure.

Both rollbacks are gone with the notes that needed them, and `noteMirroredWrite` is private. A
source-scanning census holds each writer to the path by name.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): answer a page batch write at the first pair it cannot take

Ruling 35's other half. `settleBatch` collected a refusal per pair, logged each, and rejected
with the first that could reject while the rest of the batch went in anyway — one promise
describing a call where some pairs landed and some did not, which is not something a caller can
act on.

A batch is one call with one answer now: every pair before the refusal is applied, the refusal is
the answer, and nothing after it is attempted. No page-closure writer calls `multiSet` or
`multiRemove` today, so this is the rule for whoever writes the first one rather than a change to
anyone's behaviour; both directions are pinned, including the refused first pair that stops the
rest.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the mirrored write path's census and journal writer

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count the note-first callers the mirror module says are held to one

Two gaps pullfrog found in the census. The last-visited key moved onto `persistMirrored` with no
row naming it, so removing its write path reddened nothing; and `mirrored-storage-keys.ts` says
the census holds `writeMirroredStorage` to one caller while nothing counted them.

Counted now, over every module under `mobile/src` rather than over a list of files a new caller
could sit outside of: a second one is either a writer that wants note-then-persist without the
store that earns it, or a page-reachable module that would note a refusal as an accepted write.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): fold sendInit's doc onto the function it now describes

It still described an awaited post that answered whether the page received the frame, which
ruling 34 deleted: it fires the frame and answers nothing, a refused route sends nothing at all,
and a post the view would not take is one diagnostic and no further attempt.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the page own a frame it received and could not handle

Ruling 34's addendum. On iOS the host's post is `callAsyncJavaScript`, which rejects when the
page's synchronous `onmessage` throws — with the document still mounted. The shell reads that as
a frame that never arrived, and it tracks nothing about posts, so nothing would ever send it
again. It is not a lost frame either: the page had it, one of its own listeners failed, and a
retry would fail the same way.

`receive` catches it and reports `inbound-listener-threw`, so the delivery is the channel's and
the handling is the page's. Nothing is swallowed and nothing is retried.

Two cases pinned the throw escaping and now pin it being reported: the ack that a listener bug
must not wedge, and the bootstrap stamp a tree that throws still leaves behind.

With that path closed, a post is refused only when no document holds the view, and the comments
on both halves of the route seam say so instead of naming a backoff that is stopped by then. The
repair is pinned rather than described: a tap that arrives while the view is gone is carried to
the next document's `ready`, because the held route advances on `hold` as well as on `send`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): move the page's held session out of the client, which was at its cap

The listener catch put `bridge-rpc-client.ts` at 305 counted lines against a cap of 300, so this
splits rather than bumps.

The session is the one piece of the client with a lifecycle rather than a value: a second `init`
for the same session updates it in place, a different one replaces it and takes the requests and
streams of the session before it, and each case has its own listeners to fire in its own order.
The client keeps the frames and the ports; `bridge-client-shell-session.ts` keeps what they are
for, and the client's three members delegate to it.

The client measures 277 counted lines after the move. The session route's page closure is
unchanged at 4,330 / 988: the page reaches its client from the entry, not from the route module.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-21 16:07:14 -04:00
committed by GitHub
parent 2739246058
commit 3cfb070294
85 changed files with 4809 additions and 497 deletions
@@ -0,0 +1,105 @@
/**
* One owner for the mirror the hybrid shell reads (ruling 35).
*
* `mirrored-storage-keys.ts` holds the map the shell builds every `init` from, synchronously, and
* before this the fourteen writers of a mirrored key noted it themselves — first, then persisted.
* On the page a persist can be refused, so twelve of them left the map holding a value no store
* had taken and the next `init` handed the page exactly that; the other two undid it by hand.
*
* Source-scanning rather than behavioural, and about existence rather than shape: what a
* behavioural case cannot say is that no thirteenth writer appears next week. Each row below is a
* module that owns a mirrored key, so deleting its write path reds that row by name, and every
* failure quotes the line it found.
*/
import { globSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const MIRROR_MODULE = 'src/storage/mirrored-storage-keys.ts'
/** Every module that persists a key the shell mirrors, with the constant each one writes. */
const MIRRORED_WRITERS = [
{
file: 'src/storage/preferences.ts',
keys: ['TEXT_SCALE_KEY', 'SIDEBAR_WIDTH_KEY', 'DOCK_WIDTH_KEY']
},
{ file: 'src/storage/session-view-preferences.ts', keys: ['DEFAULT_SESSION_VIEW_KEY'] },
{
file: 'src/terminal/terminal-accessory-layout.ts',
keys: ['TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY']
},
{ file: 'src/components/CustomKeyModal.tsx', keys: ['CUSTOM_ACCESSORY_KEYS_STORAGE_KEY'] },
{ file: 'src/session/mobile-structured-send-operation-journal.ts', keys: ['STORAGE_KEY'] },
{
file: 'src/worktree/last-visited-worktree-repo.ts',
keys: ['LAST_VISITED_WORKTREE_STORAGE_KEY']
}
]
/**
* The one caller of the note-then-persist path, which is the shell taking a value the page has
* already applied into a store that refuses nothing.
*
* Counted rather than described: the module says the census holds it to one caller, and until
* this row nothing did. A second caller is either a writer that wants the ordering without the
* store that earns it, or a page-reachable module that would note a refusal as an accepted write.
*/
const NOTE_FIRST_CALLER = 'src/mobile-web-shell/use-page-host-snapshot.ts'
/** Every module under `mobile/src`, so a new caller cannot arrive in a file no row names. */
function mobileSources() {
return globSync('src/**/*.{ts,tsx}', { cwd: mobileDir }).sort()
}
/** The line a match sits on, so a failure names what it found rather than only that it found one. */
function linesMatching(source, pattern) {
return source
.split('\n')
.map((line, index) => ({ line: line.trim(), at: index + 1 }))
.filter((entry) => pattern.test(entry.line))
}
function read(file) {
return readFileSync(new URL(file, new URL(mobileDir, 'file:///')), 'utf8')
}
describe('the mirrored storage write path', () => {
it('is the only thing that writes the map, which no other module can reach', () => {
const owner = read(MIRROR_MODULE)
// The map itself: a second module holding a reference to it would be a second owner, and the
// map is not exported, so this is what says so.
expect(linesMatching(owner, /^export (const|let) mirror\b/)).toEqual([])
expect(linesMatching(owner, /^export function note\b/)).toEqual([])
})
it(`calls the note-first path from ${NOTE_FIRST_CALLER} and nowhere else`, () => {
const callers = mobileSources().filter((file) => {
if (file === MIRROR_MODULE) {
return false
}
return linesMatching(read(file), /\bwriteMirroredStorage\(/).length > 0
})
expect(callers).toEqual([NOTE_FIRST_CALLER])
})
for (const row of MIRRORED_WRITERS) {
it(`writes ${row.file} through the one path and never around it`, () => {
const source = read(row.file)
expect(linesMatching(source, /\bpersistMirrored\(/).length).toBeGreaterThan(0)
// Around it would be a store call naming a key the map holds, which is the shape every one
// of these had before: note the map, then persist, and nothing between the two agreeing.
for (const key of row.keys) {
expect(
linesMatching(source, new RegExp(`AsyncStorage\\.(setItem|removeItem)\\(\\s*${key}\\b`)),
`${row.file} writes ${key} past the mirror`
).toEqual([])
}
expect(
linesMatching(source, /\bnoteMirroredWrite\b/),
`${row.file} notes the map itself`
).toEqual([])
})
}
})
@@ -17,6 +17,10 @@ import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
PAGE_ROUTE_MODULES,
pageRouteModulesCoverTheManifest
} from './mobile-web-app-page-route-modules.mjs'
import {
HAPTICS_KINDS_MODULE,
HAPTICS_NATIVE,
@@ -33,16 +37,8 @@ const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.
const read = (file) => readFileSync(join(mobileDir, file), 'utf8')
/** The route module behind each declared page route, which is what a closure is read from. */
const ROUTE_MODULES = new Map([
['/h/[hostId]', 'app/h/[hostId]/index.tsx'],
['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'],
['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'],
['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'],
['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'],
['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'],
['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx']
])
/** The route module behind each declared page route, shared with the screencast-lane census. */
const ROUTE_MODULES = PAGE_ROUTE_MODULES
const HAPTICS_GRANT = 'haptics'
@@ -269,10 +265,9 @@ describeClosure(
})
it('covers every declared page route, so a new one cannot be missed by this file', () => {
// The map above is a hand list of route modules; this is what holds it to the declarations.
expect([...ROUTE_MODULES.keys()].sort()).toEqual(
MOBILE_WEB_PAGE_ROUTES.map((route) => route.pathname).sort()
)
// The shared map is a hand list of route modules; this is what holds it to the declarations.
const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES)
expect(mapped).toEqual(declared)
})
/**
@@ -143,6 +143,32 @@ describeClosure('the browser pane closure', () => {
await expectClosureFamilies(closure.local, [C6_PIN_TABLE])
}, 60_000)
it('adds exactly those families to the session route, which is the route that mounts it', async () => {
// C6 ruling 3: a composed table is pinned against a route, and the pane had none — it is
// mounted by `MobileSessionActiveContent`, not registered. C7.7 registers that route, so the
// half is measured here against the page the shell actually serves rather than against a
// module closure read on its own. The difference matters: the session route reaches the whole
// of `src/session` around the pane, and a family the pane shares with the screen it sits in
// would be invisible in the module reading and present here.
const scenarios = JSON.parse(read('mobile/rpc-foundation/pilot-scenarios.json')).scenarios
const [layout, route] = await Promise.all([
mobileWebAppModuleClosure(['app/h/_layout']),
mobileWebAppRouteClosure('app/h/[hostId]/session/[worktreeId].tsx')
])
const layoutFamilies = pageClosureFamilies(layout.local, scenarios)
const routeFamilies = pageClosureFamilies(route.local, scenarios)
// The pane's four are in the route's set, and they are not the layout's, so the route is what
// brings them. Asserted as containment rather than as a difference: the session route reaches
// far more than the pane, and C7.8 is what pins its whole set.
for (const family of pinnedFamilyNames(read(C6_PIN_TABLE))) {
expect(routeFamilies, family).toContain(family)
expect(layoutFamilies, family).not.toContain(family)
}
// And the layout is the C1 control it is everywhere else, so the line above is a real
// difference rather than a set that happens to contain everything.
expect(layoutFamilies).toEqual(pinnedFamilyNames(read(C1_TABLE)).sort())
}, 300_000)
it('adds exactly those families to a page, and no other', async () => {
// The pin is a half: alone it would also pass if the pane dragged in a family C1 already pins
// and the table happened to list it. This reads the difference the pane makes to the layout.
@@ -0,0 +1,165 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import ts from 'typescript-api'
/**
* What a page route's own closure asks the shell for, read from the call sites rather than listed.
*
* Grants are resolved once, from the route the shell opened, and carried for the life of the
* session: a route that reaches a seam it did not declare is a page whose action is refused at the
* host with nothing on screen to say why. Six of the session route's fourteen grants were pinned
* only by the list they were copied from (ruling 33.3); this is the rule the other eight already
* had, written once and driven over every row.
*
* Parsed, not matched. A regex over source text finds the seam named in a comment, in a string and
* in an import it does not call, and the first two are exactly what a census must not count.
*/
/** A grant whose call site is a function this closure calls. */
const callRow = (grants, callee, seam, why) => ({ kind: 'call', grants, callee, seam, why })
/** A grant whose call site is an import: the bundler substitutes the module, so reaching it is use. */
const importRow = (grants, specifier, why) => ({ kind: 'import', grants, specifier, why })
/**
* One row per grant the page can ask for through a call site of its own.
*
* `haptics` and `screencastBinary` have their own files (`mobile-web-app-haptics-seam.test.mjs`,
* `mobile-web-app-screencast-lane-grant.test.mjs`) and the four audio grants have
* `mobile-web-app-session-dictation-capture.test.mjs`, so those eight are not repeated here. The
* media three share one seam and one row: `useMediaPicker` is the only way in, and `canPickMedia`
* is `pick && read && release`, so a route reaching it needs all three or none of them.
*/
export const PAGE_GRANT_CALL_SITES = [
callRow(
['navigate'],
'useRouteHandoff',
'src/navigation/route-handoff.web.ts',
'the page keeps a route it renders and hands every other one back to the shell'
),
importRow(
['storage'],
'@react-native-async-storage/async-storage',
'the bundler substitutes `page-async-storage.ts`, whose writes ride the storage notify'
),
callRow(
['externalLink'],
'openExternalLink',
'src/platform/external-link.web.ts',
'the shell is the only thing on the page that can open a URL outside the app'
),
callRow(
['native.clipboard.write'],
'useClipboardWriter',
'src/platform/clipboard.web.ts',
"the browser's own clipboard write is refused without a user gesture the page cannot prove"
),
callRow(
['native.clipboard.read'],
'useClipboardReader',
'src/platform/clipboard.web.ts',
'a paste needs the device pasteboard, which the WebView does not hand the page'
),
callRow(
['native.media.pick', 'native.media.read', 'native.media.release'],
'useMediaPicker',
'src/platform/media-picker.web.ts',
'the picker runs the OS permission prompt inside the shell and hands back a handle'
)
]
function parse(source, fileName) {
return ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
}
/**
* Whether this module reaches the row's seam: calls the function, or imports the substituted module.
*
* A call and nothing else. An import of the name without a call is a module that re-exports it, and
* a mention in a comment or a string is not a call at all — both would put a grant on a route that
* can never ask for it, which is the failure a hand-written list already had.
*/
export function moduleReachesGrantRow(source, fileName, row) {
let reached = false
const walk = (node) => {
if (
row.kind === 'call' &&
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === row.callee
) {
reached = true
}
if (
row.kind === 'import' &&
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
node.moduleSpecifier.text === row.specifier
) {
reached = true
}
ts.forEachChild(node, walk)
}
walk(parse(source, fileName))
return reached
}
/** Every module in the closure that reaches the row, the seam itself never counting as its own use. */
export function grantCallSites(mobileDir, closure, row) {
return closure.local.filter((file) => {
if (!/\.tsx?$/.test(file) || file === row.seam) {
return false
}
return moduleReachesGrantRow(readFileSync(join(mobileDir, file), 'utf8'), file, row)
})
}
/** Every grant this closure's own call sites need, in row order. */
export function grantsNeeded(mobileDir, closure) {
return PAGE_GRANT_CALL_SITES.filter(
(row) => grantCallSites(mobileDir, closure, row).length > 0
).flatMap((row) => row.grants)
}
/**
* One row's verdict: every route whose closure reaches that seam and whose entry does not name its
* grants, as `<pathname> needs <grant>`.
*
* Per row rather than per manifest, so a grant struck out of an entry reds a case named after that
* grant. A single whole-manifest check would red under every row at once and say only that
* something was missing.
*/
export async function grantsMissingForRow(mobileDir, routes, closureOf, row) {
const missing = []
for (const route of routes) {
const closure = await closureOf(route.pathname)
if (grantCallSites(mobileDir, closure, row).length === 0) {
continue
}
for (const grant of row.grants) {
if (!route.grants.includes(grant)) {
missing.push(`${route.pathname} needs ${grant}`)
}
}
}
return missing
}
/**
* Every row's verdict at once, in row order.
*
* One implementation under both the check and its control: a control that re-implemented the
* filter would prove the control works and say nothing about the rule.
*/
export async function grantsMissingForRoutes(mobileDir, routes, closureOf) {
const missing = []
for (const row of PAGE_GRANT_CALL_SITES) {
missing.push(...(await grantsMissingForRow(mobileDir, routes, closureOf, row)))
}
return missing
}
@@ -0,0 +1,219 @@
/**
* The six grants that were pinned only by the list they were copied from (ruling 33.3).
*
* `haptics`, `screencastBinary` and the four audio grants already have call-site censuses of their
* own; these six did not, so removing any of them from a manifest entry reddened nothing. Each row
* below gets its own named case, and each case's control is the same rule driven over the entry
* that route would have had with the grant struck out.
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
PAGE_ROUTE_MODULES,
pageRouteModulesCoverTheManifest
} from './mobile-web-app-page-route-modules.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import {
PAGE_GRANT_CALL_SITES,
grantCallSites,
grantsMissingForRow,
grantsNeeded,
moduleReachesGrantRow
} from './mobile-web-app-page-grant-call-sites.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const SESSION = '/h/[hostId]/session/[worktreeId]'
/** Memoised: every case below walks all eight, and a closure is a bundle the walk builds. */
const closures = new Map()
function closureOf(pathname) {
const mod = PAGE_ROUTE_MODULES.get(pathname)
if (mod === undefined) {
throw new Error(`${pathname} has no route module, so no closure can be read for it`)
}
const held = closures.get(pathname) ?? mobileWebAppRouteClosure(mod)
closures.set(pathname, held)
return held
}
/**
* The one place a page route reaches a seam it does not declare, recorded rather than exempted.
*
* `app/h/_layout.tsx` wraps every `/h` route in `HostProtocolGate`, whose `ProtocolBlockScreen`
* offers an Update Orca link through `openExternalLink`. Six routes declare `externalLink` and two
* do not, so on those two the wall's link posts a notify the shell refuses — a dead tap with
* nothing on screen. Pre-existing on main and not C7.7's to change: widening two other routes'
* grants is a capability decision, and this lane reports rather than fixes it.
*
* Exact, so it reds in both directions: adding the grant to either route empties an entry here and
* a new gap anywhere adds one.
*/
const KNOWN_UNDECLARED = new Map([
[
'externalLink',
['/h/[hostId] needs externalLink', '/h/[hostId]/agent-history/[worktreeId] needs externalLink']
]
])
describe('the call-site reader', () => {
const navigate = PAGE_GRANT_CALL_SITES[0]
const storage = PAGE_GRANT_CALL_SITES[1]
it('counts a call and not an import that never calls it', () => {
expect(
moduleReachesGrantRow(
"import { useRouteHandoff } from '../navigation/route-handoff'\nexport { useRouteHandoff }\n",
'a.ts',
navigate
)
).toBe(false)
expect(
moduleReachesGrantRow(
"import { useRouteHandoff } from '../navigation/route-handoff'\nconst r = useRouteHandoff()\n",
'a.ts',
navigate
)
).toBe(true)
})
it('ignores the seam named in a comment or a string, which text matching cannot', () => {
expect(
moduleReachesGrantRow(
['// const r = useRouteHandoff()', 'const hint = "useRouteHandoff()"'].join('\n'),
'a.ts',
navigate
)
).toBe(false)
})
it('reads a .tsx file as TSX, so nothing after the first element is swallowed', () => {
expect(
moduleReachesGrantRow(
['export const view = <View />', 'export const use = () => useRouteHandoff()'].join('\n'),
'a.tsx',
navigate
)
).toBe(true)
})
it('counts the substituted module as reached when it is imported at all', () => {
expect(
moduleReachesGrantRow(
"import AsyncStorage from '@react-native-async-storage/async-storage'\n",
'a.ts',
storage
)
).toBe(true)
expect(
moduleReachesGrantRow("import AsyncStorage from './other-storage'\n", 'a.ts', storage)
).toBe(false)
})
it('names six rows covering eight grants, none of them a grant another census owns', () => {
const grants = PAGE_GRANT_CALL_SITES.flatMap((row) => row.grants)
expect(PAGE_GRANT_CALL_SITES).toHaveLength(6)
expect(grants).toEqual([
'navigate',
'storage',
'externalLink',
'native.clipboard.write',
'native.clipboard.read',
'native.media.pick',
'native.media.read',
'native.media.release'
])
for (const owned of ['haptics', 'screencastBinary', 'native.audio.start']) {
expect(grants).not.toContain(owned)
}
})
})
describeClosure(
'what each page route reaches, against what it declared',
() => {
it('covers every declared page route, so a new one cannot be missed by this file', () => {
const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES)
expect(mapped).toEqual(declared)
})
/**
* One case per row, named after its own grants.
*
* Per row rather than one check over the manifest, because the point is attribution: striking
* `native.clipboard.read` out of an entry has to red a case that says so, and a single
* whole-manifest assertion reds the same way whichever grant went missing.
*/
it.each(PAGE_GRANT_CALL_SITES.map((row) => [row.grants.join(' + '), row]))(
'declares %s on every registered route whose own call sites reach it',
async (name, row) => {
expect(
await grantsMissingForRow(mobileDir, MOBILE_WEB_PAGE_ROUTES, closureOf, row)
).toEqual(KNOWN_UNDECLARED.get(name) ?? [])
}
)
/**
* The control for each of those, self-contained on purpose.
*
* Built from what the session route's own closure reaches rather than from what its entry
* declares, so a case stays green whatever the manifest says and reds only when the rule stops
* working. Reading the manifest here instead would make every row red as soon as any one grant
* went missing, which is the attribution the case above exists to give.
*/
it.each(PAGE_GRANT_CALL_SITES.map((row) => [row.grants.join(' + '), row]))(
'reds the session route when it is registered without %s',
async (_name, row) => {
const needed = grantsNeeded(mobileDir, await closureOf(SESSION))
expect(needed, 'the session route reaches this row').toEqual(
expect.arrayContaining(row.grants)
)
const entry = (grants) => [{ pathname: SESSION, grants }]
// Declaring everything it reaches passes, so each case is a rule and not a wall.
expect(await grantsMissingForRow(mobileDir, entry(needed), closureOf, row)).toEqual([])
const without = needed.filter((grant) => !row.grants.includes(grant))
expect(await grantsMissingForRow(mobileDir, entry(without), closureOf, row)).toEqual(
row.grants.map((grant) => `${SESSION} needs ${grant}`)
)
}
)
it('reaches every one of the eight through the session route, and names where', async () => {
const closure = await closureOf(SESSION)
// The precondition an assertion about a closure needs: the walk read a page, not nothing.
expect(closure.local.length).toBeGreaterThan(250)
expect(grantsNeeded(mobileDir, closure)).toEqual(
PAGE_GRANT_CALL_SITES.flatMap((row) => row.grants)
)
for (const row of PAGE_GRANT_CALL_SITES) {
expect(
grantCallSites(mobileDir, closure, row).length,
row.grants.join(' + ')
).toBeGreaterThan(0)
}
})
it('finds the clipboard reader and the media picker on the session route alone', async () => {
const readerRow = PAGE_GRANT_CALL_SITES[4]
const mediaRow = PAGE_GRANT_CALL_SITES[5]
const reaching = { reader: [], media: [] }
for (const pathname of PAGE_ROUTE_MODULES.keys()) {
const closure = await closureOf(pathname)
if (grantCallSites(mobileDir, closure, readerRow).length > 0) {
reaching.reader.push(pathname)
}
if (grantCallSites(mobileDir, closure, mediaRow).length > 0) {
reaching.media.push(pathname)
}
}
// Both are the session screen's and nowhere else's, which is why no other route carries them.
expect(reaching.reader).toEqual([SESSION])
expect(reaching.media).toEqual([SESSION])
})
},
240_000
)
@@ -0,0 +1,34 @@
/**
* The route module behind each declared page route, which is what a closure is read from.
*
* `MOBILE_WEB_PAGE_ROUTES` names URL patterns and the bundler walks files, so something has to
* join the two. Shared rather than restated in each census for the reason
* `mobile-web-app-external-link-seam.mjs` is: a second copy is a list that stops growing when the
* first one does, and every census over it goes quietly green on a route nobody added.
*
* Extensionless is deliberate on neither side: the `.tsx` is named because that is the file on
* disk, and the builder's own `resolveExtensions` picks the `.web.tsx` sibling ahead of it exactly
* as it would for the page.
*
* `pageRouteModulesCoverTheManifest` is the guard that holds this map to the manifest; every
* census that reads it asserts that too, so a route registered without a row here is a route no
* closure census reads.
*/
export const PAGE_ROUTE_MODULES = new Map([
['/h/[hostId]', 'app/h/[hostId]/index.tsx'],
['/h/[hostId]/agent-history/[worktreeId]', 'app/h/[hostId]/agent-history/[worktreeId].tsx'],
['/h/[hostId]/tasks', 'app/h/[hostId]/tasks.tsx'],
['/h/[hostId]/files/[worktreeId]', 'app/h/[hostId]/files/[worktreeId].tsx'],
['/h/[hostId]/files/preview/[worktreeId]', 'app/h/[hostId]/files/preview/[worktreeId].tsx'],
['/h/[hostId]/source-control/[worktreeId]', 'app/h/[hostId]/source-control/[worktreeId].tsx'],
['/h/[hostId]/review/[worktreeId]', 'app/h/[hostId]/review/[worktreeId].tsx'],
['/h/[hostId]/session/[worktreeId]', 'app/h/[hostId]/session/[worktreeId].tsx']
])
/** The map's pathnames and the manifest's, each sorted, for a caller to compare. */
export function pageRouteModulesCoverTheManifest(routes) {
return {
mapped: [...PAGE_ROUTE_MODULES.keys()].sort(),
declared: routes.map((route) => route.pathname).sort()
}
}
@@ -79,13 +79,16 @@ export async function readShellDocumentHeaders() {
* bumped `v` would otherwise reach a test as a 30s timeout naming nothing.
*/
export async function readBridgeProtocolVersion() {
// The module that declares it, which is the one both halves of the envelope import: the envelope
// re-exports the name, so a reader keyed on the re-export would answer for whichever file the
// last split left it in.
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'),
'utf8'
)
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
throw new Error('could not read BRIDGE_PROTOCOL_VERSION from bridge-frame-fields.ts')
}
return Number(match[1])
}
@@ -163,12 +166,12 @@ export async function readBrowserFrameQuality() {
/** The grant the shell offers every page, read from the same source for the same reason. */
export async function readBridgeFaultGrant() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-frame-fields.ts'),
'utf8'
)
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_FAULT_GRANT')
throw new Error('could not read BRIDGE_FAULT_GRANT from bridge-frame-fields.ts')
}
return match[1]
}
@@ -6,6 +6,20 @@ import { collectMobileWebAppRoutes } from './mobile-web-app-route-manifest.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
/**
* The file the build actually put in a chunk for this route, which is not always the one named.
*
* `resolveExtensions` puts `.web.tsx` ahead of `.tsx`, so a route with a sibling is bundled as the
* sibling and the named path appears in no output at all. Until C7.7 the session route had none
* and the lookup below was exact; the first route with a sibling to be asked for reached "no
* output" instead — a route that is served on the page reading as one the bundle never built.
*/
function chunkOwnerPaths(routeModule) {
const named = resolve(mobileDir, routeModule)
const sibling = named.replace(/\.(tsx?)$/, '.web.$1')
return sibling === named ? [named] : [sibling, named]
}
/**
* What a browser must download before one page route can paint, and what it may defer.
*
@@ -26,12 +40,14 @@ export async function mobileWebAppRouteChunkClosure(routeModule) {
metafile: true,
write: false
})
const routePath = resolve(mobileDir, routeModule)
const routePaths = chunkOwnerPaths(routeModule)
const owner = Object.entries(metafile.outputs).find(([, output]) =>
Object.keys(output.inputs ?? {}).some((input) => resolve(mobileDir, input) === routePath)
Object.keys(output.inputs ?? {}).some((input) => routePaths.includes(resolve(mobileDir, input)))
)
if (!owner) {
throw new Error(`[mobile-web-app-route-chunk-closure] ${routeModule} reached no output`)
throw new Error(
`[mobile-web-app-route-chunk-closure] ${routeModule} reached no output (tried ${routePaths.join(', ')})`
)
}
const reached = entryStaticClosure(metafile, owner[0])
const inputsOf = (outputs) =>
@@ -0,0 +1,102 @@
/**
* Which page routes mount the browser pane, and the grant the pane needs from each of them.
*
* Natively the socket carries the screencast's binary frames and the app is both halves of that
* path, so there is nothing to negotiate. In the page the frames come through a shell that may
* predate the encoder, and the pane asks first: `use-browser-binary-screencast-grant.web.ts` reads
* `init.grants.native`, and a route that did not declare `screencastBinary` subscribes without
* `wantsBinary` — a live pane on a stream no frame arrives on, with nothing on screen to say why.
*
* C6 could not write this census: the pane is mounted by a route rather than registered as one, so
* there was no route to pin the grant against (C6 ruling 3 deferred it to C7). The session route is
* that route, and this is the general rule rather than an entry for it — the haptics seam census's
* shape, against the other grant a shared component brings into a closure.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
PAGE_ROUTE_MODULES,
pageRouteModulesCoverTheManifest
} from './mobile-web-app-page-route-modules.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
/** The seam as the web build resolves it, and the native sibling the page must never reach. */
const SEAM = 'src/browser/use-browser-binary-screencast-grant.web.ts'
const NATIVE = 'src/browser/use-browser-binary-screencast-grant.ts'
/** Where the grant token is declared, so this file reads it rather than spelling it again. */
const GRANT_MODULE = 'src/mobile-web-shell/bridge/bridge-screencast-grant.ts'
/** The token, parsed off its own declaration: a second spelling is one that can drift. */
function screencastGrantToken() {
const source = readFileSync(join(mobileDir, GRANT_MODULE), 'utf8')
const declared = /BRIDGE_SCREENCAST_BINARY_GRANT = '([^']+)'/.exec(source)
if (declared === null) {
throw new Error(`${GRANT_MODULE} no longer declares the grant this census reads`)
}
return declared[1]
}
/** The modules that call the hook, which is the pane and whatever else grows one. */
function screencastGrantCallers(closure) {
return closure.local.filter((file) => {
if (!/\.tsx?$/.test(file) || file === SEAM || file === NATIVE) {
return false
}
return /\buseBrowserBinaryScreencastGrant\s*\(/.test(
readFileSync(join(mobileDir, file), 'utf8')
)
})
}
describe('the grant token this census is written against', () => {
it('is the one the shell declares', () => {
expect(screencastGrantToken()).toBe('screencastBinary')
})
})
describeClosure(
'the routes that mount the browser pane',
() => {
it('declares the screencast lane on exactly the routes whose closure asks for it', async () => {
const asking = []
for (const [route, mod] of PAGE_ROUTE_MODULES) {
const closure = await mobileWebAppRouteClosure(mod)
if (screencastGrantCallers(closure).length > 0) {
asking.push(route)
}
}
// One route today, and the precondition an assertion about a derived set needs: an empty
// list is also what a walk that read nothing produces.
expect(asking).toEqual(['/h/[hostId]/session/[worktreeId]'])
const declared = MOBILE_WEB_PAGE_ROUTES.filter((route) =>
route.grants.includes(screencastGrantToken())
).map((route) => route.pathname)
expect([...declared].sort()).toEqual([...asking].sort())
})
it('reaches the seam through its web sibling, and the caller is the pane', async () => {
const closure = await mobileWebAppRouteClosure(
PAGE_ROUTE_MODULES.get('/h/[hostId]/session/[worktreeId]')
)
expect(closure.local).toContain(SEAM)
expect(closure.local).not.toContain(NATIVE)
expect(screencastGrantCallers(closure)).toEqual(['src/browser/MobileBrowserPane.tsx'])
// The pane is mounted by the session's content row rather than by a route of its own, which
// is the whole reason this grant had no route to be pinned against until now.
expect(closure.local).toContain('src/session/MobileSessionActiveContent.tsx')
})
it('covers every declared page route, so a new one cannot be missed by this file', () => {
const { mapped, declared } = pageRouteModulesCoverTheManifest(MOBILE_WEB_PAGE_ROUTES)
expect(mapped).toEqual(declared)
})
},
240_000
)
@@ -4,10 +4,11 @@
*
* A census rather than a hand list, because a grant row written by hand is a row that stops
* agreeing with the closure the moment a screen moves: the rule below reads what each registered
* page route actually reaches and holds its `grants` to it. Vacuous today — the session route is
* the only closure that reaches the seam and `MOBILE_WEB_PAGE_ROUTES` does not carry it yet (C7.7
* registers it) — so the control beside it applies the same rule to the session route module and
* shows the rule failing without the four names.
* page route actually reaches and holds its `grants` to it. It was vacuous when it was written —
* the session route is the only closure that reaches the seam and the manifest did not carry it
* and C7.7 registers that route, so the rule now binds a real entry and the four names in it were
* taken from this census rather than copied. The control beside it stays: it is what shows the
* rule failing, which a green rule over a satisfied manifest cannot.
*
* The closure also says what the seam took out of the page. Without its web half the bundler
* resolves the native one, and the vendored `@orca/expo-two-way-audio` web stub lands in the
@@ -141,17 +142,17 @@ describeClosure(
reaching.push(route.pathname)
}
}
// None today: dictation lives on the session screen, and that route is not registered yet.
// Which is why the rule above passes without a grant row moving, and why the control below
// is what proves the rule can fail at all.
expect(reaching).toEqual([])
// One, now that C7.7 registers it: dictation lives on the session screen and nowhere else,
// so this is both the list and the reason no other route carries an audio grant. The control
// below is still what proves the rule can fail at all.
expect(reaching).toEqual([SESSION_PATHNAME])
const session = await closureOf(SESSION)
expect(session.local).toContain(SEAM)
})
it('reds the same rule when the session route is registered without them', async () => {
// The control for the rule above, which is vacuous until C7.7 registers this route: the same
// loop, driven over the entry C7.7 would write if it copied its neighbours' grants.
// The control for the rule above: the same loop, driven over the entry C7.7 would have
// written if it had copied its neighbours' grants instead of reading this census.
expect(
await grantsMissingForRoutes([
{ pathname: SESSION_PATHNAME, grants: ['navigate', 'storage'] }
@@ -212,7 +213,8 @@ describe('the census rule itself', () => {
'app/h/[hostId]/files/[worktreeId].tsx',
'app/h/[hostId]/files/preview/[worktreeId].tsx',
'app/h/[hostId]/source-control/[worktreeId].tsx',
'app/h/[hostId]/review/[worktreeId].tsx'
'app/h/[hostId]/review/[worktreeId].tsx',
'app/h/[hostId]/session/[worktreeId].tsx'
])
})
@@ -0,0 +1,326 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The session route in a real browser, on the bundle the shell would serve, under its own header.
*
* What only a browser answers for this route: that every module in the largest closure of the
* series imports and evaluates under React Native Web, that the route paints the session screen
* rather than the Unmatched route, that its chunk arrives over the wire on a client-side
* navigation, and that nothing it paints leaves the origin or violates the policy. The unit tests
* cannot say any of it, because they mock react-native away — it is Flow source vitest will not
* parse.
*
* Two defects this file found, both invisible natively and both a console line rather than a crash.
* One is fixed in the commit beside it and one is reported rather than fixed:
*
* - **Fixed.** `use-mobile-session-markdown-actions.ts` registered `BackHandler` with no platform
* guard, and the effect re-registers whenever the dirty-draft list changes. React Native Web
* answers with "BackHandler is not supported on web and should not be used." and an inert
* subscription: two lines on the console at mount, and a hardware-back guard never armed anyway.
* - **Reported.** `use-mobile-session-diff-comments.ts` runs `void loadDiffComments()` in an effect
* with no catch. The loader returns on a *refused* `worktree.show` and nothing catches a
* *rejected* one, so a host that will not answer raises an unhandled rejection on every session
* mount. `.catch` is the fix and it is one line, but the corpus certifies the rejection —
* `matrix-session.diff-notes-worktree.show-1` lists it as an effect of the loaded checkpoint — so
* fixing it is a golden re-record and a review event rather than something this lane lands.
*
* So the error assertion below is an exact list rather than `toEqual([])` or a filter: that one
* rejection and nothing else. A second error reds it, and so does the rejection going away, which
* is what makes this file the place the fix is noticed when it lands.
*
* **The terminal is not painted here, and this file must not look as though it is.** Putting a
* terminal on screen needs the host protocol handshake, a tab snapshot, a terminal inventory and a
* `terminal.subscribe` stream, which is five hand-written fixtures against five Zod schemas inside
* a transport double — the thing the harness's own docstring says it must not become. What the
* terminal does under the shipped header, opening xterm with zero CSP violations and a byte-exact
* transcript, is `mobile-web-app-terminal-render.test.mjs`, which drives the same component on the
* same build options through a probe route. The rest of what this file does not claim is at the
* bottom.
*/
const HOST_ROUTE = '/h/render-check-host'
const WORKTREE = 'wt-1'
const SESSION_ROUTE = `${HOST_ROUTE}/session/${WORKTREE}`
const SESSION_PATTERN = '/h/[hostId]/session/[worktreeId]'
/** The patterns `init.pageRoutes` names, which is what the page matches a navigation against. */
const PAGE_ROUTE_PATTERNS = ['/h/[hostId]', SESSION_PATTERN]
const SHELL_SESSION_ID = 'session-render-session'
const SHELL_BUILD_ID = 'session-render-build'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const UNMATCHED = 'Unmatched Route'
const SESSION_CHUNK_KEY = './h/[hostId]/session/[worktreeId].tsx'
/**
* Exactly what the route declares, read off the manifest rather than restated.
*
* The page's own seams are gated on these: a list written by hand here would let the route grow a
* grant this check never exercises, which is the case where a control renders and refuses.
*/
function sessionGrants() {
const declared = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === SESSION_PATTERN)
if (!declared) {
throw new Error(`${SESSION_PATTERN} is not registered`)
}
return declared.grants
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let routeChunks = {}
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-session-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
routeChunks = built.routeChunks
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 240_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/** A page carrying every signal these cases read: uncaught errors, console errors, request paths. */
async function openPage(route) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
// At document start, where the native shell installs the real channel: the entry reads it while
// its own script runs, so a channel added after `load` would already be too late.
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname: route },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant, ...sessionGrants()],
pageRoutes: PAGE_ROUTE_PATTERNS,
replies: {}
})
const errors = []
const scripts = []
const requestedHosts = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
// Every request, not only the ones that answered: a CSP refusal fails the request, and a check
// reading responses alone would read a blocked fetch as one that never happened.
page.on('request', (request) => requestedHosts.push(new URL(request.url()).host))
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, scripts, requestedHosts }
}
/**
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
* every screen is deferred behind `import()`, so `mounted` lands while the chunk is still arriving.
*/
async function waitForRoute({ page, errors }, route, awaitText) {
const named = (what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`)
try {
await page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 60_000,
polling: 250
})
} catch {
throw named('never mounted')
}
try {
await page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 60_000,
polling: 250
})
} catch {
throw named(`mounted but never painted ${JSON.stringify(awaitText)}`)
}
// A route that threw under the page's own error boundary names itself here rather than timing
// out as a page that never mounted.
for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
errors.push(`page fault: ${fault}`)
}
}
async function openRoute(route, awaitText) {
const opened = await openPage(route)
await opened.page.goto(`${origin}/`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
return opened
}
/** The session header renders it, so the chrome is on screen before this reads the tree. */
const BACK_LABEL = 'Back to worktrees'
/**
* The one error this page is expected to produce, named in full.
*
* `use-mobile-session-diff-comments.ts`'s uncaught `loadDiffComments()` against a double that
* answers no RPC. The category is the double's own, so this string is stable for this file and
* says which refusal reached the document rather than only that something did.
*/
const KNOWN_UNCAUGHT = 'RenderCheckShellDouble: the render check answers no RPC'
describeRender(
'the session route in a real browser',
() => {
it('mounts the session screen rather than the unmatched route, with nothing on the console', async () => {
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const text = await opened.page.evaluate(() => document.body.innerText)
// The command dock's own keys, which is the session screen and not a header that happens to
// say the word: no other page route renders an accessory bar.
for (const key of ['Esc', 'Tab', 'Ctrl+C', 'Ctrl+R']) {
expect(text).toContain(key)
}
expect(text).not.toContain(UNMATCHED)
// Exact, because this closure's defects are exactly console lines. The unguarded
// `BackHandler` put two here and is fixed; the uncaught diff-notes rejection is the one
// entry left and is a golden re-record away from going too.
expect(opened.errors).toEqual([KNOWN_UNCAUGHT])
await opened.page.close()
}, 120_000)
it('puts the Back control in the accessibility tree by name', async () => {
// Inside the shell there is no native chrome behind this control, so a bare Pressable is
// absent from the tree: a screen reader has nothing to announce and the device proof has
// nothing to find. The source census
// (`mobile/src/mobile-web-shell/page-served-back-control-a11y.test.ts`) holds the role and
// the wording; this is the half only a browser answers, that the two reach the rendered DOM.
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const control = await opened.page.evaluate((label) => {
const found = document.querySelector(`[aria-label="${label}"]`)
return found === null ? null : { role: found.getAttribute('role'), tag: found.tagName }
}, BACK_LABEL)
// A real `<button>`, which is what React Native Web emits for `accessibilityRole="button"`
// and what carries the name into the tree. Without the role it renders a `div` with the
// label and no role at all, so this is the assertion the role earns.
expect(control).toEqual({ role: 'button', tag: 'BUTTON' })
await opened.page.close()
}, 120_000)
it("fetches the session route's own chunk on a client-side navigation", async () => {
const opened = await openRoute(HOST_ROUTE, SHELL_HOST.name)
const loadedForFirstRoute = [...opened.scripts]
await opened.page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, SESSION_ROUTE)
await waitForRoute(opened, SESSION_ROUTE, 'Terminal')
const chunk = routeChunks[SESSION_CHUNK_KEY]
expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
// Named by the builder rather than guessed from the bytes: this is what says the route came
// over the wire now and not out of what the first route had already loaded.
expect(opened.scripts.filter((path) => !loadedForFirstRoute.includes(path))).toContain(
`/assets/${chunk}`
)
expect(loadedForFirstRoute).not.toContain(`/assets/${chunk}`)
await opened.page.close()
}, 120_000)
it('paints under the shipped policy without violating it or leaving the origin', async () => {
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
// Chromium reports a refused subresource as a console error naming the directive, so
// anything this closure loaded that the policy blocked lands here.
expect(opened.errors.filter((entry) => entry.includes('Content Security Policy'))).toEqual([])
// And nothing else beyond the one rejection above, so this case reads the whole account.
expect(opened.errors).toEqual([KNOWN_UNCAUGHT])
// Stronger than the line above and independent of it: not one request left the origin, so
// there is nothing for the policy to have refused. A font, a beacon or a provider image
// added anywhere in this closure reds this.
expect(opened.requestedHosts.filter((host) => host !== new URL(origin).host)).toEqual([])
await opened.page.close()
}, 120_000)
it('asks the desktop for the session it was opened on, so the page above is live', async () => {
// The precondition every assertion above needs: a screen that mounted and asked for nothing
// would paint the same chrome. The three reads are the header's live title, the tab snapshot
// and the terminal inventory, each carrying the workspace the route named.
const opened = await openRoute(SESSION_ROUTE, 'Terminal')
const requests = await opened.page.evaluate(() => globalThis.__orcaRenderCheckRequests ?? [])
for (const method of ['worktree.show', 'session.tabs.list', 'terminal.list']) {
expect(
requests.some((request) => request.method === method),
method
).toBe(true)
}
expect(JSON.stringify(requests)).toContain(WORKTREE)
await opened.page.close()
}, 120_000)
},
600_000
)
/**
* What this file does not claim, and where each is answered instead.
*
* **The terminal.** No terminal is attached here, for the reason in the header: the screen paints
* "Loading tabs" against a double that answers no RPC. `mobile-web-app-terminal-render.test.mjs`
* is the browser proof of the terminal itself — xterm opening under `script-src 'self'`, an
* escape-dense byte stream read back through the document's own selection path, zero CSP
* violations — and it drives the same component through a probe route because a session screen
* cannot reach one without the whole host protocol scripted.
*
* **The chat, the pickers and the clipboard.** Each is behind a control that only renders once the
* screen has provider data, which is the same wall C4's render check names. They are pinned by the
* source censuses beside them (`mobile-web-app-session-external-links.test.mjs`,
* `mobile-web-app-session-media-picker.test.mjs`) and by the host and port-pair suites.
*
* **The browser pane.** `mobile-web-app-browser-pane-render.test.mjs` drives it with real frames;
* what this route owes it is the `screencastBinary` grant, which
* `mobile-web-app-screencast-lane-grant.test.mjs` derives from this closure.
*
* **The storage refusals.** A page write needs a control to make it. The refusal's own chain is
* `mobile/src/session/mobile-structured-send-page-storage-refusal.test.ts` end to end over the
* real `page-async-storage`.
*
* **That the one uncaught rejection is harmless.** It is not reported as a page fault — the shell's
* `fault` notify is raised by the React boundary, and `__orcaRenderCheckFaults` is empty here — so
* the generation is not dropped and the screen keeps working. What it costs is a document-level
* error on every mount, which is a line in a crash report and a red herring in the device proof.
*/
@@ -183,8 +183,42 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
*
* Measured, not derived: `mobile-web-app-session-dictation-capture.test.mjs` moves the web file
* aside and walks the closure again, which puts those eight back.
*
* C7.7 registers the route and adds one more: the walk now enters through
* `app/h/[hostId]/session/[worktreeId].web.tsx` rather than the native switch, and reaches
* `src/session/MobileSessionRouteScreen.tsx` under it — the route file is one input either way and
* the component is the one that is new.
*
* The number below is re-measured rather than summed, which is what the reading above kept having
* to do: C7.7 measured 4,328 -> 4,329 against `23207bfde2` and item D measured 4,328 -> 4,323
* against a different base, and neither side's arithmetic survives the other. The merge with main
* read 4,324 modules and 982 local — one more than the 4,323 / 981 item D pinned, and that one is
* C7.7's route body, read out of `ROUTE_ENTRY` below by name rather than inferred.
*
* Round 1 re-measured it at 4,326 / 984. The two were named rather than counted:
* `notification-pane-tab.ts`, which both siblings of the pane hook read (a `.web.ts` cannot import
* its native neighbour by the plain path — the bundler answers with itself), and
* `bridge-init-route.ts`, the route half of `init` split out of an envelope that was at its line
* cap. The pane hook's own web sibling replaces the native file rather than joining it, so it
* costs nothing.
*
* Ruling 34 measures 4,330 / 988, and the four are named the same way. `bridge-frame-fields.ts`
* and `bridge-notify-envelope.ts` are the two halves an envelope back at its line cap was split
* into; the page-to-shell union in the second names the param the page may erase, which is
* declared beside the route-update accept, so `bridge-route-update.ts` and the
* `shell-screen-route.ts` it reads a route key from now enter through the envelope as well. All
* four are schema and string constants: the closure grew, the download did not gain a package.
*/
const SESSION_ROUTE_MODULES = 4323
const SESSION_ROUTE_MODULES = 4330
/** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */
const ROUTE_ENTRY = [
'app/h/[hostId]/session/[worktreeId].web.tsx',
'src/session/MobileSessionRouteScreen.tsx',
// Round 1's two, named for the reading above rather than left inside the total.
'src/session/notification-pane-tab.ts',
'src/mobile-web-shell/bridge/bridge-init-route.ts'
]
const artifactModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PAGE_ENGINE))
const packageModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PACKAGE))
@@ -222,6 +256,18 @@ describeClosure(
expect(local).not.toContain('src/terminal/terminal-webview-document-script.generated.ts')
}, 300_000)
it('enters through the web sibling and the route body, not the switch', async () => {
const { local } = await mobileWebAppRouteClosure(SESSION_ROUTE)
for (const entry of ROUTE_ENTRY) {
expect(local, `${entry} is not in the closure`).toContain(entry)
}
// The switch itself is what the shell renders natively, and it reaches
// `MobileWebShellScreen`, whose module calls `requireNativeViewManager` at import. A closure
// that carried it would be a bundle that throws when the manifest imports this route.
expect(local).not.toContain('app/h/[hostId]/session/[worktreeId].tsx')
expect(local).not.toContain('src/mobile-web-shell/MobileWebShellScreen.tsx')
}, 300_000)
it('reaches the engine as one deferred module and never as part of the download', async () => {
const { modules } = await mobileWebAppRouteClosure(SESSION_ROUTE)
// The engine is here, as the one artifact the loader imports.
@@ -52,24 +52,37 @@ function sameRoute(pushed, declared) {
* back, and the two declare the same five grants, so both hops stay in the document. Either one
* landing alone would have put a handoff — a new native screen and a new bridge session — between
* a changed-file row and its diff.
*
* The seven C7 rows are the same rule with the arrows all one way: 16 -> 23, every new entry
* `X -> session`, one from each other page route. The session screen's fourteen grants are a strict
* superset of every other route's, so nothing can reach it under the grants it was opened with —
* and nothing it pushes to leaves, because its own seven targets each declare a subset. A row in
* the other direction would mean a route had grown a grant the session lacks.
*/
const HANDED_OFF = [
'/h/[hostId] -> /h/[hostId]/files/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/files/preview/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId] -> /h/[hostId]/tasks',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/files/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/files/preview/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/agent-history/[worktreeId] -> /h/[hostId]/tasks',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/files/[worktreeId] -> /h/[hostId]/tasks',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/review/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/source-control/[worktreeId]',
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/tasks'
'/h/[hostId]/files/preview/[worktreeId] -> /h/[hostId]/tasks',
'/h/[hostId]/review/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/source-control/[worktreeId] -> /h/[hostId]/session/[worktreeId]',
'/h/[hostId]/tasks -> /h/[hostId]/session/[worktreeId]'
]
describe('in-page hops between page routes', () => {
@@ -120,6 +133,33 @@ describe('in-page hops between page routes', () => {
expect(preview.grants.filter((grant) => !explorer.grants.includes(grant))).toEqual([])
})
it('keeps every hop out of the session local, which is the other half of its seven rows', () => {
// Asserted as grant coverage rather than as the absence of seven rows: absent is also what an
// unregistered route looks like, and a `session -> tasks` handoff would read the same either
// way. Every target the session pushes to declares a subset of what it holds, so a tapped row
// stays in this document instead of costing a native frame and a second bridge session.
const session = MOBILE_WEB_PAGE_ROUTES.find(
(route) => route.pathname === '/h/[hostId]/session/[worktreeId]'
)
if (!session) {
throw new Error('the manifest lost the session route this census is written against')
}
const uncovered = MOBILE_WEB_PAGE_ROUTES.filter(
(target) => target.pathname !== session.pathname
)
.filter((target) => target.grants.some((grant) => !session.grants.includes(grant)))
.map((target) => target.pathname)
expect(uncovered).toEqual([])
// And the superset is strict, so the line above is not two equal lists.
expect(session.grants.length).toBeGreaterThan(
Math.max(
...MOBILE_WEB_PAGE_ROUTES.map((route) => route.grants.length).filter(
(length) => length !== session.grants.length
)
)
)
})
it('keeps the hub and review local to each other, in both directions', () => {
// The pair C4 registered together. Asserted as equality of the two grant lists rather than as
// the absence of two rows above: absent is also what an unregistered route looks like, and the
+50
View File
@@ -96,5 +96,55 @@ export const MOBILE_WEB_PAGE_ROUTES = [
{
pathname: '/h/[hostId]/review/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
},
// The session screen: terminal and chat. Fourteen grants, every one of them read off a call site
// in this route's own closure rather than carried from the design, and it is the only route that
// asks for the media verbs, the audio verbs or the screencast lane.
//
// `navigate` for the Back that pops the native stack and for the seven handoff sites its panels
// push from; `storage` for the nine exact keys this route added to `page-storage-keys.ts` and the
// two workspace-scoped ones beside them; `externalLink` for the six openers it reaches —
// a terminal link tap whose open mode is the phone's browser, the Markdown and file readers, and
// the PR segment it docks; `haptics` for twenty-four trigger sites, which is the most of any
// route. `native.clipboard.write` has six call sites (the quick-command row, the sheets, the diff
// note, the Markdown actions, the accessory selection, the PR conflict list) and
// `native.clipboard.read` three (the accessory selection, the terminal's paste, the attachment
// probe): this screen is the heaviest clipboard user in the app and the first route to need the
// read as well as the write.
//
// The three media verbs are one seam, `useMediaPicker`, reached from the image attachment and the
// chat's image upload. They are declared together because `canPickMedia` is
// `pick && read && release` — a picked image is a handle, then chunks, so a route holding fewer
// than all three can start a pick it cannot finish.
//
// `screencastBinary` is C6's, and this is the route C6 ruling 3 deferred it to: the browser pane
// is mounted by `MobileSessionActiveContent`, and `use-browser-binary-screencast-grant.web.ts`
// asks the shell through the grants `init` carried. Without it the pane subscribes without
// `wantsBinary` against a shell that would have encoded the frames.
//
// The four audio verbs are dictation's, and they are this route's alone: C7.10 PR D put the
// capture seam on the page and `mobile-web-app-session-dictation-capture.test.mjs` derives the
// list from the closure, which reaches `dictation-capture.web.ts` from the composer. All four or
// none — a route granted three records with the screen free to lock, and a lock mid-processing
// suspends the app and loses the transcript. Ruling 4's degradation is retired with them: the
// page no longer falls back to the vendored module's denied microphone.
{
pathname: '/h/[hostId]/session/[worktreeId]',
grants: [
'navigate',
'storage',
'externalLink',
'haptics',
'screencastBinary',
'native.clipboard.write',
'native.clipboard.read',
'native.media.pick',
'native.media.read',
'native.media.release',
'native.audio.start',
'native.audio.read',
'native.audio.stop',
'native.wakelock.set'
]
}
]
@@ -70,6 +70,25 @@ const EXPECTED_PAGE_ROUTES = [
{
pathname: '/h/[hostId]/review/[worktreeId]',
grants: ['navigate', 'storage', 'externalLink', 'haptics', 'native.clipboard.write']
},
{
pathname: '/h/[hostId]/session/[worktreeId]',
grants: [
'navigate',
'storage',
'externalLink',
'haptics',
'screencastBinary',
'native.clipboard.write',
'native.clipboard.read',
'native.media.pick',
'native.media.read',
'native.media.release',
'native.audio.start',
'native.audio.read',
'native.audio.stop',
'native.wakelock.set'
]
}
]
+98 -5
View File
@@ -1,7 +1,100 @@
import { MobileSessionSurface } from '../../../../src/session/MobileSessionSurface'
import { useMobileSessionController } from '../../../../src/session/use-mobile-session-controller'
import { useCallback } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
import { firstParam } from '../../../../src/navigation/route-param-reader'
import {
shellScreenRoute,
shellScreenRouteKey
} from '../../../../src/mobile-web-shell/shell-screen-route'
import { MobileWebShellScreen } from '../../../../src/mobile-web-shell/MobileWebShellScreen'
import { useMobileWebShellEnabled } from '../../../../src/mobile-web-shell/use-mobile-web-shell-enabled'
export default function SessionScreen() {
const controller = useMobileSessionController()
return <MobileSessionSurface controller={controller} />
/**
* The session screen — terminal and chat — from the desktop's bundle or from this app.
*
* The review switch's shape, for its reasons: the native screen is `MobileSessionRouteScreen`
* rather than the body of this file, because `useMobileSessionController` at this file's top level
* would open the terminal, chat and tab subscriptions behind the page as well as in front of it.
* As an element it is built and not mounted, and only `fallback` ever mounts it.
*
* Four query params rather than the review's four, and one of them is not part of this screen's
* identity: `paneKey`. A notification tap for a pane of the session already on screen is a tab
* switch, so keying on it would tear the bridge down and reload the page for one, and keying on it
* while the page cleared its own copy lost a repeat tap outright (ruling 33.1). It travels as a
* route update instead — a re-sent `init` to a page that said it takes one — and this file clears
* the native param once the page has been handed it, exactly as the notification hook did, so no
* later `init` can replay a spent tap.
*/
export default function MobileSessionScreen() {
// Through `firstParam` on every param, as every switch does: expo-router answers a repeated query
// key with an array, and a bare read puts `String(['a','b'])` into the template, where
// `encodeURIComponent` makes it the single segment `a%2Cb` — which the bridge's segment rule
// accepts, so the shell opens a page for a workspace nobody has.
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
created?: string | string[]
warning?: string | string[]
paneKey?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const enabled = useMobileWebShellEnabled()
const router = useRouter()
const native = <MobileSessionRouteScreen />
const paneKey = firstParam(params.paneKey) ?? ''
// The reader erasing its own request (ruling 34): the page applied a pane and names it back, and
// this is where the param it came on lives. Compared rather than obeyed — a tap that moved on
// while the page was applying the one before it leaves a newer key here, and that one is not
// spent yet. Written empty rather than removed, which is what the notification hook wrote and
// what the route builder below drops: a cleared key and a key that was never there are the same
// route.
const erasePaneKey = useCallback(
(param: 'paneKey', value: string) => {
if (param !== 'paneKey' || paneKey === '' || value !== paneKey) {
return
}
router.setParams({ paneKey: '' })
},
[paneKey, router]
)
// Each omitted when empty, because the screen reads the difference: `created` is a one-shot flag
// the create flow sets to `1`, `warning` is the host's own text, `name` is a label the screen
// otherwise derives from the workspace, and `paneKey` empty is exactly what the notification hook
// writes back to say the tap is spent.
const routeParams = Object.fromEntries(
(['name', 'created', 'warning', 'paneKey'] as const)
.map((key) => [key, firstParam(params[key])] as const)
.filter(([, value]) => value !== '')
)
const route =
hostId && worktreeId
? shellScreenRoute({
pathname: `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}`,
...(Object.keys(routeParams).length === 0 ? {} : { params: routeParams })
})
: null
if (enabled !== true || !hostId || route === null) {
return native
}
// Keyed on the route minus `paneKey`: a host captures the grants its session was opened with, so
// a screen reused across a route change would keep authorising frames under the grants of the
// route the page has left, and the key is what makes that change a remount. A pane is not such a
// change — it is a tab of the session this key already names — so it is left out here and
// delivered to the mounted page instead. Derived from the same builder every other switch uses;
// `shellScreenRouteKey` is untouched, because for the other four a param change *is* an identity
// change.
const { paneKey: _paneKey, ...identity } = routeParams
return (
<MobileWebShellScreen
key={shellScreenRouteKey({ pathname: route.pathname, params: identity })}
hostId={hostId}
route={route}
fallback={native}
onRouteParamClear={erasePaneKey}
/>
)
}
@@ -0,0 +1,13 @@
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
/**
* Web sibling for the session screen.
*
* The shell renders this page for this route, so there is no shell to mount here and no flag to
* read. The screen reads its own params, so this file is the whole of the difference: its native
* sibling reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and
* throws in a browser, and the route manifest imports every route.
*/
export default function MobileSessionScreen() {
return <MobileSessionRouteScreen />
}
+19 -2
View File
@@ -12,6 +12,7 @@ import {
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
import { persistMirrored } from '../storage/mirrored-storage-keys'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -75,7 +76,10 @@ export async function loadCustomKeys(): Promise<CustomKey[]> {
}
export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
// Through the one write path, which notes the mirror on an accepted write and on nothing else
// (ruling 35). There is no rollback here any more because there is nothing to undo: on the page
// a value over the cap rejects, and a rejected write never reached the map.
await persistMirrored(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
@@ -106,7 +110,20 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
const updated = [...existing, newKey]
await saveCustomKeys(updated)
// Caught here because both callers are `void addKey(...)`, which leaves a rejection nowhere
// to go. On the page this key is allowlisted and its write rejects for size — the contract
// `page-async-storage` states, and the one ruling 33.6 extends to a key `init` could not
// carry at all — so an uncaught save here reaches the document's unhandled-rejection
// handler, which reports a page fault and drops the generation for a key nobody could add.
// Every other allowlisted writer in this closure already catches its own save.
try {
await saveCustomKeys(updated)
} catch (error) {
// Neither reported nor closed: a drawer that dismissed itself and announced the key would
// put a row on the accessory bar that no store holds and the next load would not have.
console.warn('[custom-keys] the store would not take this key', error)
return
}
onKeysChanged(updated)
onClose()
},
@@ -0,0 +1,176 @@
import { createElement, type ReactNode } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const store = vi.hoisted(() => {
// Annotated rather than asserted: the literal alone narrows `raw` to `null`.
const held: { raw: string | null; refuse: boolean } = { raw: null, refuse: false }
return held
})
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async () => store.raw,
setItem: async () => {
if (store.refuse) {
throw new Error(
'Orca could not save orca:custom-accessory-keys: a stored value may be too large.'
)
}
}
}
}))
/**
* Function components rather than host strings, so a case can match a node by identity: the
* renderer types `node.type` as an `ElementType`, which a string literal is not, and the tests
* ratchet checks this file.
*/
const hosts = vi.hoisted(() => {
const make = (name: string) => {
const Host = (props: { children?: ReactNode }): ReactNode => props.children ?? null
Host.displayName = name
return Host
}
return {
View: make('View'),
Text: make('Text'),
Pressable: make('Pressable'),
TextInput: make('TextInput'),
Switch: make('Switch')
}
})
vi.mock('react-native', () => ({
View: hosts.View,
Text: hosts.Text,
Pressable: hosts.Pressable,
TextInput: hosts.TextInput,
Switch: hosts.Switch,
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
Platform: { OS: 'ios', select: (options: Record<string, unknown>) => options.ios }
}))
vi.mock('lucide-react-native', () => ({ ChevronLeft: hosts.View }))
vi.mock('./BottomDrawer', () => ({ BottomDrawer: hosts.View }))
import { CustomKeyModal } from './CustomKeyModal'
import { readMirroredStorage } from '../storage/mirrored-storage-keys'
const CUSTOM_KEYS = 'orca:custom-accessory-keys'
/** What a later `init` would carry for this key, which is the map and not the store. */
function mirrored(): string | undefined {
return readMirroredStorage([CUSTOM_KEYS])[CUSTOM_KEYS]
}
/** The one label a node renders, flattened, without walking a fiber into a cycle. */
function labelOf(node: { props: { children?: unknown } }): string {
const seen: string[] = []
const walk = (value: unknown): void => {
if (typeof value === 'string') {
seen.push(value)
return
}
if (Array.isArray(value)) {
for (const child of value) {
walk(child)
}
}
}
walk(node.props.children)
return seen.join(' ')
}
/** Through the drawer as a user reaches it: pick the shortcut type, then press Add. */
function addAShortcut(renderer: ReturnType<typeof create>): void {
const pressables = () => renderer.root.findAll((node) => node.type === hosts.Pressable)
const press = (match: (label: string) => boolean, what: string): void => {
const target = pressables().find((node) =>
match(
node
.findAll((child) => child.type === hosts.Text)
.map((child) => labelOf(child))
.join(' ')
)
)
if (target === undefined) {
throw new Error(`the modal rendered no ${what} control`)
}
const onPress = target.props.onPress
if (typeof onPress !== 'function') {
throw new Error(`the ${what} control has no press handler`)
}
act(() => {
onPress()
})
}
press((label) => label.includes('Shortcut Combo'), 'shortcut-type')
press((label) => label.trim() === 'Add', 'save')
}
beforeEach(() => {
store.raw = null
store.refuse = false
})
/**
* The page refuses a write the app would have taken, and the modal is one of its callers.
*
* `orca:custom-accessory-keys` is in the session route's page allowlist, and on the page a write
* over `PAGE_STORAGE_MAX_VALUE_CHARS` rejects rather than dropping — that is the size contract of
* ruling 33.4, and ruling 33.6 adds the key `init` could not carry at all. Every other allowlisted
* writer in this closure catches its save; this one awaited it inside a `void` call, so the
* rejection had nowhere to go but the page's unhandled-rejection handler, which reports a page
* fault and drops the generation.
*/
describe('adding a custom key when the store refuses the write', () => {
it('does not let the refusal escape as an unhandled rejection', async () => {
store.refuse = true
const before = mirrored()
const unhandled = vi.fn()
process.on('unhandledRejection', unhandled)
const onKeysChanged = vi.fn()
const onClose = vi.fn()
let renderer: ReturnType<typeof create> | null = null
act(() => {
renderer = create(createElement(CustomKeyModal, { visible: true, onClose, onKeysChanged }))
})
if (renderer === null) {
throw new Error('the modal did not render')
}
addAShortcut(renderer)
// Two turns: the load settles, then the save rejects into whatever catches it.
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
process.off('unhandledRejection', unhandled)
expect(unhandled).not.toHaveBeenCalled()
// And the modal does not report a key it failed to store: a row that looks added and is not
// is the failure the allowlist exists to avoid.
expect(onKeysChanged).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
// And the mirror is back where it started. `saveCustomKeys` notes the write before it
// persists, because a reader is answered from the map; a refused write that left the note
// standing would put the key the store rejected into the next `init`.
expect(mirrored()).toBe(before)
})
it('reports the key and closes when the store takes it, so the case above is the refusal', async () => {
const onKeysChanged = vi.fn()
const onClose = vi.fn()
let renderer: ReturnType<typeof create> | null = null
act(() => {
renderer = create(createElement(CustomKeyModal, { visible: true, onClose, onKeysChanged }))
})
if (renderer === null) {
throw new Error('the modal did not render')
}
addAShortcut(renderer)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(onKeysChanged).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -23,6 +23,10 @@ type ScreenDependencies = {
lifecycle: string[]
/** Every render of the shell view, which is one per render of the screen above it. */
viewRenders: number
/** Every frame the shell posted to the page, raw. */
posted: string[]
/** Whether the view refuses what it is handed, which is a page the post never reached. */
postFails: boolean
state: MobileWebShellSessionState
/** Null for every case but the bridge's: with no client the hook builds no host at all. */
client: FakeRpcClient | null
@@ -59,6 +63,8 @@ const dependencies = vi.hoisted((): ScreenDependencies => {
routeGrants: DEFAULT_ROUTE_GRANTS,
lifecycle: [],
viewRenders: 0,
posted: [],
postFails: false,
state: { kind: 'checking' },
client: null
}
@@ -132,7 +138,10 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => {
const React = await import('react')
const loadState = await import('../../modules/orca-mobile-web-shell/src/load-state')
return {
OrcaMobileWebShellView: (props: { sessionId: string }) => {
OrcaMobileWebShellView: (props: {
sessionId: string
ref?: (handle: { postBridgeMessage: (json: string) => Promise<void> } | null) => void
}) => {
dependencies.viewRenders += 1
React.useEffect(() => {
dependencies.lifecycle.push(`mount:${props.sessionId}`)
@@ -140,6 +149,22 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => {
dependencies.lifecycle.push(`unmount:${props.sessionId}`)
}
}, [props.sessionId])
// The handle the real view exposes, which nothing here used to attach: without it every
// post rejected as a view that is gone, so no case could see a frame reach the page.
const attach = props.ref
React.useLayoutEffect(() => {
attach?.({
postBridgeMessage: (json: string) => {
dependencies.posted.push(json)
return dependencies.postFails
? Promise.reject(new Error('the view would not take it'))
: Promise.resolve()
}
})
return () => {
attach?.(null)
}
}, [attach])
return React.createElement('ShellViewProbe', props)
},
parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState
@@ -159,7 +184,7 @@ vi.mock('./use-page-host-snapshot', () => ({
// down and rebuilt on every render of this screen — and every pending request settled with it.
snapshot: SNAPSHOT,
unreadable: dependencies.snapshotUnreadable,
readStorage: () => ({}),
readStorage: () => ({ storage: {}, storageOversize: [] }),
refreshStorage: () => {
dependencies.storageRefreshes += 1
},
@@ -179,7 +204,12 @@ vi.mock('./use-mobile-web-shell-session', () => ({
}))
import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY } from './bridge/bridge-envelope'
import {
BRIDGE_FAULT_GRANT,
BRIDGE_NAVIGATE_BACK_NOTIFY,
readBridgeHostMessage
} from './bridge/bridge-envelope'
import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge/bridge-route-update'
import { MobileWebShellScreen } from './MobileWebShellScreen'
/** The caller's native screen, as a component so `findAllByType` can name it without a host string. */
@@ -273,6 +303,8 @@ beforeEach(() => {
dependencies.storageRefreshes = 0
dependencies.lifecycle.length = 0
dependencies.viewRenders = 0
dependencies.posted.length = 0
dependencies.postFails = false
dependencies.client = null
dependencies.routeGrants = DEFAULT_ROUTE_GRANTS
dependencies.back.mockReset()
@@ -434,6 +466,103 @@ describe('the hybrid shell screen', () => {
expect(dependencies.storageRefreshes).toBe(2)
})
/**
* One screen whose route this case moves, and every frame that went out for it.
*
* The shell tracks nothing about delivery (ruling 34): what a case can see here is what reached
* the wire, and the request a frame carried is spent by the page, not by this screen.
*/
async function renderForRoute(params: Record<string, string>): Promise<{
tree: ReactTestRenderer
initRoutes: () => (Record<string, string> | undefined)[]
move: (next: Record<string, string>) => Promise<void>
ready: (accepts?: readonly string[]) => Promise<void>
}> {
const element = (next: Record<string, string>) =>
createElement(MobileWebShellScreen, {
hostId: 'host-1',
route: { pathname: '/h/host-1', params: next },
fallback: createElement(NativeFallback)
})
dependencies.state = readyState('session-one')
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
await act(async () => {
rendered.tree = create(element(params))
})
const tree = rendered.tree
if (tree === null) {
throw new Error('screen did not render')
}
mounted.push(tree)
return {
tree,
// Read with the page's own reader rather than parsed loose: a frame this refuses is one the
// page would have refused too, and a case counting inits must not count one of those.
initRoutes: () =>
dependencies.posted
.map((json) => readBridgeHostMessage(json))
.flatMap((read) => (read.ok && read.message.type === 'init' ? [read.message] : []))
.map((frame) => frame.route?.params),
move: async (next) => {
await act(async () => {
tree.update(element(next))
})
},
ready: async (accepts = [BRIDGE_ROUTE_UPDATE_ACCEPT]) => {
await act(async () => {
byName(tree, 'ShellViewProbe')[0]?.props.onBridgeMessage({
nativeEvent: { json: clientFrame({ type: 'ready', accepts }) }
})
})
}
}
}
/**
* A route that moved under a screen that stayed mounted (ruling 33.1, as ruling 34 leaves it).
*
* One frame per move and none for a render that moved nothing. Whether it arrived is not asked
* here and is not asked anywhere: the page's next `ready` is answered with the route the shell
* holds then, which is the whole repair path.
*/
it('posts one init for a route that moved, and none for a render that moved nothing', async () => {
dependencies.client = createFakeRpcClient()
const page = await renderForRoute({ paneKey: '' })
await page.ready()
expect(page.initRoutes()).toEqual([{ paneKey: '' }])
await page.move({ paneKey: 'pane-1' })
expect(page.initRoutes()).toEqual([{ paneKey: '' }, { paneKey: 'pane-1' }])
await page.move({ paneKey: 'pane-1' })
expect(page.initRoutes()).toHaveLength(2)
})
it('answers every ask with the route it holds then, which is how a lost frame is repaired', async () => {
dependencies.client = createFakeRpcClient()
dependencies.postFails = true
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const page = await renderForRoute({ paneKey: '' })
await page.ready()
await page.move({ paneKey: 'pane-1' })
// Both frames were refused by the view, and nothing here is holding either of them.
expect(dependencies.posted).toHaveLength(2)
dependencies.postFails = false
await page.ready()
expect(page.initRoutes().at(-1)).toEqual({ paneKey: 'pane-1' })
warned.mockRestore()
})
it('sends no second init to a page that never said it takes one', async () => {
dependencies.client = createFakeRpcClient()
const page = await renderForRoute({ paneKey: '' })
// A page built before route updates existed declares nothing, and reads a second `init` as a
// replacement: the route still moves, so its next `ready` is answered with the new one.
await page.ready([])
await page.move({ paneKey: 'pane-1' })
expect(page.initRoutes()).toEqual([{ paneKey: '' }])
await page.ready([])
expect(page.initRoutes()).toEqual([{ paneKey: '' }, { paneKey: 'pane-1' }])
})
it('ends that wait on the page asking for a session', async () => {
dependencies.client = createFakeRpcClient()
const tree = await render(readyState('session-one'))
@@ -9,6 +9,7 @@ import {
import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { BridgeInitRoute } from './bridge/bridge-envelope'
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
import type {
MobileWebShellFailureCause,
MobileWebShellSessionState
@@ -130,6 +131,12 @@ export type MobileWebShellScreenProps = {
* the negotiation falls back to, and a shell with nothing behind it would paint a blank instead.
*/
fallback: ReactNode
/**
* The page applied a one-shot route param and asks for it to be erased (ruling 34), naming what
* it applied. Only a caller that put one on the route ever hears this, and the comparison is
* that caller's: it holds the param, and a tap that moved on since leaves a newer value there.
*/
onRouteParamClear?: (param: BridgeClearableRouteParam, value: string) => void
runtime?: MobileWebShellRuntime
}
@@ -144,6 +151,7 @@ export function MobileWebShellScreen({
hostId,
route,
fallback,
onRouteParamClear,
runtime
}: MobileWebShellScreenProps) {
const insets = useSafeAreaInsets()
@@ -160,8 +168,10 @@ export function MobileWebShellScreen({
reportDocumentLoaded,
reportPageReady
} = useMobileWebShellSession({ hostId, routePathname: route.pathname, runtime })
const { snapshot, unreadable, readStorage, refreshStorage, writeStorage } =
usePageHostSnapshot(hostId)
const { snapshot, unreadable, readStorage, refreshStorage, writeStorage } = usePageHostSnapshot(
hostId,
route.pathname
)
// Declared before the bridge so the handler it is handed already belongs to this session: the
// media verbs hold staged files, and a registry born after the host would outlive the page.
const serveNativeVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
@@ -204,6 +214,9 @@ export function MobileWebShellScreen({
reportPageReady()
void refreshStorage()
},
onRouteParamClear: (param, value) => {
onRouteParamClear?.(param, value)
},
// `document-load-failed` because that is what happens: the document loads and the page refuses
// the session, so no tree is ever built. The refetch it costs is wasted on a route this shell
// produced, and the second report is terminal, which is the failure screen this deserves.
@@ -239,6 +252,17 @@ export function MobileWebShellScreen({
onBinaryFramesDropped: reportDroppedBinaryFrames
})
// A route that moved under a screen that stayed mounted: the session switch keeps `paneKey` out
// of its key so a notification tap for another pane is a tab switch rather than a page reload,
// and this is how the page hears about it. Nothing is tracked here — which route the page has,
// and which one is still owed it, belong to the host, which outlives any one run of this effect.
// All this says is what the screen is on now: a route that did not move is dropped there, and a
// frame in flight when this re-runs is not disturbed by it.
const publishRoute = bridge.publishRoute
useEffect(() => {
publishRoute(route)
}, [publishRoute, route])
// A profile read that rejected never becomes a host, so the session would otherwise sit in
// `ready` behind an un-hidden view with nothing serving it and the page asking forever.
// `document-load-failed` because that is the outcome: the document loads and no session opens.
@@ -2,10 +2,12 @@ import type { TerminalBacklogEnd, TerminalBacklogTimers } from './bridge-termina
import type { RpcClient } from '../transport/rpc-client'
import type { BridgeRefusal } from './bridge/bridge-caps'
import type { BridgeInitHost, BridgeInitRoute } from './bridge/bridge-envelope'
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import type { BridgeNativeVerb } from './bridge/bridge-native-verbs'
import type { BridgeNotifyRefusal } from './bridge/bridge-notify-grants'
import type { PageStorageForInit } from './page-storage-keys'
/**
* What the shell did with a `navigate-back`. Only `popped` moved the stack, and the other two are
@@ -42,6 +44,10 @@ export type BridgeHostDiagnostic =
/** The shell asked this host to open a screen the protocol does not allow. The host serves no
* session at all in that state: an `init` the page refuses is worse than no `init`. */
| { kind: 'route-refused'; issue: string }
/** A rewritten route this host would not hand its page: a different screen, or a shape the
* page's own reader would refuse. Local only — nothing crosses, and the tap it came from is
* then the lost repeat tap it was before ruling 33.1. */
| { kind: 'route-update-refused'; issue: string }
/** A page subscribed with `wantsBinary` on a session whose route was never granted the lane.
* Local only: the subscription proceeds and its JSON events cross, so nothing crosses back and
* this line is the only thing that can say why the frames never became binary. */
@@ -116,7 +122,7 @@ export type BridgeHostOptions = {
* mount: a document that reloads inside one mount has to be primed from after its own writes.
* Synchronous, because `init` is — see `sendInit`.
*/
readStorage: () => Readonly<Record<string, string>>
readStorage: () => PageStorageForInit
/** One allowlisted key written, or removed when the value is null. */
onStorageWrite: (key: string, value: string | null) => void
/**
@@ -174,6 +180,12 @@ export type BridgeHostOptions = {
* would leave a document that never spoke looking exactly like one still starting up.
*/
onPageReady: () => void
/**
* The page applied a one-shot route param and is asking for it to be erased (ruling 34), naming
* the value it applied. The holder of that param compares before it clears: a tap that has moved
* on since leaves a newer value here, and a clear naming the older one is not for it.
*/
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
/**
* The route this shell was built with is not one the protocol allows, so no honest `init` can be
* sent and the page will never mount. Loud on purpose: the page's own refusal is a `console.warn`
@@ -0,0 +1,85 @@
import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge/bridge-envelope'
import { captureBridgeError } from './bridge/bridge-error-capture'
import type { BridgeHostDiagnostic } from './bridge-host-contract'
/** One host's outbound frames: what it posts, and whether the post landed. */
export type BridgeHostFrames = {
/**
* Posts one frame and answers whether the page received it.
*
* True only once `post` has resolved. A caller that spends something on delivery — the screen
* clears a one-shot route param on it — must not act on the handover: `post` crosses to the
* native view and a view that is gone rejects, which used to be reported a turn later as a
* diagnostic while the caller had already treated the frame as delivered.
*/
readonly postJson: (json: string) => Promise<boolean>
/** Fire-and-forget, for every frame nobody waits on. */
readonly sendJson: (json: string) => void
readonly send: (frame: BridgeHostMessage) => void
readonly sendError: (id: string, error: unknown) => void
}
/**
* The send half of a host, split out because `bridge-host.ts` sits at its line cap.
*
* `isOpen` rather than two flags: between documents the view still exists and still accepts posts,
* which is exactly why it is asked — a frame sent then lands in the next document before it has
* said `ready` — and a disposed host can neither post nor refuse.
*/
export function createBridgeHostFrames(args: {
post: (json: string) => Promise<void>
isOpen: () => boolean
onDiagnostic?: (diagnostic: BridgeHostDiagnostic) => void
}): BridgeHostFrames {
// Once per session, for the reason a failing post is: a page nudging a broken listener nudges it
// again on every foreground, and a line per frame buries the one that says why.
let postFailureReported = false
function reportPostFailure(error: unknown): void {
if (postFailureReported) {
return
}
postFailureReported = true
args.onDiagnostic?.({ kind: 'post-failed', error })
}
async function postJson(json: string): Promise<boolean> {
if (!args.isOpen()) {
return false
}
try {
// A `post` that throws where it should reject would escape into the client's own
// state-change fan-out, which is what sends the `state` frame, and take the other listeners
// down with it. Awaited here, so both shapes land in the same catch.
await args.post(json)
return true
} catch (error) {
reportPostFailure(error)
return false
}
}
function sendJson(json: string): void {
void postJson(json)
}
return {
postJson,
sendJson,
// Every value in a host frame has already been serialized by whoever produced it — a reply by
// `splitBridgeReply`, an error `code` by the capture's round trip — so this cannot throw.
send: (frame) => {
sendJson(JSON.stringify(frame))
},
sendError: (id, error) => {
sendJson(
JSON.stringify({
v: BRIDGE_PROTOCOL_VERSION,
type: 'error',
id,
error: captureBridgeError(error)
})
)
}
}
}
@@ -7,6 +7,7 @@ import {
BRIDGE_MAX_SUBSCRIPTIONS
} from './bridge/bridge-caps'
import { BRIDGE_FAULT_GRANT } from './bridge/bridge-envelope'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update'
describe('init and state', () => {
it('answers ready with the getters, the caps it enforces, and the grants it honours', () => {
@@ -24,6 +25,8 @@ describe('init and state', () => {
type: 'init',
sessionId: 'session-a',
buildId: 'build-a',
// What this shell takes from the page, which is the page's own check before it posts one.
accepts: [BRIDGE_ROUTE_PARAM_CLEAR],
connection: {
state: 'reconnecting',
reconnectAttempt: 3,
@@ -146,6 +149,68 @@ describe('init and state', () => {
}
})
it('answers a ready for a refused route with nothing at all', async () => {
// The page is still told it was heard, which is a different fact: a refused route has no
// honest `init` behind it, so the ask is answered with no frame rather than with an empty one.
// Unreachable from the session switch, which parses the route before it mounts the shell.
const bridge = harness({ route: { pathname: '/h/a b' } })
bridge.host.receive(clientFrame({ type: 'ready' }))
await Promise.resolve()
expect(bridge.posted).toEqual([])
expect(bridge.pageReadyCount()).toBe(1)
})
it('reports a frame the view would not take, and waits for the next ask (ruling 34)', async () => {
const bridge = harness({
route: { pathname: '/h/host-a' },
post: () => Promise.reject(new Error('the view is gone'))
})
bridge.host.receive(clientFrame({ type: 'ready' }))
for (let turn = 0; turn < 4; turn += 1) {
await Promise.resolve()
}
expect(bridge.diagnostics.map((diagnostic) => diagnostic.kind)).toContain('post-failed')
// Nothing is retried and nothing is held: the page's own backoff asks again, and that ask is
// answered with the route the shell holds then.
expect(bridge.posted).toHaveLength(1)
bridge.host.receive(clientFrame({ type: 'ready' }))
expect(bridge.posted).toHaveLength(2)
})
/**
* The repair path, pinned rather than described (ruling 34 addendum).
*
* A post is refused only when no document holds the view, and every one of those is followed by
* a fresh document's `ready`. What makes that a repair is the held route advancing on `hold` as
* well as on `send`: the tap arrives while the page cannot be sent one, and the next document is
* answered with the route the tap wrote rather than the one the shell opened on.
*/
it('answers the next document with the route a tap wrote while the view was gone', async () => {
const view = { gone: true }
const bridge = harness({
route: { pathname: '/h/host-a/session/wt-1' },
post: () => (view.gone ? Promise.reject(new Error('the view is gone')) : Promise.resolve())
})
// A page that declares nothing is never sent a second `init`, so the tap can only be held.
bridge.host.receive(clientFrame({ type: 'ready' }))
bridge.host.publishRoute({
pathname: '/h/host-a/session/wt-1',
params: { paneKey: 'pane-1' }
})
for (let turn = 0; turn < 4; turn += 1) {
await Promise.resolve()
}
expect(bridge.posted).toHaveLength(1)
// The next document over the same host: a reload, or the view coming back.
view.gone = false
bridge.host.receive(clientFrame({ type: 'ready' }))
const init = bridge.last()
expect(init.type === 'init' && init.route).toEqual({
pathname: '/h/host-a/session/wt-1',
params: { paneKey: 'pane-1' }
})
})
it('opens a session for the routes a screen actually produces', () => {
for (const pathname of ['/h/host-a', '/h/host-a/tasks', '/h/a%20b', '/']) {
const bridge = harness({ route: { pathname } })
@@ -250,9 +315,41 @@ describe('init and state', () => {
expect(bridge.diagnostics).toEqual([{ kind: 'storage-refused', key: 'orca:pins:other-host' }])
})
/**
* The rollout half of ruling 33.6 (pullfrog).
*
* The page's own refusal only exists in a page built with it; a document served from an older
* desktop bundle ignores `storageOversize` and writes the key anyway, which is the clobber the
* ruling is about. The shell holds the same list on the `init` path, so it refuses there too and
* an old page is refused as well.
*/
it('refuses a write for a key it could not hand the page, whatever the page believes', () => {
const journal = 'orca:mobileStructuredSendOperations:v1'
const bridge = harness({
readStorage: () => ({
storage: { 'orca:pins:host-a': '["one"]' },
storageOversize: [journal]
})
})
bridge.host.receive(clientFrame({ type: 'ready' }))
bridge.host.receive(
clientFrame({ type: 'notify', name: 'storage', key: journal, value: '{"v":1,"entries":[]}' })
)
// Nothing reaches native storage, so the entries the device holds survive the page.
expect(bridge.storageWrites).toEqual([])
expect(bridge.diagnostics).toEqual([{ kind: 'storage-refused', key: journal }])
// And a key it did hand over is still writable, so the refusal is the size and not the path.
bridge.host.receive(
clientFrame({ type: 'notify', name: 'storage', key: 'orca:pins:host-a', value: '["two"]' })
)
expect(bridge.storageWrites).toEqual([{ key: 'orca:pins:host-a', value: '["two"]' }])
})
it('reads the keys again for each init, rather than replaying what it started with', () => {
let pins = '["one"]'
const bridge = harness({ readStorage: () => ({ 'orca:pins:host-a': pins }) })
const bridge = harness({
readStorage: () => ({ storage: { 'orca:pins:host-a': pins }, storageOversize: [] })
})
bridge.host.receive(clientFrame({ type: 'ready' }))
pins = '["one","two"]'
// The document that reloads inside one mount asks again, and has to be primed from after its
@@ -6,6 +6,7 @@ import {
BRIDGE_FAULT_GRANT,
BRIDGE_NAVIGATE_BACK_NOTIFY
} from './bridge/bridge-envelope'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update'
import {
BRIDGE_HAPTICS_GRANT,
BRIDGE_HAPTICS_KINDS,
@@ -413,3 +414,56 @@ describe('haptics', () => {
expect(init.type === 'init' && init.grants.native).not.toContain(BRIDGE_HAPTICS_NOTIFY)
})
})
describe('the page erasing a one-shot route param', () => {
/**
* The reader erasing its own request (ruling 34). One page-to-shell frame, carried up to
* whoever holds the param; the comparison is theirs, so the host forwards both values as sent.
*/
it('carries a page clear up with the param and the value it named', () => {
const bridge = harness({ route: { pathname: '/h/host-a/session/wt-1' } })
bridge.host.receive(clientFrame({ type: 'ready' }))
bridge.host.receive(
clientFrame({
type: 'notify',
name: BRIDGE_ROUTE_PARAM_CLEAR,
param: 'paneKey',
value: 'p-1'
})
)
expect(bridge.routeParamClears()).toEqual([{ param: 'paneKey', value: 'p-1' }])
})
it('carries no clear up from a page that has not asked for a session', () => {
const bridge = harness({ route: { pathname: '/h/host-a/session/wt-1' } })
bridge.host.receive(
clientFrame({
type: 'notify',
name: BRIDGE_ROUTE_PARAM_CLEAR,
param: 'paneKey',
value: 'p-1'
})
)
expect(bridge.routeParamClears()).toEqual([])
expect(bridge.diagnostics).toEqual([
{ kind: 'notify-refused', name: BRIDGE_ROUTE_PARAM_CLEAR, why: 'before-ready' }
])
})
it('refuses a clear for a param the page may not erase', () => {
const bridge = harness({ route: { pathname: '/h/host-a/session/wt-1' } })
bridge.host.receive(clientFrame({ type: 'ready' }))
bridge.host.receive(
clientFrame({ type: 'notify', name: BRIDGE_ROUTE_PARAM_CLEAR, param: 'name', value: 'x' })
)
expect(bridge.routeParamClears()).toEqual([])
expect(bridge.diagnostics).toEqual([{ kind: 'refused', refusal: 'unrecognised-message' }])
})
it('tells the page it takes a clear, so a page built for an older shell does not post one', () => {
const bridge = harness({ route: { pathname: '/h/host-a/session/wt-1' } })
bridge.host.receive(clientFrame({ type: 'ready' }))
const init = bridge.last()
expect(init.type === 'init' && init.accepts).toEqual([BRIDGE_ROUTE_PARAM_CLEAR])
})
})
@@ -0,0 +1,76 @@
import {
BridgeInitRouteSchema,
type BridgeClientMessage,
type BridgeInitRoute
} from './bridge/bridge-envelope'
import { readBridgeRouteUpdate } from './bridge/bridge-route-update'
/** The screen one host is serving, which is the one field of `init` that moves under a live page. */
export type BridgeHostRoute = {
/** Null when this shell named a screen the protocol does not allow; no session is served then. */
readonly current: () => BridgeInitRoute | null
/** Why the opened route was refused, for the line the host prints at construction. */
readonly openIssue: () => string
/** What the page's latest `ready` said it can be sent. Reset by each document's `ready`. */
readonly readReady: (message: Extract<BridgeClientMessage, { type: 'ready' }>) => void
/**
* Hands the page a rewritten param for the screen it is already on, and sends one `init` when
* that moved the route. The held route moves either way, so a page that reloads inside this
* mount is given the newest one on its next `ready` even when it is too old to be sent one in
* flight.
*
* Answers nothing, and nothing here learns anything from a post (ruling 34). It cannot: a frame
* the page received and then failed to handle is caught by the page and reported there, so what
* is left is a post refused because no document holds the view — the handle is gone, the session
* changed, or this host is closed. Every one of those is followed by a fresh document's `ready`,
* which is answered with the route held now, because the held route advances on `hold` as well
* as on `send`. The request that route carries is spent by the page, which erases the param.
*/
readonly publish: (next: BridgeInitRoute, deliverable: boolean) => void
}
/**
* One host's route, parsed once and reassigned only by `publish`.
*
* Parsed here against the same schema the page reads it with, rather than trusted. The producer
* interpolates a host id into a pathname, so a host id carrying `?`, `#`, whitespace or a dot
* segment reaches the wire as a route no page will accept; without this the page refuses the whole
* `init`, asks again on its backoff forever, and the shell un-hides a WebView that will never
* paint.
*
* Its own module because the route stopped being a constant (ruling 33.1): a notification tap for
* another pane of the session on screen rewrites one param of a page that is already mounted, and
* what the host may do about that is a decision with four inputs rather than a field. It holds no
* more than that (ruling 34): what the page has received is the page's business, and the only
* thing here that outlives a call is the route the shell is on.
*/
export function createBridgeHostRoute(args: {
/** What the shell asked for, unparsed. */
opened: BridgeInitRoute
/** True when the pair beside the route was itself refused; then no session is served either. */
refused: boolean
sendInit: () => void
onRefused: (issue: string) => void
}): BridgeHostRoute {
const parsed = BridgeInitRouteSchema.safeParse(args.opened)
let route = parsed.success && !args.refused ? parsed.data : null
let accepts: readonly string[] = []
return {
current: () => route,
openIssue: () => (parsed.success ? 'unknown' : (parsed.error.issues[0]?.message ?? 'unknown')),
readReady: (message) => {
accepts = message.accepts ?? []
},
publish: (next, deliverable) => {
const update = readBridgeRouteUpdate({ held: route, next, accepts, deliverable })
if (update.kind === 'refuse') {
args.onRefused(update.issue)
return
}
route = update.route
if (update.kind === 'send') {
args.sendInit()
}
}
}
}
@@ -22,6 +22,7 @@ import {
} from './bridge/bridge-envelope'
import type { TerminalBacklogTimers } from './bridge-terminal-output-backlog'
import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import type { PageStorageForInit } from './page-storage-keys'
export const ID = bridgeId(1)
export const OTHER = bridgeId(2)
@@ -44,6 +45,10 @@ export type Harness = {
backPops: BridgeNavigateBackOutcome[]
storageWrites: { key: string; value: string | null }[]
pageReadyCount: () => number
/** One entry per `ready` answered, saying whether its `init` reached the page. Filled as each
* post settles, so a case reads it after awaiting the turn the post resolves on. */
/** Every clear the page asked for, in order. */
routeParamClears: () => readonly { param: string; value: string }[]
routeRefusals: string[]
pageFaults: BridgeErrorCapture[]
frames: () => BridgeHostMessage[]
@@ -68,7 +73,7 @@ export function harness(
onNavigateBack?: () => BridgeNavigateBackOutcome
storage?: Readonly<Record<string, string>>
/** For the suites that need the map to change between two `init` answers. */
readStorage?: () => Readonly<Record<string, string>>
readStorage?: () => PageStorageForInit
onPageFault?: (error: BridgeErrorCapture) => void
/**
* Whether to answer a `ready` before the case runs, which is what a real page does first: the
@@ -100,6 +105,8 @@ export function harness(
const backPops: BridgeNavigateBackOutcome[] = []
const storageWrites: { key: string; value: string | null }[] = []
let pageReadies = 0
/** One entry per `ready` answered, saying whether an `init` actually went out for it. */
const routeParamClears: { param: string; value: string }[] = []
const routeRefusals: string[] = []
const pageFaults: BridgeErrorCapture[] = []
const droppedBinaryFrames: number[] = []
@@ -117,11 +124,13 @@ export function harness(
routeGrants: options.routeGrants ?? MOBILE_WEB_SHELL_GRANTS,
sessionEstablished: options.sessionEstablished ?? false,
host: HOST,
readStorage: options.readStorage ?? (() => options.storage ?? {}),
readStorage:
options.readStorage ?? (() => ({ storage: options.storage ?? {}, storageOversize: [] })),
onStorageWrite: (key, value) => storageWrites.push({ key, value }),
onPageReady: () => {
pageReadies += 1
},
onRouteParamClear: (param, value) => routeParamClears.push({ param, value }),
onRouteRefused: (issue) => routeRefusals.push(issue),
onNavigate: options.onNavigate ?? ((href) => navigations.push(href)),
onExternalLink: (url) => externalLinks.push(url),
@@ -177,6 +186,7 @@ export function harness(
backPops,
storageWrites,
pageReadyCount: () => pageReadies,
routeParamClears: () => routeParamClears,
routeRefusals,
pageFaults,
frames,
+83 -78
View File
@@ -10,19 +10,20 @@ import {
BRIDGE_FAULT_GRANT,
BRIDGE_NAVIGATE_BACK_NOTIFY,
BRIDGE_PROTOCOL_VERSION,
BridgeInitRouteSchema,
readBridgeClientMessage,
type BridgeClientMessage,
type BridgeConnectionSnapshot,
type BridgeHostMessage
type BridgeInitRoute
} from './bridge/bridge-envelope'
import { BridgePageRouteGrantsSchema } from './bridge/bridge-page-route-grants'
import { captureBridgeError } from './bridge/bridge-error-capture'
import { createBridgeInitFrame } from './bridge/bridge-init-frame'
import { BRIDGE_HAPTICS_NOTIFY } from './bridge/bridge-haptics-notify'
import { bridgeNotifyRefusal } from './bridge/bridge-notify-grants'
import { splitBridgeReply } from './bridge/bridge-reply-chunking'
import { isPageStorageKeyForHost } from './page-storage-keys'
import { pageMayWriteStorageKey } from './page-storage-keys'
import { createBridgeHostFrames } from './bridge-host-frames'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update'
import { createBridgeHostRoute } from './bridge-host-route'
import type { BridgeHostOptions } from './bridge-host-contract'
// Re-exported so a caller reaches the host and what it reports through one module.
@@ -32,6 +33,19 @@ type NotifyMessage = Extract<BridgeClientMessage, { type: 'notify' }>
export type BridgeHost = {
receive: (json: string) => void
/**
* Hands this session a rewritten route: same screen, different params (ruling 33.1).
*
* The held route moves either way, so a page that reloads inside this mount is told the newest
* one; the frame goes out only to a page that declared `BRIDGE_ROUTE_UPDATE_ACCEPT`, because a
* page too old to name it reads a second `init` as a replacement. A different pathname is a
* different screen and is refused here — that is a remount, which is what the shell already does.
*
* Answers nothing, and nothing is tracked (ruling 34): a frame the view refused is repaired by
* the next `init`, and the request it carried is spent by the page, which erases the param it
* applied.
*/
publishRoute: (next: BridgeInitRoute) => void
dispose: () => void
}
@@ -47,11 +61,6 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
const { client, buildId, sessionId, pageRoutes, host } = options
// The protocol's own grant rides with every session; the rest is what this route asked for.
const granted: readonly string[] = [BRIDGE_FAULT_GRANT, ...options.routeGrants]
// Parsed here, once, against the same schema the page reads it with. The producer interpolates a
// host id into a pathname, so a host id carrying `?`, `#`, whitespace or a dot segment reaches
// the wire as a route no page will accept; without this the page refuses the whole `init`, asks
// again on its backoff forever, and the shell un-hides a WebView that will never paint.
const parsedRoute = BridgeInitRouteSchema.safeParse(options.route)
// Checked here for the reason the route is: a pair the page's reader would refuse takes the whole
// `init` with it, and a session that never gets one is worse than one that never starts.
const parsedRouteGrants =
@@ -62,7 +71,14 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
parsedRouteGrants !== null && !parsedRouteGrants.success
? (parsedRouteGrants.error.issues[0]?.message ?? 'unknown')
: null
const route = parsedRoute.success && routeGrantsIssue === null ? parsedRoute.data : null
const routes = createBridgeHostRoute({
opened: options.route,
refused: routeGrantsIssue !== null,
sendInit: () => {
sendInit()
},
onRefused: (issue) => options.onDiagnostic?.({ kind: 'route-update-refused', issue })
})
let closed = false
// One document's turn at the bridge. `close` ends it and the next `ready` begins the next one;
// between the two the view belongs to no document, so nothing is served and nothing is posted.
@@ -75,47 +91,14 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
// Seeded from the session rather than started false: this host may be a rebuild taking over a
// session that handshook with the one before it.
let initSent = options.sessionEstablished
let postFailureReported = false
let notifyFailureReported = false
// Once per session: a page that cannot be posted to fails every frame after the first, and a
// line per frame buries the one that says why.
function reportPostFailure(error: unknown): void {
if (postFailureReported) {
return
}
postFailureReported = true
options.onDiagnostic?.({ kind: 'post-failed', error })
}
function sendJson(json: string): void {
// Defensive: teardown already settles everything that could post; this fences callers added later.
if (closed) {
return
}
// Between documents the view still exists and still accepts posts, which is exactly why this is
// checked: a `state` frame sent now lands in the next document before it has said `ready`.
if (!serving) {
return
}
// A `post` that throws where it should reject would escape into the client's own state-change
// fan-out, which is what sends the `state` frame, and take the other listeners down with it.
try {
void options.post(json).catch(reportPostFailure)
} catch (error) {
reportPostFailure(error)
}
}
// Every value in a host frame has already been serialized by whoever produced it — a reply by
// `splitBridgeReply`, an error `code` by the capture's round trip — so this cannot throw.
function send(frame: BridgeHostMessage): void {
sendJson(JSON.stringify(frame))
}
function sendError(id: string, error: unknown): void {
send({ v: BRIDGE_PROTOCOL_VERSION, type: 'error', id, error: captureBridgeError(error) })
}
const frames = createBridgeHostFrames({
post: options.post,
isOpen: () => !closed && serving,
onDiagnostic: options.onDiagnostic
})
const { postJson, sendJson, send, sendError } = frames
const subscriptions = new BridgeHostSubscriptions({
client,
@@ -142,34 +125,44 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
}
/**
* Answered every time it is asked, with the keys read every time it is answered.
* Posts `init`, and answers nothing (ruling 34).
*
* A page that saw a `state` older than the one it holds recovers by asking again rather than by
* living with a cache it knows is wrong, and the same is true of its storage: a document that
* reloads inside one mount — which the fault path produces — would otherwise be primed from
* before its own writes, and `publishPageStorage` clears the page's cache to match.
* Sent every time it is asked for, with the keys read every time it is sent. A page that saw a
* `state` older than the one it holds recovers by asking again rather than by living with a
* cache it knows is wrong, and the same is true of its storage: a document that reloads inside
* one mount — which the fault path produces — would otherwise be primed from before its own
* writes, and `publishPageStorage` clears the page's cache to match.
*
* Synchronously, because the page refuses every member until `init` lands and the golden
* recorder mounts its screen in the same turn it drains one; an `init` that waited on a promise
* would change what the first render of every replay sees. The caller keeps the map current.
* The frame is built synchronously, because the page refuses every member until `init` lands and
* the golden recorder mounts its screen in the same turn it drains one; a frame whose contents
* waited on a promise would change what the first render of every replay sees.
*
* A refused route sends nothing at all, and a post the view would not take is one diagnostic and
* no further attempt: nothing here holds a frame, and nothing retries one.
*/
function sendInit(): void {
const route = routes.current()
if (route === null) {
return
}
initSent = true
send(
createBridgeInitFrame({
sessionId,
buildId,
connection: snapshot(),
route,
pageRoutes,
...(parsedRouteGrants?.success === true ? { pageRouteGrants: parsedRouteGrants.data } : {}),
granted,
host,
storage: options.readStorage()
})
void postJson(
JSON.stringify(
createBridgeInitFrame({
sessionId,
buildId,
connection: snapshot(),
route,
pageRoutes,
...(parsedRouteGrants?.success === true
? { pageRouteGrants: parsedRouteGrants.data }
: {}),
granted,
accepts: [BRIDGE_ROUTE_PARAM_CLEAR],
host,
...options.readStorage()
})
)
)
}
@@ -263,7 +256,11 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
// Also local, and held to this host's own keys. The envelope allowlists the shape before
// this runs, which lets `orca:pins:<any host>` through: a page opened for one host must
// not rewrite another's pinned list, and the keys it was handed are the ones it may write.
if (!isPageStorageKeyForHost(message.key, host.id)) {
// Three refusals in one, decided where the keys are (ruling 33.6): the oversize half has
// to be enforced here because a page served from an older desktop bundle does not read
// `storageOversize` and would write the key whole over what the device holds.
const held = options.readStorage()
if (!pageMayWriteStorageKey(message.key, host.id, routes.current(), held)) {
options.onDiagnostic?.({ kind: 'storage-refused', key: message.key })
return
}
@@ -276,6 +273,13 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
options.onHaptic(message.kind)
return
}
if (message.name === BRIDGE_ROUTE_PARAM_CLEAR) {
// Local, and the one frame that writes to the shell's own route (ruling 34). Carried up
// rather than acted on here: the param lives on the native route the switch holds, and
// whether this still names it is that holder's comparison to make.
options.onRouteParamClear(message.param, message.value)
return
}
client.updateTerminalSubscriptionViewport(message.terminal, {
cols: message.cols,
rows: message.rows
@@ -312,9 +316,11 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
// re-asked `ready` from the document already being served is answered the same way.
if (message.type === 'ready') {
serving = true
routes.readReady(message)
// Every time it is asked, not once: the page re-asks on a backoff, and each ask is answered
// with the route the shell holds now. That is the whole repair path for a frame that never
// arrived (ruling 34) — nothing here waits on one, and nothing retries one.
sendInit()
// Every time it is asked, not once: the page re-asks on a backoff, and the shell's wait ends
// on the first of those that lands rather than on a particular one.
options.onPageReady()
return
}
@@ -365,14 +371,10 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
send({ v: BRIDGE_PROTOCOL_VERSION, type: 'state', connection: snapshot(state) })
})
if (route === null) {
if (routes.current() === null) {
// At construction rather than on the first `ready`: the verdict does not depend on the page
// behaving, and a shell that waited for a frame would hold a blank view until one arrived.
const issue = routeGrantsIssue
? `pageRouteGrants: ${routeGrantsIssue}`
: parsedRoute.success
? 'unknown'
: (parsedRoute.error.issues[0]?.message ?? 'unknown')
const issue = routeGrantsIssue ? `pageRouteGrants: ${routeGrantsIssue}` : routes.openIssue()
options.onDiagnostic?.({ kind: 'route-refused', issue })
options.onRouteRefused(issue)
}
@@ -391,6 +393,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
}
dispatch(read.message)
},
publishRoute: (next) => {
routes.publish(next, serving && initSent)
},
dispose
}
}
@@ -137,6 +137,16 @@ export function isBridgeExternalLinkUrl(url: string): boolean {
return readBridgeExternalLinkUrl(url) !== null
}
export const BRIDGE_MAX_PAGE_ROUTES = 64
/**
* What a page may say it can be sent, bounded the way every other list on the envelope is.
*
* A frame bound and not a vocabulary: the shell acts on the names it knows and ignores the rest,
* which is what lets a newer page declare one an older shell has never heard of.
*/
export const BRIDGE_MAX_PAGE_ACCEPTS = 16
export const BRIDGE_MAX_PAGE_ACCEPT_CHARS = 64
/** A host id, its name and its endpoint. Bounded because the page renders all three. */
export const BRIDGE_MAX_HOST_FIELD_CHARS = 1024
@@ -19,3 +19,13 @@ export type BridgeRpcClientDiagnostic =
| { kind: 'state-out-of-order' }
| { kind: 'binary-frame-dropped' }
| { kind: 'unknown-id' }
/**
* A frame the page received and then failed to handle: one of its own listeners threw.
*
* Not a lost frame, and not the channel's failure. On iOS the host's post is
* `callAsyncJavaScript`, so a throw out of the page's synchronous `onmessage` rejects the post
* with the document still mounted — which the shell would read as a frame that never arrived,
* and the shell tracks nothing about posts (ruling 34). The page had it, so the page says so,
* and nothing is retried: the listener would throw on the same frame again.
*/
| { kind: 'inbound-listener-threw'; error: unknown }
@@ -27,6 +27,17 @@ export type BridgeShellSession = {
host: BridgeInitHost | null
/** The allowlisted keys as the app held them when this page opened. */
storage: Readonly<Record<string, string>>
/** The allowlisted keys `storage` could not carry because the app's value is over the page's cap
* (ruling 33.6). Empty for a shell too old to name them, which is what it was before. */
storageOversize: readonly string[]
/**
* What this shell takes from the page beyond the frames every shell has taken (ruling 34).
*
* Empty for a shell that named none, which is every shell before this field: a page that posts
* one of these to one of those has the whole frame refused as `unrecognised-message`, so the
* check is the page's and it is made from here.
*/
accepts: readonly string[]
}
/**
@@ -46,6 +57,8 @@ export function readShellSession(
pageRoutes: message.pageRoutes ?? [],
pageRouteGrants: message.pageRouteGrants ?? null,
host: message.host ?? null,
storage: message.storage ?? {}
storage: message.storage ?? {},
storageOversize: message.storageOversize ?? [],
accepts: message.accepts ?? []
}
}
@@ -0,0 +1,96 @@
import { readShellSession, type BridgeShellSession } from './bridge-client-session'
import { bridgeRouteMoved } from './bridge-route-update'
import type {
BridgeConnectionSnapshot,
BridgeHostMessage,
BridgeInitRoute
} from './bridge-envelope'
type InitMessage = Extract<BridgeHostMessage, { type: 'init' }>
/**
* The session the page holds, and who it tells when `init` changes it.
*
* Its own module because it is the one piece of the client with a lifecycle rather than a value:
* a second `init` for the same session updates it in place, a different one replaces it and takes
* the requests and streams of the session before it, and each case has its own listeners to fire
* in its own order. The client keeps the frames and the ports; this keeps what they are for.
*/
export type BridgeClientShellSession = {
current: () => BridgeShellSession | null
/** Reads one `init`: updates or replaces the session, then fires what that change is owed. */
accept: (message: InitMessage) => void
/** Fires once the session exists, immediately if it already does. */
onReady: (listener: () => void) => () => void
/** Fires when a second `init` for the session the page holds moved its route, and never else. */
onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void
/** Drops the session and every listener waiting on one, which `close` is the only caller of. */
close: () => void
}
export function createBridgeClientShellSession(args: {
/** The shell was rebuilt: everything the old session had in flight belongs to nobody now. */
onReplaced: () => void
/** Seats the connection cache from the frame, between the session moving and the listeners. */
prime: (connection: BridgeConnectionSnapshot) => void
}): BridgeClientShellSession {
let session: BridgeShellSession | null = null
const readyListeners = new Set<() => void>()
const routeUpdateListeners = new Set<(route: BridgeInitRoute | null) => void>()
return {
current: () => session,
accept: (message) => {
const held = session
const update = held !== null && held.sessionId === message.sessionId ? held : null
if (held !== null && update === null) {
args.onReplaced()
}
// Updated in place for the session the page already holds, rebuilt for a different one. The
// identity of what survives is the assertion: same object, so the storage snapshot the page
// booted from is the one it keeps.
session =
update === null ? readShellSession(message) : { ...update, route: message.route ?? null }
// Re-primed either way, because a second `init` is also how the page recovers a cache it has
// refused a `state` frame into: the shell rebuilt under it publishes a generation the page's
// own is newer than, and this frame is what puts the two back in step. A pane update carries
// the same snapshot it already holds, which `prime` answers with no transition.
args.prime(message.connection)
for (const listener of readyListeners) {
listener()
}
readyListeners.clear()
// Only a route that moved is an update. The shell answers every `ready` with the route it
// holds, and the page re-asks after a refused `state` frame, so publishing each of those
// would hand the pane hook the route it is already on.
if (update === null || !bridgeRouteMoved(update.route, session.route)) {
return
}
for (const listener of routeUpdateListeners) {
listener(session.route)
}
},
onReady: (listener) => {
if (session !== null) {
listener()
return () => undefined
}
readyListeners.add(listener)
return () => {
readyListeners.delete(listener)
}
},
// Not fired on subscribe, and no replay: a listener that arrives late reads the route it wants
// off `current`, and what this publishes is the fact that one moved.
onRouteUpdate: (listener) => {
routeUpdateListeners.add(listener)
return () => {
routeUpdateListeners.delete(listener)
}
},
close: () => {
session = null
readyListeners.clear()
}
}
}
@@ -2,8 +2,19 @@ import { z } from 'zod'
import { isRpcResponse } from '../../transport/rpc-response-shape'
import type { RpcResponse } from '../../transport/types'
import { BridgeErrorCaptureSchema } from './bridge-error-capture'
import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify'
import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route'
import { BridgePageRouteGrantsSchema } from './bridge-page-route-grants'
import { BridgeNotifySchema } from './bridge-notify-envelope'
import { BRIDGE_ID_PATTERN, idSchema, methodSchema, versionSchema } from './bridge-frame-fields'
export {
BRIDGE_EXTERNAL_LINK_GRANT,
BRIDGE_FAULT_GRANT,
BRIDGE_FOREGROUND_NUDGE_REASONS,
BRIDGE_ID_PATTERN,
BRIDGE_NAVIGATE_BACK_NOTIFY,
BRIDGE_PROTOCOL_VERSION
} from './bridge-frame-fields'
import {
isPageStorageKey,
PAGE_STORAGE_MAX_ENTRIES,
@@ -11,54 +22,17 @@ import {
PAGE_STORAGE_MAX_VALUE_CHARS
} from '../page-storage-keys'
import {
BRIDGE_MAX_METHOD_CHARS,
BRIDGE_MAX_PAGE_ACCEPT_CHARS,
BRIDGE_MAX_PAGE_ACCEPTS,
BRIDGE_MAX_PAGE_ROUTES,
BRIDGE_MAX_REPLY_PARTS,
BRIDGE_MAX_EXTERNAL_LINK_CHARS,
BRIDGE_MAX_ROUTE_HREF_CHARS,
BRIDGE_MAX_ROUTE_PARAM_CHARS,
BRIDGE_MAX_ROUTE_PARAMS,
BRIDGE_MAX_ROUTE_PATHNAME_CHARS,
BRIDGE_MAX_VIEWPORT_COLS,
BRIDGE_MAX_VIEWPORT_ROWS,
BRIDGE_MAX_HOST_FIELD_CHARS,
BRIDGE_ROUTE_HREF_PATTERN,
BRIDGE_ROUTE_PATHNAME_PATTERN,
isBridgeExternalLinkUrl,
parseBridgeMessage,
type BridgeDirection,
type BridgeRead
} from './bridge-caps'
/**
* Every message the page and the shell exchange, in both directions.
*
* `v` gates envelope shape and nothing else: capability is gated by `init.grants`, so a shell that
* learns a new native grant never bumps it. Unknown keys are dropped rather than refused, because
* the page bundle is served by a desktop that updates independently of the installed shell, and an
* additive field must not take a working pair offline. The rule, in one line: `v` gates
* incompatible shape; additive fields never bump `v`.
*
* A new member of a closed list is NOT an additive field. `end.reason`, `binary.format`,
* `connection.state` and the foreground reasons are enumerated here, so a value outside the list
* takes the whole frame down as `unrecognised-message` on the older side. Adding one is a
* compatibility change: it has to be negotiated, the way a new opcode is, not shipped on the
* strength of the reader dropping what it does not know.
*
* The two readers differ in more than their schema: the page's traffic is held to the document
* caps, the shell's answers are not. `parseBridgeMessage` documents why.
*/
export const BRIDGE_PROTOCOL_VERSION = 1
/** Correlation ids are minted by whichever side opens the exchange; 22 chars is 128 bits of base64url. */
export const BRIDGE_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/
const versionSchema = z.literal(BRIDGE_PROTOCOL_VERSION)
const idSchema = z.string().regex(BRIDGE_ID_PATTERN)
// Length only: the desktop's mobile-scope allowlist decides which names exist, and a charset guess
// here would refuse a method that allowlist already permits.
const methodSchema = z.string().min(1).max(BRIDGE_MAX_METHOD_CHARS)
/** Closed against `ConnectionState`; the pin lives in this module's test. */
export const BRIDGE_CONNECTION_STATES = [
'connecting',
@@ -72,9 +46,6 @@ export const BRIDGE_CONNECTION_STATES = [
/** Closed against `BrowserScreencastFormat`; the pin lives in this module's test. */
export const BRIDGE_BINARY_FORMATS = ['jpeg', 'png'] as const
/** Closed against `ForegroundNudgeReason`; the pin lives in this module's test. */
export const BRIDGE_FOREGROUND_NUDGE_REASONS = ['focus', 'app-resume', 'network-change'] as const
/**
* What the page's synchronous `RpcClient` getters read. It travels whole rather than as deltas so a
* dropped frame cannot leave the cache half-applied, and `generation` is what lets the page notice
@@ -102,33 +73,9 @@ export const BridgeGrantsSchema = z.object({
export type BridgeGrants = z.infer<typeof BridgeGrantsSchema>
/**
* Which screen the shell opened this page for.
*
* Additive, and optional for that reason: a shell built before C1.2 sends no `route`, and the page
* says so rather than painting expo-router's Unmatched screen. It has to cross, because the
* document is served at `/` and refuses every other path, so the page's own location matches no
* route in the tree it carries and there is nothing else to derive the screen from.
*
* `params` is the search half, kept out of `pathname` so neither side has to parse a URL: the page
* builds one, once, and writes it into its history before the first render.
*/
export const BridgeInitRouteSchema = z.object({
pathname: z
.string()
.min(1)
.max(BRIDGE_MAX_ROUTE_PATHNAME_CHARS)
.regex(BRIDGE_ROUTE_PATHNAME_PATTERN),
params: z
.record(
z.string().min(1).max(BRIDGE_MAX_ROUTE_PARAM_CHARS),
z.string().max(BRIDGE_MAX_ROUTE_PARAM_CHARS)
)
.refine((params) => Object.keys(params).length <= BRIDGE_MAX_ROUTE_PARAMS)
.optional()
})
export type BridgeInitRoute = z.infer<typeof BridgeInitRouteSchema>
// The route half of `init`, in its own module; re-exported so the envelope stays one import for
// everything that reads a bridge frame.
export { BridgeInitRouteSchema, type BridgeInitRoute }
/**
* The host the shell opened this page for, minus everything secret about it.
@@ -155,36 +102,6 @@ export const BridgeInitStorageSchema = z
)
.refine((entries) => Object.keys(entries).length <= PAGE_STORAGE_MAX_ENTRIES)
/**
* The one grant negotiated for the protocol itself rather than for a screen: the shell saying it
* will act on a `fault` report.
*
* It exists because `notify` is a closed list on both sides. A page served by a newer desktop into
* an older shell that posted an unknown name would have the whole frame refused as
* `unrecognised-message`, so the page asks first and stays quiet when the answer is no.
*/
export const BRIDGE_FAULT_GRANT = 'fault'
/**
* The `navigate` grant's second verb, and the first notify whose name is not its grant's.
*
* The page is served at `/` with one history entry written by `replaceState`, so its own Back goes
* nowhere: the only stack to pop is the native one the shell pushed the page onto. It rides
* `navigate` rather than a name of its own because an app that can open a screen can close one, and
* a new grant name would leave every route that declares it native on every shell already shipped.
*/
export const BRIDGE_NAVIGATE_BACK_NOTIFY = 'navigate-back'
/**
* The grant a page needs before the shell will open anything outside it.
*
* Its own name rather than a verb of `navigate`, because it is a different capability: `navigate`
* opens a screen this app carries, and this hands a URL to whatever the device opens it with. A
* shell that implements one and not the other is a real shell, and the route policy has to be able
* to say so.
*/
export const BRIDGE_EXTERNAL_LINK_GRANT = 'externalLink'
/** Pinned against `SendRequestOptions` in this module's test. */
export const BridgeSendRequestOptionsSchema = z.object({
timeoutMs: z.number().int().positive().optional(),
@@ -231,7 +148,23 @@ const replyPartSchema = z.object({
})
const BridgeClientMessageSchema = z.discriminatedUnion('type', [
z.object({ v: versionSchema, type: z.literal('ready') }),
z.object({
v: versionSchema,
type: z.literal('ready'),
/**
* What this page can be sent beyond its first `init`; the names and why live in
* `bridge-route-update.ts`, which is the only one there is.
*
* Optional, and safe in both directions without a version bump: a page that sends none is
* never sent a second `init`, and a shell that reads none never sends one. An unknown name is
* accepted by the schema and ignored by the shell, which is what a newer page declaring a
* capability this shell has never implemented has to look like.
*/
accepts: z
.array(z.string().min(1).max(BRIDGE_MAX_PAGE_ACCEPT_CHARS))
.max(BRIDGE_MAX_PAGE_ACCEPTS)
.optional()
}),
z.object({
v: versionSchema,
type: z.literal('request'),
@@ -262,66 +195,7 @@ const BridgeClientMessageSchema = z.discriminatedUnion('type', [
id: idSchema,
seq: z.number().int().nonnegative()
}),
z.discriminatedUnion('name', [
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('foreground'),
reason: z.enum(BRIDGE_FOREGROUND_NUDGE_REASONS).optional()
}),
// Behind the `navigate` grant, and that is not a convention: this union is closed, so an older
// shell refuses the whole frame as `unrecognised-message`. The page checks `grants.native`
// before it posts, which is what a grant is for.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('navigate'),
href: z.string().min(1).max(BRIDGE_MAX_ROUTE_HREF_CHARS).regex(BRIDGE_ROUTE_HREF_PATTERN)
}),
// Behind the same `navigate` grant, and carrying no target: the shell pops what it pushed, and
// a page naming where to go back to would be naming a screen it cannot see.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_NAVIGATE_BACK_NOTIFY)
}),
// Behind the `externalLink` grant. The URL is held to the same three schemes on both sides: the
// page refuses at the call site so a tap knows it went nowhere, and this refuses the frame so a
// page that did not check is still held to it.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_EXTERNAL_LINK_GRANT),
url: z.string().min(1).max(BRIDGE_MAX_EXTERNAL_LINK_CHARS).refine(isBridgeExternalLinkUrl)
}),
// Behind the `storage` grant, for the same reason `navigate` is behind its own.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('storage'),
key: z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS).refine(isPageStorageKey),
/** Null removes it, which is what `AsyncStorage.removeItem` does. */
value: z.string().max(PAGE_STORAGE_MAX_VALUE_CHARS).nullable()
}),
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('terminalViewport'),
terminal: z.string().min(1),
cols: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_COLS),
rows: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_ROWS)
}),
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_FAULT_GRANT),
/** The capture an `error` frame already carries, so both directions share one bound and one
* reader. Nothing is owed back: the page is telling the shell, not asking it. */
error: BridgeErrorCaptureSchema
}),
// Behind the `haptics` grant, and the fields are its own module's for the reason stated there.
z.object({ v: versionSchema, ...BRIDGE_HAPTICS_NOTIFY_FIELDS })
]),
BridgeNotifySchema,
z.object({ v: versionSchema, type: z.literal('close') })
])
@@ -392,6 +266,22 @@ const BridgeHostMessageSchema = z.union([
route: BridgeInitRouteSchema.optional(),
host: BridgeInitHostSchema.optional(),
storage: BridgeInitStorageSchema.optional(),
/**
* The allowlisted keys the shell holds a value for that `storage` could not carry, because the
* app's value is over the page's own cap (ruling 33.6).
*
* Advisory, not the enforcement. The shell refuses a write to one of these on its own side
* too, because a page served from an older desktop bundle ignores this field entirely and
* would still replace what the device holds; this is the page's fast path, so a write it can
* refuse locally rejects without a round trip and reaches its caller as `too-large`.
*
* Optional and additive: an older shell sends none and an older page ignores it. Bounded by
* the same count as the storage record, since it names a subset of the same keys.
*/
storageOversize: z
.array(z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS).refine(isPageStorageKey))
.max(PAGE_STORAGE_MAX_ENTRIES)
.optional(),
/** Every route pattern the shell would render from the page. The page keeps a navigation into
* one of them and hands the rest back, which is the only thing that tells it which is which. */
pageRoutes: z
@@ -410,7 +300,19 @@ const BridgeHostMessageSchema = z.union([
* behaviour, an older page ignores it. The grant grammar is the manifest's own, so a name the
* bundle could not have declared cannot arrive here either.
*/
pageRouteGrants: BridgePageRouteGrantsSchema.optional()
pageRouteGrants: BridgePageRouteGrantsSchema.optional(),
/**
* What this shell accepts from the page beyond the frames every shell has always taken, which
* today is `BRIDGE_ROUTE_PARAM_CLEAR` and nothing else.
*
* The mirror of `ready.accepts`, and optional for the same reason: a shell that sends none is
* never posted a clear, and a page that reads none never posts one. An unknown name is a
* capability this page has never heard of and is ignored.
*/
accepts: z
.array(z.string().min(1).max(BRIDGE_MAX_PAGE_ACCEPT_CHARS))
.max(BRIDGE_MAX_PAGE_ACCEPTS)
.optional()
})
])
@@ -0,0 +1,73 @@
import { z } from 'zod'
import { BRIDGE_MAX_METHOD_CHARS } from './bridge-caps'
/**
* What every bridge frame is keyed on: its version, its correlation id, and the grant names the
* two halves of the envelope both spell.
*
* Its own module because both halves need them and neither may import the other: the page-to-shell
* union lives in `bridge-notify-envelope.ts` and the rest in `bridge-envelope.ts`, which re-exports
* everything here so no caller has to know where a name is declared.
*/
/**
* Every message the page and the shell exchange, in both directions.
*
* `v` gates envelope shape and nothing else: capability is gated by `init.grants`, so a shell that
* learns a new native grant never bumps it. Unknown keys are dropped rather than refused, because
* the page bundle is served by a desktop that updates independently of the installed shell, and an
* additive field must not take a working pair offline. The rule, in one line: `v` gates
* incompatible shape; additive fields never bump `v`.
*
* A new member of a closed list is NOT an additive field. `end.reason`, `binary.format`,
* `connection.state` and the foreground reasons are enumerated here, so a value outside the list
* takes the whole frame down as `unrecognised-message` on the older side. Adding one is a
* compatibility change: it has to be negotiated, the way a new opcode is, not shipped on the
* strength of the reader dropping what it does not know.
*
* The two readers differ in more than their schema: the page's traffic is held to the document
* caps, the shell's answers are not. `parseBridgeMessage` documents why.
*/
export const BRIDGE_PROTOCOL_VERSION = 1
/** Correlation ids are minted by whichever side opens the exchange; 22 chars is 128 bits of base64url. */
export const BRIDGE_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/
export const versionSchema = z.literal(BRIDGE_PROTOCOL_VERSION)
export const idSchema = z.string().regex(BRIDGE_ID_PATTERN)
// Length only: the desktop's mobile-scope allowlist decides which names exist, and a charset guess
// here would refuse a method that allowlist already permits.
export const methodSchema = z.string().min(1).max(BRIDGE_MAX_METHOD_CHARS)
/** Closed against `ForegroundNudgeReason`; the pin lives in this module's test. */
export const BRIDGE_FOREGROUND_NUDGE_REASONS = ['focus', 'app-resume', 'network-change'] as const
/**
* The one grant negotiated for the protocol itself rather than for a screen: the shell saying it
* will act on a `fault` report.
*
* It exists because `notify` is a closed list on both sides. A page served by a newer desktop into
* an older shell that posted an unknown name would have the whole frame refused as
* `unrecognised-message`, so the page asks first and stays quiet when the answer is no.
*/
export const BRIDGE_FAULT_GRANT = 'fault'
/**
* The `navigate` grant's second verb, and the first notify whose name is not its grant's.
*
* The page is served at `/` with one history entry written by `replaceState`, so its own Back goes
* nowhere: the only stack to pop is the native one the shell pushed the page onto. It rides
* `navigate` rather than a name of its own because an app that can open a screen can close one, and
* a new grant name would leave every route that declares it native on every shell already shipped.
*/
export const BRIDGE_NAVIGATE_BACK_NOTIFY = 'navigate-back'
/**
* The grant a page needs before the shell will open anything outside it.
*
* Its own name rather than a verb of `navigate`, because it is a different capability: `navigate`
* opens a screen this app carries, and this hands a URL to whatever the device opens it with. A
* shell that implements one and not the other is a real shell, and the route policy has to be able
* to say so.
*/
export const BRIDGE_EXTERNAL_LINK_GRANT = 'externalLink'
@@ -39,10 +39,16 @@ export function createBridgeInitFrame(args: {
pageRouteGrants?: readonly { pathname: string; grants: readonly string[] }[]
/** What this session may do: the protocol's own grant plus what its route declared. */
granted: readonly string[]
/** What this shell takes from the page beyond the frames every shell has taken (ruling 34). */
accepts?: readonly string[]
/** The host the page is showing, minus the credential the bridge already carries for it. */
host: BridgeInitHost
/** The allowlisted keys as the app holds them right now. */
storage: Readonly<Record<string, string>>
/** The allowlisted keys whose app-side value is over the page's cap, so `storage` has none
* (ruling 33.6). The page refuses its own writes to these rather than replacing the device's.
* Absent and empty are the same answer: nothing of the app's was left out. */
storageOversize?: readonly string[]
}): Extract<BridgeHostMessage, { type: 'init' }> {
return {
v: BRIDGE_PROTOCOL_VERSION,
@@ -61,6 +67,11 @@ export function createBridgeInitFrame(args: {
},
route: args.route,
pageRoutes: [...args.pageRoutes],
// Omitted when empty for the reason `storageOversize` is: a shell that declares nothing and
// one that declares an empty list are the same answer to the page's check.
...(args.accepts === undefined || args.accepts.length === 0
? {}
: { accepts: [...args.accepts] }),
// Copied entry by entry for the reason the grants are: nothing the shell keeps may be
// reachable through a frame it hands out.
...(args.pageRouteGrants === undefined
@@ -74,6 +85,11 @@ export function createBridgeInitFrame(args: {
host: args.host,
// Copied for the same reason the grants are: the frame is serialized straight after, and what
// the shell holds must not be reachable through what it hands out.
storage: { ...args.storage }
storage: { ...args.storage },
// Omitted when empty rather than sent as `[]`: a field nobody sent and a field sent empty are
// the same answer, and every golden in the corpus was recorded without it.
...(args.storageOversize === undefined || args.storageOversize.length === 0
? {}
: { storageOversize: [...args.storageOversize] })
}
}
@@ -0,0 +1,39 @@
import { z } from 'zod'
import {
BRIDGE_MAX_ROUTE_PARAM_CHARS,
BRIDGE_MAX_ROUTE_PARAMS,
BRIDGE_MAX_ROUTE_PATHNAME_CHARS,
BRIDGE_ROUTE_PATHNAME_PATTERN
} from './bridge-caps'
/**
* Which screen the shell opened this page for.
*
* Additive, and optional where it is read for that reason: a shell built before C1.2 sends no
* `route`, and the page says so rather than painting expo-router's Unmatched screen. It has to
* cross, because the document is served at `/` and refuses every other path, so the page's own
* location matches no route in the tree it carries and there is nothing else to derive it from.
*
* `params` is the search half, kept out of `pathname` so neither side has to parse a URL: the page
* builds one, once, and writes it into its history before the first render.
*
* Its own module rather than the envelope's, because three other modules want the route without
* the rest of the protocol: the native switches build one, the host holds one and republishes it
* (ruling 33.1), and the page reads one back out of `init`.
*/
export const BridgeInitRouteSchema = z.object({
pathname: z
.string()
.min(1)
.max(BRIDGE_MAX_ROUTE_PATHNAME_CHARS)
.regex(BRIDGE_ROUTE_PATHNAME_PATTERN),
params: z
.record(
z.string().min(1).max(BRIDGE_MAX_ROUTE_PARAM_CHARS),
z.string().max(BRIDGE_MAX_ROUTE_PARAM_CHARS)
)
.refine((params) => Object.keys(params).length <= BRIDGE_MAX_ROUTE_PARAMS)
.optional()
})
export type BridgeInitRoute = z.infer<typeof BridgeInitRouteSchema>
@@ -0,0 +1,107 @@
import { z } from 'zod'
import { BridgeErrorCaptureSchema } from './bridge-error-capture'
import { BRIDGE_HAPTICS_NOTIFY_FIELDS } from './bridge-haptics-notify'
import { BRIDGE_CLEARABLE_ROUTE_PARAMS, BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
import {
isPageStorageKey,
PAGE_STORAGE_MAX_KEY_CHARS,
PAGE_STORAGE_MAX_VALUE_CHARS
} from '../page-storage-keys'
import {
BRIDGE_MAX_EXTERNAL_LINK_CHARS,
BRIDGE_MAX_ROUTE_HREF_CHARS,
BRIDGE_MAX_ROUTE_PARAM_CHARS,
BRIDGE_MAX_VIEWPORT_COLS,
BRIDGE_MAX_VIEWPORT_ROWS,
BRIDGE_ROUTE_HREF_PATTERN,
isBridgeExternalLinkUrl
} from './bridge-caps'
import {
BRIDGE_EXTERNAL_LINK_GRANT,
BRIDGE_FAULT_GRANT,
BRIDGE_FOREGROUND_NUDGE_REASONS,
BRIDGE_NAVIGATE_BACK_NOTIFY,
versionSchema
} from './bridge-frame-fields'
/**
* Every one-way page-to-shell frame, as one closed union.
*
* Its own module because it is the half of the envelope that grows: a notify is what a page uses
* to tell the shell something it is owed no answer to, and each new one is a member here, a row in
* `bridge-notify-grants.ts` and a branch in the host. Closed on purpose in all three places — a
* name no shell implements is refused whole rather than half-served, which is what makes the
* page's own `grants` check the thing that keeps a newer page quiet on an older shell.
*/
export const BridgeNotifySchema = z.discriminatedUnion('name', [
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('foreground'),
reason: z.enum(BRIDGE_FOREGROUND_NUDGE_REASONS).optional()
}),
// Behind the `navigate` grant, and that is not a convention: this union is closed, so an older
// shell refuses the whole frame as `unrecognised-message`. The page checks `grants.native`
// before it posts, which is what a grant is for.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('navigate'),
href: z.string().min(1).max(BRIDGE_MAX_ROUTE_HREF_CHARS).regex(BRIDGE_ROUTE_HREF_PATTERN)
}),
// Behind the same `navigate` grant, and carrying no target: the shell pops what it pushed, and
// a page naming where to go back to would be naming a screen it cannot see.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_NAVIGATE_BACK_NOTIFY)
}),
// Behind the `externalLink` grant. The URL is held to the same three schemes on both sides: the
// page refuses at the call site so a tap knows it went nowhere, and this refuses the frame so a
// page that did not check is still held to it.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_EXTERNAL_LINK_GRANT),
url: z.string().min(1).max(BRIDGE_MAX_EXTERNAL_LINK_CHARS).refine(isBridgeExternalLinkUrl)
}),
// Behind the `storage` grant, for the same reason `navigate` is behind its own.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('storage'),
key: z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS).refine(isPageStorageKey),
/** Null removes it, which is what `AsyncStorage.removeItem` does. */
value: z.string().max(PAGE_STORAGE_MAX_VALUE_CHARS).nullable()
}),
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal('terminalViewport'),
terminal: z.string().min(1),
cols: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_COLS),
rows: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_ROWS)
}),
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_FAULT_GRANT),
/** The capture an `error` frame already carries, so both directions share one bound and one
* reader. Nothing is owed back: the page is telling the shell, not asking it. */
error: BridgeErrorCaptureSchema
}),
// Behind the `haptics` grant, and the fields are its own module's for the reason stated there.
z.object({ v: versionSchema, ...BRIDGE_HAPTICS_NOTIFY_FIELDS }),
// The reader erasing its own request (ruling 34). Ungranted, because it can only spend
// something this shell put on the route: the param is closed to the one the shell hands over,
// and the value is what the page applied — a shell holding a newer one ignores the frame.
z.object({
v: versionSchema,
type: z.literal('notify'),
name: z.literal(BRIDGE_ROUTE_PARAM_CLEAR),
param: z.enum(BRIDGE_CLEARABLE_ROUTE_PARAMS),
value: z.string().min(1).max(BRIDGE_MAX_ROUTE_PARAM_CHARS)
})
])
export type BridgeNotifyMessage = z.infer<typeof BridgeNotifySchema>
@@ -5,6 +5,7 @@ import {
type BridgeClientMessage
} from './bridge-envelope'
import { BRIDGE_HAPTICS_GRANT, BRIDGE_HAPTICS_NOTIFY } from './bridge-haptics-notify'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
/** Every `notify` name the envelope accepts, so the table below cannot be asked about another. */
export type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
@@ -31,6 +32,9 @@ export type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>[
const BRIDGE_NOTIFY_GRANTS: Readonly<Record<BridgeNotifyName, string | null>> = {
foreground: null,
terminalViewport: null,
// The protocol's own as well: it spends a request this shell handed the page, on a param closed
// to the one the shell hands over, so there is nothing here for a grant to gate.
[BRIDGE_ROUTE_PARAM_CLEAR]: null,
navigate: 'navigate',
[BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate',
storage: 'storage',
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcResponse } from '../../transport/types'
import { createBridgePortPair, createFakeBridgePortPair } from './bridge-port-pair-test-harness'
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
/** Every member of the contract, none of them a fake anything: the pair must carry a plain client. */
function plainShellClient(record: string[]): RpcClient {
@@ -39,7 +40,9 @@ describe('the bridge port pair', () => {
pageRoutes: expect.any(Array),
pageRouteGrants: null,
host: expect.objectContaining({ id: expect.any(String) }),
storage: expect.any(Object)
storage: expect.any(Object),
storageOversize: [],
accepts: [BRIDGE_ROUTE_PARAM_CLEAR]
})
})
@@ -55,6 +55,8 @@ export type BridgePortPair<TRpc extends RpcClient = FakeRpcClient> = {
pageFaults: BridgeErrorCapture[]
/** How many times the page asked for a session; it re-asks on a backoff until one lands. */
readonly pageReadyCount: () => number
/** Every clear the page asked the shell for, in order. */
readonly routeParamClears: () => readonly { param: string; value: string }[]
/** Why the host refused to open a session at all, if it did. */
readonly routeRefusals: string[]
/** Runs both lanes until a full round moves nothing. */
@@ -79,6 +81,8 @@ export type BridgePortPairOptions<TRpc extends RpcClient> = {
route?: BridgeInitRoute
pageRoutes?: readonly string[]
storage?: Readonly<Record<string, string>>
/** The allowlisted keys the app holds a value for that is over the page's cap (ruling 33.6). */
storageOversize?: readonly string[]
/**
* Rewrites each frame on its way to the page, for asking the page a counterfactual it cannot be
* asked any other way: would this run have gone differently had the shell sent one more field?
@@ -193,6 +197,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
const storageWrites: { key: string; value: string | null }[] = []
const pageFaults: BridgeErrorCapture[] = []
let pageReadies = 0
const routeParamClears: { param: string; value: string }[] = []
const routeRefusals: string[] = []
let receiveOnPage: ((json: string) => void) | null = null
@@ -226,12 +231,16 @@ export function createBridgePortPair<TRpc extends RpcClient>(
return 'popped'
},
host: { id: 'host-a', name: 'Host A', endpoint: 'ws://host-a', lastConnected: 0 },
readStorage: () => options.storage ?? {},
readStorage: () => ({
storage: options.storage ?? {},
storageOversize: options.storageOversize ?? []
}),
onStorageWrite: (key, value) => storageWrites.push({ key, value }),
onPageFault: (error) => pageFaults.push(error),
onPageReady: () => {
pageReadies += 1
},
onRouteParamClear: (param, value) => routeParamClears.push({ param, value }),
onRouteRefused: (issue) => routeRefusals.push(issue),
onDiagnostic: (diagnostic) => hostDiagnostics.push(diagnostic)
})
@@ -266,6 +275,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
storageWrites,
pageFaults,
pageReadyCount: () => pageReadies,
routeParamClears: () => routeParamClears,
routeRefusals,
async flush(): Promise<void> {
for (let round = 0; round < 64; round += 1) {
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
import type { BridgeInitRoute } from './bridge-envelope'
import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update'
const SESSION = '/h/host-a/session/wt-1'
/** The route the session switch hands the shell, with the tap's pane key on it or without. */
function sessionRoute(paneKey?: string): BridgeInitRoute {
return {
pathname: SESSION,
params: { name: 'my worktree', ...(paneKey === undefined ? {} : { paneKey }) }
}
}
async function openedOnTheSession(): Promise<ReturnType<typeof createFakeBridgePortPair>> {
const pair = createFakeBridgePortPair({
route: sessionRoute(),
storage: { 'orca:hostDockWidth': '320' }
})
await pair.flush()
return pair
}
/** Every pane key the page was told about, in order, including the clears. */
function recordRouteUpdates(pair: ReturnType<typeof createFakeBridgePortPair>): string[] {
const seen: string[] = []
pair.client.onRouteUpdate((route) => {
seen.push(route?.params?.paneKey ?? '')
})
return seen
}
/**
* A notification tap for a pane of the session already on screen (ruling 33.1).
*
* The tap rewrites one param of a route the page is already mounted on, and neither half of what
* the shell had could carry it: keying the screen on the whole route made a pane change a remount
* and a repeat tap nothing at all. The pane request travels as a re-sent `init` instead, which is
* a frame both sides already have, and the page reads a second `init` for its own session as a
* route update rather than as a replacement.
*/
describe('a route update over a re-sent init', () => {
it('reaches the page twice for a repeat tap on the same pane', async () => {
const pair = await openedOnTheSession()
const seen = recordRouteUpdates(pair)
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
// What the native switch does after posting, so a later `init` cannot replay a spent tap.
pair.host.publishRoute(sessionRoute())
await pair.flush()
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
expect(seen).toEqual(['pane-1', '', 'pane-1'])
})
it('reaches the page for a different pane without opening a second session', async () => {
const pair = await openedOnTheSession()
const before = pair.client.getShellSession()
const seen = recordRouteUpdates(pair)
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
pair.host.publishRoute(sessionRoute('pane-2'))
await pair.flush()
expect(seen).toEqual(['pane-1', 'pane-2'])
// The page never re-handshook, so nothing it holds was torn down and rebuilt.
expect(pair.pageReadyCount()).toBe(1)
expect(pair.client.getShellSession()?.sessionId).toBe(before?.sessionId)
})
/**
* A re-asked `ready` is not a route update (round 2).
*
* The page re-asks on its backoff and again after a refused `state` frame, and the shell answers
* every ask with the route it holds. Publishing those as updates made the pane hook see the
* route it was already on — `['', 'pane-1']` for one tap — so the listener fires only when the
* params have actually moved.
*/
it('publishes nothing for a re-asked ready that carries the route the page holds', async () => {
const pair = await openedOnTheSession()
const seen = recordRouteUpdates(pair)
pair.host.receive(
JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] })
)
await pair.flush()
expect(seen).toEqual([])
// And the same ask after a real update still leaves exactly the one delivery behind it.
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
pair.host.receive(
JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] })
)
await pair.flush()
expect(seen).toEqual(['pane-1'])
})
it('leaves a request pending across the update still resolving', async () => {
const pair = await openedOnTheSession()
const pending = pair.client.sendRequest('worktree.list')
await pair.flush()
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
const sent = pair.rpc.requests.find((request) => request.method === 'worktree.list')
sent?.resolve({ id: 'r1', ok: true, result: { worktrees: [] } })
await pair.flush()
await expect(pending).resolves.toMatchObject({ ok: true })
})
it('leaves the storage snapshot the same object the page already held', async () => {
const pair = await openedOnTheSession()
const before = pair.client.getShellSession()?.storage
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
expect(pair.client.getShellSession()?.storage).toBe(before)
})
/**
* Wire compatibility, the half a schema cannot state: an older page never declares `accepts`, so
* the shell must not post it a second `init`. That page would read one as a replacement and
* settle every request it held; the tap it degrades to losing is what it loses today.
*/
it('sends no second init to a page that never declared it accepts one', async () => {
const pair = await openedOnTheSession()
expect(pair.readToShell().find((frame) => frame.type === 'ready')).toMatchObject({
accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT]
})
// This page, then the same document reloaded as a build that declares nothing -- which is what
// a released page is. The host reads `accepts` off whichever `ready` it last answered.
pair.host.receive(JSON.stringify({ v: 1, type: 'ready' }))
await pair.flush()
const framesBefore = pair.toPage.length
pair.host.publishRoute(sessionRoute('pane-1'))
await pair.flush()
expect(pair.toPage.length).toBe(framesBefore)
// And the contrast, so the case fails when the gate stops gating rather than when it starts.
pair.host.receive(
JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] })
)
await pair.flush()
const framesAfterReady = pair.toPage.length
pair.host.publishRoute(sessionRoute('pane-2'))
await pair.flush()
expect(pair.toPage.length).toBe(framesAfterReady + 1)
})
})
@@ -0,0 +1,105 @@
import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route'
import { shellScreenRouteKey } from '../shell-screen-route'
/**
* The one thing a page can say it accepts, which is a second `init` for the session it already has.
*
* A notification tap for another pane of the session on screen rewrites one route param of a page
* that is already mounted, and the shell has no push lane to deliver it on: `notify` runs
* page-to-shell only, and a shell-to-page frame kind would need a capability of its own
* (`bridge-audio-verbs.ts` refused one for raw PCM). `init` already carries `route`, is already
* re-sent on every `ready` and is already re-read, so the pane request rides it.
*
* Declared by the page rather than assumed by the shell, in both directions. A page too old to
* name it reads a second `init` as a replacement and settles every request it holds, so a shell
* that sent one unasked would break it; a shell too old to re-send one leaves a newer page exactly
* where it is today. Both degrade to the repeat tap doing nothing, which is what it does now.
*/
export const BRIDGE_ROUTE_UPDATE_ACCEPT = 'route-update'
/**
* The one thing a shell can say it accepts, which is a page erasing a one-shot route param.
*
* The reader erases (ruling 34). A notification tap writes `paneKey` onto the native route, the
* shell re-sends `init` for as long as it holds one, and the page — having applied the pane — asks
* for it to be cleared, naming the value it applied. Nothing tracks delivery on either side, and
* the shell learns nothing from a post: a frame the page received and then failed to handle is the
* page's own failure, reported there, and a post is refused only when no document holds the view.
* Every one of those is followed by a fresh document's `ready`, answered with the route the shell
* holds then. A clear naming a pane the tap has already moved past is refused by the comparison
* rather than by a sequence number.
*
* Declared in `init` rather than assumed, in the direction `ready.accepts` runs the other way. No
* shipped shell serves a page, so nothing needs negotiating today; the declaration is here so the
* page's check exists from the first version that can post one.
*/
export const BRIDGE_ROUTE_PARAM_CLEAR = 'route-param-clear'
/**
* The params a page may ask to have erased, closed on purpose: a page naming any param would be
* editing the shell's route rather than spending a request the shell handed it.
*/
export const BRIDGE_CLEARABLE_ROUTE_PARAMS = ['paneKey'] as const
export type BridgeClearableRouteParam = (typeof BRIDGE_CLEARABLE_ROUTE_PARAMS)[number]
/**
* Whether two routes are different screens as the page experiences them.
*
* `shellScreenRouteKey` is the page's own identity for a route, so "moved" means here exactly what
* a remount means at the switch, and both ends of the seam read the same definition: the host will
* not send an `init` for a route that did not move, and the page will not publish one it was sent
* anyway. The second half is not redundant — the shell answers every `ready`, including the ones
* the page's own backoff and its stale-`state` restart ask for, and each of those carries the held
* route.
*/
export function bridgeRouteMoved(
held: BridgeInitRoute | null,
next: BridgeInitRoute | null
): boolean {
if (held === null || next === null) {
return held !== next
}
return shellScreenRouteKey(held) !== shellScreenRouteKey(next)
}
/**
* What a host does with a rewritten route: hold it for the next `init`, send one now, or refuse.
*
* `hold` and `send` both move what the host will publish, because a page that reloads inside this
* mount must be handed the newest route on its next `ready` even when it is too old to be sent one
* in flight. Only `send` is a frame, and only a frame lets the caller clear the param it delivered.
*/
export type BridgeRouteUpdate =
| { readonly kind: 'send'; readonly route: BridgeInitRoute }
| { readonly kind: 'hold'; readonly route: BridgeInitRoute }
| { readonly kind: 'refuse'; readonly issue: string }
/**
* Whether this host may hand its page that route, decided from the route it is already serving.
*
* A different pathname is a different screen, which the shell remounts for; the schema is the
* page's own reader, so a shape it would refuse never leaves. Movement is measured with
* `shellScreenRouteKey` rather than a second spelling of it, so "moved" here means exactly what a
* remount means at the switch.
*/
export function readBridgeRouteUpdate(args: {
held: BridgeInitRoute | null
next: BridgeInitRoute
/** What the page's last `ready` declared. Empty for every page built before this existed. */
accepts: readonly string[]
/** Whether this host has a served document that has already had an `init`. */
deliverable: boolean
}): BridgeRouteUpdate {
const { held } = args
const parsed = BridgeInitRouteSchema.safeParse(args.next)
if (held === null || !parsed.success) {
return { kind: 'refuse', issue: held === null ? 'no-route' : 'unreadable-route' }
}
if (parsed.data.pathname !== held.pathname) {
return { kind: 'refuse', issue: 'not-this-screen' }
}
const moved = bridgeRouteMoved(held, parsed.data)
const sendable = moved && args.deliverable && args.accepts.includes(BRIDGE_ROUTE_UPDATE_ACCEPT)
return { kind: sendable ? 'send' : 'hold', route: parsed.data }
}
@@ -12,6 +12,7 @@ import {
BRIDGE_ACK_INTERVAL_FRAMES
} from './bridge-client-subscriptions'
import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge-envelope'
import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update'
import {
BRIDGE_READY_RETRY_MAX_MS,
BRIDGE_READY_RETRY_MIN_MS
@@ -39,9 +40,11 @@ afterEach(() => {
})
describe('bridge client handshake', () => {
it('asks for a session as soon as it exists', () => {
it('asks for a session as soon as it exists, naming what it can be sent', () => {
const page = createPageClient()
expect(page.frames()).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }])
expect(page.frames()).toEqual([
{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }
])
})
it('keeps asking on a widening backoff until init answers', () => {
@@ -130,7 +133,11 @@ describe('bridge client handshake', () => {
pageRouteGrants: null,
// And no host and no stored keys, which is what `host-store.web.ts` then answers with.
host: null,
storage: {}
storage: {},
storageOversize: [],
// And one that takes nothing from the page beyond the frames every shell has taken, which
// is what stops the page posting an erase it would refuse whole (ruling 34).
accepts: []
})
})
@@ -227,6 +234,41 @@ describe('bridge client before a session', () => {
})
})
/**
* A frame the page received and then failed to handle (ruling 34 addendum).
*
* On iOS the host's post is `callAsyncJavaScript`, which rejects when the page's synchronous
* `onmessage` throws — with the document still mounted. That reads to the shell exactly like a
* frame that never arrived, and the shell tracks nothing about posts, so nothing would ever send
* it again. It is not a lost frame either: the page had it, its own listener failed, and a retry
* would fail the same way. The page catches it and says so.
*/
describe('a listener of the page that throws on a frame it received', () => {
it('is reported once, leaves the client usable, and never escapes the delivery', () => {
const page = createPageClient()
page.start()
const thrown = new Error('the pane hook could not apply it')
page.client.onRouteUpdate(() => {
throw thrown
})
const moved = {
...INIT,
route: { pathname: '/h/host-a/session/wt-1', params: { paneKey: 'pane-1' } }
}
// What `__deliver` does on the device: one synchronous call, whose throw would reject the post.
expect(() => page.deliver(moved)).not.toThrow()
expect(page.diagnostics).toEqual([{ kind: 'inbound-listener-threw', error: thrown }])
// And the next frame is read: the failure was the listener's, not the channel's. This one is
// refused by the reader, which is a diagnostic the channel could only raise while it still
// works.
page.deliver(eventFrame('unknown-exchange-id-0', 1, 'x'))
expect(page.diagnostics.map((entry) => entry.kind)).toEqual([
'inbound-listener-threw',
'refused'
])
})
})
describe('bridge client after close', () => {
it('goes inert instead of throwing into a teardown, and posts nothing more', async () => {
const page = createPageClient()
@@ -529,8 +571,14 @@ describe('bridge client acks', () => {
})
const id = idOf(page, 0)
for (let seq = 1; seq <= BRIDGE_ACK_INTERVAL_FRAMES; seq += 1) {
expect(() => page.deliver(eventFrame(id, seq, seq))).toThrow('listener bug')
// Reported rather than thrown (ruling 34 addendum), and counted either way: the window is
// the shell's to reopen, and a page that let the throw out would reject the host's post for
// a frame it had already taken.
expect(() => page.deliver(eventFrame(id, seq, seq))).not.toThrow()
}
expect(
page.diagnostics.filter((entry) => entry.kind === 'inbound-listener-threw')
).toHaveLength(BRIDGE_ACK_INTERVAL_FRAMES)
expect(page.frames().filter((frame) => frame.type === 'ack')).toHaveLength(1)
})
@@ -9,7 +9,8 @@ import {
} from './bridge-caps'
import { BridgeConnectionCache } from './bridge-client-connection-cache'
import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics'
import { readShellSession, type BridgeShellSession } from './bridge-client-session'
import { createBridgeClientShellSession } from './bridge-client-shell-session'
import type { BridgeShellSession } from './bridge-client-session'
import { createBridgeInitHandshake } from './bridge-client-init-handshake'
import {
BridgeClientCapExceededError,
@@ -26,11 +27,17 @@ import { createBridgeClientNotifications } from './bridge-client-notifications'
import { BridgeClientRequests } from './bridge-client-requests'
import { BridgeClientSubscriptions } from './bridge-client-subscriptions'
import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs'
import {
BRIDGE_ROUTE_PARAM_CLEAR,
BRIDGE_ROUTE_UPDATE_ACCEPT,
type BridgeClearableRouteParam
} from './bridge-route-update'
import {
BRIDGE_PROTOCOL_VERSION,
type BridgeClientMessage,
type BridgeConnectionSnapshot,
type BridgeHostMessage
type BridgeHostMessage,
type BridgeInitRoute
} from './bridge-envelope'
export type { BridgeShellSession } from './bridge-client-session'
@@ -60,6 +67,17 @@ export type BridgeRpcClientOptions = {
export type BridgeRpcClient = RpcClient & {
/** Fires once `init` has landed, immediately if it already has. Mount no screen before it. */
onReady: (listener: () => void) => () => void
/**
* Fires each time the shell rewrites a param of the screen this page is already on, which is a
* second `init` for the session it already holds. Never for the first one, and never for a
* re-sent `init` whose route is the one the page already holds — a re-asked `ready` is answered
* with that route and is not a request.
*
* One delivery per tap rather than one per distinct value: the shell clears the param after each
* delivery, so a notification tap for the pane already showing arrives as a real move and a
* listener that deduplicated by value would lose exactly that one.
*/
onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void
getShellSession: () => BridgeShellSession | null
/**
* Asks the shell to open a screen this page does not render. False when the shell granted no
@@ -104,6 +122,16 @@ export type BridgeRpcClient = RpcClient & {
* once will not say itself on a retry, and the shell's own load state is the other way it finds out.
*/
notifyPageFault: (error: unknown) => boolean
/**
* Erases a one-shot route param the shell handed this page, naming the value the page applied
* (ruling 34). The reader erases: the shell tracks no delivery, so a request stays on the route
* and keeps arriving until the page that applied it says so.
*
* False when this shell never declared it takes one, which is every shell older than the field.
* Nothing is owed the caller either way — a clear that did not leave is repaired by the request
* arriving again, which is the same path a lost frame takes.
*/
clearRouteParam: (param: BridgeClearableRouteParam, value: string) => boolean
}
/**
@@ -125,8 +153,6 @@ export type BridgeRpcClient = RpcClient & {
export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRpcClient {
const requests = new BridgeClientRequests()
const cache = new BridgeConnectionCache()
const readyListeners = new Set<() => void>()
let session: BridgeShellSession | null = null
let closed = false
let idCounter = 0
@@ -184,7 +210,9 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
})
const handshake = createBridgeInitHandshake(() => {
sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' })
// Declared on every ask, because the shell reads it off whichever `ready` it answers: this
// page build knows how to take a second `init` for the session it already holds.
sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] })
})
/**
@@ -194,7 +222,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
* means for its own return type.
*/
function requireSession(): void {
if (session === null && !closed) {
if (shellSession.current() === null && !closed) {
throw new BridgeClientNotReadyError()
}
}
@@ -208,21 +236,31 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
return held
}
/** A second `init` is ordinary: the shell answers every `ready`, and a page that re-asked hears
* its own session again. A different id is not, and nothing the page held survives it. */
function acceptInit(message: Extract<BridgeHostMessage, { type: 'init' }>): void {
handshake.stop()
if (session !== null && session.sessionId !== message.sessionId) {
/**
* A second `init` is ordinary: the shell answers every `ready`, and a page that re-asked hears
* its own session again. A different id is not, and nothing the page held survives it.
*
* For the same id it is a route update and nothing else (ruling 33.1). The screen is the one
* already mounted, so every request in flight, every subscription, the storage snapshot the page
* booted from and the generation stay exactly as they are, and only `route` moves — which is how
* a notification tap for another pane of this session reaches a page that is already on it. The
* session object is rebuilt only when the id changes, so the identity of what the page holds is
* itself the assertion that nothing was replaced.
*/
const shellSession = createBridgeClientShellSession({
onReplaced: () => {
const replaced = new BridgeShellReplacedError()
requests.closeAll(replaced)
subscriptions.failAll(replaced.message)
},
prime: (connection) => {
cache.prime(connection)
}
session = readShellSession(message)
cache.prime(message.connection)
for (const listener of readyListeners) {
listener()
}
readyListeners.clear()
})
function acceptInit(message: Extract<BridgeHostMessage, { type: 'init' }>): void {
handshake.stop()
shellSession.accept(message)
}
/** A shell rebuilt under the page: what the cache holds is for a client that is already gone. */
@@ -244,8 +282,18 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
/** After `close` the page is not the document the shell is answering any more. */
function receive(json: string): void {
if (!closed) {
if (closed) {
return
}
try {
readInboundFrame(json)
} catch (error) {
// The delivery is the channel's and the handling is the page's (ruling 34 addendum). This is
// the one place that separates them: on iOS the host's post is `callAsyncJavaScript`, so a
// listener throwing here would reject a post for a frame the page already had, and the shell
// would read that as a frame that never arrived. Reported and not rethrown, and not retried
// either — the same listener would throw on the same frame again.
report({ kind: 'inbound-listener-threw', error })
}
}
@@ -332,8 +380,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
sendFrame({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' })
requests.closeAll()
cache.close()
session = null
readyListeners.clear()
shellSession.close()
unsubscribeFromMessages()
}
@@ -341,7 +388,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
send: posted,
requireSession,
isClosed: () => closed,
hasGrant: (name) => session?.grants.native.includes(name) === true
hasGrant: (name) => shellSession.current()?.grants.native.includes(name) === true
})
const unsubscribeFromMessages = options.onMessage(receive)
@@ -388,16 +435,17 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
notifyHaptics: notifications.notifyHaptics,
notifyPageFault: notifications.notifyPageFault,
close,
onReady: (listener) => {
if (session !== null) {
listener()
return () => undefined
}
readyListeners.add(listener)
return () => {
readyListeners.delete(listener)
}
},
getShellSession: () => session
onReady: shellSession.onReady,
onRouteUpdate: shellSession.onRouteUpdate,
getShellSession: shellSession.current,
clearRouteParam: (param, value) =>
shellSession.current()?.accepts.includes(BRIDGE_ROUTE_PARAM_CLEAR) === true &&
posted({
v: BRIDGE_PROTOCOL_VERSION,
type: 'notify',
name: BRIDGE_ROUTE_PARAM_CLEAR,
param,
value
})
}
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { PAGE_STORAGE_MAX_VALUE_CHARS } from '../page-storage-keys'
import pageAsyncStorage, { publishPageStorage } from './page-async-storage'
import pageAsyncStorage, { PageStorageRefusedError, publishPageStorage } from './page-async-storage'
type Write = { key: string; value: string | null }
@@ -8,6 +8,7 @@ const writes: Write[] = []
let granted = true
const HOST_ID = 'host-1'
const SESSION_ROUTE = '/h/host-1/session/wt-1'
function publish(entries: Record<string, string> = {}): void {
writes.length = 0
@@ -20,10 +21,24 @@ function publish(entries: Record<string, string> = {}): void {
writes.push({ key, value })
return true
},
HOST_ID
HOST_ID,
SESSION_ROUTE
)
}
/** The refusal a caller's own catch reads, as this file asserts it everywhere below. */
async function refusalOf(write: Promise<void>): Promise<PageStorageRefusedError> {
try {
await write
} catch (error) {
if (error instanceof PageStorageRefusedError) {
return error
}
throw error
}
throw new Error('the write was not refused')
}
beforeEach(() => {
granted = true
publish()
@@ -69,26 +84,37 @@ describe('a write the page makes', () => {
await expect(pageAsyncStorage.getItem('orca:pins:host-1')).resolves.toBeNull()
})
it('is refused, and not kept, for a key outside the allowlist', async () => {
it('is dropped, and not kept, for a key outside the allowlist', async () => {
// Held locally it would answer a later read with a value no other screen in the app can see —
// a pin that looks set and is not, which is the failure the grant exists to avoid.
await pageAsyncStorage.setItem('orca:mobileWebShellEnabled', 'true')
// a pin that looks set and is not, which is the failure the grant exists to avoid. Dropped
// rather than rejected: ruling 33.4, and the case at the end of this file says why.
await expect(
pageAsyncStorage.setItem('orca:mobileWebShellEnabled', 'true')
).resolves.toBeUndefined()
expect(writes).toEqual([])
await expect(pageAsyncStorage.getItem('orca:mobileWebShellEnabled')).resolves.toBeNull()
})
it('is refused, and not kept, when the shell granted no storage', async () => {
granted = false
await pageAsyncStorage.setItem('orca:pins:host-1', '["wt-1"]')
await expect(pageAsyncStorage.getItem('orca:pins:host-1')).resolves.toBeNull()
it('carries each pair of a multi-write up to the first it cannot, and no further', async () => {
await expect(
pageAsyncStorage.multiSet([
['orca:pins:host-1', '["wt-1"]'],
['orca:remotePushHostRegistrations', '{}']
])
).resolves.toBeUndefined()
// Everything before the refusal is applied, and the refusal is where the batch ends: one call
// with one answer (ruling 35), rather than a promise describing a half-applied batch.
expect(writes).toEqual([{ key: 'orca:pins:host-1', value: '["wt-1"]' }])
})
it('carries each pair of a multi-write separately, and drops the ones outside the list', async () => {
await pageAsyncStorage.multiSet([
['orca:pins:host-1', '["wt-1"]'],
['orca:remotePushHostRegistrations', '{}']
])
expect(writes).toEqual([{ key: 'orca:pins:host-1', value: '["wt-1"]' }])
it('stops a multi-write at a refused first pair rather than applying the rest behind it', async () => {
await expect(
pageAsyncStorage.multiSet([
['orca:remotePushHostRegistrations', '{}'],
['orca:pins:host-1', '["wt-1"]']
])
).resolves.toBeUndefined()
expect(writes).toEqual([])
})
it('never empties the app store, which is not this document to empty', async () => {
@@ -100,21 +126,71 @@ describe('a write the page makes', () => {
})
describe('what the page will not keep', () => {
it("refuses another host's pinned list, so a later read cannot answer with it", async () => {
it("drops another host's pinned list, so a later read cannot answer with it", async () => {
publish({ 'orca:pins:host-1': '["mine"]' })
await pageAsyncStorage.setItem('orca:pins:host-2', '["theirs"]')
await expect(
pageAsyncStorage.setItem('orca:pins:host-2', '["theirs"]')
).resolves.toBeUndefined()
// Nothing posted, and nothing cached: a value held here that the shell will not write is a pin
// that looks set to this document and to nothing else in the app.
expect(writes).toEqual([])
expect(await pageAsyncStorage.getItem('orca:pins:host-2')).toBeNull()
})
it("drops another workspace's chat tabs on the session route it was not opened for", async () => {
publish()
await expect(
pageAsyncStorage.setItem('orca:nativeChatTabs:host-1:wt-2', '{}')
).resolves.toBeUndefined()
expect(writes).toEqual([])
// And the one it was opened for goes through, so the drop above is about the workspace.
await pageAsyncStorage.setItem('orca:nativeChatTabs:host-1:wt-1', '{}')
expect(writes).toEqual([{ key: 'orca:nativeChatTabs:host-1:wt-1', value: '{}' }])
})
it('refuses a value over the envelope bound rather than caching what the wire will drop', async () => {
publish()
const oversized = 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS + 1)
await pageAsyncStorage.setItem('orca:last-visited-worktree', oversized)
const refusal = await refusalOf(
pageAsyncStorage.setItem('orca:mobileStructuredSendOperations:v1', oversized)
)
// The sentence a screen puts on itself, which is what ruling 7 asks for: the durable send
// journal outgrows the bound at 48 unsettled sends, and vanishing is what it must not do.
expect(refusal.refusal).toBe('too-large')
expect(refusal.message).toContain('orca:mobileStructuredSendOperations:v1')
expect(refusal.message).toContain(String(PAGE_STORAGE_MAX_VALUE_CHARS))
expect(writes).toEqual([])
expect(await pageAsyncStorage.getItem('orca:mobileStructuredSendOperations:v1')).toBeNull()
})
/**
* A batch with two oversize pairs, which is one rejection and not two.
*
* `settleBatch` returned the first rejected promise and dropped the rest, so every later
* oversize entry was a rejected promise nobody held — an unhandled rejection in the page, which
* is the outcome this module's rejection scope exists to avoid.
*/
it('rejects a batch of two oversize pairs once, leaving no rejection nobody holds', async () => {
publish()
const unhandled: unknown[] = []
const record = (reason: unknown) => {
unhandled.push(reason)
}
process.on('unhandledRejection', record)
const oversized = 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS + 1)
const refusal = await refusalOf(
pageAsyncStorage.multiSet([
['orca:mobileStructuredSendOperations:v1', oversized],
['orca:custom-accessory-keys', oversized]
])
)
// Two turns, which is when an orphaned rejection is reported.
await new Promise((resolve) => setImmediate(resolve))
process.off('unhandledRejection', record)
expect(unhandled).toEqual([])
// The first pair is the one the caller is told about, and neither reached the wire.
expect(refusal.key).toBe('orca:mobileStructuredSendOperations:v1')
expect(writes).toEqual([])
expect(await pageAsyncStorage.getItem('orca:last-visited-worktree')).toBeNull()
})
it('still keeps a value exactly at the bound, so the refusal above discriminates', async () => {
@@ -124,4 +200,30 @@ describe('what the page will not keep', () => {
expect(writes).toEqual([{ key: 'orca:last-visited-worktree', value: atBound }])
expect(await pageAsyncStorage.getItem('orca:last-visited-worktree')).toBe(atBound)
})
it('drops a write it may not make rather than rejecting, because nobody catches one', async () => {
// Ruling 33.4. Every page-closure writer of an unlisted key calls `setItem` with no catch —
// `notification-delivery-preferences.ts:39` awaits it inside a function its callers `void` —
// so rejecting here turns a dropped preference into an unhandled rejection in the page. The
// drop is the old behaviour and the right one; only the journal's oversize path rejects,
// because the composer is written to catch that one.
publish()
await expect(
pageAsyncStorage.setItem('orca:notificationDeliveryPreferences', '{}')
).resolves.toBeUndefined()
expect(writes).toEqual([])
expect(await pageAsyncStorage.getItem('orca:notificationDeliveryPreferences')).toBeNull()
})
it('drops a removal it may not make, for the same reason', async () => {
publish()
await expect(pageAsyncStorage.removeItem('orca:pins:host-2')).resolves.toBeUndefined()
expect(writes).toEqual([])
})
it('drops a write the shell would not take, rather than rejecting', async () => {
granted = false
await expect(pageAsyncStorage.setItem('orca:pins:host-1', '["wt-1"]')).resolves.toBeUndefined()
await expect(pageAsyncStorage.getItem('orca:pins:host-1')).resolves.toBeNull()
})
})
@@ -1,4 +1,4 @@
import { isPageStorageKeyForHost, PAGE_STORAGE_MAX_VALUE_CHARS } from '../page-storage-keys'
import { isPageStorageKeyForRoute, PAGE_STORAGE_MAX_VALUE_CHARS } from '../page-storage-keys'
/**
* The page's AsyncStorage: the app's store, read from `init` and written over the `storage` grant.
@@ -15,76 +15,167 @@ import { isPageStorageKeyForHost, PAGE_STORAGE_MAX_VALUE_CHARS } from '../page-s
*/
type PageStorageWriter = (key: string, value: string | null) => boolean
/** Why a write went nowhere, in the page's own words rather than a boolean. */
type PageStorageRefusal = 'not-allowed' | 'too-large' | 'not-delivered'
const REFUSAL_SENTENCES: Record<PageStorageRefusal, string> = {
'not-allowed': 'this screen was not given that setting to write',
'too-large': `a stored value may be at most ${String(PAGE_STORAGE_MAX_VALUE_CHARS)} characters`,
'not-delivered': 'the app did not take the write'
}
/**
* A write the page could not make, as something a screen can put on itself.
*
* Raised for one refusal only, `too-large`, and that scope is the whole of ruling 33.4. The real
* AsyncStorage rejects when its store refuses — a value over the row limit is a SQLite error on
* Android — so rejecting is the module's own contract for a value too big, and the caller that
* needs it is written for it: the durable send journal, whose composer catches this and answers
* "Message not sent" rather than sending a mutation whose operation id was never written down
* (ruling 7).
*
* The other two refusals stay silent drops, because nothing catches them. A page-closure writer of
* an unlisted key calls `setItem` and awaits it with no catch —
* `notification-delivery-preferences.ts:39` is the plain case, and `preferences.ts` has several —
* so rejecting there converts a preference the page was never allowed to keep into an unhandled
* rejection in the document. A write nobody may make and a write the shell would not take are both
* the page failing to change anything, which is what it already does; the log is where they go.
*/
export class PageStorageRefusedError extends Error {
readonly refusal: PageStorageRefusal
readonly key: string
constructor(key: string, refusal: PageStorageRefusal) {
super(`Orca could not save ${key}: ${REFUSAL_SENTENCES[refusal]}.`)
this.name = 'PageStorageRefusedError'
this.refusal = refusal
this.key = key
}
}
const values = new Map<string, string>()
let write: PageStorageWriter = () => false
/**
* The allowlisted keys `init` could not carry because the app's value was over the page's cap.
*
* Writable by the allowlist and unwritable in fact (ruling 33.6): the page holds no value for one
* of these, so anything it writes replaces what the device has rather than extending it. Refused
* as `too-large`, which is the truth about the key and the one refusal the composer catches.
*/
let oversizeKeys: ReadonlySet<string> = new Set()
/** The host this document was opened for; no key belonging to another one is writable. */
let hostId = ''
/** And the route, because two of the keys are scoped to the workspace the route names. */
let routePathname = ''
/** Called once by the entry, before anything renders, with what `init` carried. */
export function publishPageStorage(
entries: Readonly<Record<string, string>>,
writer: PageStorageWriter,
forHostId: string
forHostId: string,
forRoutePathname: string,
forOversizeKeys: readonly string[] = []
): void {
values.clear()
oversizeKeys = new Set(forOversizeKeys)
for (const [key, value] of Object.entries(entries)) {
values.set(key, value)
}
write = writer
hostId = forHostId
routePathname = forRoutePathname
}
/**
* Refused rather than kept locally.
* Refused rather than kept locally, and named rather than dropped.
*
* A key outside the allowlist is one the shell will not write, so holding it here would answer a
* later read with a value no other screen in the app can see — a pin that looks set and is not,
* which is exactly the failure the grant exists to avoid.
*/
function accept(key: string, value: string | null): boolean {
if (!isPageStorageKeyForHost(key, hostId)) {
return false
function accept(key: string, value: string | null): PageStorageRefusal | null {
if (!isPageStorageKeyForRoute(key, hostId, routePathname)) {
return 'not-allowed'
}
// The envelope's own bound, imported rather than restated: without it an oversized value is
// cached here and dropped on the wire, so the page reads back a write no other screen can see.
if (value !== null && value.length > PAGE_STORAGE_MAX_VALUE_CHARS) {
return false
return 'too-large'
}
// And the same refusal for a key whose app-side value was already over it: the page was handed
// nothing for this key, so any write it makes is a replacement rather than an edit.
if (oversizeKeys.has(key)) {
return 'too-large'
}
if (!write(key, value)) {
return false
return 'not-delivered'
}
if (value === null) {
values.delete(key)
} else {
values.set(key, value)
}
return true
return null
}
/**
* The error a refusal is, or nothing. Logged here, because the drop is the thing a reader of a
* device log has to be able to find — a preference that did not stick looks identical to one
* nobody set.
*
* An error rather than a rejected promise, so a caller that raises no rejection creates none. A
* promise built per refusal and then discarded is an unhandled rejection in the page, which is
* exactly what this module's rejection scope exists to avoid.
*/
function refusalError(
key: string,
refusal: PageStorageRefusal | null
): PageStorageRefusedError | null {
if (refusal === null) {
return null
}
if (refusal === 'too-large') {
return new PageStorageRefusedError(key, refusal)
}
console.warn('[page-bridge] storage-write-dropped', { key, refusal })
return null
}
/** One refusal: the rejection the composer's catch is written for, or a logged drop. */
function settle(key: string, refusal: PageStorageRefusal | null): Promise<void> {
const error = refusalError(key, refusal)
return error === null ? Promise.resolve() : Promise.reject(error)
}
/**
* A batch is one call with one answer, so it stops where it cannot go on: every pair before the
* refusal is applied, the refusal is the answer, and nothing after it is attempted (ruling 35).
*
* What this replaces collected a refusal per pair, logged each, and rejected with the first that
* could reject while the rest of the batch went in anyway — one promise describing a call where
* some pairs landed and some did not, which is the thing a caller cannot act on. No page-closure
* writer calls `multiSet` or `multiRemove` today, so this is the rule for whoever writes the
* first one rather than a change to anybody's behaviour.
*/
function applyBatch(pairs: readonly (readonly [string, string | null])[]): Promise<void> {
for (const [key, value] of pairs) {
const refusal = accept(key, value)
if (refusal !== null) {
return settle(key, refusal)
}
}
return Promise.resolve()
}
const pageAsyncStorage = {
getItem: (key: string): Promise<string | null> => Promise.resolve(values.get(key) ?? null),
setItem: (key: string, value: string): Promise<void> => {
accept(key, value)
return Promise.resolve()
},
removeItem: (key: string): Promise<void> => {
accept(key, null)
return Promise.resolve()
},
setItem: (key: string, value: string): Promise<void> => settle(key, accept(key, value)),
removeItem: (key: string): Promise<void> => settle(key, accept(key, null)),
multiGet: (keys: readonly string[]): Promise<[string, string | null][]> =>
Promise.resolve(keys.map((key) => [key, values.get(key) ?? null])),
multiSet: (pairs: readonly [string, string][]): Promise<void> => {
for (const [key, value] of pairs) {
accept(key, value)
}
return Promise.resolve()
},
multiRemove: (keys: readonly string[]): Promise<void> => {
for (const key of keys) {
accept(key, null)
}
return Promise.resolve()
},
multiSet: (pairs: readonly [string, string][]): Promise<void> => applyBatch(pairs),
multiRemove: (keys: readonly string[]): Promise<void> =>
applyBatch(keys.map((key) => [key, null] as const)),
getAllKeys: (): Promise<string[]> => Promise.resolve([...values.keys()]),
// The app's store is not this document's to empty, and no screen in the page closure calls it.
clear: (): Promise<void> => Promise.resolve()
@@ -153,9 +153,12 @@ describe('the page bootstrap inside the shell', () => {
refuseUnroutedShell: () => {}
})
// The throw does not leave the delivery (ruling 34 addendum): on iOS that would reject the
// host's post for an `init` the page had already taken, and the shell tracks nothing about
// posts, so nothing would ever send it again. The stamp is what a device log reads instead.
expect(() => {
channel.deliver(INIT)
}).toThrow('the route tree threw')
}).not.toThrow()
expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('shell-ready')
})
@@ -118,7 +118,7 @@ vi.mock('./use-page-host-snapshot', () => ({
usePageHostSnapshot: () => ({
snapshot: SNAPSHOT,
unreadable: false,
readStorage: () => ({}),
readStorage: () => ({ storage: {}, storageOversize: [] }),
refreshStorage: () => {},
writeStorage: () => {}
})
@@ -22,6 +22,7 @@ const FILES_ROUTE = 'app/h/[hostId]/files/[worktreeId].tsx'
const FILES_PREVIEW_ROUTE = 'app/h/[hostId]/files/preview/[worktreeId].tsx'
const SOURCE_CONTROL_ROUTE = 'app/h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW_ROUTE = 'app/h/[hostId]/review/[worktreeId].tsx'
const SESSION_ROUTE = 'app/h/[hostId]/session/[worktreeId].tsx'
/** The one switch with no native screen behind it; its route file only re-exports this body. */
const CATCH_ALL_ROUTE = 'src/mobile-web-shell/catch-all-page-route.tsx'
/** One entry per screen the flag can switch to the page, which is what a review reads. */
@@ -33,6 +34,7 @@ const SWITCHED_ROUTES = [
FILES_PREVIEW_ROUTE,
SOURCE_CONTROL_ROUTE,
REVIEW_ROUTE,
SESSION_ROUTE,
CATCH_ALL_ROUTE
]
const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx'
@@ -0,0 +1,245 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RouteDependencies = {
storage: Map<string, string>
routes: { pathname: string; params?: Record<string, string> }[]
natives: number
/** `mount:<pathname>` / `unmount:<pathname>`, which is the only thing that tells a remount from
* a prop update — and a remount is what drops the old session's bridge and its grants. */
lifecycle: string[]
/** Every `onRouteParamClear` the screen was handed, so a case can erase the way the page does. */
clears: ((param: 'paneKey', value: string) => void)[]
params: Record<string, string | string[] | undefined>
}
const dependencies = vi.hoisted((): RouteDependencies => ({
storage: new Map(),
routes: [],
natives: 0,
lifecycle: [],
clears: [],
params: {}
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => dependencies.storage.get(key) ?? null,
setItem: async (key: string, value: string) => {
dependencies.storage.set(key, value)
}
}
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => dependencies.params,
useRouter: () => ({
setParams: (next: Record<string, string>) => {
dependencies.params = { ...dependencies.params, ...next }
}
})
}))
vi.mock('../session/MobileSessionRouteScreen', () => ({
MobileSessionRouteScreen: () => {
dependencies.natives += 1
return null
}
}))
vi.mock('./MobileWebShellScreen', async () => {
const React = await import('react')
return {
MobileWebShellScreen: (props: {
hostId: string
route: { pathname: string; params?: Record<string, string> }
onRouteParamClear?: (param: 'paneKey', value: string) => void
}) => {
dependencies.routes.push(props.route)
if (props.onRouteParamClear) {
dependencies.clears.push(props.onRouteParamClear)
}
// Empty deps on purpose: keyed on the pathname this would re-fire on a prop update and read
// exactly like a remount, which is the one thing it exists to tell apart.
const mountedAs = React.useRef(props.route.pathname)
React.useEffect(() => {
const pathname = mountedAs.current
dependencies.lifecycle.push(`mount:${pathname}`)
return () => {
dependencies.lifecycle.push(`unmount:${pathname}`)
}
}, [])
return null
}
}
})
import { BRIDGE_ROUTE_PATHNAME_PATTERN } from './bridge/bridge-caps'
import MobileSessionScreen from '../../app/h/[hostId]/session/[worktreeId]'
async function renderSession(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(createElement(MobileSessionScreen))
})
if (renderer === null) {
throw new Error('the session switch did not render')
}
return renderer
}
async function update(renderer: ReactTestRenderer): Promise<void> {
await act(async () => {
renderer.update(createElement(MobileSessionScreen))
})
}
/** Every pane key the shell was handed, in the order it was handed them, clears included. */
function paneKeysSeen(): string[] {
return dependencies.routes.map((route) => route.params?.paneKey ?? '')
}
beforeEach(() => {
dependencies.storage.clear()
dependencies.routes.length = 0
dependencies.lifecycle.length = 0
dependencies.clears.length = 0
dependencies.natives = 0
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'my worktree' }
Object.assign(globalThis, { __DEV__: true })
dependencies.storage.set('orca:mobileWebShellEnabled', 'true')
})
describe('the native session route that hands off to the shell', () => {
it('opens the shell on this screen, with the name as the search half', async () => {
await renderSession()
expect(dependencies.routes).toEqual([
{ pathname: '/h/host-1/session/wt-1', params: { name: 'my worktree' } }
])
})
it('renders the native screen while the flag read is still settling', async () => {
// The element is built on every render and mounted only by `fallback`, so the count below is
// what a settling read costs: one native screen, before the switch has an answer.
dependencies.storage.delete('orca:mobileWebShellEnabled')
await renderSession()
expect(dependencies.routes).toEqual([])
expect(dependencies.natives).toBeGreaterThan(0)
})
/**
* A repeated query key, which expo-router answers with an array.
*
* A bare read puts that array straight into a template, where `String(['a','b'])` is `a,b` and
* `encodeURIComponent` makes it `a%2Cb` — one segment, so the bridge's segment rule accepts it
* and the shell opens a page for a host that does not exist.
*/
it('takes the first value of a repeated param, not the joined array', async () => {
dependencies.params = {
hostId: ['host-a', 'host-b'],
worktreeId: ['wt-1', 'wt-2'],
name: ['first', 'second'],
paneKey: ['pane-1', 'pane-2']
}
await renderSession()
expect(dependencies.routes).toEqual([
{ pathname: '/h/host-a/session/wt-1', params: { name: 'first', paneKey: 'pane-1' } }
])
const pathname = dependencies.routes[0]?.pathname ?? ''
expect(pathname).not.toContain('%2C')
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname)).toBe(true)
})
it('stays native for a dot-segment id the bridge would refuse', async () => {
for (const hostId of ['.', '..']) {
dependencies.params = { hostId, worktreeId: 'wt-1' }
dependencies.routes.length = 0
await renderSession()
expect(dependencies.routes, hostId).toEqual([])
}
})
it('encodes both dynamic segments, so a deep-linked id stays one segment each', async () => {
for (const hostId of ['a?b', 'a#b', 'a b', 'a/b', 'a\\b']) {
dependencies.params = { hostId, worktreeId: 'wt/1' }
dependencies.routes.length = 0
await renderSession()
const pathname = dependencies.routes[0]?.pathname ?? ''
expect(pathname, hostId).toBe(
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent('wt/1')}`
)
expect(BRIDGE_ROUTE_PATHNAME_PATTERN.test(pathname), hostId).toBe(true)
}
})
it('renders the native screen with the flag off, which is every store build', async () => {
dependencies.storage.set('orca:mobileWebShellEnabled', 'false')
await renderSession()
expect(dependencies.routes).toEqual([])
expect(dependencies.natives).toBeGreaterThan(0)
})
/**
* A route change is a new session, and the old one's bridge must not outlive it.
*
* The host captures the grants its session was opened with, so a screen reused across a route
* change keeps authorising frames under the grants of the route the page has left.
*/
it('remounts the shell when the route changes rather than updating it', async () => {
const renderer = await renderSession()
dependencies.params = { hostId: 'host-2', worktreeId: 'wt-9' }
await update(renderer)
expect(dependencies.lifecycle).toEqual([
'mount:/h/host-1/session/wt-1',
'unmount:/h/host-1/session/wt-1',
'mount:/h/host-2/session/wt-9'
])
})
it('remounts when a param other than the pane changes, which the page cannot learn otherwise', async () => {
const renderer = await renderSession()
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', name: 'renamed' }
await update(renderer)
expect(dependencies.lifecycle).toEqual([
'mount:/h/host-1/session/wt-1',
'unmount:/h/host-1/session/wt-1',
'mount:/h/host-1/session/wt-1'
])
})
/**
* The two pane cases (ruling 33.1). Both measure mounts, because a remount here is a bridge
* teardown and a page reload for what is a tab switch.
*
* The spent tap is what made the repeat one unreachable: the page cleared the param on its own
* router, the native route kept it, and `SET_PARAMS` writing the value already there moved no
* key and remounted nothing. Here the page erases the native param (ruling 34), so the second
* tap is a change again.
*/
it('hands a repeat tap for the same pane to the mounted page, twice, with no remount', async () => {
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', paneKey: 'pane-1' }
const renderer = await renderSession()
expect(paneKeysSeen()).toEqual(['pane-1'])
// The page applied it and says so, naming what it applied.
await act(async () => {
dependencies.clears.at(-1)?.('paneKey', 'pane-1')
})
await update(renderer)
expect(dependencies.params.paneKey).toBe('')
// The same notification tapped again.
dependencies.params = { ...dependencies.params, paneKey: 'pane-1' }
await update(renderer)
expect(paneKeysSeen().filter((key) => key === 'pane-1')).toHaveLength(2)
expect(dependencies.lifecycle).toEqual(['mount:/h/host-1/session/wt-1'])
})
it('hands a different pane to the mounted page with no remount', async () => {
dependencies.params = { hostId: 'host-1', worktreeId: 'wt-1', paneKey: 'pane-1' }
const renderer = await renderSession()
dependencies.params = { ...dependencies.params, paneKey: 'pane-2' }
await update(renderer)
expect(paneKeysSeen().at(-1)).toBe('pane-2')
expect(dependencies.lifecycle).toEqual(['mount:/h/host-1/session/wt-1'])
})
})
@@ -62,7 +62,8 @@ const PAGE_SERVED_SCREENS = [
{
pathname: '/h/[hostId]/review/[worktreeId]',
screen: 'src/components/MobileDiffReviewHeader.tsx'
}
},
{ pathname: '/h/[hostId]/session/[worktreeId]', screen: 'src/session/MobileSessionHeader.tsx' }
]
/** The rule reads whole trees, so a Back added beside a screen is ruled as well as the screen's. */
@@ -1,15 +1,25 @@
import { describe, expect, it } from 'vitest'
import {
isPageStorageKey,
isPageStorageKeyForHost,
pageStorageKeysForHost,
PAGE_STORAGE_MAX_KEY_CHARS
isPageStorageKeyForRoute,
pageRouteWorkspace,
pageStorageEntriesForInit,
pageStorageKeysForRoute,
PAGE_STORAGE_EXACT_KEYS,
PAGE_STORAGE_MAX_ENTRIES,
PAGE_STORAGE_MAX_KEY_CHARS,
PAGE_STORAGE_MAX_VALUE_CHARS
} from './page-storage-keys'
const HOST_ROUTE = '/h/host-1'
const SESSION_ROUTE = '/h/host-1/session/wt-1'
describe('the keys a page may read and write', () => {
it('takes the two the list keeps', () => {
it('takes the ones the list keeps', () => {
expect(isPageStorageKey('orca:last-visited-worktree')).toBe(true)
expect(isPageStorageKey('orca:pins:host-1')).toBe(true)
expect(isPageStorageKey('orca:terminalTextScale')).toBe(true)
expect(isPageStorageKey('orca:nativeChatTabs:host-1:wt-1')).toBe(true)
})
it('refuses the rest of the namespace, including the flag that turns this on', () => {
@@ -19,7 +29,7 @@ describe('the keys a page may read and write', () => {
'orca:mobileWebShellEnabled',
'orca:remotePushHostRegistrations',
'orca:pushServiceNotificationsEnabled',
'orca:terminalTextScale',
'orca:home-snapshot:v1',
'orca:hosts'
]) {
expect(isPageStorageKey(key), key).toBe(false)
@@ -28,6 +38,7 @@ describe('the keys a page may read and write', () => {
it('refuses the bare prefix, which names no host', () => {
expect(isPageStorageKey('orca:pins:')).toBe(false)
expect(isPageStorageKey('orca:nativeChatTabs:')).toBe(false)
})
it('refuses a key that only starts like an allowlisted one', () => {
@@ -39,24 +50,131 @@ describe('the keys a page may read and write', () => {
expect(isPageStorageKey(`orca:pins:${'h'.repeat(PAGE_STORAGE_MAX_KEY_CHARS)}`)).toBe(false)
})
it('names what the shell reads out of the app store for one host', () => {
const keys = pageStorageKeysForHost('host-1')
expect(keys).toEqual(['orca:last-visited-worktree', 'orca:pins:host-1'])
it('names what the shell reads out of the app store for a route with no workspace', () => {
const keys = pageStorageKeysForRoute('host-1', HOST_ROUTE)
expect(keys).toEqual([...PAGE_STORAGE_EXACT_KEYS, 'orca:pins:host-1'])
for (const key of keys) {
expect(isPageStorageKey(key), key).toBe(true)
}
})
it("adds the session route's two workspace-scoped keys, and only for it", () => {
const session = pageStorageKeysForRoute('host-1', SESSION_ROUTE)
expect(
session.filter((key) => !pageStorageKeysForRoute('host-1', HOST_ROUTE).includes(key))
).toEqual(['orca:nativeChatTabs:host-1:wt-1', 'orca:terminalLiveInputDisabled:host-1:wt-1'])
// A route that carries a worktree segment and declares no key of its own gets none.
expect(pageStorageKeysForRoute('host-1', '/h/host-1/files/wt-1')).toEqual(
pageStorageKeysForRoute('host-1', HOST_ROUTE)
)
})
it('stays inside the entry count init is allowed to carry', () => {
// The handed list is what `init` is built from, and its schema refines on this number: a list
// that outgrew it would take the whole frame down rather than one key.
expect(pageStorageKeysForRoute('host-1', SESSION_ROUTE).length).toBeLessThanOrEqual(
PAGE_STORAGE_MAX_ENTRIES
)
})
it('drops a workspace key whose id pushes it past the key cap, rather than handing one over', () => {
const long = 'w'.repeat(PAGE_STORAGE_MAX_KEY_CHARS)
const keys = pageStorageKeysForRoute('host-1', `/h/host-1/session/${long}`)
// Every key `init` carries is one the page's own schema will take; without the filter the two
// long ones are in this list and the page refuses the frame.
expect(keys.filter((key) => !isPageStorageKey(key))).toEqual([])
expect(keys).toEqual(pageStorageKeysForRoute('host-1', HOST_ROUTE))
})
})
describe('the allowlist narrowed to one host', () => {
it('admits exactly the keys that host was handed', () => {
for (const key of pageStorageKeysForHost('host-1')) {
expect(isPageStorageKeyForHost(key, 'host-1'), key).toBe(true)
describe('the workspace a route names', () => {
it('reads the session route, decoded as the router decodes it', () => {
expect(pageRouteWorkspace('/h/host-1/session/wt-1')).toEqual({
hostId: 'host-1',
worktreeId: 'wt-1'
})
expect(pageRouteWorkspace('/h/host%201/session/folder%3A%2Ftmp%2Fa')).toEqual({
hostId: 'host 1',
worktreeId: 'folder:/tmp/a'
})
})
it('answers null for every other shape, so no other route is handed a workspace key', () => {
for (const pathname of [
'/h/host-1',
'/h/host-1/tasks',
'/h/host-1/files/wt-1',
'/h/host-1/review/wt-1',
'/h/host-1/files/preview/wt-1',
'/h/host-1/session/',
'/h//session/wt-1',
'h/host-1/session/wt-1'
]) {
expect(pageRouteWorkspace(pathname), pathname).toBeNull()
}
})
it('answers null for a stray percent rather than throwing inside the init build', () => {
// `decodeURIComponent('%zz')` throws, and this runs while the shell is building `init`.
expect(pageRouteWorkspace('/h/host-1/session/%zz')).toBeNull()
})
})
describe('the allowlist narrowed to one session', () => {
it('admits exactly the keys that session was handed', () => {
for (const key of pageStorageKeysForRoute('host-1', SESSION_ROUTE)) {
expect(isPageStorageKeyForRoute(key, 'host-1', SESSION_ROUTE), key).toBe(true)
}
})
it("refuses another host's pinned list, which the shape check alone admits", () => {
expect(isPageStorageKey('orca:pins:host-2')).toBe(true)
expect(isPageStorageKeyForHost('orca:pins:host-2', 'host-1')).toBe(false)
expect(isPageStorageKeyForRoute('orca:pins:host-2', 'host-1', SESSION_ROUTE)).toBe(false)
})
it("refuses another workspace's chat tabs, which the shape check alone admits", () => {
// One level in from the host rule above, and the reason the route is threaded at all: a page
// opened on one workspace must not rewrite the chat tabs of the one beside it.
expect(isPageStorageKey('orca:nativeChatTabs:host-1:wt-2')).toBe(true)
expect(
isPageStorageKeyForRoute('orca:nativeChatTabs:host-1:wt-2', 'host-1', SESSION_ROUTE)
).toBe(false)
expect(
isPageStorageKeyForRoute('orca:nativeChatTabs:host-1:wt-1', 'host-1', SESSION_ROUTE)
).toBe(true)
})
it('refuses a workspace key on a route that names no workspace', () => {
expect(isPageStorageKeyForRoute('orca:nativeChatTabs:host-1:wt-1', 'host-1', HOST_ROUTE)).toBe(
false
)
})
})
describe('what init may carry', () => {
it('keeps a value at the bound and drops the one above it, naming what it dropped', () => {
const held = {
'orca:terminalTextScale': '1',
'orca:mobileStructuredSendOperations:v1': 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS + 1)
}
const { entries, dropped } = pageStorageEntriesForInit(held)
expect(Object.keys(entries)).toEqual(['orca:terminalTextScale'])
expect(dropped).toEqual(['orca:mobileStructuredSendOperations:v1'])
})
it('keeps a value of exactly the bound, so the drop above discriminates', () => {
const at = { 'orca:custom-accessory-keys': 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS) }
expect(pageStorageEntriesForInit(at)).toEqual({ entries: at, dropped: [], oversize: [] })
})
it('never carries more entries than the schema admits', () => {
const held = Object.fromEntries(
Array.from({ length: PAGE_STORAGE_MAX_ENTRIES + 4 }, (_, index) => [`k${String(index)}`, 'v'])
)
const { entries, dropped, oversize } = pageStorageEntriesForInit(held)
expect(Object.keys(entries)).toHaveLength(PAGE_STORAGE_MAX_ENTRIES)
expect(dropped).toHaveLength(4)
// Room, not size: the page may write any of these four itself, so none is refused (33.6).
expect(oversize).toEqual([])
})
})
@@ -10,15 +10,42 @@
* push registrations, the hybrid shell flag itself — and a page that could write any of them could
* turn the feature on for a build that never offered it. A prefix is listed only where the key
* carries an id the desktop chooses; the rest are exact.
*
* Every key here was read off a page route's own closure rather than taken from a list: a key a
* screen reads and this file does not name is a preference that silently falls back to its default
* inside the page and keeps working outside it, which is the failure the grant exists to end
* (rulings-ota-c7.md ruling 7).
*/
export const PAGE_STORAGE_EXACT_KEYS = [
/** The repo the New Workspace drawer opens on. */
'orca:last-visited-worktree'
'orca:last-visited-worktree',
/** The worktree-list sidebar's width, which `app/h/_layout.tsx` renders above every page route. */
'orca:hostSidebarWidth',
/** The session screen's docked panel width, dragged by `use-mobile-dock-resize.ts`. */
'orca:hostDockWidth',
/** The terminal accessory bar's order and visibility. */
'orca:terminal-accessory-layout',
/** The user's own accessory keys, which `CustomKeyModal` writes. */
'orca:custom-accessory-keys',
/** Whether a supported agent session opens on the terminal or the native chat. */
'orca:defaultSessionView',
/** The durable send journal: which `agentSession.send` operation ids are still unsettled. */
'orca:mobileStructuredSendOperations:v1',
/** The terminal's text scale, which pinch-to-zoom writes. */
'orca:terminalTextScale',
/** Whether the terminal's command inputs offer autocorrect. */
'orca:terminalAutocompleteEnabled',
/** Whether a terminal link opens in Orca's browser or the phone's. */
'orca:terminalLinkOpenMode'
] as const
export const PAGE_STORAGE_KEY_PREFIXES = [
/** `orca:pins:<hostId>`: the pinned worktrees of the host whose list the page is showing. */
'orca:pins:'
'orca:pins:',
/** `orca:nativeChatTabs:<hostId>:<worktreeId>`: which tabs of one workspace show the chat. */
'orca:nativeChatTabs:',
/** `orca:terminalLiveInputDisabled:<hostId>:<worktreeId>`: the handles typing goes around. */
'orca:terminalLiveInputDisabled:'
] as const
/** Long enough for a host's pinned ids, and far short of what a quota refuses. */
@@ -37,18 +64,153 @@ export function isPageStorageKey(key: string): boolean {
)
}
/** The keys the shell reads out of the app's store and hands the page in `init`. */
export function pageStorageKeysForHost(hostId: string): string[] {
return [...PAGE_STORAGE_EXACT_KEYS, `orca:pins:${hostId}`]
/**
* The workspace a page route names, or null for a route that names none.
*
* Two of the keys above are scoped to one workspace as well as one host, and both sides of the
* bridge have to agree on which: the shell builds `init` from it and the page holds its writes to
* it. Derived from the pathname rather than passed down, because the pathname is the one thing both
* sides are given — the shell is handed the route it opened and the page reads the same route out
* of `init`, and a second channel for the same fact is how the two would disagree.
*
* The session route is the only pattern with a workspace segment *and* keys of its own, so it is
* the only one read here; the files, review and source-control routes carry a `worktreeId` and no
* per-workspace key, and a route that grows one adds its pattern here beside this one.
*/
export function pageRouteWorkspace(
pathname: string
): { hostId: string; worktreeId: string } | null {
const segments = pathname.split('/')
if (segments.length !== 5 || segments[0] !== '' || segments[1] !== 'h') {
return null
}
if (segments[3] !== 'session') {
return null
}
try {
const hostId = decodeURIComponent(segments[2] ?? '')
const worktreeId = decodeURIComponent(segments[4] ?? '')
return hostId === '' || worktreeId === '' ? null : { hostId, worktreeId }
} catch {
// A stray `%` is not an escape. The route would have been refused upstream; answering null
// here means the page is handed no workspace key rather than a key built from rubbish.
return null
}
}
/** Both workspace-scoped keys are built the same way, so the shape is written once. */
function workspaceScopedKey(prefix: string, hostId: string, worktreeId: string): string {
return `${prefix}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
/**
* The allowlist narrowed to one host, which is the one every write is actually held to.
* The keys the shell reads out of the app's store and hands the page in `init`.
*
* Filtered through `isPageStorageKey` rather than trusted: a workspace id long enough to push its
* key past `PAGE_STORAGE_MAX_KEY_CHARS` would otherwise put a key in `init` that the page's own
* schema refines away, and the page refuses the whole frame rather than that one key.
*/
export function pageStorageKeysForRoute(hostId: string, routePathname: string): string[] {
const workspace = pageRouteWorkspace(routePathname)
const scoped =
workspace === null || workspace.hostId !== hostId
? []
: [
workspaceScopedKey('orca:nativeChatTabs:', hostId, workspace.worktreeId),
workspaceScopedKey('orca:terminalLiveInputDisabled:', hostId, workspace.worktreeId)
]
return [...PAGE_STORAGE_EXACT_KEYS, `orca:pins:${hostId}`, ...scoped].filter(isPageStorageKey)
}
/**
* The allowlist narrowed to one session, which is the one every write is actually held to.
*
* `isPageStorageKey` answers for the shape, so `orca:pins:<any host>` passes it; a page opened for
* one host could therefore rewrite another's pinned list, which is not a key it was ever handed.
* What the page may write is exactly what it was given, so this is that same list.
* The same holds one level further in for the two workspace-scoped keys: a session page opened on
* one workspace must not rewrite another's chat tabs. What the page may write is exactly what it
* was given, so this is that same list.
*/
export function isPageStorageKeyForHost(key: string, hostId: string): boolean {
return key.length <= PAGE_STORAGE_MAX_KEY_CHARS && pageStorageKeysForHost(hostId).includes(key)
export function isPageStorageKeyForRoute(
key: string,
hostId: string,
routePathname: string
): boolean {
return (
key.length <= PAGE_STORAGE_MAX_KEY_CHARS &&
pageStorageKeysForRoute(hostId, routePathname).includes(key)
)
}
/**
* Whether the page opened for this route may write this key, which is three refusals in one.
*
* Not this page's host or route, not a key it was ever told about, or one the shell could not hand
* it for size (ruling 33.6). The last is the one the page cannot be trusted with: a document
* served from an older desktop bundle does not read `storageOversize`, so an allowlisted key it
* holds no value for would be written whole and replace what the device has. Decided here so the
* host and the page's own shim answer the same question.
*/
export function pageMayWriteStorageKey(
key: string,
hostId: string,
route: { pathname: string } | null,
held: PageStorageForInit
): boolean {
return (
route !== null &&
isPageStorageKeyForRoute(key, hostId, route.pathname) &&
!held.storageOversize.includes(key)
)
}
/** What `init` carries about the app's store: the values, and the keys it could not carry. */
export type PageStorageForInit = {
storage: Readonly<Record<string, string>>
storageOversize: readonly string[]
}
/**
* The allowlisted values as `init` may carry them: nothing over the caps the page's schema refines
* on, and the names of whatever was left out.
*
* The send journal is why this exists and is not a hypothetical. Measured on this tree: one entry
* with no attachment costs 343 characters in the array — 342 of its own plus the comma that joins
* it — so 47 unsettled sends measure 16,140 and 48 measure 16,483, past
* `PAGE_STORAGE_MAX_VALUE_CHARS`, and the journal's own schema admits 4,096 of them. Handed to
* `init` whole, the page's `BridgeInitStorageSchema` refuses the *frame* — not the key — and the
* session screen never opens at all. Dropping the key instead leaves the page reading a default,
* which is what `dropped` is for: a degradation the caller can name rather than a page that does
* not start.
*
* `oversize` is the half of `dropped` the page must be told about, and it is a correctness matter
* rather than a diagnostic one (ruling 33.6). A dropped key is still in
* `pageStorageKeysForRoute`, so the page may write it — and for the journal that is destructive:
* the page reads no journal, builds an empty one, and its first send writes a one-entry value over
* the device's, losing every native entry and issuing a fresh `operationId` for an operation
* native already holds. Named here, the page refuses the write instead. Only the value-cap drops
* qualify: an entry-cap drop is a key that fits and did not make the frame, and the page's own
* write of it is the same size the shell would have carried.
*/
export function pageStorageEntriesForInit(held: Readonly<Record<string, string>>): {
entries: Record<string, string>
dropped: string[]
oversize: string[]
} {
const entries: Record<string, string> = {}
const dropped: string[] = []
const oversize: string[] = []
for (const [key, value] of Object.entries(held)) {
if (value.length > PAGE_STORAGE_MAX_VALUE_CHARS) {
dropped.push(key)
oversize.push(key)
continue
}
if (Object.keys(entries).length >= PAGE_STORAGE_MAX_ENTRIES) {
dropped.push(key)
continue
}
entries[key] = value
}
return { entries, dropped, oversize }
}
@@ -232,6 +232,7 @@ describe('the switches that hand a route to the shell', () => {
'files/preview/[worktreeId].tsx',
'index.tsx',
'review/[worktreeId].tsx',
'session/[worktreeId].tsx',
'source-control/[worktreeId].tsx',
'tasks.tsx',
'web.tsx'
@@ -1,4 +1,7 @@
import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge/bridge-envelope'
// From the module that declares it, not from the envelope that re-exports it: the envelope reaches
// this file through the page-to-shell union, so reading the schema back through it is a cycle —
// and one that resolves to `undefined` in the page bundle rather than failing to build.
import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge/bridge-init-route'
/**
* The route to hand the shell, or nothing if the page could not be given it.
@@ -146,12 +146,13 @@ function Harness(props: {
return 'popped'
},
snapshot: SNAPSHOT,
readStorage: () => STORAGE,
readStorage: () => ({ storage: STORAGE, storageOversize: [] }),
onStorageWrite: (key, value) => props.probe.storageWrites.push({ key, value }),
// A fresh closure every render, which is the shape a screen passes and the one a ref must
// absorb: rebuilding the host here would settle every pending request on each render.
onPageFault: (error) => props.faults.push(error),
onRouteRefused: () => {},
onRouteParamClear: () => {},
onBinaryFramesDropped: (total) => props.probe.droppedBinaryFrames.push(total),
onPageReady: () => {
props.readies.push(
@@ -6,6 +6,7 @@ import type {
import { useHostClient } from '../transport/client-context'
import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log'
import type { BridgeInitRoute } from './bridge/bridge-envelope'
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
import { createBridgeHost, type BridgeHost } from './bridge-host'
import type { BridgeNavigateBackOutcome } from './bridge-host-contract'
@@ -13,6 +14,7 @@ import type { BridgeNativeVerb } from './bridge/bridge-native-verbs'
import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
import type { PageHostSnapshot } from './use-page-host-snapshot'
import type { PageStorageForInit } from './page-storage-keys'
class BridgeViewGoneError extends Error {
constructor() {
@@ -46,6 +48,11 @@ export type MobileWebShellBridgeView = {
readonly bridgeEnabled: boolean
readonly viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void
readonly onBridgeMessage: (event: MobileWebShellBridgeMessageEvent) => void
/**
* Hands the mounted host a rewritten route for the screen it is already serving. Dropped when
* there is no host yet; the route the host is built from carries it instead.
*/
readonly publishRoute: (route: BridgeInitRoute) => void
}
/**
@@ -83,12 +90,14 @@ export function useMobileWebShellBridge(args: {
*/
snapshot: PageHostSnapshot | null
/** The allowlisted keys as the app holds them, asked for on each `init` rather than at mount. */
readStorage: () => Readonly<Record<string, string>>
readStorage: () => PageStorageForInit
onStorageWrite: (key: string, value: string | null) => void
/** The page could not render the generation on screen. Reported, never recovered from here. */
onPageFault: (error: BridgeErrorCapture) => void
/** The page asked for a session. Reported so the screen can stop waiting for it. */
onPageReady: () => void
/** The page applied a one-shot route param and asks for it to be erased (ruling 34). */
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
/** This shell named a screen the protocol does not allow, so no session is served. */
onRouteRefused: (issue: string) => void
/** Every screencast frame this host has dropped, so the shell can show the running total. */
@@ -100,9 +109,15 @@ export function useMobileWebShellBridge(args: {
const buildId = ready?.buildId ?? null
const viewRef = useRef<MountedView | null>(null)
const hostRef = useRef<MountedHost | null>(null)
// Fixed for the life of one host: the page routes once, before its first render, so a route that
// changed afterwards would have nothing left to change. Held in a ref for that reason — an inline
// object in the deps would rebuild the host on every render and settle its pendings each time.
// Held in a ref rather than in the deps: an inline object there would rebuild the host on every
// render and settle its pendings each time. The host reads this once, when it is built.
//
// It is not the whole story any more (ruling 33.1). A same-path param change used to be
// unreachable — the page routes once, before its first render, so a route that changed
// afterwards had nothing left to change, and every switch keyed on the whole route to make one
// a remount. The session switch does not: a notification tap for another pane of the session on
// screen is a tab switch, so it keeps `paneKey` out of its key and hands the change to
// `publishRoute` below, which re-sends `init` to a page that said it takes one.
const routeRef = useRef(args.route)
const pageRoutesRef = useRef(args.pageRoutes)
const pageRouteGrantsRef = useRef(args.pageRouteGrants)
@@ -120,6 +135,7 @@ export function useMobileWebShellBridge(args: {
const readStorageRef = useRef(args.readStorage)
const pageFaultRef = useRef(args.onPageFault)
const pageReadyRef = useRef(args.onPageReady)
const routeParamClearRef = useRef(args.onRouteParamClear)
const routeRefusedRef = useRef(args.onRouteRefused)
const binaryFramesDroppedRef = useRef(args.onBinaryFramesDropped)
// Commit-phase and declared above the host's effect, so the host is built against what this
@@ -138,6 +154,7 @@ export function useMobileWebShellBridge(args: {
readStorageRef.current = args.readStorage
pageFaultRef.current = args.onPageFault
pageReadyRef.current = args.onPageReady
routeParamClearRef.current = args.onRouteParamClear
routeRefusedRef.current = args.onRouteRefused
binaryFramesDroppedRef.current = args.onBinaryFramesDropped
}, [
@@ -149,6 +166,7 @@ export function useMobileWebShellBridge(args: {
args.onNavigateBack,
args.onPageFault,
args.onPageReady,
args.onRouteParamClear,
args.onRouteRefused,
args.onStorageWrite,
args.readStorage,
@@ -181,6 +199,9 @@ export function useMobileWebShellBridge(args: {
establishedSessionRef.current = sessionId
pageReadyRef.current()
},
onRouteParamClear: (param, value) => {
routeParamClearRef.current(param, value)
},
onRouteRefused: (issue) => {
routeRefusedRef.current(issue)
},
@@ -239,6 +260,22 @@ export function useMobileWebShellBridge(args: {
mounted.host.receive(event.nativeEvent.json)
},
[sessionId]
),
// Fenced on the session the same way inbound frames are: a host left over from a session this
// render has moved past must not be handed this one's route.
//
// Keyed on everything the host is built from, not on the session alone: a caller that holds a
// route the host was not there to take retries when this identity changes, and the host's own
// effect is a layout effect, so by the time a passive effect sees the new identity the host
// behind it exists.
publishRoute: useCallback(
(route: BridgeInitRoute) => {
const mounted = hostRef.current
if (mounted !== null && mounted.sessionId === sessionId) {
mounted.host.publishRoute(route)
}
},
[buildId, client, sessionId, snapshot]
)
}
}
@@ -37,7 +37,10 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
removeItem: async (key: string) => {
doubles.writes.push({ key, value: null })
doubles.store.delete(key)
}
},
// Read back by the mirror after every write it makes, which is how the note follows what the
// store took rather than what it was handed (ruling 35).
getItem: async (key: string) => doubles.store.get(key) ?? null
}
}))
vi.mock('../transport/host-store', () => ({
@@ -51,15 +54,20 @@ vi.mock('../transport/host-store', () => ({
import { savePinnedIds } from '../storage/preferences'
import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-repo'
import { PAGE_STORAGE_MAX_VALUE_CHARS } from './page-storage-keys'
import { usePageHostSnapshot, type PageHostSnapshotView } from './use-page-host-snapshot'
const PINS = 'orca:pins:host-1'
const LAST_VISITED = 'orca:last-visited-worktree'
const CHAT_TABS = 'orca:nativeChatTabs:host-1:wt-1'
const JOURNAL = 'orca:mobileStructuredSendOperations:v1'
/** The route every case below mounts for: the one page route with workspace-scoped keys. */
const SESSION_ROUTE = '/h/host-1/session/wt-1'
async function mount(): Promise<{ view: () => PageHostSnapshotView }> {
async function mount(routePathname = SESSION_ROUTE): Promise<{ view: () => PageHostSnapshotView }> {
const held: { view: PageHostSnapshotView | null } = { view: null }
function Probe(): null {
held.view = usePageHostSnapshot('host-1')
held.view = usePageHostSnapshot('host-1', routePathname)
return null
}
await act(async () => {
@@ -93,13 +101,13 @@ describe('what the shell puts on every init', () => {
it('carries the write the page just made, not the map it was primed with', async () => {
doubles.store.set(PINS, '["one"]')
const mounted = await mount()
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["one"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["one"]' })
// The device repro: the page writes, its document reloads inside this same mount, and the
// `init` that primes the new document has to carry the write rather than what came before it.
await act(async () => {
mounted.view().writeStorage(PINS, '["one","two"]')
})
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["one","two"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["one","two"]' })
expect(doubles.writes).toEqual([{ key: PINS, value: '["one","two"]' }])
})
@@ -109,18 +117,18 @@ describe('what the shell puts on every init', () => {
await act(async () => {
mounted.view().writeStorage(PINS, null)
})
expect(mounted.view().readStorage()).toEqual({})
expect(mounted.view().readStorage().storage).toEqual({})
})
it('carries what the app wrote from its own screens on the next init, not the one after', async () => {
const mounted = await mount()
expect(mounted.view().readStorage()).toEqual({})
expect(mounted.view().readStorage().storage).toEqual({})
// The device repro: the session screen writes while the page is open, the document reloads,
// and the `init` answering its ready is built from this map with no read in between. A mirror
// only the store read refreshed would hand the drawer the repo the user left, an `init` late.
writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' })
await savePinnedIds('host-1', new Set(['one']))
expect(mounted.view().readStorage()).toEqual({
expect(mounted.view().readStorage().storage).toEqual({
[LAST_VISITED]: JSON.stringify({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' }),
[PINS]: '["one"]'
})
@@ -138,17 +146,17 @@ describe('what the shell puts on every init', () => {
})
// The read was already behind the write when it answered, so putting it back would undo a pin
// the page has been told is set.
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["just-written"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["just-written"]' })
})
it('picks up what the app changed underneath, on the next ask', async () => {
const mounted = await mount()
expect(mounted.view().readStorage()).toEqual({})
expect(mounted.view().readStorage().storage).toEqual({})
doubles.store.set(PINS, '["set-by-the-app"]')
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["set-by-the-app"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["set-by-the-app"]' })
})
it('never carries another host key, whatever the store holds', async () => {
@@ -158,12 +166,65 @@ describe('what the shell puts on every init', () => {
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["mine"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["mine"]' })
// And a write for one is refused rather than mirrored, so a later read cannot answer with it.
await act(async () => {
mounted.view().writeStorage('orca:pins:host-2', '["theirs"]')
})
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["mine"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["mine"]' })
})
it("carries the session route's own workspace, and never the workspace beside it", async () => {
doubles.store.set(CHAT_TABS, '{"tab-1":"chat"}')
doubles.store.set('orca:nativeChatTabs:host-1:wt-2', '{"tab-9":"chat"}')
const mounted = await mount()
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage().storage).toEqual({ [CHAT_TABS]: '{"tab-1":"chat"}' })
await act(async () => {
mounted.view().writeStorage('orca:nativeChatTabs:host-1:wt-2', '{}')
})
expect(mounted.view().readStorage().storage).toEqual({ [CHAT_TABS]: '{"tab-1":"chat"}' })
})
it('hands a route that names no workspace neither of the two, so the line above is the route', async () => {
doubles.store.set(CHAT_TABS, '{"tab-1":"chat"}')
const mounted = await mount('/h/host-1')
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage().storage).toEqual({})
})
it('leaves out a value the page would refuse the whole frame over, and says which', async () => {
// The send journal is the real one: 48 unsettled sends put it past the cap, and `init` is
// refined on that bound — so handing it over takes the session screen down rather than one key.
const warned: unknown[][] = []
const warn = console.warn
console.warn = (...args: unknown[]) => warned.push(args)
try {
doubles.store.set(PINS, '["one"]')
doubles.store.set(JOURNAL, 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS + 1))
const mounted = await mount()
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["one"]' })
} finally {
console.warn = warn
}
expect(JSON.stringify(warned)).toContain(JOURNAL)
})
it('carries the same journal at exactly the bound, so the drop above discriminates', async () => {
const atBound = 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS)
doubles.store.set(JOURNAL, atBound)
const mounted = await mount()
await act(async () => {
mounted.view().refreshStorage()
})
expect(mounted.view().readStorage().storage).toEqual({ [JOURNAL]: atBound })
})
})
@@ -180,7 +241,7 @@ describe('the host the page is handed', () => {
releaseReads()
})
expect(mounted.view().snapshot?.host.id).toBe('host-1')
expect(mounted.view().readStorage()).toEqual({ [PINS]: '["one"]' })
expect(mounted.view().readStorage().storage).toEqual({ [PINS]: '["one"]' })
})
})
@@ -6,7 +6,12 @@ import {
readMirroredStorage,
writeMirroredStorage
} from '../storage/mirrored-storage-keys'
import { isPageStorageKeyForHost, pageStorageKeysForHost } from './page-storage-keys'
import {
isPageStorageKeyForRoute,
pageStorageEntriesForInit,
pageStorageKeysForRoute,
type PageStorageForInit
} from './page-storage-keys'
export type PageHostSnapshot = {
host: BridgeInitHost
@@ -24,7 +29,7 @@ export type PageHostSnapshotView = {
* The allowlisted keys as the app currently holds them, for the host to put on every `init`.
* Synchronous because `init` is; the app's own writers keep it current as they write.
*/
readStorage: () => Readonly<Record<string, string>>
readStorage: () => PageStorageForInit
/** Re-seats that map on the app's store. Cheap, and asked for whenever a page asks to start. */
refreshStorage: () => Promise<void>
/** Applies one page write to the app's store and to the map the next `init` will carry. */
@@ -42,15 +47,15 @@ export type PageHostSnapshotView = {
* keys are re-seated per `init` answer, because the store is their truth; between those reads the
* map is kept current by every writer of one, which is what lets `init` stay synchronous.
*/
export function usePageHostSnapshot(hostId: string): PageHostSnapshotView {
export function usePageHostSnapshot(hostId: string, routePathname: string): PageHostSnapshotView {
const [snapshot, setSnapshot] = useState<PageHostSnapshot | null>(null)
const [unreadable, setUnreadable] = useState(false)
// The allowlist is the shell's, not the mirror's: what the page may be handed is named here on
// every read and every seat, so nothing else the app happens to mirror can reach it.
const refreshStorage = useCallback(
(): Promise<void> => hydrateMirroredStorage(pageStorageKeysForHost(hostId)),
[hostId]
(): Promise<void> => hydrateMirroredStorage(pageStorageKeysForRoute(hostId, routePathname)),
[hostId, routePathname]
)
useEffect(() => {
@@ -98,18 +103,32 @@ export function usePageHostSnapshot(hostId: string): PageHostSnapshotView {
(key: string, value: string | null): void => {
// The host refuses a key outside this list before this ever runs. Held to it here too, so the
// map cannot hold something the next refresh would drop and answer a read with it meanwhile.
if (!isPageStorageKeyForHost(key, hostId)) {
if (!isPageStorageKeyForRoute(key, hostId, routePathname)) {
return
}
writeMirroredStorage(key, value)
},
[hostId]
[hostId, routePathname]
)
return {
snapshot,
unreadable,
readStorage: useCallback(() => readMirroredStorage(pageStorageKeysForHost(hostId)), [hostId]),
readStorage: useCallback(() => {
// Bounded here rather than at the mirror, which holds no policy about the keys it is asked
// for: a value over the cap would otherwise reach `init`, where the page's own schema
// refuses the whole frame and the screen never opens. The name of what was left out is
// reported rather than swallowed, because a preference falling back to its default is a
// degradation someone has to be able to read.
const held = readMirroredStorage(pageStorageKeysForRoute(hostId, routePathname))
const { entries, dropped, oversize } = pageStorageEntriesForInit(held)
if (dropped.length > 0) {
console.warn('[web-shell] a stored value is too large for the page', { keys: dropped })
}
// `oversize` crosses as well as being logged: a key the page holds no value for is one its
// own write would replace rather than extend (ruling 33.6).
return { storage: entries, storageOversize: oversize }
}, [hostId, routePathname]),
refreshStorage,
writeStorage
}
@@ -65,6 +65,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={requestLeaveSession}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Back to worktrees"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
@@ -0,0 +1,20 @@
import { MobileSessionSurface } from './MobileSessionSurface'
import { useMobileSessionController } from './use-mobile-session-controller'
/**
* The session screen as a component rather than a route module, which is what lets a switch hold it.
*
* `useMobileSessionController` is 32 hooks deep and opens the terminal, chat and tab subscriptions;
* hooks cannot be conditional, so a route file that both read the flag and called it would run all
* of that behind the page as well as in front of it. As an element passed for `fallback` it is
* created and not mounted, which is `MobileDiffReviewRouteScreen`'s reason and the explorer
* switch's before it.
*
* The params are read below this file — the controller's own foundation hook reads them, as does
* the notification pane hook — so nothing is handed down and the two route files above are the
* switch and its web sibling.
*/
export function MobileSessionRouteScreen() {
const controller = useMobileSessionController()
return <MobileSessionSurface controller={controller} />
}
@@ -160,6 +160,7 @@ export function QuickCommandsSheet({
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
onPress={() => setView(view === 'agent' ? 'editor' : 'list')}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={18} color={colors.textSecondary} />
@@ -6,6 +6,8 @@ import { describe, expect, it } from 'vitest'
import { MOBILE_SESSION_ROUTE_SOURCE_FILES } from './mobile-session-route-source-family.test-support'
const SESSION_FILES = MOBILE_SESSION_ROUTE_SOURCE_FILES
/** The function the route mounts, which C7.7 moved out of the route file and into a component. */
const ROOT_COMPONENT = 'MobileSessionRouteScreen'
const LOGIC_EXPANSION_NAMES = new Set([
'useMobileSessionController',
'useMobileSessionFoundation',
@@ -65,8 +67,8 @@ const HOST_COMPONENT_NAMES = new Set([
// Refreshed by C7.2: five clipboard hooks joined the expanded route, which is the whole of the +5 —
// a writer in the diff-note, Markdown and selection actions, a reader in the selection actions and
// the attachment probe. The screen's other four clipboard sites (the terminal's paste, the sheets,
// the quick-command row, the diff-review send) sit outside the walk from `SessionScreen` and so do
// not move this pin. The copy-path sheet also gained the failure toast the other two copies had.
// the quick-command row, the diff-review send) sit outside the walk from `MobileSessionRouteScreen`
// and so do not move this pin. The copy-path sheet also gained the failure toast the other two had.
const HEAD_MAIN_HOOK_SHA256 = '6d309ebdf13ecf21e4b42fb29de9db586a3c9835ead43015a5261b67bf18b8f6'
const HEAD_HOOK_BINDING_SHA256 = '9041e8a74efdacc6099933bac11fb624aff46c99648746cf5504bf320ec431c5'
const HEAD_CALLBACK_IDENTITY_SHA256 =
@@ -95,7 +97,7 @@ const HEAD_CALLBACK_BODY_SHA256 = '5845c3b85217a3af9d3d2bfafe564a2b29a1b2c6776b5
// refusal the timer site passed when it had no reply at all. Refreshed once more for the
// last-visited-worktree effect, whose bare store write became the one writer of that key, so the
// hybrid shell's page mirror sees it as it is written rather than one `init` later.
const HEAD_EFFECT_SHA256 = 'dfce9d5cb921c734bd44801283aa579ee61ab69acbbf69ac1e769de24fd829ce'
const HEAD_EFFECT_SHA256 = 'e510f68c935dd4dc4cae8815d4e74f8db70cc3d8e2947d98615e6f2385422f94'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
// Same pin for the 12 bodies that sit in nested functions rather than callbacks, moved by the same
// rewrite of those send and read expressions. Count unchanged. Refreshed again in step 6 for
@@ -120,9 +122,17 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345
// now shows: "Couldn't copy path" when the sheets moved onto the clipboard seam, taking the count
// from 532 to 533, and "Couldn't copy" when the Markdown copy action gained the failure branch the
// other copy paths already had, taking it to 534.
//
// Two more across C7.7, 534 -> 536: `'web'`, the platform guard the Markdown actions'
// `BackHandler` registration gained so it stops logging on the page, and `"button"`, the
// accessibility role the header's Back control gained so the page serves it by name. Neither is a
// behaviour change on a phone. The same `'web'` moves the effect hash, and `"button"` the host-JSX
// hash. The effect hash moved a second time and back: the diff-notes loader's uncaught rejection
// was caught and then reverted, because the fix moves `matrix-session.diff-notes-worktree.show-1`
// and a golden is a review event — so this hash is the one the uncaught `void` call produces.
const HEAD_RUNTIME_STRING_SHA256 =
'ce4c68956cec3b49aaf785e99bc2d7efd3eafeb4fdac6ce116cd854546c045f4'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
'2e533c7b630364b65281c55a5b62bfb76de156f47b4225052243beedf4d215d9'
const HEAD_HOST_JSX_SHA256 = '3ba319e8823e304b1024b5f583ddb340ae583010e522e50ac276231d3658180f'
const HEAD_LEAF_JSX_SHA256 = 'c7e1a4b90197697f1eaa640c38da63281b4f7b84fb036ae2152f00c2f7d7cb77'
const HEAD_STYLE_REFERENCE_SHA256 =
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
@@ -281,7 +291,7 @@ function readNestedFunctions(definitions: ReadonlyMap<string, Definition>): stri
}
visit(definition.declaration.body)
}
visitDefinition('SessionScreen', new Set())
visitDefinition(ROOT_COMPONENT, new Set())
return functions
}
@@ -324,7 +334,7 @@ function readNativeAndTimerFacts(definitions: ReadonlyMap<string, Definition>):
}
}
visitLogicalFunction('FileReader', definitions, collect)
visitLogicalFunction('SessionScreen', definitions, collect)
visitLogicalFunction(ROOT_COMPONENT, definitions, collect)
return { cleanups, creations, registrations, removals }
}
@@ -441,7 +451,7 @@ function readJsxFacts(definitions: ReadonlyMap<string, Definition>): {
for (const name of CONTENT_COMPONENT_NAMES) {
visitDefinition(name)
}
visitDefinition('SessionScreen')
visitDefinition(ROOT_COMPONENT)
const styleReferences: string[] = []
for (const record of [...host, ...leaf]) {
for (const match of record.matchAll(/styles\.([A-Za-z0-9_]+)/g)) {
@@ -459,7 +469,7 @@ function readCompatibilityFacts(definitions: ReadonlyMap<string, Definition>): {
const capabilities: string[] = []
const identityFields: string[] = []
const navigation: string[] = []
visitLogicalFunction('SessionScreen', definitions, (node, sourceFile) => {
visitLogicalFunction(ROOT_COMPONENT, definitions, (node, sourceFile) => {
if (!isRuntimeNode(node)) {
return
}
@@ -510,7 +520,7 @@ function readCompatibilityFacts(definitions: ReadonlyMap<string, Definition>): {
describe('mobile session route extraction parity', () => {
it('preserves hooks, callbacks, effects, and nested action bodies', () => {
const definitions = readDefinitions()
const main = readHookFacts('SessionScreen', definitions)
const main = readHookFacts(ROOT_COMPONENT, definitions)
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
@@ -559,7 +569,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(534)
expect(strings).toHaveLength(536)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
@@ -1,8 +1,16 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
/**
* The session screen as one source family, rooted at the component the route mounts.
*
* `MobileSessionRouteScreen.tsx` and not the route file: C7.7 made that file a flag switch, whose
* business is which of the two screens to render and not what the session screen does. Walking it
* would pin the switch's own params and literals into an extraction parity hash that is about the
* screen, and the root function this family is read from is the one that calls the controller.
*/
export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [
'../../app/h/[hostId]/session/[worktreeId].tsx',
'./MobileSessionRouteScreen.tsx',
'./use-mobile-session-controller.ts',
'./use-mobile-session-foundation.ts',
'./use-mobile-session-screen-state.ts',
@@ -18,8 +18,11 @@ import {
mobileStructuredSendOperationKey,
resetMobileStructuredSendOperationJournalForTests
} from './mobile-structured-send-operation-journal'
import { readMirroredStorage } from '../storage/mirrored-storage-keys'
const NOW = 1_900_000_000_000
/** The key the journal persists under, which the hybrid shell mirrors into every `init`. */
const JOURNAL_KEY = 'orca:mobileStructuredSendOperations:v1'
const OPERATION_KEY = 'a'.repeat(64)
const CALLER_IDENTITY = 'mobile-device-a'
@@ -80,6 +83,47 @@ describe('mobile structured send operation journal', () => {
expect(createAfterRemount).not.toHaveBeenCalled()
})
/**
* A mirror the page reads is not allowed to run ahead of the store (round 4, CodeRabbit).
*
* The hybrid shell builds `init` from the mirror synchronously, so the page is handed whatever
* was noted here. Noting the write before it is persisted is what keeps an `init` in the same
* turn current; keeping the note after the persist was refused publishes a journal that does
* not exist, and the page resumes operations the device never wrote down.
*/
it('rolls the mirror back when persisting an added entry fails', async () => {
await getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => operationIdAt(NOW, '8'),
now: NOW
})
const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]
asyncStorage.setItem.mockRejectedValueOnce(new Error('the store is full'))
await expect(
getOrCreateMobileStructuredSendOperation({
operationKey: 'c'.repeat(64),
createOperationId: () => operationIdAt(NOW, '9'),
now: NOW
})
).rejects.toThrow('the store is full')
expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held)
})
it('rolls the mirror back when persisting the last clear fails', async () => {
const firstId = operationIdAt(NOW, 'a')
await getOrCreateMobileStructuredSendOperation({
operationKey: OPERATION_KEY,
createOperationId: () => firstId,
now: NOW
})
const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]
asyncStorage.removeItem.mockRejectedValueOnce(new Error('the store is full'))
await expect(
clearMobileStructuredSendOperation({ operationKey: OPERATION_KEY, operationId: firstId })
).rejects.toThrow('the store is full')
expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held)
})
it('clears only the exact settled operation', async () => {
const firstId = operationIdAt(NOW, '3')
await getOrCreateMobileStructuredSendOperation({
@@ -1,5 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { z } from 'zod'
import { persistMirrored } from '../storage/mirrored-storage-keys'
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import {
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
@@ -86,11 +87,13 @@ function parseJournal(raw: string | null): OperationJournal {
}
async function writeEntries(entries: OperationEntry[]): Promise<void> {
if (entries.length === 0) {
await AsyncStorage.removeItem(STORAGE_KEY)
return
}
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, entries }))
// Through the one write path, which notes the mirror on an accepted write and on nothing else
// (ruling 35). The rejection this can raise is the point of the key: a journal the device never
// wrote must not reach the page, and the composer above catches it as "Message not sent".
await persistMirrored(
STORAGE_KEY,
entries.length === 0 ? null : JSON.stringify({ v: 1, entries })
)
}
async function serialize<T>(action: () => Promise<T>): Promise<T> {
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* The page's own AsyncStorage under the durable send journal, which is the one allowlisted value
* that outgrows what the bridge will carry.
*
* Measured on this tree: a journal entry with no attachment costs 343 characters in the array —
* 342 of its own plus the comma that joins it — and the schema admits 4,096 of them, so 47
* unsettled sends measure 16,140 and 48 measure 16,483, past `PAGE_STORAGE_MAX_VALUE_CHARS`. Every
* page write goes through `page-async-storage`, which the
* bundler aliases over the real module, so this is the module the journal actually writes to
* inside the page — and the reason the refusal must be a rejection rather than a dropped write.
*
* What a resolved refusal would cost, which is more than a lost preference: the journal would
* answer with an operation id no store holds, the mutation would go out carrying it, and a retry
* after a crash would send the same message again. Ruling 7's "nothing silently no-ops", at the
* one key where silence is a duplicate message rather than a default.
*
* The other half of the chain — that the rejection reaches the composer as "Message not sent" — is
* in `use-mobile-structured-agent-session-send.test.tsx`, which already drives the real send with
* a client double; asserting it here would mean building a second one.
*/
vi.mock('@react-native-async-storage/async-storage', async () => ({
default: (await import('../mobile-web-shell/bridge/page-async-storage')).default
}))
const { publishPageStorage } = await import('../mobile-web-shell/bridge/page-async-storage')
const { PAGE_STORAGE_MAX_VALUE_CHARS, pageStorageEntriesForInit } =
await import('../mobile-web-shell/page-storage-keys')
const {
getOrCreateMobileStructuredSendOperation,
resetMobileStructuredSendOperationJournalForTests
} = await import('./mobile-structured-send-operation-journal')
const HOST_ID = 'host-1'
const SESSION_ROUTE = '/h/host-1/session/wt-1'
const JOURNAL = 'orca:mobileStructuredSendOperations:v1'
const hex = (fill: string) => fill.repeat(64)
/**
* A journal the module itself reads back, built past the cap out of real entries rather than
* filler: a value the parser refuses reads as "unreadable" and never reaches the write at all.
* `ENTRIES_OVER_THE_CAP` is the measured number — one entry costs 343 characters in the array.
*/
const ENTRIES_OVER_THE_CAP = 48
function storedJournal(count: number): string {
return JSON.stringify({
v: 1,
entries: Array.from({ length: count }, (_, index) => ({
operationKey: index.toString(16).padStart(64, '0'),
operationId: `17584320${String(index).padStart(5, '0')}-${'b'.repeat(32)}`,
callerFingerprint: hex('c'),
payloadFingerprint: hex('d'),
attachmentPaths: []
}))
})
}
const posted: { key: string; value: string | null }[] = []
/**
* The page seated the way the shell seats it, rather than from a hand-written record.
*
* `pageStorageEntriesForInit` is the split the shell runs before `init` is built, so driving the
* page through it is what makes these states ones production can reach: a journal over the cap
* never arrives as a value, it arrives as a name on the oversize list.
*/
function publishAsTheShellWould(held: Record<string, string>): void {
posted.length = 0
const { entries, oversize } = pageStorageEntriesForInit(held)
publishPageStorage(
entries,
(key, value) => {
posted.push({ key, value })
return true
},
HOST_ID,
SESSION_ROUTE,
oversize
)
}
function publish(entries: Record<string, string>): void {
publishAsTheShellWould(entries)
}
function claim() {
return getOrCreateMobileStructuredSendOperation({
operationKey: hex('a'),
callerIdentity: 'caller-1',
payloadFingerprint: hex('d'),
attachmentPaths: [],
createOperationId: () => `${String(Date.now())}-${hex('b').slice(0, 32)}`
})
}
beforeEach(() => {
resetMobileStructuredSendOperationJournalForTests()
publish({})
})
describe('the durable send journal against the page store', () => {
it('rejects the send that would take the journal past what the page may write', async () => {
// The real precondition, seated through the shell's own split: 47 entries fit, so `init`
// carries them and the page holds a journal it can read. The 48th is the one that does not.
const held = storedJournal(ENTRIES_OVER_THE_CAP - 1)
expect(held.length).toBeLessThanOrEqual(PAGE_STORAGE_MAX_VALUE_CHARS)
expect(storedJournal(ENTRIES_OVER_THE_CAP).length).toBeGreaterThan(PAGE_STORAGE_MAX_VALUE_CHARS)
publishAsTheShellWould({ [JOURNAL]: held })
await expect(claim()).rejects.toThrow(/could not save/)
// Nothing posted either: the value the wire would have dropped never left the page.
expect(posted).toEqual([])
})
/**
* The destructive one (ruling 33.6).
*
* A native journal past the cap is dropped from `init` for size, and the key stays in
* `pageStorageKeysForRoute`. Without the oversize list the page reads `null`, `parseJournal`
* answers an empty journal, and the first send writes a one-entry value over the device's — the
* native entries gone and a fresh `operationId` for an operation native already holds, which is
* the duplicate send ruling 7 exists to prevent.
*/
it('refuses a send when init could not carry the journal, instead of replacing it', async () => {
const held = storedJournal(ENTRIES_OVER_THE_CAP)
const { entries, oversize } = pageStorageEntriesForInit({ [JOURNAL]: held })
// The precondition itself: the shell hands no value for this key and names it instead.
expect(entries).toEqual({})
expect(oversize).toEqual([JOURNAL])
publishAsTheShellWould({ [JOURNAL]: held })
await expect(claim()).rejects.toThrow(/could not save/)
// The half that makes it destructive: nothing was posted, so the native journal is untouched.
expect(posted).toEqual([])
})
it('claims an id when the journal fits, so the refusal above is the size and not the path', async () => {
publish({})
await expect(claim()).resolves.toEqual(expect.objectContaining({ retained: false }))
expect(posted.map((write) => write.key)).toEqual([JOURNAL])
})
})
@@ -0,0 +1,21 @@
import { parsePaneKey } from '../../../src/shared/stable-pane-id'
import type { MobileSessionTab } from './mobile-session-route-types'
/**
* The tab a notification's pane key names, or nothing when that pane has since closed.
*
* Its own module because both siblings of the navigation hook read it, and a web sibling cannot
* import its native neighbour by name: the bundler's `resolveExtensions` answers
* `./use-notification-pane-navigation` with the `.web.ts` file, so that import is the file itself.
*/
export function notificationPaneTab(tabs: readonly MobileSessionTab[], paneKey: string) {
const pane = parsePaneKey(paneKey)
if (!pane) {
return undefined
}
return tabs.find((tab) =>
tab.type === 'terminal'
? (tab.parentTabId ?? tab.id) === pane.tabId && tab.leafId === pane.leafId
: tab.type === 'agent-session' && tab.id === pane.tabId
)
}
@@ -0,0 +1,180 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => {
// Annotated rather than asserted: the literal alone narrows to 'ios' and the tests reassign it.
const platform: { os: 'ios' | 'android' | 'web' } = { os: 'ios' }
const remove = vi.fn()
return {
platform,
remove,
dismiss: vi.fn(),
addEventListener: vi.fn((_event: string, _handler: () => boolean) => ({ remove }))
}
})
vi.mock('react-native', () => ({
BackHandler: {
addEventListener: (event: string, handler: () => boolean) =>
native.addEventListener(event, handler)
},
Keyboard: { dismiss: () => native.dismiss() },
get Platform() {
return { OS: native.platform.os }
}
}))
vi.mock('../platform/clipboard', () => ({
useClipboardWriter: () => ({ writeText: async () => {} })
}))
vi.mock('../platform/haptics', () => ({ triggerSuccess: () => {}, triggerError: () => {} }))
vi.mock('./mobile-session-write-operations', () => ({ markdownTabSave: () => ({}) }))
import {
useMobileSessionMarkdownActions,
type MobileSessionMarkdownActionsScope
} from './use-mobile-session-markdown-actions'
import type { MarkdownDocState } from './mobile-session-route-types'
const leaves: { back: (() => void) | null } = { back: null }
/** The three members the hook calls, which is all a probe of it can honestly stand behind. */
const probeRouter = {
canGoBack: () => true,
back: () => {
leaves.back?.()
},
replace: () => {}
}
/** A dirty draft is what makes the gate's effect re-register, which is the second half of the gate. */
function scopeWith(markdownDocs: Map<string, MarkdownDocState>): MobileSessionMarkdownActionsScope {
return {
hostId: 'host-1',
worktreeId: 'wt-1',
/**
* SAFETY: expo-router's `Router` carries members this probe has no use for, and the hook calls
* exactly the three above. A call to any other is a TypeError this probe fails on rather than
* passes through, which is the invariant the assertion stands on.
*/
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stated above.
router: probeRouter as unknown as MobileSessionMarkdownActionsScope['router'],
client: null,
sessionTabs: [],
markdownDocs,
setMarkdownDocs: () => {},
discardMarkdownTarget: null,
setDiscardMarkdownTarget: () => {},
setLeaveDrafts: () => {},
markdownSaveSeqRef: { current: new Map() },
markdownSaveInFlightRef: { current: new Set() },
showToast: () => {},
readMarkdownTab: async () => {}
}
}
function readyDoc(content: string, localContent: string): MarkdownDocState {
return {
status: 'ready',
content,
localContent,
baseVersion: 'v1',
isDirty: content !== localContent,
editable: true
}
}
function Probe({ docs }: { docs: Map<string, MarkdownDocState> }): null {
useMobileSessionMarkdownActions(scopeWith(docs))
return null
}
function render(docs: Map<string, MarkdownDocState>): ReturnType<typeof create> {
let renderer: ReturnType<typeof create> | null = null
act(() => {
renderer = create(createElement(Probe, { docs }))
})
if (renderer === null) {
throw new Error('the probe did not render')
}
return renderer
}
beforeEach(() => {
native.platform.os = 'ios'
native.addEventListener.mockClear()
native.remove.mockClear()
native.dismiss.mockClear()
leaves.back = null
})
/**
* The session's own hardware-back registration, which had no unit test of its own (ruling 33.2).
*
* Two things were wrong before C7.7 and both are asserted here. React Native Web answers
* `BackHandler.addEventListener` with "BackHandler is not supported on web and should not be used."
* and an inert subscription, so inside the shell's page every session mount put that line on the
* console and armed nothing — and the effect re-registers whenever the dirty-draft list changes,
* which is why it was two lines and not one.
*/
describe("the session's hardware back gate", () => {
it('arms the hardware back press natively', () => {
render(new Map())
expect(native.addEventListener).toHaveBeenCalledTimes(1)
expect(native.addEventListener.mock.calls[0]?.[0]).toBe('hardwareBackPress')
})
it('never arms it on the web, where it is inert and says so on the console', () => {
native.platform.os = 'web'
render(new Map())
expect(native.addEventListener).not.toHaveBeenCalled()
})
it('leaves through the router when nothing is dirty', () => {
render(new Map())
const handler = native.addEventListener.mock.calls[0]?.[1]
const left = vi.fn()
leaves.back = left
act(() => {
expect(handler?.()).toBe(true)
})
expect(left).toHaveBeenCalledTimes(1)
expect(native.dismiss).not.toHaveBeenCalled()
})
it('asks instead of leaving when a draft is dirty, and dismisses the keyboard to ask', () => {
render(new Map([['tab-1', readyDoc('saved', 'edited')]]))
const handler = native.addEventListener.mock.calls[0]?.[1]
const left = vi.fn()
leaves.back = left
act(() => {
expect(handler?.()).toBe(true)
})
expect(left).not.toHaveBeenCalled()
expect(native.dismiss).toHaveBeenCalledTimes(1)
})
it('re-registers when the dirty-draft list changes, and removes what it replaced', () => {
const renderer = render(new Map())
expect(native.addEventListener).toHaveBeenCalledTimes(1)
act(() => {
renderer.update(
createElement(Probe, { docs: new Map([['tab-1', readyDoc('saved', 'edited')]]) })
)
})
expect(native.addEventListener).toHaveBeenCalledTimes(2)
expect(native.remove).toHaveBeenCalledTimes(1)
})
it('never re-registers on the web, however many times the drafts change', () => {
native.platform.os = 'web'
const renderer = render(new Map())
act(() => {
renderer.update(
createElement(Probe, { docs: new Map([['tab-1', readyDoc('saved', 'edited')]]) })
)
})
expect(native.addEventListener).not.toHaveBeenCalled()
expect(native.remove).not.toHaveBeenCalled()
})
})
@@ -60,6 +60,12 @@ export function useMobileSessionDiffComments(scope: MobileSessionDocumentReaders
)
useEffect(() => {
// No catch, deliberately, and it is a recorded defect rather than an oversight: a *refused*
// `worktree.show` returns above, and a *rejected* one is an unhandled rejection on every mount
// — visible in the page as a document-level error, measured by
// `config/scripts/mobile-web-app-session-render.test.mjs`. Adding `.catch` here is the fix and
// it moves a golden: `matrix-session.diff-notes-worktree.show-1` certifies the rejection as an
// effect of the loaded checkpoint, so the change is a re-record and a review event, not a line.
void loadDiffComments()
}, [loadDiffComments])
@@ -1,12 +1,39 @@
import { useEffect, useCallback } from 'react'
import { BackHandler, Keyboard } from 'react-native'
import { BackHandler, Keyboard, Platform } from 'react-native'
import { useClipboardWriter } from '../platform/clipboard'
import { markdownTabSave } from './mobile-session-write-operations'
import { triggerSuccess, triggerError } from '../platform/haptics'
import type { DirtyMarkdownDraft, MobileSessionTab } from './mobile-session-route-types'
import type { MobileSessionDiffCommentsModel } from './use-mobile-session-diff-comments'
export function useMobileSessionMarkdownActions(scope: MobileSessionDiffCommentsModel) {
/**
* What these actions read, which is fourteen of the session model's two hundred and sixty-eight.
*
* Declared rather than taking the whole model, so the hook can be rendered on its own: the gate
* below is the only `BackHandler` registration in this tree without a unit test of its own
* (ruling 33.2), and a probe that had to build the whole session to reach it would be testing the
* session. `MobileSessionDiffCommentsModel` satisfies this by construction, so the one caller is
* unchanged.
*/
export type MobileSessionMarkdownActionsScope = Pick<
MobileSessionDiffCommentsModel,
| 'hostId'
| 'worktreeId'
| 'router'
| 'client'
| 'sessionTabs'
| 'setMarkdownDocs'
| 'markdownDocs'
| 'setDiscardMarkdownTarget'
| 'discardMarkdownTarget'
| 'setLeaveDrafts'
| 'markdownSaveSeqRef'
| 'markdownSaveInFlightRef'
| 'showToast'
| 'readMarkdownTab'
>
export function useMobileSessionMarkdownActions(scope: MobileSessionMarkdownActionsScope) {
const {
hostId,
worktreeId,
@@ -94,6 +121,15 @@ export function useMobileSessionMarkdownActions(scope: MobileSessionDiffComments
}, [getDirtyMarkdownDrafts, leaveSession])
useEffect(() => {
// Native only, as the drawers and the file preview already are: react-native-web's
// `BackHandler.addEventListener` logs "BackHandler is not supported on web and should not be
// used." and hands back an inert subscription, and this effect re-registers whenever the
// dirty-draft list changes — two lines on the console at mount, measured. There is no hardware
// back to intercept in a WebView; the shell owns the phone's, and the page's own Back control
// is where the unsaved-draft prompt lives.
if (Platform.OS === 'web') {
return
}
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
requestLeaveSession()
return true
@@ -430,4 +430,22 @@ describe('mobile structured send retries', () => {
expect(calls()).toHaveLength(0)
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
it('puts a store that would not take the journal on screen, and sends nothing', async () => {
// Inside the page the store is the app's, reached over the `storage` grant, and it rejects a
// journal past `PAGE_STORAGE_MAX_VALUE_CHARS` — 48 unsettled sends, measured. A refusal that
// resolved instead would put a mutation on the wire carrying an operation id nothing holds,
// and a retry after a crash would send this message twice (rulings-ota-c7.md ruling 7).
asyncStorage.setItem.mockImplementation(async () => {
throw new Error('Orca could not save orca:mobileStructuredSendOperations:v1')
})
await mountSession()
await act(async () => {
expect(await hook!.sendWithOutcome('the journal will not take this')).toBe('rejected')
})
expect(onSendError).toHaveBeenCalledWith('Message not sent')
expect(calls()).toHaveLength(0)
})
})
@@ -1,10 +1,8 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import {
notificationPaneTab,
useNotificationPaneNavigation
} from './use-notification-pane-navigation'
import { notificationPaneTab } from './notification-pane-tab'
import { useNotificationPaneNavigation } from './use-notification-pane-navigation'
import type { MobileSessionTab } from './mobile-session-route-types'
const route = vi.hoisted(() => ({ paneKey: '', setParams: vi.fn() }))
vi.mock('expo-router', () => ({
@@ -1,21 +1,9 @@
import { useEffect } from 'react'
import { useLocalSearchParams } from 'expo-router'
import { useRouteHandoff } from '../navigation/route-handoff'
import { parsePaneKey } from '../../../src/shared/stable-pane-id'
import { notificationPaneTab } from './notification-pane-tab'
import type { MobileSessionTab } from './mobile-session-route-types'
export function notificationPaneTab(tabs: readonly MobileSessionTab[], paneKey: string) {
const pane = parsePaneKey(paneKey)
if (!pane) {
return undefined
}
return tabs.find((tab) =>
tab.type === 'terminal'
? (tab.parentTabId ?? tab.id) === pane.tabId && tab.leafId === pane.leafId
: tab.type === 'agent-session' && tab.id === pane.tabId
)
}
export function useNotificationPaneNavigation({
sessionTabs,
terminalsLoaded,
@@ -0,0 +1,200 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BridgeInitRoute } from '../mobile-web-shell/bridge/bridge-envelope'
import type { MobileSessionTab } from './mobile-session-route-types'
type RouteUpdateListener = (route: BridgeInitRoute | null) => void
const bridge = vi.hoisted(() => {
const held: {
route: BridgeInitRoute | null
listeners: RouteUpdateListener[]
/** Every erase the page asked the shell for, in order (ruling 34). */
cleared: { param: string; value: string }[]
} = { route: null, listeners: [], cleared: [] }
return held
})
vi.mock('../transport/client-context.web', () => ({
usePageBridgeClient: () => ({
getShellSession: () => ({ route: bridge.route }),
onRouteUpdate: (listener: RouteUpdateListener) => {
bridge.listeners.push(listener)
return () => {
bridge.listeners = bridge.listeners.filter((held) => held !== listener)
}
},
clearRouteParam: (param: string, value: string) => {
bridge.cleared.push({ param, value })
return true
}
})
}))
import { useNotificationPaneNavigation } from './use-notification-pane-navigation.web'
/** A real leaf id: `parsePaneKey` refuses anything that is not one, so a made-up key parses to
* nothing and every case below would read as "the pane closed". */
const LEAF = '11111111-1111-4111-8111-111111111111'
const TABS: MobileSessionTab[] = [
{
type: 'terminal',
id: 'first',
parentTabId: 'tab-a',
leafId: LEAF,
title: 'first',
terminal: 'pty-a',
isActive: true
},
{
type: 'terminal',
id: 'second',
parentTabId: 'tab-b',
leafId: LEAF,
title: 'second',
terminal: 'pty-b',
isActive: false
}
]
const switched: MobileSessionTab[] = []
function Probe({ terminalsLoaded }: { terminalsLoaded: boolean }): null {
useNotificationPaneNavigation({
sessionTabs: TABS,
terminalsLoaded,
switchSessionTab: (tab) => switched.push(tab)
})
return null
}
function render(terminalsLoaded: boolean): ReactTestRenderer {
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
act(() => {
rendered.tree = create(createElement(Probe, { terminalsLoaded }))
})
if (rendered.tree === null) {
throw new Error('the probe did not render')
}
return rendered.tree
}
/** What the shell does: re-send `init`, which the client publishes as a route that moved. */
function deliver(paneKey: string): void {
act(() => {
for (const listener of bridge.listeners.slice()) {
listener({ pathname: '/h/host-1/session/wt-1', params: { paneKey } })
}
})
}
beforeEach(() => {
bridge.route = null
bridge.listeners = []
bridge.cleared.length = 0
switched.length = 0
})
/**
* The page's pane hook, which has no route to read and no param to write back.
*
* Its native sibling reads `paneKey` off the app's route and clears it with `setParams`; inside
* the page the document is served at `/` with one history entry, so the request arrives as a
* re-sent `init` instead (ruling 33.1) and the shell clears the native param once this page has
* been handed one. The native file's test mounts the native file, and the bridge test stops at
* the client, so this is the only cover this half has.
*/
describe('the page pane hook', () => {
it('switches to the pane the page was opened on, which arrives in the first init', () => {
bridge.route = { pathname: '/h/host-1/session/wt-1', params: { paneKey: `tab-b:${LEAF}` } }
render(true)
expect(switched.map((tab) => tab.id)).toEqual(['second'])
})
it('holds that request until the terminals have loaded, rather than dropping it', () => {
bridge.route = { pathname: '/h/host-1/session/wt-1', params: { paneKey: `tab-b:${LEAF}` } }
const tree = render(false)
expect(switched).toEqual([])
act(() => {
tree.update(createElement(Probe, { terminalsLoaded: true }))
})
expect(switched.map((tab) => tab.id)).toEqual(['second'])
})
it('erases the param that carried the pane it applied', () => {
// The reader erases (ruling 34). The shell holds the request until the page that applied it
// says so, naming the value: a tap that moved on since leaves a newer one on the route, and
// the shell refuses this by comparison rather than by a sequence number.
bridge.route = { pathname: '/h/host-1/session/wt-1', params: { paneKey: `tab-b:${LEAF}` } }
render(true)
expect(bridge.cleared).toEqual([{ param: 'paneKey', value: `tab-b:${LEAF}` }])
})
it('switches again for a repeat tap on the pane already showing', () => {
// The clear between the two is what makes the second a request rather than a repetition: the
// shell erases the param, re-sends the route without it, and the tap writes it back.
render(true)
deliver(`tab-a:${LEAF}`)
deliver('')
deliver(`tab-a:${LEAF}`)
expect(switched.map((tab) => tab.id)).toEqual(['first', 'first'])
expect(bridge.cleared).toEqual([
{ param: 'paneKey', value: `tab-a:${LEAF}` },
{ param: 'paneKey', value: `tab-a:${LEAF}` }
])
})
it('applies one pane once however many inits carry it', () => {
// A re-asked `ready` is answered with the route the shell holds, which is still this one while
// the clear is in flight or was lost. Applying is a no-op the second time; asking again is not,
// because a clear that never arrived is repaired by this.
render(true)
deliver(`tab-a:${LEAF}`)
deliver(`tab-a:${LEAF}`)
expect(switched.map((tab) => tab.id)).toEqual(['first'])
expect(bridge.cleared).toHaveLength(2)
})
it('takes the tap that arrived while it was applying the one before it', () => {
render(true)
deliver(`tab-a:${LEAF}`)
deliver(`tab-b:${LEAF}`)
expect(switched.map((tab) => tab.id)).toEqual(['first', 'second'])
// Both are named back; the shell spends only the one its param still holds.
expect(bridge.cleared.map((entry) => entry.value)).toEqual([`tab-a:${LEAF}`, `tab-b:${LEAF}`])
})
it('takes a different pane as its own request', () => {
render(true)
deliver(`tab-a:${LEAF}`)
deliver(`tab-b:${LEAF}`)
expect(switched.map((tab) => tab.id)).toEqual(['first', 'second'])
})
it('asks for nothing and applies nothing when the route carries no pane', () => {
// Load-bearing rather than defensive: the shell's own erase comes back as a route that moved,
// and so does every `ready` answered after it.
render(true)
deliver(`tab-a:${LEAF}`)
bridge.cleared.length = 0
deliver('')
expect(switched.map((tab) => tab.id)).toEqual(['first'])
expect(bridge.cleared).toEqual([])
})
it('consumes a request for a pane that has since closed, so no later render serves it', () => {
render(true)
deliver(`gone:${LEAF}`)
expect(switched).toEqual([])
// A re-render with nothing new delivered must not go looking for it again.
deliver('')
expect(switched).toEqual([])
})
it('does nothing at all when the page was opened on no pane', () => {
render(true)
expect(switched).toEqual([])
})
})
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react'
import { usePageBridgeClient } from '../transport/client-context.web'
import type { MobileSessionTab } from './mobile-session-route-types'
import { notificationPaneTab } from './notification-pane-tab'
/**
* Web sibling: the pane a notification tap asked for, read off the shell rather than off a router.
*
* On the page there is no native route to read `paneKey` from and no native param to write back:
* the page is one document served at `/`, and `setParams` here would rewrite the document's own
* history entry while the app's route kept the spent tap. The shell delivers the request as a
* re-sent `init` for the session this page already holds, and tracks nothing about whether it
* arrived — the reader erases (ruling 34), so this hook is what spends the tap.
*
* Two things follow from that, and both are here rather than in the shell. The erase is asked for
* on every `init` that carries a pane, not only on the one that changed something: a clear that
* never reached the shell leaves the param in place, and the next `init` carrying it is the repair.
* The switch happens once per value: a re-asked `ready` is answered with the route the shell still
* holds, and applying that again would drag the page back off a tab the user has since moved to.
*
* The request waits in a ref and a counter wakes the effect, rather than the request living in
* state: a tap can arrive before the terminals have loaded, and an effect that adjusted state on
* every delivery would render the stale selection first.
*/
export function useNotificationPaneNavigation({
sessionTabs,
terminalsLoaded,
switchSessionTab
}: {
sessionTabs: MobileSessionTab[]
terminalsLoaded: boolean
switchSessionTab: (tab: MobileSessionTab) => void
}) {
const client = usePageBridgeClient()
// Seeded from the route this page was opened on: a tap that opened the session arrives in the
// first `init` and never as an update, so a hook that only listened would lose it.
const opened = client.getShellSession()?.route?.params?.paneKey ?? null
const pending = useRef(opened)
/** The pane this hook last acted on, so one request is not served twice. Cleared by the erase. */
const applied = useRef<string | null>(null)
const [asked, setAsked] = useState(0)
useEffect(() => {
// The route this page opened on is erased once, here: it arrived in the first `init`, which
// is not an update, so nothing below would ever name it back. Read from the client rather
// than from the render above, which keeps this effect keyed on the client alone.
const seed = client.getShellSession()?.route?.params?.paneKey ?? null
if (seed !== null && seed !== '') {
client.clearRouteParam('paneKey', seed)
}
return client.onRouteUpdate((route) => {
const paneKey = route?.params?.paneKey ?? ''
if (paneKey === '') {
// The shell's own erase, and every `ready` answered after it. Not a request, and what
// releases the next tap for the pane this one named.
applied.current = null
return
}
// Asked for on arrival rather than on the switch below: the shell holds the request until
// it hears, so a clear that was lost has to be re-asked by the frame that repeats it.
client.clearRouteParam('paneKey', paneKey)
if (paneKey === applied.current) {
return
}
pending.current = paneKey
setAsked((count) => count + 1)
})
}, [client])
useEffect(() => {
const paneKey = pending.current
if (paneKey === null || paneKey === '' || !terminalsLoaded) {
return
}
// Consumed even when the pane has since closed, for the reason the native hook consumes its
// param: a request left standing is one a later render would serve.
pending.current = null
applied.current = paneKey
const tab = notificationPaneTab(sessionTabs, paneKey)
if (tab) {
switchSessionTab(tab)
}
}, [asked, sessionTabs, switchSessionTab, terminalsLoaded])
}
+44 -7
View File
@@ -5,9 +5,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
*
* The hybrid shell is that reader: it hands the page these keys on every `init`, which is built
* synchronously, so a value it had to read out of the store would always be the one from before
* the app's last write. Every writer of a mirrored key notes it here as it writes, and the store
* read below only seats the map, so what a reader is answered with is current at the moment it
* asks. Which keys those are is the caller's to say: this holds no policy about them.
* the app's last write. Every writer of a mirrored key goes through `persistMirrored` below, which
* is the only thing that writes this map, and the store read only seats it. Which keys those are
* is the caller's to say: this holds no policy about them.
*/
const mirror = new Map<string, string>()
@@ -59,8 +59,7 @@ export async function hydrateMirroredStorage(keys: readonly string[]): Promise<v
}
}
/** Notes a write whose caller persists it itself, which is how the app's own writers stay current. */
export function noteMirroredWrite(key: string, value: string | null): void {
function note(key: string, value: string | null): void {
writeCount += 1
if (value === null) {
mirror.delete(key)
@@ -69,9 +68,47 @@ export function noteMirroredWrite(key: string, value: string | null): void {
}
}
/** Noted first, then persisted, because a reader is answered from the map and not from the store. */
/**
* The one way a mirrored key is written, and the only thing that notes the map (ruling 35).
*
* Noted on an accepted write and on nothing else. The store is what accepts: inside the page it is
* the bridge's adapter, which refuses a key this route was never given and a value past the frame
* cap, and on the device it is AsyncStorage, which refuses neither. Deciding here rather than in
* fourteen writers is the whole point — twelve of them noted first and never looked again, so a
* refused page write left this map holding a value no store had taken and the next `init` handed
* the page exactly that. There is nothing to undo, because nothing is written until the answer.
*
* Returns the store's own promise, so a caller that has something to say about a refusal — the
* durable send journal is the one — still hears it, and a caller that has not is unchanged.
*/
export function persistMirrored(key: string, value: string | null): Promise<void> {
const write = value === null ? AsyncStorage.removeItem(key) : AsyncStorage.setItem(key, value)
// Seated from what the store holds afterwards rather than from what was asked for, because a
// refusal is not always a rejection: the page's adapter resolves a `not-allowed` write and logs
// it, so a page-closure writer that awaits with no catch does not raise an unhandled rejection
// in the document. Reading back is what makes the note the store's answer instead of a guess.
return write
.then(() => AsyncStorage.getItem(key))
.then((stored) => {
note(key, stored)
})
}
/**
* The app taking a value the page has already applied, for the one store that cannot refuse one.
*
* Noted first here, and that is not a second policy: the shell writes this to the device's own
* AsyncStorage, which has no allowlist and no frame cap to refuse against, so the answer is known
* before it is asked for. What the ordering buys is the next `init`, which the shell builds
* synchronously in the same turn it takes the write — noting on the store's reply instead would
* hand the page back the value it just changed.
*
* Its one caller is the shell's own storage hook, which is native-only; a page-reachable writer
* belongs on `persistMirrored`, where a refusal is a real possibility. The census beside this
* module is what holds that to one caller.
*/
export function writeMirroredStorage(key: string, value: string | null): void {
noteMirroredWrite(key, value)
note(key, value)
void (value === null ? AsyncStorage.removeItem(key) : AsyncStorage.setItem(key, value)).catch(
() => {
// Nothing is owed to the page for a notify, and a pin that failed to persist is not a reason
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* The one write path every mirrored key goes through, driven against the page's own store.
*
* The adapter is what can refuse — `not-allowed` for a key this route was never given,
* `too-large` for a value past the frame cap — and ruling 35 puts the mirror note behind that
* decision. Before it, every writer noted the mirror first and two of them rolled it back by
* hand: twelve did not, so a refused page write left the app's mirror holding a value the store
* had never taken, and the next `init` handed the page exactly that.
*
* Driven through the real page adapter rather than a stub, because the refusals under test are
* its own and a stub would be a second opinion about them.
*/
vi.mock('@react-native-async-storage/async-storage', async () => ({
default: (await import('../mobile-web-shell/bridge/page-async-storage')).default
}))
const { publishPageStorage } = await import('../mobile-web-shell/bridge/page-async-storage')
const { pageStorageEntriesForInit, pageStorageKeysForRoute, PAGE_STORAGE_MAX_VALUE_CHARS } =
await import('../mobile-web-shell/page-storage-keys')
const { persistMirrored, readMirroredStorage } = await import('./mirrored-storage-keys')
const HOST_ID = 'host-1'
const ROUTE = '/h/host-1/session/wt-1'
const DOCK_WIDTH = 'orca:hostDockWidth'
const OTHER_HOST_PIN = 'orca:pins:host-2'
/** What the shell would put in the next `init` for this route, which is the mirror's one reader. */
function initStorage(): Readonly<Record<string, string>> {
return pageStorageEntriesForInit(readMirroredStorage(pageStorageKeysForRoute(HOST_ID, ROUTE)))
.entries
}
beforeEach(() => {
publishPageStorage({ [DOCK_WIDTH]: '320' }, () => true, HOST_ID, ROUTE)
// The app's side of the same key, which is what a refused write must leave standing.
return persistMirrored(DOCK_WIDTH, '320')
})
describe('the mirrored write path', () => {
it('leaves the mirror and the next init alone when the store refuses the value', async () => {
const before = initStorage()
await expect(
persistMirrored(DOCK_WIDTH, 'x'.repeat(PAGE_STORAGE_MAX_VALUE_CHARS + 1))
).rejects.toThrow('could not save')
expect(readMirroredStorage([DOCK_WIDTH])[DOCK_WIDTH]).toBe('320')
expect(initStorage()).toEqual(before)
})
it('leaves them alone for a key this route was never given, and logs the drop', async () => {
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
// Resolves rather than rejects: a page-closure writer of an unlisted key awaits with no catch.
await expect(persistMirrored(OTHER_HOST_PIN, '["a"]')).resolves.toBeUndefined()
expect(readMirroredStorage([OTHER_HOST_PIN])[OTHER_HOST_PIN]).toBeUndefined()
expect(warned).toHaveBeenCalledWith(
'[page-bridge] storage-write-dropped',
expect.objectContaining({ key: OTHER_HOST_PIN, refusal: 'not-allowed' })
)
warned.mockRestore()
})
it('answers a reader with an accepted write, which is what `init` is built from', async () => {
await persistMirrored(DOCK_WIDTH, '480')
expect(readMirroredStorage([DOCK_WIDTH])[DOCK_WIDTH]).toBe('480')
expect(initStorage()[DOCK_WIDTH]).toBe('480')
})
it('drops a removed key from the mirror, which is what absent means to `init`', async () => {
await persistMirrored(DOCK_WIDTH, null)
expect(readMirroredStorage([DOCK_WIDTH])[DOCK_WIDTH]).toBeUndefined()
expect(initStorage()[DOCK_WIDTH]).toBeUndefined()
})
})
+3 -1
View File
@@ -31,10 +31,12 @@ import {
updateSessionViewOverride
} from './session-view-preferences'
// A store rather than two bare spies: the mirrored write path reads a key back after writing it,
// so a `setItem` that answers with nothing is not a store any caller could have (ruling 35).
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
setItem: vi.fn(async () => undefined)
}
}))
+16 -14
View File
@@ -1,5 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { noteMirroredWrite } from './mirrored-storage-keys'
import { persistMirrored } from './mirrored-storage-keys'
import { TERMINAL_TEXT_SCALES } from '../terminal/terminal-text-scales'
const PINS_PREFIX = 'orca:pins:'
@@ -93,7 +93,10 @@ export async function loadTerminalTextScale(): Promise<number> {
}
export async function saveTerminalTextScale(scale: number): Promise<void> {
await AsyncStorage.setItem(TEXT_SCALE_KEY, String(scale))
const value = String(scale)
// Through the one write path: the hybrid shell hands this key to the page on every `init`,
// built synchronously, and what it reads is noted there on an accepted write (ruling 35).
await persistMirrored(TEXT_SCALE_KEY, value)
}
const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled'
@@ -111,7 +114,8 @@ export async function loadTerminalAutocompleteEnabled(): Promise<boolean> {
}
export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise<void> {
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
const value = String(enabled)
await persistMirrored(AUTOCOMPLETE_KEY, value)
}
const MOBILE_WEB_SHELL_KEY = 'orca:mobileWebShellEnabled'
@@ -179,10 +183,9 @@ export async function saveDisabledTerminalLiveInputHandles(
worktreeId: string,
handles: ReadonlySet<string>
): Promise<void> {
await AsyncStorage.setItem(
terminalLiveInputDisabledKey(hostId, worktreeId),
JSON.stringify([...handles])
)
const key = terminalLiveInputDisabledKey(hostId, worktreeId)
const value = JSON.stringify([...handles])
await persistMirrored(key, value)
}
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
@@ -214,7 +217,8 @@ export async function loadHostSidebarWidth(): Promise<number> {
}
export async function saveHostSidebarWidth(width: number): Promise<void> {
await AsyncStorage.setItem(SIDEBAR_WIDTH_KEY, String(clampHostSidebarWidth(width)))
const value = String(clampHostSidebarWidth(width))
await persistMirrored(SIDEBAR_WIDTH_KEY, value)
}
const DOCK_WIDTH_KEY = 'orca:hostDockWidth'
@@ -248,7 +252,8 @@ export async function loadHostDockWidth(): Promise<number> {
}
export async function saveHostDockWidth(width: number): Promise<void> {
await AsyncStorage.setItem(DOCK_WIDTH_KEY, String(clampHostDockWidth(width)))
const value = String(clampHostDockWidth(width))
await persistMirrored(DOCK_WIDTH_KEY, value)
}
export type MobileTerminalLinkOpenMode = 'orca-browser' | 'phone-browser'
@@ -266,7 +271,7 @@ export async function loadTerminalLinkOpenMode(): Promise<MobileTerminalLinkOpen
}
export async function saveTerminalLinkOpenMode(mode: MobileTerminalLinkOpenMode): Promise<void> {
await AsyncStorage.setItem(TERMINAL_LINK_OPEN_MODE_KEY, mode)
await persistMirrored(TERMINAL_LINK_OPEN_MODE_KEY, mode)
}
function stringArray(value: unknown): string[] {
@@ -290,8 +295,5 @@ export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
export async function savePinnedIds(hostId: string, ids: Set<string>): Promise<void> {
const key = PINS_PREFIX + hostId
const value = JSON.stringify([...ids])
// Noted before it is persisted: the hybrid shell hands this key to the page on every `init`,
// built synchronously, so a write that only reached the store would be one `init` behind.
noteMirroredWrite(key, value)
await AsyncStorage.setItem(key, value)
await persistMirrored(key, value)
}
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { persistMirrored } from './mirrored-storage-keys'
/** How a supported agent session opens: the raw terminal or the native chat view. */
export type MobileSessionView = 'terminal' | 'chat'
@@ -51,9 +52,11 @@ export async function loadDefaultSessionView(): Promise<MobileSessionView> {
export function saveDefaultSessionView(view: MobileSessionView): Promise<void> {
// Why: callers can outlive their route; a shared barrier keeps remounted
// Settings screens from letting an older write land after a newer choice.
const write = (defaultViewWriteBarrier ?? Promise.resolve()).then(() =>
AsyncStorage.setItem(DEFAULT_SESSION_VIEW_KEY, view)
)
const write = (defaultViewWriteBarrier ?? Promise.resolve()).then(() => {
// Through the one write path: the hybrid shell hands this key to the page on every `init`,
// built synchronously, and what it reads is noted there on an accepted write (ruling 35).
return persistMirrored(DEFAULT_SESSION_VIEW_KEY, view)
})
const barrier = write.catch(() => undefined)
defaultViewWriteBarrier = barrier
void barrier.then(() => clearDefaultViewWriteBarrier(barrier))
@@ -142,7 +145,8 @@ export async function updateSessionViewOverride(
throw new Error('Session view overrides could not be read')
}
current.overrides.set(tabId, view)
await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides)))
const value = JSON.stringify(Object.fromEntries(current.overrides))
await persistMirrored(key, value)
})
const barrier = update.catch(() => undefined)
overrideUpdateBarriers.set(key, barrier)
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { persistMirrored } from '../storage/mirrored-storage-keys'
import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys'
@@ -226,5 +227,8 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(layout)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
const value = JSON.stringify(preference)
// Through the one write path: the hybrid shell hands this key to the page on every `init`,
// built synchronously, and what it reads is noted there on an accepted write (ruling 35).
await persistMirrored(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, value)
}
@@ -1,4 +1,4 @@
import { writeMirroredStorage } from '../storage/mirrored-storage-keys'
import { persistMirrored } from '../storage/mirrored-storage-keys'
import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers'
export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree'
@@ -56,5 +56,9 @@ export function readLastVisitedWorktreeRepoId(raw: string | null, hostId: string
* would open on the repo the user left rather than the one they just came from.
*/
export function writeLastVisitedWorktree(record: LastVisitedWorktreeRecord): void {
writeMirroredStorage(LAST_VISITED_WORKTREE_STORAGE_KEY, JSON.stringify(record))
// Through the path that notes on an accepted write (ruling 35): this module is in the page's
// own closure, so the store behind it may be the bridge's adapter, which refuses a key this
// route was never given. Nothing is owed a caller that cannot act on one, so the refusal is the
// adapter's log rather than a rejection here.
void persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, JSON.stringify(record)).catch(() => {})
}
+3 -1
View File
@@ -91,7 +91,9 @@ bootstrapShellPage({
publishPageStorage(
session.storage,
(key, value) => client.notifyStorageWrite(key, value),
session.host?.id ?? ''
session.host?.id ?? '',
session.route?.pathname ?? '',
session.storageOversize
)
createRoot(container).render(
// Above `ExpoRoot`, not inside its wrapper: a route this bundle cannot resolve or import
+8
View File
@@ -77,6 +77,10 @@
"file": "app/h/[hostId]/review/[worktreeId].web.tsx",
"reason": "The shell renders this page for diff review, for the same reason as the source-control hub beside it: no nested shell, and a native file that reaches requireNativeViewManager at import. Shorter than its siblings because the route body is MobileDiffReviewRouteScreen, which reads its own params — the native file had to extract that anyway so the review controller does not subscribe behind the page."
},
{
"file": "app/h/[hostId]/session/[worktreeId].web.tsx",
"reason": "The shell renders this page for the session screen, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and the route manifest imports every route, so one throwing module takes the whole bundle down. Shorter than its siblings because the route body is MobileSessionRouteScreen, which the switch also needs as an element: useMobileSessionController opens the terminal, chat and tab subscriptions, and at the switch's top level it would open them behind the page too."
},
{
"file": "src/browser/browser-frame-layer-paint.web.ts",
"reason": "setNativeProps does not exist on React Native Web: an Image or View ref is the DOM node itself, so both native writes throw rather than paint and the pane never shows a frame. This file writes the same two things through the element — the frame as a background-image on the child RN Web sizes, the double buffer as one opacity write per layer — so the pane still never re-renders while it streams. It also answers whenBrowserFrameDisplayable, which is a no-op natively: a background write fires no load event, so the flip to the pending layer is armed from an image decode instead of the Image's onLoad."
@@ -136,6 +140,10 @@
{
"file": "src/mobile-web-shell/catch-all-page-route.web.tsx",
"reason": "The catch-all's native body mounts the shell, and the page is what that shell displays; nesting one here would open a second document inside the first, and the shell module calls requireNativeViewManager at import, which throws in a browser."
},
{
"file": "src/session/use-notification-pane-navigation.web.ts",
"reason": "The native hook reads `paneKey` off the app's route with `useLocalSearchParams` and clears it with `setParams`. Inside the page neither exists: the document is served at `/` with one history entry, so `setParams` would rewrite the page's own URL while the app's route kept the spent tap, and the pane request never reaches the page as a param at all. It arrives as a re-sent `init` for the session this page already holds (ruling 33.1), which this sibling reads off the client's standing route-update listener; the shell clears the native param once the page has been handed one."
}
]
}