mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
* perf(mobile): answer a page's ready that arrived before its bridge host A page's ready can reach the shell before the screen has built its bridge host (the host waits on the client and the host profile read). It used to be dropped, so init waited for the page's next retry, up to 2 s later. Keep the latest ready per session and answer it once the host exists. * perf(mobile): keep one prewarmed page document while Home is shown While native Home is on screen, boot one page document for the most recently connected host with a cached build, parked invisibly in the window. Opening a page from that build adopts it instead of building a WebView, which removes the WebView start and script parse from the tap. The spare is dropped when Home is left without a claim (2 s grace), on background, memory pressure, its own failure and before the cache deletes its files. It is never kept beside a shown page, and only phones with more than 3 GiB of RAM (4 GB phones) get one. A parked page is told to stop asking for init; the adopting view answers the ready it sent. Builds on Jinwoo's spare pool and replay work in #32203 and #32204. Co-authored-by: Jinwoo-H <73622457+Jinwoo-H@users.noreply.github.com> * fix(mobile): unregister the shell's memory callbacks from the context that registered them --------- Co-authored-by: Jinwoo-H <73622457+Jinwoo-H@users.noreply.github.com>
607 lines
23 KiB
Swift
607 lines
23 KiB
Swift
import ExpoModulesCore
|
||
import WebKit
|
||
|
||
private let networkBlockIdentifier = "dev.orca.mobile-web-shell.network-block-v1"
|
||
|
||
/// Block http(s)/ws(s) beneath CSP; compilation failure is terminal and must load nothing.
|
||
private let networkBlockRules = """
|
||
[
|
||
{ "trigger": { "url-filter": "^https?://" }, "action": { "type": "block" } },
|
||
{ "trigger": { "url-filter": "^wss?://" }, "action": { "type": "block" } }
|
||
]
|
||
"""
|
||
|
||
/// Compiled once per launch and shared by every view, so later mounts load without waiting; main thread only.
|
||
private enum NetworkBlockRuleList {
|
||
private static var compiled: WKContentRuleList?
|
||
|
||
/// Synchronous once compiled in this process; nil means isolation is unavailable.
|
||
static func load(_ completion: @escaping (WKContentRuleList?) -> Void) {
|
||
if let compiled {
|
||
completion(compiled)
|
||
return
|
||
}
|
||
// Optional chaining would skip completion when the store is absent, leaving loading forever; fail closed instead.
|
||
guard let store = WKContentRuleListStore.default() else {
|
||
completion(nil)
|
||
return
|
||
}
|
||
store.compileContentRuleList(
|
||
forIdentifier: networkBlockIdentifier,
|
||
encodedContentRuleList: networkBlockRules
|
||
) { ruleList, _ in
|
||
DispatchQueue.main.async {
|
||
if let ruleList {
|
||
compiled = ruleList
|
||
}
|
||
completion(ruleList)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// CSP fences fetch/XHR; document-start script blocks unobservable WebSocket and service-worker APIs.
|
||
/// Match Android; both non-configurable and non-writable prevent page replacement.
|
||
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(_){}
|
||
})();
|
||
"""
|
||
|
||
/// Inject the same orcaBridge surface as Android before scripts, alongside the network blocker.
|
||
/// Capture the sink so deleting window.webkit cannot break it; freeze properties against replacement.
|
||
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});
|
||
})();
|
||
"""
|
||
|
||
/// Bind payload as JS value m, never script text. A missing installer must throw to reject undelivered posts.
|
||
private let bridgeDeliver = """
|
||
globalThis.orcaBridge.__deliver(m)
|
||
"""
|
||
|
||
private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler {
|
||
/// Read up-to-10-MiB assets off WebKit’s main-thread scheme callbacks.
|
||
private let readQueue = DispatchQueue(label: "dev.orca.mobile-web-shell.read")
|
||
/// Main-thread live-task set prevents stopped-task delivery’s uncatchable Objective-C exception.
|
||
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 handlers; a weak document reference prevents a React-unmount leak.
|
||
private final class MobileWebShellBridgeReceiver: NSObject, WKScriptMessageHandler {
|
||
weak var document: MobileWebShellDocument?
|
||
|
||
func userContentController(
|
||
_ controller: WKUserContentController,
|
||
didReceive message: WKScriptMessage
|
||
) {
|
||
document?.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"
|
||
}
|
||
}
|
||
|
||
/// Throw so a host chunking bug rejects rather than leaving its request unsettled.
|
||
internal final class MobileWebShellBridgeMessageTooLargeException: GenericException<Int>,
|
||
@unchecked Sendable {
|
||
override var reason: String {
|
||
"A bridge message of \(param) bytes exceeds the \(MobileWebShellBridge.maxMessageByteCount) byte cap"
|
||
}
|
||
}
|
||
|
||
/// Where a document's reports go: the view showing it, or the spare pool while it is parked.
|
||
protocol MobileWebShellDocumentSink: AnyObject {
|
||
func documentLoadState(_ payload: [String: Any])
|
||
func documentBridgeMessage(_ json: String)
|
||
func documentExternalNavigation(_ url: String)
|
||
}
|
||
|
||
/// One WKWebView and every fence on it, apart from React. The view hosts it and lays it out.
|
||
final class MobileWebShellDocument: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||
weak var sink: MobileWebShellDocumentSink?
|
||
let webView: WKWebView
|
||
|
||
private let schemeHandler = MobileWebShellSchemeHandler()
|
||
private let bridgeReceiver = MobileWebShellBridgeReceiver()
|
||
private let bridgeGate = MobileWebShellBridgeGate()
|
||
private var bridgeInstalled = false
|
||
private var bridgeTarget = MobileWebShellBridgeTarget<WKFrameInfo>()
|
||
private var applied: MobileWebShellAppliedProps?
|
||
private var appliedSessionId: String? { applied?.sessionId }
|
||
private var pendingDocumentUrl: URL?
|
||
private var isolationReady = false
|
||
private var isolationFailed = false
|
||
private var destroyed = false
|
||
private let loadState = MobileWebShellLoadStateMachine()
|
||
private var keyboardPreparationPending = false
|
||
private var windowObservers: [NSObjectProtocol] = []
|
||
|
||
override init() {
|
||
let configuration = WKWebViewConfiguration()
|
||
configuration.allowsInlineMediaPlayback = true
|
||
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||
// WebKit cannot disable DOM storage/databases; isolate with a non-persistent store, per-session origin and unmount destruction.
|
||
configuration.websiteDataStore = .nonPersistent()
|
||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||
// Disabling text interaction suppresses text after keydown; page user-select handles long-press selection.
|
||
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme)
|
||
configuration.userContentController.addUserScript(Self.makeBlockerScript())
|
||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||
super.init()
|
||
bridgeReceiver.document = self
|
||
webView.navigationDelegate = self
|
||
webView.uiDelegate = self
|
||
webView.allowsBackForwardNavigationGestures = false
|
||
// WKWebView’s opaque white default flashes during dark-page boot; transparency exposes the shell’s themed frame.
|
||
webView.isOpaque = false
|
||
webView.backgroundColor = .clear
|
||
webView.scrollView.backgroundColor = .clear
|
||
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||
hideKeyboardAccessoryBar(of: webView)
|
||
ignoreKeyboardNotifications(in: webView)
|
||
for name in [
|
||
UIWindow.didBecomeKeyNotification, UIScene.didActivateNotification,
|
||
UIApplication.didBecomeActiveNotification,
|
||
UIResponder.keyboardDidHideNotification, UITextField.textDidEndEditingNotification,
|
||
UITextView.textDidEndEditingNotification
|
||
] {
|
||
windowObservers.append(NotificationCenter.default.addObserver(
|
||
forName: name, object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
self?.loadWhenIsolated()
|
||
})
|
||
}
|
||
installNetworkBlock(into: configuration.userContentController)
|
||
}
|
||
|
||
deinit {
|
||
for observer in windowObservers {
|
||
NotificationCenter.default.removeObserver(observer)
|
||
}
|
||
}
|
||
|
||
/// Still showable: not torn down, and no terminal load failure or content-process loss.
|
||
var isUsable: Bool { !destroyed && !loadState.isTerminal }
|
||
|
||
/// The spare pool's teardown; the content process goes with the last reference.
|
||
func destroy() {
|
||
destroyed = true
|
||
sink = nil
|
||
clearBridgeTarget()
|
||
pendingDocumentUrl = nil
|
||
webView.stopLoading()
|
||
webView.removeFromSuperview()
|
||
}
|
||
|
||
/// Apply unordered props together; identical triples do not retry (remount required).
|
||
/// Include bridgeEnabled: document-start scripts require a reload to change.
|
||
func apply(generationDirectory: String, sessionId: String, bridgeEnabled: Bool) {
|
||
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(bridgeEnabled)
|
||
if isolationFailed {
|
||
failPropUpdate(.isolationUnavailable)
|
||
return
|
||
}
|
||
pendingDocumentUrl = documentUrl
|
||
loadWhenIsolated()
|
||
}
|
||
|
||
/// Retire the old visible generation even when the replacement fails.
|
||
private func failPropUpdate(_ reason: MobileWebShellFailureReason) {
|
||
clearBridgeTarget()
|
||
schemeHandler.sessionId = nil
|
||
schemeHandler.generation = nil
|
||
pendingDocumentUrl = nil
|
||
webView.stopLoading()
|
||
webView.isHidden = true
|
||
emit(loadState.failed(reason))
|
||
}
|
||
|
||
/// removeAllUserScripts is WebKit’s only removal API, so disabling the bridge must reinstall 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(_ bridgeEnabled: Bool) {
|
||
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,
|
||
// Main-frame injection is convenience; accepts must still refuse subframes that reach the handler.
|
||
forMainFrameOnly: true
|
||
)
|
||
)
|
||
} else {
|
||
controller.removeScriptMessageHandler(forName: MobileWebShellBridge.handlerName)
|
||
controller.removeAllUserScripts()
|
||
controller.addUserScript(Self.makeBlockerScript())
|
||
}
|
||
bridgeInstalled = bridgeEnabled
|
||
}
|
||
|
||
/// Use the loaded session, not latest props; the replaced same-origin document survives until commit.
|
||
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)
|
||
sink?.documentBridgeMessage(json)
|
||
}
|
||
|
||
/// Anything that ends the document the page spoke from ends the only target native has.
|
||
private func clearBridgeTarget() {
|
||
bridgeTarget.clear()
|
||
}
|
||
|
||
/// Reject posts without a live committed frame that spoke; resolve only after page execution so host requests cannot hang.
|
||
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)
|
||
}
|
||
// Use the two in: labels plus completionHandler to select the callback overload and reply to the frame that spoke.
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A frame from the shell itself, to the frame that spoke; nothing waits on its delivery.
|
||
func postHostFrame(_ json: String) {
|
||
guard let frame = bridgeTarget.frame else { return }
|
||
webView.callAsyncJavaScript(bridgeDeliver, arguments: ["m": json], in: frame, in: .page) { _ in }
|
||
}
|
||
|
||
private func installNetworkBlock(into controller: WKUserContentController) {
|
||
NetworkBlockRuleList.load { [weak self] ruleList in
|
||
guard let self else { return }
|
||
guard let ruleList else {
|
||
self.isolationFailed = true
|
||
self.pendingDocumentUrl = nil
|
||
// Rule compilation can finish after refusal; the state machine prevents a second terminal reason.
|
||
if self.appliedSessionId != nil {
|
||
self.failPropUpdate(.isolationUnavailable)
|
||
}
|
||
return
|
||
}
|
||
controller.add(ruleList)
|
||
self.isolationReady = true
|
||
self.loadWhenIsolated()
|
||
}
|
||
}
|
||
|
||
/// Load only in a foreground-active key window; the view calls this again when it moves to one.
|
||
func loadWhenIsolated() {
|
||
guard isolationReady, pendingDocumentUrl != nil, !keyboardPreparationPending,
|
||
let window = webView.window, window.isKeyWindow,
|
||
window.windowScene?.activationState == .foregroundActive else { return }
|
||
keyboardPreparationPending = true
|
||
DispatchQueue.main.async { [weak self, weak window] in
|
||
guard let self else { return }
|
||
guard let window, self.webView.window === window, window.isKeyWindow,
|
||
window.windowScene?.activationState == .foregroundActive else {
|
||
self.keyboardPreparationPending = false
|
||
self.loadWhenIsolated()
|
||
return
|
||
}
|
||
let delay: TimeInterval
|
||
switch MobileWebShellKeyboardWarmup.prepare(in: window) {
|
||
case .ready(let remainingDelay):
|
||
delay = remainingDelay
|
||
case .waitingForTextInput:
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||
guard let self else { return }
|
||
self.keyboardPreparationPending = false
|
||
self.loadWhenIsolated()
|
||
}
|
||
return
|
||
case .failed:
|
||
self.keyboardPreparationPending = false
|
||
self.failPropUpdate(.documentLoadFailed)
|
||
return
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak window] in
|
||
guard let self else { return }
|
||
self.keyboardPreparationPending = false
|
||
guard let window, self.webView.window === window, window.isKeyWindow,
|
||
window.windowScene?.activationState == .foregroundActive else {
|
||
self.loadWhenIsolated()
|
||
return
|
||
}
|
||
guard self.isolationReady, let url = self.pendingDocumentUrl else { return }
|
||
self.pendingDocumentUrl = nil
|
||
self.loadState.shellLoadStarted()
|
||
self.webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func emit(_ emission: MobileWebShellLoadEmission?) {
|
||
guard let emission else { return }
|
||
sink?.documentLoadState(emission.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: appliedSessionId ?? "") == "/"
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView,
|
||
decidePolicyFor navigationAction: WKNavigationAction,
|
||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||
) {
|
||
var isDownload = false
|
||
if #available(iOS 14.5, *) {
|
||
isDownload = navigationAction.shouldPerformDownload
|
||
}
|
||
let isMainFrame = navigationAction.targetFrame?.isMainFrame == true
|
||
// linkActivated gates only opener offers; gesture-free subframe top navigation must never authorize a load.
|
||
let verdict = MobileWebShellNavigationPolicy.verdict(
|
||
url: navigationAction.request.url?.absoluteString,
|
||
isMainFrame: isMainFrame,
|
||
isDocumentUrl: isDocumentUrl(navigationAction.request.url),
|
||
isShellLoad: loadState.isShellLoad,
|
||
hasGesture: navigationAction.navigationType == .linkActivated,
|
||
isDownload: isDownload
|
||
)
|
||
if case let .cancelAndOffer(url) = verdict {
|
||
sink?.documentExternalNavigation(url)
|
||
}
|
||
if verdict == .allow && isMainFrame {
|
||
// Spend before handing back the decision so another main-frame action cannot reuse the allowance.
|
||
loadState.shellLoadConsumed()
|
||
}
|
||
decisionHandler(verdict == .allow ? .allow : .cancel)
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView,
|
||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||
) {
|
||
// Only srcdoc subframes pass the action policy; refusing one must not fail the shell document.
|
||
guard navigationResponse.isForMainFrame else {
|
||
let url = navigationResponse.response.url?.absoluteString
|
||
decisionHandler(url == MobileWebShellNavigationPolicy.inlineDocumentUrl ? .allow : .cancel)
|
||
return
|
||
}
|
||
let allowed = navigationResponse.canShowMIMEType &&
|
||
isDocumentUrl(navigationResponse.response.url)
|
||
if !allowed {
|
||
reportDocumentFailure()
|
||
}
|
||
decisionHandler(allowed ? .allow : .cancel)
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||
// Clear replies and acceptance when the document is replaced; its successor must commit and send ready.
|
||
clearBridgeTarget()
|
||
loadState.documentEnded()
|
||
guard appliedSessionId != nil else { return }
|
||
emit(loadState.started())
|
||
}
|
||
|
||
/// Arm at commit, before document-start messages and didFinish.
|
||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||
guard isDocumentUrl(webView.url) else { return }
|
||
// Commit rearms acceptance, so clear the replaced frame here as well as at provisional start.
|
||
clearBridgeTarget()
|
||
loadState.committed()
|
||
}
|
||
|
||
/// history.replaceState changes the path before finish; finished() uses commit state instead of URL equality.
|
||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||
MobileWebShellWarmUp.release()
|
||
emit(loadState.finished())
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView,
|
||
didFailProvisionalNavigation navigation: WKNavigation!,
|
||
withError error: Error
|
||
) {
|
||
MobileWebShellWarmUp.release()
|
||
reportNavigationFailure(error)
|
||
}
|
||
|
||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||
MobileWebShellWarmUp.release()
|
||
reportNavigationFailure(error)
|
||
}
|
||
|
||
/// Report renderer death; memory pressure/provider updates look alike, so the caller owns retry policy.
|
||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||
MobileWebShellWarmUp.release()
|
||
clearBridgeTarget()
|
||
// Jetsam takes WebContent while Orca is backgrounded; the report can arrive as the app resumes, before it is active.
|
||
let background = webView.window == nil || UIApplication.shared.applicationState != .active
|
||
emit(loadState.failed(.renderProcessGone, background: background))
|
||
}
|
||
|
||
func webView(
|
||
_ webView: WKWebView,
|
||
createWebViewWith configuration: WKWebViewConfiguration,
|
||
for navigationAction: WKNavigationAction,
|
||
windowFeatures: WKWindowFeatures
|
||
) -> WKWebView? {
|
||
nil
|
||
}
|
||
}
|