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

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
}
}