mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* 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
109 lines
4.6 KiB
Swift
109 lines
4.6 KiB
Swift
import Foundation
|
|
|
|
/// The page ↔ native message channel: what it is called, how big a message may be, and the
|
|
/// predicate that decides whether a script message came from the document we served.
|
|
///
|
|
/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc`
|
|
/// and checks it without a device or a simulator.
|
|
enum MobileWebShellBridge {
|
|
/// The `WKScriptMessageHandler` name and the global the document-start script installs. Android
|
|
/// uses the same name for its `WebMessageListener`, so one page reaches both shells.
|
|
static let handlerName = "orcaBridge"
|
|
|
|
/// Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract
|
|
/// holds the same ceiling; native is the one that cannot be talked out of it.
|
|
static let maxMessageByteCount = 640 * 1024
|
|
|
|
/// Every clause is an allow, so a message shape nobody anticipated is refused rather than passed.
|
|
///
|
|
/// Simulator-verified 2026-09-18: `WKFrameInfo.securityOrigin` does populate for a custom scheme,
|
|
/// but WebKit ASCII-lowercases the host, so `orca-mobile-web://sess-01JN_aZ9/` reports host
|
|
/// `sess-01jn_az9`. Session ids are base64url and mixed case, so exact equality would refuse every
|
|
/// message; the fold is `MobileWebShellOrigin.asciiLowercased`, shared with the request predicate.
|
|
static func accepts(_ source: MobileWebShellBridgeSource, sessionId: String) -> Bool {
|
|
guard
|
|
source.isOurWebView,
|
|
source.isMainFrame,
|
|
source.hasCommittedDocument,
|
|
source.originProtocol == MobileWebShellOrigin.scheme,
|
|
MobileWebShellOrigin.isValidSessionId(sessionId),
|
|
MobileWebShellOrigin.asciiLowercased(source.originHost)
|
|
== MobileWebShellOrigin.asciiLowercased(sessionId)
|
|
else { return false }
|
|
return true
|
|
}
|
|
|
|
/// WebKit hands the handler no reply proxy, so a native → page post has to name a frame itself.
|
|
/// The frame is the one the last accepted message came from, and nil is the whole answer for a
|
|
/// page that has never spoken, a load that failed and a renderer that died: a post with nowhere
|
|
/// proven to go is refused, never delivered to whatever frame happens to be current.
|
|
///
|
|
/// `hasCommittedDocument` is the same arming acceptance reads. Between a new provisional
|
|
/// navigation and its commit there is no document the held frame belongs to, and `WKFrameInfo` is
|
|
/// a snapshot that outlives the frame it describes, so it cannot be asked.
|
|
static func canPost(
|
|
toFrameOriginHost host: String?,
|
|
sessionId: String,
|
|
hasCommittedDocument: Bool
|
|
) -> Bool {
|
|
guard
|
|
hasCommittedDocument,
|
|
let host,
|
|
MobileWebShellOrigin.isValidSessionId(sessionId),
|
|
MobileWebShellOrigin.asciiLowercased(host)
|
|
== MobileWebShellOrigin.asciiLowercased(sessionId)
|
|
else { return false }
|
|
return true
|
|
}
|
|
|
|
static func acceptsByteCount(_ byteCount: Int) -> Bool {
|
|
byteCount <= maxMessageByteCount
|
|
}
|
|
}
|
|
|
|
/// Where a native post may go: the frame of the last accepted message and the host that frame
|
|
/// reported when it spoke. One value, so the frame and the host it is checked against can never be
|
|
/// from different documents, and generic over the frame so the rule needs no WebKit type.
|
|
///
|
|
/// Held for the document that armed it and no longer. Every boundary that ends that document clears
|
|
/// it — a new provisional navigation, the commit that replaces it, a load failure, a dead renderer,
|
|
/// a prop update — so the document now on screen has to speak before anything is posted to it.
|
|
struct MobileWebShellBridgeTarget<Frame> {
|
|
private var armed: (frame: Frame, originHost: String)?
|
|
|
|
var frame: Frame? { armed?.frame }
|
|
var originHost: String? { armed?.originHost }
|
|
|
|
mutating func arm(frame: Frame, originHost: String) {
|
|
armed = (frame: frame, originHost: originHost)
|
|
}
|
|
|
|
mutating func clear() {
|
|
armed = nil
|
|
}
|
|
}
|
|
|
|
/// A script message reduced to what the predicate reads, so the predicate needs no WebKit type.
|
|
struct MobileWebShellBridgeSource {
|
|
var isOurWebView: Bool
|
|
var isMainFrame: Bool
|
|
/// Whether a document has committed under the props this message is being judged against.
|
|
var hasCommittedDocument: Bool
|
|
var originProtocol: String
|
|
var originHost: String
|
|
}
|
|
|
|
/// Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that
|
|
/// learns which messages were dropped learns the cap. The tally is what a test can hold the cap to.
|
|
final class MobileWebShellBridgeGate {
|
|
private(set) var refusedCount = 0
|
|
|
|
func accepts(byteCount: Int) -> Bool {
|
|
guard MobileWebShellBridge.acceptsByteCount(byteCount) else {
|
|
refusedCount += 1
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|