Files
orca/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift
T
Jinwoo Hong 3aefee4a13 feat(mobile): native page-shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) (#21434)
* feat(mobile): native page↔shell bridge in orca-mobile-web-shell (OTA phase C, C0.2)

Adds one prop, one event and one view function to the shell view, off unless
asked for: with `bridgeEnabled` false nothing is registered on either platform,
so Phase B's behaviour is byte-identical.

iOS accepts a `WKScriptMessageHandler` message only from our own WebView, the
main frame, the `orca-mobile-web` scheme and the session we loaded under, and
replies through `callAsyncJavaScript` with the payload bound as a real JS value.
Android registers a `WebMessageListener` gated on a `WEB_MESSAGE_LISTENER`
feature query (Chromium 88; unsupported is `isolation-unavailable`, and only
when the bridge was asked for) and replies through the reply proxy.

Simulator-measured before any acceptance logic was written: WKFrameInfo's
securityOrigin does populate for the custom scheme, but WebKit ASCII-lowercases
the host, so `orca-mobile-web://sess-01JN_aZ9/` reports `sess-01jn_az9`. Exact
equality would refuse every message from a mixed-case session id. Folding is
ASCII-only rather than caseInsensitiveCompare, because U+212A KELVIN SIGN folds
to `k` under Unicode and would match a host nobody minted.

The 640 KiB cap is measured on the raw UTF-8 string. Inbound it is a silent,
counted refusal; outbound `postBridgeMessage` throws, because its only caller is
the host and a dropped reply is a request that never settles.

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

* fix(mobile): pick the completion-handler callAsyncJavaScript overload

The trailing closure resolved to the `async` overload, which the compiler read
as an extra trailing closure. The label is `in contentWorld:`, and naming the
completion handler is what selects the synchronous one. Restates the two
exception classes' inherited Sendable conformance, which Swift 6 warns on.

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

* fix(mobile): fold the request host ASCII-only, shared with the bridge

`resolveRequestPath` compared the request host with `caseInsensitiveCompare`,
which folds U+212A KELVIN SIGN to `k`, so a host nobody minted could match a
session id containing `k` and be served every asset. Both predicates now use
one `MobileWebShellOrigin.asciiLowercased`.

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

* fix(mobile): converge the shell load guard on applied props, not install success

The re-entry guard compared `bridgeEnabled` with `bridgeInstalled`, which is
written only where the install succeeds. With the prop true, every early return
— malformed session id, unreadable generation, a WebView with no
WEB_MESSAGE_LISTENER — left the two unequal, so the next prop commit re-entered,
reset the state machine and re-emitted loading then failed, forever.

Both platforms now record the prop triple and compare it field by field in one
pure `MobileWebShellAppliedProps.matches`, checked by swiftc and JUnit.

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

* fix(mobile): settle postBridgeMessage on delivery and bind it to the frame that spoke

postBridgeMessage resolved whatever happened: the completion handler was nil,
and `bridgeInstalled` stayed true after the renderer died and after a failed
prop update, so the host's request never settled. It also posted with `in: nil`,
which means the current main frame, while page to native binds to the applied
session.

Both ends now use the frame the last accepted message came from, checked
against the applied session id with the same ASCII fold, and the promise is
rejected when there is nowhere to post or when WebKit reports the delivery
failed. Android drops its reply proxy on the same three events for parity.

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

* fix(mobile): let the bridge delivery script throw when the page has no bridge

`if (bridge) { bridge.__deliver(m) }` made a page the installer never ran in
indistinguishable from a delivered message: the script completed, so
callAsyncJavaScript succeeded, so the host's promise resolved on a message
nobody received. Unguarded, the missing global throws and the promise rejects.

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

* test(mobile): pin the applied-props record to the fields it compares

Nothing failed if a fourth prop joined the record and no comparison mentioned
it — the prop would simply never reload. Both suites now assert the record's
stored fields by name, so adding one without deciding whether it re-enters is
red rather than silent.

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

* test(mobile): import assertEquals for the applied-props field pin

Belongs with the previous commit, which left the import behind; no amend.

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

* fix(mobile): refuse and unbind the document a prop update replaced

Two ways the previous document kept speaking for the load that replaced it. On
Android a failed prop update nulled `served` and the reply proxy but left the
web message listener installed, so a page still alive after `stopLoading` posted
through a listener bound to the origin this mount had stopped serving, and
re-armed the proxy doing it. Every disable path now goes through one removal.

On both platforms that document is same-origin whenever only the directory or
the bridge prop changed, so it passed acceptance between `stopLoading` and the
next commit and emitted after the host was told `loading`. Acceptance is now
armed at navigation commit — `didCommit` on iOS, `onPageStarted` on Android —
and disarmed by a new prop triple, a failure, and a renderer that died. The
state lives in the load-state machine and the arming clause is a field of the
pure accept predicate, so both are checked by swiftc and JUnit.

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

* fix(mobile): hold the bridge post target only for the document that armed it

`WKFrameInfo` outlives the frame it describes, so the held target has to be
cleared at the commit that re-opens arming as well as at the provisional start,
and a post in flight between the two has no document to go to.

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

* fix(mobile): publish the Android bridge state written off the main thread

`reportDocumentFailure` runs from `shouldInterceptRequest`, so the reply proxy
it drops and the commit flag it clears are written off the UI thread that reads
them. Same reason `documentFailed` and `served` already carry it.

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

* docs(mobile): say what a resolved postBridgeMessage does not prove

Android's reply proxy is void with no acknowledgement, so resolve there means
enqueued. The shared handle promised delivery, which is only ever an iOS answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 07:38:47 -04:00

91 lines
3.5 KiB
Swift

import Foundation
/// The wire names the TypeScript parser accepts; a swap here is a silent change of meaning.
enum MobileWebShellFailureReason: String {
case generationUnreadable = "generation-unreadable"
case isolationUnavailable = "isolation-unavailable"
case documentLoadFailed = "document-load-failed"
case renderProcessGone = "render-process-gone"
}
struct MobileWebShellLoadEmission: Equatable {
let state: String
let reason: String?
}
/// What a mount is still allowed to report. A failure is terminal: a rule list can fail to compile
/// long after the generation was already refused, and WebKit still reports a navigation outcome
/// after a response was cancelled, so without this a second reason or a `ready` lands on top of a
/// failure the caller has already acted on. Consecutive duplicates are dropped as well.
///
/// Pure, and the same rule as the Kotlin copy, so `swiftc` can check it without a device.
final class MobileWebShellLoadStateMachine {
private var isTerminal = false
private var last: MobileWebShellLoadEmission?
/// Whether a document under the current prop triple has committed. The document a load replaces
/// stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the
/// directory or the bridge prop changed, so without this it passes every origin check and speaks
/// for a load the caller has already been told is `loading`.
private(set) var hasCommittedDocument = false
/// A new prop pair. Nothing else reopens a terminal state: a retry is a remount.
func reset() {
isTerminal = false
last = nil
documentEnded()
}
func committed() {
guard !isTerminal else { return }
hasCommittedDocument = true
}
/// The committed document is gone: a new load, a failure, or a renderer that died.
func documentEnded() {
hasCommittedDocument = false
}
func started() -> MobileWebShellLoadEmission? {
emit(MobileWebShellLoadEmission(state: "loading", reason: nil))
}
func finished() -> MobileWebShellLoadEmission? {
emit(MobileWebShellLoadEmission(state: "ready", reason: nil))
}
func failed(_ reason: MobileWebShellFailureReason) -> MobileWebShellLoadEmission? {
let emission = emit(MobileWebShellLoadEmission(state: "failed", reason: reason.rawValue))
isTerminal = true
documentEnded()
return emission
}
private func emit(_ emission: MobileWebShellLoadEmission) -> MobileWebShellLoadEmission? {
guard !isTerminal, emission != last else { return nil }
last = emission
return emission
}
}
/// A navigation WebKit reports as failed but which is not a failure of the document.
///
/// `stopLoading` on a prop update, and every navigation the policy delegate refuses, arrive at the
/// failure delegates as errors. Reporting those would fail a healthy page, swallow its `ready`, and
/// send the caller off to delete a cached generation that is fine.
///
/// The WebKit constant is written out because the iOS SDK exports no symbol for it: `WKErrorCode`
/// stops at the content-rule-list and app-bound-domain errors, and the frame-load codes live in the
/// legacy `WebKitErrorDomain`, which WKWebView still reports a policy-cancelled frame load under.
enum MobileWebShellNavigationError {
static let webKitDomain = "WebKitErrorDomain"
static let frameLoadInterruptedByPolicyChange = 102
static func isIgnorable(domain: String, code: Int) -> Bool {
if domain == NSURLErrorDomain, code == NSURLErrorCancelled {
return true
}
return domain == webKitDomain && code == frameLoadInterruptedByPolicyChange
}
}