Files
orca/mobile/web-entry/web-overrides.json
Jinwoo Hong 57fdf68ab3 feat(mobile): let the page hand a link to the device through the shell (OTA phase C, C2.3) (#21597)
* feat(mobile): answer externalLink on the shell side of the bridge (OTA phase C, C2.3)

A page has no way to open a URL outside itself: `Linking.openURL` is the
app's, and inside the shell the page is a document that cannot reach it. Adds
`notify { name: 'externalLink', url }` and a new grant name of its own in
`MOBILE_WEB_SHELL_GRANTS`, rather than a verb of `navigate` — `navigate`
opens a screen this app carries, this hands a URL to whatever the device
opens it with, and a shell implementing one and not the other is a real shell
the route policy has to be able to describe.

`https:`, `http:` and `mailto:` only, and broad inside that: any host, any
path, because a grant that named GitHub would grow a row per provider. The
rule is parsed rather than prefix-matched, since a scheme is what a URL
parser says it is and `startsWith('https:')` reads one out of
`javascript:alert("https://x")`. It is enforced at the frame as well as at
the page's call site, so a page that skipped its own check still cannot reach
the device handler. Bounded by the route href cap, per the ruling.

`BRIDGE_PROTOCOL_VERSION` is not bumped. Inert until a consumer exists: no
call site and no barrel is touched here.

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

* test(mobile): plumb externalLink through the bridge hook probe (OTA phase C, C2.3)

The hook's own suite builds its caller options inline, so the new required
option made it stop typechecking. `tsc -p tsconfig.json` excludes test files;
only the tests-typecheck ratchet saw it.

Adds the case that goes with it: a URL the page hands over reaches the
caller that can leave the app, and nothing reaches the navigate path.

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

* feat(mobile): let the page post an externalLink over the bridge (OTA phase C, C2.3)

`notifyExternalLink(url)` on the page client, gated on the `externalLink`
grant and on the same scheme rule the frame enforces.

Checked twice on purpose. Nothing crosses back for a notify, so the boolean
is the only answer a tap gets: a page that posted a URL the shell's reader
then dropped would report "opened" into a frame nobody acted on, which is
precisely the dead tap the grant exists to rule out.

False before `init`, false after `close`, and never a throw — the callers are
tap handlers with no catch around them.

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

* feat(mobile): add the external-link seam the tasks call sites will use (OTA phase C, C2.3)

One module with a `.web.ts` sibling, which is the shape every platform gap in
this bundle already takes. Native is `Linking.openURL` with the rejection
swallowed, because every caller is a tap handler and `openURL` rejects for a
URL no installed app claims. Web posts the `externalLink` notify after the
same scheme check the frame enforces, names its refusals and throws nothing.

The opener is published by the entry rather than read from context, for the
reason `publishPageStorage` is: the callers are plain functions in render
trees the provider does not wrap. A document that published none refuses
every URL, which is the right answer for a page with no shell.

Registered in `web-overrides.json`: inside the shell's WebView, react-native
-web's `Linking.openURL` opens the URL in that WebView and replaces the page
rather than handing it to the system browser.

No call site and no barrel is touched. The consumer PR swaps them onto this.

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

* docs(mobile): state the real reason the page cannot open its own links (OTA phase C, C2.3)

The override reason claimed react-native-web's `Linking.openURL` opens the
URL in the shell's WebView and replaces the page. It does not. Read from
react-native-web 0.21.2: `openURL` calls
`window.open(url, '_blank', 'noopener')` and resolves whether or not anything
opened; only a `tel:` URL assigns `window.location`, and none of the three
allowed schemes is one.

The true failure is the worse one and the better argument for the verb. Both
shells refuse `window.open` outright, measured in their own sources: iOS sets
`javaScriptCanOpenWindowsAutomatically = false` and returns nil from
`WKUIDelegate`'s `createWebViewWith`; Android sets the same flag false, calls
`setSupportMultipleWindows(false)` and returns false from `onCreateWindow`.
So nothing opens, `openURL` resolves anyway, and the native path reports
success into a tap that did nothing — precisely the dead tap the grant exists
to rule out.

The seam's own comment now says the same, so the two files cannot drift.

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

* fix(mobile): report a URL the phone could not open (OTA phase C, C2.3)

The shell swallowed `Linking.openURL`'s rejection. Nothing crosses back to
the page for a notify, so an open that failed — a `mailto:` on a phone with
no mail account — was silent on both sides. That is the one dead tap this
verb does not rule out, and it was the only one with no record at all.

Warned with the URL and the error, in the shape the two neighbouring reports
in this screen use, and still not rethrown: this runs on the native frame
handler. The contract now asks for the report rather than only for the
absence of a throw.

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

* fix(mobile): forward the URL the parser read, not the string the page sent (OTA phase C, C2.3)

The scheme check reads the protocol through the WHATWG parser, which strips
tab, LF and CR from anywhere in a URL and trims leading C0 and space before
the scheme is visible. So `ht\ntps://example.com`, `https://example.com/a\r\n`,
`  https://example.com/a  ` and `https:example.com` all passed the check, and
both sides then forwarded the original string. Not a scheme escape — the
parser had already decided the scheme — but the device handler was given a
URL the check never looked at, which is a dead tap through an allowed URL.

`readBridgeExternalLinkUrl` answers the parsed href, and the page posts it
and the host forwards it. Normalizing rather than comparing, because
`https://example.com` differs from its own href by a path slash: refusing
what differs from its normalization would refuse an ordinary URL.

The cap now applies to the normalized form as well as the raw string, since
percent-encoding expands and a string inside the cap can leave it.

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

* test(mobile): exercise the seam's default opener instead of a published one (OTA phase C, C2.3)

The case named for a document that published no opener published one first,
and `post` is module state every earlier case had already set, so the default
at the top of the module was never the thing under test. The only assertion
was `not.toThrow()`, which passes against any implementation.

`vi.resetModules()` and a fresh import, and the refusal reason is asserted.
Confirmed to discriminate: flipping the default to `() => true` turns this
case red and leaves the other three green.

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

* test(mobile): build the openURL rejection per call, not at mock setup

The case added for a failed open used
`mockReturnValue(Promise.reject(failure))`, which builds the rejected promise
at setup time. Nothing attaches a handler until the notify frame arrives
several awaits later, so the suite reported an unhandled rejection and exited
1 with every test passing — a red run that reads as green in the counts
alone.

A fresh rejection per call closes the window, and `openUrl` is reset between
cases so the mock cannot leak into one that does not expect it.

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

* fix(mobile): guard the openURL failure that escapes a tap handler (OTA phase C, C2.3)

`Linking.openURL` validates before it returns anything: `_validateURL` is an
`invariant` that throws for an empty string (react-native 0.83.10,
`Libraries/Linking/Linking.js:117-123`). So the seam's `.catch` was attached
to a promise that, in that case, never existed, and the throw went straight
through a tap handler — contradicting the module's own claim to be safe in
one.

Both failure modes are now caught and reported, and neither is rethrown. The
test double validates the way the real module does, because a mock that only
rejects cannot reproduce the failure that escapes.

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

* refactor(mobile): collapse the duplicate openURL wrapper onto the seam (OTA phase C, C2.3)

`mobile-pr-url.ts` was the seam's native body already, byte for byte, written
before it. It now re-exports the seam under its own name, so the empty-URL
guard and the failure report reach its four callers too.

Nothing changes natively: the seam's native form is what those callers were
running. On the web they would now post the notify instead, which is the
behaviour they should have had; it is unreachable today, since none of them
is in a page route's closure.

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

* test(mobile): read externalLink end to end over the port pair (OTA phase C, C2.3)

The pair harness grew `externalLinks` and nothing read it. This is the
`navigate` twin's shape, over the six normalization inputs: what the page put
on the wire and what the shell forwarded are the same strings, read back off
the frames rather than recomputed, and nothing reaches the shell's client.

Two halves on purpose. The page normalizes before it posts, so over the
client the host only ever receives an already-normalized URL and forwarding
it raw would pass — the frame injected straight into the host at the end is
what holds the host to the rule on its own. Confirmed to discriminate:
reverting the host to forward `message.url` turns that assertion red.

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

* style(mobile): match the neighbours on three small inconsistencies (OTA phase C, C2.3)

Three of a kind, none behavioural:

- the notify guard's docstring had a 109-character line; reflowed
- the shell's could-not-open warning passed three arguments where the three
  other reports in that screen pass two; it now passes `{ url, error }`
- the native seam's suite built its rejection with `mockReturnValue`, which
  is the shape `52191bab02` removed elsewhere; it now builds one per call,
  through the same double that validates the way the real module does

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 03:33:41 -04:00

38 lines
3.5 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. The dictation hook imports @orca/expo-two-way-audio, whose native module is a Swift/Kotlin JSI binding with no browser counterpart; the web file answers the same surface with denied microphone permission and no playback."
},
{
"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": "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."
}
]
}