diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 1c2731d61a7..95fc6e9714b 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -152,6 +152,17 @@ external-link authority. - Top-level navigation is restricted to the active document. External navigation, popups, downloads, workers, and arbitrary bridge origins fail closed. +- The Android session id *is* the private origin's host label, so it is drawn + from a canonical hostname alphabet (lowercase base32). `https` is a special + scheme: Chromium ASCII-lowercases the host of every URL it loads and reports + back, and `java.net.URI.getHost()` is null for a label holding `_`. A + base64url id therefore lost every asset request to a 403 and dropped every + bridge message (`MobileWebOrigin.kt`, `MobileWebSessionIdentifier.kt`). iOS + uses a custom scheme, whose opaque host preserves both, which is why the iOS + lane never saw it. +- A failed main-frame document does not stop at Chromium's error page: the + Android shell hides the WebView and reports `onLoadState` `failed` with a + reason code the React Native shell shows instead. ### Capability bridge diff --git a/mobile/app/hybrid.tsx b/mobile/app/hybrid.tsx index 3e0a348cf66..b11505646be 100644 --- a/mobile/app/hybrid.tsx +++ b/mobile/app/hybrid.tsx @@ -18,6 +18,7 @@ import { MobileWebHealthDeadline } from '../src/mobile-web/mobile-web-health-dea import { useMobileWebAlertSafePackageSession } from '../src/mobile-web/use-mobile-web-alert-safe-package-session' import { createMobileWebNativeCapabilityAuthority } from '../src/mobile-web/mobile-web-native-capability-authority' import { MobileWebHybridShellPresentation } from '../src/mobile-web/MobileWebHybridShellPresentation' +import { mobileWebShellLoadFailureWarning } from '../src/mobile-web/mobile-web-shell-load-failure-warning' import { useMobileWebNavigationIntentHandoff } from '../src/mobile-web/use-mobile-web-navigation-intent-handoff' import { useMobileWebColdResumeRoute } from '../src/mobile-web/use-mobile-web-cold-resume-route' import { mobileWebBridgeConnectionState } from '../src/mobile-web/mobile-web-bridge-connection-state' @@ -384,6 +385,10 @@ export default function HybridScreen() { hardwareBackHandoff.resetPage() void postInit() }} + onLoadFailed={(reason) => { + healthDeadlineRef.current.clear() + showWarning(mobileWebShellLoadFailureWarning(reason)) + }} onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')} onProcessTerminated={(sessionId) => { hardwareBackHandoff.resetPage() diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt index be7b1a3ede2..8c8be144e0d 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt @@ -9,7 +9,7 @@ internal fun isAllowedMobileWebBridgeDocumentUrl(value: String, sessionId: Strin val url = URI(value) value.length <= MOBILE_WEB_DOCUMENT_URL_LIMIT && url.scheme == MOBILE_WEB_ORIGIN_SCHEME && - url.host == mobileWebOriginHostForSession(sessionId) && + isMobileWebOriginHostForSession(url.host, sessionId) && url.port == -1 && url.userInfo == null && url.fragment == sessionId diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt index 9d457f4c10a..2ea64639024 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt @@ -3,13 +3,26 @@ package expo.modules.mobilewebshell import android.net.Uri private const val MOBILE_WEB_ORIGIN_SUFFIX = ".orca-mobile-web.invalid" +private const val MOBILE_WEB_ORIGIN_LABEL_LIMIT = 32 -/** Derives a per-session origin so WebView cookies/storage cannot cross hosts. */ +/** + * Derives a per-session origin so WebView cookies/storage cannot cross hosts. + * + * The id's prefix *is* the host label, so it must already be a canonical hostname label: `https` is + * a special scheme, so Chromium ASCII-lowercases the host of every URL it loads and reports back, + * and `java.net.URI.getHost()` returns null for a label holding anything outside `[a-z0-9-]`. + * Rejecting a non-canonical id here fails the session open loudly instead of leaving every asset + * request to 403 behind Chromium's own error page. + */ internal fun mobileWebOriginForSession(sessionId: String): String { - require(sessionId.isNotEmpty() && sessionId.length <= 128 && sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { + require( + sessionId.isNotEmpty() && + sessionId.length <= 128 && + sessionId.all { it in 'a'..'z' || it in '0'..'9' } + ) { "mobile_web_session_id_invalid" } - return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(32)}$MOBILE_WEB_ORIGIN_SUFFIX" + return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(MOBILE_WEB_ORIGIN_LABEL_LIMIT)}$MOBILE_WEB_ORIGIN_SUFFIX" } internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mobileWebOriginForSession(sessionId)) @@ -18,8 +31,12 @@ internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mo internal fun mobileWebOriginHostForSession(sessionId: String): String = mobileWebOriginForSession(sessionId).substringAfter("://") +/** Hosts are case-insensitive, so a non-canonical parser must still bind to the active session. */ +internal fun isMobileWebOriginHostForSession(host: String?, sessionId: String): Boolean = + host != null && host.equals(mobileWebOriginHostForSession(sessionId), ignoreCase = true) + internal fun isMobileWebOriginForSession(url: Uri, sessionId: String): Boolean = url.scheme == MOBILE_WEB_ORIGIN_SCHEME && - url.host == mobileWebOriginUriForSession(sessionId).host && + isMobileWebOriginHostForSession(url.host, sessionId) && url.port == -1 && url.userInfo == null diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt index abf26135866..0caec2db1a8 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt @@ -9,7 +9,6 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.security.MessageDigest -import java.security.SecureRandom private const val CHUNK_BYTE_LIMIT = 48 * 1024 private const val CHUNK_BASE64_CHARACTER_LIMIT = ((CHUNK_BYTE_LIMIT + 2) / 3) * 4 @@ -676,14 +675,7 @@ internal class MobileWebPackageStore internal constructor( "url" to "${mobileWebOriginForSession(sessionId)}/#$sessionId" ) - private fun randomIdentifier(): String { - val bytes = ByteArray(32) - SecureRandom().nextBytes(bytes) - return encodeBase64(bytes) - .replace('+', '-') - .replace('/', '_') - .trimEnd('=') - } + private fun randomIdentifier(): String = mobileWebRandomIdentifier() private fun requireStage(stageId: String): MobileWebStageRecord = stages[stageId] ?: throw IllegalArgumentException("mobile_web_stage_unknown") diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebSessionIdentifier.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebSessionIdentifier.kt new file mode 100644 index 00000000000..e4ac28dffe8 --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebSessionIdentifier.kt @@ -0,0 +1,36 @@ +package expo.modules.mobilewebshell + +import java.security.SecureRandom + +private const val MOBILE_WEB_IDENTIFIER_BYTE_LENGTH = 32 +private const val MOBILE_WEB_IDENTIFIER_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" + +/** + * Session ids become the WebView origin's host label, so they are drawn from a canonical hostname + * alphabet: base64url's `_` makes `URI.getHost()` null and its uppercase letters do not survive + * Chromium's host canonicalization. + */ +internal fun mobileWebRandomIdentifier(random: SecureRandom = SecureRandom()): String { + val bytes = ByteArray(MOBILE_WEB_IDENTIFIER_BYTE_LENGTH) + random.nextBytes(bytes) + return encodeMobileWebIdentifierAlphabet(bytes) +} + +/** RFC 4648 base32 over a lowercase alphabet, without padding. */ +private fun encodeMobileWebIdentifierAlphabet(bytes: ByteArray): String { + val encoded = StringBuilder() + var buffer = 0 + var bufferedBits = 0 + for (byte in bytes) { + buffer = (buffer shl 8) or (byte.toInt() and 0xff) + bufferedBits += 8 + while (bufferedBits >= 5) { + bufferedBits -= 5 + encoded.append(MOBILE_WEB_IDENTIFIER_ALPHABET[(buffer shr bufferedBits) and 0x1f]) + } + } + if (bufferedBits > 0) { + encoded.append(MOBILE_WEB_IDENTIFIER_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f]) + } + return encoded.toString() +} diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt index 06b1d6f3033..f4e2f6b9162 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt @@ -263,7 +263,14 @@ internal class MobileWebShellView( private inner class LockedWebViewClient : WebViewClient() { override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse { - return runCatching { serveRequest(request) }.getOrElse { blockedResponse() } + return runCatching { serveRequest(request) }.getOrElse { error -> + if (request.isForMainFrame) { + // Chromium turns a non-2xx main-frame response into its own error page, so the shell has + // to name the reason itself before that page replaces the document. + reportDocumentFailure(error.message ?: "mobile_web_document_unavailable") + } + blockedResponse() + } } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { @@ -280,6 +287,7 @@ internal class MobileWebShellView( override fun onPageFinished(view: WebView, url: String) { if (isAllowedDocumentUrl(Uri.parse(url))) { documentLoaded = true + view.visibility = View.VISIBLE view.clearHistory() onLoadState(mapOf("state" to "loaded")) } @@ -291,8 +299,17 @@ internal class MobileWebShellView( error: android.webkit.WebResourceError ) { if (request.isForMainFrame && isAllowedDocumentRequestUrl(request.url)) { - documentLoaded = false - onLoadState(mapOf("state" to "failed")) + reportDocumentFailure("mobile_web_document_load_error_${error.errorCode}") + } + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse + ) { + if (request.isForMainFrame && isAllowedDocumentRequestUrl(request.url)) { + reportDocumentFailure("mobile_web_document_http_${errorResponse.statusCode}") } } @@ -323,6 +340,15 @@ internal class MobileWebShellView( } } + /** Hides the Chromium error page and hands the RN shell a reason it can show instead. */ + private fun reportDocumentFailure(reason: String) { + post { + documentLoaded = false + webView.visibility = View.INVISIBLE + onLoadState(mapOf("state" to "failed", "reason" to reason.take(128))) + } + } + private fun serveRequest(request: WebResourceRequest): WebResourceResponse { val url = request.url val sessionId = activeSessionId diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt index 0c72c08dcf4..046e3e566d8 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt @@ -5,7 +5,7 @@ import org.junit.Assert.assertTrue import org.junit.Test class MobileWebBridgeDocumentUrlTest { - private val sessionId = "S".repeat(43) + private val sessionId = "s5i5ifxeticyxgmyvxhtvzktwwbo5o7h5uzaapc0w" private val origin = mobileWebOriginForSession(sessionId) @Test @@ -39,7 +39,7 @@ class MobileWebBridgeDocumentUrlTest { "$origin:443/#$sessionId", "https://orca-mobile-web.invalid.evil.test/#$sessionId", "$origin/", - "${mobileWebOriginForSession("T".repeat(43))}/#$sessionId", + "${mobileWebOriginForSession("t".repeat(43))}/#$sessionId", "$origin/${"a".repeat(8 * 1024)}#$sessionId", "not a url" ) diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebOriginTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebOriginTest.kt new file mode 100644 index 00000000000..36f2ab61e72 --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebOriginTest.kt @@ -0,0 +1,64 @@ +package expo.modules.mobilewebshell + +import java.net.URI +import java.security.SecureRandom +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The session id's prefix is the private origin's host label. Chromium ASCII-lowercases the host of + * every `https` URL it loads and reports back, and `java.net.URI.getHost()` is null for a label + * holding `_`, so a base64url id silently loses the document (403) and the bridge (dropped + * messages). + */ +class MobileWebOriginTest { + @Test + fun `generated session ids survive host parsing unchanged`() { + val random = SecureRandom.getInstance("SHA1PRNG").apply { setSeed(ByteArray(16)) } + + repeat(64) { + val sessionId = mobileWebRandomIdentifier(random) + val host = mobileWebOriginHostForSession(sessionId) + + assertEquals(host, host.lowercase()) + assertEquals(host, URI(mobileWebOriginForSession(sessionId)).host) + assertTrue(sessionId, isMobileWebOriginHostForSession(URI("https://$host/").host, sessionId)) + assertTrue( + sessionId, + isAllowedMobileWebBridgeDocumentUrl( + "${mobileWebOriginForSession(sessionId)}/#$sessionId", + sessionId + ) + ) + } + } + + @Test + fun `rejects session ids a URL host cannot carry`() { + val rejected = listOf( + "47Im-Tb2rq2Y5jz235dEGMcvyQSk5p17H5UZaAPc", + "47im-tb2_rq2y5jz235degmcvyqsk5p17h5uzaapc", + "47im-tb2-rq2y5jz235degmcvyqsk5p17h5uzaapc", + "" + ) + + for (sessionId in rejected) { + assertEquals( + sessionId, + "mobile_web_session_id_invalid", + runCatching { mobileWebOriginForSession(sessionId) }.exceptionOrNull()?.message + ) + } + } + + @Test + fun `keeps distinct sessions on distinct origins`() { + val sessionId = mobileWebRandomIdentifier() + val other = mobileWebRandomIdentifier() + + assertFalse(mobileWebOriginForSession(sessionId) == mobileWebOriginForSession(other)) + assertFalse(isMobileWebOriginHostForSession(mobileWebOriginHostForSession(other), sessionId)) + } +} diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreGeneratedMutationTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreGeneratedMutationTest.kt index 3b70f1f6bd2..7d98d72186d 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreGeneratedMutationTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreGeneratedMutationTest.kt @@ -37,7 +37,7 @@ class MobileWebPackageStoreGeneratedMutationTest { } val stageId = store.beginStage("generated-host", fixture.manifest, fixture.canonical) - assertTrue(stageId.matches(Regex("^[A-Za-z0-9_-]{43}$"))) + assertTrue(stageId.matches(Regex("^[a-z2-7]{52}$"))) val encoded = Base64.getEncoder().encodeToString(fixture.bytes) repeat(256) { iteration -> val characters = encoded.toCharArray() diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt index 741b7ab6850..8d14cf064b8 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt @@ -31,8 +31,9 @@ class MobileWebPackageStoreTest { val asset = store.readAsset(sessionId, "index.html") assertEquals(fixture.buildId, session["buildId"]) - assertEquals(43, sessionId.length) - assertEquals(true, Regex("[A-Za-z0-9_-]{43}").matches(sessionId)) + assertEquals(52, sessionId.length) + assertEquals(true, Regex("[a-z2-7]{52}").matches(sessionId)) + assertEquals("${mobileWebOriginForSession(sessionId)}/#$sessionId", session["url"]) assertEquals("text/html; charset=utf-8", asset.contentType) assertArrayEquals(fixture.bytes, asset.bytes) val error = assertThrows(IllegalArgumentException::class.java) { diff --git a/mobile/packages/expo-mobile-web-shell/src/ExpoMobileWebShellView.ts b/mobile/packages/expo-mobile-web-shell/src/ExpoMobileWebShellView.ts index ba792af87cb..6b2c01f0bf5 100644 --- a/mobile/packages/expo-mobile-web-shell/src/ExpoMobileWebShellView.ts +++ b/mobile/packages/expo-mobile-web-shell/src/ExpoMobileWebShellView.ts @@ -24,7 +24,7 @@ export type MobileWebShellViewProps = ViewProps & { onBridgeMessage?: MobileWebShellEvent<{ data: string }> onNavigationBlocked?: MobileWebShellEvent<{ url: string }> onProcessTerminated?: MobileWebShellEvent<{ sessionId: string }> - onLoadState?: MobileWebShellEvent<{ state: 'loading' | 'loaded' | 'failed' }> + onLoadState?: MobileWebShellEvent<{ state: 'loading' | 'loaded' | 'failed'; reason?: string }> } const NativeMobileWebShellView: ComponentType< diff --git a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx index 183ad149ad5..3bfb34ae213 100644 --- a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx +++ b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx @@ -35,6 +35,7 @@ type MobileWebHybridShellPresentationProps = { onRecoveryFailure: () => void onBridgeMessage: (message: string) => void onPageLoaded: () => void + onLoadFailed: (reason: string | undefined) => void onNavigationBlocked: () => void onProcessTerminated: (sessionId: string) => void } @@ -56,6 +57,7 @@ export function MobileWebHybridShellPresentation({ onRecoveryFailure, onBridgeMessage, onPageLoaded, + onLoadFailed, onNavigationBlocked, onProcessTerminated }: MobileWebHybridShellPresentationProps) { @@ -127,6 +129,10 @@ export function MobileWebHybridShellPresentation({ onLoadState={(event) => { if (event.nativeEvent.state === 'loaded') { onPageLoaded() + return + } + if (event.nativeEvent.state === 'failed') { + onLoadFailed(event.nativeEvent.reason) } }} onNavigationBlocked={onNavigationBlocked} diff --git a/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts b/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts index 4358fc73fbc..059eb60baed 100644 --- a/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-transport-source.test.ts @@ -71,7 +71,7 @@ describe('mobile web native bridge transport', () => { '!isAllowedMobileWebBridgeDocumentUrl(documentUrl.toString(), sessionId)' ) expect(androidBridgeUrlSource).toContain('url.scheme == MOBILE_WEB_ORIGIN_SCHEME') - expect(androidBridgeUrlSource).toContain('url.host == mobileWebOriginHostForSession(sessionId)') + expect(androidBridgeUrlSource).toContain('isMobileWebOriginHostForSession(url.host, sessionId)') expect(androidBridgeUrlSource).toContain('url.fragment == sessionId') expect(androidBridgeUrlSource).toContain('url.userInfo == null') expect(androidSource).toContain('request.isForMainFrame && isAllowedDocumentUrl(url)') diff --git a/mobile/src/mobile-web/mobile-web-shell-load-failure-warning.ts b/mobile/src/mobile-web/mobile-web-shell-load-failure-warning.ts new file mode 100644 index 00000000000..0de8bd8b235 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-shell-load-failure-warning.ts @@ -0,0 +1,10 @@ +/** Turns a native shell document-load failure code into text the hosted shell can show. */ +export function mobileWebShellLoadFailureWarning(reason: string | undefined): string { + if (reason === 'mobile_web_generation_invalid') { + return 'This desktop’s cached workspace interface failed verification and was not displayed.' + } + if (reason && reason !== 'mobile_web_document_unavailable') { + return `The workspace interface could not be displayed (${reason}).` + } + return 'The workspace interface could not be displayed.' +} diff --git a/mobile/src/mobile-web/mobile-web-shell-load-failure.test.tsx b/mobile/src/mobile-web/mobile-web-shell-load-failure.test.tsx new file mode 100644 index 00000000000..eff6dfdd8c1 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-shell-load-failure.test.tsx @@ -0,0 +1,90 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileWebHybridShellPresentation } from './MobileWebHybridShellPresentation' +import { mobileWebShellLoadFailureWarning } from './mobile-web-shell-load-failure-warning' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + StyleSheet: { create: (styles: Record) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronLeft: 'ChevronLeft', + MonitorSmartphone: 'MonitorSmartphone' +})) + +vi.mock('@orca/expo-mobile-web-shell', () => ({ + MobileWebShellView: 'MobileWebShellView' +})) + +const noop = () => {} + +describe('hosted shell document load failures', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('hands the native failure reason to the shell instead of leaving the error page unexplained', () => { + const failures: (string | undefined)[] = [] + const loaded = vi.fn() + + act(() => { + renderer = create( + createElement(MobileWebHybridShellPresentation, { + viewRef: { current: null }, + selectedHost: { id: 'host-1', name: 'Desk', publicKeyB64: 'k' } as never, + session: { sessionId: 'abc', buildId: 'build-1' } as never, + viewEpoch: 0, + packageLoading: false, + packageProgress: undefined, + packageWarning: undefined, + hostedViewActive: true, + onBack: noop, + onShowHosts: noop, + onRetryRecovery: noop, + onUsePrevious: noop, + onClearCache: noop, + onRecoveryFailure: noop, + onBridgeMessage: noop, + onPageLoaded: loaded, + onLoadFailed: (reason) => failures.push(reason), + onNavigationBlocked: noop, + onProcessTerminated: noop + }) + ) + }) + + const shell = renderer!.root.findByType('MobileWebShellView' as never) + act(() => { + shell.props.onLoadState({ + nativeEvent: { state: 'failed', reason: 'mobile_web_document_http_403' } + }) + }) + + expect(failures).toEqual(['mobile_web_document_http_403']) + expect(loaded).not.toHaveBeenCalled() + }) + + it('names the failure in copy the hosted shell can show', () => { + expect(mobileWebShellLoadFailureWarning('mobile_web_document_http_403')).toContain( + 'mobile_web_document_http_403' + ) + expect(mobileWebShellLoadFailureWarning('mobile_web_generation_invalid')).toContain( + 'failed verification' + ) + expect(mobileWebShellLoadFailureWarning(undefined)).toBe( + 'The workspace interface could not be displayed.' + ) + }) +})