feat(mobile): native page-shell bridge in orca-mobile-web-shell (OTA phase C, C0.2) (#21434)

* feat(mobile): native page↔shell bridge in orca-mobile-web-shell (OTA phase C, C0.2)

Adds one prop, one event and one view function to the shell view, off unless
asked for: with `bridgeEnabled` false nothing is registered on either platform,
so Phase B's behaviour is byte-identical.

iOS accepts a `WKScriptMessageHandler` message only from our own WebView, the
main frame, the `orca-mobile-web` scheme and the session we loaded under, and
replies through `callAsyncJavaScript` with the payload bound as a real JS value.
Android registers a `WebMessageListener` gated on a `WEB_MESSAGE_LISTENER`
feature query (Chromium 88; unsupported is `isolation-unavailable`, and only
when the bridge was asked for) and replies through the reply proxy.

Simulator-measured before any acceptance logic was written: WKFrameInfo's
securityOrigin does populate for the custom scheme, but WebKit ASCII-lowercases
the host, so `orca-mobile-web://sess-01JN_aZ9/` reports `sess-01jn_az9`. Exact
equality would refuse every message from a mixed-case session id. Folding is
ASCII-only rather than caseInsensitiveCompare, because U+212A KELVIN SIGN folds
to `k` under Unicode and would match a host nobody minted.

The 640 KiB cap is measured on the raw UTF-8 string. Inbound it is a silent,
counted refusal; outbound `postBridgeMessage` throws, because its only caller is
the host and a dropped reply is a request that never settles.

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

* fix(mobile): pick the completion-handler callAsyncJavaScript overload

The trailing closure resolved to the `async` overload, which the compiler read
as an extra trailing closure. The label is `in contentWorld:`, and naming the
completion handler is what selects the synchronous one. Restates the two
exception classes' inherited Sendable conformance, which Swift 6 warns on.

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

* fix(mobile): fold the request host ASCII-only, shared with the bridge

`resolveRequestPath` compared the request host with `caseInsensitiveCompare`,
which folds U+212A KELVIN SIGN to `k`, so a host nobody minted could match a
session id containing `k` and be served every asset. Both predicates now use
one `MobileWebShellOrigin.asciiLowercased`.

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

* fix(mobile): converge the shell load guard on applied props, not install success

The re-entry guard compared `bridgeEnabled` with `bridgeInstalled`, which is
written only where the install succeeds. With the prop true, every early return
— malformed session id, unreadable generation, a WebView with no
WEB_MESSAGE_LISTENER — left the two unequal, so the next prop commit re-entered,
reset the state machine and re-emitted loading then failed, forever.

Both platforms now record the prop triple and compare it field by field in one
pure `MobileWebShellAppliedProps.matches`, checked by swiftc and JUnit.

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

* fix(mobile): settle postBridgeMessage on delivery and bind it to the frame that spoke

postBridgeMessage resolved whatever happened: the completion handler was nil,
and `bridgeInstalled` stayed true after the renderer died and after a failed
prop update, so the host's request never settled. It also posted with `in: nil`,
which means the current main frame, while page to native binds to the applied
session.

Both ends now use the frame the last accepted message came from, checked
against the applied session id with the same ASCII fold, and the promise is
rejected when there is nowhere to post or when WebKit reports the delivery
failed. Android drops its reply proxy on the same three events for parity.

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

* fix(mobile): let the bridge delivery script throw when the page has no bridge

`if (bridge) { bridge.__deliver(m) }` made a page the installer never ran in
indistinguishable from a delivered message: the script completed, so
callAsyncJavaScript succeeded, so the host's promise resolved on a message
nobody received. Unguarded, the missing global throws and the promise rejects.

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

* test(mobile): pin the applied-props record to the fields it compares

Nothing failed if a fourth prop joined the record and no comparison mentioned
it — the prop would simply never reload. Both suites now assert the record's
stored fields by name, so adding one without deciding whether it re-enters is
red rather than silent.

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

* test(mobile): import assertEquals for the applied-props field pin

Belongs with the previous commit, which left the import behind; no amend.

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

* fix(mobile): refuse and unbind the document a prop update replaced

Two ways the previous document kept speaking for the load that replaced it. On
Android a failed prop update nulled `served` and the reply proxy but left the
web message listener installed, so a page still alive after `stopLoading` posted
through a listener bound to the origin this mount had stopped serving, and
re-armed the proxy doing it. Every disable path now goes through one removal.

On both platforms that document is same-origin whenever only the directory or
the bridge prop changed, so it passed acceptance between `stopLoading` and the
next commit and emitted after the host was told `loading`. Acceptance is now
armed at navigation commit — `didCommit` on iOS, `onPageStarted` on Android —
and disarmed by a new prop triple, a failure, and a renderer that died. The
state lives in the load-state machine and the arming clause is a field of the
pure accept predicate, so both are checked by swiftc and JUnit.

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

* fix(mobile): hold the bridge post target only for the document that armed it

`WKFrameInfo` outlives the frame it describes, so the held target has to be
cleared at the commit that re-opens arming as well as at the provisional start,
and a post in flight between the two has no document to go to.

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

* fix(mobile): publish the Android bridge state written off the main thread

`reportDocumentFailure` runs from `shouldInterceptRequest`, so the reply proxy
it drops and the commit flag it clears are written off the UI thread that reads
them. Same reason `documentFailed` and `served` already carry it.

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

* docs(mobile): say what a resolved postBridgeMessage does not prove

Android's reply proxy is void with no acknowledgement, so resolve there means
enqueued. The shared handle promised delivery, which is only ever an iOS answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-18 07:38:47 -04:00
committed by GitHub
parent 8fc81182ab
commit 3aefee4a13
16 changed files with 1055 additions and 31 deletions
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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<Map<String, Any>>()
private val onBridgeMessage by EventDispatcher<Map<String, Any>>()
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"
)
@@ -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()
}
@@ -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")))
}
}
@@ -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)
}
}
@@ -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()
@@ -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
}
}
@@ -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<Frame> {
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
}
}
@@ -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
}
@@ -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,
@@ -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<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 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))
}
@@ -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()
}
@@ -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<MobileWebShellLoadStatePayload>) => 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<MobileWebShellBridgeMessagePayload>) => 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<void>
}
/**
* 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<OrcaMobileWebShellViewProps> =
requireNativeViewManager<OrcaMobileWebShellViewProps>('OrcaMobileWebShell')
export const OrcaMobileWebShellView: ComponentType<
OrcaMobileWebShellViewProps & RefAttributes<OrcaMobileWebShellViewHandle>
> = requireNativeViewManager<
OrcaMobileWebShellViewProps & RefAttributes<OrcaMobileWebShellViewHandle>
>('OrcaMobileWebShell')
export {
MOBILE_WEB_SHELL_FAILURE_REASONS,
@@ -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<String>, committed: Bool) -> Bool {
MobileWebShellBridge.canPost(
toFrameOriginHost: target.originHost,
sessionId: session,
hasCommittedDocument: committed
)
}
var target = MobileWebShellBridgeTarget<String>()
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")
}
}