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
531 lines
21 KiB
Swift
531 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()
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
guard isDocumentUrl(webView.url) else { return }
|
|
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
|
|
}
|
|
}
|