Files
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

133 lines
4.3 KiB
Swift

import Foundation
/// The private origin a generation is served from, and the predicate that guards it.
///
/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc`
/// and checks it without a device or a simulator.
enum MobileWebShellOrigin {
/// A scheme WebKit has no handler for, so the origin shares no cookie jar, cache or storage with
/// anything else in the app. A custom scheme's host is opaque, so the session id is used verbatim.
static let scheme = "orca-mobile-web"
static let maxSessionIdLength = 128
static let maxUrlByteCount = 8 * 1024
static func isValidSessionId(_ sessionId: String) -> Bool {
guard !sessionId.isEmpty, sessionId.count <= maxSessionIdLength else { return false }
return sessionId.allSatisfy { character in
character.isASCII &&
(character.isLetter || character.isNumber || character == "-" || character == "_")
}
}
/// Host comparison folds case, because a URL parser canonicalises a host and comparing against
/// the exact spelling we minted is how the reference lost every asset to a 403. ASCII-only and
/// never Unicode: U+212A KELVIN SIGN folds to `k` under `NSString.caseInsensitiveCompare`, which
/// would match a host nobody minted against a session id containing `k`.
static func asciiLowercased(_ value: String) -> String {
var scalars = String.UnicodeScalarView()
for scalar in value.unicodeScalars {
guard (65...90).contains(scalar.value), let lowered = Unicode.Scalar(scalar.value + 32) else {
scalars.append(scalar)
continue
}
scalars.append(lowered)
}
return String(scalars)
}
static func documentUrl(sessionId: String) -> URL? {
guard isValidSessionId(sessionId) else { return nil }
return URL(string: "\(scheme)://\(sessionId)/")
}
/// The map key for a request we are willing to answer, or nil to refuse. Every clause is an
/// allow, so a component nobody anticipated falls to refusal rather than through it.
static func resolveRequestPath(
_ parts: MobileWebShellRequestParts,
sessionId: String
) -> String? {
guard
isValidSessionId(sessionId),
parts.method == "GET",
!parts.hasRangeHeader,
parts.scheme == scheme,
let host = parts.host,
asciiLowercased(host) == asciiLowercased(sessionId),
parts.port == nil,
parts.user == nil,
parts.query == nil,
parts.fragment == nil,
parts.urlByteCount <= maxUrlByteCount,
!parts.percentEncodedPath.contains("%")
else { return nil }
if parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/" { return "/" }
guard parts.percentEncodedPath.hasPrefix("/") else { return nil }
return parts.percentEncodedPath
}
}
/// A request reduced to the components the predicate reads, so the predicate needs no WebKit type.
struct MobileWebShellRequestParts {
var method: String
var hasRangeHeader: Bool
var scheme: String?
var host: String?
var port: Int?
var user: String?
var query: String?
var fragment: String?
var percentEncodedPath: String
var urlByteCount: Int
init(
method: String,
hasRangeHeader: Bool,
scheme: String?,
host: String?,
port: Int?,
user: String?,
query: String?,
fragment: String?,
percentEncodedPath: String,
urlByteCount: Int
) {
self.method = method
self.hasRangeHeader = hasRangeHeader
self.scheme = scheme
self.host = host
self.port = port
self.user = user
self.query = query
self.fragment = fragment
self.percentEncodedPath = percentEncodedPath
self.urlByteCount = urlByteCount
}
init?(url: URL, method: String = "GET", hasRangeHeader: Bool = false) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return nil
}
self.init(
method: method,
hasRangeHeader: hasRangeHeader,
scheme: url.scheme,
host: url.host,
port: url.port,
user: url.user,
query: url.query,
fragment: url.fragment,
percentEncodedPath: components.percentEncodedPath,
urlByteCount: url.absoluteString.utf8.count
)
}
init?(request: URLRequest) {
guard let url = request.url else { return nil }
self.init(
url: url,
method: request.httpMethod ?? "GET",
hasRangeHeader: request.value(forHTTPHeaderField: "Range") != nil
)
}
}