mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
cef66fbab8a62b5324e0b520a4de5fe0a2c9f48c
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
996f9cc306 |
feat(mobile-web-bundle): gzipped 384 KiB ranges over a capability-negotiated mobileWeb.bundle.range (OTA phase C follow-up) (#22381)
* feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability Adds mobileWeb.bundle.range with its own strict params and result, so shipped chunk readers see no reply change. The host gzips each range at level 6 and sends identity when gzip does not shrink it, sharing the chunk method's read-slot budget and per-asset verification. status.get advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the method is allowlisted for paired phones. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read the bundle range capability and range replies Adds the range reply reader and operation, and picks range or chunk from the status.get capabilities the connection already proved, so an older desktop keeps being paged in chunks with no probe round trip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): keep four bundle chunk reads in flight across the whole manifest The fetch ran one worker per asset and paged inside an asset sequentially, so the largest script's 71 chunks were 71 serial round trips while the other readers idled. One window of four chunk reads now covers every (asset, offset) on the host's chunk grid, largest asset first. A read_limited refusal narrows the window and retries the read; eof is still read from the reply. Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): add fflate 0.8.2 for gzip bundle ranges Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): decode gzip bundle ranges into a bounded buffer Inflates each range into a buffer one byte past its window, so a gzip bomb costs at most that allocation and an overlong body is visible. A corrupt, truncated or unknown-encoding body refuses as range-undecodable; a body of the wrong decoded length refuses as range-length-mismatch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): pass the bundle read method from the session to the fetch The download reads the capabilities of the gates the reducer decided under and hands the fetch range or chunk. The fetch does not act on it yet; the range read lands on the pipelined window. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under pipelined reads Baseline moves to |
||
|
|
e47ef8cc28 |
feat(mobile): the shell tells a page which optional capabilities it has (OTA phase D, C8.1) (#22141)
* fix(mobile): publish page-route pairs the strict host schema accepts (OTA phase D, C8.1) `routeViewOf` handed the manifest's own route entries to the host as `pageRouteGrants`. The phone reads a manifest route loosely, so an entry arrives carrying whatever field the desktop that wrote it knew about, and `BridgePageRouteGrantsSchema` is `.strict()`: one unread key refuses the pairs, `createBridgeHost` refuses the route with them, and the page gets no `init` at all rather than losing one field. Fixed before any route carries an optional grant (ruling 37.4), so the manifest field the next commits add costs an installed shell nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: drop the closure and bundle probe scripts from the tree Scratch measurements for C8.1 (which route closures reach the HTML preview, and what the preview render rig costs to bundle with a client provider). They belong outside the repository and were swept in by the previous commit's `git add -A`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): a manifest route may declare optional grants (OTA phase D, C8.1) Design B of design-ota-c8-1.md, ruling 37. `MobileWebBundleRouteSchema` grows `optionalGrants` under the required lane's own grammar, with the 16-name ceiling applied over the union of the two lists rather than to each. Serving a route still reads `grants` alone, so a capability a screen cannot work without stays required and takes the route native; a session's granted list is `[...grants, ...optionalGrants]` narrowed to what this shell implements, from one helper that both `grantsForRoute` and the `pageRouteGrants` publish read. The ruling's compatibility rationale is corrected in place. `z.looseObject` passes unknown members through rather than dropping them (measured, zod 4.4.3), so a shell older than the field still receives the key; what it lacks is a policy that reads one. What makes the lane safe against such a shell is therefore the previous commit's publish fix, not the reader. BRIDGE_PROTOCOL_VERSION stays 1. No new notify, verb or frame field. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name the shell's cancelled-navigation behaviour as a grant (OTA phase D, C8.1) `externalNavigation` joins `MOBILE_WEB_SHELL_GRANTS` beside `screencastBinary` and `haptics`, declared in `cancelled-navigation-target.ts` because that is the module holding the rule which acts on it. A third token that is neither a verb nor a notify: the page posts nothing to make a cancelled top-frame navigation happen, so this list is the only thing that can tell a page whether a tap inside the sealed HTML-preview frame escapes at all. A constant and not a platform read (ruling 37.1): both engines dispatch the event, `ios/MobileWebShellView.swift:481` and Android's `MobileWebShellView.kt:382`, so an app build carries the behaviour on both or on neither. The policy census grows the half that was only pinned by the verb table: the implemented set is that table plus exactly three non-verb tokens, each read off the module that declares it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the bundle builder carries a route's optional grants (OTA phase D, C8.1) `resolveMobileWebPageRoutes` maps each declaration member by member, so a field the declaration grows reaches a phone only once the map names it: until now `optionalGrants` would have been dropped in silence and every route would have declared nothing optional. Omitted when the route declares none, because absent and empty are the same answer to a shell. The declaration suite grows the rule rather than a row: the map carries the lane through and writes no key without one, and the lane is held to the manifest's own grammar and to the ceiling over the union of the two lists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the HTML preview hides its links on a shell that cannot open one (OTA phase D, C8.1) The session route declares `externalNavigation` on the optional lane, and the preview asks for it before it renders an artifact's links as links. Ruling 37.2's three readings are what "hide" means here, and removing `href` is what delivers all three at once: `a:any-link` stops matching, so the UA stylesheet stops underlining, the element leaves the tab order, and there is no dead anchor a tap does nothing on. The text the author wrote stays where it was, the artifact paints, and the Preview/Source toggle is untouched. Done with the browser's own parser rather than over the source text: an `href` inside a comment or a `<template>` is text to a browser, and a pass that rewrote either would be editing the artifact instead of its links. The frame also loses `allow-top-navigation-by-user-activation` on that path, so a link the pass somehow missed is refused by the browsing context as well. One route, measured rather than assumed: the design said two, and the file preview route's closure does not reach the HTML preview at all - it renders `MobileFilePreviewScreen`. The new closure census derives that list from the hook's callers. The render rig grows the case on both engines and the readings it needs, and `mobile-web-app-preview-frame-readings.mjs` is split out of it at the readings/arms boundary, because the two were over the 600-line cap together. Two engine findings are recorded in the rig: an `<a>` with no `href` still answers `tabIndex` 0 on both, so focusability is asked by focusing; and WebKit computes `cursor: auto` for a real link, so that reading is pinned where it discriminates and its blindness pinned where it does not. The hop-coverage census now reads the effective set, because that is what the running rule compares. Inert today: the session route is the only declarer and an opener into every other route. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's hidden link path where the unit suite can reach it (OTA phase D, C8.1) The mobile suite runs in a `node` environment whose resolver has no `.web` precedence, so `MobileHtmlPreview.web.tsx`'s import of the grant hook lands on the native sibling, which answers yes unconditionally. That is why the existing component suite still measured the granted frame without knowing a grant exists, and it means the hidden path had no coverage in the sharded `test` job, where the render rig is skipped for want of the bundler's dependencies. So the wiring gets its own file with the module replaced: that the component asks, and that both the frame's sandbox and the document it is handed follow the one answer. happy-dom rather than the suite default, because the inerting pass parses with the browser's own `DOMParser`. `String(node.type)` rather than a literal comparison: `node.type` is `ElementType`, which overlaps a real intrinsic tag and not the host strings these mocks render, so `=== 'Pressable'` is a no-overlap error under `tsconfig.test.json` and the tests-typecheck ratchet reds on it. Also replaces a `Reflect.get` the anti-slop gate refuses with an `in` check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the session page closure at 4,362 for C8.1's three modules Measured on both sides with `mobileWebAppRouteClosure(SESSION_ROUTE)` at base `841d06a969` with all five postinstall generators run first, and the two `local` lists diffed rather than the total inferred: 4,359 -> 4,362 modules, 1,017 -> 1,020 local. All three are local source modules and none is vendored: the page's read of `init.grants.native`, the pass that turns an artifact's links back into text without the grant, and the module declaring the token beside the rule that acts on it - reached both by that hook and by `page-route-policy.ts`. The `bridge-caps.ts` it imports was already in this closure, and the hook's native sibling is replaced rather than joined. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): allowlist the preview's grant sibling among the .web.* overrides `mobile-web-app-web-overrides.test.mjs` pins the allowlist against the `.web.*` files on disk, so a new web sibling reds it until the file says why the page needs one. Red before: `expected [ …(36) ] to deeply equal [ …(37) ]`, naming `src/components/use-html-preview-link-grant.web.ts`. The preview's own entry is corrected with it: its reason said `allow-top-navigation-by-user-activation` is granted, and that token is now conditional on the shell answering that it can open such a navigation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the hidden-link render case waits on the frame's own reading (round 1) CI's chromium arm timed out at the full 240 s on this case alone while the WebKit sibling passed in 1.5 s and it passed 26/26 locally. The cause is the third arm: it tapped the granted link and waited through `expectNavigation: 'main-frame'`, and `waitForRecordedNavigation` has no bound but the case's own timeout. Under CI load the click missed its 2 s actionability window, no navigation was ever recorded, and the arm sat in that wait until vitest gave up - `recorded []`, with the frame attached only at 38.9 s. Three arms sharing one budget is what made this the case to find it. The arm is dropped rather than its wait lengthened or retried. Every verdict left is a reading the frame itself publishes: the anchors its document holds, the style the engine computed for one, whether focus lands on it, and now whether the tap this arm made landed at all - `actError` is asserted null, so a click that never reached its target is no longer the same three zeros as a tap that did nothing. Nothing is lost. The tap's outcome on a granted shell is the next case, on these same counters from this same rig and with a budget of its own, which is the presence precondition this file already uses elsewhere for the same reason. The WebKit sibling's discriminating reads are untouched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the inert-link pass changes nothing an engine renders but the links (round 2) Round 2's ruling: the hidden-link path may change nothing about the artifact's rendering except that links are not links. A parse and a reserialise is not free of that by default, and all four findings reproduced on Chromium 147 and WebKit 26.4. A same-document fragment link is kept. It starts no navigation at all, so it goes on working inside the sealed frame whatever the shell can do, and taking it away would be degradation over a capability it never needed - an artifact's own table of contents is the case. Its `target` still goes, because a fragment aimed at another frame is a navigation rather than a scroll, and `href=""` is not a fragment: it resolves to the frame's own URL. Links inside `template.content` are reached, recursively. `<template shadowrootmode>` is a declarative shadow root the frame's parser attaches and renders, and `querySelectorAll` does not walk into template content, so those links arrived live inside a sandbox that refuses their navigation - the dead anchor ruling 37.2 forbids. Measured: `parseFromString` attaches no such root on either engine or in happy-dom, so the pass can reach them. The leading newline of a `pre`, `listing` or `textarea` is written back. A parser drops one after the start tag and the serialiser is specified to put it back; measured, neither engine's does, so a round trip lost a blank line from every such block. The doctype is carried whole, and the reason is corrected from the one the finding gave. It cannot move this frame between layout modes: a `srcdoc` document takes its mode from its embedder, and measured, a quirks doctype, the bare name and no doctype at all all read `CSS1Compat` inside the frame. What rewriting it does is change the document the author wrote for no reason, with `document.doctype` observable beside a Source tab showing the original. The render case pins `compatMode` as the blind reading it is and reads the frame's own doctype identifiers as the one that discriminates. Option B was not available: the frame has no `allow-scripts` and inherits `script-src 'self'`, so nothing runs inside it and there is no injection to carry the work. Also drops a vacuous half of the affordance test. `renderSource()` is called with no argument, so the markup a Source view shows is the caller's own closure and asserting it equals the fixture passed whatever the component did. What the component decides is whether the rewritten frame stays mounted underneath, and that is what is read now. `mobile-web-app-preview-arm-driver.mjs` is split out of the render rig at the boundary the readings module already names - the rig holds what each case claims, the driver how an arm is driven, the readings what it reports - since the three were over the 600-line cap together. No max-lines disable or bump. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a fragment link is a frame navigation in this preview, so it is inerted too (round 3) pullfrog is right, reproduced on both engines before believing it. Round 2 kept `#`-prefixed hrefs on the theory that they are same-document scrolls. In this frame they are not: the document's URL is `about:srcdoc` while its base URL is inherited from the embedder, so `#section` resolves against the shell's own URL and the destination differs from the document's by more than a fragment - which makes activating it a frame navigation, and the shipped `frame-src 'none'` refuses it. Measured under the shipped policy, one tap, with something to scroll: Chromium 147 scrollY 0, frame becomes chrome-error://chromewebdata/, artifact gone, embedder reports frame-src <origin>/preview WebKit 26.4 scrollY 0, frame stays about:srcdoc and intact, same report So the destruction is Chromium-only but the absence of a scroll is not: there was no working affordance to carve out for, and the carve-out left a live link that destroys the preview - worse than the inert text it was meant to avoid. Both sandbox values behave the same, so this is the base URL and the policy rather than the sandbox. The same tap does the same thing on the granted path, where this pass does not run, so an artifact's internal links have never worked in the preview. That is not this change's to fix; it is recorded in `followup-html-preview-fragment-links.md`, and the render case reads the granted arm's violation as its presence precondition so the behaviour is pinned rather than merely known. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
5d13a70ea3 |
fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9) The page decides an in-page hop from `init.pageRoutes`, which says which patterns this shell would render and nothing about what each one costs. So a push kept local on the strength of the pattern alone runs the target under the opener's grants — which is how the tasks page is reached from the wide-layout sidebar without `native.clipboard.write`, and why its copy actions refuse silently. `init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs, from the manifest the shell already holds. Optional in both directions: an older shell omits it and an older page ignores it, and a page that receives none keeps today's rule. No new frame kind, no cap change, no protocol bump. The grammar is the manifest's, imported rather than restated (`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the bundle could not have declared cannot reach the page through this field either. The host validates the pairs before it builds the frame and refuses the session when they fail, for the reason it already refuses a malformed route: an `init` the page would reject whole is worse than no session at all. Two files were at their line ceiling and are split rather than bumped. The pairs schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope and the host, so it belonged in one place anyway. In the session reducer the three sites that each spelled out "patterns, their grants, this route's grants" become one `routeViewOf`; that is a net reduction and removes the fourth spelling before it is written. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a hop local only when the session's grants cover it The rule the page was using is "the shell would render this pattern", and that is not the question. Grants are resolved once, from the route the shell opened, so a push kept local runs the target under the opener's list. On a wide layout the sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this seam, so from the worktree list, agent history or the files pages the tasks page ran without `native.clipboard.write` and its copy actions refused with nothing on screen to say why. `servedHere` now means served here *and* covered: the target's declared grants must be a subset of this session's. An uncovered page route is handed to the shell exactly like a non-page route, and the shell opens it as its own session with its own grants — which is the mechanism that already exists, rather than a new one. Three answers, not two, because an absent field is not an empty one. A shell that sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older shell has to keep working. A target the shell lists but names no entry for is *not* covered — the page cannot justify that hop, so it hands it over rather than guessing in the direction that loses grants. This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is covered by this rule and stays local, and the rule scales to the sidebar, which reaches every route and which no pairwise list can keep up with. Red first on the two cases only the new rule answers; the other four are the regression guards and passed before and after. Two whole-session assertions gained `pageRouteGrants: null`, which is what the reader now returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop in a browser, under the session's own grants The unit tests pin the decision; only a browser shows the control exists, is reachable at the viewport where the sidebar renders, and that the document does not move when the hop is handed over. Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted when a caller names none, because an absent field is not an empty one and the page reads the difference). - Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one `navigate` notify, the document stays on the worktree list, and **no new chunk is fetched** — which is what says the page did not quietly render tasks under the wrong grants. - Wide, same tap with the grant added: no notify, the document moves to `/tasks`. Without this the first case would pass on a page that simply never navigates. - Wide, shell sending no pairs at all: the old behaviour, local. An older shell must not start handing every hop over on a field nobody sent. - Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the sidebar only on a wide layout, and only that header branch labels its Accounts and Tasks controls; the narrow header's are unlabelled pressables. So the hop does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is the honest assertion. That unlabelled narrow header is a real accessibility gap and is not this lane's to fix. Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the in-page hops a session's grants cannot cover The rule landed in the commit before this one decides each hop; this says which hops those are, so a route's grants growing — or a new push between two page routes — shows up here rather than as a verb that silently refuses on a device. Openers are every page route, not the one that happens to push. On a wide layout `app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and its header pushes tasks, which is exactly why a pairwise pin is the wrong shape: the sidebar reaches everything, so the census has to be the cross product of what the manifest declares against what the source actually builds. Targets come from the hrefs the app builds, read out of `mobile/src` and `mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned and a hop someone adds is. A presence case asserts the sidebar's tasks push is among them, because a census that stopped finding hops would go quietly green. Two hops are pinned as handed off today, both into tasks, which is the only route declaring more than `navigate` and `storage`. A third case asserts the other half of the rule on the manifest: a target asking for no more than its opener stays in the document. Checked that it discriminates rather than assuming: widening the worktree list's grants to cover tasks fails the pin, and restoring them passes it. **No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has three routes and no `files` entry, so there is nothing to remove. When C3.1 lands, its pin is this census's to subsume. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop an unused import from the hop census `statSync` was imported and never used; `oxlint` fails it. My error: I committed the census on a green test run without waiting for lint, the same order mistake I made earlier in this lane. Fixed forward rather than amended, because the lane forbids rewriting a commit that exists. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fold C3.1's pairwise grant pin into the hop census C3.1 landed while this branch was open, and it brought the case this lane generalises: the explorer pushes to its own preview, that push stays in the document, so the preview runs under the explorer's grants. Its pin asserted that one pair by name. The census now covers it as a consequence rather than a rule. With the files routes in the manifest the cross product finds six more hops the session cannot cover — the sidebar into files from the worktree list and from agent history, and both files routes into tasks — and it does **not** find explorer → preview, because the preview declares no more than the explorer. That absence is the pairwise pin, derived. So the pairwise block is deleted, with its import. The rest of that file stays: its external-link seam checks and its clipboard-absence control are about what the files closure contains, which this census says nothing about. Checked the extended census still discriminates: granting the explorer `native.clipboard.write` fails the pin, restoring it passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the sidebar hop from a files route, not only the worktree list The defect is not "the worktree list pushes tasks". On a wide layout the sidebar renders beside every `/h` route, so the same hop exists from the files explorer, whose session carries `externalLink` but not `native.clipboard.write`. One opener proving the rule would have left the general case to inference, which is the inference C3.1's pairwise pin already made once. Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's Tasks control posts exactly one `navigate` notify, the document stays on the files route, and no new chunk is fetched. The harness helper now takes the route and the text to wait for, so a case can open on something other than the worktree list without a second copy of it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the render helper wait on the text its caller named The `awaitText` parameter I added in the commit before this one was never wired into the wait, so it was dead and `oxlint` failed it. The case still passed, because the files route renders the host name in its sidebar and that is what the helper was still waiting on — which is exactly the kind of accident a dead parameter hides. Third time in this lane I have committed on a green test run before lint finished. Fixed forward, not amended. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): carry route grants through the download path `onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so every first install and every OTA update reached `ready` with the default or the previous generation's pairs. The page then read each target as listed-with-no- entry and handed off every in-page hop. `routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls, to keep the reducer under its line cap without a bump; its stale neighbouring comment, which described a filter that moved into it, goes. Red first: the cold-cache and generation-change cases failed, the cached-hit case already passed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): derive census targets from navigation call sites The reachability filter was inert. Harvesting every `/h/${…}` template caught the five screens that declare their own mount pathname, two `pathname ===` comparisons and the route template types, so every declared route was reachable through its own mount: the pinned table was the all-pairs one, eight hops with the filter and eight without. Targets now come from the arguments of `router`/`navigation` `push`, `replace` and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are excluded by construction because they are not navigation arguments. Two real hops are not written as a literal, so a local binding or a call is followed one step to the function that returns the pathname: the files explorer is pushed as `{ pathname: descriptor.pathname }` and the preview as `push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read is returned rather than dropped. Derived patterns go from 11 to 10; the pinned table stays at eight because all five page routes are genuinely pushed to. What changes is that the filter now discriminates: deleting the header's two tasks pushes reds the presence case and drops the four `-> tasks` rows from the pin, where the old derivation stayed green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the pathname. A push added at a real call site appears in the set. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore the preview-declares-something guard The pairwise pin this case replaced asserted the preview declares at least one grant before asserting the explorer covers them all; without it two empty lists satisfy the subset check and a route that lost its grants passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): describe the route list under the handoff rule Two passages described the world before this PR: the explorer's note said the census pins its pair with the preview, and a closing paragraph left the sidebar's tasks hop open for a later PR. This is that PR. Covering the preview now buys the in-document hop rather than making it correct, an uncovered target is handed to the shell and reopened under its own grants, and the census reads the explorer to preview relation off this list rather than pinning it by name. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the manifest's tasks grants in both fixtures CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`, `storage`, `native.clipboard.write` while the manifest also declares `externalLink`, so no covered-session case ever required it. Both now mirror the manifest's four, and the covered sessions hold them. That alone does not make an `externalLink`-blind rule fail, since those sessions hold every grant either way, so the unit suite gains the case that does: a session holding the clipboard but not `externalLink` must still hand the hop off. Mutating the rule to treat `externalLink` as always held reds that one case and no other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a stalled hop name its own cause Both waits for the hop to land read as a bare 30 s timeout when it does not. The CI failure that sent this file back was a `TypeError` inside React Navigation that blanked the document, and it was invisible here because the error assertions run after a wait that never returns. The wait now throws with the page's own account: the pathname it stayed on, the collected page and console errors, the `navigate` notifies posted, the first 300 characters of the body, and every `.js` response since the click with its status. The response listener records every script answer rather than only the 200s, so a chunk the navigation waits on can be seen failing; the 200-only list the no-new-chunk assertions read is unchanged, as is everything the five cases assert. Kept in this file because no other render file waits on the pathname moving. Proved by mutating the rule to hand every hop off: the covered case fails naming the pathname it stayed on, an empty error list, the notify it posted and no scripts since the click — which is the handoff signature, distinct from the crash signature CI saw. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): aim the narrow hop at the control C2.10 named The narrow case asserted the absence of a labelled Tasks control, which was true only because the narrow toolbar carried no accessibility props. C2.10 gave it the wide sibling's role and label, so the assertion was red on the merge and, worse, the rule this file is about went unproven on the branch the phone actually presses. It taps that control now: at 390 px there is exactly one, and the tap posts exactly one navigate notify for the tasks route while the document stays on the worktree list and fetches no new chunk. Red first against the merged header (count 1, expected 0); with the session given native.clipboard.write the hop goes local and the case reds, which is what says the assertions discriminate. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop the handoff predicate's contradicted one-liner The pre-C2.9 summary said the answer is whether this document renders the target, which is exactly the claim the block comment below it replaced: the predicate now also requires the target's grants to be covered. Two doc comments on one declaration, the first of them wrong. Comment only; the 35 handoff cases are unchanged and green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert which field a route refusal blames The host builds `pageRouteGrants: <issue>` so a refusal says which of the two checked inputs failed, and nothing read it: the case counted refusals, so a host that reported the route's own verdict for a malformed pair would have stayed green while sending whoever reads the refusal to a pathname that was never the problem. The case pins the prefix, a non-empty issue behind it, and that the diagnostic and the callback carry the same string. The control is an opener that fails the other way: a malformed route reports its own issue and does not take this prefix, without which the pin would hold on any reason at all. Red first with the field branch dropped from the reason: the prefix assertion fails and the control stays green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop exporting the route filter the reducer stopped calling `implementedPageRouteEntries` and `implementedPageRoutes` were the reducer's two ways in before it moved to `routeViewOf`. The entries form had no caller anywhere afterwards and the patterns form had only this test, so the module's public surface advertised two functions no product code reaches. Both are module-local now; the surface is `matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`, `routeViewOf` and the grant list. The test reads the same list through `routeViewOf(...).pageRoutes`, which is the reducer's own view of it, so no assertion changed and no export is kept for a test. Red first: with both un-exported and the test untouched, seven cases fail with `implementedPageRoutes is not a function`; routed through the view all nineteen pass. Still discriminating, as a control: with the grant filter dropped from the entries helper, four of them fail. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): route the merged haptics cases through the policy view PR E's two haptics cases arrived with the merge calling `implementedPageRoutes`, which this branch had already made module-local, so the merged file was red with `implementedPageRoutes is not defined` on both of them. They read the same list through `pageRoutesOf`, the view the rest of the file already uses, so neither assertion changes. PR E's paragraph named that function for the filter it describes; the filter now sits in the entries helper the view is built on, so the sentence says that instead of naming a function the reader cannot see. Red: the two cases above on the merge. Green: all 21, PR E's two included. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mirror the haptics token in every handoff fixture PR E put `haptics` on all five manifest routes, and these fixtures still carried the pre-E grant lists: tasks with four grants where the manifest now declares five. A fixture that is short the same token on both sides of the subset check agrees with the rule by accident, and would have gone on agreeing after the token stopped being universal. The pairs mirror the manifest now, and each session carries what its opener route would actually be granted, since the host narrows a route's declared grants to what the shell implements and the shell implements the token. Red first, with the token added to the pairs alone: the two covered-hop cases flip to handed-off, `stays in this document when the session already covers the target` and `keeps the hop in the document when the session covers tasks`. Green once the sessions carry it, 35 and 5. The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself. Measured there, all 5 routes declare the token and it is the missing grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the rule still reads only `pageRouteGrants`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): count C2.9's two bridge modules in the session route closure #21908 recorded this pin at 4,324 for the haptics notify module. C2.9 adds two more that the same closure reaches: the page-route-grants schema and the manifest contract whose grant grammar it imports rather than restates, both pulled in by `bridge-envelope.ts`, which the page reads to parse `init`. Named in the docstring beside #21908's sentence rather than folded into its number, because the three modules arrived from two PRs and a single count with one reason invites the next author to assume the rest. Red first against 4,324: expected 4,326. Measured on this head, not inferred -- a control worktree at pristine main gives 4,324, so the two are this branch's. 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 |
||
|
|
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 |
||
|
|
9641a1b544 |
feat(mobile-web-bundle): serve the packaged mobile web bundle over RPC (OTA phase A, 3/5) (#21348)
* feat(mobile-web-bundle): serve the bundle manifest and chunks over RPC Two paired-runtime methods on the already-authenticated connection: `mobileWeb.bundle.manifest` returns this install's manifest plus the chunk size it advertises, and `mobileWeb.bundle.chunk` returns one aligned range of one asset with the whole asset's length and hash, so a single chunk describes what it belongs to. `path` is accepted only by exact match against a manifest member, so traversal is unreachable rather than mitigated. Each asset's on-disk sha256 is verified once and the verdict remembered, concurrent first readers sharing one hash. Reads are capped at four in flight per connection, and a disconnected client stops costing reads at the next checkpoint. No SSH or relay proxying: a runtime answers only out of its own install. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the three buildId serializers against each other The canonical serialization exists in the builder, the packaging guard, and the shared contract, because the two packaging scripts run on bare node before any build output exists and cannot import TypeScript. A divergence in any one would reject every honest bundle at packaging, or ship a bundle whose id the phone recomputes differently and re-downloads forever. Proved red by swapping the guard's code-unit sort for localeCompare: five of six cases fail. Exports the guard's serializer for the test; no packaging behaviour changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover every error code and a multi-chunk paging round trip Against a synthetic bundle in a temp dir, because the real builder's largest asset is under one chunk and CI unit jobs never build out/mobile-web. The fixture's script spans three chunks, its stylesheet is exactly one, and one asset is empty, so paging, the eof boundary, and the zero-byte case are exercised rather than assumed. Reads in flight are held by latching `open`, so the four-per-connection cap and an abort arriving mid-read are deterministic rather than a race with a stopwatch. Both were proved red: dropping the abort check after verification fails the abort case, and keying the cap on connectionId alone fails the device-token case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): resolve the bundle root through the AppEnvironment port check:runtime-electron-ratchet caught this: the resolver sat beside getBundledWebClientRoot in src/main/startup and imported electron, and importing it from an RPC method pulled the first electron edge into a runtime graph whose baseline is zero. The runtime has to stay bootable on plain Node. So it reads app.getAppPath() through the port every other runtime module already uses, and moves next to its two callers under src/main/runtime. A host with no environment installed has no install root, which is the same answer as having no bundle. orcad answers getAppPath from its own install root, so a headless runtime that carries the artifact serves it with no special case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover the resolver's two probe layouts directly Also stops exporting the manifest filename, which nothing outside the resolver needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin both methods on the mobile allowlist The scanner only checks mobile-used ⊆ allowlist, and no mobile source calls these until A5, so deleting both entries left every test green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): keep filesystem failures inside the six error codes An asset unlinked or truncated after its verdict was cached reached the client as runtime_error carrying the desktop's absolute install path. Both now answer mobile_web_bundle_asset_changed, with the cause warned host-side only. A short positional read is the truncation case, so it throws instead of paging the client past the end. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): drop the unreachable release-idempotence guard The one caller releases exactly once in a finally; removing the flag left every test green, so it was defensiveness against a caller that does not exist. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): prove a failed verify is not cached as a verdict The verdict cache never invalidates, so a transient read failure remembered as a verdict would poison the asset for the life of the process. Removing the delete left every test green until now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): delete the unsatisfiable manifest params schema The dispatcher substitutes `{}` for absent params, so `z.null()` could never parse; the method declares `params: null` instead. A comment on the method name records why there is no schema. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): fill the read window instead of failing a partial read fs.read may answer short of what it was asked for before EOF, so the previous check turned a legitimate partial read into a spurious asset_changed. The loop mirrors the relay's readFullStreamChunk, which is not imported because it sits behind the relay dispatcher's module graph; only a read returning nothing is treated as truncation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): read the disconnect idiom with the shared predicate isClientDisconnectedError already exports exactly the check the catch needed, so the local error class goes away and the throw returns to the repo-wide idiom. The module doc now says asContractError is a total catch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the four branches no test was holding Each one survived a mutation: the abort check before verification, the per-process manifest cache, the buildId component of the verdict key, and delete-at-zero in the admission map. The last two matter beyond hygiene — a verdict keyed by path alone carries a failed verdict onto the next build of index.html, and a map that never drops a key retains one pairing token per socket. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f2ca3cbfb7 |
feat(mobile-web-bundle): manifest and RPC contract for the desktop-served mobile web bundle (OTA phase A, 1/5) (#21325)
* feat(mobile-web-bundle): add the manifest contract and content-addressed build id The schema every later Phase A lane parses against: the ceilings that bound host memory (256 assets, 32 MiB total, 10 MiB per asset), and a build id that is a pure function of content so a client can use it as a cache key unconditionally. The serializer sorts its input rather than trusting the caller, so a producer that emits assets in any order still lands on the same id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile-web-bundle): add the bundle RPC payload contract Method names, capability name, the 48 KiB chunk size, params/result schemas for both methods, and the six error codes as a closed union pinned by a coverage record. Constants and data only; the host wiring and the capability push land in later lanes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): hash the build id without node:crypto Metro ships no Node core shims, so a value import from these modules would fail to bundle on the phone. The pure-JS sha256 keeps both contract modules runtime-neutral, which also lets a cached manifest be re-verified on device. Verified digest parity against node:crypto across the 55/56/64-byte padding boundaries before the swap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): reject a manifest whose buildId is not its content hash A stale id passed every other check and would then serve the wrong bytes under a cache key the client already trusts. Runs last of the invariants because it is the only one that hashes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): require a lowercase content type The pattern carried an `i` flag over lowercase character classes, so the same bytes described as `Text/HTML` and `text/html` produced two different build ids. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): move the capability name to a zod-free module A4 wires this constant into protocol-version.ts, which the phone reads on the capability path. Leaving it in the schema module would have dragged zod along with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): name the chunk reply's length assetByteLength It is the whole asset's length, not the chunk's, and sitting beside dataBase64 under the old name it read as the chunk's. Both are non-negative integers, so a producer that emitted the wrong one would only surface at the final hash check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): reject asset paths that are not portable or that fold together Two paths differing only in case are one file on macOS and Windows, so the host would serve the same bytes under two entries and one of the two hashes could never match. Windows-reserved segment names and trailing dots cannot be written to the bundle root at all. Both follow skill-package-manifest's checks, the folded-path Set and the reserved-segment pattern. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): accept one spelling of a parameterised content type The optional space in `; ?charset=` let the same bytes carry two content types and therefore two build ids. Pinned to the single-space form the bundle builder emits. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile-web-bundle): stop hashing a manifest a cheaper invariant already rejected zod runs superRefine even after the asset-array ceiling has failed, so a 257 asset manifest was still sorted and hashed. Each invariant now returns on its own issue and the count is checked first, which is what the comment claimed. The tests read the issue paths: an oversized or otherwise invalid manifest with a deliberately wrong buildId reports no buildId issue, while the same wrong id inside the ceiling does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile-web-bundle): say that the manifest has no additive path `.strict()` plus a literal schemaVersion closes the shape completely, so the version bump is the only way to change it. The phone value-imports this schema, so Phase B must read an unrecognised schemaVersion as a bundle to re-fetch rather than as a parse crash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |