mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
WebKit reports no source frame for an app-initiated load() before iOS 18.5 (WebKit c28b242), so the iOS shell read its own document load as subframe-initiated and cancelled it, leaving the phone on "Opening workspace" forever. The navigation policy no longer takes the initiating frame as an input: the shell's own document loads when its one-shot load flag is up, the URL is the exact document URL, the target is the main frame, and it is not a download, matching the Android copy. Verified on an iOS 18.1 simulator (hang before, workspace opens after) and iOS 26.5 (unchanged). Refs #28626, STA-10545
84 lines
4.0 KiB
Swift
84 lines
4.0 KiB
Swift
import Foundation
|
|
|
|
/// What the shell does with one navigation: the whole decision, so the "allow it" half and the
|
|
/// "offer it to the opener" half cannot drift apart.
|
|
enum MobileWebShellNavigationVerdict: Equatable {
|
|
/// The served document loading itself, which is the only navigation this WebView performs.
|
|
case allow
|
|
/// Refused, and the host is told nothing. Every navigation was this before the preview existed.
|
|
case cancel
|
|
/// Refused, and the URL is handed to the host's opener, which decides what may open.
|
|
case cancelAndOffer(String)
|
|
}
|
|
|
|
/// The rule for a navigation the shell is deciding about.
|
|
///
|
|
/// Framework-free on purpose, like `MobileWebShellOrigin`: `tests/MobileWebShellChecks.swift`
|
|
/// compiles this file with `swiftc` and checks it without a device or a simulator. Kept in step with
|
|
/// the Kotlin copy.
|
|
enum MobileWebShellNavigationPolicy {
|
|
/// Longest URL the shell hands back to JS for a cancelled navigation.
|
|
///
|
|
/// The page's own bound is `BRIDGE_MAX_EXTERNAL_LINK_CHARS` (2048) and the filter that applies it
|
|
/// is `readBridgeExternalLinkUrl`, in TypeScript. This is not a second copy of that rule: it is a
|
|
/// cap on what crosses the native boundary at all, so an artifact cannot spend the bridge on a URL
|
|
/// the opener will refuse anyway.
|
|
static let maxCancelledNavigationUrlCharacters = 4096
|
|
|
|
/// The whole decision.
|
|
///
|
|
/// Three rules, in this order, and the order is the design.
|
|
///
|
|
/// A navigation outside the main frame is the sealed preview frame loading itself. It is refused
|
|
/// and never offered: forwarding it would let an artifact ask for a browser with no tap behind it.
|
|
///
|
|
/// **The document URL loads only when the shell asked for it.** `isShellLoad` is a flag the view
|
|
/// raises around its own `webView.load` and drops at commit; nothing else can raise it. Every
|
|
/// other navigation that names the document is refused and never offered -- offering the shell's
|
|
/// own URL to the opener would send the user out of the app instead of reloading it.
|
|
///
|
|
/// The rule deliberately does not rest on the host reporting a gesture. Measured against a real
|
|
/// WKWebView, off-device: a sandboxed subframe navigating the top frame to the document URL
|
|
/// arrives as `.other` with no gesture at all, and under a gesture-shaped rule that is an allow
|
|
/// and a shell reload -- the bridge target cleared, the load state restarted, the page's state
|
|
/// gone. The initiating frame is deliberately not an input: before iOS 18.5 WebKit reports no
|
|
/// source frame for the view's own `load` (WebKit c28b242), and reading it refused the shell's
|
|
/// own document. The one-shot flag already refuses a subframe's top navigation, as on Android.
|
|
///
|
|
/// What is left for the gesture is the only thing an artifact may ask for: a foreign URL, which
|
|
/// is refused and handed to the opener. A download is not a document load, so it takes that path
|
|
/// too, which is what makes `<a download>` behave the way it does on the native screens -- but a
|
|
/// download that still names the document takes the rule above and is refused without an offer,
|
|
/// because `<a href="/" download>` is the shell's own URL however it is dressed.
|
|
///
|
|
/// Which URLs may actually open is not decided here -- `readBridgeExternalLinkUrl` owns the scheme
|
|
/// list, in the half that ships over the air.
|
|
static func verdict(
|
|
url: String?,
|
|
isMainFrame: Bool,
|
|
isDocumentUrl: Bool,
|
|
isShellLoad: Bool,
|
|
hasGesture: Bool,
|
|
isDownload: Bool
|
|
) -> MobileWebShellNavigationVerdict {
|
|
guard isMainFrame else {
|
|
return .cancel
|
|
}
|
|
if isDocumentUrl {
|
|
return isShellLoad && !isDownload ? .allow : .cancel
|
|
}
|
|
guard hasGesture, let offered = offerableUrl(url) else {
|
|
return .cancel
|
|
}
|
|
return .cancelAndOffer(offered)
|
|
}
|
|
|
|
/// The URL a cancelled navigation may be offered under, or nil when nothing crosses.
|
|
static func offerableUrl(_ url: String?) -> String? {
|
|
guard let url, !url.isEmpty, url.count <= maxCancelledNavigationUrlCharacters else {
|
|
return nil
|
|
}
|
|
return url
|
|
}
|
|
}
|