Files
orca/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellNavigationPolicy.swift
T
Jinwoo Hong 8d42410e01 feat(mobile): render the HTML preview on the page in a sealed srcdoc frame (OTA phase C, C7.10 A) (#21862)
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener

Both shells cancelled every navigation off their own document in silence: iOS
`decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's
`shouldOverrideUrlLoading` dropped anything whose resolved path was not "/".
Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping
a link inside C7.10's sealed HTML-preview frame reaches the top frame as a
navigation request, and the shell was the only thing that could act on it.

A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and
goes through the same `Linking.openURL` the `externalLink` notify already uses.
The scheme list is not restated natively: the native side caps the string and
says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens
in the half that ships over the air. A subframe navigation is never offered,
because that is the sealed preview loading itself.

swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs).

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

* feat(mobile): render the HTML preview in a sealed srcdoc frame on the page

C7.6 gave the page the artifact's source, which is the native component's Source
tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes
back as an `<iframe sandbox srcdoc>` inside the page's own document.

`srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium
and WebKit: 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'`,
while a `blob:` frame is refused by `frame-src` on both and refused a second time
in WebKit by the `frame-ancestors 'none'` it inherits.

Two independent fences seal it, and the render check measures each on its own:
the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the
inherited `script-src 'self'` refuses the artifact's inline script even when a
control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'`
govern its subresources, against a no-header control where the same three are
fetched.

`allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so
a tapped link becomes one top-frame navigation the shell now opens externally,
while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated
navigation produce none.

`lucideBarrelPlugin` is exported from the bundle builder so the check builds the
toolbar's icons the way the page does rather than carrying a second shim.

config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a
literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script
fence and the token census.

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

* test(mobile): pin the preview's sealed frame where the degradation was pinned

The three HTML-preview cases in this file described the state ruling 26 retires:
no toggle, no frame, the source only. They now pin the frame's shape through the
test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither
`allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source
takes the frame away with it -- and the "never renders the html itself" case
becomes "never puts it anywhere but the frame", counted rather than merely absent.
What a browser does with that frame stays in the render check, which is the only
thing that can answer it.

The rich Markdown editor's half is unchanged: it is still the plain field, and
item C is a later PR.

Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the
toggle renders one, and `lucide-react-native`, whose barrel imports a
`LucideProvider` its own context module does not export and so does not load under
vitest at all.

9 passed, exit 0.

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

* fix(mobile): refuse a link-activated top-frame navigation, even to the document

F1, blocking, with F5 and F6 folded in because they are the same decision and
splitting them would mean three rewrites of one function.

F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the
embedder's base, so both named the shell's own document URL -- which both shells
ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed
preview reloaded the shell's page: bridge target cleared, load state restarted,
page state gone. A navigation a human started is now never allowed, whatever it
names; it is offered instead, and `cancelledShellNavigationTarget` drops
`orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting
its own path carries no gesture and is still allowed.

F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a
redirect is cancelled and never opened externally.

F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a
download>` was dead on iOS and opened on Android. The early return goes; a
download is refused rather than allowed when nothing started it, and a
gesture-started one reaches the opener on both platforms.

The allow half and the offer half are now one function per platform
(`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so
they cannot drift. The gesture is the platform's own answer: `.linkActivated` on
iOS, `request.hasGesture()` on Android.

Native tests, both platforms: document URL + gesture refused and offered; document
URL without gesture allowed; foreign + gesture cancelled and offered; foreign
without gesture cancelled and silent; download both ways; subframe never offered.
swiftc OK; control run with the gesture rule removed exits 133. Gradle
MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0.

Also corrected: the screen comment that claimed the document's own reloads reach
the handler (they never do), and the prop doc, which now states the gesture rule.

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

* test(mobile): count own-origin top-frame navigations, and drop the goto cap

F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling
render check uses the 30 s default, so under load the first WebKit cases redded on
the navigation rather than on anything they assert. The cap goes; the per-action
timeouts that needed to be short are already passed at their call sites.

F1's page-side half: the rig now routes the page's own origin as well as the
foreign one and counts main-frame navigations to each separately, with two cases
pinning that `href="/"` and `href=""` each produce exactly one own-origin
top-frame request. Playwright is not the shell, so what these state is the request
the shell is handed; refusing it is the native tests' job and the docstring names
which ones. The own-origin route is registered after the initial load, because it
aborts main-frame navigations and the first `goto` is one.

The foreign-tap and meta-refresh cases now also assert zero own-origin
navigations, so a fix that merely moved the target would not pass.

16 passed, exit 0, no Errors line.

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

* test(mobile): wait for the preview frame's own load, never a clock

CI read the child frame before its srcdoc committed: frameUrl came back ''
and the control arm's script as not yet run. The frame list, the frame's URL
and anything read inside it settle at their own moments, and a 900 ms wait
reads whichever of them has happened -- on a loaded runner, none.

Polls for a child frame at about:srcdoc with its load fired, bounded by the
case's own timeout, and an override arm now resolves on the document its
srcdoc assignment commits rather than on the assignment.

Red-first: with a 2.5 s mount delay standing in for a loaded runner, the
paint case failed on both engines before this and all 16 cases pass after.

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

* docs(mobile): say whose violations the preview rig reads

The list is the main frame's: securitypolicyviolation does not cross into a
frame, so an empty one says the embedder raised none and says nothing about
the artifact's own style, image or font. A listener inside the frame cannot
be the fix -- the fence under test is that nothing in the artifact runs.

So the comment now claims what the reading supports, and names where the
frame's containment is actually measured: the pixel for its inline style,
the counting server for its img-src and font-src.

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

* fix(mobile): announce which side of the preview toggle is showing

The Preview/Source pair carried a label each and nothing else, so which one
was showing lived only in the active background -- invisible to a screen
reader on both surfaces. Each button is now a tab carrying its selected
state, inside a tablist, and the two files' toolbars stay character-identical
so the page and the phone announce the same thing.

Red-first: the new case renders both siblings and failed on both for the
missing role before this.

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

* test(mobile): type the WebView mock like the file's other hosts

The anti-slop gate refuses a bare `object` parameter. Takes the same shape as
the react-native mocks beside it, which pass it.

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

* fix(mobile): allow only the load the shell itself started

The document URL was allowed whenever the host reported no gesture, so a
navigation the shell never asked for could reload the page out from under the
session. Measured against a real WKWebView off-device: a sandboxed subframe
navigating the top frame to the document URL arrives as `.other` with no
gesture at all, and Chromium's own docs allow hasGesture() to be false for a
request a human started. Census first: nothing in the page navigates the top
frame -- no location assignment, reload, replace, window.open or form -- the
router moves by pushState and replaceState only, so the rule needs no gesture
and no page cooperation.

Both shells now raise a flag around their own load and drop it at commit, and
allow a main-frame navigation only while it is up. Everything else naming the
document is refused and never offered, since offering it would send the user
out of the app. iOS carries the second discriminator the same probe measured:
sourceFrame is the main frame for the shell's own load and the subframe for a
subframe's top navigation, so a subframe can never take the allow path.

Red-first: the Swift checks and the Kotlin tests were written first and failed
to compile against the old signature. 9 Kotlin tests, 54 in the module.

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

* test(mobile): point the meta-refresh arm at the embedder's own URL

The fixture pointed off-origin, so its own-origin assertion could not move
whatever the frame did. The new arm refreshes to `/`, which resolves against
the embedder's base, and pins zero top-frame requests on a counter the
`href="/"` case proves reads 1 in the same rig.

It also counts what the frame asks for itself, with a presence control that
attributes the fence: with `allow-same-origin` and no policy the same fixture
navigates the frame to the embedder's `/`, and with the policy dropped but the
product's token kept it navigates nothing, so the opaque origin is what
refuses it rather than the CSP.

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

* test(mobile): read what an action produced, not what a clock allowed

The 600 ms after every action is gone. An arm that expects a navigation now
returns the moment the route handler records it, with a deadline only so a
click that missed its target says so instead of spending the case's timeout.
An arm that expects none waits for two painted frames inside the page and one
200 ms drain for the popup queue, which is a browser-process event with no
in-page counterpart; the docstring says why that one is bounded.

Measured and reported rather than claimed: with the new wait replaced by a
no-op every arm still passes, because the reads that follow are each a round
trip. It is insurance against the runner load that produced the frame-commit
race, not a fix for a failure seen here.

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

* test(mobile): take the settling branch as a ternary

What oxlint's prefer-ternary asks for, and the changed-code gate with it.

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

* test(mobile): find the preview frame by its element, not its URL

CI timed out on all seven preview cases on one engine: the poll waited for a
child frame whose URL reads about:srcdoc, and that browser reports an empty
URL for a srcdoc frame, so every case ran to its own timeout. The same
difference had already shown as `expected '' to be 'about:srcdoc'`.

The frame is now the element: waitForSelector('iframe') then contentFrame(),
with readiness taken from the fixture's own marker inside it. Nothing compares
a frame URL any more -- the paint case reads the element's srcdoc attribute
and the absence of src instead, which is what "parsed inside the frame rather
than fetched into it" actually means. The one arm whose artifact navigates the
frame away says so rather than waiting for a marker that is not coming.

Red-first: with the old poll keyed on a URL the browser never reports, both
engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s
mount delay still injected.

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

* test(mobile): make a frame that never becomes ready say what it saw

The runner's Chrome read the preview frame's URL as empty where three
chromium builds here read about:srcdoc: bundled headless, the headless shell,
and --headless=old, all 147. So the difference is not reproducible locally and
the next CI run has to carry its own diagnosis.

The marker wait is bounded well inside the case timeout, and on expiry it
reports the frame's URL, the srcdoc attribute's length and the page's CSP
violation list -- which separates a frame the policy refused from one that was
merely slow, the two readings that look identical from a timeout.

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

* test(mobile): run the containment arms the comment only claimed

The comment said the fixture navigates nothing with the policy dropped and
the product's token kept, but no arm ran it: the control dropped both fences
at once. Both single-fence arms exist now, either of which would hold.

Measured rather than assumed, and one of them is not what the comment said.
The token alone: the navigation never starts, no request, no violation. The
policy alone, with allow-same-origin granted: the navigation does start and
frame-src refuses it, which the embedder reports as its own violation. The
engines differ only in what is left in the frame -- chromium an error page,
WebKit the artifact -- so neither is asserted; what is asserted is that the
request never reaches the server.

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

* fix(mobile): refuse a download that names the shell's own document

The document branch skipped downloads, so `<a href="/" download>` fell
through to the offer path carrying the shell's own URL. Harmless in practice,
because the opener's scheme list drops it, but it contradicted the policy's
own comment and the prop doc, and it left the one URL that must never be
offered reaching the boundary.

The branch now covers a download too: refused, from either frame, gesture or
not, and never offered. A gesture-started download of anything else still
reaches the opener.

Red-first on both platforms: the Swift checks exited 133 and the Kotlin row
failed against the old policy. 10 navigation tests, 55 in the module.

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

* fix(mobile): drop the own-load flag wherever a document ends

The flag lived beside the load call and had to remember every ending
separately, so iOS missed two: a prop update that fails before it loads, and a
renderer that died. Both left it raised, and a navigation to the document URL
during that window would have been allowed.

It now lives in the load state machine, which every ending already runs
through -- a commit, a failure, a dead renderer, a prop update, a reset -- on
both platforms, so there is nothing left to remember. The view raises it and
reads it, and drops it nowhere.

The Android residual is stated in the policy rather than papered over: between
loadUrl raising the flag and onPageStarted dropping it, a navigation to the
document URL from inside the preview frame would be allowed, because that
callback says nothing about which frame asked and no host discriminator
exists. It needs a generation switch and a tap in that window; iOS closes the
same gap with sourceFrame.

Red-first: the new Swift row failed to compile and the Kotlin row with it.
12 load-state tests, 56 in the module.

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

* fix(mobile): spend the own-load flag on the allow, not on the commit

The flag stayed raised from the load until didCommit, so a second main-frame
action naming the document inside that window was allowed too and replaced the
document. WebKit can decide a second action before the first one starts, so
the commit is too late to be what spends it.

The allow itself spends it now, before the decision goes back, and every
ending still drops it for a load that is allowed and never commits.

Red-first: the new check composes the machine with the policy -- the seam the
flag and the rule meet at -- and failed to compile against the old machine.

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

* fix(mobile): stop raising an own-load flag Android never consults

WebViewClient's javadoc, verbatim: "This callback is not called for all page
navigations. In particular, this is not called for navigations which the app
initiated with loadUrl(): this callback would not serve a purpose in this
case, because the app already knows about the navigation."

So the flag guarded nothing on this platform and, while raised, was the one
thing that could have let a competing request to the document URL through.
The view passes isShellLoad = false always now, the machine drops the field it
had no raiser for, and the policy comment carries the quote. Nothing reaching
that callback is the shell's own load, so nothing naming the document is
allowed there at all -- which also closes the generation-switch window the
residual named, so that paragraph goes.

No red to show: this is a removal, and the behaviour it leaves is the refusal
the existing rows already pin. What a device proof must check is stated in the
policy instead: a WebView that did route its own load here would have it
refused and the load state would sit at loading. 55 tests in the module.

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

* test(mobile): settle every arm, not only the ones that tap

An arm with no action read its counters as soon as the frame's marker
appeared, so a zero-delay meta refresh could dispatch after the reading. The
arms that pin zero were the ones relying on it.

Every arm settles now, and what it settles on is what it expects: the sealed
refresh arms take the bounded no-navigation path, and the loose arm waits for
a recorded navigation that is neither main-frame nor foreign -- its own
frame's -- rather than the main-frame wait it would never satisfy.

Red-first: with the settling removed and the refresh moved to 2 s, the loose
arm reads 0 on both engines; with it back, 1 on both, the delay still in.
A 0.4 s refresh passes either way, which is why the finding was invisible.

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

* test(mobile): wait for what the artifact's script wrote, not for the element

The two-fences control asserts the inline script ran, and the marker element
it waited for exists from parse time, so the arm could read window.__ran
before the script had touched it. Under a loaded runner that reads 0, which is
CI's "expected +0 to be 1" on chromium.

Readiness is now per-arm: 'script' waits for the script's own write, 'load'
for the arm whose artifact navigates the frame away, 'artifact' for the rest.

Red-first: with the inline script's write delayed 1.5 s, the old arm fails on
both engines with that exact message and the new one passes, delay still in.

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

* test(mobile): bound the rig's waits by the case timeout and nothing else

Two inner deadlines, 20 s and 15 s, were racing the outer one they sit
inside, so a slow runner could fail a case on a number this file picked
rather than on the one the case declares.

Both now run to vitest's own `ctx.signal`, which aborts when the case times
out. On abort the rig prints its reading -- the frame's URL, the srcdoc
length, the violation list, or the navigations it did record -- and lets the
case fail as the timeout it is. Nothing is rethrown from that path: a
rejection raised after vitest has given up on a case has nobody left to catch
it, and an unhandled one fails a run whose every test passed.

Red-first: with the marker selector pointed at an element that never appears
and the case timeout cut to 8 s, the diagnostic prints and the case fails as
`Test timed out in 8000ms` rather than hanging in silence.

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

* test(mobile): ask a stuck preview frame everything it can still answer

The old diagnostic said only that a frame never parsed, and its violation
list was the top document's -- securitypolicyviolation does not cross frames,
so it said nothing about what the frame itself refused.

It now prints the browser version, the arm it came from, the iframe element's
srcdoc length and sandbox, contentDocument.readyState and contentWindow.href
(which answer for a same-origin arm and report `refused` for an opaque one,
so the arm's own origin is in the log), and every Playwright frame with its
url, name, readyState, body length, marker presence, window.__ran and its own
violations. Per frame, because the page's init script installs the collector
in every frame -- measured on both engines -- and CDP evaluates inside an
opaque frame whose scripts are blocked.

Two corrections that the local probes forced. The reading is sampled while
waiting and printed from the last sample: read at the abort it lost its race
with vitest's teardown and printed nothing at all. And two arms had never been
given the case's signal, so their waits could not be bounded or diagnosed.

The diagnosis moves to its own module because the test file is at its line
limit, and because the bound and the reading it prints are one thing.

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

* test(mobile): build a widened control frame instead of relaxing a live one

A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context
when it is created, and Chrome 152 keeps the original ones through a srcdoc
reassignment while still parsing the new document -- so the control arms that
widened the product's own frame stayed sealed on the runner, and CI read a
script that never ran and a refresh that never navigated. Chromium 147 here
honours the relaxation, which is why it passed locally for a year of runs.

The override now clones the element, sets the sandbox on the clone, gives it
the artifact and replaces the product's frame with it, so the widened flags
are there from creation -- the way the product does it, since React sets the
attribute before insertion and never after. The product's own arms are
untouched: a null override still returns immediately.

And the control can no longer pass for the wrong reason on any engine. The
header-keeping arm now reads the violation raised inside the frame: a
script-src refusal can only happen if the sandbox let the script start, so it
separates "the policy held" from "the frame was never widened", which the old
arm could not. The loose arm pins an empty list beside it, the sealed arm pins
an empty one too, and those three readings are the whole fence story. The
violations come from each frame's own collector, because the embedder never
sees them.

Two diagnostic repairs the local probes forced: the browser version is read
once at open, since asking at the abort printed "browser unknown" in the CI
log this exists for, and the reading is sampled immediately as well as every
five seconds, since a wait that only prints "no reading was taken" says
nothing.

Red-first: with the widening disabled, both engines fail exactly as CI did --
180 s timeouts on the script arm -- and the diagnostic names the arm, the
version and the sandbox it actually had.

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

* fix(mobile): put the toggle's selected state where a browser reads it

CodeRabbit is right, and the browser says so: react-native-web's createDOMProps
never reads accessibilityState, so on the page the tab pair emitted role="tab"
and no aria-selected at all. The test renderer could not see it, because it
reports the props the component was handed rather than the DOM they become.

Both siblings carry aria-selected beside accessibilityState now -- the phone's
screen reader takes the latter, the browser the former -- and the toolbars stay
character-identical.

Red-first, in a real browser on both engines: the rig now reads every
[role="tab"] element's aria-selected before and after the tap, and it read null
for both positions before this line existed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 03:32:34 -04:00

85 lines
4.0 KiB
Swift

import Foundation
/// What the shell does with one navigation: the whole decision, so the "allow it" half and the
/// "offer it to the opener" half cannot drift apart.
enum MobileWebShellNavigationVerdict: Equatable {
/// The served document loading itself, which is the only navigation this WebView performs.
case allow
/// Refused, and the host is told nothing. Every navigation was this before the preview existed.
case cancel
/// Refused, and the URL is handed to the host's opener, which decides what may open.
case cancelAndOffer(String)
}
/// The rule for a navigation the shell is deciding about.
///
/// Framework-free on purpose, like `MobileWebShellOrigin`: `tests/MobileWebShellChecks.swift`
/// compiles this file with `swiftc` and checks it without a device or a simulator. Kept in step with
/// the Kotlin copy.
enum MobileWebShellNavigationPolicy {
/// Longest URL the shell hands back to JS for a cancelled navigation.
///
/// The page's own bound is `BRIDGE_MAX_EXTERNAL_LINK_CHARS` (2048) and the filter that applies it
/// is `readBridgeExternalLinkUrl`, in TypeScript. This is not a second copy of that rule: it is a
/// cap on what crosses the native boundary at all, so an artifact cannot spend the bridge on a URL
/// the opener will refuse anyway.
static let maxCancelledNavigationUrlCharacters = 4096
/// The whole decision.
///
/// Three rules, in this order, and the order is the design.
///
/// A navigation outside the main frame is the sealed preview frame loading itself. It is refused
/// and never offered: forwarding it would let an artifact ask for a browser with no tap behind it.
///
/// **The document URL loads only when the shell asked for it.** `isShellLoad` is a flag the view
/// raises around its own `webView.load` and drops at commit; nothing else can raise it. Every
/// other navigation that names the document is refused and never offered -- offering the shell's
/// own URL to the opener would send the user out of the app instead of reloading it.
///
/// The rule deliberately does not rest on the host reporting a gesture. Measured against a real
/// WKWebView, off-device: a sandboxed subframe navigating the top frame to the document URL
/// arrives as `.other` with no gesture at all, and under a gesture-shaped rule that is an allow
/// and a shell reload -- the bridge target cleared, the load state restarted, the page's state
/// gone. `isFromSubframe` is the second discriminator for the same reason: the same probe shows
/// the shell's own load arriving with source and target both the main frame, and a subframe's top
/// navigation arriving with the subframe as its source.
///
/// What is left for the gesture is the only thing an artifact may ask for: a foreign URL, which
/// is refused and handed to the opener. A download is not a document load, so it takes that path
/// too, which is what makes `<a download>` behave the way it does on the native screens -- but a
/// download that still names the document takes the rule above and is refused without an offer,
/// because `<a href="/" download>` is the shell's own URL however it is dressed.
///
/// Which URLs may actually open is not decided here -- `readBridgeExternalLinkUrl` owns the scheme
/// list, in the half that ships over the air.
static func verdict(
url: String?,
isMainFrame: Bool,
isFromSubframe: Bool,
isDocumentUrl: Bool,
isShellLoad: Bool,
hasGesture: Bool,
isDownload: Bool
) -> MobileWebShellNavigationVerdict {
guard isMainFrame else {
return .cancel
}
if isDocumentUrl {
return isShellLoad && !isFromSubframe && !isDownload ? .allow : .cancel
}
guard hasGesture, let offered = offerableUrl(url) else {
return .cancel
}
return .cancelAndOffer(offered)
}
/// The URL a cancelled navigation may be offered under, or nil when nothing crosses.
static func offerableUrl(_ url: String?) -> String? {
guard let url, !url.isEmpty, url.count <= maxCancelledNavigationUrlCharacters else {
return nil
}
return url
}
}