Files
orca/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift
Jinwoo Hong edd9e3125b fix(mobile): give the page its height and its long press; C1.7 device proof (OTA phase C, C1.9) (#21589)
* fix(mobile): give the Route A document the height its mounted tree measures against (OTA phase C, C1.9)

The document this builder emits carries no stylesheet, so `html`, `body` and `#root` have no
height, and every box react-native-web lays out below the mount is `flex: 1` against a parent
that measures 0. The collapse is silent in every check that existed: the entry stamps `mounted`,
the route tree commits, `innerText` holds every row, and the accessibility tree reports each one
at the offset it would have had. Nothing is painted below the header, and nothing takes a tap —
the list sits inside a scroller the collapse clipped, and a phone reads it to VoiceOver while no
row responds. Lane C1.7 found it on both an iPhone 17 Pro simulator and a Pixel 9 Pro emulator,
and the same bytes reproduce it in headless Chromium.

The fix is the reset Expo's own web template ships for a react-native-web root, emitted inline
because the shell's CSP already allows `style-src 'unsafe-inline'` for the sheet react-native-web
injects at runtime; a linked asset would paint the collapsed layout until it landed.

The render check gains the assertion that would have caught it: the root's box measured against
the viewport, and the one control this route paints with no RPC answered — the New Workspace
button, positioned against the bottom of the root, which the collapse moved to y = -72 — asked
for by `elementFromPoint` at its own centre. Laid out is not reachable, so the check is a hit
test and not another read of the DOM. Without the reset it fails `expected +0 to be 844`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the page have the long press WebKit was taking (OTA phase C, C1.9)

The shell's WKWebView is built with the default text interaction, so WebKit installs its selection
assistant over the page. A hold on a worktree row raises the selection loupe over the row's own
text and the touch is cancelled before the page's responder sees it, which leaves every
long-press action in the page dead on iOS while a tap works. Lane C1.7 measured it: the same
injected hold opens the row action sheet on the native list and on the page in the Android
WebView, and does nothing in the page on iOS.

It is not the document's to fix, which the device disproved one rule at a time:
`-webkit-touch-callout: none`, `-webkit-user-select: none`, and both together all left the loupe
and left the hold undelivered, and headless Chromium confirms the property computes to `none` on
the page's text, so the CSS reaches it and WebKit's own gesture wins anyway.

The cost is real and named here rather than discovered later: the page has no text selection on
iOS, so selectable `Text` — markdown, diff rows, file preview, chat — cannot be selected there
until a page-side copy affordance exists. Everything the shell already forbids is unchanged, and
Android is untouched.

No unit test: the module's Swift checks compile the seven WebKit-free logic files and never import
WebKit, so a `WKWebViewConfiguration` cannot be built in them. The device proof stands in.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the root reset the template's bytes, not a copy with an addition

The comment said the reset is what Expo's web template ships, and `margin:0` was not in it.
`@expo/cli@55.0.36/static/template/index.html` carries height, `overflow` and the root's flex box
and nothing else, and react-native-web emits `body{margin:0}` in the sheet it injects at runtime,
so the addition only covered the frames before that sheet landed. Nothing pinned it either:
removing it left all 60 tests green, which is the other way of saying it was never load-bearing.

Dropping it makes the string one thing with one source instead of a copy to keep in step with two.
The pins on the rest of the reset are unchanged, and so is the frame that mattered: the root still
has a definite height before the first paint, which is what the collapse needed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* style(mobile): format the render check with the root formatter

`config/scripts` is formatted by the root oxfmt, not mobile's, and CI checks neither, so a 102-char
line I added sat over the root's `printWidth: 100` with nothing to catch it. Reflowed by
`./node_modules/.bin/oxfmt --write` from the repo root; no behaviour change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say what the root reset shares with Expo's template, not that it is its bytes

"The bytes Expo's web template ships" is false and checkable: the template's own block is
pretty-printed with comments and trailing semicolons at 410 bytes, and this string is 112. What is
actually true, and what the next reader needs, is that it carries the same declaration set and the
same `id="expo-reset"`, minified.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin every rule of the root reset, not two substrings of itself

The check read the constant back against itself: `toContain(MOBILE_WEB_APP_ROOT_RESET)` plus two
substrings taken off that same constant. A rule dropped from it took the assertion with it, so
`body{overflow:hidden}`, `flex:1` and the `expo-reset` id were unpinned — and the render check
stays green without the overflow rule, so nothing else held them either.

Each rule is now a literal written here, named one at a time so a failure says which one went, and
the id is pinned beside them. Verified red-first: removing the overflow rule, the `flex:1`, or the
id each fails this test and only this test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-19 02:28:11 -04:00

537 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
// WebKit's text interaction assistant wins the hold and raises its selection loupe, so the page
// never sees a long press and every long-press action in it is dead (lane C1.7, measured).
// Unguarded: the API is iOS 14.5+ and this target's floor is 15.1, so `#available` would be
// dead code the compiler warns on.
configuration.preferences.isTextInteractionEnabled = 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()
}
/// No URL check: the page rewrites its own path before its first render, so the document that
/// committed at "/" finishes at the route it opened. `finished()` holds the rule that is left.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
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
}
}