diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt new file mode 100644 index 00000000000..b4c96c9053a --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedProps.kt @@ -0,0 +1,25 @@ +package expo.modules.orcamobilewebshell + +/** + * The prop triple a load was started for, and the only thing that decides whether the next prop + * commit re-enters. The same rule as the Swift copy. + * + * Recording the props rather than the outcome is what makes a failure converge. A guard that reads + * whether the bridge actually installed never agrees with a prop that is true but could not be + * honoured — a malformed session id, an unreadable generation, a WebView too old for the listener — + * so every later commit re-enters, resets the machine, and re-emits loading then failed forever. + */ +internal class MobileWebShellAppliedProps( + private val generationDirectory: String, + val sessionId: String, + private val bridgeEnabled: Boolean +) { + /** + * Field by field rather than a data class: a generated `equals` would grow with any field added + * to the record, which is how a prop nobody meant to be a reload becomes one. + */ + fun matches(other: MobileWebShellAppliedProps): Boolean = + generationDirectory == other.generationDirectory && + sessionId == other.sessionId && + bridgeEnabled == other.bridgeEnabled +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt new file mode 100644 index 00000000000..e2323044994 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellBridge.kt @@ -0,0 +1,72 @@ +package expo.modules.orcamobilewebshell + +/** + * The `WebMessageListener` name, which is also the global Chromium injects into the page. iOS + * installs a global of the same name, so one page reaches both shells. + */ +internal const val MOBILE_WEB_SHELL_BRIDGE_OBJECT = "orcaBridge" + +/** + * Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract holds + * the same ceiling; native is the one that cannot be talked out of it. + */ +internal const val MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES = 640 * 1024 + +internal fun acceptsMobileWebShellBridgeByteCount(byteCount: Int): Boolean = + byteCount <= MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES + +/** + * Chromium enforces the allowed-origin set before the listener runs, so the origin is not re-checked + * here; what is left is the frame. CSP already says `frame-src 'none'`, but the injected object + * reaches every same-origin frame, so the shell states the main-frame rule itself rather than + * inheriting it from a header a future bundle could need relaxed. + * + * The document the current props replaced is same-origin whenever only the directory or the bridge + * prop changed, and it is alive until the next one commits, so it has to be refused by when it + * spoke rather than by where it spoke from. + */ +internal fun acceptsMobileWebShellBridgeFrame( + isMainFrame: Boolean, + isStringMessage: Boolean, + hasCommittedDocument: Boolean +): Boolean = isMainFrame && isStringMessage && hasCommittedDocument + +/** + * Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that + * learns which messages were dropped learns the cap. The tally is what a test can hold the cap to. + */ +internal class MobileWebShellBridgeGate { + var refusedCount = 0 + private set + + fun accepts(byteCount: Int): Boolean { + if (!acceptsMobileWebShellBridgeByteCount(byteCount)) { + refusedCount += 1 + return false + } + return true + } +} + +/** What a prop update should do about the listener, decided before any WebView call. */ +internal enum class MobileWebShellBridgeInstall { + /** The prop is false, so nothing is registered and Phase B behaviour is byte-identical. */ + SKIP, + INSTALL, + /** The WebView provider is older than `WEB_MESSAGE_LISTENER` (Chromium 88). Terminal. */ + UNAVAILABLE +} + +/** + * The floor is asked as a feature query and never as a version string: the query is the capability. + * An unsupported provider only matters when the bridge was asked for, so the enabled check comes + * first — with the prop false the shell must load on a WebView the bridge could not run on. + */ +internal fun mobileWebShellBridgeInstall( + bridgeEnabled: Boolean, + isListenerSupported: Boolean +): MobileWebShellBridgeInstall = when { + !bridgeEnabled -> MobileWebShellBridgeInstall.SKIP + isListenerSupported -> MobileWebShellBridgeInstall.INSTALL + else -> MobileWebShellBridgeInstall.UNAVAILABLE +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt index 6255a01ccd3..7836824a87c 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellLoadState.kt @@ -22,21 +22,45 @@ internal data class MobileWebShellLoadEmission(val state: String, val reason: St * generation was already refused, so without this a `ready` or a second reason lands on top of a * failure the caller has already acted on. Consecutive duplicates are dropped as well. * - * Pure, and the same rule on both platforms, so a JVM test and a `swiftc` check can hold it. + * Pure, and the same rule on both platforms, so a JVM test and a `swiftc` check can hold it. The + * two fields a caller reads directly are volatile: Android decides a document failure from + * `shouldInterceptRequest`, which Chromium does not run on the UI thread. */ internal class MobileWebShellLoadStateMachine { private var terminal = false private var last: MobileWebShellLoadEmission? = null /** Which load this machine is reporting on. Read before deferring work, checked on delivery. */ + @Volatile var epoch: Int = 0 private set + /** + * Whether a document under the current prop triple has committed. The document a load replaces + * stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the + * directory or the bridge prop changed, so without this it passes every origin check and speaks + * for a load the caller has already been told is `loading`. + */ + @Volatile + var hasCommittedDocument = false + private set + /** A new prop pair. Nothing else reopens a terminal state: a retry is a remount. */ fun reset() { terminal = false last = null epoch += 1 + documentEnded() + } + + fun committed() { + if (terminal) return + hasCommittedDocument = true + } + + /** The committed document is gone: a new load, a failure, or a renderer that died. */ + fun documentEnded() { + hasCommittedDocument = false } fun started(): MobileWebShellLoadEmission? = emit(MobileWebShellLoadEmission("loading", null)) @@ -46,6 +70,7 @@ internal class MobileWebShellLoadStateMachine { fun failed(reason: MobileWebShellFailureReason): MobileWebShellLoadEmission? { val emission = emit(MobileWebShellLoadEmission("failed", reason.wireName)) terminal = true + documentEnded() return emission } diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt index 7dfaae4cb78..866d05cf77d 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellView.kt @@ -15,8 +15,13 @@ import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ScriptHandler +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import expo.modules.kotlin.AppContext +import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView import java.io.ByteArrayInputStream @@ -38,11 +43,19 @@ internal class OrcaMobileWebShellView( appContext: AppContext ) : ExpoView(context, appContext) { private val onLoadState by EventDispatcher>() + private val onBridgeMessage by EventDispatcher>() private var generationDirectory = "" private var sessionId = "" - private var appliedDirectory: String? = null - private var appliedSessionId: String? = null + private var bridgeEnabled = false + private var bridgeInstalled = false + private val bridgeGate = MobileWebShellBridgeGate() + // Chromium hands a reply proxy to the listener, so native cannot speak first. The envelope has + // the page send `ready` before anything is delivered, so there is nothing to speak first about. + // Volatile for the same reason as `documentFailed`: `reportDocumentFailure` drops the proxy from + // whichever thread `shouldInterceptRequest` ran on, and the listener reads it on the UI thread. + @Volatile private var replyProxy: JavaScriptReplyProxy? = null + private var applied: MobileWebShellAppliedProps? = null private val loadState = MobileWebShellLoadStateMachine() // Written on the main thread, read from onPageStarted/onPageFinished, which Chromium runs after // the failure that hid the view; `shouldInterceptRequest` also runs off the main thread. @@ -63,14 +76,18 @@ internal class OrcaMobileWebShellView( sessionId = value } + fun setBridgeEnabled(value: Boolean) { + bridgeEnabled = value + } + /** * Props arrive in no defined order, so neither setter starts anything; this does, once both are - * in. A repeat of the same pair is not a retry: a retry is a remount under a new React key. + * in. A repeat of the same triple is not a retry: a retry is a remount under a new React key. */ fun propsDidUpdate() { - if (generationDirectory == appliedDirectory && sessionId == appliedSessionId) return - appliedDirectory = generationDirectory - appliedSessionId = sessionId + val next = MobileWebShellAppliedProps(generationDirectory, sessionId, bridgeEnabled) + if (applied?.matches(next) == true) return + applied = next documentFailed = false loadState.reset() val view = webView @@ -101,6 +118,10 @@ internal class OrcaMobileWebShellView( failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE) return } + if (!applyBridgeListener(view, origin)) { + failPropUpdate(MobileWebShellFailureReason.ISOLATION_UNAVAILABLE) + return + } served = MobileWebShellServed(loaded, host) view.visibility = View.VISIBLE view.loadUrl("$origin/") @@ -111,14 +132,87 @@ internal class OrcaMobileWebShellView( * served and visible would show a page the caller has just been told is not loaded. */ private fun failPropUpdate(reason: MobileWebShellFailureReason) { + // The listener outlives the props it was installed under, and the document it was installed + // for is still alive after `stopLoading`: left in place it would keep posting through an + // origin this mount has just stopped serving, and re-arm the reply proxy doing it. + removeBridgeListener() served = null webView?.visibility = View.INVISIBLE emit(loadState.failed(reason)) } + /** + * `addWebMessageListener` is the whole install: Chromium injects an `orcaBridge` object of the + * agreed shape before any page script runs, and enforces the allowed origin itself, which is why + * the listener needs no origin check of its own. Answers false only for a provider too old to + * offer the listener at all. + */ + private fun applyBridgeListener(view: WebView, origin: String): Boolean { + removeBridgeListener() + val outcome = mobileWebShellBridgeInstall( + bridgeEnabled, + WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + ) + if (outcome != MobileWebShellBridgeInstall.INSTALL) { + return outcome == MobileWebShellBridgeInstall.SKIP + } + return runCatching { + WebViewCompat.addWebMessageListener( + view, + MOBILE_WEB_SHELL_BRIDGE_OBJECT, + setOf(origin), + bridgeListener + ) + bridgeInstalled = true + }.isSuccess + } + + /** The one way the bridge goes away, so no disable path can leave a listener behind. */ + private fun removeBridgeListener() { + val view = webView + if (bridgeInstalled && view != null) { + WebViewCompat.removeWebMessageListener(view, MOBILE_WEB_SHELL_BRIDGE_OBJECT) + } + bridgeInstalled = false + replyProxy = null + } + + /** Chromium calls this on the UI thread, which is also the only thread that may reply. */ + private val bridgeListener = WebViewCompat.WebMessageListener { + _, message, _, isMainFrame, proxy -> + val isStringMessage = message.type == WebMessageCompat.TYPE_STRING + val json = if (isStringMessage) message.data else null + if ( + acceptsMobileWebShellBridgeFrame( + isMainFrame, + isStringMessage, + loadState.hasCommittedDocument + ) && json != null && + bridgeGate.accepts(json.toByteArray(Charsets.UTF_8).size) + ) { + replyProxy = proxy + onBridgeMessage(mapOf("json" to json)) + } + } + + /** + * 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. + */ + fun postBridgeMessage(json: String) { + val proxy = replyProxy ?: throw MobileWebShellBridgeUnavailableException() + val byteCount = json.toByteArray(Charsets.UTF_8).size + if (!acceptsMobileWebShellBridgeByteCount(byteCount)) { + throw MobileWebShellBridgeMessageTooLargeException(byteCount) + } + proxy.postMessage(json) + } + /** Expo calls this once React Native is done with the view, and onRenderProcessGone calls it. */ fun destroyWebView() { val view = webView ?: return + removeBridgeListener() + loadState.documentEnded() webView = null blocker?.remove() blocker = null @@ -183,6 +277,10 @@ internal class OrcaMobileWebShellView( * thing on screen. `shouldInterceptRequest` also runs off the main thread. */ private fun reportDocumentFailure() { + replyProxy = null + // Synchronously, unlike the emission: the error document commits before the post runs, and a + // page that failed is not one to hear from in the meantime. + loadState.documentEnded() // Set before the post, not inside it: onPageFinished runs in between and would otherwise // report `ready` over the failure and make the error page visible again. documentFailed = true @@ -264,7 +362,14 @@ internal class OrcaMobileWebShellView( ) override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + // The document that spoke is being replaced, so its proxy stops being somewhere to post: the + // next one has to say `ready` first, which is what the envelope has it do. + replyProxy = null + loadState.documentEnded() if (documentFailed || !isDocumentUrl(Uri.parse(url))) return + // The load the caller was told about is the one now on screen, so this is where the page + // becomes something to hear. Chromium runs page script after this. + loadState.committed() emit(loadState.started()) } @@ -303,3 +408,11 @@ internal class OrcaMobileWebShellView( } } } + +internal class MobileWebShellBridgeUnavailableException : + CodedException("The mobile web shell bridge is not installed on this view") + +internal class MobileWebShellBridgeMessageTooLargeException(byteCount: Int) : CodedException( + "A bridge message of $byteCount bytes exceeds the " + + "$MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES byte cap" +) diff --git a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt index ecb410d23e7..042f25e9f31 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/OrcaMobileWebShellModule.kt @@ -8,7 +8,7 @@ class OrcaMobileWebShellModule : Module() { Name("OrcaMobileWebShell") View(OrcaMobileWebShellView::class) { - Events("onLoadState") + Events("onLoadState", "onBridgeMessage") Prop("generationDirectory") { view: OrcaMobileWebShellView, value: String -> view.setGenerationDirectory(value) @@ -18,6 +18,14 @@ class OrcaMobileWebShellModule : Module() { view.setSessionId(value) } + Prop("bridgeEnabled") { view: OrcaMobileWebShellView, value: Boolean -> + view.setBridgeEnabled(value) + } + + AsyncFunction("postBridgeMessage") { view: OrcaMobileWebShellView, json: String -> + view.postBridgeMessage(json) + } + OnViewDidUpdateProps { view: OrcaMobileWebShellView -> view.propsDidUpdate() } diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt new file mode 100644 index 00000000000..d9bbd327f52 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellAppliedPropsTest.kt @@ -0,0 +1,45 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MobileWebShellAppliedPropsTest { + private fun props( + generationDirectory: String = "/gen/aa", + sessionId: String = "sess-01JN_aZ9", + bridgeEnabled: Boolean = true + ) = MobileWebShellAppliedProps(generationDirectory, sessionId, bridgeEnabled) + + @Test + fun `the same triple does not re-enter`() { + assertTrue(props().matches(props())) + } + + @Test + fun `every field re-enters on its own`() { + assertFalse(props().matches(props(generationDirectory = "/gen/ab"))) + assertFalse(props().matches(props(sessionId = "sess-01JN_aZ8"))) + assertFalse(props().matches(props(bridgeEnabled = false))) + } + + @Test + fun `compares every stored field`() { + // A fourth prop that nobody compared is a prop that silently never reloads, so the record's + // shape is pinned here rather than left to whoever adds the field. + val fields = MobileWebShellAppliedProps::class.java.declaredFields + .filterNot { it.isSynthetic } + .map { it.name } + .sorted() + assertEquals(listOf("bridgeEnabled", "generationDirectory", "sessionId"), fields) + } + + @Test + fun `a triple that failed to apply is still applied`() { + // The prop pair that could not install the listener is compared like any other: the caller sees + // isolation-unavailable once, not on every commit for the life of the mount. + val failed = props(generationDirectory = "/gen/corrupt") + assertTrue(failed.matches(props(generationDirectory = "/gen/corrupt"))) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt new file mode 100644 index 00000000000..672e4ca8488 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellBridgeTest.kt @@ -0,0 +1,87 @@ +package expo.modules.orcamobilewebshell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MobileWebShellBridgeTest { + @Test + fun `caps a message at 640 KiB of raw bytes`() { + val cap = MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES + assertEquals(640 * 1024, cap) + assertTrue(acceptsMobileWebShellBridgeByteCount(0)) + assertTrue(acceptsMobileWebShellBridgeByteCount(cap - 1)) + assertTrue(acceptsMobileWebShellBridgeByteCount(cap)) + assertFalse(acceptsMobileWebShellBridgeByteCount(cap + 1)) + } + + @Test + fun `measures the cap in UTF-8 bytes, not characters`() { + // A multi-byte payload must not buy extra room; the view measures the same way. + val wide = "😀".repeat(4) + assertEquals(8, wide.length) + assertEquals(16, wide.toByteArray(Charsets.UTF_8).size) + } + + @Test + fun `counts every refusal and lets nothing under the cap through uncounted`() { + val cap = MOBILE_WEB_SHELL_BRIDGE_MAX_MESSAGE_BYTES + val gate = MobileWebShellBridgeGate() + assertEquals(0, gate.refusedCount) + assertTrue(gate.accepts(cap)) + assertEquals(0, gate.refusedCount) + assertFalse(gate.accepts(cap + 1)) + assertFalse(gate.accepts(cap * 2)) + assertEquals(2, gate.refusedCount) + } + + @Test + fun `hears only a string message from the main frame of a committed document`() { + assertTrue(frame()) + // CSP says frame-src 'none', but Chromium injects the object into every same-origin frame, so + // the shell states the rule itself rather than inheriting it from a header C0.7 has to relax. + assertFalse(frame(isMainFrame = false)) + // An ArrayBuffer message: getData() throws on one, and base64 in JSON is the only binary lane. + assertFalse(frame(isStringMessage = false)) + assertFalse(frame(isMainFrame = false, isStringMessage = false)) + // The document the current props replaced, still alive and still same-origin, speaking for a + // load the caller has already been told is `loading`. + assertFalse(frame(hasCommittedDocument = false)) + } + + private fun frame( + isMainFrame: Boolean = true, + isStringMessage: Boolean = true, + hasCommittedDocument: Boolean = true + ) = acceptsMobileWebShellBridgeFrame(isMainFrame, isStringMessage, hasCommittedDocument) + + @Test + fun `asks for the listener only when the bridge was asked for`() { + // The floor is a feature query, never a version string. With the prop false the shell must + // still load on a provider that could not have run the bridge at all. + assertEquals( + MobileWebShellBridgeInstall.SKIP, + mobileWebShellBridgeInstall(bridgeEnabled = false, isListenerSupported = false) + ) + assertEquals( + MobileWebShellBridgeInstall.SKIP, + mobileWebShellBridgeInstall(bridgeEnabled = false, isListenerSupported = true) + ) + assertEquals( + MobileWebShellBridgeInstall.INSTALL, + mobileWebShellBridgeInstall(bridgeEnabled = true, isListenerSupported = true) + ) + assertEquals( + MobileWebShellBridgeInstall.UNAVAILABLE, + mobileWebShellBridgeInstall(bridgeEnabled = true, isListenerSupported = false) + ) + } + + @Test + fun `names the injected object the same thing on both platforms`() { + // iOS installs a global of this name from its document-start script; a swap here is a page that + // reaches one shell and not the other. + assertEquals("orcaBridge", MOBILE_WEB_SHELL_BRIDGE_OBJECT) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt index 05785ad9293..71188f49657 100644 --- a/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt +++ b/mobile/modules/orca-mobile-web-shell/android/src/test/java/expo/modules/orcamobilewebshell/MobileWebShellLoadStateTest.kt @@ -1,8 +1,10 @@ package expo.modules.orcamobilewebshell import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test private fun failure(reason: String) = MobileWebShellLoadEmission("failed", reason) @@ -21,6 +23,31 @@ class MobileWebShellLoadStateTest { ) } + @Test + fun `hears a document only between its commit and the end of that load`() { + val machine = MobileWebShellLoadStateMachine() + assertFalse(machine.hasCommittedDocument) + machine.started() + // The previous document is alive and same-origin until the next one commits. + assertFalse(machine.hasCommittedDocument) + machine.committed() + assertTrue(machine.hasCommittedDocument) + + // A new prop triple: the committed document is the one being replaced. + machine.reset() + assertFalse(machine.hasCommittedDocument) + machine.committed() + machine.documentEnded() + assertFalse(machine.hasCommittedDocument) + + // A failure ends the document, and nothing after it re-arms: a retry is a remount. + machine.committed() + machine.failed(MobileWebShellFailureReason.RENDER_PROCESS_GONE) + assertFalse(machine.hasCommittedDocument) + machine.committed() + assertFalse(machine.hasCommittedDocument) + } + @Test fun `reports a load in progress and then a load that finished`() { val machine = MobileWebShellLoadStateMachine() diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift new file mode 100644 index 00000000000..8180f5cbf41 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellAppliedProps.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The prop triple a load was started for, and the only thing that decides whether the next prop +/// commit re-enters. +/// +/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc` +/// and checks it without a device or a simulator. +/// +/// Recording the props rather than the outcome is what makes a failure converge. A guard that reads +/// whether the bridge actually installed never agrees with a prop that is true but could not be +/// honoured — a malformed session id, an unreadable generation, a WebView too old for the listener +/// — so every later commit re-enters, resets the machine, and re-emits loading then failed forever. +struct MobileWebShellAppliedProps { + var generationDirectory: String + var sessionId: String + var bridgeEnabled: Bool + + /// Field by field rather than `Equatable`: a synthesized `==` would grow with any field added to + /// the record, which is how a prop nobody meant to be a reload becomes one. + func matches(_ other: MobileWebShellAppliedProps) -> Bool { + generationDirectory == other.generationDirectory + && sessionId == other.sessionId + && bridgeEnabled == other.bridgeEnabled + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift new file mode 100644 index 00000000000..61629a6cf96 --- /dev/null +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellBridge.swift @@ -0,0 +1,108 @@ +import Foundation + +/// The page ↔ native message channel: what it is called, how big a message may be, and the +/// predicate that decides whether a script message came from the document we served. +/// +/// Framework-free on purpose: `tests/MobileWebShellChecks.swift` compiles this file with `swiftc` +/// and checks it without a device or a simulator. +enum MobileWebShellBridge { + /// The `WKScriptMessageHandler` name and the global the document-start script installs. Android + /// uses the same name for its `WebMessageListener`, so one page reaches both shells. + static let handlerName = "orcaBridge" + + /// Measured on the raw JSON string in UTF-8, before anything parses it. The TypeScript contract + /// holds the same ceiling; native is the one that cannot be talked out of it. + static let maxMessageByteCount = 640 * 1024 + + /// Every clause is an allow, so a message shape nobody anticipated is refused rather than passed. + /// + /// Simulator-verified 2026-09-18: `WKFrameInfo.securityOrigin` does populate for a custom scheme, + /// but WebKit ASCII-lowercases the host, so `orca-mobile-web://sess-01JN_aZ9/` reports host + /// `sess-01jn_az9`. Session ids are base64url and mixed case, so exact equality would refuse every + /// message; the fold is `MobileWebShellOrigin.asciiLowercased`, shared with the request predicate. + static func accepts(_ source: MobileWebShellBridgeSource, sessionId: String) -> Bool { + guard + source.isOurWebView, + source.isMainFrame, + source.hasCommittedDocument, + source.originProtocol == MobileWebShellOrigin.scheme, + MobileWebShellOrigin.isValidSessionId(sessionId), + MobileWebShellOrigin.asciiLowercased(source.originHost) + == MobileWebShellOrigin.asciiLowercased(sessionId) + else { return false } + return true + } + + /// WebKit hands the handler no reply proxy, so a native → page post has to name a frame itself. + /// The frame is the one the last accepted message came from, and nil is the whole answer for a + /// page that has never spoken, a load that failed and a renderer that died: a post with nowhere + /// proven to go is refused, never delivered to whatever frame happens to be current. + /// + /// `hasCommittedDocument` is the same arming acceptance reads. Between a new provisional + /// navigation and its commit there is no document the held frame belongs to, and `WKFrameInfo` is + /// a snapshot that outlives the frame it describes, so it cannot be asked. + static func canPost( + toFrameOriginHost host: String?, + sessionId: String, + hasCommittedDocument: Bool + ) -> Bool { + guard + hasCommittedDocument, + let host, + MobileWebShellOrigin.isValidSessionId(sessionId), + MobileWebShellOrigin.asciiLowercased(host) + == MobileWebShellOrigin.asciiLowercased(sessionId) + else { return false } + return true + } + + static func acceptsByteCount(_ byteCount: Int) -> Bool { + byteCount <= maxMessageByteCount + } +} + +/// Where a native post may go: the frame of the last accepted message and the host that frame +/// reported when it spoke. One value, so the frame and the host it is checked against can never be +/// from different documents, and generic over the frame so the rule needs no WebKit type. +/// +/// Held for the document that armed it and no longer. Every boundary that ends that document clears +/// it — a new provisional navigation, the commit that replaces it, a load failure, a dead renderer, +/// a prop update — so the document now on screen has to speak before anything is posted to it. +struct MobileWebShellBridgeTarget { + private var armed: (frame: Frame, originHost: String)? + + var frame: Frame? { armed?.frame } + var originHost: String? { armed?.originHost } + + mutating func arm(frame: Frame, originHost: String) { + armed = (frame: frame, originHost: originHost) + } + + mutating func clear() { + armed = nil + } +} + +/// A script message reduced to what the predicate reads, so the predicate needs no WebKit type. +struct MobileWebShellBridgeSource { + var isOurWebView: Bool + var isMainFrame: Bool + /// Whether a document has committed under the props this message is being judged against. + var hasCommittedDocument: Bool + var originProtocol: String + var originHost: String +} + +/// Refusal is silent: the shell exposes no new state and tells the page nothing, because a page that +/// learns which messages were dropped learns the cap. The tally is what a test can hold the cap to. +final class MobileWebShellBridgeGate { + private(set) var refusedCount = 0 + + func accepts(byteCount: Int) -> Bool { + guard MobileWebShellBridge.acceptsByteCount(byteCount) else { + refusedCount += 1 + return false + } + return true + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift index 37b7233b995..200b5330c00 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellLoadState.swift @@ -23,10 +23,27 @@ final class MobileWebShellLoadStateMachine { private var isTerminal = false private var last: MobileWebShellLoadEmission? + /// Whether a document under the current prop triple has committed. The document a load replaces + /// stays alive between `stopLoading` and the next commit, and it is same-origin whenever only the + /// directory or the bridge prop changed, so without this it passes every origin check and speaks + /// for a load the caller has already been told is `loading`. + private(set) var hasCommittedDocument = false + /// A new prop pair. Nothing else reopens a terminal state: a retry is a remount. func reset() { isTerminal = false last = nil + documentEnded() + } + + func committed() { + guard !isTerminal else { return } + hasCommittedDocument = true + } + + /// The committed document is gone: a new load, a failure, or a renderer that died. + func documentEnded() { + hasCommittedDocument = false } func started() -> MobileWebShellLoadEmission? { @@ -40,6 +57,7 @@ final class MobileWebShellLoadStateMachine { func failed(_ reason: MobileWebShellFailureReason) -> MobileWebShellLoadEmission? { let emission = emit(MobileWebShellLoadEmission(state: "failed", reason: reason.rawValue)) isTerminal = true + documentEnded() return emission } diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift index 904d66bdcb8..745d757fe0f 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellOrigin.swift @@ -19,6 +19,22 @@ enum MobileWebShellOrigin { } } + /// Host comparison folds case, because a URL parser canonicalises a host and comparing against + /// the exact spelling we minted is how the reference lost every asset to a 403. ASCII-only and + /// never Unicode: U+212A KELVIN SIGN folds to `k` under `NSString.caseInsensitiveCompare`, which + /// would match a host nobody minted against a session id containing `k`. + static func asciiLowercased(_ value: String) -> String { + var scalars = String.UnicodeScalarView() + for scalar in value.unicodeScalars { + guard (65...90).contains(scalar.value), let lowered = Unicode.Scalar(scalar.value + 32) else { + scalars.append(scalar) + continue + } + scalars.append(lowered) + } + return String(scalars) + } + static func documentUrl(sessionId: String) -> URL? { guard isValidSessionId(sessionId) else { return nil } return URL(string: "\(scheme)://\(sessionId)/") @@ -35,10 +51,8 @@ enum MobileWebShellOrigin { parts.method == "GET", !parts.hasRangeHeader, parts.scheme == scheme, - // Case-insensitive: a URL parser may canonicalise a host, and comparing against the exact - // spelling we minted is how the reference lost every asset to a 403. let host = parts.host, - host.compare(sessionId, options: .caseInsensitive) == .orderedSame, + asciiLowercased(host) == asciiLowercased(sessionId), parts.port == nil, parts.user == nil, parts.query == nil, diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift index 4b3fb940b32..c6ad6891ffa 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -25,6 +25,39 @@ private let networkApiBlocker = """ })(); """ +/// 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. @@ -102,15 +135,58 @@ private final class MobileWebShellSchemeHandler: NSObject, WKURLSchemeHandler { } } +/// `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, + @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, + @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() private var webView: WKWebView! private var generationDirectory = "" private var sessionId = "" - private var appliedDirectory: String? - private var appliedSessionId: String? + private var applied: MobileWebShellAppliedProps? + private var appliedSessionId: String? { applied?.sessionId } private var pendingDocumentUrl: URL? private var isolationReady = false private var isolationFailed = false @@ -125,13 +201,8 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate configuration.websiteDataStore = .nonPersistent() configuration.preferences.javaScriptCanOpenWindowsAutomatically = false configuration.setURLSchemeHandler(schemeHandler, forURLScheme: MobileWebShellOrigin.scheme) - configuration.userContentController.addUserScript( - WKUserScript( - source: networkApiBlocker, - injectionTime: .atDocumentStart, - forMainFrameOnly: false - ) - ) + configuration.userContentController.addUserScript(Self.makeBlockerScript()) + bridgeReceiver.view = self webView = WKWebView(frame: bounds, configuration: configuration) webView.navigationDelegate = self webView.uiDelegate = self @@ -156,12 +227,23 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate 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 pair is not a retry: a retry is a remount under a new React key. + /// 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() { - guard generationDirectory != appliedDirectory || sessionId != appliedSessionId else { return } - appliedDirectory = generationDirectory - appliedSessionId = sessionId + 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() @@ -183,6 +265,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate } schemeHandler.sessionId = sessionId schemeHandler.generation = generation + applyBridgeInstallation() if isolationFailed { failPropUpdate(.isolationUnavailable) return @@ -194,6 +277,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate /// 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 @@ -202,6 +286,102 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate 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` @@ -249,6 +429,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate } private func reportDocumentFailure() { + clearBridgeTarget() emit(loadState.failed(.documentLoadFailed)) } @@ -295,10 +476,25 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate } 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()) @@ -319,6 +515,7 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate /// 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)) } diff --git a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift index 9596f54c7fa..cc1b3ef6d24 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/OrcaMobileWebShellModule.swift @@ -5,7 +5,7 @@ public class OrcaMobileWebShellModule: Module { Name("OrcaMobileWebShell") View(OrcaMobileWebShellView.self) { - Events("onLoadState") + Events("onLoadState", "onBridgeMessage") Prop("generationDirectory") { (view: OrcaMobileWebShellView, value: String) in view.setGenerationDirectory(value) @@ -15,6 +15,15 @@ public class OrcaMobileWebShellModule: Module { view.setSessionId(value) } + Prop("bridgeEnabled") { (view: OrcaMobileWebShellView, value: Bool) in + view.setBridgeEnabled(value) + } + + AsyncFunction("postBridgeMessage") { + (view: OrcaMobileWebShellView, json: String, promise: Promise) in + try view.postBridgeMessage(json, promise: promise) + } + OnViewDidUpdateProps { (view: OrcaMobileWebShellView) in view.propsDidUpdate() } diff --git a/mobile/modules/orca-mobile-web-shell/src/index.ts b/mobile/modules/orca-mobile-web-shell/src/index.ts index 1b838c55410..4b4490328ee 100644 --- a/mobile/modules/orca-mobile-web-shell/src/index.ts +++ b/mobile/modules/orca-mobile-web-shell/src/index.ts @@ -1,8 +1,11 @@ import { requireNativeViewManager } from 'expo-modules-core' -import type { ComponentType } from 'react' +import type { ComponentType, RefAttributes } from 'react' import type { NativeSyntheticEvent, ViewProps } from 'react-native' import type { MobileWebShellLoadStatePayload } from './load-state' +/** One raw JSON envelope, exactly as the page posted it. Parsing is the caller's. */ +export type MobileWebShellBridgeMessagePayload = { json: string } + export type OrcaMobileWebShellViewProps = ViewProps & { /** * Absolute path of an activated generation directory: `index.html`, `manifest.json`, and @@ -12,16 +15,49 @@ export type OrcaMobileWebShellViewProps = ViewProps & { generationDirectory: string /** `[A-Za-z0-9_-]{1,128}`. Scopes the private origin, so every mount must mint a fresh one. */ sessionId: string + /** + * Off unless asked for: with it false nothing is registered on either platform, so the view + * behaves exactly as it did before the bridge existed. On Android a provider older than + * `WEB_MESSAGE_LISTENER` (Chromium 88) reports `isolation-unavailable` rather than loading + * without a channel, and only when this is true. + */ + bridgeEnabled?: boolean onLoadState?: (event: NativeSyntheticEvent) => void + /** + * The page posted `json` through `window.orcaBridge`. Native has already refused anything from + * another origin, another frame or another WebView, and anything over the 640 KiB cap + * (`MobileWebShellBridge.maxMessageByteCount`); a refusal is silent and reaches no event. + */ + onBridgeMessage?: (event: NativeSyntheticEvent) => void +} + +/** What a ref on the view carries. Expo puts the view's functions on the component prototype. */ +export type OrcaMobileWebShellViewHandle = { + /** + * Delivers one raw JSON envelope to the page. Rejects when the message is over the cap, and when + * there is nowhere to post: no page has spoken since the last load, a navigation is in flight, + * the load failed, or the renderer is gone. The caller is the host, so a silent drop is a request + * that never settles. + * + * Delivery is never proven by resolve. iOS rejects the failures it is told about, because + * `callAsyncJavaScript` reports whether the page ran the delivery; Android cannot, because + * `JavaScriptReplyProxy.postMessage` is void and has no acknowledgement, so resolve there means + * enqueued rather than delivered. Anything that must know the page received a message has to + * hear that from the page. + */ + postBridgeMessage: (json: string) => Promise } /** - * Renders one generation directory in a WebView served from a private origin. There is no reload - * and no imperative surface: a retry is a remount under a new React key, which rebuilds the - * WebView and reinstalls every fence. + * Renders one generation directory in a WebView served from a private origin. There is no reload: + * a retry is a remount under a new React key, which rebuilds the WebView and reinstalls every + * fence. The only imperative call is `postBridgeMessage`, and it can say nothing about the load. */ -export const OrcaMobileWebShellView: ComponentType = - requireNativeViewManager('OrcaMobileWebShell') +export const OrcaMobileWebShellView: ComponentType< + OrcaMobileWebShellViewProps & RefAttributes +> = requireNativeViewManager< + OrcaMobileWebShellViewProps & RefAttributes +>('OrcaMobileWebShell') export { MOBILE_WEB_SHELL_FAILURE_REASONS, diff --git a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift index ef1193c3001..b84dc374bcf 100644 --- a/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift +++ b/mobile/modules/orca-mobile-web-shell/tests/MobileWebShellChecks.swift @@ -7,6 +7,7 @@ import Foundation // swiftc -O -o /tmp/mobile-web-shell-checks \ // ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \ // ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \ +// ios/MobileWebShellBridge.swift ios/MobileWebShellAppliedProps.swift \ // tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks @main struct MobileWebShellChecks { static let session = "sess-01JN_aZ9" @@ -94,6 +95,16 @@ import Foundation precondition(resolve(parts(path: "/", hasRangeHeader: true)) == nil) precondition(resolve(parts(path: "/", scheme: "https")) == nil) precondition(resolve(parts(path: "/", scheme: nil)) == nil) + // The same ASCII-only fold as the bridge: a Kelvin-sign host is a host nobody minted, and a + // caseInsensitiveCompare here would serve it every asset. + precondition(MobileWebShellOrigin.resolveRequestPath( + parts(path: "/", host: "\u{212A}ey"), + sessionId: "key" + ) == nil) + precondition(MobileWebShellOrigin.resolveRequestPath( + parts(path: "/", host: "KEY"), + sessionId: "key" + ) == "/") precondition(resolve(parts(path: "/", host: "other-session")) == nil) precondition(resolve(parts(path: "/", host: nil)) == nil) precondition(resolve(parts(path: "/", port: 443)) == nil) @@ -231,6 +242,29 @@ import Foundation refused.reset() precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable") + + // A document is heard only between its own commit and the end of that load. + let arming = MobileWebShellLoadStateMachine() + precondition(!arming.hasCommittedDocument) + _ = arming.started() + // The previous document is alive and same-origin until the next one commits. + precondition(!arming.hasCommittedDocument) + arming.committed() + precondition(arming.hasCommittedDocument) + + // A new prop triple: the committed document is the one being replaced. + arming.reset() + precondition(!arming.hasCommittedDocument) + arming.committed() + arming.documentEnded() + precondition(!arming.hasCommittedDocument) + + // A failure ends the document, and nothing after it re-arms: a retry is a remount. + arming.committed() + _ = arming.failed(.renderProcessGone) + precondition(!arming.hasCommittedDocument) + arming.committed() + precondition(!arming.hasCommittedDocument) } static func checkResponseHeaders() { @@ -273,6 +307,182 @@ import Foundation precondition(!ignorable("SomeOtherDomain", 102)) } + static func bridgeSource( + isOurWebView: Bool = true, + isMainFrame: Bool = true, + hasCommittedDocument: Bool = true, + originProtocol: String = MobileWebShellOrigin.scheme, + originHost: String = session + ) -> MobileWebShellBridgeSource { + MobileWebShellBridgeSource( + isOurWebView: isOurWebView, + isMainFrame: isMainFrame, + hasCommittedDocument: hasCommittedDocument, + originProtocol: originProtocol, + originHost: originHost + ) + } + + static func acceptsBridge(_ source: MobileWebShellBridgeSource) -> Bool { + MobileWebShellBridge.accepts(source, sessionId: session) + } + + static func checkAppliedProps() { + func props( + directory: String = "/gen/aa", + session: String = session, + bridge: Bool = true + ) -> MobileWebShellAppliedProps { + MobileWebShellAppliedProps( + generationDirectory: directory, + sessionId: session, + bridgeEnabled: bridge + ) + } + + precondition(props().matches(props())) + precondition(!props().matches(props(directory: "/gen/ab"))) + precondition(!props().matches(props(session: "sess-01JN_aZ8"))) + precondition(!props().matches(props(bridge: false))) + // A triple that could not be honoured is still applied: re-entry reads the props, never whether + // the install succeeded, so a corrupt generation reports its failure once rather than on every + // commit for the life of the mount. + precondition(props(directory: "/gen/corrupt").matches(props(directory: "/gen/corrupt"))) + + // A fourth prop that nobody compared is a prop that silently never reloads, so the record's + // shape is pinned here rather than left to whoever adds the field. + let fields = Mirror(reflecting: props()).children.compactMap(\.label).sorted() + precondition(fields == ["bridgeEnabled", "generationDirectory", "sessionId"]) + } + + static func checkBridgeAcceptance() { + precondition(acceptsBridge(bridgeSource())) + // Simulator-measured: WebKit reports the custom scheme's host ASCII-lowercased, so the session + // we minted never equals the host verbatim. Exact equality here refuses every message. + precondition(acceptsBridge(bridgeSource(originHost: "sess-01jn_az9"))) + precondition(acceptsBridge(bridgeSource(originHost: "SESS-01JN_AZ9"))) + + // A frame we did not serve. + precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ8"))) + precondition(!acceptsBridge(bridgeSource(originHost: ""))) + precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ9.evil"))) + // ASCII folding only: U+212A KELVIN SIGN lowercases to "k" under Unicode case folding, so a + // caseInsensitiveCompare would accept a host nobody minted. + precondition(!MobileWebShellBridge.accepts( + bridgeSource(originHost: "\u{212A}ey"), + sessionId: "key" + )) + precondition(MobileWebShellOrigin.asciiLowercased("\u{212A}EY") == "\u{212A}ey") + + // Another scheme reaching the same handler. + precondition(!acceptsBridge(bridgeSource(originProtocol: "https"))) + precondition(!acceptsBridge(bridgeSource(originProtocol: ""))) + precondition(!acceptsBridge(bridgeSource(originProtocol: "orca-mobile-web "))) + + // A subframe, and a message routed to a WebView that is not ours. + precondition(!acceptsBridge(bridgeSource(isMainFrame: false))) + precondition(!acceptsBridge(bridgeSource(isOurWebView: false))) + + // The document the current props replaced: same session, same origin, still alive between + // `stopLoading` and the next commit, speaking for a load already reported as `loading`. + precondition(!acceptsBridge(bridgeSource(hasCommittedDocument: false))) + + // No applied session is not an empty one: nothing may be accepted before a load. + precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: ""), sessionId: "")) + precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: "a b"), sessionId: "a b")) + } + + static func checkBridgePostTarget() { + func canPost( + _ host: String?, + _ sessionId: String = session, + committed: Bool = true + ) -> Bool { + MobileWebShellBridge.canPost( + toFrameOriginHost: host, + sessionId: sessionId, + hasCommittedDocument: committed + ) + } + + precondition(canPost(session)) + // The same ASCII fold as acceptance: WebKit reports the host lowercased. + precondition(canPost("sess-01jn_az9")) + + // Nowhere to post, all four for the same reason: no frame has been accepted. A page that has + // never spoken, a document whose load failed, a renderer that died, a bridge not installed. + precondition(!canPost(nil)) + + // A frame from another document, and a frame under no session at all. + precondition(!canPost("sess-01JN_aZ8")) + precondition(!canPost("\u{212A}ey", "key")) + precondition(!canPost(session, "")) + precondition(!canPost("", "")) + + // In flight: a navigation has started and not committed, so there is no document to post into + // even while a frame from the one being replaced is still held. + precondition(!canPost(session, committed: false)) + } + + /// The target across one document replacing another, in the order the navigation delegate runs: + /// a frame armed by document A is never what a post to document B goes to. + static func checkBridgeTargetLifecycle() { + func canPost(_ target: MobileWebShellBridgeTarget, committed: Bool) -> Bool { + MobileWebShellBridge.canPost( + toFrameOriginHost: target.originHost, + sessionId: session, + hasCommittedDocument: committed + ) + } + + var target = MobileWebShellBridgeTarget() + precondition(target.frame == nil && target.originHost == nil) + precondition(!canPost(target, committed: true)) + + // didCommit for document A, then A's first accepted message. + target.clear() + target.arm(frame: "frame-a", originHost: session) + precondition(target.frame == "frame-a") + precondition(canPost(target, committed: true)) + + // didStartProvisionalNavigation for document B. Refused twice over: nothing armed, and nothing + // committed to post into. + target.clear() + precondition(target.frame == nil) + precondition(!canPost(target, committed: false)) + + // didCommit for document B. Arming re-opens, so the clear has to happen here as well or A's + // frame becomes postable again as B's. + target.clear() + precondition(!canPost(target, committed: true)) + + // B speaks for itself, and that is the only way a post reaches it. + target.arm(frame: "frame-b", originHost: session) + precondition(target.frame == "frame-b") + precondition(canPost(target, committed: true)) + } + + static func checkBridgeByteCap() { + let cap = MobileWebShellBridge.maxMessageByteCount + precondition(cap == 640 * 1024) + precondition(MobileWebShellBridge.acceptsByteCount(0)) + precondition(MobileWebShellBridge.acceptsByteCount(cap - 1)) + precondition(MobileWebShellBridge.acceptsByteCount(cap)) + precondition(!MobileWebShellBridge.acceptsByteCount(cap + 1)) + + // The cap is on UTF-8 bytes, not characters: a multi-byte payload must not buy extra room. + let wide = String(repeating: "\u{1F600}", count: 4) + precondition(wide.count == 4 && wide.utf8.count == 16) + + let gate = MobileWebShellBridgeGate() + precondition(gate.refusedCount == 0) + precondition(gate.accepts(byteCount: cap)) + precondition(gate.refusedCount == 0) + precondition(!gate.accepts(byteCount: cap + 1)) + precondition(!gate.accepts(byteCount: cap * 2)) + precondition(gate.refusedCount == 2) + } + static func main() { checkSessionIds() checkRequestResolution() @@ -283,6 +493,11 @@ import Foundation checkLoadStateMachine() checkResponseHeaders() checkNavigationErrors() + checkAppliedProps() + checkBridgeAcceptance() + checkBridgePostTarget() + checkBridgeTargetLifecycle() + checkBridgeByteCap() print("mobile web shell checks OK") } }