mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
532 lines
21 KiB
Swift
532 lines
21 KiB
Swift
import ExpoModulesCore
|
|
import WebKit
|
|
|
|
private let networkBlockIdentifier = "dev.orca.mobile-web-shell.network-block-v1"
|
|
|
|
/// Blocks every http(s) and ws(s) load beneath CSP, at the network layer. A nil compile result is a
|
|
/// fence we could not install, which is terminal: nothing loads.
|
|
private let networkBlockRules = """
|
|
[
|
|
{ "trigger": { "url-filter": "^https?://" }, "action": { "type": "block" } },
|
|
{ "trigger": { "url-filter": "^wss?://" }, "action": { "type": "block" } }
|
|
]
|
|
"""
|
|
|
|
/// CSP is the fence for fetch and XMLHttpRequest. This script exists only for the two things a
|
|
/// native layer is never shown: a WebSocket handshake, which no request interceptor sees, and a
|
|
/// service worker registration. Kept in step with the Android copy. `configurable: false` with
|
|
/// `writable: false` is the only property shape the page cannot put back.
|
|
private let networkApiBlocker = """
|
|
(function(){
|
|
var deny=function(){throw new TypeError('Network access is disabled')};
|
|
try{Object.defineProperty(globalThis,'WebSocket',{value:deny,configurable:false,writable:false})}catch(_){}
|
|
try{Object.defineProperty(Navigator.prototype,'serviceWorker',{get:function(){return undefined},configurable:false})}catch(_){}
|
|
try{Object.defineProperty(navigator,'serviceWorker',{value:undefined,configurable:false,writable:false})}catch(_){}
|
|
})();
|
|
"""
|
|
|
|
/// Installs `window.orcaBridge`, the whole page-facing surface: `postMessage(json)` and an
|
|
/// `onmessage` assignment. Android needs no counterpart because `addWebMessageListener` injects an
|
|
/// object of the same name and shape, so the contract is the intersection of the two.
|
|
///
|
|
/// CSP is untouched and the network blocker still runs: this is a second document-start script, not
|
|
/// a replacement. The sink is captured at install time so a page that deletes `window.webkit`
|
|
/// cannot take the channel with it, and every property is non-configurable and non-writable, the
|
|
/// only shape the page cannot put back.
|
|
private let bridgeInstaller = """
|
|
(function(){
|
|
var sink=window.webkit.messageHandlers.orcaBridge;
|
|
var handler=null;
|
|
var bridge={};
|
|
Object.defineProperty(bridge,'postMessage',{value:function(json){
|
|
if(typeof json!=='string'){throw new TypeError('orcaBridge.postMessage expects a string')}
|
|
sink.postMessage(json)},configurable:false,writable:false,enumerable:true});
|
|
Object.defineProperty(bridge,'onmessage',{get:function(){return handler},
|
|
set:function(value){handler=typeof value==='function'?value:null},configurable:false,enumerable:true});
|
|
Object.defineProperty(bridge,'__deliver',{value:function(json){if(handler){handler({data:json})}},
|
|
configurable:false,writable:false,enumerable:false});
|
|
Object.defineProperty(globalThis,'orcaBridge',{value:bridge,configurable:false,writable:false,enumerable:true});
|
|
})();
|
|
"""
|
|
|
|
/// The body of a `callAsyncJavaScript` call, with the payload bound to `m` as a real JS value, so no
|
|
/// reply content is ever parsed as script text.
|
|
///
|
|
/// Unguarded on purpose: a missing global is a page the installer never ran in, and throwing is what
|
|
/// rejects the host's promise. Checking for it would resolve a message nobody received.
|
|
private let bridgeDeliver = """
|
|
globalThis.orcaBridge.__deliver(m)
|
|
"""
|
|
|
|
private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
/// An asset is up to 10 MiB, and WebKit starts and stops scheme tasks on the main thread, so the
|
|
/// read must not happen there.
|
|
private let readQueue = DispatchQueue(label: "dev.orca.mobile-web-shell.read")
|
|
/// Delivering to a task WebKit has already stopped raises an Objective-C exception Swift cannot
|
|
/// catch, so a task is only touched while it is in this set. Main thread only.
|
|
private var liveTasks: Set<ObjectIdentifier> = []
|
|
|
|
var sessionId: String?
|
|
var generation: MobileWebShellGeneration?
|
|
|
|
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
|
let key = ObjectIdentifier(urlSchemeTask)
|
|
liveTasks.insert(key)
|
|
guard
|
|
let sessionId,
|
|
let generation,
|
|
let url = urlSchemeTask.request.url,
|
|
let parts = MobileWebShellRequestParts(request: urlSchemeTask.request),
|
|
let path = MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId),
|
|
let asset = generation.entries[path]
|
|
else {
|
|
fail(urlSchemeTask, key)
|
|
return
|
|
}
|
|
readQueue.async { [weak self] in
|
|
let data = try? Data(contentsOf: asset.file)
|
|
DispatchQueue.main.async {
|
|
guard let self, self.liveTasks.contains(key) else { return }
|
|
guard
|
|
let data,
|
|
let response = Self.makeResponse(
|
|
url: url,
|
|
asset: asset,
|
|
byteCount: data.count,
|
|
path: path
|
|
)
|
|
else {
|
|
self.fail(urlSchemeTask, key)
|
|
return
|
|
}
|
|
self.liveTasks.remove(key)
|
|
urlSchemeTask.didReceive(response)
|
|
urlSchemeTask.didReceive(data)
|
|
urlSchemeTask.didFinish()
|
|
}
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
|
liveTasks.remove(ObjectIdentifier(urlSchemeTask))
|
|
}
|
|
|
|
private func fail(_ urlSchemeTask: WKURLSchemeTask, _ key: ObjectIdentifier) {
|
|
guard liveTasks.remove(key) != nil else { return }
|
|
urlSchemeTask.didFailWithError(URLError(.resourceUnavailable))
|
|
}
|
|
|
|
private static func makeResponse(
|
|
url: URL,
|
|
asset: MobileWebShellAsset,
|
|
byteCount: Int,
|
|
path: String
|
|
) -> HTTPURLResponse? {
|
|
HTTPURLResponse(
|
|
url: url,
|
|
statusCode: 200,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: MobileWebShellResponseHeaders.forPath(
|
|
path,
|
|
contentType: asset.contentType,
|
|
byteCount: byteCount
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// `WKUserContentController` retains its message handlers, so the back-reference has to be weak or
|
|
/// the view outlives the React element that owned it.
|
|
private final class MobileWebShellBridgeReceiver: NSObject, WKScriptMessageHandler {
|
|
weak var view: OrcaMobileWebShellView?
|
|
|
|
func userContentController(
|
|
_ controller: WKUserContentController,
|
|
didReceive message: WKScriptMessage
|
|
) {
|
|
view?.receiveBridgeMessage(message)
|
|
}
|
|
}
|
|
|
|
/// The RN host sees this, never the page: it is the difference between a request that failed and
|
|
/// one that never settles.
|
|
internal final class MobileWebShellBridgeDeliveryFailedException: GenericException<String>,
|
|
@unchecked Sendable {
|
|
override var reason: String {
|
|
"The mobile web shell bridge could not deliver a message: \(param)"
|
|
}
|
|
}
|
|
|
|
internal final class MobileWebShellBridgeUnavailableException: Exception, @unchecked Sendable {
|
|
override var reason: String {
|
|
"The mobile web shell bridge is not installed on this view"
|
|
}
|
|
}
|
|
|
|
/// Thrown rather than dropped: the only caller is the React Native host, and a silent drop would
|
|
/// turn a chunking bug there into a request that never settles.
|
|
internal final class MobileWebShellBridgeMessageTooLargeException: GenericException<Int>,
|
|
@unchecked Sendable {
|
|
override var reason: String {
|
|
"A bridge message of \(param) bytes exceeds the \(MobileWebShellBridge.maxMessageByteCount) byte cap"
|
|
}
|
|
}
|
|
|
|
final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate {
|
|
let onLoadState = EventDispatcher()
|
|
let onBridgeMessage = EventDispatcher()
|
|
|
|
private let schemeHandler = MobileWebShellSchemeHandler()
|
|
private let bridgeReceiver = MobileWebShellBridgeReceiver()
|
|
private let bridgeGate = MobileWebShellBridgeGate()
|
|
private var bridgeEnabled = false
|
|
private var bridgeInstalled = false
|
|
private var bridgeTarget = MobileWebShellBridgeTarget<WKFrameInfo>()
|
|
private var webView: WKWebView!
|
|
private var generationDirectory = ""
|
|
private var sessionId = ""
|
|
private var applied: MobileWebShellAppliedProps?
|
|
private var appliedSessionId: String? { applied?.sessionId }
|
|
private var pendingDocumentUrl: URL?
|
|
private var isolationReady = false
|
|
private var isolationFailed = false
|
|
private let loadState = MobileWebShellLoadStateMachine()
|
|
|
|
required init(appContext: AppContext? = nil) {
|
|
super.init(appContext: appContext)
|
|
let configuration = WKWebViewConfiguration()
|
|
// DOM storage and databases cannot be switched off on WebKit. A non-persistent store plus a
|
|
// per-session origin plus destruction on unmount is the whole mitigation, and no isolation
|
|
// claim here rests on them being absent.
|
|
configuration.websiteDataStore = .nonPersistent()
|
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
|
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme)
|
|
configuration.userContentController.addUserScript(Self.makeBlockerScript())
|
|
bridgeReceiver.view = self
|
|
webView = WKWebView(frame: bounds, configuration: configuration)
|
|
webView.navigationDelegate = self
|
|
webView.uiDelegate = self
|
|
webView.allowsBackForwardNavigationGestures = false
|
|
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(webView)
|
|
NSLayoutConstraint.activate([
|
|
webView.topAnchor.constraint(equalTo: topAnchor),
|
|
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
webView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
|
])
|
|
installNetworkBlock(into: configuration.userContentController)
|
|
}
|
|
|
|
func setGenerationDirectory(_ value: String) {
|
|
generationDirectory = value
|
|
}
|
|
|
|
func setSessionId(_ value: String) {
|
|
sessionId = value
|
|
}
|
|
|
|
func setBridgeEnabled(_ value: Bool) {
|
|
bridgeEnabled = value
|
|
}
|
|
|
|
/// Props arrive in no defined order, so neither setter starts anything; this does, once both are
|
|
/// in. A repeat of the same triple is not a retry: a retry is a remount under a new React key.
|
|
/// `bridgeEnabled` is in the record because a document-start script only takes effect at the next
|
|
/// document start: toggling it has to reload, or the prop would silently do nothing.
|
|
func propsDidUpdate() {
|
|
let next = MobileWebShellAppliedProps(
|
|
generationDirectory: generationDirectory,
|
|
sessionId: sessionId,
|
|
bridgeEnabled: bridgeEnabled
|
|
)
|
|
guard applied?.matches(next) != true else { return }
|
|
applied = next
|
|
clearBridgeTarget()
|
|
loadState.reset()
|
|
pendingDocumentUrl = nil
|
|
webView.stopLoading()
|
|
webView.isHidden = false
|
|
emit(loadState.started())
|
|
guard
|
|
MobileWebShellOrigin.isValidSessionId(sessionId),
|
|
let documentUrl = MobileWebShellOrigin.documentUrl(sessionId: sessionId)
|
|
else {
|
|
// The private origin is the isolation primitive; a malformed session id leaves us without one.
|
|
failPropUpdate(.isolationUnavailable)
|
|
return
|
|
}
|
|
guard
|
|
let generation = try? MobileWebShellGeneration.load(directoryPath: generationDirectory)
|
|
else {
|
|
failPropUpdate(.generationUnreadable)
|
|
return
|
|
}
|
|
schemeHandler.sessionId = sessionId
|
|
schemeHandler.generation = generation
|
|
applyBridgeInstallation()
|
|
if isolationFailed {
|
|
failPropUpdate(.isolationUnavailable)
|
|
return
|
|
}
|
|
pendingDocumentUrl = documentUrl
|
|
loadWhenIsolated()
|
|
}
|
|
|
|
/// The generation that failed to apply replaces whatever was on screen; leaving the previous one
|
|
/// served and visible would show a page the caller has just been told is not loaded.
|
|
private func failPropUpdate(_ reason: MobileWebShellFailureReason) {
|
|
clearBridgeTarget()
|
|
schemeHandler.sessionId = nil
|
|
schemeHandler.generation = nil
|
|
pendingDocumentUrl = nil
|
|
webView.stopLoading()
|
|
webView.isHidden = true
|
|
emit(loadState.failed(reason))
|
|
}
|
|
|
|
/// Rebuilt per install rather than stored: `removeAllUserScripts` is the only removal WebKit has,
|
|
/// so uninstalling the bridge means re-adding the blocker.
|
|
private static func makeBlockerScript() -> WKUserScript {
|
|
WKUserScript(
|
|
source: networkApiBlocker,
|
|
injectionTime: .atDocumentStart,
|
|
forMainFrameOnly: false
|
|
)
|
|
}
|
|
|
|
/// Nothing here runs while the prop stays false, which is what keeps Phase B byte-identical.
|
|
private func applyBridgeInstallation() {
|
|
guard bridgeEnabled != bridgeInstalled else { return }
|
|
clearBridgeTarget()
|
|
let controller = webView.configuration.userContentController
|
|
if bridgeEnabled {
|
|
controller.add(bridgeReceiver, name: MobileWebShellBridge.handlerName)
|
|
controller.addUserScript(
|
|
WKUserScript(
|
|
source: bridgeInstaller,
|
|
injectionTime: .atDocumentStart,
|
|
// A convenience, not the fence: a subframe can reach a handler this never ran in, and
|
|
// `accepts` is what refuses it.
|
|
forMainFrameOnly: true
|
|
)
|
|
)
|
|
} else {
|
|
controller.removeScriptMessageHandler(forName: MobileWebShellBridge.handlerName)
|
|
controller.removeAllUserScripts()
|
|
controller.addUserScript(Self.makeBlockerScript())
|
|
}
|
|
bridgeInstalled = bridgeEnabled
|
|
}
|
|
|
|
/// The session the page was loaded under, not the latest prop: a document served under the
|
|
/// previous one is still alive until the next load commits, and it must not be heard.
|
|
fileprivate func receiveBridgeMessage(_ message: WKScriptMessage) {
|
|
guard bridgeInstalled, let json = message.body as? String else { return }
|
|
let origin = message.frameInfo.securityOrigin
|
|
let source = MobileWebShellBridgeSource(
|
|
isOurWebView: message.webView === webView,
|
|
isMainFrame: message.frameInfo.isMainFrame,
|
|
hasCommittedDocument: loadState.hasCommittedDocument,
|
|
originProtocol: origin.`protocol`,
|
|
originHost: origin.host
|
|
)
|
|
guard
|
|
MobileWebShellBridge.accepts(source, sessionId: appliedSessionId ?? ""),
|
|
bridgeGate.accepts(byteCount: json.utf8.count)
|
|
else { return }
|
|
bridgeTarget.arm(frame: message.frameInfo, originHost: origin.host)
|
|
onBridgeMessage(["json": json])
|
|
}
|
|
|
|
/// Anything that ends the document the page spoke from ends the only target native has.
|
|
private func clearBridgeTarget() {
|
|
bridgeTarget.clear()
|
|
}
|
|
|
|
/// Settles on what WebKit did, not on what we handed it: a post into a dead renderer, a document
|
|
/// that failed to load, a navigation still in flight or a page that has never spoken rejects here,
|
|
/// and the delivery itself resolves only once the page has run it. Resolving any of those
|
|
/// optimistically turns a request the RN host is waiting on into one that never settles.
|
|
func postBridgeMessage(_ json: String, promise: Promise) throws {
|
|
guard
|
|
MobileWebShellBridge.canPost(
|
|
toFrameOriginHost: bridgeTarget.originHost,
|
|
sessionId: appliedSessionId ?? "",
|
|
hasCommittedDocument: loadState.hasCommittedDocument
|
|
),
|
|
let frame = bridgeTarget.frame
|
|
else {
|
|
throw MobileWebShellBridgeUnavailableException()
|
|
}
|
|
let byteCount = json.utf8.count
|
|
guard MobileWebShellBridge.acceptsByteCount(byteCount) else {
|
|
throw MobileWebShellBridgeMessageTooLargeException(byteCount)
|
|
}
|
|
// Two `in:` labels is the real signature: `in frame:` and `in contentWorld:`. Naming the
|
|
// completion handler is what picks it over the `async` overload. The frame is the one that
|
|
// spoke, so the reply goes where the request came from rather than to the current main frame.
|
|
webView.callAsyncJavaScript(
|
|
bridgeDeliver,
|
|
arguments: ["m": json],
|
|
in: frame,
|
|
in: .page
|
|
) { result in
|
|
switch result {
|
|
case .success:
|
|
promise.resolve()
|
|
case .failure(let error):
|
|
promise.reject(MobileWebShellBridgeDeliveryFailedException(error.localizedDescription))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func installNetworkBlock(into controller: WKUserContentController) {
|
|
guard let store = WKContentRuleListStore.default() else {
|
|
// Optional-chaining past this ran no completion handler at all, so the view sat at `loading`
|
|
// for the rest of its life. No store is no fence, which is the same terminal answer.
|
|
isolationFailed = true
|
|
pendingDocumentUrl = nil
|
|
return
|
|
}
|
|
store.compileContentRuleList(
|
|
forIdentifier: networkBlockIdentifier,
|
|
encodedContentRuleList: networkBlockRules
|
|
) { [weak self] ruleList, _ in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
guard let ruleList else {
|
|
self.isolationFailed = true
|
|
self.pendingDocumentUrl = nil
|
|
// Compiling is asynchronous, so this can land after the generation was already refused;
|
|
// the state machine is what keeps that from being a second terminal reason.
|
|
if self.appliedSessionId != nil {
|
|
self.failPropUpdate(.isolationUnavailable)
|
|
}
|
|
return
|
|
}
|
|
controller.add(ruleList)
|
|
self.isolationReady = true
|
|
self.loadWhenIsolated()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadWhenIsolated() {
|
|
guard isolationReady, let url = pendingDocumentUrl else { return }
|
|
pendingDocumentUrl = nil
|
|
webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData))
|
|
}
|
|
|
|
private func emit(_ emission: MobileWebShellLoadEmission?) {
|
|
guard let emission else { return }
|
|
var payload: [String: Any] = ["state": emission.state]
|
|
if let reason = emission.reason {
|
|
payload["reason"] = reason
|
|
}
|
|
onLoadState(payload)
|
|
}
|
|
|
|
private func reportDocumentFailure() {
|
|
clearBridgeTarget()
|
|
emit(loadState.failed(.documentLoadFailed))
|
|
}
|
|
|
|
/// A cancelled navigation is our own doing, not the document's; see MobileWebShellNavigationError.
|
|
private func reportNavigationFailure(_ error: Error) {
|
|
let error = error as NSError
|
|
guard !MobileWebShellNavigationError.isIgnorable(domain: error.domain, code: error.code) else {
|
|
return
|
|
}
|
|
reportDocumentFailure()
|
|
}
|
|
|
|
private func isDocumentUrl(_ url: URL?) -> Bool {
|
|
guard let url, let parts = MobileWebShellRequestParts(url: url) else { return false }
|
|
return MobileWebShellOrigin.resolveRequestPath(parts, sessionId: sessionId) == "/"
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
decidePolicyFor navigationAction: WKNavigationAction,
|
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
|
) {
|
|
if #available(iOS 14.5, *), navigationAction.shouldPerformDownload {
|
|
decisionHandler(.cancel)
|
|
return
|
|
}
|
|
let allowed = navigationAction.targetFrame?.isMainFrame == true &&
|
|
isDocumentUrl(navigationAction.request.url)
|
|
decisionHandler(allowed ? .allow : .cancel)
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
decidePolicyFor navigationResponse: WKNavigationResponse,
|
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
|
) {
|
|
let allowed = navigationResponse.isForMainFrame &&
|
|
navigationResponse.canShowMIMEType &&
|
|
isDocumentUrl(navigationResponse.response.url)
|
|
if !allowed {
|
|
reportDocumentFailure()
|
|
}
|
|
decisionHandler(allowed ? .allow : .cancel)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
|
// The document that spoke is being replaced, so it stops being somewhere to post and stops
|
|
// being someone to hear: the next one has to commit, then say `ready`, which is what the
|
|
// envelope has it do.
|
|
clearBridgeTarget()
|
|
loadState.documentEnded()
|
|
guard appliedSessionId != nil else { return }
|
|
emit(loadState.started())
|
|
}
|
|
|
|
/// The load the caller was told about is the one now on screen, so this is where the page becomes
|
|
/// something to hear. Earlier than `didFinish`, because the page speaks at document start.
|
|
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
|
guard isDocumentUrl(webView.url) else { return }
|
|
// Cleared here too, not only at the provisional start: arming is what this re-opens, so the
|
|
// frame the replaced document spoke from must not be inheritable by the one replacing it.
|
|
clearBridgeTarget()
|
|
loadState.committed()
|
|
}
|
|
|
|
/// No URL check: the page rewrites its own path before its first render, so the document that
|
|
/// committed at "/" finishes at the route it opened. `finished()` holds the rule that is left.
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
emit(loadState.finished())
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
didFailProvisionalNavigation navigation: WKNavigation!,
|
|
withError error: Error
|
|
) {
|
|
reportNavigationFailure(error)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
reportNavigationFailure(error)
|
|
}
|
|
|
|
/// Reported, never recovered from here. Renderer memory pressure and a WebView provider update
|
|
/// look identical at this point, so the retry policy is the caller's and lives in one place.
|
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
clearBridgeTarget()
|
|
emit(loadState.failed(.renderProcessGone))
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
createWebViewWith configuration: WKWebViewConfiguration,
|
|
for navigationAction: WKNavigationAction,
|
|
windowFeatures: WKWindowFeatures
|
|
) -> WKWebView? {
|
|
nil
|
|
}
|
|
}
|