import { useEffect, useState, type ReactNode } from 'react' import { ActivityIndicator, Linking, Platform, Pressable, StyleSheet, Text, View } from 'react-native' import { useNavigation, useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { OrcaMobileWebShellView, parseMobileWebShellLoadState } from '../../modules/orca-mobile-web-shell/src' import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner' import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import type { BridgeInitRoute } from './bridge/bridge-envelope' import type { BridgeClearableRouteParam } from './bridge/bridge-route-update' import type { MobileWebShellFailureCause, MobileWebShellSessionState, MobileWebShellUpdateNotice } from './mobile-web-shell-session-contract' import { formatMobileWebShellDevFacts, isDevelopmentBuild, useMobileWebShellDroppedFrames } from './mobile-web-shell-dev-facts' import { cancelledShellNavigationTarget } from './cancelled-navigation-target' import { playPageHaptic } from './page-haptics' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' import { useKeyboardOcclusion } from '../platform/keyboard-occlusion' import { softwareKeyboardWindowInset } from '../platform/software-keyboard-window-inset' import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs' import { useShellPageBack } from './use-shell-page-back' import { useShellStackPop } from './use-shell-stack-pop' import { useMobileWebShellSession } from './use-mobile-web-shell-session' import { usePageHostSnapshot } from './use-page-host-snapshot' import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame' function failureMessage(reason: MobileWebShellFailureCause): string { switch (reason) { case 'isolation-unavailable': return "This device's WebView is too old to open the workspace safely." case 'download-failed': return 'The workspace could not be downloaded from this host.' case 'status-unreadable': return "Could not read this host's status. Go back and reopen it." case 'render-process-gone': return 'The workspace stopped responding.' case 'generation-unreadable': case 'document-load-failed': return 'The downloaded workspace could not be opened.' } } /** Says what happened and what is on screen because of it, and claims nothing else: the shell does * not schedule a second attempt, so this must not promise one. */ function updateNoticeMessage(notice: MobileWebShellUpdateNotice): string { switch (notice) { case 'update-failed': return "Couldn't update the workspace from this host. Showing the last version that worked." } } function Centered({ children }: { children: ReactNode }) { return {children} } function Waiting({ label }: { label: string }) { return ( ) } function Fetching({ state }: { state: Extract }) { return ( Downloading workspace {`${state.completedAssets}/${state.totalAssets} files · ${state.receivedBytes}/${state.totalBytes} bytes`} ) } function Failed({ state, onRetry }: { state: Extract onRetry: () => void }) { // No retry for the fence, and none for an unread status: a device whose WebView cannot be // isolated will not grow one on a tap, and a retry re-reads the same settled gate it already has. const retryable = state.reason !== 'isolation-unavailable' && state.reason !== 'status-unreadable' return ( {failureMessage(state.reason)} {retryable ? ( [styles.retryButton, pressed && styles.pressed]} testID="mobile-web-shell-retry" onPress={onRetry} > Try again ) : null} ) } function DevFacts({ state, droppedBinaryFrames }: { state: Extract droppedBinaryFrames: number }) { if (!isDevelopmentBuild()) { return null } return ( {formatMobileWebShellDevFacts({ buildId: state.buildId, totalBytes: state.totalBytes, elapsedMs: state.elapsedMs, droppedBinaryFrames })} ) } export type MobileWebShellScreenProps = { hostId: string /** The screen this shell stands in for, which the page cannot derive from a document served at `/`. */ route: BridgeInitRoute /** * What to render when the bundle does not list this route, or lists it needing a grant this app * does not implement. Required, because every caller has a native screen behind it: that is what * the negotiation falls back to, and a shell with nothing behind it would paint a blank instead. */ fallback: ReactNode /** * The page applied a one-shot route param and asks for it to be erased (ruling 34), naming what * it applied. Only a caller that put one on the route ever hears this, and the comparison is * that caller's: it holds the param, and a tap that moved on since leaves a newer value there. */ onRouteParamClear?: (param: BridgeClearableRouteParam, value: string) => void runtime?: MobileWebShellRuntime } /** * The hybrid shell route's screen: one generation, rendered by the native view, or the plain state * that says why it is not. * * The native view is keyed on the session id, so a remount the reducer asks for is a new key and a * rebuilt WebView with every fence reinstalled — the view has no reload of its own by design. */ export function MobileWebShellScreen({ hostId, route, fallback, onRouteParamClear, runtime }: MobileWebShellScreenProps) { const insets = useSafeAreaInsets() // The page cannot see the IME for itself: edge-to-edge makes the manifest's `adjustResize` inert, // so the window never shrinks and `visualViewport` inside the WebView reads full height with the // keyboard up — the session route lays its live input row out under the keys. The shell owns the // window, so it takes the strip off the view and the page lays out in what is left. const keyboardHeight = useKeyboardOcclusion() const keyboardInset = softwareKeyboardWindowInset({ keyboardHeight, bottomInset: insets.bottom, platform: Platform.OS }) const router = useRouter() const navigation = useNavigation() const popShellStack = useShellStackPop() const { droppedBinaryFrames, reportDroppedBinaryFrames } = useMobileWebShellDroppedFrames() const { state, pageRoutes, pageRouteGrants, routeGrants, updateNotice, retry, reportShellFailure, reportDocumentStarted, reportDocumentLoaded, reportPageReady, reportPagePainted, reportPageBackClaim, pageReady, pageFrame, backClaimed } = useMobileWebShellSession({ hostId, routePathname: route.pathname, runtime }) // Which mount the notice was dismissed on, not whether it was: a later refusal opens its own // generation under a new session id, so it is not silenced by a tap on the one before it. const [noticeDismissedFor, setNoticeDismissedFor] = useState(null) const { snapshot, unreadable, readStorage, refreshStorage, writeStorage } = usePageHostSnapshot( hostId, route.pathname ) // Declared before the bridge so the handler it is handed already belongs to this session: the // media verbs hold staged files, and a registry born after the host would outlive the page. const serveNativeVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null) // Straight to the system handler, and the one opener the shell has: the page's `externalLink` // notify and a cancelled top-frame navigation both arrive here already filtered. The only failure // left is a device with nothing registered for the scheme -- a `mailto:` on a phone with no mail // account. Reported rather than swallowed, because nothing crosses back for either path, and not // rethrown, because both run on a native frame handler. const openUrlForPage = (url: string) => { void Linking.openURL(url).catch((error: unknown) => { console.warn('[web-shell] could not open a URL for the page', { url, error }) }) } const bridge = useMobileWebShellBridge({ hostId, route, pageRoutes, pageRouteGrants, routeGrants, session: state, sessionEstablished: pageReady, snapshot, readStorage, onStorageWrite: writeStorage, // Reported as the document failing to load, which is what it is: the document loaded and never // produced a tree. That reason drops this generation and downloads once, so a page broken by // bytes this host has since replaced recovers, and a page broken by its own code stops at the // failure screen instead of a blank one. // Must not throw: it runs inside the page's own error boundary on one side and the native frame // handler on the other, and neither has anywhere to put a throw. onPageFault: (error) => { console.warn('[web-shell] the page faulted', error) reportShellFailure('document-load-failed') }, // The `init` this ready is answered with is already built from the app's writes, which reach // the map as they are made. This re-seats that map on the store afterwards, for the key whose // write never persisted, and it runs on every ask because a document that reloads inside this // mount asks again. onPageReady: (reports) => { reportPageReady(reports) void refreshStorage() }, // The one thing that says the page is something to look at. The cover below stays up until it // lands, for a page that declared it would send one. onPagePainted: reportPagePainted, // While this is true the key below belongs to the page, not to the stack this screen sits on. onPageBackClaim: reportPageBackClaim, onRouteParamClear: (param, value) => { onRouteParamClear?.(param, value) }, // `document-load-failed` because that is what happens: the document loads and the page refuses // the session, so no tree is ever built. The refetch it costs is wasted on a route this shell // produced, and the second report is terminal, which is the failure screen this deserves. onRouteRefused: (issue) => { console.warn('[web-shell] refused to open this screen', issue) reportShellFailure('document-load-failed') }, // Pushed, never replaced: the page stays mounted underneath, so Back reveals it with no // download and no second `init`. onNavigate: (href: string) => { router.push(href) }, // Answered on this device and never forwarded; the host holds it to the verb table first. serveNativeVerb, // Straight to the system handler. The envelope allowlisted the scheme before this ran, so the // only failure left is a device with nothing registered for it — a `mailto:` on a phone with no // mail account. Reported rather than swallowed: nothing crosses back for a notify, so this is // the one dead tap the verb does not rule out, and silence is what would hide it. Still not // rethrown, because this runs on the native frame handler. // The same opener a cancelled top-frame navigation takes, hoisted above this call so both // paths are one function: its body is the `Linking.openURL` and the warning this handler // carried inline. onExternalLink: openUrlForPage, // The app's own haptics, reached through one mapping rather than a second copy of the // `Platform.OS` split. Nothing crosses back and nothing can fail: each function already // swallows its own rejection on the device. onHaptic: playPageHaptic, // The page's own Back goes nowhere: it holds the one history entry the entry wrote, so the only // stack to pop is this one. onNavigateBack: popShellStack, // A dropped screencast frame leaves no other trace on a device: the stream stays up by design // and the diagnostic beside it prints once per host. onBinaryFramesDropped: reportDroppedBinaryFrames }) // A route that moved under a screen that stayed mounted: the session switch keeps `paneKey` out // of its key so a notification tap for another pane is a tab switch rather than a page reload, // and this is how the page hears about it. Nothing is tracked here — which route the page has, // and which one is still owed it, belong to the host, which outlives any one run of this effect. // All this says is what the screen is on now: a route that did not move is dropped there, and a // frame in flight when this re-runs is not disturbed by it. const publishRoute = bridge.publishRoute useEffect(() => { publishRoute(route) }, [publishRoute, route]) // The navigation object rather than the router: what this takes away is this screen's own place // on the stack, which is a screen option, and the router has no member that says it. useShellPageBack({ claimed: backClaimed, sendBack: bridge.sendBack, setOptions: navigation.setOptions }) // A profile read that rejected never becomes a host, so the session would otherwise sit in // `ready` behind an un-hidden view with nothing serving it and the page asking forever. // `document-load-failed` because that is the outcome: the document loads and no session opens. // The refetch it costs is wasted on a device-local read, and the second report is terminal, which // is the failure screen with a Try again this deserves. useEffect(() => { if (unreadable) { console.warn('[web-shell] this host could not be read from the app store') reportShellFailure('document-load-failed') } }, [reportShellFailure, unreadable]) if (state.kind === 'native-route') { return fallback } if (state.kind === 'wall') { return } if (state.kind === 'failed') { return } if (state.kind === 'offline') { return ( Connect to this host to download the workspace ) } if (state.kind === 'fetching') { return } if (state.kind !== 'ready') { return } return ( {/* Above the page and dismissible, never in front of it: the workspace below this line works, and the only thing that did not happen is the update to a newer one. */} {updateNotice !== null && noticeDismissedFor !== state.sessionId && ( setNoticeDismissedFor(state.sessionId)} /> )} { const target = cancelledShellNavigationTarget(event.nativeEvent.url) if (target === null) { // Cancelled and not openable. Nothing naming the shell's own document reaches here: // the shell refuses that without offering it, whatever asked. What lands here and is // dropped is a URL outside the three allowed schemes. Silent, as every cancelled // navigation was before this event existed. return } openUrlForPage(target) }} onLoadState={(event) => { const parsed = parseMobileWebShellLoadState(event.nativeEvent) if (parsed?.state === 'failed') { reportShellFailure(parsed.reason) return } // A finished document is not a working one. The WebView says the response committed; only // the page's own first frame says its code ran, so this is where the wait for it starts. if (parsed?.state === 'ready') { reportDocumentLoaded() return } // The view is drawing the document it is leaving until the new one paints, so the cover // goes back up here rather than on the `ready` that follows it. if (parsed?.state === 'loading') { reportDocumentStarted() } }} /> ) } const styles = StyleSheet.create({ shellRoot: { flex: 1, backgroundColor: colors.bgBase }, shellView: { flex: 1 }, centered: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bgBase, paddingHorizontal: spacing.lg }, waitingLabel: { fontSize: typography.bodySize, color: colors.textSecondary, marginTop: spacing.md, textAlign: 'center' }, progress: { fontSize: typography.metaSize, color: colors.textMuted, marginTop: spacing.sm }, failedMessage: { fontSize: typography.bodySize, color: colors.textPrimary, textAlign: 'center', marginBottom: spacing.lg }, retryButton: { backgroundColor: colors.bgRaised, paddingVertical: spacing.sm + 2, paddingHorizontal: spacing.lg, borderRadius: radii.button }, retryLabel: { fontSize: typography.bodySize, fontWeight: '600', color: colors.textPrimary }, pressed: { opacity: 0.7 }, devFacts: { position: 'absolute', left: spacing.sm, bottom: spacing.sm, paddingHorizontal: spacing.sm, paddingVertical: 2, borderRadius: radii.button, backgroundColor: colors.bgPanel }, devFactsText: { fontSize: typography.metaSize, color: colors.textMuted } })