Files
orca/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.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

504 lines
23 KiB
Swift

import Foundation
// Everything the shell decides before WebKit is involved: the session id it will accept, the
// requests it will answer, the map it builds from a manifest, and the policy header. Compiled and
// run without a device:
//
// swiftc -O -o /tmp/mobile-web-shell-checks \
// ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \
// ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \
// ios/MobileWebShellBridge.swift ios/MobileWebShellAppliedProps.swift \
// tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks
@main struct MobileWebShellChecks {
static let session = "sess-01JN_aZ9"
static func parts(
path: String,
method: String = "GET",
hasRangeHeader: Bool = false,
scheme: String? = MobileWebShellOrigin.scheme,
host: String? = session,
port: Int? = nil,
user: String? = nil,
query: String? = nil,
fragment: String? = nil,
urlByteCount: Int = 64
) -> MobileWebShellRequestParts {
MobileWebShellRequestParts(
method: method,
hasRangeHeader: hasRangeHeader,
scheme: scheme,
host: host,
port: port,
user: user,
query: query,
fragment: fragment,
percentEncodedPath: path,
urlByteCount: urlByteCount
)
}
static func resolve(_ request: MobileWebShellRequestParts) -> String? {
MobileWebShellOrigin.resolveRequestPath(request, sessionId: session)
}
static func manifest(
schemaVersion: Int = 1,
entrypoint: String = "index.html",
assets: [[String: Any]] = [
["path": "index.html", "contentType": "text/html; charset=utf-8"],
["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"],
["path": "assets/bb.png", "contentType": "image/png"]
]
) -> Data {
let root: [String: Any] = [
"schemaVersion": schemaVersion,
"entrypoint": entrypoint,
"assets": assets
]
return try! JSONSerialization.data(withJSONObject: root)
}
static func generation(_ data: Data) -> MobileWebShellGeneration? {
try? MobileWebShellGeneration.make(
manifestData: data,
directory: URL(fileURLWithPath: "/tmp/generation", isDirectory: true)
)
}
static func checkSessionIds() {
precondition(MobileWebShellOrigin.isValidSessionId("aZ0-_"))
precondition(MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 128)))
precondition(!MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 129)))
precondition(!MobileWebShellOrigin.isValidSessionId(""))
precondition(!MobileWebShellOrigin.isValidSessionId("has space"))
precondition(!MobileWebShellOrigin.isValidSessionId("dots.are.hosts.too"))
precondition(!MobileWebShellOrigin.isValidSessionId("sl/ash"))
// Non-ASCII letters and digits satisfy Character.isLetter/isNumber, so the ASCII gate is load
// bearing: an IDNA-mapped host would not be the origin we minted.
precondition(!MobileWebShellOrigin.isValidSessionId("sessioñ"))
precondition(!MobileWebShellOrigin.isValidSessionId("session٣"))
precondition(MobileWebShellOrigin.documentUrl(sessionId: session)?.absoluteString ==
"orca-mobile-web://\(session)/")
precondition(MobileWebShellOrigin.documentUrl(sessionId: "bad host") == nil)
}
static func checkRequestResolution() {
precondition(resolve(parts(path: "/")) == "/")
precondition(resolve(parts(path: "")) == "/")
precondition(resolve(parts(path: "/assets/aa.js")) == "/assets/aa.js")
// A host a parser canonicalised must still bind to this session.
precondition(resolve(parts(path: "/", host: session.uppercased())) == "/")
precondition(resolve(parts(path: "/", method: "POST")) == nil)
precondition(resolve(parts(path: "/", method: "HEAD")) == nil)
precondition(resolve(parts(path: "/", hasRangeHeader: true)) == nil)
precondition(resolve(parts(path: "/", scheme: "https")) == nil)
precondition(resolve(parts(path: "/", scheme: nil)) == nil)
// The same ASCII-only fold as the bridge: a Kelvin-sign host is a host nobody minted, and a
// caseInsensitiveCompare here would serve it every asset.
precondition(MobileWebShellOrigin.resolveRequestPath(
parts(path: "/", host: "\u{212A}ey"),
sessionId: "key"
) == nil)
precondition(MobileWebShellOrigin.resolveRequestPath(
parts(path: "/", host: "KEY"),
sessionId: "key"
) == "/")
precondition(resolve(parts(path: "/", host: "other-session")) == nil)
precondition(resolve(parts(path: "/", host: nil)) == nil)
precondition(resolve(parts(path: "/", port: 443)) == nil)
precondition(resolve(parts(path: "/", user: "someone")) == nil)
precondition(resolve(parts(path: "/", query: "v=1")) == nil)
precondition(resolve(parts(path: "/", fragment: "frag")) == nil)
precondition(resolve(parts(path: "/assets/%2e%2e/etc")) == nil)
precondition(resolve(parts(path: "assets/aa.js")) == nil)
precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024)) == "/")
precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024 + 1)) == nil)
precondition(MobileWebShellOrigin.resolveRequestPath(parts(path: "/"), sessionId: "") == nil)
}
static func checkAssetPaths() {
precondition(MobileWebShellGeneration.isServableAssetPath("index.html"))
precondition(MobileWebShellGeneration.isServableAssetPath("assets/a-b_c.2.js"))
precondition(!MobileWebShellGeneration.isServableAssetPath(""))
precondition(!MobileWebShellGeneration.isServableAssetPath("/leading"))
precondition(!MobileWebShellGeneration.isServableAssetPath("trailing/"))
precondition(!MobileWebShellGeneration.isServableAssetPath("a//b"))
precondition(!MobileWebShellGeneration.isServableAssetPath("../secret"))
precondition(!MobileWebShellGeneration.isServableAssetPath("assets/../../secret"))
precondition(!MobileWebShellGeneration.isServableAssetPath("assets/./a.js"))
precondition(!MobileWebShellGeneration.isServableAssetPath("back\\slash"))
precondition(!MobileWebShellGeneration.isServableAssetPath("has space.js"))
precondition(MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 255)))
precondition(!MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 256)))
}
static func checkContentTypes() {
precondition(MobileWebShellGeneration.isServableContentType("image/png"))
precondition(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8"))
precondition(MobileWebShellGeneration.isServableContentType("application/manifest+json"))
precondition(!MobileWebShellGeneration.isServableContentType(""))
precondition(!MobileWebShellGeneration.isServableContentType("text/html"
+ "\r\nX-Injected: 1"))
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8; x=1"))
precondition(!MobileWebShellGeneration.isServableContentType("TEXT/HTML"))
// A header value we did not mint character for character is a value we did not check.
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=UTF-8"))
precondition(!MobileWebShellGeneration.isServableContentType("text"))
precondition(!MobileWebShellGeneration.isServableContentType("text/html/extra"))
precondition(!MobileWebShellGeneration.isServableContentType("/html"))
precondition(!MobileWebShellGeneration.isServableContentType("-text/html"))
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset="))
precondition(!MobileWebShellGeneration.isServableContentType(
String(repeating: "a", count: 130) + "/b"))
}
static func checkGenerationMap() {
guard let built = generation(manifest()) else { preconditionFailure("manifest rejected") }
precondition(built.entries.count == 4)
precondition(built.entries["/"]?.file.path == "/tmp/generation/index.html")
precondition(built.entries["/"]?.contentType == "text/html; charset=utf-8")
// Only "/" reaches the document: a second URL for the same bytes would answer without the CSP
// header, which rides the document response alone.
precondition(built.entries["/index.html"] == nil)
precondition(built.entries["/assets/aa.js"]?.contentType == "text/javascript; charset=utf-8")
precondition(built.entries["/assets/bb.png"]?.file.path == "/tmp/generation/assets/bb.png")
precondition(built.entries["/manifest.json"]?.contentType == "application/json")
precondition(built.entries["/assets/cc.js"] == nil)
precondition(built.entries["/../secret"] == nil)
precondition(generation(manifest(schemaVersion: 2)) == nil)
precondition(generation(manifest(entrypoint: "start.html")) == nil)
precondition(generation(manifest(assets: [])) == nil)
// The entrypoint must be one of the assets, or "/" would map to a file nobody declared.
precondition(generation(manifest(assets: [
["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"]
])) == nil)
precondition(generation(manifest(assets: [
["path": "index.html", "contentType": "text/html; charset=utf-8"],
["path": "../escape.js", "contentType": "text/javascript; charset=utf-8"]
])) == nil)
precondition(generation(manifest(assets: [
["path": "index.html", "contentType": "text/html; charset=utf-8"],
["path": "assets/aa.js", "contentType": "text/javascript\r\nX-Injected: 1"]
])) == nil)
precondition(generation(manifest(assets: [
["path": "index.html", "contentType": "text/html; charset=utf-8"],
["path": 7, "contentType": "text/javascript; charset=utf-8"]
])) == nil)
let tooMany = (0..<257).map { index in
["path": "assets/a\(index).js", "contentType": "text/javascript; charset=utf-8"]
}
precondition(generation(manifest(assets: tooMany)) == nil)
// A JSON string is not a JSON number, and true and 1.0 are not the integer 1, though NSNumber
// bridges all three to something `as? Int` accepts.
precondition(generation(Data(#"{"schemaVersion":true,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
precondition(generation(Data(#"{"schemaVersion":1.0,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
precondition(generation(Data(#"{"schemaVersion":1,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) != nil)
precondition(generation(Data(#"{"schemaVersion":"1","entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
precondition(generation(Data("not json".utf8)) == nil)
precondition(generation(Data("[]".utf8)) == nil)
}
static func checkCsp() {
let header = MobileWebShellCsp.header
let directives = header.components(separatedBy: "; ")
precondition(directives.contains("default-src 'none'"))
precondition(directives.contains("script-src 'self'"))
precondition(directives.contains("connect-src 'self'"))
precondition(directives.contains("worker-src 'none'"))
precondition(directives.contains("frame-src 'none'"))
precondition(directives.contains("base-uri 'none'"))
precondition(directives.contains("form-action 'none'"))
precondition(directives.contains("frame-ancestors 'none'"))
// An inline script or an eval would make the no-inline-script build rule unenforced.
precondition(!header.contains("unsafe-inline"))
precondition(!header.contains("unsafe-eval"))
precondition(!header.contains("data:"))
precondition(!header.contains("blob:"))
precondition(!header.contains("\r") && !header.contains("\n"))
}
static func checkLoadStateMachine() {
precondition(MobileWebShellFailureReason.generationUnreadable.rawValue == "generation-unreadable")
precondition(MobileWebShellFailureReason.isolationUnavailable.rawValue == "isolation-unavailable")
precondition(MobileWebShellFailureReason.documentLoadFailed.rawValue == "document-load-failed")
precondition(MobileWebShellFailureReason.renderProcessGone.rawValue == "render-process-gone")
let progress = MobileWebShellLoadStateMachine()
precondition(progress.started()?.state == "loading")
precondition(progress.started() == nil)
precondition(progress.finished()?.state == "ready")
precondition(progress.finished() == nil)
// A rule list compiles asynchronously, so it can fail after the generation was already refused.
let refused = MobileWebShellLoadStateMachine()
precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable")
precondition(refused.failed(.isolationUnavailable) == nil)
precondition(refused.failed(.renderProcessGone) == nil)
precondition(refused.finished() == nil)
precondition(refused.started() == nil)
refused.reset()
precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable")
// A document is heard only between its own commit and the end of that load.
let arming = MobileWebShellLoadStateMachine()
precondition(!arming.hasCommittedDocument)
_ = arming.started()
// The previous document is alive and same-origin until the next one commits.
precondition(!arming.hasCommittedDocument)
arming.committed()
precondition(arming.hasCommittedDocument)
// A new prop triple: the committed document is the one being replaced.
arming.reset()
precondition(!arming.hasCommittedDocument)
arming.committed()
arming.documentEnded()
precondition(!arming.hasCommittedDocument)
// A failure ends the document, and nothing after it re-arms: a retry is a remount.
arming.committed()
_ = arming.failed(.renderProcessGone)
precondition(!arming.hasCommittedDocument)
arming.committed()
precondition(!arming.hasCommittedDocument)
}
static func checkResponseHeaders() {
let document = MobileWebShellResponseHeaders.forPath(
"/",
contentType: "text/html; charset=utf-8",
byteCount: 12
)
precondition(document["Content-Security-Policy"] == MobileWebShellCsp.header)
precondition(document["Content-Type"] == "text/html; charset=utf-8")
precondition(document["Content-Length"] == "12")
precondition(document["Cache-Control"] == "no-store")
precondition(document["X-Content-Type-Options"] == "nosniff")
// The policy rides the document alone; on a subresource response it is inert.
for path in ["/index.html", "/assets/aa.js", "/manifest.json", "/assets/bb.png"] {
let headers = MobileWebShellResponseHeaders.forPath(
path,
contentType: "text/javascript; charset=utf-8",
byteCount: 0
)
precondition(headers["Content-Security-Policy"] == nil)
precondition(headers["Cache-Control"] == "no-store")
precondition(headers["X-Content-Type-Options"] == "nosniff")
}
}
static func checkNavigationErrors() {
let ignorable = MobileWebShellNavigationError.isIgnorable
// Our own stopLoading on a prop update, and every navigation the policy delegate refuses.
precondition(ignorable(NSURLErrorDomain, NSURLErrorCancelled))
precondition(ignorable("WebKitErrorDomain", 102))
// Anything else is the document failing to load, which is the caller's cue to redownload.
precondition(!ignorable(NSURLErrorDomain, NSURLErrorNetworkConnectionLost))
precondition(!ignorable(NSURLErrorDomain, NSURLErrorResourceUnavailable))
precondition(!ignorable("WebKitErrorDomain", 101))
precondition(!ignorable("WebKitErrorDomain", NSURLErrorCancelled))
// WKErrorDomain has no frame-load codes at all, so 102 there is some other error.
precondition(!ignorable("WKErrorDomain", 102))
precondition(!ignorable("SomeOtherDomain", 102))
}
static func bridgeSource(
isOurWebView: Bool = true,
isMainFrame: Bool = true,
hasCommittedDocument: Bool = true,
originProtocol: String = MobileWebShellOrigin.scheme,
originHost: String = session
) -> MobileWebShellBridgeSource {
MobileWebShellBridgeSource(
isOurWebView: isOurWebView,
isMainFrame: isMainFrame,
hasCommittedDocument: hasCommittedDocument,
originProtocol: originProtocol,
originHost: originHost
)
}
static func acceptsBridge(_ source: MobileWebShellBridgeSource) -> Bool {
MobileWebShellBridge.accepts(source, sessionId: session)
}
static func checkAppliedProps() {
func props(
directory: String = "/gen/aa",
session: String = session,
bridge: Bool = true
) -> MobileWebShellAppliedProps {
MobileWebShellAppliedProps(
generationDirectory: directory,
sessionId: session,
bridgeEnabled: bridge
)
}
precondition(props().matches(props()))
precondition(!props().matches(props(directory: "/gen/ab")))
precondition(!props().matches(props(session: "sess-01JN_aZ8")))
precondition(!props().matches(props(bridge: false)))
// A triple that could not be honoured is still applied: re-entry reads the props, never whether
// the install succeeded, so a corrupt generation reports its failure once rather than on every
// commit for the life of the mount.
precondition(props(directory: "/gen/corrupt").matches(props(directory: "/gen/corrupt")))
// A fourth prop that nobody compared is a prop that silently never reloads, so the record's
// shape is pinned here rather than left to whoever adds the field.
let fields = Mirror(reflecting: props()).children.compactMap(\.label).sorted()
precondition(fields == ["bridgeEnabled", "generationDirectory", "sessionId"])
}
static func checkBridgeAcceptance() {
precondition(acceptsBridge(bridgeSource()))
// Simulator-measured: WebKit reports the custom scheme's host ASCII-lowercased, so the session
// we minted never equals the host verbatim. Exact equality here refuses every message.
precondition(acceptsBridge(bridgeSource(originHost: "sess-01jn_az9")))
precondition(acceptsBridge(bridgeSource(originHost: "SESS-01JN_AZ9")))
// A frame we did not serve.
precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ8")))
precondition(!acceptsBridge(bridgeSource(originHost: "")))
precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ9.evil")))
// ASCII folding only: U+212A KELVIN SIGN lowercases to "k" under Unicode case folding, so a
// caseInsensitiveCompare would accept a host nobody minted.
precondition(!MobileWebShellBridge.accepts(
bridgeSource(originHost: "\u{212A}ey"),
sessionId: "key"
))
precondition(MobileWebShellOrigin.asciiLowercased("\u{212A}EY") == "\u{212A}ey")
// Another scheme reaching the same handler.
precondition(!acceptsBridge(bridgeSource(originProtocol: "https")))
precondition(!acceptsBridge(bridgeSource(originProtocol: "")))
precondition(!acceptsBridge(bridgeSource(originProtocol: "orca-mobile-web ")))
// A subframe, and a message routed to a WebView that is not ours.
precondition(!acceptsBridge(bridgeSource(isMainFrame: false)))
precondition(!acceptsBridge(bridgeSource(isOurWebView: false)))
// The document the current props replaced: same session, same origin, still alive between
// `stopLoading` and the next commit, speaking for a load already reported as `loading`.
precondition(!acceptsBridge(bridgeSource(hasCommittedDocument: false)))
// No applied session is not an empty one: nothing may be accepted before a load.
precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: ""), sessionId: ""))
precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: "a b"), sessionId: "a b"))
}
static func checkBridgePostTarget() {
func canPost(
_ host: String?,
_ sessionId: String = session,
committed: Bool = true
) -> Bool {
MobileWebShellBridge.canPost(
toFrameOriginHost: host,
sessionId: sessionId,
hasCommittedDocument: committed
)
}
precondition(canPost(session))
// The same ASCII fold as acceptance: WebKit reports the host lowercased.
precondition(canPost("sess-01jn_az9"))
// Nowhere to post, all four for the same reason: no frame has been accepted. A page that has
// never spoken, a document whose load failed, a renderer that died, a bridge not installed.
precondition(!canPost(nil))
// A frame from another document, and a frame under no session at all.
precondition(!canPost("sess-01JN_aZ8"))
precondition(!canPost("\u{212A}ey", "key"))
precondition(!canPost(session, ""))
precondition(!canPost("", ""))
// In flight: a navigation has started and not committed, so there is no document to post into
// even while a frame from the one being replaced is still held.
precondition(!canPost(session, committed: false))
}
/// The target across one document replacing another, in the order the navigation delegate runs:
/// a frame armed by document A is never what a post to document B goes to.
static func checkBridgeTargetLifecycle() {
func canPost(_ target: MobileWebShellBridgeTarget<String>, committed: Bool) -> Bool {
MobileWebShellBridge.canPost(
toFrameOriginHost: target.originHost,
sessionId: session,
hasCommittedDocument: committed
)
}
var target = MobileWebShellBridgeTarget<String>()
precondition(target.frame == nil && target.originHost == nil)
precondition(!canPost(target, committed: true))
// didCommit for document A, then A's first accepted message.
target.clear()
target.arm(frame: "frame-a", originHost: session)
precondition(target.frame == "frame-a")
precondition(canPost(target, committed: true))
// didStartProvisionalNavigation for document B. Refused twice over: nothing armed, and nothing
// committed to post into.
target.clear()
precondition(target.frame == nil)
precondition(!canPost(target, committed: false))
// didCommit for document B. Arming re-opens, so the clear has to happen here as well or A's
// frame becomes postable again as B's.
target.clear()
precondition(!canPost(target, committed: true))
// B speaks for itself, and that is the only way a post reaches it.
target.arm(frame: "frame-b", originHost: session)
precondition(target.frame == "frame-b")
precondition(canPost(target, committed: true))
}
static func checkBridgeByteCap() {
let cap = MobileWebShellBridge.maxMessageByteCount
precondition(cap == 640 * 1024)
precondition(MobileWebShellBridge.acceptsByteCount(0))
precondition(MobileWebShellBridge.acceptsByteCount(cap - 1))
precondition(MobileWebShellBridge.acceptsByteCount(cap))
precondition(!MobileWebShellBridge.acceptsByteCount(cap + 1))
// The cap is on UTF-8 bytes, not characters: a multi-byte payload must not buy extra room.
let wide = String(repeating: "\u{1F600}", count: 4)
precondition(wide.count == 4 && wide.utf8.count == 16)
let gate = MobileWebShellBridgeGate()
precondition(gate.refusedCount == 0)
precondition(gate.accepts(byteCount: cap))
precondition(gate.refusedCount == 0)
precondition(!gate.accepts(byteCount: cap + 1))
precondition(!gate.accepts(byteCount: cap * 2))
precondition(gate.refusedCount == 2)
}
static func main() {
checkSessionIds()
checkRequestResolution()
checkAssetPaths()
checkContentTypes()
checkGenerationMap()
checkCsp()
checkLoadStateMachine()
checkResponseHeaders()
checkNavigationErrors()
checkAppliedProps()
checkBridgeAcceptance()
checkBridgePostTarget()
checkBridgeTargetLifecycle()
checkBridgeByteCap()
print("mobile web shell checks OK")
}
}