diff --git a/mobile/app/h/[hostId]/web.tsx b/mobile/app/h/[hostId]/web.tsx new file mode 100644 index 00000000000..2425b1e00f1 --- /dev/null +++ b/mobile/app/h/[hostId]/web.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { ActivityIndicator, StyleSheet, View } from 'react-native' +import { Redirect, useLocalSearchParams } from 'expo-router' +import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen' +import { loadMobileWebShellEnabled } from '../../../src/storage/preferences' +import { colors } from '../../../src/theme/mobile-theme' + +/** + * The hybrid shell route, dark behind a development-only flag. + * + * The only caller of `loadMobileWebShellEnabled`. With the flag off — which is every store build, + * since the only writer is the `__DEV__` Troubleshoot toggle — this redirects and the screen is + * never constructed, so nothing is fetched, written or swept. It sits under `app/h/[hostId]` so + * `HostProtocolGate` in that group's layout still owns the `desktop-too-old` wall above it. + * + * Reachable by deep link and from the developer row only; no screen links here. + */ +export default function MobileWebShellRoute() { + const { hostId } = useLocalSearchParams<{ hostId: string }>() + const [enabled, setEnabled] = useState(null) + + useEffect(() => { + let stale = false + void loadMobileWebShellEnabled().then((value) => { + if (!stale) { + setEnabled(value) + } + }) + return () => { + stale = true + } + }, []) + + if (enabled === null) { + // A redirect fired before the read settles would bounce a flag that is on, and a screen mounted + // before it settles would fetch on a flag that is off. Neither, until it is known. + return ( + + + + ) + } + if (!enabled || !hostId) { + return + } + return +} + +const styles = StyleSheet.create({ + pending: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/app/h/_layout.tsx b/mobile/app/h/_layout.tsx index 7f77c33b648..d98c457f2a0 100644 --- a/mobile/app/h/_layout.tsx +++ b/mobile/app/h/_layout.tsx @@ -54,6 +54,8 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) { /> + {/* Dev-flag only: redirects to the host screen unless the hybrid shell flag is on. */} + ) } diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index d64b238b577..ffa9031e00a 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -1,5 +1,6 @@ import { useRouter } from 'expo-router' import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-probe-row' +import { MobileWebShellDevRow } from '../src/diagnostics/mobile-web-shell-dev-row' import { TroubleshootView } from '../src/diagnostics/troubleshoot-view' import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics' @@ -21,7 +22,14 @@ export default function NativeTroubleshootRoute() { runDiagnostics={() => void runDiagnostics()} onBack={() => router.back()} onConnectionLog={() => router.push('/connection-log')} - developerRow={isDevelopmentBuild ? : null} + developerRow={ + isDevelopmentBuild ? ( + <> + + + + ) : null + } /> ) } diff --git a/mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx b/mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx new file mode 100644 index 00000000000..26147feb778 --- /dev/null +++ b/mobile/src/diagnostics/mobile-web-shell-dev-row.test.tsx @@ -0,0 +1,121 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * The developer toggle is the only writer of the hybrid shell flag, and the route it opens reads + * that flag back from storage rather than from this screen. So what the switch shows and what the + * open button permits must both follow the write, not the tap. + */ +type Doubles = { + stored: boolean + saves: { next: boolean; settle: () => void; fail: () => void }[] + pushes: string[] +} + +const doubles = vi.hoisted((): Doubles => ({ stored: false, saves: [], pushes: [] })) + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + Switch: 'Switch', + Text: 'Text', + View: 'View' +})) +vi.mock('expo-router', () => ({ + useRouter: () => ({ + push: (href: string) => { + doubles.pushes.push(href) + } + }) +})) +vi.mock('lucide-react-native', () => ({ LayoutTemplate: 'LayoutTemplate' })) +vi.mock('../transport/host-store', () => ({ loadHosts: async () => [{ id: 'host-1' }] })) +vi.mock('../storage/preferences', () => ({ + loadMobileWebShellEnabled: async () => doubles.stored, + saveMobileWebShellEnabled: (next: boolean) => + new Promise((resolve, reject) => { + doubles.saves.push({ + next, + settle: () => { + doubles.stored = next + resolve() + }, + fail: () => reject(new Error('storage unavailable')) + }) + }) +})) +vi.mock('./troubleshoot-screen-styles', () => ({ troubleshootScreenStyles: {} })) + +import { MobileWebShellDevRow } from './mobile-web-shell-dev-row' + +function only(tree: ReactTestRenderer, testID: string): ReactTestInstance { + const found = tree.root.findAll((node) => node.props.testID === testID) + const node = found[0] + if (node === undefined || found.length !== 1) { + throw new Error(`expected one ${testID}, found ${found.length}`) + } + return node +} + +async function mountRow(): Promise { + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileWebShellDevRow)) + }) + const tree = rendered.tree + if (tree === null) { + throw new Error('the row did not mount') + } + return tree +} + +async function toggle(tree: ReactTestRenderer, next: boolean): Promise { + await act(async () => { + only(tree, 'mobile-web-shell-flag').props.onValueChange(next) + }) +} + +describe('the hybrid shell developer row', () => { + beforeEach(() => { + doubles.stored = false + doubles.saves.length = 0 + doubles.pushes.length = 0 + }) + + it('offers neither the new position nor the route until the write lands', async () => { + const tree = await mountRow() + await toggle(tree, true) + + expect(doubles.saves).toHaveLength(1) + expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(false) + expect(only(tree, 'mobile-web-shell-flag').props.disabled).toBe(true) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true) + + await act(async () => { + doubles.saves[0]?.settle() + }) + expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(true) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(false) + }) + + it('keeps the open button shut while a write that turns the flag off is still in flight', async () => { + doubles.stored = true + const tree = await mountRow() + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(false) + + await toggle(tree, false) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true) + }) + + it('leaves the switch where storage still is when the write fails', async () => { + const tree = await mountRow() + await toggle(tree, true) + await act(async () => { + doubles.saves[0]?.fail() + }) + + expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(false) + expect(only(tree, 'mobile-web-shell-flag').props.disabled).toBe(false) + expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true) + }) +}) diff --git a/mobile/src/diagnostics/mobile-web-shell-dev-row.tsx b/mobile/src/diagnostics/mobile-web-shell-dev-row.tsx new file mode 100644 index 00000000000..1f24c11fbe3 --- /dev/null +++ b/mobile/src/diagnostics/mobile-web-shell-dev-row.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { Pressable, Switch, Text, View } from 'react-native' +import { useRouter } from 'expo-router' +import { LayoutTemplate } from 'lucide-react-native' +import { loadHosts } from '../transport/host-store' +import { loadMobileWebShellEnabled, saveMobileWebShellEnabled } from '../storage/preferences' +import { colors } from '../theme/mobile-theme' +import { troubleshootScreenStyles as styles } from './troubleshoot-screen-styles' + +/** + * Development-only: the one caller of `saveMobileWebShellEnabled`, and the one way into the hybrid + * shell route that is not a deep link. + * + * `app/troubleshoot.tsx` mounts it behind `__DEV__`, exactly as it mounts A5's probe row, so a + * shipped build never renders the toggle and the flag it guards can only stay off. The route itself + * reads the flag again rather than trusting this screen, because a deep link arrives without it. + */ +export function MobileWebShellDevRow() { + const router = useRouter() + const [enabled, setEnabled] = useState(null) + const [saving, setSaving] = useState(false) + const [hostId, setHostId] = useState(null) + + useEffect(() => { + let stale = false + void Promise.all([loadMobileWebShellEnabled(), loadHosts()]).then(([flag, hosts]) => { + if (!stale) { + setEnabled(flag) + setHostId(hosts[0]?.id ?? null) + } + }) + return () => { + stale = true + } + }, []) + + // Not while a write is in flight: the route reads the key back from storage, so a button that + // opened on the switch's position would mount a shell the persisted flag does not permit yet. + const openable = enabled === true && hostId !== null && !saving + return ( + + + Hybrid shell (dev) + { + setSaving(true) + void saveMobileWebShellEnabled(next) + .then(() => { + setEnabled(next) + }) + // A write that never landed leaves the previous position showing, because that is + // still what the route will read. + .catch(() => undefined) + .finally(() => { + setSaving(false) + }) + }} + /> + + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed, + !openable && styles.diagnosticButtonDisabled + ]} + testID="mobile-web-shell-open" + disabled={!openable} + onPress={() => { + if (hostId !== null) { + router.push(`/h/${hostId}/web`) + } + }} + > + + + Open hybrid shell for the first paired host + + + + ) +} diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx new file mode 100644 index 00000000000..b148374e9dd --- /dev/null +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -0,0 +1,234 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' + +type ScreenDependencies = { + retry: Mock + reportShellFailure: Mock + openUrl: Mock + lifecycle: string[] + state: MobileWebShellSessionState +} + +const dependencies = vi.hoisted((): ScreenDependencies => { + // Before the module under test is imported, so its `__DEV__` guard is on and the developer facts + // are reachable at all — they are the one thing here that must never grow a secret. + Object.assign(globalThis, { __DEV__: true }) + return { + retry: vi.fn(), + reportShellFailure: vi.fn(), + openUrl: vi.fn(), + lifecycle: [], + state: { kind: 'checking' } + } +}) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Linking: { openURL: dependencies.openUrl }, + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 }) +})) +vi.mock('expo-router', () => ({ router: { replace: vi.fn() } })) +// A component rather than a host string: the React key is what makes a retry a rebuilt WebView, +// and a mount/unmount log is the only thing that can tell a remount from a prop update. +vi.mock('../../modules/orca-mobile-web-shell/src', async () => { + const React = await import('react') + const loadState = await import('../../modules/orca-mobile-web-shell/src/load-state') + return { + OrcaMobileWebShellView: (props: { sessionId: string }) => { + React.useEffect(() => { + dependencies.lifecycle.push(`mount:${props.sessionId}`) + return () => { + dependencies.lifecycle.push(`unmount:${props.sessionId}`) + } + }, [props.sessionId]) + return React.createElement('ShellViewProbe', props) + }, + parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState + } +}) +vi.mock('./use-mobile-web-shell-session', () => ({ + useMobileWebShellSession: () => ({ + state: dependencies.state, + retry: dependencies.retry, + reportShellFailure: dependencies.reportShellFailure + }) +})) + +import { MobileWebShellScreen } from './MobileWebShellScreen' + +const BUILD_ID = 'a1b2c3d4e5f6'.repeat(5) + 'abcd' +const DIRECTORY = '/var/mobile/Containers/Data/Caches/mobile-web/deadbeef/generations/a1b2' + +async function render(state: MobileWebShellSessionState): Promise { + dependencies.state = state + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileWebShellScreen, { hostId: 'host-1' })) + }) + if (rendered.tree === null) { + throw new Error('screen did not render') + } + return rendered.tree +} + +function readyState(sessionId: string): MobileWebShellSessionState { + return { + kind: 'ready', + generationDirectory: DIRECTORY, + sessionId, + buildId: BUILD_ID, + totalBytes: 4096, + elapsedMs: 811 + } +} + +async function update(tree: ReactTestRenderer, state: MobileWebShellSessionState): Promise { + dependencies.state = state + await act(async () => { + tree.update(createElement(MobileWebShellScreen, { hostId: 'host-1' })) + }) +} + +/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit + * an arbitrary React Native host name, so the typed form is a predicate. */ +function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] { + return tree.root.findAll((node) => String(node.type) === name) +} + +function textOf(tree: ReactTestRenderer): string { + return byName(tree, 'Text') + .map((node) => node.children.filter((child) => typeof child === 'string').join('')) + .join('\n') +} + +describe('the hybrid shell screen', () => { + beforeEach(() => { + dependencies.retry.mockReset() + dependencies.reportShellFailure.mockReset() + dependencies.lifecycle.length = 0 + }) + + it('renders the update wall for a bundle verdict, with no shell view', async () => { + const tree = await render({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + expect(textOf(tree)).toContain('Update Orca on your computer') + expect(byName(tree, 'ShellViewProbe')).toEqual([]) + }) + + it('renders the refetch wall a cached generation older than the host earns', async () => { + const tree = await render({ + kind: 'wall', + verdict: { + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'mobile', + bundleRuntimeProtocolVersion: 3, + requiredBundleRuntimeProtocolVersion: 9 + } + }) + expect(textOf(tree)).toContain('Refresh the mobile workspace') + }) + + it('offers Try again on a failure a retry can clear', async () => { + const tree = await render({ + kind: 'failed', + reason: 'document-load-failed', + retriedOnce: true + }) + expect(textOf(tree)).toContain('The downloaded workspace could not be opened.') + const retry = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry') + expect(retry).toHaveLength(1) + await act(async () => { + retry[0].props.onPress() + }) + expect(dependencies.retry).toHaveBeenCalledTimes(1) + }) + + it('offers no retry when the device cannot isolate a WebView', async () => { + const tree = await render({ + kind: 'failed', + reason: 'isolation-unavailable', + retriedOnce: false + }) + expect(textOf(tree)).toContain("This device's WebView is too old") + expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')).toEqual([]) + }) + + it('offers no retry for a status that could not be read, since the gate is settled', async () => { + const tree = await render({ + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: false + }) + expect(textOf(tree)).toContain("Could not read this host's status") + expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')).toEqual([]) + }) + + it('names what is missing when the host is unreachable and nothing is cached', async () => { + expect(textOf(await render({ kind: 'offline' }))).toContain( + 'Connect to this host to download the workspace' + ) + }) + + it('counts assets and bytes while downloading', async () => { + const tree = await render({ + kind: 'fetching', + completedAssets: 2, + totalAssets: 4, + receivedBytes: 2048, + totalBytes: 4096 + }) + expect(textOf(tree)).toContain('2/4 files') + expect(textOf(tree)).toContain('2048/4096 bytes') + }) + + it('hands the shell view the generation path and the session id', async () => { + const tree = await render(readyState('session-one')) + const view = byName(tree, 'ShellViewProbe')[0] + expect(view.props.generationDirectory).toBe(DIRECTORY) + expect(view.props.sessionId).toBe('session-one') + }) + + it('rebuilds the view rather than updating it when the session id changes', async () => { + const tree = await render(readyState('session-one')) + await update(tree, readyState('session-two')) + expect(dependencies.lifecycle).toEqual([ + 'mount:session-one', + 'unmount:session-one', + 'mount:session-two' + ]) + }) + + it('forwards a failure the native view reports and drops a payload it cannot read', async () => { + const tree = await render(readyState('session-one')) + const view = byName(tree, 'ShellViewProbe')[0] + await act(async () => { + view.props.onLoadState({ nativeEvent: { state: 'ready' } }) + view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'invented' } }) + view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'render-process-gone' } }) + }) + expect(dependencies.reportShellFailure.mock.calls).toEqual([['render-process-gone']]) + }) + + it('shows a build id prefix and never the whole one, the cache path, or the host id', async () => { + const tree = await render(readyState('session-one')) + const text = textOf(tree) + expect(text).toContain(BUILD_ID.slice(0, 12)) + expect(text).toContain('4096 B') + expect(text).toContain('811 ms') + expect(text).not.toContain(BUILD_ID) + expect(text).not.toContain(DIRECTORY) + expect(text).not.toContain('host-1') + }) +}) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx new file mode 100644 index 00000000000..0d73b5a8adf --- /dev/null +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -0,0 +1,229 @@ +import type { ReactNode } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { + OrcaMobileWebShellView, + parseMobileWebShellLoadState +} from '../../modules/orca-mobile-web-shell/src' +import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { + MobileWebShellFailureCause, + MobileWebShellSessionState +} from './mobile-web-shell-session-contract' +import { + useMobileWebShellSession, + type MobileWebShellRuntime +} from './use-mobile-web-shell-session' + +// Same guard as the Troubleshoot developer row: `__DEV__` is undefined outside the React Native +// runtime, and the facts below are for whoever is bringing the shell up, not for a user. +const isDevelopmentBuild = typeof __DEV__ !== 'undefined' && __DEV__ + +/** Enough of a build id to tell two generations apart in a screenshot, and not enough to be one. */ +const BUILD_ID_PREFIX_LENGTH = 12 + +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.' + } +} + +function Centered({ children }: { children: ReactNode }) { + return {children} +} + +function Waiting({ label }: { label: string }) { + return ( + + + {label} + + ) +} + +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} + + ) +} + +/** Never the generation directory, never the whole build id, never the host id: this renders on a + * device someone may be screen-sharing, and none of those three tell them anything a prefix does + * not. */ +function DevFacts({ state }: { state: Extract }) { + if (!isDevelopmentBuild) { + return null + } + return ( + + + {`${state.buildId.slice(0, BUILD_ID_PREFIX_LENGTH)} · ${state.totalBytes} B · ${state.elapsedMs} ms`} + + + ) +} + +export type MobileWebShellScreenProps = { + hostId: string + 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, runtime }: MobileWebShellScreenProps) { + const insets = useSafeAreaInsets() + const { state, retry, reportShellFailure } = useMobileWebShellSession({ hostId, runtime }) + + 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 ( + + { + const parsed = parseMobileWebShellLoadState(event.nativeEvent) + if (parsed?.state === 'failed') { + reportShellFailure(parsed.reason) + } + }} + /> + + + ) +} + +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 + } +}) diff --git a/mobile/src/mobile-web-shell/generation-store-file-system.test.ts b/mobile/src/mobile-web-shell/generation-store-file-system.test.ts new file mode 100644 index 00000000000..19bbf29f1bd --- /dev/null +++ b/mobile/src/mobile-web-shell/generation-store-file-system.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +// The module pulls in expo-file-system at import time; only the pure converter is under test here. +vi.mock('expo-file-system', () => ({ + Directory: class {}, + File: class {}, + Paths: { cache: '' } +})) + +import { generationDirectoryPath } from './generation-store-file-system' + +describe('generationDirectoryPath', () => { + it('hands the native view the absolute path both loaders demand', () => { + // Both refuse anything without a leading slash, and the store speaks file:// uris. + expect( + generationDirectoryPath('file:///var/mobile/Caches/mobile-web/abc/generations/def') + ).toBe('/var/mobile/Caches/mobile-web/abc/generations/def') + expect(generationDirectoryPath('file:///data/user/0/com.stably.orca.mobile/cache/mw')).toBe( + '/data/user/0/com.stably.orca.mobile/cache/mw' + ) + }) + + it('decodes what a uri escaped and a path spells literally', () => { + expect(generationDirectoryPath('file:///var/Orca%20Mobile/mobile-web')).toBe( + '/var/Orca Mobile/mobile-web' + ) + }) + + it('leaves a value that is already a path alone, so nobody can decode one twice', () => { + expect(generationDirectoryPath('/var/mobile/Caches/100%25')).toBe('/var/mobile/Caches/100%25') + }) +}) diff --git a/mobile/src/mobile-web-shell/generation-store-file-system.ts b/mobile/src/mobile-web-shell/generation-store-file-system.ts index 4c5718fabca..79241093b73 100644 --- a/mobile/src/mobile-web-shell/generation-store-file-system.ts +++ b/mobile/src/mobile-web-shell/generation-store-file-system.ts @@ -34,6 +34,23 @@ export type GenerationFileSystem = { moveDirectory(fromUri: string, toUri: string): Promise } +const FILE_URI_PREFIX = 'file://' + +/** + * The `file://` uri the store works in, as the absolute path the native shell view requires. + * + * The two sides speak different dialects of the same location: `expo-file-system` hands out uris, + * and both native loaders refuse anything that does not start with `/`. Percent-decoded because a + * uri escapes what a path spells literally, and left alone when it is already a path so a caller + * cannot double-decode one. + */ +export function generationDirectoryPath(uri: string): string { + if (!uri.startsWith(FILE_URI_PREFIX)) { + return uri + } + return decodeURIComponent(uri.slice(FILE_URI_PREFIX.length)) +} + export function createExpoGenerationFileSystem(): GenerationFileSystem { return { rootUri: new Directory(Paths.cache, MOBILE_WEB_CACHE_DIRECTORY_NAME).uri, diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts new file mode 100644 index 00000000000..347bf41fd32 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-flag-census.test.ts @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * The hybrid shell flag is the whole of what keeps this feature dark, so who touches it is a + * product invariant rather than a convention. A second reader is how a dark feature stops being + * dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run in a store + * build the moment anything flipped it, and none of those would fail a type check. + */ +const MOBILE_ROOT = join(import.meta.dirname, '..', '..') +const FLAG_KEY = 'orca:mobileWebShellEnabled' +const DEFINITION = 'src/storage/preferences.ts' +const ROUTE = 'app/h/[hostId]/web.tsx' +const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx' +/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files, + * but it is where the native view lives and so the easiest place for a second reader to hide. */ +const TREES = { src: 200, app: 10, modules: 1 } +const SHELL_VIEW = 'modules/orca-mobile-web-shell/src/index.ts' + +function sourceFiles(directory: string): string[] { + const found: string[] = [] + for (const entry of readdirSync(join(MOBILE_ROOT, directory), { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + found.push(...sourceFiles(path)) + } else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) { + found.push(path) + } + } + return found +} + +const SOURCES = Object.keys(TREES) + .flatMap((tree) => sourceFiles(tree)) + .map((path) => ({ + path: path.split('\\').join('/'), + text: readFileSync(join(MOBILE_ROOT, path), 'utf8') + })) + +function filesContaining(needle: string): string[] { + return SOURCES.filter((file) => file.text.includes(needle)) + .map((file) => file.path) + .sort() +} + +describe('who touches the hybrid shell flag', () => { + it('reaches every shipped tree, so the absence assertions below cannot pass vacuously', () => { + const paths = SOURCES.map((file) => file.path) + expect(paths).toContain(DEFINITION) + expect(paths).toContain(ROUTE) + expect(paths).toContain(DEVELOPER_ROW) + expect(paths).toContain(SHELL_VIEW) + const trees = Object.keys(TREES) + for (const [tree, floor] of Object.entries(TREES)) { + expect(paths.filter((path) => path.startsWith(`${tree}/`)).length).toBeGreaterThan(floor) + } + expect(paths.filter((path) => !trees.some((tree) => path.startsWith(`${tree}/`)))).toEqual([]) + }) + + it('keeps the storage key itself in one module', () => { + expect(filesContaining(FLAG_KEY)).toEqual([DEFINITION]) + }) + + it('is read by the route and by the developer row that writes it, and nowhere else', () => { + expect(filesContaining('loadMobileWebShellEnabled')).toEqual( + [DEFINITION, DEVELOPER_ROW, ROUTE].sort() + ) + }) + + it('is written only by the developer row', () => { + expect(filesContaining('saveMobileWebShellEnabled')).toEqual([DEFINITION, DEVELOPER_ROW].sort()) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts new file mode 100644 index 00000000000..5996a335bf9 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-reachability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +import { readMobileWebShellReachability } from './mobile-web-shell-session' + +/** Only `client === null` is read, but a real shape keeps this out of the casting gate. */ +function fakeClient(): RpcClient { + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: vi.fn(), + close: vi.fn() + } +} + +const CLIENT = fakeClient() + +function reachability(state: ConnectionState, client: RpcClient | null = CLIENT): string { + return readMobileWebShellReachability(state, client) +} + +describe('readMobileWebShellReachability', () => { + it('is connected only with a live client on a connected socket', () => { + expect(reachability('connected')).toBe('connected') + expect(reachability('connected', null)).toBe('connecting') + }) + + it('waits through the first dial', () => { + expect(reachability('connecting')).toBe('connecting') + expect(reachability('handshaking')).toBe('connecting') + }) + + /** + * Observed on a simulator with the paired desktop stopped: the client never settles on + * `disconnected`. It dials, fails, schedules a retry and cycles `connecting` -> `reconnecting` + * with the delay growing to a minute. Reading `reconnecting` as "still dialling" left a phone + * holding a verified cached generation spinning on `checking` forever instead of opening it. + */ + it('treats a scheduled retry as an unreachable host, not as a dial in progress', () => { + expect(reachability('reconnecting')).toBe('unreachable') + }) + + it('treats a settled non-connection as unreachable', () => { + expect(reachability('disconnected')).toBe('unreachable') + expect(reachability('auth-failed')).toBe('unreachable') + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx new file mode 100644 index 00000000000..b2753ae54f2 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-route.test.tsx @@ -0,0 +1,112 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type RouteDependencies = { storage: Map; mounted: string[] } + +const dependencies = vi.hoisted((): RouteDependencies => ({ storage: new Map(), mounted: [] })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => dependencies.storage.get(key) ?? null, + setItem: async (key: string, value: string) => { + dependencies.storage.set(key, value) + } + } +})) + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + StyleSheet: { create: (styles: unknown) => styles }, + View: 'View' +})) + +vi.mock('expo-router', () => ({ + Redirect: 'Redirect', + useLocalSearchParams: () => ({ hostId: 'host-1' }) +})) + +vi.mock('./MobileWebShellScreen', () => ({ + MobileWebShellScreen: (props: { hostId: string }) => { + dependencies.mounted.push(props.hostId) + return null + } +})) + +import MobileWebShellRoute from '../../app/h/[hostId]/web' + +/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit + * an arbitrary React Native host name, so the typed form is a predicate. */ +function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] { + return tree.root.findAll((node) => String(node.type) === name) +} + +async function renderRoute(): Promise { + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(MobileWebShellRoute)) + }) + if (rendered.tree === null) { + throw new Error('route did not render') + } + return rendered.tree +} + +/** `__DEV__` is a React Native global, absent outside that runtime; assigned rather than cast so + * the test says which build kind it is running as without asserting a type on `globalThis`. */ +function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void { + if (isDevelopmentBuild === undefined) { + Reflect.deleteProperty(globalThis, '__DEV__') + return + } + Object.assign(globalThis, { __DEV__: isDevelopmentBuild }) +} + +describe('the hybrid shell route', () => { + beforeEach(() => { + dependencies.storage.clear() + dependencies.mounted.length = 0 + setDevelopmentBuild(true) + }) + + it('redirects to the host screen with the flag unset, and mounts nothing', async () => { + const tree = await renderRoute() + expect(byName(tree, 'Redirect').map((node) => node.props.href)).toEqual(['/h/host-1']) + expect(dependencies.mounted).toEqual([]) + }) + + it('redirects with the flag explicitly off', async () => { + dependencies.storage.set('orca:mobileWebShellEnabled', 'false') + const tree = await renderRoute() + expect(byName(tree, 'Redirect')).toHaveLength(1) + expect(dependencies.mounted).toEqual([]) + }) + + it('mounts the shell screen for this host with the flag on', async () => { + dependencies.storage.set('orca:mobileWebShellEnabled', 'true') + const tree = await renderRoute() + expect(byName(tree, 'Redirect')).toEqual([]) + expect(dependencies.mounted).toEqual(['host-1']) + }) + + it('redirects a store build whose container kept a flag a development build set', async () => { + setDevelopmentBuild(undefined) + dependencies.storage.set('orca:mobileWebShellEnabled', 'true') + const tree = await renderRoute() + expect(byName(tree, 'Redirect')).toHaveLength(1) + expect(dependencies.mounted).toEqual([]) + }) + + it('neither redirects nor mounts until the flag has been read', async () => { + dependencies.storage.set('orca:mobileWebShellEnabled', 'true') + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + // No `await` inside act: the effect's promise is deliberately left unsettled. + act(() => { + rendered.tree = create(createElement(MobileWebShellRoute)) + }) + const tree = rendered.tree + expect(tree === null ? [] : byName(tree, 'Redirect')).toEqual([]) + expect(dependencies.mounted).toEqual([]) + await act(async () => {}) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts new file mode 100644 index 00000000000..d9fa56a3b88 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -0,0 +1,177 @@ +import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' +import type { + MobileWebBundleCompatManifest, + MobileWebBundleCompatVerdict, + MobileWebBundleHostStatus +} from '../transport/mobile-web-bundle-compat' + +/** + * Whether the host can be asked anything right now. + * + * Three values, not a boolean: a connection still being made is not an offline host, and opening a + * cached generation with no compat check for the second or two before a socket completes would + * flash a workspace this host may already have replaced. `connecting` waits; only a settled + * non-connection opens the cache unchecked. + */ +export type MobileWebShellReachability = 'connected' | 'connecting' | 'unreachable' + +/** Everything the gates say that decides a step here, as one value so a transition is a pure + * function of it rather than of four separately-arriving props. */ +export type MobileWebShellGates = { + readonly statusPending: boolean + /** False for a status nobody answered *and* for one this client could not decode. Both leave the + * capability list empty, which would otherwise read as `bundle-unavailable` and wall a host that + * simply did not reply. */ + readonly statusReadable: boolean + readonly reachability: MobileWebShellReachability + readonly hostCapabilities: readonly string[] + readonly hostStatus: MobileWebBundleHostStatus +} + +/** The manifest fields a transition reads: the wall's three, plus what names and sizes the + * generation the cache is compared against. */ +export type MobileWebShellManifestFacts = MobileWebBundleCompatManifest & { + readonly buildId: string + readonly totalBytes: number + readonly totalAssets: number +} + +/** What `readActiveGeneration` found, reduced to what a transition reads. */ +export type CachedGeneration = { + readonly buildId: string + readonly directory: string + readonly totalBytes: number +} + +export type MobileWebShellBlockedVerdict = Extract< + MobileWebBundleCompatVerdict, + { kind: 'blocked' } +> + +/** Which side a bundle read failed on. `transport` is the link between phone and host, which says + * nothing about the bundle; `bundle` is a verdict about it, from the host or from the bytes. */ +export type MobileWebShellReadFailure = 'transport' | 'bundle' + +/** The shell's own failures plus the one the view cannot report: a download or a cache write that + * never produced a generation to hand it. */ +export type MobileWebShellFailureCause = + | MobileWebShellFailureReason + | 'download-failed' + | 'status-unreadable' + +export type MobileWebShellSessionState = + /** Gates unsettled, cache being read, or a manifest in flight. Nothing is on screen yet. */ + | { readonly kind: 'checking' } + | { + readonly kind: 'fetching' + readonly completedAssets: number + readonly totalAssets: number + readonly receivedBytes: number + readonly totalBytes: number + } + /** Bytes are in; the store is staging and committing, or a cache hit is being opened. */ + | { readonly kind: 'activating' } + | { + readonly kind: 'ready' + readonly generationDirectory: string + readonly sessionId: string + readonly buildId: string + readonly totalBytes: number + readonly elapsedMs: number + } + | { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict } + | { + readonly kind: 'failed' + readonly reason: MobileWebShellFailureCause + readonly retriedOnce: boolean + } + | { readonly kind: 'offline' } + +export type MobileWebShellSessionEffect = + /** Sweep every host's staging tree, then read this host's activation. Lazy on purpose: with the + * flag off nothing in the app reaches this, so nothing sweeps at launch. */ + | { readonly kind: 'open-cache' } + | { readonly kind: 'read-manifest' } + /** Fetch, stage, commit. The runner reports progress, then `download-staged`, then `activated`. */ + | { readonly kind: 'download' } + /** A cache hit: nothing to download, so this only mints a session id and reports the activation. */ + | { + readonly kind: 'open-generation' + readonly directory: string + readonly buildId: string + readonly totalBytes: number + } + | { readonly kind: 'delete-cache' } + /** Mint a new session id for the generation already on screen, which is what remounts the view. */ + | { readonly kind: 'remount' } + +/** + * Events, in two kinds. + * + * The seven that carry a `flow` are results reported out of an effect, and the number is the flow + * the step that asked for them was in. Anything a superseded flow reports is dropped: a manifest + * read that was in flight when the socket dropped still rejects afterwards, and applying that + * rejection would replace a workspace already on screen with a download failure. The other three + * come from outside the flow: the gates and the retry button always apply, and the view's failure + * applies only while its generation is the one on screen, which is the only state that mounted it. + */ +export type MobileWebShellSessionEvent = + | { readonly type: 'gates-changed'; readonly gates: MobileWebShellGates } + | { + readonly type: 'cache-read' + readonly flow: number + readonly generation: CachedGeneration | null + } + | { + readonly type: 'manifest-read' + readonly flow: number + readonly manifest: MobileWebShellManifestFacts + } + | { + readonly type: 'fetch-progress' + readonly flow: number + readonly completedAssets: number + readonly totalAssets: number + readonly receivedBytes: number + readonly totalBytes: number + } + | { readonly type: 'download-staged'; readonly flow: number } + | { + readonly type: 'activated' + readonly flow: number + readonly generationDirectory: string + readonly sessionId: string + readonly buildId: string + readonly totalBytes: number + readonly elapsedMs: number + } + | { readonly type: 'remounted'; readonly flow: number; readonly sessionId: string } + | { + readonly type: 'download-failed' + readonly flow: number + readonly failure: MobileWebShellReadFailure + } + | { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason } + | { readonly type: 'retry-pressed' } + +/** Latches live beside the state because both outlive the state they were set in: `retriedOnce` + * spans the delete-and-refetch that puts the state back to `checking`, and `remountedOnce` spans a + * `ready` that is replaced by a `ready` under a new session id. */ +export type MobileWebShellSession = { + readonly state: MobileWebShellSessionState + readonly retriedOnce: boolean + readonly remountedOnce: boolean + /** The gates the current step was taken on; null until the first one arrives. */ + readonly gates: MobileWebShellGates | null + readonly cached: CachedGeneration | null + /** Which run of the flow the session is on. Bumped by every restart, stamped on the effects that + * run belongs to, and echoed back on their results. */ + readonly flow: number +} + +/** A transition: the session it produced and the effects it owes. Every effect belongs to + * `session.flow`, which is what the runner echoes back on the result. */ +export type MobileWebShellStep = { + readonly session: MobileWebShellSession + readonly effects: readonly MobileWebShellSessionEffect[] +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts new file mode 100644 index 00000000000..1af288f069d --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -0,0 +1,717 @@ +import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { + createMobileWebShellSession, + reduceMobileWebShellSession +} from './mobile-web-shell-session' +import type { + CachedGeneration, + MobileWebShellGates, + MobileWebShellManifestFacts, + MobileWebShellSession, + MobileWebShellSessionEvent, + MobileWebShellStep +} from './mobile-web-shell-session-contract' + +function gates(overrides: Partial = {}): MobileWebShellGates { + return { + statusPending: false, + statusReadable: true, + reachability: 'connected', + hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY], + hostStatus: { protocolVersion: 10, minCompatibleMobileVersion: 1 }, + ...overrides + } +} + +const MANIFEST: MobileWebShellManifestFacts = { + buildId: 'b'.repeat(64), + schemaVersion: 1, + runtimeProtocolVersion: 5, + minCompatibleRuntimeProtocolVersion: 2, + totalBytes: 4096, + totalAssets: 4 +} + +const CACHED: CachedGeneration = { + buildId: MANIFEST.buildId, + directory: '/cache/mobile-web/host/generations/b', + totalBytes: 4096 +} + +/** An event as a test writes it. An effect result is stamped with the flow the session is on, which + * is what an in-order runner does; a test replaying a superseded run pins the flow itself. */ +type PendingEvent = E extends { flow: number } + ? Omit & { readonly flow?: number } + : E + +function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent { + switch (event.type) { + case 'gates-changed': + case 'shell-failed': + case 'retry-pressed': + return event + case 'cache-read': + case 'manifest-read': + case 'fetch-progress': + case 'download-staged': + case 'activated': + case 'remounted': + case 'download-failed': + return { ...event, flow: event.flow ?? flow } + } +} + +function run( + session: MobileWebShellSession, + ...events: readonly PendingEvent[] +): MobileWebShellStep { + let step: MobileWebShellStep = { session, effects: [] } + for (const event of events) { + step = reduceMobileWebShellSession(step.session, stamp(step.session.flow, event)) + } + return step +} + +function started(overrides: Partial = {}): MobileWebShellStep { + return run(createMobileWebShellSession(), { type: 'gates-changed', gates: gates(overrides) }) +} + +/** Connected, capability present, cache read, manifest in flight. */ +function afterCacheRead(generation: CachedGeneration | null): MobileWebShellStep { + return run(started().session, { type: 'cache-read', generation }) +} + +function readySession(): MobileWebShellStep { + return run( + afterCacheRead(CACHED).session, + { type: 'manifest-read', manifest: MANIFEST }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: MANIFEST.totalBytes, + elapsedMs: 12 + } + ) +} + +/** The second half of a recovery: the refetch the delete queued, through to a mounted view. */ +function readyAgain(session: MobileWebShellSession, sessionId: string): MobileWebShellStep { + return run( + session, + { type: 'cache-read', generation: null }, + { type: 'manifest-read', manifest: MANIFEST }, + { type: 'download-staged' }, + { + type: 'activated', + generationDirectory: '/cache/gen', + sessionId, + buildId: MANIFEST.buildId, + totalBytes: MANIFEST.totalBytes, + elapsedMs: 7 + } + ) +} + +describe('the gates decide whether a step is taken at all', () => { + it('waits while a connection is still being made', () => { + const step = started({ reachability: 'connecting' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([]) + }) + + it('waits while status.get is still pending rather than reading its empty capabilities', () => { + const step = started({ statusPending: true, hostCapabilities: [] }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([]) + }) + + it('says a status could not be read rather than walling or waiting on it forever', () => { + const step = started({ statusReadable: false, hostCapabilities: [] }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: false + }) + expect(step.effects).toEqual([]) + }) + + it('picks the flow back up if that status ever becomes readable', () => { + const unreadable = started({ statusReadable: false, hostCapabilities: [] }) + const step = run(unreadable.session, { type: 'gates-changed', gates: gates() }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('walls a readable host that serves no bundle', () => { + const step = started({ hostCapabilities: [] }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + expect(step.effects).toEqual([]) + }) + + it('sweeps and reads the cache once the capability is answered', () => { + expect(started().effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('reads the cache for an unreachable host too, before deciding anything', () => { + expect(started({ reachability: 'unreachable' }).effects).toEqual([{ kind: 'open-cache' }]) + }) +}) + +describe('the offline rule', () => { + it('opens a cached generation with no compat check when the host is unreachable', () => { + const start = started({ reachability: 'unreachable', hostCapabilities: [] }) + const step = run(start.session, { type: 'cache-read', generation: CACHED }) + expect(step.session.state).toEqual({ kind: 'activating' }) + expect(step.effects).toEqual([ + { + kind: 'open-generation', + directory: CACHED.directory, + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes + } + ]) + }) + + it('says so when an unreachable host has nothing cached', () => { + const start = started({ reachability: 'unreachable' }) + const step = run(start.session, { type: 'cache-read', generation: null }) + expect(step.session.state).toEqual({ kind: 'offline' }) + expect(step.effects).toEqual([]) + }) + + it('waits on a cache read that lands mid-dial instead of opening it unchecked', () => { + const dialling = run(started().session, { + type: 'gates-changed', + gates: gates({ reachability: 'connecting' }) + }) + const step = run(dialling.session, { type: 'cache-read', generation: CACHED }) + // Connecting is not unreachable: the compat check is a moment away, and skipping it would put a + // generation on screen the host is about to say it no longer serves. + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.session.cached).toEqual(CACHED) + expect(step.effects).toEqual([]) + }) + + it('restarts the flow when the host becomes reachable while offline is showing', () => { + const offline = run(started({ reachability: 'unreachable' }).session, { + type: 'cache-read', + generation: null + }) + const step = run(offline.session, { type: 'gates-changed', gates: gates() }) + expect(step.effects).toEqual([{ kind: 'open-cache' }]) + }) +}) + +describe('the connected flow', () => { + it('asks the host for a manifest once the cache has been read', () => { + expect(afterCacheRead(null).effects).toEqual([{ kind: 'read-manifest' }]) + expect(afterCacheRead(CACHED).effects).toEqual([{ kind: 'read-manifest' }]) + }) + + it('walls a manifest written in a schema this shell does not know', () => { + const step = run(afterCacheRead(null).session, { + type: 'manifest-read', + manifest: { ...MANIFEST, schemaVersion: 99 } + }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-shell-too-old', schemaVersion: 99 } + }) + expect(step.effects).toEqual([]) + }) + + it('opens the cached generation without paging when the build ids match', () => { + const step = run(afterCacheRead(CACHED).session, { type: 'manifest-read', manifest: MANIFEST }) + expect(step.session.state).toEqual({ kind: 'activating' }) + expect(step.effects).toEqual([ + { + kind: 'open-generation', + directory: CACHED.directory, + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes + } + ]) + }) + + it('downloads when the cached build id is a different one', () => { + const stale = { ...CACHED, buildId: 'c'.repeat(64) } + const step = run(afterCacheRead(stale).session, { type: 'manifest-read', manifest: MANIFEST }) + expect(step.session.state).toEqual({ + kind: 'fetching', + completedAssets: 0, + totalAssets: 4, + receivedBytes: 0, + totalBytes: 4096 + }) + expect(step.effects).toEqual([{ kind: 'download' }]) + }) + + it('downloads when there is no cache at all', () => { + const step = run(afterCacheRead(null).session, { type: 'manifest-read', manifest: MANIFEST }) + expect(step.effects).toEqual([{ kind: 'download' }]) + }) + + it('carries download progress and then stages and activates', () => { + const fetching = run(afterCacheRead(null).session, { + type: 'manifest-read', + manifest: MANIFEST + }) + const progressed = run(fetching.session, { + type: 'fetch-progress', + completedAssets: 2, + totalAssets: 4, + receivedBytes: 2048, + totalBytes: 4096 + }) + expect(progressed.session.state).toMatchObject({ kind: 'fetching', completedAssets: 2 }) + const staged = run(progressed.session, { type: 'download-staged' }) + expect(staged.session.state).toEqual({ kind: 'activating' }) + const ready = run(staged.session, { + type: 'activated', + generationDirectory: '/cache/gen', + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: 4096, + elapsedMs: 900 + }) + expect(ready.session.state).toEqual({ + kind: 'ready', + generationDirectory: '/cache/gen', + sessionId: 'session-one', + buildId: MANIFEST.buildId, + totalBytes: 4096, + elapsedMs: 900 + }) + }) + + it('ignores progress that arrives after the fetching state is gone', () => { + const ready = readySession() + const step = run(ready.session, { + type: 'fetch-progress', + completedAssets: 1, + totalAssets: 4, + receivedBytes: 1, + totalBytes: 4096 + }) + expect(step.session.state).toEqual(ready.session.state) + }) + + it('fails when the download or the cache write never produced a generation', () => { + const step = run(afterCacheRead(null).session, { + type: 'download-failed', + failure: 'bundle' + }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'download-failed', + retriedOnce: false + }) + }) +}) + +describe('a read the link cut short falls back to what is on disk', () => { + /** Connected, a generation cached, the manifest read in flight — where the drop is felt. */ + function manifestInFlight() { + return afterCacheRead(CACHED) + } + + it('opens the cached generation when the socket drops before the reachability change does', () => { + const step = run(manifestInFlight().session, { type: 'download-failed', failure: 'transport' }) + expect(step.session.state).toEqual({ kind: 'activating' }) + expect(step.effects).toEqual([ + { + kind: 'open-generation', + directory: CACHED.directory, + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes + } + ]) + const ready = run(step.session, { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes, + elapsedMs: 12 + }) + expect(ready.session.state).toMatchObject({ kind: 'ready', buildId: CACHED.buildId }) + }) + + it('still says the workspace could not be downloaded when nothing is on disk', () => { + const step = run(afterCacheRead(null).session, { + type: 'download-failed', + failure: 'transport' + }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'download-failed', + retriedOnce: false + }) + expect(step.effects).toEqual([]) + }) + + it('fails on a verdict about the bundle even with a generation cached', () => { + // A host that refuses the read, or bytes that do not hash, is an answer about the bundle. A + // cached generation is no reason to hide it behind a workspace that is merely older. + const step = run(manifestInFlight().session, { type: 'download-failed', failure: 'bundle' }) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'download-failed', + retriedOnce: false + }) + expect(step.effects).toEqual([]) + }) +}) + +describe('a displayed generation is not restarted by the gates', () => { + it.each(['connected', 'unreachable', 'connecting'] as const)( + 'keeps a ready session when reachability becomes %s', + (reachability) => { + const ready = readySession() + const step = run(ready.session, { type: 'gates-changed', gates: gates({ reachability }) }) + expect(step.session.state).toEqual(ready.session.state) + expect(step.effects).toEqual([]) + } + ) + + it('keeps a wall and a terminal failure', () => { + const wall = started({ hostCapabilities: [] }) + expect(run(wall.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([]) + const failed = run(afterCacheRead(null).session, { + type: 'download-failed', + failure: 'bundle' + }) + expect(run(failed.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([]) + }) +}) + +describe('recovery follows the shell view contract', () => { + it.each(['generation-unreadable', 'document-load-failed'] as const)( + 'deletes this host cache and runs once more on %s', + (reason) => { + const step = run(readySession().session, { type: 'shell-failed', reason }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }]) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.session.retriedOnce).toBe(true) + expect(step.session.cached).toBeNull() + } + ) + + it('takes a recovery through the gate rather than back to a manifest check', () => { + const ready = readySession() + // A reconnect whose status probe failed. Stored, not acted on: a workspace on screen is not + // restarted by a gates change, which is how a ready session ends up holding one like this. + const stale = run(ready.session, { + type: 'gates-changed', + gates: gates({ statusReadable: false, hostCapabilities: [] }) + }) + expect(stale.session.state).toMatchObject({ kind: 'ready' }) + const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' }) + // Not the wall the empty capability list would have produced, which nothing leaves. + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: true + }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }]) + const rearmed = run(step.session, { type: 'gates-changed', gates: gates() }) + expect(rearmed.session.state).toEqual({ kind: 'checking' }) + expect(rearmed.effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('still walls a recovery whose host readably serves no bundle', () => { + const stale = run(readySession().session, { + type: 'gates-changed', + gates: gates({ hostCapabilities: [] }) + }) + const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }]) + }) + + it('deletes the suspect cache and waits when the recovery lands mid-reconnect', () => { + const dialling = run(readySession().session, { + type: 'gates-changed', + gates: gates({ reachability: 'connecting' }) + }) + const step = run(dialling.session, { type: 'shell-failed', reason: 'generation-unreadable' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([{ kind: 'delete-cache' }]) + expect(run(step.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([ + { kind: 'open-cache' } + ]) + }) + + it.each(['generation-unreadable', 'document-load-failed'] as const)( + 'is terminal the second time %s is reported', + (reason) => { + const first = run(readySession().session, { type: 'shell-failed', reason }) + const refetched = readyAgain(first.session, 'session-two') + const second = run(refetched.session, { type: 'shell-failed', reason }) + expect(second.effects).toEqual([]) + expect(second.session.state).toEqual({ kind: 'failed', reason, retriedOnce: true }) + } + ) + + it('remounts once on render-process-gone and never deletes anything', () => { + const ready = readySession() + const step = run(ready.session, { type: 'shell-failed', reason: 'render-process-gone' }) + expect(step.effects).toEqual([{ kind: 'remount' }]) + expect(step.session.state).toEqual(ready.session.state) + const remounted = run(step.session, { type: 'remounted', sessionId: 'session-two' }) + expect(remounted.session.state).toMatchObject({ + kind: 'ready', + sessionId: 'session-two', + generationDirectory: CACHED.directory + }) + }) + + it('is terminal the second time the render process is gone, still without a delete', () => { + const first = run(readySession().session, { + type: 'shell-failed', + reason: 'render-process-gone' + }) + const remounted = run(first.session, { type: 'remounted', sessionId: 'session-two' }) + const second = run(remounted.session, { type: 'shell-failed', reason: 'render-process-gone' }) + expect(second.effects).toEqual([]) + expect(second.session.state).toEqual({ + kind: 'failed', + reason: 'render-process-gone', + retriedOnce: false + }) + }) + + it('is terminal on the first isolation-unavailable, with no retry and no delete', () => { + const step = run(readySession().session, { + type: 'shell-failed', + reason: 'isolation-unavailable' + }) + expect(step.effects).toEqual([]) + expect(step.session.state).toEqual({ + kind: 'failed', + reason: 'isolation-unavailable', + retriedOnce: false + }) + }) + + it('ignores a session id for a generation that is no longer ready', () => { + const step = run(started().session, { type: 'remounted', sessionId: 'session-two' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + }) +}) + +describe('try again', () => { + it('clears both latches and restarts the flow', () => { + const first = run(readySession().session, { + type: 'shell-failed', + reason: 'document-load-failed' + }) + const refetched = readyAgain(first.session, 'session-two') + const failed = run(refetched.session, { type: 'shell-failed', reason: 'document-load-failed' }) + const retried = run(failed.session, { type: 'retry-pressed' }) + expect(retried.session.retriedOnce).toBe(false) + expect(retried.session.remountedOnce).toBe(false) + expect(retried.session.cached).toBeNull() + expect(retried.effects).toEqual([{ kind: 'open-cache' }]) + // And the delete-and-refetch is available again. + const again = run( + run(retried.session, { type: 'cache-read', generation: CACHED }).session, + { type: 'manifest-read', manifest: MANIFEST }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-three', + buildId: MANIFEST.buildId, + totalBytes: 4096, + elapsedMs: 3 + }, + { type: 'shell-failed', reason: 'document-load-failed' } + ) + expect(again.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }]) + }) + + it('walls again rather than looping when the host still serves no bundle', () => { + const wall = started({ hostCapabilities: [] }) + const retried = run(wall.session, { type: 'retry-pressed' }) + expect(retried.session.state).toMatchObject({ kind: 'wall' }) + expect(retried.effects).toEqual([]) + }) + + it('does nothing but reset when no gates have arrived yet', () => { + const step = run(createMobileWebShellSession(), { type: 'retry-pressed' }) + expect(step.session.state).toEqual({ kind: 'checking' }) + expect(step.effects).toEqual([]) + }) +}) + +describe('a result from a superseded flow reports into nothing', () => { + it('drops the cache read of a run a reconnect replaced, so nothing opens unchecked', () => { + const first = started({ reachability: 'unreachable' }) + const restarted = run(first.session, { type: 'gates-changed', gates: gates() }) + expect(restarted.effects).toEqual([{ kind: 'open-cache' }]) + // The offline read would have opened this generation with no compat check at all. + const stale = run(restarted.session, { + type: 'cache-read', + flow: first.session.flow, + generation: CACHED + }) + expect(stale.effects).toEqual([]) + expect(stale.session.cached).toBeNull() + expect(run(stale.session, { type: 'cache-read', generation: CACHED }).effects).toEqual([ + { kind: 'read-manifest' } + ]) + }) + + it('drops the manifest of a run the socket drop replaced, so no download is asked for', () => { + const first = afterCacheRead(null) + const restarted = run(first.session, { + type: 'gates-changed', + gates: gates({ reachability: 'unreachable' }) + }) + const stale = run(restarted.session, { + type: 'manifest-read', + flow: first.session.flow, + manifest: MANIFEST + }) + expect(stale.effects).toEqual([]) + expect(stale.session.state).toEqual({ kind: 'checking' }) + const current = run(stale.session, { type: 'cache-read', generation: null }) + expect(current.session.state).toEqual({ kind: 'offline' }) + expect(current.effects).toEqual([]) + }) + + it('keeps a workspace on screen when the manifest read the drop abandoned finally rejects', () => { + // The reproduced sequence: connected, cache read, manifest in flight, socket drops, the offline + // path opens the cached generation, and only then does the abandoned RPC settle. + const inFlight = afterCacheRead(CACHED) + const offline = run(inFlight.session, { + type: 'gates-changed', + gates: gates({ reachability: 'unreachable' }) + }) + const ready = run( + offline.session, + { type: 'cache-read', generation: CACHED }, + { + type: 'activated', + generationDirectory: CACHED.directory, + sessionId: 'session-one', + buildId: CACHED.buildId, + totalBytes: CACHED.totalBytes, + elapsedMs: 4 + } + ) + expect(ready.session.state).toMatchObject({ kind: 'ready' }) + const late = run(ready.session, { + type: 'download-failed', + failure: 'bundle', + flow: inFlight.session.flow + }) + expect(late.session.state).toEqual(ready.session.state) + }) + + it('applies a remount of the current flow and ignores one from a replaced run', () => { + const ready = readySession() + const remounting = run(ready.session, { type: 'shell-failed', reason: 'render-process-gone' }) + const stale = run(remounting.session, { + type: 'remounted', + flow: remounting.session.flow - 1, + sessionId: 'session-stale' + }) + expect(stale.session.state).toEqual(ready.session.state) + expect( + run(stale.session, { type: 'remounted', sessionId: 'session-two' }).session.state + ).toMatchObject({ sessionId: 'session-two' }) + }) +}) + +describe('the remount budget is one per session, not one per reconnect', () => { + it('keeps the latch set when the gates restart the flow after a load failure', () => { + const remounted = run( + readySession().session, + { type: 'shell-failed', reason: 'render-process-gone' }, + { type: 'remounted', sessionId: 'session-two' }, + { type: 'shell-failed', reason: 'document-load-failed' } + ) + expect(remounted.session.remountedOnce).toBe(true) + const restarted = run(remounted.session, { type: 'gates-changed', gates: gates() }) + expect(restarted.session.remountedOnce).toBe(true) + expect(run(restarted.session, { type: 'retry-pressed' }).session.remountedOnce).toBe(false) + }) +}) + +describe('only the state that mounted the view hears the view', () => { + it('ignores the second failure of one native batch, leaving the first recovery running', () => { + const recovering = run(readySession().session, { + type: 'shell-failed', + reason: 'document-load-failed' + }) + const batched = run(recovering.session, { + type: 'shell-failed', + reason: 'render-process-gone' + }) + expect(batched.session.state).toEqual({ kind: 'checking' }) + expect(batched.effects).toEqual([]) + expect(batched.session.flow).toBe(recovering.session.flow) + // And the cache read the recovery already asked for still lands on the recovery. + expect(run(batched.session, { type: 'cache-read', generation: null }).effects).toEqual([ + { kind: 'read-manifest' } + ]) + }) + + it('leaves a wall standing when a view that is no longer mounted reports a failure', () => { + const wall = started({ hostCapabilities: [] }) + const step = run(wall.session, { type: 'shell-failed', reason: 'isolation-unavailable' }) + expect(step.session.state).toEqual(wall.session.state) + expect(step.effects).toEqual([]) + }) +}) + +describe('a gates change that says nothing new starts nothing', () => { + it('leaves a check in flight alone rather than sweeping and reading a second time', () => { + const checking = started() + const again = run(checking.session, { type: 'gates-changed', gates: gates() }) + expect(again.effects).toEqual([]) + expect(again.session.flow).toBe(checking.session.flow) + }) + + it('holds the offline screen through a reconnect cycle that never reaches the host', () => { + const offline = run(started({ reachability: 'unreachable' }).session, { + type: 'cache-read', + generation: null + }) + const cycled = run( + offline.session, + { type: 'gates-changed', gates: gates({ reachability: 'unreachable' }) }, + { type: 'gates-changed', gates: gates({ reachability: 'unreachable' }) } + ) + expect(cycled.effects).toEqual([]) + expect(cycled.session.state).toEqual({ kind: 'offline' }) + }) + + it('restarts on the verdict that changed, not on the object that was rebuilt', () => { + const checking = started({ statusPending: true }) + const settled = run(checking.session, { type: 'gates-changed', gates: gates() }) + expect(settled.effects).toEqual([{ kind: 'open-cache' }]) + }) + + it('walls a check in flight the moment the host stops serving a bundle', () => { + const checking = started() + const step = run(checking.session, { + type: 'gates-changed', + gates: gates({ hostCapabilities: [] }) + }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { kind: 'blocked', reason: 'bundle-unavailable' } + }) + }) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts new file mode 100644 index 00000000000..5bc9f893ab6 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -0,0 +1,385 @@ +import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { evaluateMobileWebBundleCompat } from '../transport/mobile-web-bundle-compat' +import type { + CachedGeneration, + MobileWebShellBlockedVerdict, + MobileWebShellGates, + MobileWebShellManifestFacts, + MobileWebShellReachability, + MobileWebShellReadFailure, + MobileWebShellSession, + MobileWebShellSessionEffect, + MobileWebShellSessionEvent, + MobileWebShellSessionState, + MobileWebShellStep +} from './mobile-web-shell-session-contract' + +/** + * The host's connection state as the three answers a step here needs. + * + * `reconnecting` is unreachable, not connecting, and that is the whole point of the distinction: a + * host whose desktop is gone never settles on `disconnected`. The client dials, fails, schedules a + * retry and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a + * minute, so treating `reconnecting` as "still dialling" leaves a phone with a perfectly good + * cached workspace spinning forever. `connecting` alone is the first dial, which is worth the wait + * because it usually succeeds; a scheduled retry after a failure is evidence the host is not there. + */ +export function readMobileWebShellReachability( + connState: ConnectionState, + client: RpcClient | null +): MobileWebShellReachability { + if (connState === 'connected') { + return client === null ? 'connecting' : 'connected' + } + return connState === 'connecting' || connState === 'handshaking' ? 'connecting' : 'unreachable' +} + +const CHECKING: MobileWebShellSessionState = { kind: 'checking' } + +export function createMobileWebShellSession(): MobileWebShellSession { + return { + state: CHECKING, + retriedOnce: false, + remountedOnce: false, + gates: null, + cached: null, + flow: 0 + } +} + +function step( + session: MobileWebShellSession, + patch: Partial, + effects: readonly MobileWebShellSessionEffect[] = [] +): MobileWebShellStep { + return { session: { ...session, ...patch }, effects } +} + +/** + * Whether a gates change may start or restart the flow. + * + * Only from the two states still waiting on one. A displayed generation is not restarted by a + * reconnect: the manifest check that would follow swaps the page out from under whoever is reading + * it, and a cached generation stays valid until the route is entered again. A wall and a terminal + * failure are both left by acting, so neither reacts either. + */ +function awaitsGates(state: MobileWebShellSessionState): boolean { + if (state.kind === 'failed') { + // The one failure the gates can answer: a status that becomes readable is a different host + // screen, and it costs nothing to take it rather than make someone walk back out. + return state.reason === 'status-unreadable' + } + return state.kind === 'checking' || state.kind === 'offline' +} + +/** + * What the gates permit, before any manifest is read. + * + * One answer for both ways into the flow. A recovery used to keep whatever gates the `ready` + * session was holding and go straight back to the manifest check, and gates that arrive while a + * generation is on screen are stored without restarting: a reconnect whose status probe failed + * therefore left a ready session carrying an unreadable status and an empty capability list, and + * the next view failure walled the host as `bundle-unavailable` — terminal, no retry, about a host + * that had simply not answered. + */ +type MobileWebShellGateVerdict = + /** Nothing is decidable yet. Two kinds rather than one so a dial that settles into a pending + * status still counts as a change worth restarting on. */ + | { readonly kind: 'dialling' } + | { readonly kind: 'pending' } + | { readonly kind: 'offline' } + | { readonly kind: 'status-unreadable' } + | { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict } + | { readonly kind: 'open' } + +function gateVerdict(gates: MobileWebShellGates): MobileWebShellGateVerdict { + if (gates.reachability === 'connecting') { + return { kind: 'dialling' } + } + if (gates.reachability === 'unreachable') { + return { kind: 'offline' } + } + if (gates.statusPending) { + return { kind: 'pending' } + } + // Never a wall on an unreadable status: the empty capability list it leaves behind is + // indistinguishable from a desktop that ships no bundle, and that wall tells the wrong story. It + // is not a wait either — the gate settles once per host screen and does not probe again — so the + // one honest answer is to say the status could not be read and let a fresh gate reopen it. + if (!gates.statusReadable) { + return { kind: 'status-unreadable' } + } + const verdict = evaluateMobileWebBundleCompat({ + hostCapabilities: gates.hostCapabilities, + hostStatus: gates.hostStatus, + manifest: null + }) + // Which block, not why: any blocked verdict walls, and the wall reads its own reason. + return verdict.kind === 'blocked' ? { kind: 'wall', verdict } : { kind: 'open' } +} + +/** + * The gate verdict as one comparable value. + * + * A restart is worth taking only when this changes. The gates object is rebuilt on every status + * refetch and every connection event, and most of those say exactly what the last one said: a + * reconnect cycle that re-derives the same verdict used to re-sweep the staging tree and flip an + * offline screen to a spinner and back for as long as the cycle ran. + */ +function gateKey(gates: MobileWebShellGates): string { + return gateVerdict(gates).kind +} + +/** + * The step the gate takes, and every entry into the flow goes through it. + * + * The first run, the one "Try again" returns to, and the recovery a failed view triggers, which + * passes the delete it owes as `before` so the cache goes whatever the gate then decides. + */ +function startFlow( + session: MobileWebShellSession, + gates: MobileWebShellGates, + patch: Partial = {}, + before: readonly MobileWebShellSessionEffect[] = [] +): MobileWebShellStep { + // A new flow, so nothing the replaced one has in flight can land on this one. That is also what + // keeps a status refetch arriving mid-check from running the cache read and the download twice. + const base = { ...patch, gates, flow: session.flow + 1 } + const verdict = gateVerdict(gates) + if (verdict.kind === 'wall') { + return step(session, { ...base, state: { kind: 'wall', verdict: verdict.verdict } }, before) + } + if (verdict.kind === 'status-unreadable') { + return step( + session, + { + ...base, + state: { + kind: 'failed', + reason: 'status-unreadable', + retriedOnce: patch.retriedOnce ?? session.retriedOnce + } + }, + before + ) + } + if (verdict.kind === 'dialling' || verdict.kind === 'pending') { + return step(session, { ...base, state: CHECKING }, before) + } + // Offline sweeps and reads the cache exactly as a connected host does. What it skips is the + // compat check, and `onCacheRead` is where that shows. + return step(session, { ...base, state: CHECKING }, [...before, { kind: 'open-cache' }]) +} + +/** Puts a generation that is already on disk on screen. The only producer of `open-generation`. */ +function openCached( + session: MobileWebShellSession, + generation: CachedGeneration, + patch: Partial = {} +): MobileWebShellStep { + return step(session, { ...patch, state: { kind: 'activating' } }, [ + { + kind: 'open-generation', + directory: generation.directory, + buildId: generation.buildId, + totalBytes: generation.totalBytes + } + ]) +} + +function onCacheRead( + session: MobileWebShellSession, + generation: CachedGeneration | null +): MobileWebShellStep { + const gates = session.gates + if (gates === null) { + return step(session, { cached: generation }) + } + if (gates.reachability === 'connecting') { + // A dial in progress is not a host that cannot be reached: opening the cache here would skip a + // compat check the connection about to land is what makes answerable. + return step(session, { cached: generation }) + } + if (gates.reachability === 'unreachable') { + // No compat check on this path, by design: the generation was compatible when it was cached and + // a host nobody can reach cannot have changed since. The next entry while connected re-checks. + return generation === null + ? step(session, { cached: null, state: { kind: 'offline' } }) + : openCached(session, generation, { cached: generation }) + } + return step(session, { cached: generation, state: CHECKING }, [{ kind: 'read-manifest' }]) +} + +function onManifestRead( + session: MobileWebShellSession, + manifest: MobileWebShellManifestFacts +): MobileWebShellStep { + const gates = session.gates + if (gates === null) { + return step(session, {}) + } + const verdict = evaluateMobileWebBundleCompat({ + hostCapabilities: gates.hostCapabilities, + hostStatus: gates.hostStatus, + manifest + }) + if (verdict.kind === 'blocked') { + return step(session, { state: { kind: 'wall', verdict } }) + } + const cached = session.cached + if (cached !== null && cached.buildId === manifest.buildId) { + return openCached(session, cached) + } + return step( + session, + { + state: { + kind: 'fetching', + completedAssets: 0, + totalAssets: manifest.totalAssets, + receivedBytes: 0, + totalBytes: manifest.totalBytes + } + }, + [{ kind: 'download' }] + ) +} + +/** + * B3's contract, and the only place it is interpreted. + * + * `generation-unreadable` and `document-load-failed` say the bytes on disk are suspect, so the + * host's cache goes and the flow runs once more. `render-process-gone` says nothing about the + * bytes — renderer memory pressure and a WebView provider update look identical from here — so it + * remounts and never deletes. `isolation-unavailable` is terminal on the first report: the fence is + * the whole reason this view exists, and a device that cannot install it will not on a retry. + * + * Only `ready` hears any of it. The view exists in no other state, so a report arriving outside one + * is from a view that has already been taken off screen: the second failure of a native batch that + * the first one's recovery has already answered, or a mount that a wall or a retry has replaced. + * Acting on it would strand the recovery already in flight — the delete-and-refetch would be made + * terminal while its own cache read was still coming back, and that read would then drag the + * session back to checking behind a failure screen. + */ +function onShellFailed( + session: MobileWebShellSession, + reason: MobileWebShellFailureReason +): MobileWebShellStep { + if (session.state.kind !== 'ready') { + return step(session, {}) + } + const failed = { kind: 'failed', reason, retriedOnce: session.retriedOnce } as const + if (reason === 'isolation-unavailable') { + return step(session, { state: failed }) + } + if (reason === 'render-process-gone') { + return session.remountedOnce + ? step(session, { state: failed }) + : step(session, { remountedOnce: true }, [{ kind: 'remount' }]) + } + if (session.retriedOnce || session.gates === null) { + return step(session, { state: failed }) + } + // Through the gate, not straight back to the manifest check: the gates a ready session holds are + // whatever the last reconnect stored, so a recovery that trusted them walled hosts whose status + // had gone unreadable underneath a workspace that was, until this failure, working. + return startFlow(session, session.gates, { retriedOnce: true, cached: null }, [ + { kind: 'delete-cache' } + ]) +} + +function onDownloadFailed( + session: MobileWebShellSession, + failure: MobileWebShellReadFailure +): MobileWebShellStep { + const cached = session.cached + if (failure === 'transport' && cached !== null) { + // The link went, not the bundle. A generation already on disk was compatible when it was + // written, and it is the same one the offline gate would have opened had the reachability + // change arrived before this rejection did; which of the two lands first is a race. + return openCached(session, cached) + } + return step(session, { + state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce } + }) +} + +/** + * One transition of the hybrid shell session: a state and the effects the runner owes it. + * + * Pure, so every rule above is a table test rather than a simulator run. The runner may drop an + * effect's result (an unmount, a host change) but must never invent one, and a result it reports + * late is dropped here by its flow rather than by whatever state the session happens to be in. + */ +export function reduceMobileWebShellSession( + session: MobileWebShellSession, + event: MobileWebShellSessionEvent +): MobileWebShellStep { + if ('flow' in event && event.flow !== session.flow) { + return step(session, {}) + } + switch (event.type) { + case 'gates-changed': + return awaitsGates(session.state) && + (session.gates === null || gateKey(session.gates) !== gateKey(event.gates)) + ? startFlow(session, event.gates) + : step(session, { gates: event.gates }) + case 'cache-read': + return onCacheRead(session, event.generation) + case 'manifest-read': + return onManifestRead(session, event.manifest) + case 'fetch-progress': + return session.state.kind === 'fetching' + ? step(session, { + state: { + kind: 'fetching', + completedAssets: event.completedAssets, + totalAssets: event.totalAssets, + receivedBytes: event.receivedBytes, + totalBytes: event.totalBytes + } + }) + : step(session, {}) + case 'download-staged': + return session.state.kind === 'fetching' + ? step(session, { state: { kind: 'activating' } }) + : step(session, {}) + case 'activated': + return step(session, { + state: { + kind: 'ready', + generationDirectory: event.generationDirectory, + sessionId: event.sessionId, + buildId: event.buildId, + totalBytes: event.totalBytes, + elapsedMs: event.elapsedMs + } + }) + case 'remounted': + // Only the session id changes, so the view remounts against the same verified bytes. + return session.state.kind === 'ready' + ? step(session, { state: { ...session.state, sessionId: event.sessionId } }) + : step(session, {}) + case 'download-failed': + return onDownloadFailed(session, event.failure) + case 'shell-failed': + return onShellFailed(session, event.reason) + case 'retry-pressed': + // Clears both latches, so the delete-and-refetch and the remount are each available again. + // Only here: a reconnect is not a reason to grant a second remount of the same session. + return session.gates === null + ? step(session, { + retriedOnce: false, + remountedOnce: false, + state: CHECKING, + flow: session.flow + 1 + }) + : startFlow(session, session.gates, { + retriedOnce: false, + remountedOnce: false, + cached: null + }) + } +} diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts new file mode 100644 index 00000000000..5aa808c952a --- /dev/null +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts @@ -0,0 +1,346 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch' +import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas' +import type { ActiveGeneration, GenerationStore, StagedGeneration } from './generation-store' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' + +/** + * The runner, not the rules: what the reducer decides has table tests, and this covers the three + * things only the wiring can get wrong — abandoning an effect whose session is gone, aborting the + * bytes it was pulling, and doing both again when someone taps Try again. A cancellation that is + * merely intended is a download that keeps four of the host's read slots and a cache write that + * lands under a host nobody is looking at any more. + * + * The React Native and Expo modules are mocked at the edge of the import graph rather than stubbed + * one deep, because importing any of them pulls the runtime this test does not have. + */ +type Settle = (value: T) => void + +type Doubles = { + connection: { client: object | null; state: string } + gates: { + statusPending: boolean + statusReadable: boolean + hostCapabilities: string[] + hostProtocolWindow: { protocolVersion: number; minCompatibleMobileVersion: number } + } + manifestReads: number + manifestClients: unknown[] + manifestRejection: unknown + fetches: { signal: AbortSignal; settle: Settle }[] + manifest: MobileWebBundleManifestRead +} + +const doubles = vi.hoisted((): Doubles => { + const manifest: MobileWebBundleManifestRead = { + schemaVersion: 1, + buildId: 'b'.repeat(64), + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 5, + entrypoint: 'index.html', + totalBytes: 2048, + assets: [ + { path: 'index.html', sha256: 'c'.repeat(64), byteLength: 2048, contentType: 'text/html' } + ] + } + return { + connection: { client: {}, state: 'connected' }, + gates: { + statusPending: false, + statusReadable: true, + // Filled in `beforeEach`: a hoisted factory runs before this module's imports do. + hostCapabilities: [], + hostProtocolWindow: { protocolVersion: 10, minCompatibleMobileVersion: 1 } + }, + manifestReads: 0, + manifestClients: [], + manifestRejection: null, + fetches: [], + manifest + } +}) + +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths: { cache: '' } })) +vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({ + encodeBase64Url: () => 'session-id' +})) +vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates })) +vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection })) +vi.mock('../transport/rpc-operation', () => ({ + defineRpcOperation: (definition: unknown) => definition, + runRpcOperation: async (client: unknown) => { + doubles.manifestReads += 1 + doubles.manifestClients.push(client) + if (doubles.manifestRejection !== null) { + throw doubles.manifestRejection + } + return { manifest: doubles.manifest } + } +})) +vi.mock('../transport/mobile-web-bundle-fetch', () => ({ + fetchMobileWebBundle: (args: { signal: AbortSignal }) => + new Promise((resolve) => { + doubles.fetches.push({ signal: args.signal, settle: resolve }) + }) +})) + +import { useMobileWebShellSession } from './use-mobile-web-shell-session' + +const HOST_ID = 'host-1' +const DIRECTORY = 'file:///cache/mobile-web/host/generations/b' + +function activeGeneration(): ActiveGeneration { + return { buildId: doubles.manifest.buildId, directory: DIRECTORY, manifest: doubles.manifest } +} + +function stagedGeneration(): StagedGeneration { + return { + hostKey: 'host-key', + buildId: doubles.manifest.buildId, + directory: DIRECTORY, + manifest: doubles.manifest + } +} + +/** A store whose cache read is held open, so a test can decide when the answer arrives. Staging can + * be held open too, which is the only way to stand inside the window between it and the commit. */ +function createFakeStore(): { + store: GenerationStore + settleCacheRead: Settle + holdStage: () => void + settleStage: () => void + staged: () => number + committed: () => number + aborted: () => number +} { + let settleCacheRead: Settle = () => {} + let releaseStage: () => void = () => {} + let heldStage = false + let staged = 0 + let committed = 0 + let aborted = 0 + const store: GenerationStore = { + readActiveGeneration: () => + new Promise((resolve) => { + settleCacheRead = resolve + }), + stageGeneration: async () => { + staged += 1 + if (heldStage) { + await new Promise((resolve) => { + releaseStage = resolve + }) + } + return stagedGeneration() + }, + commitGeneration: async () => { + committed += 1 + return activeGeneration() + }, + abortStagedGeneration: async () => { + aborted += 1 + }, + sweepStagedGenerations: async () => undefined, + deleteHostCache: async () => undefined + } + return { + store, + settleCacheRead: (value) => settleCacheRead(value), + holdStage: () => { + heldStage = true + }, + settleStage: () => releaseStage(), + staged: () => staged, + committed: () => committed, + aborted: () => aborted + } +} + +type Mounted = { + tree: ReactTestRenderer + retry: () => void + rerender: () => void + states: () => readonly MobileWebShellSessionState[] +} + +async function mount(store: GenerationStore): Promise { + const handle: { retry: () => void; states: MobileWebShellSessionState[] } = { + retry: () => {}, + states: [] + } + function Probe() { + const session = useMobileWebShellSession({ + hostId: HOST_ID, + runtime: { createStore: () => store, mintSessionId: () => 'session-id', now: () => 0 } + }) + handle.retry = session.retry + handle.states.push(session.state) + return null + } + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create(createElement(Probe)) + }) + const tree = rendered.tree + if (tree === null) { + throw new Error('the hook did not mount') + } + return { + tree, + retry: () => handle.retry(), + rerender: () => tree.update(createElement(Probe)), + states: () => handle.states + } +} + +async function flush(): Promise { + await act(async () => undefined) +} + +describe('the hybrid shell runner', () => { + beforeEach(() => { + doubles.manifestReads = 0 + doubles.manifestClients.length = 0 + doubles.manifestRejection = null + doubles.fetches.length = 0 + doubles.connection = { client: {}, state: 'connected' } + doubles.gates.hostCapabilities = [MOBILE_WEB_BUNDLE_CAPABILITY] + }) + + it('abandons the cache read of a session that has been unmounted', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + await act(async () => { + mounted.tree.unmount() + }) + fake.settleCacheRead(null) + await flush() + // The read came back to nobody: had it been applied, the next effect would have asked the host + // for a manifest on behalf of a screen that is gone. + expect(doubles.manifestReads).toBe(0) + }) + + it('aborts the download an unmount interrupts, and never writes what it was pulling', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + expect(doubles.fetches).toHaveLength(1) + const inFlight = doubles.fetches[0] + if (inFlight === undefined) { + throw new Error('no download was started') + } + await act(async () => { + mounted.tree.unmount() + }) + expect(inFlight.signal.aborted).toBe(true) + inFlight.settle({ + manifest: doubles.manifest, + assets: new Map(), + totalBytes: 2048, + elapsedMs: 1 + }) + await flush() + expect(fake.staged()).toBe(0) + expect(fake.committed()).toBe(0) + }) + + it('shows the cached workspace when the socket drops the manifest read it was waiting on', async () => { + // The device repro: the rejection reaches the reducer before the reachability change does, so + // the offline gate never fires and only the error's own marks say the link was what went. + doubles.manifestRejection = markRpcDeliveryUnknown(new Error('Connection interrupted')) + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(activeGeneration()) + await flush() + + expect(doubles.manifestReads).toBe(1) + expect(doubles.fetches).toHaveLength(0) + expect(mounted.states().map((state) => state.kind)).toContain('ready') + await act(async () => { + mounted.tree.unmount() + }) + }) + + it('takes the staged tree back out when the unmount lands between staging and the commit', async () => { + const fake = createFakeStore() + fake.holdStage() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + const inFlight = doubles.fetches[0] + if (inFlight === undefined) { + throw new Error('no download was started') + } + inFlight.settle({ + manifest: doubles.manifest, + assets: new Map(), + totalBytes: 2048, + elapsedMs: 1 + }) + await flush() + expect(fake.staged()).toBe(1) + + await act(async () => { + mounted.tree.unmount() + }) + await act(async () => { + fake.settleStage() + }) + // The commit is the write the staging tree cannot undo: it renames into the active slot and + // moves the host index, so a generation nobody asked for would be the one the next mount opens. + expect(fake.committed()).toBe(0) + expect(fake.aborted()).toBe(1) + expect(mounted.states().map((state) => state.kind)).not.toContain('ready') + }) + + it('reads the manifest through the client the host has now, not the one it opened with', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + expect(doubles.manifestClients).toHaveLength(1) + // A reconnect hands the screen a new client object with the same reachability, so nothing the + // gates effect watches changes; only the next flow can show which one the runner kept. + const reconnected = {} + doubles.connection = { client: reconnected, state: 'connected' } + await act(async () => { + mounted.rerender() + }) + await act(async () => { + mounted.retry() + }) + fake.settleCacheRead(null) + await flush() + expect(doubles.manifestClients.at(-1)).toBe(reconnected) + await act(async () => { + mounted.tree.unmount() + }) + }) + + it('abandons the download still in flight when Try again starts a new one', async () => { + const fake = createFakeStore() + const mounted = await mount(fake.store) + fake.settleCacheRead(null) + await flush() + const first = doubles.fetches[0] + if (first === undefined) { + throw new Error('no download was started') + } + await act(async () => { + mounted.retry() + }) + expect(first.signal.aborted).toBe(true) + first.settle({ manifest: doubles.manifest, assets: new Map(), totalBytes: 2048, elapsedMs: 1 }) + await flush() + expect(fake.staged()).toBe(0) + await act(async () => { + mounted.tree.unmount() + }) + }) +}) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts new file mode 100644 index 00000000000..87f7e5defd8 --- /dev/null +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -0,0 +1,335 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as ExpoCrypto from 'expo-crypto' +import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' +import { useHostProtocolGates } from '../components/HostProtocolGate' +import { useHostClient } from '../transport/client-context' +import { encodeBase64Url } from '../transport/mobile-endpoint-supervisor-support' +import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch' +import { + isMobileWebBundleTransportFailure, + mobileWebBundleManifestRead +} from '../transport/mobile-web-bundle-operations' +import { runRpcOperation } from '../transport/rpc-operation' +import type { RpcClient } from '../transport/rpc-client' +import { createGenerationStore, type GenerationStore } from './generation-store' +import { + createExpoGenerationFileSystem, + generationDirectoryPath +} from './generation-store-file-system' +import { deriveHostCacheKey } from './host-cache-key' +import { + createMobileWebShellSession, + readMobileWebShellReachability, + reduceMobileWebShellSession +} from './mobile-web-shell-session' +import type { + MobileWebShellReadFailure, + MobileWebShellSessionEffect, + MobileWebShellSessionEvent, + MobileWebShellSessionState +} from './mobile-web-shell-session-contract' + +/** 32 bytes, base64url: the session id scopes the view's private origin, so two mounts must never + * share one and a remount must never reuse the one that was just on screen. */ +const SESSION_ID_BYTES = 32 + +/** The impure edges, injectable so the wiring is testable without a simulator. */ +export type MobileWebShellRuntime = { + createStore(): GenerationStore + mintSessionId(): string + now(): number +} + +function defaultRuntime(): MobileWebShellRuntime { + return { + createStore: () => createGenerationStore({ fileSystem: createExpoGenerationFileSystem() }), + mintSessionId: () => encodeBase64Url(ExpoCrypto.getRandomBytes(SESSION_ID_BYTES)), + now: Date.now + } +} + +export type MobileWebShellSessionView = { + readonly state: MobileWebShellSessionState + readonly retry: () => void + /** B3's failure reasons, forwarded verbatim; the reducer owns what each one means. */ + readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void +} + +/** + * Drives one hybrid shell session for one host: the reducer decides, this runs what it asks for. + * + * Every effect result is checked against an epoch before it is dispatched, so an unmount, a host + * change or a retry abandons work in flight instead of applying it to the next session. Nothing + * here decides anything — a rule that lived in this file would be a rule with no table test. + */ +export function useMobileWebShellSession(args: { + hostId: string + runtime?: MobileWebShellRuntime +}): MobileWebShellSessionView { + const { hostId } = args + const gates = useHostProtocolGates() + const { client, state: connState } = useHostClient(hostId) + + const runtimeRef = useRef(null) + runtimeRef.current ??= args.runtime ?? defaultRuntime() + const runtime = runtimeRef.current + const storeRef = useRef(null) + storeRef.current ??= runtime.createStore() + + const sessionRef = useRef(createMobileWebShellSession()) + const [state, setState] = useState(sessionRef.current.state) + const hostKey = useMemo(() => deriveHostCacheKey(hostId), [hostId]) + const startedAtRef = useRef(runtime.now()) + // Bumped by anything that invalidates work in flight; every dispatch out of an effect checks it. + const epochRef = useRef(0) + // Aborted on the same bump: a download nobody will use still holds four of the host's read slots. + const downloadsRef = useRef>(new Set()) + const runEffectRef = useRef< + ((epoch: number, flow: number, effect: MobileWebShellSessionEffect) => void) | null + >(null) + + const dispatch = useCallback((epoch: number, event: MobileWebShellSessionEvent): void => { + if (epoch !== epochRef.current) { + return + } + const stepped = reduceMobileWebShellSession(sessionRef.current, event) + sessionRef.current = stepped.session + setState(stepped.session.state) + for (const effect of stepped.effects) { + // Every effect of a step belongs to the flow that step produced, and its result carries that + // number back, so a flow the session has since restarted reports into nothing. + runEffectRef.current?.(epoch, stepped.session.flow, effect) + } + }, []) + + const invalidate = useCallback((): void => { + epochRef.current += 1 + for (const controller of downloadsRef.current) { + controller.abort() + } + downloadsRef.current.clear() + }, []) + + const runEffect = useCallback( + async (epoch: number, flow: number, effect: MobileWebShellSessionEffect): Promise => { + const store = storeRef.current + if (store === null) { + return + } + const send = (event: MobileWebShellSessionEvent) => dispatch(epoch, event) + switch (effect.kind) { + case 'delete-cache': + // Reports nothing: the store serialises its own queue, so the sweep and read the reducer + // queued behind this one already run after it. + await store.deleteHostCache(hostKey).catch(() => undefined) + return + case 'open-cache': + send({ type: 'cache-read', flow, generation: await openCache(store, hostKey) }) + return + case 'read-manifest': + await readManifest(client, flow, send) + return + case 'download': + await download({ + client, + store, + hostKey, + flow, + runtime, + startedAt: startedAtRef.current, + downloads: downloadsRef.current, + send + }) + return + case 'open-generation': + send({ + type: 'activated', + flow, + generationDirectory: effect.directory, + sessionId: runtime.mintSessionId(), + buildId: effect.buildId, + totalBytes: effect.totalBytes, + elapsedMs: runtime.now() - startedAtRef.current + }) + return + case 'remount': + send({ type: 'remounted', flow, sessionId: runtime.mintSessionId() }) + return + } + }, + [client, dispatch, hostKey, runtime] + ) + // Written after the commit, never during render: React may replay or discard a render, and a + // closure from one that never committed would run effects for a session that never existed. + // Declared above every effect that dispatches, so the first one already finds it. + useEffect(() => { + runEffectRef.current = (epoch, flow, effect) => { + void runEffect(epoch, flow, effect) + } + }, [runEffect]) + + useEffect(() => { + // A new host is a new session: the old one's latches, cache handle and in-flight work all go. + invalidate() + sessionRef.current = createMobileWebShellSession() + startedAtRef.current = runtime.now() + setState(sessionRef.current.state) + return invalidate + }, [hostId, invalidate, runtime]) + + const { statusPending, statusReadable, hostCapabilities, hostProtocolWindow } = gates + const reachability = readMobileWebShellReachability(connState, client) + useEffect(() => { + dispatch(epochRef.current, { + type: 'gates-changed', + gates: { + statusPending, + statusReadable, + reachability, + hostCapabilities, + hostStatus: hostProtocolWindow + } + }) + // `hostId` is in the list for the host whose gates read identically to the last one's: the + // reducer now starts nothing on a repeat verdict, so a session that never re-armed would sit + // in `checking` forever. + }, [ + dispatch, + hostCapabilities, + hostId, + hostProtocolWindow, + reachability, + statusPending, + statusReadable + ]) + + const retry = useCallback(() => { + // A fresh epoch first: a failed download still in flight must not land on the retried session. + invalidate() + startedAtRef.current = runtime.now() + dispatch(epochRef.current, { type: 'retry-pressed' }) + }, [dispatch, invalidate, runtime]) + + const reportShellFailure = useCallback( + (reason: MobileWebShellFailureReason) => { + dispatch(epochRef.current, { type: 'shell-failed', reason }) + }, + [dispatch] + ) + + return { state, retry, reportShellFailure } +} + +async function openCache( + store: GenerationStore, + hostKey: string +): Promise<{ buildId: string; directory: string; totalBytes: number } | null> { + try { + // Here and nowhere earlier: with the flag off no code path reaches this hook, so a store build + // never sweeps a cache it never wrote. + await store.sweepStagedGenerations() + const active = await store.readActiveGeneration(hostKey) + return active === null + ? null + : { + buildId: active.buildId, + directory: generationDirectoryPath(active.directory), + totalBytes: active.manifest.totalBytes + } + } catch { + // A cache that cannot be read is not a cache that is wrong: nothing is deleted, and the flow + // treats it as absent, which downloads when connected and says so when not. + return null + } +} + +/** A rejection the link caused says nothing about the bundle, and the reducer opens the cache on it + * rather than telling a phone that already holds a workspace it could not be downloaded. */ +function readFailure(error: unknown): MobileWebShellReadFailure { + return isMobileWebBundleTransportFailure(error) ? 'transport' : 'bundle' +} + +async function readManifest( + client: RpcClient | null, + flow: number, + send: (event: MobileWebShellSessionEvent) => void +): Promise { + if (client === null) { + // No client is no link, and the gates are about to say so. + send({ type: 'download-failed', flow, failure: 'transport' }) + return + } + try { + const opened = await runRpcOperation(client, mobileWebBundleManifestRead, null) + const manifest = opened.manifest + send({ + type: 'manifest-read', + flow, + manifest: { + buildId: manifest.buildId, + schemaVersion: manifest.schemaVersion, + runtimeProtocolVersion: manifest.runtimeProtocolVersion, + minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion, + totalBytes: manifest.totalBytes, + totalAssets: manifest.assets.length + } + }) + } catch (error) { + send({ type: 'download-failed', flow, failure: readFailure(error) }) + } +} + +async function download(args: { + client: RpcClient | null + store: GenerationStore + hostKey: string + flow: number + runtime: MobileWebShellRuntime + startedAt: number + downloads: Set + send: (event: MobileWebShellSessionEvent) => void +}): Promise { + const { client, store, hostKey, flow, runtime, send } = args + if (client === null) { + send({ type: 'download-failed', flow, failure: 'transport' }) + return + } + const controller = new AbortController() + args.downloads.add(controller) + try { + const fetched = await fetchMobileWebBundle({ + client, + signal: controller.signal, + onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress }) + }) + // The bytes are in; the session they were for may not be. The fetch throws on an abort it sees, + // but an abort landing between its last read and this line would otherwise still write a + // generation for a host screen nobody is on any more. + if (controller.signal.aborted) { + return + } + send({ type: 'download-staged', flow }) + const staged = await store.stageGeneration(hostKey, fetched) + // Again before the commit, because the commit is the write that is not the staging tree's to + // undo: it renames into the active slot and moves the host index. An abort that landed while + // the bytes were being staged takes the staged tree back out instead. + if (controller.signal.aborted) { + await store.abortStagedGeneration(staged).catch(() => undefined) + return + } + const committed = await store.commitGeneration(staged) + send({ + type: 'activated', + flow, + generationDirectory: generationDirectoryPath(committed.directory), + sessionId: runtime.mintSessionId(), + buildId: committed.buildId, + totalBytes: committed.manifest.totalBytes, + elapsedMs: runtime.now() - args.startedAt + }) + } catch (error) { + send({ type: 'download-failed', flow, failure: readFailure(error) }) + } finally { + args.downloads.delete(controller) + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c8cfb54863a..f338918af42 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -10,6 +10,7 @@ import { clampHostSidebarWidth, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, + loadMobileWebShellEnabled, loadPushNotificationsEnabled, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, @@ -504,3 +505,40 @@ describe('terminal link open mode preference', () => { expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalLinkOpenMode', 'phone-browser') }) }) + +/** `__DEV__` is a React Native global, absent outside that runtime; assigned rather than cast so + * the test says which build kind it is running as without asserting a type on `globalThis`. */ +function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void { + if (isDevelopmentBuild === undefined) { + Reflect.deleteProperty(globalThis, '__DEV__') + return + } + Object.assign(globalThis, { __DEV__: isDevelopmentBuild }) +} + +describe('hybrid shell flag', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + setDevelopmentBuild(undefined) + }) + + it('reads the developer toggle in a development build', async () => { + setDevelopmentBuild(true) + vi.mocked(AsyncStorage.getItem).mockResolvedValue('true') + + await expect(loadMobileWebShellEnabled()).resolves.toBe(true) + expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:mobileWebShellEnabled') + }) + + it.each([ + ['a release build', false], + ['a runtime with no __DEV__ at all', undefined] + ])('is off in %s even with the key left on, and never reads it', async (_label, isDev) => { + setDevelopmentBuild(isDev) + // The value a development build left behind in a container the install-over kept. + vi.mocked(AsyncStorage.getItem).mockResolvedValue('true') + + await expect(loadMobileWebShellEnabled()).resolves.toBe(false) + expect(AsyncStorage.getItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 57420469609..2b417ce9ae2 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -117,6 +117,30 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const MOBILE_WEB_SHELL_KEY = 'orca:mobileWebShellEnabled' + +// Why: the hybrid shell route is dark. Default-off means a store build never fetches, writes or +// sweeps a bundle cache, and the only writer is the __DEV__ Troubleshoot toggle — anything but +// `'true'`, including an unreadable store, is off. +export async function loadMobileWebShellEnabled(): Promise { + // A release build never reads the key at all: it shares its bundle id with the development build + // and the iOS data container survives an install-over, so a flag a developer left on would + // otherwise follow the store build in and mount the shell on a deep link. + if (typeof __DEV__ === 'undefined' || !__DEV__) { + return false + } + try { + const raw = await AsyncStorage.getItem(MOBILE_WEB_SHELL_KEY) + return raw === 'true' + } catch { + return false + } +} + +export async function saveMobileWebShellEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(MOBILE_WEB_SHELL_KEY, String(enabled)) +} + const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' export type DisabledTerminalLiveInputHandlesPreference = { diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index 9418750b22e..c9f92cb2305 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -3,6 +3,7 @@ import type { RpcClient } from './rpc-client' import type { ConnectionState } from './types' import { hostStatusProbe, readHostStatusGates } from './host-status-probe-operations' import { evaluateCompat, type CompatVerdict } from './protocol-compat' +import type { HostStatusReply } from './host-status-reply-schema' import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store' export type HostStatusGates = { @@ -10,7 +11,17 @@ export type HostStatusGates = { floatingWorkspaceEnabled: boolean desktopAppVersion: string | null compatVerdict: CompatVerdict + /** The two protocol numbers the status carried, for callers that evaluate a compat window this + * hook does not own — the mobile web bundle's. Kept as the reply's own fields rather than a + * restated shape so a rename upstream is a build error here. */ + hostProtocolWindow: HostProtocolWindow statusPending: boolean + /** Whether the settled answer came from a status this host actually returned and this client + * could decode. Both failure paths below settle the same closed gates an old host with no + * capabilities would produce, so without this a caller cannot tell "this desktop does not have + * the feature" from "nobody answered" — and the mobile web shell's wall is terminal, so it must + * never be shown for the second. */ + statusReadable: boolean } // statusPending is not stored: pending-ness belongs to the live connection, not to the answer. @@ -19,7 +30,19 @@ type LoadedHostStatusGates = Omit & { client: RpcClient } +export type HostProtocolWindow = Pick< + HostStatusReply, + 'protocolVersion' | 'minCompatibleMobileVersion' +> + const EMPTY_HOST_CAPABILITIES: string[] = [] +// Stable identities: consumers compare gates by reference to decide whether to re-run a step. +// Both keys stated: the reply schema salvages them as present-and-possibly-undefined, and +// `evaluateMobileWebBundleCompat` reads an absent number as "oldest host" and "no floor". +const EMPTY_HOST_PROTOCOL_WINDOW: HostProtocolWindow = { + protocolVersion: undefined, + minCompatibleMobileVersion: undefined +} // Reads status.get on connect for capabilities, protocol-compat verdict, and the // floating-workspace flag. Compat constants are wide-open today so this never blocks yet. @@ -57,7 +80,9 @@ export function useHostStatusGates(args: { hostCapabilities: [], floatingWorkspaceEnabled: false, desktopAppVersion: null, - compatVerdict: { kind: 'ok' } + compatVerdict: { kind: 'ok' }, + hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW, + statusReadable: false }) return } @@ -73,7 +98,12 @@ export function useHostStatusGates(args: { hostCapabilities: status.capabilities ?? [], floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, desktopAppVersion, - compatVerdict: verdict + compatVerdict: verdict, + hostProtocolWindow: { + protocolVersion: status.protocolVersion, + minCompatibleMobileVersion: status.minCompatibleMobileVersion + }, + statusReadable: true }) if (verdict.kind === 'blocked') { // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. @@ -91,7 +121,9 @@ export function useHostStatusGates(args: { hostCapabilities: [], floatingWorkspaceEnabled: false, desktopAppVersion: null, - compatVerdict: { kind: 'ok' } + compatVerdict: { kind: 'ok' }, + hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW, + statusReadable: false }) } } @@ -109,7 +141,9 @@ export function useHostStatusGates(args: { floatingWorkspaceEnabled: false, desktopAppVersion: null, compatVerdict: { kind: 'ok' }, - statusPending: connState === 'connected' && client !== null + hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW, + statusPending: connState === 'connected' && client !== null, + statusReadable: false } } return { @@ -117,6 +151,8 @@ export function useHostStatusGates(args: { floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled, desktopAppVersion: proven.desktopAppVersion, compatVerdict: proven.compatVerdict, + hostProtocolWindow: proven.hostProtocolWindow, + statusReadable: proven.statusReadable, // Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no // longer blanks the capabilities this same host already proved. statusPending: connState === 'connected' && unverified diff --git a/mobile/src/transport/mobile-web-bundle-operations.ts b/mobile/src/transport/mobile-web-bundle-operations.ts index accd27bdfe5..7db7b510c65 100644 --- a/mobile/src/transport/mobile-web-bundle-operations.ts +++ b/mobile/src/transport/mobile-web-bundle-operations.ts @@ -8,7 +8,9 @@ import { MobileWebBundleChunkReplySchema, MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { defineRpcOperation } from './rpc-operation' +import { isLogicalClientCutoverError } from './stable-logical-rpc-client' import { rpcResultVariant } from './rpc-operation-result-reader' // The two reads that hand a paired phone the desktop's mobile web bundle. Both are @@ -75,3 +77,16 @@ export function readMobileWebBundleErrorCode(error: unknown): MobileWebBundleErr const parsed = MobileWebBundleErrorCodeSchema.safeParse(nested) return parsed.success ? parsed.data : null } + +/** + * True when a bundle read failed on the link to the host rather than on the bundle it serves. + * + * Both marks come from the transport itself: delivery-unknown on every request a socket close, a + * relay drop or a timeout cut off, and the cutover error on a connection migration. Nothing else + * qualifies, on purpose — the fetch raises plain errors for a hash mismatch, a short asset and a + * build that changed mid-fetch, and every one of those is a verdict about the bytes that arrived. + * `readMobileWebBundleErrorCode` above reads the host's own refusals, which are verdicts too. + */ +export function isMobileWebBundleTransportFailure(error: unknown): boolean { + return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error) +} diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts index c80073ea511..8c723ba1116 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -11,10 +11,12 @@ import { import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' import { evaluateMobileWebBundleCompat } from './mobile-web-bundle-compat' import { + isMobileWebBundleTransportFailure, mobileWebBundleChunkRead, mobileWebBundleManifestRead, readMobileWebBundleErrorCode } from './mobile-web-bundle-operations' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas' import type { RpcReadResult } from './rpc-operation-contract' @@ -329,3 +331,30 @@ describe('mobile web bundle operation descriptors', () => { } }) }) + +describe('which side a bundle read failed on', () => { + it('reads the transport marks the transport itself sets', () => { + // Every socket close, relay drop and request timeout rejects in-flight requests with this mark. + expect( + isMobileWebBundleTransportFailure(markRpcDeliveryUnknown(new Error('Connection closed'))) + ).toBe(true) + // The cutover error matches by message as well as by class, across bundle copies. + expect( + isMobileWebBundleTransportFailure(new Error('RPC interrupted by connection migration')) + ).toBe(true) + }) + + it.each([ + ['a host refusal', `invalid_argument: ${MOBILE_WEB_BUNDLE_ERROR_CODES[0]}`], + ['bytes that do not hash', 'bundle asset index.html hashed aa, not bb'], + ['a build that changed mid-fetch', 'bundle build changed mid-fetch: asked aa, served bb'], + ['an unread reply', 'The host sent a reply this app could not read (mobileWeb.bundle.manifest)'] + ])('treats %s as a verdict about the bundle', (_label, message) => { + expect(isMobileWebBundleTransportFailure(new Error(message))).toBe(false) + }) + + it('treats anything that is not an error as a verdict too, rather than guessing', () => { + expect(isMobileWebBundleTransportFailure('Connection closed')).toBe(false) + expect(isMobileWebBundleTransportFailure(null)).toBe(false) + }) +})