mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
ca310a5288221e1014d5fbed3c46a67a91de0bbe
31
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
633da07af5 |
test: audit CLI dispatch and mobile host boundaries (#32576)
* test: verify independent CLI quoting and screenshot bytes * test: audit CLI registration and offline hook boundaries * test: record CLI automation fence review * test: audit computer CLI dispatch and exported files * test: record CLI enrollment and emulator contract review * test: audit file command path and navigation contracts * test: bracket login shutdown grace and fallback deadlines * test: keep managed server timeout expectations independent * test: record Linear CLI input and dispatch review * test: verify legacy completion dispatch boundary * test: replace copied config and browser controls * test: isolate gate refusal from inherited caller identity * test: exercise real mobile host admission and teardown boundaries * docs: reconcile audit entrypoints and refresh completed reviews * test: fix mobile audit React Doctor findings --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
5aa5467c27 |
feat(mobile): one download bar whose fill follows progress on the native driver (#31755)
* feat(mobile): one download bar whose fill follows progress on the native driver The page download's replies keep the JS thread busy, so React committed nothing until the end and both bars sat at zero, then jumped to full. The session now publishes each progress step to a small feed as it is dispatched, and one shared bar eases its fill to each value with Animated on the native driver (scaleX). The caption stays React-drawn. * fix(mobile): the download bar always moves on the native driver With reduced motion, the fill jumped through a JS-side setValue on a value that never became native, so on Fabric it only moved with React commits, which the download holds off. An unread preference also counted as reduced, so a freshly mounted bar took that path. Every step is now a native-driven timing, of zero length when motion is reduced, and the preference is read through a ref. * fix(mobile): the download bar starts at most one ease per 50 ms The feed fires per asset, often well under a millisecond apart. Starting a new 150 ms timing on each one replaced every ease before it drew a frame, so on iOS the fill stayed near empty until the last update. Now a callback inside the 50 ms window only records the latest fraction; the next callback past the window, or a trailing timer at its end, eases to it over 100 ms. Full lands at once. The effect cleanup clears the timer. |
||
|
|
aa07d5891a |
fix(mobile): break the transport and terminal-document import cycles, and gate cycles after mobile's install (#30290)
* fix(mobile): break the transport and terminal-document import cycles and gate them after mobile's install client-context re-exported the host-client hooks, which import client-context back: the re-export barrel is gone and importers (and their test mocks) name host-client-hooks directly. In the terminal document, the cell-metric getters move to a leaf cell-metrics module and select-mode entry/exit to selection-mode, so fit, transform, overlay and range no longer import each other. pr.yml lints imports before mobile's install, when mobile's tsconfig can't load, so Mobile verify now runs the same import rules over mobile after its install with zero warnings allowed. * fix(mobile): the browser-pane render harness imports useHostClient from host-client-hooks Its route source is a template string, so no typecheck saw the removed client-context re-export; the mobile web bundle check did. * fix(ci): the e2e typecheck ratchet counts the same with or without mobile's install Specs reach mobile/src/transport, whose expo-crypto and async-storage imports resolve only after mobile's install and then load React Native's globals (numeric timers, a required NODE_ENV), adding errors in unrelated specs. An empty stub pins both, so the static job reads 232 either way. |
||
|
|
b2419a58ad |
fix(mobile): show the update screen instead of native screens when the desktop has no page (#29956)
* fix(mobile): wall hosts that cannot serve the page instead of opening native screens * refactor(mobile): drop the native-route state and the shell fallback prop; unknown routes say unavailable * test(mobile): read the nullable page route refusal in its policy test |
||
|
|
27c0275e3a |
feat(mobile): log OTA shell lifecycle to the connection diagnostics (#28840)
* feat(mobile): log OTA shell lifecycle to the connection diagnostics Exported connection logs showed nothing about the web shell, so a phone stuck on "Opening workspace" could not say whether it stalled activating the download, loading the page, or waiting for its first paint. Record sparse shell milestones, page signals, one-shot 15s stall warnings and bridge diagnostics into the per-host connection log. Diagnostics only. Refs STA-10545, #28626 * fix(mobile): cut shell log volume and pinpoint each waiting screen Every page route mounts its own shell, so four lines per healthy open evicted connection history from the 200-entry log. A healthy open now writes one line, on paint, carrying source, build and elapsed. The 15s stall line now also covers "Checking host" (gate verdict and whether the cache read answered) and a download with no progress. The "page has not drawn" line counts the page's ready asks and whether the document loaded, telling a page that keeps refusing init from one that took it and died. Frames dropped with no bridge host, an unreadable host profile, post-failed error names and route-refused issues are now logged; path masking catches quoted, bracketed and Windows paths. The exported report header names the shell build and bridge protocol. Diagnostics only: no behaviour, UI or wire change. Refs STA-10545 * fix(mobile): dedupe flapping shell lines and mask spaced paths A host with no cache flapping between unreachable and dialling wrote "checking host" and "host unreachable" every cycle per mounted shell; each is now written once per host until a different shell line lands. Path masking keeps going across a space while the next word still holds a separator, and accepts a comma before the path. The paint line's elapsed time is renamed activatedMs, which is what it measures. A route-only session rebuild re-arms the stall timer. Refs STA-10545 * refactor(mobile): split the shell log module by responsibility shell-lifecycle-log.ts passed the 300-line lint cap. The line type, recorder and flapping dedupe move to shell-lifecycle-line-recorder.ts, bridge diagnostic lines to shell-bridge-log-lines.ts, and user-path masking to shell-log-path-masking.ts, with tests split to match. No behaviour change. Refs STA-10545 |
||
|
|
2b62a3db1a |
fix(mobile): drop the download bar glide so it always matches the MB counter (#28089)
The OTA download bar no longer glides: its width is bytes received over the total, the same number the MB caption shows, so the two always agree. On a fast local link the screen still only re-renders near the end of the download, because decrypting replies keeps the JS thread busy. The glide then started late and was cut off by "Opening workspace", leaving a ~10% bar next to "8.0 of 8.1 MB". At normal speeds, renders land every 100-250 ms since the native hashing in #28056, so the glide added only lag. Verified on the iOS simulator: at 1 MB/s bar and caption agree within 1 point in every screenshot, and uncapped they go from empty to full together. |
||
|
|
974b78d4e3 |
feat(mobile): show the OTA download as a progress bar that renders while downloading (#28056)
The OTA download screen now shows a progress bar over bytes received, labelled "Downloading the latest app" with a "from <host> · X.X of Y.Y MB" caption, instead of a spinner with raw file and byte counts. Related offline and failure copy says "the latest app" instead of "the workspace". The bar barely moved before because React never committed progress during the download. SHA-256 of each asset ran in JS (3.8 s of a 5.7 s download), and React Native's scheduler runs queued native callbacks ahead of normal-priority renders, forcing a render only after 5 s. Assets are now hashed with expo-crypto's native digest (~5 ms total), so progress renders every 100–250 ms at 1 MB/s. Verified on an Android emulator and the iOS simulator. The bar glides between bursts of progress (moving average of burst gaps, clamped 200–2000 ms) and is ShellWaitingFrame's indicator, so the label keeps the same position as on "Opening workspace". On an uncapped local link, decryption still keeps the JS thread busy and the bar can jump; that is left for a follow-up. |
||
|
|
2d31941286 |
fix(mobile): the update-mobile wall opens the exact release (#23789)
* fix(mobile): the update-mobile wall opens the exact release When the in-app checker knows the newest release, the update-mobile wall offers "Get Orca <version>" and opens that release's page instead of the generic releases list or a hand-built App Store link. Mounting the wall runs the single-flight, bounded checker once. Dismissal is ignored here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the update checker out of the page closure The wall now takes the offered release as a prop and opens it through the external-link seam. A shell-only hook runs the checker once per update-mobile wall and supplies the release; MobileWebShellScreen, which no page route reaches, wires it. HostProtocolGate is in every page closure, so it stays unwired pending a ruling. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): platform-split the wall's release offer and throttle its check The hook is the one module whose behaviour differs by platform: the native file runs the checker, the .web.ts sibling offers nothing, so both HostProtocolGate and MobileWebShellScreen wire it the same way and the page closure never carries the checker. A remount checks only when no release is known and the last check is absent or over the retry interval old, since each check is an unauthenticated GitHub call. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the checker decide whether the wall's check is due The hook's own throttle read lastCheckedAt, which moves only on success, so after a failed lookup every wall remount looked up again, and before preferences loaded a cold-start wall looked up despite a fresh stored result. The checker already owns the cadence: checkIfDue waits for the stored state and checks only past the retry or daily interval. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the wall reads the known release and triggers nothing The started checker's own timer, cold-start and foreground paths already run every due check, so a check requested by the wall could never be due. The hook now only returns the known release; the checker is back to main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): the wall reads its release through useWallAppUpdate The .web.ts sibling alone keeps the page closure clean, so the screen calls the hook itself and the optional prop, the gate wiring and the shell wiring go. HostProtocolGate and MobileWebShellScreen are back to their base content. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
6e1b7e7fa3 |
test: remove junk tests that assert source text instead of behavior (#23815)
Deletes 101 test files and trims 112 more, all matching documented junk patterns: exact source/import/string greps, copied inventories and export lists, duplicate invocations of a contract another test already owns, typeof-shape checks TypeScript already enforces, and self-comparisons. The largest group read a production `.ts` file and asserted on its text — for example a TaskPage test that required the source to contain `selectedRepos.find((r) => r.id === newIssueRepoId) ?? selectedRepos[0] ?? null`. Any behavior-preserving rename broke it; no behavior change ever did. Production-side follow-through: exports that only these tests imported are de-exported or deleted, stale comments pointing at removed censuses are dropped, and the reliability-gate registry, `cloud/package.json` test lists, and orphaned source-reading helpers are updated so nothing references a deleted file. Two files kept their real coverage and lost only the census scaffolding: `agent-status-producer-census.test.ts` now drives all five producers end to end instead of grepping the source tree, and `config-toml-trust-stale-writes` replaces an export-list parity check. |
||
|
|
ba858ee446 |
feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)
The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched. |
||
|
|
37820f9683 |
feat(mobile): the page owns its safe area, like a native screen (OTA phase C follow-up) (#22570)
* feat(mobile): the page owns its safe area, like a native screen The shell reserved both system-bar strips outside the WebView and painted them bgBase, so every page screen showed a flat band above its header, sheet scrims stopped short of the status bar, and the dock floated above the gesture bar. For a page that declares `safe-area-insets` in `ready.accepts`, the shell now draws the WebView edge-to-edge and keeps only the keyboard strip off it. `init` carries the insets the view sits under (bottom 0 while the keyboard ends the view, top 0 under the update banner), and a move is re-sent over the existing route-update `init`. An older page keeps the reserved strips, since it has no reader for the insets. On the page, a root layout (`app/_layout.web.tsx`) feeds those insets to react-native-safe-area-context below ExpoRoot's env()-measuring provider. It also replaces expo-router's DefaultNavigator, an all-edges SafeAreaView that padded a second time. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web): re-measure the page pins for the root layout The page's route tree gained `./_layout.tsx` (its web sibling of the native root), so every pin that counts the tree moved: - Script sweep re-measured by building `routes.slice(0, n)` for each n. It reads 69 scripts at 16 routes, which matches the real build. The asset-ceiling crossing moves from 31 routes to 32. - Route closures now enter through both layouts. `entryNames` gains `[dir]` because `app/_layout` and `app/h/_layout` share a name. - Session closure pin 4216 -> 4219. The added modules are bridge-safe-area-insets, page-safe-area-provider and _layout.web. - The web-overrides allowlist names `app/_layout.web.tsx`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read safe-area ownership from the page-document state Review fixes on the page-owns-safe-area change. - Ownership is page-document state now. `page-ready` carries `accepts` beside `reports`, the patch sets `pageOwnsSafeArea` from `safe-area-insets`, and the session hook projects it like `backClaimed`. The screen's own per-session copy is gone. - Insets moves re-send `init` only to a page that declared `safe-area-insets`. A page that took route updates but not insets was sent a useless `init` on every keyboard show and hide. - The banner wrapper is gone. The root pads the status bar strip while the banner shows. - The shell session defaults the insets inline, with no predicate that mutated its argument. - The provider is folded into its single caller, `app/_layout.web.tsx`. The session closure pin reads 4218 (local 1032). - The screen tests share their module mocks, and the safe-area cases move to a suite of their own: owned page, banner, iOS and Android keyboard, and an older page that gets no re-init. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
7240368726 |
feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived
The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): record why a hybrid shell update failed, on the device
A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): forget a removed host's recorded update failures
Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): show recorded update failures in Troubleshoot
A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the update-failure row doubles without casts
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forget a host's update failures once a newer generation commits
The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-pin the session closure for the shared journal producer #22299 added
main at
|
||
|
|
11db2b9a7d |
feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key The shell's page had no way to hear Android Back: every sheet inside it early-returned on web, so the key popped the whole session route. Adds the first negotiated shell-to-page frame kind alongside it. - `back-claim`, page to shell, declared in `init.accepts`: the document is holding the key, or has let it go. - `back`, shell to page, declared in `ready.accepts`: one press, dispatched to the newest consumer that takes it. A press nothing takes is handed back as a `navigate-back` rather than dropped. Both are optional fields on frames the other side already reads, so an old shell never hears a claim and an old page is never sent a press; each pops as it does today. No protocol bump and no stream opcode. `bridge-host.ts` was at its line cap, so the notify forwarder moves to `bridge-host-notify.ts` unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): Android Back closes the sheet on the page, not the screen Inside the shell's page every sheet early-returned on web, so one press left the session route with the sheet still open. The drawer, the right drawer and the file-preview prompt now claim the key through one seam on both platforms: `use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on the shell's. All sixteen session sheets render through `MountedBottomDrawer`, so the one claim there covers every one of them, and a census fails if a sheet bypasses it. `route-handoff.web.ts` claims while the page grew a stack of its own, and hands the press back when it did not. The shell takes the key off the navigator only while a claim is live: Android gets a `hardwareBackPress` handler that returns the host's own answer, iOS loses the stack's swipe-back. The claim is cleared on `document-started`, on a remount, on a new `ready`, on anything that takes the generation off screen, on the page's `close` and on dispose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a Back press closes a sheet on the real bundle The unit suites reach both halves of the lane but never the two together on a document a browser rendered. The render rig can now post a `back` frame, and the drawer check opens the Filter sheet, reads the claim off the notify list, sends one press and pins that the sheet closed with no `navigate-back` behind it. Red without the drawer's claim: the claim never arrives. Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is shared with the native app and mounts under no page provider in a bare tree, where `usePageBridgeClient` threw; the seam now reads the bridge through `usePageBridgeClientIfPresent` and claims nothing without one. Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route handoff, `bridge-page-back.ts` through the envelope. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): mirror the Back seam's latest values from an effect Three `ref.current = …` writes sat in render, which React replays and discards. Each moves into a dependency-list-free effect declared ahead of the registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already uses for the same reason: the caller rebuilds the value every render, so there is nothing to depend on, and `useRef` seeds the first mount. The registration still keys on the claim alone, so a rebuilt handler re-registers nothing. The web seam's test drops its two type assertions for a named fixture type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page says its Back claim again on every init The claim was edge-triggered and the shell forgets on purpose: it drops the claim answering every `ready`, and a host rebuilt under a live page — a client swap through forceReconnect, which leaves the WebView mounted — starts with none at all. A document still holding a sheet was then unknown to the shell, and the next press popped the screen out from under it. `init` is the shell saying it is here now, so the page answers each one with the state rather than with a transition. Posted after the session has taken the frame, so the gate reads that `init`'s own `accepts` and a shell that never named the claim still hears nothing. Nothing is said while nothing is held. Every `init` answering a `ready` comes from a host that dropped the claim first, so it already holds false; the only other one carries a rewritten route, where a stale true needs a `false` the page posted to have never left, and a port that refused that frame refuses this one too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a rebuilt host keeps the session's Back claim A host is rebuilt when the client under it changes, and the page document does not move: the WebView stays mounted, the session id holds, and the page is never told. The rebuilt host started with no claim and no `accepts`, so it refused every press and the navigator popped the screen out from under an open sheet. Two clients on the same generation leave the page nothing to refuse, so nothing made it re-ask and re-assert. What the page declared and what it is holding are facts about the session, the way `sessionEstablished` already is. `createBridgeHostBack` takes them as a seed, `readSessionBack()` hands them on, and the hook holds them stamped with the session so a record left by one never seeds the next. `dispose()` no longer reports the claim gone: a host retiring is not a document ending, and that report was the thing taking the key off a live sheet. Every reset path is unchanged and still has its own case — the page's `ready`, its `close`, and the session's own store for `document-started`, `remounted` and anything that takes the generation off screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3bb9a4e261 |
fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint The shell tore its own frame down the moment a generation was on screen (`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws nothing until its document paints. What showed for the whole of the page's boot was the surface behind it with nothing on it: 1.42 s on a cached generation, against a one-frame budget. The page is the only thing that knows when it has a frame, so it says so. It declares `painted` in `ready.reports` and posts the notify after the browser has painted its first commit; the shell holds the same neutral frame it was already painting while it opened the generation, then fades it out. The wait is bounded by the declaration and never by a timer: a generation served by an older desktop declares nothing and is uncovered on `ready`, which is what every shell did before this. iOS painted white rather than nothing: a WKWebView is opaque by default, so the shell's own surface never showed through. It is now transparent, as the Android view already was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the cover through the compositor handover The page reports the paint its own renderer made; putting that on the app's surface costs another frame or two. A linear fade from the report left two frames of bare surface between the two on an emulator, which is the hole the cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity across that handover: five reopens now show 0-21 ms of bare surface against 102-2043 ms on the build without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): negotiate the paint report in both directions The page posted `painted` whatever shell it met, and `notify` is a closed union: every shell installed before this answered it with an error frame, once per mount. The shell now advertises the name in `init.accepts` beside the param clear and the client identity, and the page posts only when it was advertised. The declaration in `ready.reports` stays unconditional, because it is an optional field an older reader strips rather than a new opcode, and because the first `ready` — the only one that matters for the first paint — is sent before any `init` has arrived. The accepts list moves into `bridge-init-frame.ts` beside the grants, which is the module that builds the frame carrying it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cover's colour instead of asserting its shape Two gate findings on the round-two head. The cover test reached the background through a cast of the style prop; it now reads it through a checked narrowing, so the test proves the shape it depends on rather than declaring it. `use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args gained `onPagePainted`: its harness is a literal, so a new required handler is a missing property. The probe now counts paints and one case spends the counter, which is what a handler wired only to satisfy a type would not do. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the cached-generation opening out of the reducer `mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused- update work and the paint handling both grew it. What comes out is one thing — putting a generation already on disk on screen, and deciding whether this route is one that bundle carries. It is the reducer's cache path and its refused- update path both, and it was already three functions sitting together. `step` goes into a module of its own because the two now share it; a copy in each would be two spellings of one transition, and exporting it from either would point the dependency the wrong way. No behaviour moves: the reducer's table tests are unchanged and the page closure is unchanged at 4,211, since neither new module is reachable from a page route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the previous document's paint when a new one starts A document that replaced a painted one inside the same mount inherited its `pagePainted`, so the cover lifted before the replacement had drawn anything. The native view already reports `loading`; the screen dropped it. It now reaches the reducer as `document-started` and clears the page document state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the declaration case call the frame policy The case compared the name to itself and never called `shellPageFrame`, so it passed for a policy that ignored the declaration entirely. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report the page's frame from the route screen, not the router Every route screen is behind `import()`, so the wrapper above expo-router commits with a suspense fallback while the chunk is still arriving. The paint report hung there, which uncovered the shell's view over an empty body on a cold chunk. It now hangs on the screen the manifest resolves, layouts excluded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): retire the readiness wait a replaced document armed `document-started` cleared the page document state and left the flow alone, so the previous document's readiness deadline passed the flow check, read `pageReady` as false and failed a session whose replacement was still loading. The flow moves with the document, for the reason `remounted` already moves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a departing route screen take its paint report back The report waits two frames, and nothing cancelled the second one, so a screen unmounted in between still told the shell to uncover. The reporter now answers with a take-back the wrapper returns as its cleanup, and the once-per-document latch frees only when a report was cancelled before it landed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let the screen that arrived take over a frame still owed A screen committing inside the two frames an earlier one was owed found the latch taken and reported nothing; the earlier screen then freed that latch on its way out and nobody was left to lift the cover. The newest commit now supersedes the pending report, and only a posted one spends the latch. Covers the redirect window with a render check against the pr route, whose target chunk is held open while the document sits on the hub's fallback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the take-over case turn on the take-over The case cancelled the first screen's frame through the cleanup path, so it passed with the take-over deleted. It now leaves that screen mounted and reads the clock: the frame after the replacement commits is the replacement's first, not the one the screen behind it was still owed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
66ade30576 |
fix(mobile): the page's live input stays above the soft keyboard (#22252)
* fix(mobile): the page's live input stays above the soft keyboard Edge-to-edge makes the manifest's `adjustResize` inert, so the window never shrinks for the IME and the page's `visualViewport` reads full height with the keyboard up: the session route laid its live input row out under the keys. The shell owns the window, so it shortens the WebView by the keyboard instead. The session screen's own `Keyboard.addListener` pair never fired on react-native-web, so the page also never held off the terminal refit. Both facts now come from the platform seam, which answers them separately on the page: the keyboard is open, and it covers nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the session keyboard harness without a cast The hoisted keyboard now carries the seam's own `SoftKeyboardState`, and the mocks the screen is handed carry the types it calls them with, so nothing is asserted into shape. The scope the harness builds could not be spelled at all before: the hook took the whole lifecycle model to read 28 of its fields. It now names those fields, which the model still satisfies, and the test builds one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3d76c22b57 |
fix(mobile): a refused update serves the cached generation while the host is up (#22237)
* fix(mobile): a refused update serves the cached generation while the host is up A newer generation that fails to fetch or stage was refused with a named reason and then painted a wall with "Try again" over an intact generation already on disk — the same one the offline branch opens without being asked the moment the host goes away. `onDownloadFailed` now branches on what is cached rather than on which side refused: with nothing on disk the refusal is still the screen, and with a generation on disk it is opened through the offline branch, judged by its own route list. The refused generation is never staged, committed or persisted, and nothing about the refusal is written, so the next launch asks the host again. The bundle-side refusal is named as a dismissible notice above the page, on the existing host-route banner. It says what happened and promises no retry, because the shell schedules none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge the cached generation against the host it can reach before serving it The fallback served a cached generation on the strength of the offline rule, which skips the compat check because a host nobody can reach cannot have changed. On this path the host has just answered, and an update usually exists precisely because it moved — so bytes that were inside the protocol window when they were written may be outside it now. `CachedGeneration` now carries the three fields the compat verdict reads, projected in `openCache` off the manifest stored beside the assets. That manifest is never absent: `readActiveGeneration` answers null for a generation whose manifest did not parse, and the read schema requires all three. `cachedGenerationWall` lives beside `gateVerdict`, because only an `open` gate is judged further. The other verdicts already have answers there: an absent capability is the native-route rule, and an unreadable status leaves the same empty list, so walling on either would be the `bundle-unavailable` wall that file exists to keep off a host that simply did not reply. A generation outside the window now earns the wall with its verdict, not the download-failed screen, and nothing is deleted: the bytes are intact and a newer host is not what makes them wrong. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): ask the cached generation's own routes before walling it The compat wall ran before the route question, so a cached generation that does not carry this route — or predates route listing entirely, `routes: undefined` — earned a terminal `bundle-incompatible` wall where the answer is `native-route`. `onManifestRead` has always taken the other order: a route that stays native has nothing to wall about. `openByOwnRoutes` now asks the route first and applies the wall only on the served branch, and the update notice moved to `served`, so a native answer carries no notice about a screen it is not showing. The other half is the verdict the gates hold at the moment of the refusal. A `fetching` session does not await the gates, so a refusal can land under a verdict the flow never started on. `gateState` is now the one mapping from a gate verdict to a screen, shared by the entry into the flow and by the fallback, so the two cannot answer the same verdict differently: a host that stopped serving a bundle is `native-route`, a status that went unreadable says so and re-arms, a dial in progress or a pending status waits in `checking`, an unreachable host keeps the offline rule and serves the cache unjudged, and `open` is the only answer that leaves a host to judge the generation against. That inverts two round-2 assertions that expected the cached page to be served when the capability list had gone empty. Both were wrong for the same reason: an empty list is the gate's question, not a verdict about a bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce the host route notice banner, as loudly as its tone The banner is inserted into a screen that is already on screen, so a reader who has moved past the top of the list never arrives at it. It carried no live region and no role, so nothing carried it to them. The urgency follows `tone` rather than being assertive for everything. The failure tone is an action that did not happen — a refused worktree action, or the shell's refused update — and interrupts with `alert` and an assertive region. The notice tone is a bounced route, context for a list already being read, and waits its turn politely; interrupting for that would train people to ignore the first. No role on that arm: React Native has no `status` role, so the polite region is the whole of the answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9267423f22 |
refactor(mobile): the bridge host mount reads one args ref, and the handshake fact lives in the session (OTA phase C, audit item 3) (#22080)
* test(mobile): pin every bridge callback to the render on screen The host is built once per session, so each callback it holds has to reach the render that is on screen rather than the one that built it. Only the page fault was pinned that way; this covers the eight a frame can reach, and asserts the host was not rebuilt for the re-render by leaving a request open across it. Green before the collapse that follows: the pin is an invariance guard, not a reproduction. Verified to fail when a callback is closed over instead of read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the bridge host's props through one args ref Sixteen latest-callback and route mirrors, sixteen assignments and a sixteen- entry dependency list become one ref holding the whole props object, written in one layout effect with one dependency, and one adapter built with the host that reads it at call time. Adding a callback was a three-place edit the React Doctor gate has caught four people missing; it is now one entry in the args type and one line beside the host. The props type is named for that: the host is built against the whole object. `viewRef`, `hostRef` and `establishedSessionRef` stay, as do the host's own `serving`, `initSent` and report-once flags. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the handshake fence to the session, not to the mount Red on this head, in three places. The bridge hook has no way for a caller to say the session is already open, so a page whose host was rebuilt for a new client is refused; the session hook does not report the handshake its reducer already records; and the screen has nothing to hand over. The second bridge case is the control: the same frame on a session the caller says nothing about is still refused, so the first one is the fence moving rather than the fence going. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the bridge handshake fact into the session it describes `establishedSessionRef` kept "this session has completed a handshake" for the life of one mount, so the fence a rebuilt host inherits was remembered beside the session rather than by it. The reducer already records the same fact as `pageReady`; the session hook now reports it and the screen hands it over, so the bridge host takes it from the render. `options.sessionEstablished` stays. It is the host's own seed for `initSent`, which is the fence a rebuild has to inherit, and the red cases above are what say so. The shared bridge harness gained the screen's half of that: it records the handshake on `onPageReady` and re-renders, which is what the client-rebuild case was reading off the deleted ref. No existing case body changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the dependency list from the bridge args refresh A caller builds the props object inline, so every render is a new one and there is nothing for the list to compare; React Doctor reads that as a dependency recreated each render, and it is right that the list says nothing. No list is what the ref is for: it runs after each commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3cfb070294 |
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 |
||
|
|
a91ca8b19e |
feat(mobile): dictation on the OTA page over native audio verbs (Phase C, C7.10 PR D) (#21905)
* feat(mobile): serve dictation capture over four native audio verbs The page owns dictation's state machine and speaks `speech.dictation.*` to the desktop, where transcription runs; the microphone is the shell's. So the shell gains `native.audio.start|read|stop` and `native.wakelock.set` — four rows, four grants — and rings what the microphone produces at the page's own pending-audio budget rather than pushing bytes the page would hand straight back. `native_audio_not_capturing` joins the refusal vocabulary: a read for a capture this session does not have is the one refusal the page must tell from a device that failed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): take dictation capture through one seam on both hosts `use-mobile-dictation.ts` held the microphone and the wake tag directly, so the page had a hook whose every device call was a stub answering denied. The five calls move behind `src/platform/dictation-capture.ts`: natively the same calls in the same order, on the page the shell's four verbs, with the drain raising the events the engine emits. The tag bookkeeping stays where it was and stops importing `expo-keep-awake`: two calls come in through the seam and the pools, the queue, the timeouts and the retries are the same on either host. The chunk sender is untouched. A chunk carries raw PCM because that is what the budget counts and what `speech.dictation.chunk` is built from, so the page pays one decode of 32 KB a second rather than the flow carrying two shapes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): drain the shell's audio ring on a 500 ms batch One `speech.dictation.chunk` per native microphone event is 31.25 forwarded requests a second, and each holds one of the bridge's 64 in-flight slots for a whole desktop round trip. Measured over ten seconds against a two-second link: 63 in flight at the peak and one slot left for the rest of the page. Drained every 500 ms instead: 5 in flight, 60 slots free, the same 42 KiB/s, and 38 frames out and 34 back for the whole session. The frame cap was never the bound — half a second of PCM is 3.3% of one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive dictation's grant rows from the route closure Which routes need the four audio verbs is a census, not a hand list: the rule reads each registered page route's own closure and holds its `grants` to what that closure reaches. Vacuous on today's route list — the session route is the only closure carrying the seam and is not registered yet — so a control runs the same rule against the session module and names all four as missing. The closure also records what the seam took off the page: `@orca/expo-two-way- audio` and `expo-keep-awake` are gone from it entirely, and removing the web file puts four of the vendored stub's modules back. The mic control's render case found a real one. A start the shell refused outright left the button on "Starting voice dictation" with no way back, which is every tap on a route without the grants. It reports the refusal and returns to idle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the mic control's render case inside the tests typecheck A `let` the renderer assigns inside a callback narrows to `never` afterwards, and the mocked `Pressable` took `children` as `unknown`. Both are type-level only, and the ratchet is the gate that notices: a test outside `tsc` can pin a shape that stopped existing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand over the audio still in the ring before stopping the shell `end` cancelled the drain and stopped the capture without a last read, so up to one drain interval of the utterance's tail — 16,000 bytes, the 400 ms a user is still speaking as they lift the button — was discarded on every stop. The reviewer's probe spoke 12,288 bytes in the last 400 ms and the page delivered none of them. Natively that audio is already in the hook's hands, so this was a page-only loss of the end of every sentence. `end` is now asynchronous: it cancels the timer, waits for any read in flight, reads once more, and only then stops the shell — stopping first takes the capture away and the read after it is refused. `stop()` awaits it before it stops accepting chunks and before it takes the pending set, or the tail would be dropped one line later and `finish` could overtake the last send. A release still skips the last read: the screen is going away and there is nobody left to hand the tail to. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): release the page session's wake tag when the session ends The wake-lock server held its tags per instance and a new one was built per page session with nothing ever disposing it, so a tag a session took was never given back and the screen stayed awake for the app's lifetime. The page is a document that can navigate, fault or be swiped away mid-dictation, so nothing else was ever going to call deactivate. Its own docstring claimed the opposite. It now answers `{ serve, dispose }` and is disposed with the session exactly as the microphone and the staged media handles are. `dispose` drops only what is still held, so a tag the page already gave back is not deactivated twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the capability flag no screen reads `canCaptureAudio` answered four grants to nobody and had no test. The fence that actually holds is the per-verb `ungranted` check every member already makes before a frame is sent, and the mic control's render case pins what a screen does with it. The surface's member list is pinned instead, so the next flag with nothing behind it has to be added there on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the closure the dictation census records, and make its controls real Three corrections to the census, all of them about the census lying rather than the product being wrong. The recorded number was four modules; measured on this head it is eight — five from `@orca/expo-two-way-audio` and three from `expo-keep-awake` — for a net +7 once the local file that left is counted. The absolute closure counts are provenance in the docstring and are not asserted, because every merge of main moves them. The absence now has a precondition: both package names are resolved from the install, so a substring matching nothing fails as a typo. The case named "reads the census file" read no file. It reads the shell's own verb table through `import()`, behind the closure guard, so a grant the shell has no row for reds instead of agreeing with itself. And the grant control re-implemented the rule's filter inline. Both the rule and the control drive one function now, over the entry C7.7 would write if it copied its neighbours' grants. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serialise the shell's audio starts and stops Two starts racing the OS permission prompt — a page reloaded while it is up, which is the case the replacement rule exists for — both reached `listen()`, and the second overwrote the first's handlers without removing them. The engine went on calling into a capture nobody could read, and `dispose` freed one of the two. A stop that overlapped a start found nothing to end and the start opened a microphone behind it. Starts and stops now run one at a time in the order the page asked for them, and a start that comes back after the session ended opens nothing. Reads stay off the queue: they must not wait behind an opening capture, and a read with no capture is already a refusal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): end a capture on the same two interruptions on both hosts The page treated any interruption as the capture being taken away, while the native seam has always gated on `began` and `blocked`. So an `ended` on its own — the OS handing the session back after a notification chime — cancelled a live dictation on the page and did nothing natively. The rule is now one predicate beside the vocabulary it belongs to, read by all three places that decide it: the shell, which stops filling its ring; the native seam, which raises it off `onAudioInterruption`; and the page, which raises it off a read reply. `recording` still ends the page's capture whatever the kind, because a capture the shell no longer has is gone however it went. The native half had no test of its own, which is why the drift was invisible. It has one now: the five calls it makes, the chunk it hands over, the wake tag, and which interruptions end it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): type the chunk sender as what its callers pass The sender's parameter was the native `MicrophoneDataEvent` though both callers hand it a `DictationCaptureChunk`. Structurally the event is the wider type, so it accepted either and read `droppedBytes` off neither — a page whose audio the shell's ring had dropped would have sent it as though nothing were missing, and nothing would have failed to compile. Typed as the chunk, with a compile fence beside the seam holding both directions: a chunk is accepted, an event is refused, and a raw buffer is not a chunk. The seam normalises the bytes, so the widening the sender did has no caller left. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the session closure with dictation's page modules on it The capture seam moves this count down rather than up. Measured at |
||
|
|
c77a82f783 |
feat(mobile): fire the page's haptics over the bridge notify (OTA phase C, C7.10 E) (#21864)
* feat(mobile): give the page a haptics notify and the grant that gates it `native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly the five `src/platform/haptics.ts` has, and the single token `haptics` joins `BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb because nothing is owed back: a reply would spend a slot in the same 64-deep in-flight window a forwarded request does, and there are 90 call sites in this app, some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30). The arm's fields live in their own module because `bridge-envelope.ts` is at its line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays in the envelope, so the fields are spread in beside it rather than reading it back through an import cycle. The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other device-local notify does: the host is the protocol's side of the bridge and a static import of the app's haptics would put `react-native` and `expo-haptics` in its graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping — `haptics.ts`'s own functions, its `Platform.OS` split and its Android `HapticFeedbackConstants` untouched. The dispatch branch rides along with the union rather than waiting for the page side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over that union, so the shell does not compile without it. That is the totality working, and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is. Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin and the missing-row type error all failed against the tree before this. Control on the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): post the page's haptics over the notify instead of doing nothing `haptics.web.ts` stops being five no-ops. Each of the five posts its own kind through the notify seam the entry publishes — the same shape `publishExternalLinkOpener` has, and for the same reason: every caller is a plain function inside a row's press handler that no provider wraps. `notifyHaptics` joins the page client beside the other gated notifies and answers whether the frame left, which nothing reads: a tap that did not buzz is what the page did before this, and a warning per refusal would be one per row of a scrolling list. Measured off the frame the client posted rather than a written copy of its shape, which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection / success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`, and a twelve-row scroll 888 bytes across twelve frames. The `web-overrides.json` reason now says what the file does instead of what it declines to do. Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a function`, and the six client cases on `notifyHaptics is not a function`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): grant haptics on every page route, with a census that derives the list All five declared page routes carry the `haptics` grant, and the list is a measurement rather than a hand choice: `WorktreeListRow` is in every page closure and calls the seam, so a route without the grant is a page whose taps stop buzzing with nothing on screen to say why. Grants are resolved once from the route the shell opened and held for the session, so the declaration is the only place to fix it. `mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one: it reads the kinds off the tuple that declares them, finds every exported `trigger…` function in a haptics module, and reports the kind each one posts. The posting call is found through the binding `publishHapticsNotifier` assigns rather than a local spelled `post`, because a rename would otherwise turn every posting site into a non-posting one and leave this green on a page with no haptics at all. The census proper holds each route's closure to the `.web.ts` sibling, asserts at least one importer so the grant is not idle, and derives the granted-route list from the closures. The control is the design's: the same walk over the native sibling finds the same five functions and no posting site, so "all five post" is a number rather than an empty scan. Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post reds 1 of 23. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record what the haptics notify costs a page closure One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it arrives through `page-route-policy.ts` reading the grant token rather than through the seam, whose import of the kind type is erased; its only dependency is `zod`, which the envelope already put in every closure, so the module total moved by the same one. Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336. Pinned structurally rather than as a total, because an absolute closure count is main's to move and a number that drifts for unrelated reasons is one nobody reads. The call sites this replaces, measured over product modules: `triggerError` 43, `triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10, `triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus `page-haptics.ts` itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry the haptics grant into the shell's two grant pins `bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over and reaching the real module pulls in an Expo runtime this test does not have, which failed the whole suite at import. Which expo member each kind reaches stays in `page-haptics.test.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): map each haptic kind to a named import, not a namespace index The changed-code gate refuses a computed reference into an imported namespace, in both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()` is a call nothing can follow. Each function is a named import instead, which also keeps the second compile-time direction — a row naming something `haptics.ts` does not export is now an import error rather than a `keyof` mismatch. The third direction moves with it, from a namespace read in the test to the census that already reads both files' text: `hapticsImportedNames` names what the shell's mapping takes from the app's haptics, and the census holds that to the five the native file exports. So a haptic added there with no kind of its own still fails, and now it fails where the other two siblings' names are already compared. The test's two `as` assertions become one annotated hoisted type, the shape `MobileWebShellScreen.test.tsx` uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the true reason the haptics grant is one token `GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>` with lowercase segments, which is why it rejected `native.media.readChunk` and rejects `navigate-back`, not a dotted name as such. So four comments claiming a route declaring the notify's own name would have its bundle refused were false, and they are gone: the grant is a token because the notify table's grants are tokens, a notify not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from the verb table alone. Also folded, with the false claim: `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so a token every page route declares couples the whole set to a shell that carries it — against one without it, no page route is served at all and the phone renders five native screens. Stated in the function's docstring and beside the census's derived list, and pinned: the same declaration under a grant this build does not implement comes back empty, with the token-free route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS` reds that case. `%#` consumes no argument, so the web seam's five cases were titled with the whole function body; the kind is the first element now and `%s` names it. One 110-char comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two still over it there are main's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
e5181113ca |
feat(mobile): add the media verbs the page's pickers and paste will use (OTA phase C, C7.4) (#21779)
* feat(mobile): add the media verb rows to the shell's native table (OTA phase C, C7.4)
`native.media.pick`, `native.media.readChunk` and `native.media.release` join
`BRIDGE_NATIVE_VERB_NAMES`, so each becomes a grant name `init.grants.native` can
carry and each gets a strict zod contract in `bridge-media-verbs.ts`. A chunk read
is held to the upload path's own budget, imported rather than restated: the leaf
module `mobile-clipboard-image-upload-chunk.ts` now declares
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` so the bridge can read it
without pulling the upload path's RPC operations into every page closure.
Inert: no page calls the verbs and no route declares them, so an older page never
names them and an older shell refuses them as `unknown-verb`.
`native.clipboard.read`'s `{ mime: 'image' }` refusal is retired rather than left
pointing at nothing. The broad shape existed so a later build could serve an image
without a contract change; that build is `pick { source: 'clipboard' }`, which
stages the image behind a handle instead of inlining 24 MiB of base64 through an
8 MiB reply. The mime enum is text only, the handler's out-of-scope arm is gone,
and the refusal's test flips to the new answer: `invalid-params`, before dispatch.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the shell the staged-media handle registry (OTA phase C, C7.4)
A handle names a file this shell copied into its own cache, and such a file has no
owner otherwise: the page that asked for it is a document that can navigate, fault
or be swiped away without telling anyone. So the lifetime is bounded four ways and
all four are here — the page's own `release`, `MEDIA_HANDLE_TTL_MS`, and
`releaseAll`, which both the session's end and the page's unmount reach through
`useMediaHandleRegistry`. The TTL is measured from the last touch, not the mint, so
a page reading a large file one chunk at a time cannot have it swept out from under
it; its five minutes is sized for the page that picked and then stopped, not for
the read, which is 48 round trips on the largest item a pick may stage.
`BRIDGE_MEDIA_MAX_LIVE_HANDLES` caps what one session holds. A pick that would pass
it is refused whole and discards what it staged: half a multi-select is an answer a
page cannot tell from a user who picked fewer.
Three refusals join the seam's vocabulary. `native_media_handle_unknown` covers
never-minted, released and swept alike — which of the three it was is a fact about
another page's pick. `native_media_range` refuses a read at or past the end of an
item that had bytes, because the previous chunk already said `eof` and an empty
answer would let a page loop instead of failing where the bug is.
`native_media_handle_cap` is the cap above.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): serve the media verbs on the device, behind staged handles (OTA phase C, C7.4)
`pick` runs the OS permission prompt inside the shell — `expo-image-picker` for the
library, `expo-document-picker` for Files, the pasteboard for `clipboard` — stages
each item as a file this shell owns, and answers handles. `readChunk` reads the
byte range the registry hands it and base64-encodes it through the upload path's
own accumulator; `release` deletes the file.
The device calls live in `native-media-device.ts` and nothing else. Importing
`expo-image-picker` imports React Native, so a module naming it cannot be driven in
a unit test at all, and the arms worth pinning are exactly the ones a simulator
makes expensive: a denied permission, a cancel, an item over the staging ceiling.
`native-media.ts` takes those seven calls as dependencies and is tested whole.
Two refusals join the vocabulary, each because a page acts differently on it.
`native_media_permission_denied` is a permission the user can still grant in
Settings, not a library the shell could not read. `native_media_too_large` is an
item over `MEDIA_STAGED_MAX_BYTES`, weighed from the staged file rather than from
what the picker declared, since a picker's own size is optional on both platforms.
A pick refused that way discards every file it staged.
Each chunk is base64 on its own, so a page concatenates decoded bytes and never
strings: only the last chunk of a read ends on a partial group.
Measured: the largest reply this verb can produce is a full
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` chunk, 524,427 bytes on the
wire with its envelope — one frame, 80.0% of the 640 KiB frame cap and 6.3% of the
8 MiB reply ceiling. Pinned in `native-media.test.ts`, so a cap or an envelope
field that moves shows up as a diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): only mint a media handle over a file this shell owns (OTA phase C, C7.4)
The Android arm of the same handler. Both pickers are configured to hand back a
copy in this app's own cache — `expo-image-picker` always copies, and
`expo-document-picker` does under `copyToCacheDirectory` — and the whole handle
contract rests on it: `release` is a delete, and so is the TTL sweep. A provider
that answered `content://media/...` instead would mint a handle over a file this
shell can neither size nor delete, and every sweep would be a silent no-op that
leaves the cache growing. `ownsStagedMediaUri` refuses that where the assumption
is made rather than letting it surface as a cache that never empties.
`native-media-device.test.ts` pins the options that make the assumption true: the
document picker's `copyToCacheDirectory`, the library picker's `base64: false` and
its selection limit, the pasteboard's png, and the cache file `stageBase64` writes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): move a picked image across the real pair, chunk by chunk (OTA phase C, C7.4)
Through `createFakeBridgePortPair` rather than the host alone, because a chunk is
the one reply this seam produces that is within a rounding error of the frame cap:
a case that called the server directly would never serialize one, and serializing
is where a reply that fits and a reply that splits part ways.
A granted page picks an 18 MiB item, reads all 48 chunks in order to `eof` and
releases it; the decoded length is the file's own and both ends are the fixture's,
which a reader that dropped or reordered a chunk would not have. Nothing reaches
the desktop client. A page granted only `navigate` and `storage` is refused
`native_verb_ungranted` with no picker run — the refusal is the host's, because
the page side of these verbs is C7.6's. A released handle, a swept handle, a read
past the end and a pick over the live-handle cap each come back under their own
code.
The pair's default verb handler answered a clipboard shape for every verb, so a
media call through an unconfigured pair came back as `native_verb_result` — a
shell bug's code for a harness that was never told about the verb. It now answers
one shape per row of the table.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the four type assertions the changed-code gate found (OTA phase C, C7.4)
`new Array<T>()` for the two hoisted mock ledgers, and the probe holds its handler
in a record it null-checks rather than asserting one that a render might not have
produced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): rename the chunk verb to native.media.read, which a manifest may name (OTA phase C, C7.4)
`native.media.readChunk` is not a legal grant name. `GRANT_NAME_PATTERN` holds every
`native.` segment to `[a-z][a-z0-9]*`, so a route declaring it fails
`MobileWebBundleRouteSchema` — and `bundled-mobile-web-bundle.ts` parses the manifest
whole, so the camel-cased segment is not a route that degrades to its native screen.
It is a bundle the phone rejects entire. Params are unchanged.
The test that would have caught it now reads the schema itself rather than a copy of
its pattern: every entry of `MOBILE_WEB_SHELL_GRANTS`, and every name in
`BRIDGE_NATIVE_VERB_NAMES`, parsed as the grants of a manifest route. Both lists,
because the spread is what makes them agree today and a build that stopped spreading
would leave this the only thing that noticed. Red on the old name with the exact
message: `native.media.readChunk: expected false to be true`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): exercise a short read, which nothing separated from the chunk cap (OTA phase C, C7.4)
Every other read in the suite asks for a whole chunk of a file shorter than one, so
a handler that ignored `length` and read to the end passed all of them: the fake
file clamps at its own size. Replacing the range with the cap left 74/74 green.
A 16-byte read at offset 400 of a 1000-byte item now pins the length, the offset and
`eof: false`. Under that same mutation it reds with `expected 600 to be 16`, and it
is the only case that does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): kill the four device arms that survived mutation (OTA phase C, C7.4)
Each was green with the arm removed; each now reds with it removed and nothing else
does.
The data-url strip: `getImageAsync` answers `data:image/png;base64,...`, which is
what an `<Image>` source wants and not what a file wants. Staged unstripped, every
byte is shifted by the prefix and the page decodes a corrupt image with no error
anywhere. The old fixture had no prefix at all.
The cancel flag: both pickers answer `assets: null` beside `canceled: true` today,
so a handler keyed on the list alone passed every fixture here. The new case sends
the flag with a populated list, which is what a picker version that changed its mind
would send.
The unknown mime: an empty string is not a mime the result schema takes, so the
alternative to the floor is `native_verb_result` — a shell bug's code for a document
picker doing what it may do.
The handle close: a ledger per opened handle, asserted on the way out and on the way
through a reader that throws. A file handle a shell leaks is invisible on a fake and
a file descriptor on a phone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the stated constants as literals (OTA phase C, C7.4)
The TTL, the live-handle cap, the handle and mime lengths, and the 48 chunks an
18 MiB item reads in. Literals, not the constants restated: every one is a number a
body claims and a reviewer checked, and read through its own name the assertion
would hold whatever it became — which is what the 524,427-byte reply pin already
does for the frame it measures.
Each reds when its number moves: 8 to 12, 64 to 32, 128 to 256, five minutes to ten.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the fixture by its size, assert the clipboard refusal, reach the clamp (OTA phase C, C7.4)
Three minors from round 1.
The case that said "18 MiB" staged a million bytes. Renamed to what it stages, with
a line saying the largest item a pick may hold runs in the port-pair suite, where
the frames are serialized and the size is the one that matters.
The clipboard image case had been weakened to a bare `.rejects.toThrow()`, which
would pass for a handler that reached the pasteboard and failed there — the one
outcome it exists to rule out. It now asserts a `ZodError` naming the `mime` path,
and reds when the parse is taken out.
The registry's chunk clamp was unreachable behind the params schema. Kept and
reached rather than deleted: the two bounds are different promises, one saying what
a page may ask for and one saying what the registry will hand any reader, and
`read` is a public method its own suite already calls directly. A case asks for
three chunks at once and gets one; it reds when the clamp goes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): copy a provider uri into the cache instead of refusing the pick (OTA phase C, C7.4)
`MediaHandler.readExtras` in expo-image-picker 55.0.24 has a reachable arm: when
`toMediaType` cannot resolve a MIME it answers
`ImagePickerAsset(type = null, uri = uri.toString())`, the provider's own
`content://` uri, uncopied. The OS completed that pick, so refusing it as
`native_verb_failed` loses a photo the user chose — and the comment above the guard
claimed both pickers "always" answer `file:`, which the same file disproves.
The pick now copies what it does not own into the shell's cache and mints the handle
over the copy, which is what the body already claimed. The guard stays for anything
the copy could not adopt, and that arm still fails closed: a sweep that can never
delete anything must not look like one that did. The failure path discards what this
shell owns rather than the uris the picker answered, since a provider's uri was
never ours to unlink.
Copied through `bytes()` rather than `copy()`: `FileSystemPath.copy` goes to
`javaFile.copyRecursively`, a `java.io.File` with nothing to open for a provider uri,
while the read path goes through the unified file and does. A source that cannot be
read takes the empty destination with it, because the caller never learns that name.
Red-first: a fixture answering `content://media/external/images/media/42` asserting a
handle over a `file:` copy, its 300 bytes read back whole, and the copy being what
`release` deletes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound a multi-select to the room the registry has left (OTA phase C, C7.4)
`selectionLimit: 0` means unlimited to the OS picker, and `mint` then refuses the
whole pick past `BRIDGE_MEDIA_MAX_LIVE_HANDLES`. A user who chose nine photos waited
through the OS copying every one of them into the cache to be told none were taken.
`pick` now reads `remainingCapacity()` before it launches anything: an empty room is
`native_media_handle_cap` up front, for every source, with no picker run and no byte
copied. What room is left becomes the picker's `selectionLimit`, so the selection
cannot exceed what the registry will accept and the refusal after the fact is only
reachable by a page that never released what it holds.
`getDocumentAsync` takes no selection limit, so for Files the room is the up-front
refusal only; `mint` remains the bound that cannot be skipped.
Each pin reds on its own mutation: dropping the up-front refusal, putting
`selectionLimit` back to 0, and fixing the limit at the cap instead of the room.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for the library permission on iOS only (OTA phase C, C7.4)
Read from expo-image-picker 55.0.24, not from the docs. Neither platform's
`launchImageLibraryAsync` gates on a permission: `launchCameraAsync` calls
`ensureCameraPermissionsAreGranted` on Android and `hasGrantedPermission` on iOS, and
the library arm goes straight to its contract on both. On Android
`getMediaLibraryPermissions` answers an empty array from API 33, so the request
prompts nothing and always resolves granted; below 33 it asks for
`READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE`, which the system picker never
reads, and a denial there reached `native_media_permission_denied` for a pick the OS
would have completed. Android no longer asks.
iOS still does, because the prompt inside `pick` is what ruling 6 asked the shell to
own and the photo-library dialog is a real thing a user sees there.
One finding for the lead rather than a silent change: with `allowsEditing: false`
the iOS path is `launchMultiSelectPicker`, a `PHPickerViewController`, which also
opens without authorization. So the iOS request is the ruling's and not the SDK's,
and a denial refuses a pick that would have worked. Dropping it is a ruling-6
amendment, not mine to make.
Both arms pinned and both red under their mutations: asking everywhere fails three
cases, asking nowhere fails two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mock react-native in the device-verb hook suite (OTA phase C, C7.4)
The hook reaches `native-media-device.ts`, which now imports `Platform`, and reaching
the real module imports React Native — which this suite has no runtime for, so it
failed to load and ran zero tests while the run still reported every other file
passing. The device half's own platform arms are `native-media-device.test.ts`; here
the OS only has to be one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for no library permission on either platform (OTA phase C, C7.4)
Ruling 6b. `launchImageLibraryAsync` gates on nothing in expo-image-picker 55.0.24:
Android goes straight to its contract and iOS to `launchImagePicker(.photoLibrary)`,
and only the camera arm checks a permission. The prompt "inside the shell" that
ruling 6 asked for is the OS picker's own, so `pick { source: 'library' }` adds
none. `requestLibraryPermission` is gone from the deps, from the device calls and
from `pickFrom`, and with it the last way a denial could refuse a pick the OS would
have completed — Android below API 33 for storage permissions the picker never
reads, iOS for a `PHPickerViewController` that opens without authorization.
`native_media_permission_denied` stays in the refusal vocabulary, documented for the
first source that needs one rather than for a producer this build has.
Red-first, and driven through the whole verb rather than through `launchLibrary`
alone, since the request was `pick`'s and a case that only called the picker would
have passed either way: with the request restored, the pick on iOS calls the
permission API once and the deps still carry the member, and both cases red.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): sweep before release, and name one basis per ratio (OTA phase C, C7.4)
Two lows from round 2.
`release` was the only lifetime path that did not sweep first, so an expired handle
was still in the map when it looked and the page was told `released: true` for a file
the next sweep would have taken anyway — the opposite of what
`mediaReleaseParamsSchema`'s docstring promises it. It sweeps now, like every other
path that reads the map. Red-first with the reviewer's own probe: expired, then
released, answered true. The exact boundary is pinned while we are here, because the
sweep's comparison is inclusive and nothing said so: at exactly `MEDIA_HANDLE_TTL_MS`
the handle is gone, one millisecond earlier it is not.
The raw ceiling was called "three times the reply ceiling" in two places.
`CLIPBOARD_IMAGE_MAX_SOURCE_BYTES` is 18,874,368 against 8,388,608, which is 2.25;
three is the ratio after base64 expands it. Both sentences now name which basis they
are on, and say that the two differ.
Each pin reds on its own mutation: dropping the sweep from `release`, and loosening
the sweep's comparison to exclusive.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never sweep a uri this shell does not own, and weigh before copying (OTA phase C, C7.4)
Round 3's five items.
(1) `owned.push(uri)` ran before the ownership guard, so a copy that could not be
adopted sent the provider's own `content://` uri to the failure path's discard — the
one thing the array's docstring says it never holds. The push now happens below the
throw, inside a named `adopt` that either hands back a uri this shell owns or throws
having claimed nothing. Red-first: the refusal case discarded exactly
`["content://media/external/images/media/42"]`.
(2) The copy's docstring claimed the in-memory read was bounded by a ceiling checked
"right after", which ran after the whole `bytesSync()`. The source is now weighed
before the copy, which is real: `FileSystemFile.size` routes a `content:` uri to
`SAFDocumentFile.length()`. A provider reporting nothing answers 0 there, so the
docstring also says what is true for that case — the read is bounded only by
`mediaTypes: ['images']`, and the copy is weighed after the fact instead. The copy's
own size stays authoritative for the handle.
(3) Both `react-native` mocks went inert when `324c4093be` dropped `Platform`. Gone,
along with the two-platform loop that had become one code path run twice; the 6b
guard now reads "never calls `requestMediaLibraryPermissionsAsync`", which is what it
was checking.
(4) The hook case no longer claims a permission prompt.
(5) The 150-character comment is wrapped, along with the three other comments over
100 that I had authored in this file and the two platform modules.
Each behavioural pin reds on its own mutation: pushing to `owned` above the throw,
and dropping the pre-copy ceiling check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse what cannot be weighed, and count a Files pick before staging it (OTA phase C, C7.4)
(a) The pre-copy weigh is now a mechanism rather than a docstring, and the branch it
takes is the one the SDK allows. Verified in expo-file-system 55.0.26: a `content:`
uri reads its size through `SAFDocumentFile.length()`, so the common case is weighed
before `bytesSync()` materializes anything. There is no bounded read to fall back on
for the rest — `File.open()`, `readableStream()` and `writableStream()` all reach
`FileSystemPath.javaFile`, which throws `This method cannot be used with content
URIs` outright, leaving `bytesSync()` as the only read a provider uri has and it is
all or nothing. So an item whose provider reports no size, which answers 0 and is
indistinguishable from an empty file, is refused rather than read at an unknown
size. An empty pick was nothing to stage either way.
(b) `getDocumentAsync` takes no selection limit, so the room `pick` hands it was
advisory and a user could return more than the registry holds. Counted before
staging: previously all nine assets were copied into the cache and `mint` refused the
lot, which the red run showed as nine discards. The refusal is
`native_media_handle_cap` and it sweeps nothing, because nothing of this shell's
existed yet.
Three pins, each red on its own mutation: dropping the unweighable refusal, making
the room check a no-op, and taking it off the Files arm.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the last two traces of the library permission prompt from the media tests
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
f5d2d6e757 |
feat(mobile): carry browser screencast frames over the bridge as base64 (OTA phase C, C6.1) (#21758)
* feat(mobile): carry screencast frames over the bridge as base64 (OTA phase C, C6.1) `bridge-screencast-binary.ts` landed in C0 as the page's half of the binary lane and named C6 as the owner of the encoder that satisfies it. This is that encoder, plus the host honouring `wantsBinary`: a subscribe that asked for binary gets an `onBinaryFrame` on the native stream, and each frame crosses as the envelope's `event.binary` on the same `seq` ledger as the stream's JSON events, because the page acks by that count. The base64 encoder is grouped rather than per byte or per `fromCharCode` window. Its docstring carries the measurement, including the part that contradicts the design note this came from: on V8 the per-byte form is the fastest of the three, not the quadratic one, and the chunked form it was meant to beat is the slowest. The grouped one is here because its cost does not depend on how an engine ropes `+=`, and Hermes is what the shell runs. No new opcode, no `v` bump, no negotiation added: `wantsBinary` is already in the contract and is the negotiation. Over-cap behaviour is unchanged in this commit — a binary event over the frame cap still ends the stream, which is what C6.2 changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): drop an over-cap screencast frame instead of ending the stream (OTA phase C, C6.1) Measured at the pane's own request parameters, a screencast frame exceeds the 640 KiB envelope on a phone layout whenever the page will not compress: JPEG's worst case is 0.545 bytes per pixel at quality 72, so mobile view mode at 780x1424 is 811,289 bytes, 124% of the cap. Ending the stream there blacks out a browser tab for the life of the pane over one frame. So the two kinds of event part at the cap. A JSON event that will not fit still ends the stream with `overflow`, because its reader cannot see the hole it would leave; a screencast frame is dropped and the stream lives, because the next frame is one throttle interval away and the pane is still showing the last one. Both are asserted side by side so neither turns into the other. A drop leaves no other trace: the diagnostic beside it prints once per host, so a stream shedding a frame a second and one that shed a single frame read the same. The host therefore counts them per stream for the diagnostic and keeps a session total, and the shell's dev facts carry that total — the surface that already shows build state, with the line moved into its own module so what it says is pinned rather than inferred from a template. The 12-character build prefix it has always shown is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the binary screencast lane as a grant (OTA phase C, C6.1) Ruling 5's negotiation, and the check it asked for first: no reader of a grant is a closed enum, so there is no blocker and nothing an older page has to tolerate. `BridgeGrantsSchema.native` and the shell's manifest reader are both open string arrays, and the shell reader's own docstring already states the degradation — a grant name a build does not know leaves that one route native rather than refusing the bundle. What does constrain the name is the host contract's `GRANT_NAME_PATTERN`: a grant is one camelCase token or a `native.<domain>.<action>` verb with at least two dot segments. So `browser.screencast` and `native.screencast` are both refused, and the lane is `screencastBinary`. `screencast` alone would be wrong: the page can already subscribe to `browser.screencast` and receive its JSON events, and only the binary frames need the encoder. Added to the shell's implemented set, which is the same list `init.grants.native ` offers, so a route declaring it is served by a shell that has the encoder and left native by one that does not. No route declares it here; C7's session route does. The contract-side case is a characterisation pin, not a red-first one: the pattern already admitted this name, and the test records that the two tempting spellings are the ones it refuses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): check the dropped-frame total through the bridge hook (OTA phase C, C6.1) The hook gained a required `onBinaryFramesDropped` two commits ago and this test kept calling it without one, so the tests-typecheck ratchet went red on that commit — caught here rather than in CI because an exit code was read off a pipeline's last stage instead of the script. Fixed by wiring the callback into the probe rather than by a cast, and with the case that makes the wiring evidence instead of types: a dropped frame raises the total the screen receives, and the stream stays subscribed while it does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the dropped-frame counter with the ledger it belongs to (OTA phase C, C6.1) Declared between a getter and a method, which is not where this class keeps state: the subscription map is at the top and the counter is the same kind of thing. Move only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the binary screencast lane only to a route granted it (OTA phase C, C6.1) Reported as a gap after C6.1's third commit and ruled on: the host honoured `wantsBinary` from any page, so a route that never declared `screencastBinary` could still make the shell encode base64 on its behalf. That is the hole per-route grants exist to close — the same class as a route granted only `navigate` and `storage` reaching the clipboard. The rule now reads the session's resolved list, which is what its route declared narrowed to what this shell implements, and is the same set `init.grants.native` is built from. So the host offers the lane in `init` exactly when it will serve it. Ungranted is not a refusal. The subscription proceeds and its JSON events cross as before, which is the silence every other grant gives at the call site; a page that reads its own grants never reaches that state. Both branches are pinned beside each other, and `grantsForRoute` is pinned dropping a grant this shell does not implement — granted-but-unimplemented and never-granted arrive at the host as the same absence, so its rule reads one case. The grant name moves into the module that holds the rule reading it, so the two cannot drift. `bridge-host.ts` is at 298 of its 300-line cap after this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the page's stream-frame rules out of the host (OTA phase C, C6.1) `bridge-host.ts` reached 298 of its 300-line cap, so the next main merge that touched it would have crossed under CI pressure on someone else's PR. Split deliberately instead, at the boundary the growth came from. `bridge-host.ts` is the host's lifecycle and its dispatch. Opening a stream is the only frame kind whose handling is more than one line of delegation — four refusals and, since C6.1, the binary-lane decision — so it moves whole, and `cancel` and `ack` move with it so all three stream frames are decided in one place. The host's `cancel` arm still chooses between a stream and a request where it always did: a page's `cancel` names one or the other, and splitting that choice would leave half an arm in each module. Counted without blank lines or comments, as the rule counts them: bridge-host.ts 298 -> 270, and the new module is 59. A pure move. No test changed and none was added, which is what makes the existing suites the proof: 45 files and 745 tests green on the same assertions as before. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): report a page that asked for screencast frames it was not granted (OTA phase C, C6.1) An ungranted `wantsBinary` is not a refusal on the wire, so nothing crosses back: the subscription proceeds and its JSON events cross as they always have. That left a page which did ask getting JSON for the life of the document with no side able to say why. `notify-refused` has covered the equivalent notify case since C0; this is the same shape for the one frame kind that lacked it. The rule now answers a verdict rather than a boolean, because `not-asked` and `ungranted` are the same answer for different reasons and only one is worth reporting. So the decision and the report read one rule, and a page that never asked stays silent — pinned, along with a granted route staying silent, so the line cannot start firing on either. The wire is unchanged and pinned unchanged: the case beside this one still asserts one JSON event delivered and zero error frames. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): reset the dropped-frame total with the host that counts it (OTA phase C, C6.1) Round 1 on #21758, three findings. The real one: the count is per host and the screen's copy was not. A rebuilt host starts its own total at zero, so the screen kept the retired host's number until the new one dropped a frame and then read *lower* — a falling count looks like frames coming back, which is worse than starting over. The hook now announces a fresh count as it builds a host. That also reports zero on the first build, where the screen is already at zero and React bails out of the render; the two hook cases pin that leading zero rather than leave it to be rediscovered. Two docstrings that described nothing: `BUILD_ID_PREFIX_LENGTH`'s stayed behind when the constant moved to the dev-facts module and had drifted above `failureMessage`, and `page-route-policy.test.ts` kept the docstring of the test it replaced above the one that replaced it. Both deleted; the first's text lives on the new module. Red-first for the reset, checked against its final expectations rather than its first: with the one line reverted both hook cases fail on the missing zero, and both pass with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the dropped-frame total out of a production build's render path (OTA phase C, C6.1) CodeRabbit's Major on #21758. The total went into React state on every dropped frame in every build, and outside a development build the line that reads it renders null — so an over-cap page re-rendered the whole shell screen up to ten times a second for a fact nobody can see. Measured, not argued: five drops, five extra renders. Fixed at the seam rather than with a ternary at the call site. The dev-facts module owns the line, so it now owns the number behind it and the rule that the number is only state where something renders it. The screen holds no flag and no counter; it asks for both and passes the reporter on. The reporter is stable, so the bridge host is never rebuilt for it. `isDevelopmentBuild` becomes a call rather than a module constant. A build flag never changes at runtime so this costs nothing, and as a constant the branch was unreachable to anything that did not set the global before the module loaded — which is why the production case could not be written at the screen at all. Also fixed, found while writing that case: the screen test's `usePageHostSnapshot` double returned a fresh object on every render, so the host effect's identity changed each time and the bridge host was torn down and rebuilt on every render of the screen, settling every pending request with it. The real hook holds the snapshot in `useState` and is stable. One object for the file now. This was masking the fold under test — the count reset to zero on every render — and every other case in that file was measuring a rebuild storm. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): price a screencast frame before encoding it (OTA phase C, C6.1) Round 2 on #21758, two lows. The encode is a base64 pass over the whole image and the window decides whether the frame can be posted at all, so deciding after encoding made a page that had stopped acking pay for every frame the shell then threw away — the reviewer's case is ten 300 KB frames against a closed window, 3 MB encoded and nothing sent. The size is knowable without encoding: base64 is ASCII, so JSON escapes none of it and the frame is its header serialized plus exactly the image's encoded length. `encodeBridgeScreencastFrame` is now built from that header rather than beside it, so the shape measured and the shape sent cannot drift, and the window arithmetic is one rule read before the encode and again on the frame that was. Exact, not conservative, so the drop diagnostic still reports the whole frame and the committed byte pin is untouched. Red-first with the real encoder wrapped in a counter: window full, ten frames, ten encodes before and zero after, with the drop count still ten. An over-cap frame likewise goes from one encode to none. A third case holds the other direction — two carryable frames still encode twice — so the fix cannot pass by encoding nothing. Second low: the dev-facts block sat outside the only `beforeEach` and left `routeGrants` and `client` mutated, inert only because it runs last. The shared setup moves to file level where the mutable dependencies actually live, resets both, and a case at the end of the file pins it — deleting the reset fails there and nowhere else, since nothing else runs after a case that mutates them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
c22c442fdb |
feat(mobile): answer native verbs on the shell, clipboard first (OTA phase C, C2.4) (#21623)
* feat(mobile): declare the native verb table and advertise it (OTA phase C, C2.4) The contract half of the shell-answered request seam: the `native.` prefix, a typed table with params and result schemas per verb, and the two clipboard verbs. `MOBILE_WEB_SHELL_GRANTS` spreads the table's own name tuple rather than restating it, so a verb cannot be advertised without a row and a row cannot exist unadvertised — the table is `Record<BridgeNativeVerb, …>`, so a missing row does not compile, and the suite holds the other direction. Verb names go in the flat grant list on purpose: a route may declare one, and a shell that lacks it keeps that route native rather than walling it. The mime shape admits `image` because a later build will serve one; this one refuses it, and the reason will say out of scope rather than unsupported, since `expo-clipboard` implements the image calls. No frame kind is added and no protocol version moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): answer native verbs on the shell and fence them from the desktop (OTA phase C, C2.4) The host half of the seam. `forward()` is the one place a request reaches the client, so the `native.` check sits there and nothing about ids, caps, settlement or cancel moves: a native request takes a pending slot and settles on the same frames as a forwarded one. `readBridgeNativeVerbCall` is the whole decision, separate from the host so the `ungranted` arm can be exercised at all — every page is offered every verb this build implements, so through a real host that arm is unreachable today and is the point of the check once a grant is per-route. Refusals carry `native_verb_refused`, which the desktop's vocabulary does not contain: an unlisted method comes back from `MOBILE_RPC_METHOD_ALLOWLIST` as `forbidden`, so reusing that would make a leaked fence read as an ordinary scope refusal. Every case in the host suite reads `client.requests` for the same reason. `_meta` is omitted from host-authored replies per the ruling, which required making it optional on `RpcSuccess`/`RpcFailure`: the type required a field the wire never has. `isRpcResponse` does not read it, `runtime-rpc-envelope` already makes it optional on a failure, and nothing in this app reads it — every occurrence is a fixture writing one. Zero other type errors resulted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the harness verb-type import and drop an unused one Two leftovers from threading the native reply type through and then removing it: the host harness lost its `BridgeNativeVerb` import, and the request module kept a type import nothing uses. `tsc` and oxlint both failed on the previous commit; this is the follow-up rather than an amend. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): typecheck the native verb suites and drop the dead reply type Three leftovers the ratchet caught, none visible to `tsc -p tsconfig.json`, which excludes test files: - the fence suite read `frame.payload` off the whole `reply` union, and a chunked reply has no `payload`; it narrows on the field now - the bridge hook's own suite builds its caller options inline and had no `serveNativeVerb` - `BridgeHostAuthoredReply` became unused once `_meta` was optional, and an exported type nothing reads is the pattern round 2 of C2.3 flagged; the statement it carried already lives in the verb table's header Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): give the page a typed surface for the native verbs (OTA phase C, C2.4) `useNativeVerbs` is the page's side, typed from the same table the host serves, so a verb cannot be called with params the shell will refuse. Each call goes out as an ordinary `request` and settles on the ordinary frames; the method name is the whole difference. A verb the shell did not grant is refused before a frame is sent, because a rejection after a round trip and one that never left look identical to an `await` and only the first costs an in-flight slot — `granted` is exposed so a caller can pick its own fallback instead. Results are parsed rather than trusted: the shell is a different build than the page, and a result shape that moved should fail at the seam rather than halfway through a screen reading a field that is not there. No call site uses it yet; the two `Clipboard.setStringAsync` sites are the consumer PR's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send native verbs from the module that owns the request port (OTA phase C, C2.4) `use-native-verbs.ts` called `client.sendRequest` directly, which the unvalidated-request-port boundary refuses: new code must send through an `RpcOperation`, and nothing may be added to the inventory. An `RpcOperation` is not available to this seam. Its `method` is typed `RpcMethodName`, which is `keyof typeof RPC_PARAMS_BY_METHOD` from the desktop's generated params catalog. Putting `native.clipboard.read` there would declare that the desktop serves a method the whole fence exists to keep off it. So the send moves into `bridge-rpc-client.ts`, already listed as an owner of the port — a module that implements the port rather than a call site picking its own method and acceptance. `callNativeVerb` rides the same frame, id space and in-flight cap as any request, and the page surface stays a thin typed wrapper that reaches no raw port. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fence native methods on subscribe, not only on request (OTA phase C, C2.4) The fence sat in `forward()`, which is the one place a *request* reaches the client. A `subscribe` reaches the same client by another door: a frame naming `native.clipboard.read` opened a real stream on the desktop, and because `client.requests` stayed empty the whole suite read as green over it. Refused in `handleSubscribe` before the id is claimed, under the same `native_verb_refused` code, so nothing about the frame reaches the desktop or occupies a slot. Cancel needs no arm of its own: it can only settle an id that was admitted, and none is. The oracle is widened with it. Every case now reads the client's streams as well as its requests, because the old one could not see this at all — an absence that only ever looked at half the boundary. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a native verb's answer to the result it declares (OTA phase C, C2.4) The table names a result schema per verb and the host never applied it, so a handler could answer `{ nonsense: 1 }` and the page's own parse would be the first to notice — halfway through a screen, not at the seam. Validated on the host and refused by name on a mismatch, which is what makes the table's claim true on the side that serves it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the native verb member from being a raw port (OTA phase C, C2.4) `callNativeVerb(verb: string, params: unknown)` took any method, so `callNativeVerb('worktree.list', …)` reached the desktop through the real pair — a raw request port in the one module allowed to hold one, and invisible to the inventory, whose scan counts `.sendRequest` shapes and not a bare call inside the owner. The parameter is typed `BridgeNativeVerb` now, which is the fence for every caller the compiler can see, and the prefix is checked at runtime for one that reached the member through a widened type. The compile-time half is pinned by a `@ts-expect-error` the tests-typecheck ratchet holds: widening the parameter back makes that directive unused and fails there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give every native verb refusal one typed error at the page (OTA phase C, C2.4) Only the seam's own refusal carried `native_verb_refused`. A handler that declined and a reply too large to send arrived as other categories with no code at all, and the hook rethrew a bare `Error(message)` — so a caller telling an out-of-scope mime from an unsendable clipboard had to read message text, and those want different handling. Three changes, one shape. The host re-raises a handler's failure under the seam's code, keeping the handler's message because that is what says why. `BridgeReplyUndeliverableError` carries its frame refusal as a code, so `reply-too-large` survives to the page. The hook throws `NativeVerbError` with a `reason` read off the code `reconstructBridgeError` already copies onto the rejection, plus `ungranted` for the arm this side decides. Removes the unreachable `ok: false` branch from the hook with it. The narrowing it was doing moves into the client member, which now promises a success or a rejection and nothing else. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): test the in-flight cap and cancel, not the duplicate-id branch (OTA phase C, C2.4) The case named for the cap sent the same id twice, so what it exercised was the already-in-flight check. It never held a second slot and would have passed against a seam that took none. It now fills the cap with distinct ids against a handler that never settles, and asserts the one over it is refused with the cap's own message. A cancel case goes with it: a native request cancelled before its handler settles posts nothing afterwards, the way a forwarded one does not answer an exchange the page has moved on from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native verb member's type with a directive, not a cast The case proving a desktop method cannot go through `callNativeVerb` reached the runtime guard with `as never`, which the casting gate refuses — and a cast is the wrong tool anyway: it asserts past the very type the case exists to pin. `@ts-expect-error` instead, which the tests-typecheck ratchet holds: widening the parameter back to `string` makes the directive unused and fails there. The call still runs, so the runtime guard is exercised too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): parse the shell's error code instead of reaching for it (OTA phase C, C2.4) The anti-slop audit refuses `Reflect.get`: dynamic input is parsed into a named shape before it is read. `code` is not a property of `Error` — it is whatever `reconstructBridgeError` copied onto the rejection from the capture — so a schema is the honest reader here, and it says what this takes without asserting the rest away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the id collision before the native fence on subscribe (OTA phase C, C2.4) The fence ran before the already-in-flight check, so a `subscribe` naming a `native.` method under a live request's id settled that request with the fence's message. The page lost the request either way — the collision class predates this PR — but it was told the wrong cause, which is the difference between a bug it can see and one it cannot. Collision first. The fence still runs before any slot is taken, so nothing about the frame reaches the desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): parse a native verb result once, inside the catch (OTA phase C, C2.4) The result was parsed twice: by the send path against the table's schema, and again at each caller against the concrete one. The second parse was dead, and it sat outside `call`'s catch, so a shell answering a shape the page did not expect would have escaped as a bare `ZodError` — the one shape this surface promises not to throw. `call` takes the verb's result schema and parses once, inside the catch, so every failure leaves as a `NativeVerbError`. The params parse at the callers goes with it; the host validates params and the page builds them typed. Also moves the comment block documenting `onExternalLink` back above it, which `serveNativeVerb` had landed in front of. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give every native verb refusal its own code, and keep handler words on the device (OTA phase C, C2.4) Two findings that had to land together. Six faults all arrived as `native_verb_refused` and differed only in message text, which the hook's own comment said nobody may switch on. And a handler's message crossed verbatim: a clipboard read that failed after reading is free to put what it read in its error, and the error frame is the only path out of this seam that is not a declared result. So each fault gets a code — unknown verb, ungranted, bad params, wrong result, out of scope, handler failure, native-on-subscribe — and the three paths that reached the page uncoded get one too: the in-flight cap, a non-native method through a widened member, and host disposal. `reason` is now drawn from a declared list with no `unknown` arm, asserted at the hook. A handler's code crosses and its message does not; the shell logs the real one. The out-of-scope mime stays distinguishable because the code carries it, not the text. `bridge-host.ts` crossed the line cap with this, so the serving half moves to `bridge-host-native-verbs.ts` — read the call, serve it, hold the answer to what the verb declares — leaving the host the frames around it. No cap was disabled or raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse unknown verb params, and floor an unknown code (OTA phase C, C2.4) Two the bots caught, both about a shape one side does not know. `z.object` strips unknown keys, so `{ mime, value, unexpected }` dispatched as if the extra key had not been sent — and the page and the shell are separate builds, so a param the shell silently ignores is the shape of a verb that changed underneath a page. `z.strictObject` on the verb params and results. And the page passed any code through as `reason`, while its own doc and `NATIVE_VERB_REASONS` promised a closed list; a shell newer than the page would have fallen off the end of a caller's switch. Unrecognised codes floor to `unreported`, `reason` is typed to the list, and the doc says what the list actually is rather than the single code the per-arm ones replaced. The flooring is tested by delivering the frame such a shell would send: this build's host normalises an unknown code before it leaves, so the pair cannot produce one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve no request before the page has asked for a session (OTA phase C, C2.4) `serving` starts true so a page's first frames are not refused for arriving in the same native batch as its `ready`, but nothing checked whether an `init` had ever been sent. So a request from a document this host had told no caps, no grants and no route was forwarded to the desktop, or served as a native verb, while the notify path had refused exactly that since C0. Gated on `initSent`, under the protocol's own `before-ready` name. Streams are left alone: the finding names requests, and gating `subscribe` too is a wider change than it asked for — worth its own decision, since the same hole is there. Fourteen host cases and five hook cases were relying on this: they open a request without ever asking for a session, which no real page does. They take a `ready` now, through a harness option, and the counts that read what the host posted account for the `init` a session opens with. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): grant a page session what its route declared, not what the app can do (OTA phase C, C2.4) `init.grants.native` handed every session the shell's whole capability set, so a route declaring only `navigate` and `storage` was granted `native.clipboard.read` as well. That was harmless while every grant was a navigation or a write the page could make anyway. It stopped being harmless the moment a verb reads something back, which is this PR. The session is now granted the intersection of what this shell implements and what the mounted route declared in `MOBILE_WEB_PAGE_ROUTES`, plus the protocol's own `fault`. One list: `init` issues it and every grant check — notify and native verb — reads the same one, so what a page is told it may do and what it will be served cannot drift. `MOBILE_WEB_SHELL_GRANTS` and `implementsGrant` are unchanged; the shell's capability set is still the ceiling a route's list is drawn from. User-mediated authorization is not attempted here and goes to C2.7 as an open question. `use-mobile-web-shell-session.ts` crossed the line cap with the extra field, so the three effect workers that touch the network and the disk move to `mobile-web-shell-session-effects.ts`, leaving the hook its reducer and callbacks. No cap was disabled or raised. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): key the pre-handshake refusal on the session, and apply it to streams (OTA phase C, C2.4) Two rulings, one mechanism. The gate keyed on the host instance, and a host is rebuilt whenever the client under it changes. The page does not know: the session id is the same, so it neither re-handshakes nor hears that the shell was replaced. So a live page's next request was refused, and would have been until reload — a regression, not a safety gain, and not covered by the in-flight settling as delivery-unknown. The host now inherits whether its session already handshook, which the hook records when the page first asks. And the rule is about the session rather than the frame kind, so `subscribe` is gated with `request`: a stream opened before the handshake was the same hole. Fourteen stream cases were exercising a state the protocol forbids — they subscribe without ever asking for a session, which no page does. Every one is about caps, backpressure windows, acks, cancel, idempotency or arity; none was testing anything through the hole itself. They complete the handshake now, and the counts that read what the host posted account for the `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): judge a cached fallback by its own routes and grants (OTA phase C, C2.4) The newer manifest is read before the download is attempted, so its `pageRoutes` and `routeGrants` are already on the session when the download fails. Opening the cached generation then mounted an older page under a newer bundle's grants: a cached route that never declared the clipboard would have been granted it by a manifest it is not running. The fallback now derives both from `cached.routes`, and applies that generation's own render eligibility before mounting it — a route only the newer bundle claims is not a route the cached page can serve. This is the Phase D "grants across generations" item arriving early. Only the grant side is fixed here; persisting a generation's grants with the generation itself stays Phase D's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): remount the shell on a route change, so its bridge cannot outlive it (OTA phase C, C2.4) A host captures the grants its session was opened with, and the agent-history route renders `MobileWebShellScreen` with a pathname derived from `worktreeId` and no key. So changing worktree updated the screen in place: the old bridge stayed mounted and kept authorising frames under the grants of the route the page had already left. Keyed on the route now, which makes the change a remount — the old bridge is disposed in the commit, before it can read another frame, and the new session starts with no grants until its own `init`. The worktree-list and embedded- browser routes are keyed on the host id for the same reason; the hazard is the same whenever a dynamic segment moves under a mounted shell. The probe that catches this uses an empty dependency array on purpose: keyed on the pathname it re-fires on a prop update and reads exactly like a remount, which is the one thing it exists to tell apart. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(shared): let a manifest declare a native verb as a route grant (OTA phase C, C2.4) `GRANT_NAME_PATTERN` was dotless, and the contract's own pin asserted a dotted grant is refused. So no manifest the desktop can produce could declare `native.clipboard.write` — and once grants are scoped per route, a verb no route can name is a verb no route is ever granted. Every native verb was unreachable for every route. The grammar now admits the verb shape the table names: `native.` followed by at least two lowercase dotted segments, which is `native.<domain>.<action>`. A plain name wearing a dot is still refused, `native.navigate` included, so the pin keeps its meaning. Wire compatibility, checked rather than assumed: widening what a manifest field may contain is a new optional value reaching readers that shipped before it, and the phone's reader already tolerates one. Its route schema bounds a grant's length and nothing else, deliberately — an unknown name is not a parse failure that would refuse the whole bundle, it is a grant this build does not implement, so `implementsGrant` drops it and the route stays native. Both halves are now tested: an unknown verb leaves its route native and grants nothing, and a known one reaches `init.grants.native`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the session reducer suite by concern before main pushes it over the cap Merged with main the reducer suite reaches 805 lines against a cap of 800 — neither side alone crosses it, which is the case the lane rules warn about. Split at a concern boundary rather than raised: the grant-facing cases (the cached fallback's own routes, and a manifest verb reaching the session grants) move to `mobile-web-shell-session-grants.test.ts`, and the fixtures both suites drive the reducer with move to a shared module beside them, the way the bridge host suites already share a harness. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
57fdf68ab3 |
feat(mobile): let the page hand a link to the device through the shell (OTA phase C, C2.3) (#21597)
* feat(mobile): answer externalLink on the shell side of the bridge (OTA phase C, C2.3)
A page has no way to open a URL outside itself: `Linking.openURL` is the
app's, and inside the shell the page is a document that cannot reach it. Adds
`notify { name: 'externalLink', url }` and a new grant name of its own in
`MOBILE_WEB_SHELL_GRANTS`, rather than a verb of `navigate` — `navigate`
opens a screen this app carries, this hands a URL to whatever the device
opens it with, and a shell implementing one and not the other is a real shell
the route policy has to be able to describe.
`https:`, `http:` and `mailto:` only, and broad inside that: any host, any
path, because a grant that named GitHub would grow a row per provider. The
rule is parsed rather than prefix-matched, since a scheme is what a URL
parser says it is and `startsWith('https:')` reads one out of
`javascript:alert("https://x")`. It is enforced at the frame as well as at
the page's call site, so a page that skipped its own check still cannot reach
the device handler. Bounded by the route href cap, per the ruling.
`BRIDGE_PROTOCOL_VERSION` is not bumped. Inert until a consumer exists: no
call site and no barrel is touched here.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): plumb externalLink through the bridge hook probe (OTA phase C, C2.3)
The hook's own suite builds its caller options inline, so the new required
option made it stop typechecking. `tsc -p tsconfig.json` excludes test files;
only the tests-typecheck ratchet saw it.
Adds the case that goes with it: a URL the page hands over reaches the
caller that can leave the app, and nothing reaches the navigate path.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): let the page post an externalLink over the bridge (OTA phase C, C2.3)
`notifyExternalLink(url)` on the page client, gated on the `externalLink`
grant and on the same scheme rule the frame enforces.
Checked twice on purpose. Nothing crosses back for a notify, so the boolean
is the only answer a tap gets: a page that posted a URL the shell's reader
then dropped would report "opened" into a frame nobody acted on, which is
precisely the dead tap the grant exists to rule out.
False before `init`, false after `close`, and never a throw — the callers are
tap handlers with no catch around them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): add the external-link seam the tasks call sites will use (OTA phase C, C2.3)
One module with a `.web.ts` sibling, which is the shape every platform gap in
this bundle already takes. Native is `Linking.openURL` with the rejection
swallowed, because every caller is a tap handler and `openURL` rejects for a
URL no installed app claims. Web posts the `externalLink` notify after the
same scheme check the frame enforces, names its refusals and throws nothing.
The opener is published by the entry rather than read from context, for the
reason `publishPageStorage` is: the callers are plain functions in render
trees the provider does not wrap. A document that published none refuses
every URL, which is the right answer for a page with no shell.
Registered in `web-overrides.json`: inside the shell's WebView, react-native
-web's `Linking.openURL` opens the URL in that WebView and replaces the page
rather than handing it to the system browser.
No call site and no barrel is touched. The consumer PR swaps them onto this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the real reason the page cannot open its own links (OTA phase C, C2.3)
The override reason claimed react-native-web's `Linking.openURL` opens the
URL in the shell's WebView and replaces the page. It does not. Read from
react-native-web 0.21.2: `openURL` calls
`window.open(url, '_blank', 'noopener')` and resolves whether or not anything
opened; only a `tel:` URL assigns `window.location`, and none of the three
allowed schemes is one.
The true failure is the worse one and the better argument for the verb. Both
shells refuse `window.open` outright, measured in their own sources: iOS sets
`javaScriptCanOpenWindowsAutomatically = false` and returns nil from
`WKUIDelegate`'s `createWebViewWith`; Android sets the same flag false, calls
`setSupportMultipleWindows(false)` and returns false from `onCreateWindow`.
So nothing opens, `openURL` resolves anyway, and the native path reports
success into a tap that did nothing — precisely the dead tap the grant exists
to rule out.
The seam's own comment now says the same, so the two files cannot drift.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): report a URL the phone could not open (OTA phase C, C2.3)
The shell swallowed `Linking.openURL`'s rejection. Nothing crosses back to
the page for a notify, so an open that failed — a `mailto:` on a phone with
no mail account — was silent on both sides. That is the one dead tap this
verb does not rule out, and it was the only one with no record at all.
Warned with the URL and the error, in the shape the two neighbouring reports
in this screen use, and still not rethrown: this runs on the native frame
handler. The contract now asks for the report rather than only for the
absence of a throw.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forward the URL the parser read, not the string the page sent (OTA phase C, C2.3)
The scheme check reads the protocol through the WHATWG parser, which strips
tab, LF and CR from anywhere in a URL and trims leading C0 and space before
the scheme is visible. So `ht\ntps://example.com`, `https://example.com/a\r\n`,
` https://example.com/a ` and `https:example.com` all passed the check, and
both sides then forwarded the original string. Not a scheme escape — the
parser had already decided the scheme — but the device handler was given a
URL the check never looked at, which is a dead tap through an allowed URL.
`readBridgeExternalLinkUrl` answers the parsed href, and the page posts it
and the host forwards it. Normalizing rather than comparing, because
`https://example.com` differs from its own href by a path slash: refusing
what differs from its normalization would refuse an ordinary URL.
The cap now applies to the normalized form as well as the raw string, since
percent-encoding expands and a string inside the cap can leave it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): exercise the seam's default opener instead of a published one (OTA phase C, C2.3)
The case named for a document that published no opener published one first,
and `post` is module state every earlier case had already set, so the default
at the top of the module was never the thing under test. The only assertion
was `not.toThrow()`, which passes against any implementation.
`vi.resetModules()` and a fresh import, and the refusal reason is asserted.
Confirmed to discriminate: flipping the default to `() => true` turns this
case red and leaves the other three green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build the openURL rejection per call, not at mock setup
The case added for a failed open used
`mockReturnValue(Promise.reject(failure))`, which builds the rejected promise
at setup time. Nothing attaches a handler until the notify frame arrives
several awaits later, so the suite reported an unhandled rejection and exited
1 with every test passing — a red run that reads as green in the counts
alone.
A fresh rejection per call closes the window, and `openUrl` is reset between
cases so the mock cannot leak into one that does not expect it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): guard the openURL failure that escapes a tap handler (OTA phase C, C2.3)
`Linking.openURL` validates before it returns anything: `_validateURL` is an
`invariant` that throws for an empty string (react-native 0.83.10,
`Libraries/Linking/Linking.js:117-123`). So the seam's `.catch` was attached
to a promise that, in that case, never existed, and the throw went straight
through a tap handler — contradicting the module's own claim to be safe in
one.
Both failure modes are now caught and reported, and neither is rethrown. The
test double validates the way the real module does, because a mock that only
rejects cannot reproduce the failure that escapes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): collapse the duplicate openURL wrapper onto the seam (OTA phase C, C2.3)
`mobile-pr-url.ts` was the seam's native body already, byte for byte, written
before it. It now re-exports the seam under its own name, so the empty-URL
guard and the failure report reach its four callers too.
Nothing changes natively: the seam's native form is what those callers were
running. On the web they would now post the notify instead, which is the
behaviour they should have had; it is unreachable today, since none of them
is in a page route's closure.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read externalLink end to end over the port pair (OTA phase C, C2.3)
The pair harness grew `externalLinks` and nothing read it. This is the
`navigate` twin's shape, over the six normalization inputs: what the page put
on the wire and what the shell forwarded are the same strings, read back off
the frames rather than recomputed, and nothing reaches the shell's client.
Two halves on purpose. The page normalizes before it posts, so over the
client the host only ever receives an already-normalized URL and forwarding
it raw would pass — the frame injected straight into the host at the end is
what holds the host to the rule on its own. Confirmed to discriminate:
reverting the host to forward `message.url` turns that assertion red.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): match the neighbours on three small inconsistencies (OTA phase C, C2.3)
Three of a kind, none behavioural:
- the notify guard's docstring had a 109-character line; reflowed
- the shell's could-not-open warning passed three arguments where the three
other reports in that screen pass two; it now passes `{ url, error }`
- the native seam's suite built its rejection with `mockReturnValue`, which
is the shape `52191bab02` removed elsewhere; it now builds one per call,
through the same double that validates the way the real module does
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
1ef947394b |
feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2) (#21582)
* feat(mobile): answer navigate-back on the shell side of the bridge (OTA phase C, C2.2)
A page served at `/` holds the one history entry its entry wrote with
`replaceState`, so `history.back()` goes nowhere and a page Back button is
dead. The only stack with somewhere to go is the native one the shell pushed
the page onto.
Adds `notify { name: 'navigate-back' }` to the closed client union, gated on
the existing `navigate` grant rather than a name of its own: an app that can
open a screen can close one, and a new grant name would leave every route
declaring it native on every shell already shipped. `MOBILE_WEB_SHELL_GRANTS`
is unchanged and `BRIDGE_PROTOCOL_VERSION` is not bumped.
`bridgeNotifyRefusal` grows a name-to-grant table, since this is the first
notify whose name is not its grant's. The shell screen pops its own stack and
answers false when there is nothing left, which the host logs as
`navigate-back-refused` — nothing crosses back to the page either way, so
silence there is indistinguishable from a Back button that worked.
Inert until a consumer exists: no page posts the name yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): hand the page's dead Back button to the shell (OTA phase C, C2.2)
`useRouteHandoff` wrapped `push`, `replace` and `dismissTo` and left `back`
as expo-router's own, which inside the page pops a history of one and does
nothing. It now pops locally when the document grew a stack of its own, and
otherwise posts `navigate-back` for the shell to pop the native stack.
This is what makes the Tasks header's `onPress={() => router.back()}`
(`src/tasks/mobile-tasks-screen-chrome.tsx`) work once the C2.1 consumer
routes that screen's router through this seam; the barrel still reaches
expo-router directly, so nothing calls this yet.
A shell that granted no `navigate` falls through to the local router rather
than throwing out of a tap handler. A shell that granted `navigate` but is
too old to know the verb refuses the frame as `unrecognised-message` and
logs it; neither is distinguishable from the page, and the fallback goes
nowhere in both — which is exactly where Back already went.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): count back among the members that leave the page document
The header said three wrapped members driven by one answer. `back` is a
fourth, and it is not driven by that answer: it carries no target, so the
document's own stack decides it rather than the shell's route list.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): complete the bridge hook probe in its two local literals
`Probe` grew `backPops`, and two cases build the object inline rather than
through `mount`. `tsc -p tsconfig.json` excludes test files, so only the
tests-typecheck ratchet saw it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure the stack the page Back button rests on (OTA phase C, C2.2)
`useRouteHandoff().back()` asks expo-router's `canGoBack()` whether this
document can serve a back itself, and that answer is React Navigation's, so
no unit test settles it. The render check now measures it in the browser it
actually runs in.
Read through `router.back()` on `/h/[hostId]/edit`, a real route of this tree
whose chevron is expo-router's own back, because the page exposes no handle
to call `canGoBack()` on and a global added for a test would ship forever.
Measured: the router has nowhere to go on the document the shell opens, and a
location change does not give it one either. So the handoff's `canGoBack()`
gate answers false for everything the shell or the browser can do to the
page, and its local branch belongs to a push the page makes through the
handoff itself.
The shell double now records every notify the page posts and takes the grant
list as a parameter, so a control that handed something to the shell can be
told from one that did nothing. The first case asserts a real tap crossing
the bridge, which is what makes the two absences after it evidence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse a second stack pop while the first is still queued (OTA phase C, C2.2)
`canGoBack()` and `back()` disagree about time. The first reads the committed
navigation state; the second only adds `GO_BACK` to expo-router's
`routingQueue`, which `useImperativeApiEmitter` drains from an effect. Two
`navigate-back` frames delivered in one native batch therefore both read the
stack the first pop has not left yet, both queue, and a three-deep stack
unwinds past the screen the page was opened over. The host forwards every
notify it is granted, so nothing upstream coalesced them.
`useShellStackPop` owns the pop and latches it. The latch clears on the
committed route rather than on a timer, because that commit is the first
moment `canGoBack()` answers for the stack the pop actually left; a pop that
takes this screen off the stack unmounts it and takes the ref with it.
`onNavigateBack` now answers `popped` / `nothing-to-pop` / `pop-pending` so
the `navigate-back-refused` diagnostic is true for the frame it names, and
the log dedupes per reason rather than burying the second behind the first.
Driven against expo-router 55.0.18's own `global-state/routing.js`,
evaluated verbatim with only its externals stubbed: a mock of `canGoBack`
is what hid this.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the notify grant table total over the union (OTA phase C, C2.2)
The table was `Record<string, string | undefined>` indexed with a plain
string, so a notify name with no row returned undefined, read as ungated,
and the host acted on a frame it had never granted. Adding a member to the
envelope's notify union raised no error anywhere — the hole was silent.
Keyed on `Extract<BridgeClientMessage, { type: 'notify' }>['name']` with
`string | null` values, an omitted row is now a TS2741 on the table itself,
and `bridgeNotifyRefusal` cannot be asked about a name the table has no row
for.
Adds the `navigate` and `storage` rows, which were missing: the host was
enforcing the navigate grant for `navigate-back` but not for `navigate`.
Both are inert while every page is offered every grant, and load-bearing the
moment a grant is per-route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): assert no page errors in the browser case that drives popstate (OTA phase C, C2.2)
The case that dispatches a synthetic `popstate` read its answer as "the page
did not move", and a throw under the page's fault boundary leaves the page
exactly there. Without the errors assertion the other two cases carry, that
absence was not evidence of what it claimed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the routing module's members instead of asserting them
The casting gate refuses the two assertions the loader used, and it is
right: a stub that stopped covering an import would have left the members
undefined and the test would have driven a half-evaluated module. Destructure
and check instead, so that case says so.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the two pops the stack latch never hears about (OTA phase C, C2.2)
The comment read as if the clear were exhaustive. It is not: a pop landing on
an equal pathname does not transition `usePathname`, and a `GO_BACK` the queue
discards commits no route at all — `routingQueue.run` shifts every action off
the queue whether or not `ref.current` is set.
Kept the pathname clear rather than moving to the navigator's `state` event.
The event would cover the first stick: `@react-navigation/core` 7.17.2 emits
`state` from an effect keyed on the navigator state object, and every pop
replaces it. It would not cover the second, which changes no state. And the
emitter is the navigator, not the routing module this hook is written and
tested against, so the switch cannot be earned by a test here the way the
queue behaviour was — it would rest on a mock of the signal under test.
Both sticks are bounded instead, in the commit that makes the latch one per
stack: the holder releases on unmount, so a stick lasts at most as long as the
screen that took it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold one pending stack pop per stack, not per screen (OTA phase C, C2.2)
`MobileWebShellScreen` mounts at both `app/h/[hostId]/index.tsx` and
`app/h/[hostId]/web.tsx`, and `/h/a/web` is deep-linkable over `/h/a`, so two
shells can be mounted over one native stack. A latch per screen left each of
them holding its own, and two frames from two pages still unwound two screens.
The latch is module-scoped and carries which screen took it, so a shell whose
own route commits cannot release a pop another shell is still waiting on. The
holder also releases on unmount, which is what bounds the two pops the
pathname clear never hears about: a latch nobody is left to release would
outlive the stack it guards and leave Back dead for the session.
Both screen suites now unmount their trees between cases, because a tree that
is only dropped is a screen still holding whatever pop it took.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the gated-notify name list nothing reads (OTA phase C, C2.2)
`BRIDGE_GRANT_GATED_NOTIFY_NAMES` existed so a caller could ask which names
ride a grant. Once the table became total over the notify union, the table
itself answers that and the only readers left were the two assertions that
read the list for its own sake.
Deleted with them. The behaviour they stood next to is kept: the protocol's
own names are still asserted ungated through `bridgeNotifyRefusal`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say what the routing-module guard actually catches (OTA phase C, C2.2)
The comment claimed the guard catches a stub that stopped covering an import.
It does not: evaluating the module with every stub dropped still defines all
three exports, because the module assigns them whatever its imports resolved
to, and the failure surfaces later as `TypeError: Cannot read properties of
undefined (reading 'navigationRef')`.
What the guard does catch is an expo-router upgrade that renames or removes
one of the three members this test drives.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the second shell mounted across the holder's removal (OTA phase C, C2.2)
The harness swapped the tree's root element between a single shell, a
fragment of two, and a single shell again. Each swap remounts everything
under it, so the callbacks the cases held belonged to unmounted hooks. One of
those could still take the module-scoped latch, and the instance that took it
was already gone, so nothing was left to release it — the last case in the
file leaked the latch into whatever ran next.
The root is now one component with a slot per shell, so removing the holder
leaves the second shell's instance alone, and every pop is taken through a
callback re-read at call time.
`afterEach` now asserts the latch is clear by mounting a screen after every
other one is gone and requiring it to pop. Without it this leak was invisible:
it surfaces only in a following case, and the case that caused it was last.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
|
||
|
|
e2afb5eef9 |
feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) (#21503)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page reads this host and keeps the app's pins (OTA phase C, C1.4) Three gaps the census named, and the last of them is why the page needed a second grant. `expo-secure-store` is `{}` on web, so the page's own `loadHosts()` answered with an empty array and the list painted "Host not found" over the host the shell had just opened it for. `init` gains an optional `host`, and `host-store.web.ts` serves it: the profile the screens read, with no device token and no public key, because the bridge already carries the connection those would have opened. The native writes it cannot make — pairing, renaming, recency — settle rather than throw, since recency orders a list the page never shows. AsyncStorage's web build is `window.localStorage`, and the page has none worth having: Android turns DOM storage off and on iOS the origin host is the session id, so a pin set in the page was gone on the next remount. The builder aliases the module to a page store whose values are the app's own — `init` primes the allowlisted keys, a write is applied locally and posted over a new `storage` grant, and the app is where it lands. The allowlist is two keys and is the whole fence: everything the app stores shares one namespace, the hybrid shell flag included, so a page that could write any of it could turn the feature on for a build that never offered it. A key outside the list is refused and, crucially, not kept locally either — a pin that looks set and is not is the failure the grant exists to avoid. The bridge host is built only once both have been read, because `init` is answered once per `ready` and carries them: a host that started without them would have to be torn down to carry them, and the list would already have mounted against a host it could not name. `Alert.alert` on a failed host removal is a silent no-op in React Native Web, so inside the page that failure had no surface at all. It routes to the error the list already shows, on both platforms. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the storage-write ref after the commit The last render-phase ref write in this hook joins the commit-phase effect. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the confirm setter this callback already calls in its deps A `useState` setter is stable, so the identity of the callback is unchanged; the list now says what the body reads. Reported on the line this branch rewrote. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a failed action off the whole screen and the page's store honest (OTA phase C, C1.4) Five, from one round of review. A removal that fails no longer writes the identity error: that one is an early return over the header, the list and the overlays, with nothing to dismiss it and nothing left to render the confirm it re-opens. It goes to a dismissible line above the list instead, on both platforms, cleared by the next confirmed refresh. `init` reads the allowlisted keys on every answer rather than capturing them at mount, so a document that reloads inside one mount is primed from after its own writes. The read stays synchronous: the page refuses every member until `init` lands and the golden recorder mounts a screen in the same turn it drains one, so a promise here moves the first render of every bridged replay. A profile read that rejects is now a shell failure with a diagnostic instead of a `ready` session with no host behind it and a page asking forever. The page bounds a value by the envelope's own constant rather than caching what the wire drops. And a write is held to the keys this page was handed, so one host's page cannot rewrite another's pinned list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): state the two test fixtures' types instead of asserting them The casting gate reads a `SAFETY:` rationale off the line directly above the assertion, and a wrapped comment puts a comment there instead. Two of the four were not assertions worth keeping at all: a hoisted fixture says its own type, and the router comes from the mock the file already installs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the two fixtures in place, not through a widened binding `const x: unknown = …` followed by `x as T` is the widen-then-assert the anti-slop gate refuses, and rightly: the evidence is discarded and then invented again. The assertion belongs at the literal, with its rationale on the line above it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the key a refused page write reached for The last diagnostic still falling through to the line about a view outliving its host, on the branch that added it. The key is the evidence: it says which host's pinned list the page was reaching into. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe this branch's storage writes too Same merge, one branch further: a probe here also collects what the page asked the screen to write. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the page the app's storage as it stands, not one init late The page is handed its allowlisted keys on every `init`, built synchronously, and the app writes two of them from its own native screens. The shell's map was only re-read after a ready, so a native write between two readies reached the init after next: the drawer opened on the repo the user left. The map is now module-scoped and every writer of an allowlisted key notes it as it writes, so the init that answers a ready already carries it. The store read only seats the map, and a read that started before a write no longer puts the older value back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the banner's dismiss without an assertion Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): keep the storage mirror in the storage tree The app's own writers had to reach into `src/mobile-web-shell/` to note a write, which is the dependency the wrong way round: the shell is what is built on the app's storage, not the other way. The mirror moves to `src/storage/` and no longer knows which keys the page is allowed; the caller names them on every read and every seat, so the allowlist stays where it is enforced. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
ac4dc6599b |
feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b8f3b1ec00 |
feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) (#21501)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
d043cfbbee |
feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f2be6299c8 |
feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) (#21459)
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3) One page document's end of the bridge: page frames in through the C0.1 reader, one RpcClient behind it, host frames out. Requests forward with the arity the page used and answer with the verbatim RpcResponse, chunked when it is over the frame cap; a rejection crosses as the five-field capture instead. Subscriptions carry a seq and an unacked window, and end with `overflow` rather than dropping frames a reader cannot see are missing. The fence is structural: the protocol names no host, so the client is whichever this host was built with, and the in-flight caps the page is told about in `init` are enforced here rather than trusted from there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3) The channel opens on the session B4 put on screen and closes with it. The session id is B4's: nothing new is minted, and a remount is a new one, which is what makes a dead page's frames fail the native origin check. Both halves are stamped with the session they belong to, because React swaps refs during the commit and runs the retiring effect's cleanup after it — a host disposing on a remount would otherwise post its teardown into the page that replaced it. `bridgeEnabled` is derived from the session step alone, since the native side treats a prop change as a reload. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the bridge fence holds for traffic, not just for answers A mutation that dropped the post-teardown guard in `receive` survived: the teardown case only fed a frame whose answer the outbound guard already swallowed, so nothing observed that a dead page could still reach a live client. Both teardown paths now feed a request, a subscribe and a notify, and assert the client saw none of them. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the hook's frames through the page's own reader `JSON.parse` returns `any`, and taming it with an assertion is a cast the gate refuses and a check nobody gets. Reading each posted frame through `readBridgeHostMessage` types it and proves the same thing the host's own suite does: a frame the page would refuse is a frame that never arrives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): list the bridge host as a raw request port owner The boundary ratchet reads a `.sendRequest` access as a call site, and the host has three: one per arity the page can use. It is not a call site. It picks no method, reads no reply and decides no acceptance — the page names the method and runs the typed operation over the client this carries, which is what the C0 design put page-side so `runRpcOperation` stays unchanged there. That makes it an owner, beside the socket and relay senders, not a migration backlog entry. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove a stream that overflows inside subscribe is unsubscribed A client that emits synchronously from `subscribe` can retire a stream before its unsubscribe exists to be stored. The identity check that calls it instead had no test; deleting it left the suite green while the client's stream leaked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hand the bridge host over in the commit, not after it A client swap that keeps the session id leaves the handler's own fence inert: until the passive effect ran, a native frame reached the retiring host and the client it closed over. A layout effect swaps both inside the commit. Teardown on unmount now runs while the view is still attached, so a pending request is answered delivery-unknown instead of being dropped on the floor. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold a refused page frame to one warning per page A page that sends one bad frame usually sends many, and a line each buries the first — the one that says why. Same bound the host already keeps on a failing post, applied per kind and reset when a new page gets a new host. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound the page's terminal viewport at the bridge contract A viewport crossing the bridge is written into the cached subscribe params of every stream naming that terminal, including the native terminal screen's, and the desktop refuses cols over 1000 or rows over 500 when those streams resubscribe. Unbounded, one page could kill streams it never opened; the frame is refused instead, and the bound is pinned to the desktop's own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the page's close from latching the bridge host shut One view carries every document the shell loads, so the page that says `close` is not the last one. A latched host dropped the next document's `ready` in silence, and a page that re-sends `ready` on a backoff would retry forever with nothing posted and nothing logged. Close now cancels what the page owned and leaves the host live; only dispose shuts it, and a frame arriving after that is diagnosed rather than dropped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a throwing client or post inside the bridge host The `state` frame is sent from inside the client's own state-change fan-out and a notify runs on the native event handler that delivered the page's frame, so a synchronous throw from either escapes into a loop the bridge does not own and takes unrelated listeners with it. Both are fenced and reported once. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove an ack releases the stream's unacked bytes The frame window reopens on ack through the splice, so deleting the byte release left every existing test green while a long-lived stream of large frames would end with overflow on its first frame after an ack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pass the commit-window harness its children as a prop `createElement`'s variadic children do not satisfy a props type that declares `children`, so the file dropped out of the tests typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): render the harness from the commit-window wrapper, not as children A props type that declares `children` is what `createElement`'s variadic form does not satisfy, and passing it as a prop instead trips the react rule. The wrapper renders the harness itself, which is the parent position the layout effect ordering needs anyway. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the desktop viewport bound by reading it, not importing it Mobile may not pull an rpc-contract *value* into its bundle, and the boundary test that enforces that scans this test file too. The pin reads the schema's own source instead, so drift in either bound still fails loudly. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): serve the bridge to one document at a time A page's `close` now ends that document's turn: until the next `ready` claims the view, every other frame is dropped and diagnosed instead of reaching the client, and nothing is posted. Without the fence a straggler from the closed document was still forwarded, and a `state` frame from the still-running client landed in the replacement document before its `init`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hold the request cap against the calls, not the page's ledger `sendRequest` has no cancel, so a request the page cancelled or closed out keeps running on the desktop until it answers. The cap now counts those calls until each settles; counting the pending map let a page interleaving `close` with batches hold many more than the cap `init` advertises. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
47d107cf2e |
feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window `useHostStatusGates` settled the same closed gates for a host that answered `status.get` with no capabilities and for one whose status nobody could read: both paths produced an empty capability list and an `ok` verdict. A caller that walls on a missing capability cannot tell those apart, and the mobile web bundle's wall is terminal, so it must never fire for the second. `statusReadable` distinguishes them. `hostProtocolWindow` exposes the two protocol numbers the hook already read for `evaluateCompat`, as the reply's own fields, so the bundle wall can evaluate its own window without a second `status.get`. Both are additive; no existing consumer changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): add the hybrid shell flag and the generation path both loaders demand `orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same shape as the terminal autocomplete flag. `generationDirectoryPath` converts the store's `file://` uri to the absolute path the native shell view requires: both `MobileWebShellGeneration.load` implementations refuse anything without a leading slash, and `expo-file-system` only ever hands out uris. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell session as a pure reducer Every decision the route makes, as `(session, event) -> (state, effects)`: the capability wall, the lazy sweep and cache read, the manifest check, the cached build-id hit that skips paging, the offline open with no compat check, and the three recovery rules the native shell view's contract states. Pure, so the rules are table tests rather than a simulator run. Two latches sit beside the state because both outlive it: `retriedOnce` spans the delete and refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced by a `ready` under a new session id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) Wires the four Phase B and A pieces together and adds no decision of its own. `h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native `desktop-too-old` wall still applies above it. With the flag off — every store build, since the only writer is a `__DEV__` Troubleshoot toggle — the route redirects to `h/[hostId]` and the screen is never constructed, so nothing is fetched, written or swept. The runner owns only the impure edges and checks an epoch before every dispatch, so an unmount, a host change or a retry abandons work in flight and aborts a download that would otherwise hold four of the host's read slots. The native view is keyed on the session id, which is what makes the reducer's remount a rebuilt WebView with every fence reinstalled. A census test pins who touches the flag: the route reads it, the developer row reads and writes it, and the key itself lives in one module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the hoisted test doubles instead of asserting them The changed-code casting gate refuses `as` in new code, and these three were only widening an empty literal. An annotated `vi.hoisted` factory does the same job under a check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress Found on a simulator with the paired desktop stopped: the client never settles on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping `reconnecting` to "still connecting" left a phone holding a verified cached generation on `checking` forever instead of opening it, which is the one case the offline rule exists for. `connecting` and `handshaking` are the first dial and still wait; everything else is unreachable. The mapping moves next to the reducer it feeds, because it is a decision and the runner is supposed to hold none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the reachability stub instead of asserting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the shell session vocabulary into its own module Pure move, no behaviour: the states, events, effects and gates the reducer and its runner share now sit beside the reducer rather than inside it, so the transition rules have room to grow under the file's line budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop a shell effect result whose flow has been superseded Every restart of the flow bumps a number the effects of that run are stamped with, and a result echoes it back: a manifest read still in flight when the socket drops used to reject after the offline path had already opened the cached generation, replacing a displayed workspace with a download failure, and a status refetch arriving mid-check used to run the cache read and the download twice. The gates restart no longer clears the remount latch either; only the retry button does, so a reconnect cannot grant a second remount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the flag across modules, not just src and app The native view tree was outside the scan, so a reader added there would have passed an assertion that reads as exhaustive. Proven by adding one to the shell view module: the census fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let only the state that mounted the view hear the view A native batch reports two failures in a row, and the reducer applied both: document-load-failed started the delete-and-refetch, render-process-gone then made it terminal without a new flow, and the cache read the recovery had already asked for dragged the session back to checking behind a failure screen. A report arriving outside `ready` is from a view that is no longer on screen, so it changes nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say a host status could not be read instead of spinning on it A transient status.get failure settles the gate unreadable and nothing probes it again, so the route sat on "Checking host" for as long as anyone left it there and Try again re-read the same settled answer. It now says what happened and offers no retry, and a status that does become readable picks the flow back up on its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restart the flow on the verdict that changed, not on every gates object A reconnect cycle rebuilds the gates several times a second with the same answer in them, and each one re-swept the staging tree and flipped an offline screen to a spinner and back. Only a changed verdict restarts now, which is also why the gates effect has to depend on the host id: two hosts whose gates read identically would otherwise leave the second session in `checking`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover what only the shell runner can get wrong Three cancellations had no test: the epoch that stops a result reaching a session that is gone, the unmount cleanup that aborts the download, and the retry that does both before starting over. Each is now red under its own mutant. The download also re-checks the abort before it writes, since an abort landing between the fetch's last read and the commit would otherwise still put a generation on disk for a screen nobody is on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the shell runner's refs after the commit, not during render React can replay or discard a render, so a handle written during one can run effects for a session that never existed. The client and the host cache key stop being refs at all; the effect handle is committed in an effect above every effect that dispatches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the hybrid shell flag unreadable outside a development build Development and release share a bundle id, and the iOS data container survives an install-over, so a flag a developer toggled on would follow the store build in and mount the shell on a deep link. The release read never reaches storage. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the route's flag read as each build kind reads it The route test exercises the real preference read, so it has to say which build it is. A store build whose container kept a development toggle redirects. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a cache read that lands mid-dial wait for the compat check A connection still being made is not a host that cannot be reached. Opening the cached generation there skips the compat check the landing connection is what makes answerable, so only `unreachable` takes the offline path now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the developer toggle only after its write lands The route reads the flag back from storage, so a switch that moved on the tap let the open button race the value that was being persisted. The switch and the button both stay put until the write settles, and a failed write keeps the previous position. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): say which build kind a test runs as without asserting on globalThis Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): undo a staged generation the abort reached before the commit The commit is the write staging cannot take back: it renames into the active slot and moves the host index. An abort landing while the bytes were being staged now removes the staged tree instead of activating it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open the cached workspace when the link, not the bundle, cut a read short An RPC rejection can reach the reducer before the reachability change does, so the offline gate never fires and a phone holding a valid generation reads that the workspace could not be downloaded. A read that failed on the link now opens what is on disk, the same path offline takes; a verdict about the bundle, from the host or from the bytes, still fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send a hybrid shell recovery through the same gate a start takes A view failure deleted the host cache and went straight back to the manifest check on whatever gates the ready session happened to be holding. Gates that arrive while a generation is on screen are stored without restarting, so after a reconnect whose status probe failed a ready session carried statusReadable false and an empty capability list, and the recovery's manifest check walled the host as bundle-unavailable: terminal, no retry, about a host that never answered. The gate is now one verdict both entries read, and recovery passes its delete through it, so an unreadable status lands on the status-unreadable message that re-arms when a readable gate arrives, and only a readable refusal still walls. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |