mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)
Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.
`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.
No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.
Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct what the sealed preview frame is stricter about
The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.
Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.
Comment only; no behaviour and no test moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): measure both halves of the preview frame's image fence
"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.
The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.
`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.
Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(scripts): split the preview frame's settling out of the render check
The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.
`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.
Move only. Same 20 tests pass on both engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send Referrer-Policy: no-referrer on the shell document
`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.
`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.
WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.
Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
source the way `readShellCsp` does and throws rather than returning an
empty map. With the value flipped to `unsafe-url` the WebKit arm fails
`expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
the parse throws "could not parse the shell document headers".
The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.
MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): quote the current directive where the old text was written down
Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.
The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the surfaces img-src https: actually unblocks today
The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.
What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.
Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.
That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.
Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): point the new source-control route pin at the current directive
Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.
Both pins now read the current directive.
One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): wait for the admitted images before reading their hits
CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.
So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.
One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.
The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.
What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:
[html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
["/css-bg.png","/img.png","/never-arrives.png"]; #remote
{"complete":true,"naturalWidth":1,
"currentSrc":"https://artifact-images.invalid/img.png?n=n1",
"loading":null}: arm csp=shipped sandbox=product frameReady=images
nonce=n1 | browser 147.0.7727.15 | ... | frames [...]
`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): say why an admitted image never arrived, and hand back the context
CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.
Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.
Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.
Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three rationales the widening left wrong
(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.
(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.
(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.
A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.
Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): probe why Chrome never asks for the artifact image
CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.
On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.
Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.
`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.
The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.
Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): time the artifact image against the frame's attachment
CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.
So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.
Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.
Abort path only; the passing run is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): serve the artifact's https assets from a real TLS listener
Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.
So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.
Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.
The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.
And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.
One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.
24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the artifact fetch and the attachment on one clock
The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.
So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.
The commit message of b5e82065f3 carries the same overstatement and is left
as it stands; this is the correction.
Also the stale route-handler references, now that the asset listener records
the secure origin and the navigation record is a page subscription. Three were
in the review; two more were not, and both were stale for the same reason:
`waitForRecordedNavigation`'s docstring still credited the route with
recording a main-frame navigation, which stopped being true when the record
moved off interception, and the request log described a refusal as one the
request never reached a route handler with. The route now only refuses; it
counts nothing. The one remaining mention is the deliberate contrast in the
rig that says the record is the page's event and not the route's.
Comments only. 24/24 on both local engines.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
142 lines
21 KiB
JSON
142 lines
21 KiB
JSON
{
|
|
"$comment": "Every .web.* sibling the Route A web build resolves ahead of its native file. One entry per documented React Native Web gap; config/scripts/mobile-web-app-web-overrides.test.mjs fails on an unlisted one, a listed file that is gone, or one with no native sibling.",
|
|
"overrides": [
|
|
{
|
|
"file": "src/transport/client-context.web.tsx",
|
|
"reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: it serves the BridgeRpcClient the entry built, for every hostId, because the bridge protocol names no host. The entry mounts nothing until init lands, so this provider never holds a client that cannot answer."
|
|
},
|
|
{
|
|
"file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts",
|
|
"reason": "Vendored with the module, not added for Route A. Nothing on the page reaches it any more: dictation's capture went behind src/platform/dictation-capture.web.ts, which asks the shell for the microphone, so the only importer of @orca/expo-two-way-audio is the native half of that seam. The file stays because it ships with the vendored module, and it answers denied microphone permission and no playback, which is what a browser outside the shell can honestly say."
|
|
},
|
|
{
|
|
"file": "src/navigation/route-handoff.web.ts",
|
|
"reason": "The page is one document standing in for one screen, so a route it does not render has to be handed to the app that does. The native file is the router; this one posts a navigate notify for a target outside the page's routes and pushes locally for one inside them."
|
|
},
|
|
{
|
|
"file": "src/transport/host-store.web.ts",
|
|
"reason": "expo-secure-store resolves to {} on web, so the real store answers loadHosts() with an empty array and every screen that looks this host up paints \"Host not found\" over the host the shell just opened. This one serves the profile init carried, without the credential the bridge already holds."
|
|
},
|
|
{
|
|
"file": "src/transport/host-device-token-store.web.ts",
|
|
"reason": "expo-secure-store resolves to {} on web, and the bridge carries the RPC, so the page holds no device token."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/index.web.tsx",
|
|
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
|
},
|
|
{
|
|
"file": "src/platform/text-input-font-size.web.ts",
|
|
"reason": "iOS zooms the page on focus of any text input under 16px and does not zoom back out, which leaves the document at a scale other than 1 for a whole typing session. keyboard-occlusion.web.ts reads a scale other than 1 as 'not a keyboard' on purpose, because geometry cannot separate a zoom from a keyboard, so the commit bar and the note composer would never lift on the platform they were written for. The web file raises the app's 14px body size to that floor; the native file is the body size, so a phone renders exactly what it rendered before. maximum-scale=1 on the viewport meta was the other fix and was rejected: Android WebView honours it and it would take deliberate pinch zoom away from low-vision users."
|
|
},
|
|
{
|
|
"file": "src/platform/haptics.web.ts",
|
|
"reason": "expo-haptics has a web build that fakes an iOS haptic by appending a hidden <label><input type=\"checkbox\" switch> to document.head, clicking it and removing it, once per call. C1.9 traced a long press that never fired on the worktree list to that stray click, and the file explorer calls triggerSelection on every row tap. So the page asks the shell for the device's own haptic instead, over the native.haptics.trigger notify behind the haptics grant: 70 to 77 bytes per frame, no reply, and haptics.ts's own Platform.OS split on the other side. A notify rather than a verb because nothing is owed back and a reply would spend a slot in the same in-flight window a forwarded request does, at 90 call sites (rulings-ota-c7.md ruling 30). The five exports are the five kinds the frame admits; mobile-web-app-haptics-seam.test.mjs is the fence."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/files/[worktreeId].web.tsx",
|
|
"reason": "The shell renders this page for the file explorer, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser — reproduced in the render check, which painted that console error instead of the route."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/files/preview/[worktreeId].web.tsx",
|
|
"reason": "The shell renders this page for the file preview, for the same reason as the explorer beside it: no nested shell, and a native file that reaches requireNativeViewManager at import. Normalizing the route params stays here, because the page reads them back out of its own URL exactly as the native screen reads them out of the router."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/agent-history/[worktreeId].web.tsx",
|
|
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/web.web.tsx",
|
|
"reason": "The hybrid shell route opens a WebView on this very page, so on web it redirects to the host instead of nesting the shell inside itself. Its native file pulls in OrcaMobileWebShellView, whose requireNativeViewManager call runs at import and throws in a browser."
|
|
},
|
|
{
|
|
"file": "src/platform/external-link.web.ts",
|
|
"reason": "The page runs inside the shell's WebView, where react-native-web's Linking.openURL calls window.open(url, '_blank', 'noopener') and resolves whether or not anything opened; only a tel: URL assigns window.location, and none of the three allowed schemes is one. Both shells refuse window.open outright: iOS sets javaScriptCanOpenWindowsAutomatically = false and returns nil from WKUIDelegate's createWebViewWith, and Android sets javaScriptCanOpenWindowsAutomatically = false, setSupportMultipleWindows(false) and returns false from onCreateWindow. So the native path reports success into a tap that did nothing. This one posts the externalLink notify instead, after the same scheme check the frame enforces, and names its refusal rather than throwing inside a tap handler."
|
|
},
|
|
{
|
|
"file": "src/platform/keyboard-occlusion.web.ts",
|
|
"reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document \u2014 the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing."
|
|
},
|
|
{
|
|
"file": "src/platform/clipboard.web.ts",
|
|
"reason": "expo-clipboard resolves to navigator.clipboard on the web, which needs a secure context; the iOS shell serves the page from the custom scheme orca-mobile-web://<session>/ while Android serves https, so that path would work on one platform and silently not on the other. This one asks the shell through the native.clipboard.write and native.clipboard.read verbs, where the pasteboard is the device's, and rejects when the route was not granted one so the caller's own catch puts that on screen. An image is the same pasteboard reached a different way: native.clipboard.read admits only text, and a 24 MiB base64 image cannot cross an 8 MiB reply cap, so readImage runs native.media.pick with source 'clipboard', reads the staged bytes back a chunk at a time and gives the handle back. contents answers per grant without probing, because the shell serves no 'is there text' verb and reading to find out would raise iOS's paste-consent prompt on every mount."
|
|
},
|
|
{
|
|
"file": "src/components/pr-sidebar/MermaidDiagram.web.tsx",
|
|
"reason": "The native component seals the diagram inside a WebView whose document embeds the whole mermaid bundle as a string, because react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one diagram. This one renders the same diagram in this document instead — mermaid is a browser library, so it is an import() inside the render effect rather than a 3.7 MB literal, and the two hosts share one MERMAID_DIAGRAM_CONFIG. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs the serialized SVG through DOMPurify; the native path's </script> escaping has no analogue because the source is a JS string argument rather than text spliced into an inline script. Both are measured in both engines under the shipped CSP by config/scripts/mobile-web-app-mermaid-render.test.mjs, which also pins the rendered SVG byte for byte against the native document's own render."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/tasks.web.tsx",
|
|
"reason": "The shell renders this page for this route, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and one throwing route module takes the whole bundle down because the manifest imports them all."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/source-control/[worktreeId].web.tsx",
|
|
"reason": "The shell renders this page for the source-control hub, so the page has no shell to mount inside itself and no flag to read; the switch already happened natively. Its native file reaches OrcaMobileWebShellView, whose module calls requireNativeViewManager at import and throws in a browser, and the route manifest imports every route, so one throwing module takes the whole bundle down. The tab, name and origin params are parsed here exactly as the native sibling parses them, because the page reads them back out of its own URL the way the native screen reads them out of the router."
|
|
},
|
|
{
|
|
"file": "app/h/[hostId]/review/[worktreeId].web.tsx",
|
|
"reason": "The shell renders this page for diff review, for the same reason as the source-control hub beside it: no nested shell, and a native file that reaches requireNativeViewManager at import. Shorter than its siblings because the route body is MobileDiffReviewRouteScreen, which reads its own params — the native file had to extract that anyway so the review controller does not subscribe behind the page."
|
|
},
|
|
{
|
|
"file": "src/browser/browser-frame-layer-paint.web.ts",
|
|
"reason": "setNativeProps does not exist on React Native Web: an Image or View ref is the DOM node itself, so both native writes throw rather than paint and the pane never shows a frame. This file writes the same two things through the element — the frame as a background-image on the child RN Web sizes, the double buffer as one opacity write per layer — so the pane still never re-renders while it streams. It also answers whenBrowserFrameDisplayable, which is a no-op natively: a background write fires no load event, so the flip to the pending layer is armed from an image decode instead of the Image's onLoad."
|
|
},
|
|
{
|
|
"file": "src/browser/browser-frame-data-uri.web.ts",
|
|
"reason": "The bridge hands the page its frame as base64 and the data URI wants base64, so the native file encodes the bytes back into the string they arrived as. The page resolves buffer to the JS shim rather than Node's native encoder, which makes that the largest per-frame cost the page pays. This file reads the base64 the bridge kept and falls back to the native encoding for a frame that carried none."
|
|
},
|
|
{
|
|
"file": "src/browser/use-browser-binary-screencast-grant.web.ts",
|
|
"reason": "Natively the socket carries the binary screencast frame and this app is both halves of that path, so there is nothing to negotiate. In the page the frames come through a shell that may predate the encoder, and subscribing with wantsBinary against one leaves the pane on a stream no frame can arrive on. This file asks the shell through the grants init carried, per C6 ruling 5."
|
|
},
|
|
{
|
|
"file": "src/browser/browser-screencast-request.web.ts",
|
|
"reason": "Not an RN Web API gap but a transport one that exists only in the page: a screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES, and a phone's mobile view at the native device scale factor produces a worst-case JPEG larger than that. This file budgets the mobile view's area against the cap, the envelope it measures rather than names, and one worst-case bytes-per-pixel constant. Web view mode is untouched, because a letterboxed desktop viewport is not an area the page can predict."
|
|
},
|
|
{
|
|
"file": "src/session/terminal-snapshot-byte-budget.web.ts",
|
|
"reason": "Not an RN Web API gap but a transport one that exists only in the page. A terminal's first frame is its scrollback snapshot, which the desktop trims to 512 KiB of raw terminal text while the bridge measures the serialized event against BRIDGE_MAX_MESSAGE_BYTES. An ANSI snapshot is mostly ESC bytes and JSON spends six on each, so a colour-dense 80-column screen comes back at 669,268 bytes against a 655,360-byte cap and the stream ends with overflow before a live byte is painted. This file derives the budget the page sends in terminal.subscribe from the cap less the event envelope; the native sibling sends nothing, because a socket frame has no ceiling above it and a budget there would shrink a phone's scrollback for a limit that does not exist."
|
|
},
|
|
{
|
|
"file": "src/session/mobile-native-chat-input-styles.web.ts",
|
|
"reason": "The chat's composer and its question field render one point above the app's body size, which is 15 and under the floor below which iOS zooms the page on focus. keyboard-occlusion.web.ts reads a scale other than 1 as 'no keyboard' and answers 0, and on this screen that lift is the terminal's only feedback that its hidden input has focus, so one focus of the composer would cost the rest of the session. This file puts both fields on TEXT_INPUT_FONT_SIZE; the native sibling keeps 15."
|
|
},
|
|
{
|
|
"file": "src/browser/browser-address-field-styles.web.ts",
|
|
"reason": "The address bar renders at the theme's 12px meta size, and in a browser an input under 16px makes iOS zoom the page on focus and never zoom back. keyboard-occlusion.web.ts reads that scale as 'no keyboard' and answers 0, so one focus would stop the pane lifting for the rest of the typing session. This file puts the input and its overlaid label on TEXT_INPUT_FONT_SIZE with a line box to match; the native sibling keeps 12px, which is what a phone has always rendered and where no page can zoom."
|
|
},
|
|
{
|
|
"file": "src/terminal/TerminalWebView.web.tsx",
|
|
"reason": "react-native-webview has no web build that renders anything: on the page it paints the line \"React Native WebView does not support this platform\" where the terminal was, which is a red line and no terminal rather than a crash. This file mounts the same document the WebView loads — xterm imported from @xterm/xterm with the unicode11 and webgl addons, and the document's own modules imported in the order the generator emits them — behind the identical TerminalWebViewProps and TerminalWebViewHandle, so nothing above the contract can tell the two apart."
|
|
},
|
|
{
|
|
"file": "src/terminal/terminal-webview-html.web.ts",
|
|
"reason": "The native file composes the whole WebView document, which splices in the 612 KiB minified xterm engine string. On the page that string is unusable — the shell's CSP is script-src 'self' with frame-src 'none', so there is no nested document to load it into — and it would be the largest single module in the session route's closure. The web file answers the caret options, the markup and the stylesheet, which is everything the page mounts, and nothing else; mobile-web-terminal-engine-closure.test.mjs is the fence."
|
|
},
|
|
{
|
|
"file": "src/platform/dictation-capture.web.ts",
|
|
"reason": "Dictation's capture seam. The native file holds the microphone through @orca/expo-two-way-audio and the wake tag through expo-keep-awake, neither of which a browser has; this one asks the shell for both over native.audio.start|read|stop and native.wakelock.set, draining the shell's ring on a timer and raising each reply as the events the engine emits directly. The flow above the seam is one file on both hosts."
|
|
},
|
|
{
|
|
"file": "src/platform/media-picker.web.ts",
|
|
"reason": "expo-image-picker and expo-document-picker are native modules whose import runs a codegen lookup that throws in a browser, and the route manifest imports every route, so one of them in a page closure takes the whole bundle down rather than one picker. This one asks the shell through native.media.pick, reads the bytes back a chunk at a time over native.media.read because a picked image reaches 18 MiB raw against an 8 MiB reply ceiling, and releases every handle it was handed, including the ones its caller never took. The pasteboard is not here: clipboard.web.ts owns it on both platforms and reaches the same verbs for an image."
|
|
},
|
|
{
|
|
"file": "src/session/mobile-clipboard-image-resize.web.ts",
|
|
"reason": "expo-image-manipulator is a native module with no browser counterpart, and the two expo-file-system writes its native path makes exist to work around an iOS loader that cannot decode a large base64 data URI, which a browser does not need. img-src 'self' data: https: already admits the source, so this file decodes one <img>, draws it into a canvas at the target size and reads the PNG back out of toDataURL, with no file anywhere in it. It reads the size back off the canvas rather than echoing the size asked for; the two are the same number, because a browser reflects the width it was assigned, and reading it back keeps the dimensions and the bytes coming from one element. Measured in a real browser under the shipped header: a 1400x1000 noise PNG of 5,476,032 base64 characters converges in one pass to 368x263 and 397,220 characters, 75.8% of the upload path's 512 KiB chunk."
|
|
},
|
|
{
|
|
"file": "src/components/MobileRichMarkdownEditor.web.tsx",
|
|
"reason": "The native editor is a rich document inside a WebView, and react-native-webview is a native component with no browser counterpart: importing it runs a codegen lookup that throws, and the route manifest imports every route, so one such import takes the whole page down rather than one editor. This one renders the Markdown source in a plain field on the text-input seam, which is the state the screen around it already handles — the text, every edit through onChange, and Save, Discard, Copy and Refresh unchanged. The degradation is the formatting toolbar and the rendered view: the toolbar's fifteen commands are the rich document's, and a contenteditable reimplementation is a different surface with its own escaping and its own proof rather than a smaller version of this one (rulings-ota-c7.md ruling 8). onKeyboardInsetChange is never called, which is correct rather than missing: it exists because native Keyboard events under-report a WebView's covered area, and on the page keyboard-occlusion.web.ts is the only measurement there is."
|
|
},
|
|
{
|
|
"file": "src/components/MobileHtmlPreview.web.tsx",
|
|
"reason": "The native preview renders an agent-produced HTML artifact inside a sandboxed WebView with navigation locked to the initial inline document, and react-native-webview throws at import in a browser for the reason above. This one renders the artifact in a sandboxed iframe with no allow-scripts and no allow-same-origin, keeping the Preview/Source toggle. srcdoc rather than a blob: URL and no CSP change at all: a srcdoc frame has no URL for frame-src to match and inherits its embedder's policy instead, so it is admitted under the shipped frame-src 'none' on Chromium and WebKit alike, while a blob: frame is refused by frame-src and refused again in WebKit by the frame-ancestors 'none' it inherits. The inherited policy is also what seals it -- script-src 'self' refuses the artifact's inline script, img-src bounds its images, font-src 'none' its fonts -- and allow-top-navigation-by-user-activation is the one capability granted, so a tapped link becomes a top-frame navigation the shell opens externally (ruling 29) while a meta refresh, a form submit, target=_blank and any script-initiated navigation produce none."
|
|
},
|
|
{
|
|
"file": "src/mobile-web-shell/catch-all-page-route.web.tsx",
|
|
"reason": "The catch-all's native body mounts the shell, and the page is what that shell displays; nesting one here would open a second document inside the first, and the shell module calls requireNativeViewManager at import, which throws in a browser."
|
|
}
|
|
]
|
|
}
|