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. `isFromSubframe` is the second discriminator for the same reason: the same probe shows /// the shell's own load arriving with source and target both the main frame, and a subframe's top /// navigation arriving with the subframe as its source. /// /// 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 `` 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 `` 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, isFromSubframe: Bool, isDocumentUrl: Bool, isShellLoad: Bool, hasGesture: Bool, isDownload: Bool ) -> MobileWebShellNavigationVerdict { guard isMainFrame else { return .cancel } if isDocumentUrl { return isShellLoad && !isFromSubframe && !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 } }