diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index 56f80af979e..f7d06a8cf49 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -297,7 +297,7 @@ describe('the hybrid shell screen', () => { const logged = () => connectionLogStore.get('host-1').map((entry) => entry.message) const before = logged().length await renderScreen(readyState('session-one')) - expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']]) + expect(dependencies.reportShellFailure.mock.calls).toEqual([['host-profile-unreadable']]) expect(logged().slice(before)).toEqual(['Web shell: host profile unreadable']) warned.mockRestore() }) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index d7a4a3693e7..ecb70af25df 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -111,8 +111,15 @@ export function MobileWebShellScreen({ // Edge-to-edge disables adjustResize and zeroes WebView IME insets, so visualViewport never shrinks; publish native keyboard height to the page. const keyboardInset = Math.max(0, useKeyboardOcclusion()) const pageInsets = pageSafeAreaInsets({ insets, topCovered: noticeShown }) - const { snapshot, unreadable, readStorage, refreshStorage, writeStorage, readWorkspaceKey } = - usePageHostSnapshot(hostId, route.pathname, ownsHostArea) + const { + snapshot, + unreadable, + retry: retryHostSnapshot, + readStorage, + refreshStorage, + writeStorage, + readWorkspaceKey + } = usePageHostSnapshot(hostId, route.pathname, ownsHostArea) // Create the media registry before the bridge; its staged files must belong to this page session. const serveDeviceVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null) const servePdfView = useNativePdfViewer(hostId, state.kind === 'ready' ? state.sessionId : null) @@ -232,11 +239,11 @@ export function MobileWebShellScreen({ }) useReportedShellOverlayLoad(onOverlayLoad, state, pageFrame, downloadProgress) - // A rejected profile read creates no host; fail the document instead of leaving ready forever. Recovery refetches once, then exposes Try again. + // Profile failures preserve verified bundle bytes and retry the device store. useEffect(() => { if (unreadable) { console.warn('[web-shell] this host could not be read from the app store') - reportShellFailure('document-load-failed') + reportShellFailure('host-profile-unreadable') } }, [reportShellFailure, unreadable]) useEffect(() => { @@ -253,7 +260,12 @@ export function MobileWebShellScreen({ { + if (unreadable) { + retryHostSnapshot() + } + retry() + }} downloadProgress={downloadProgress} host={snapshot?.host ?? null} /> diff --git a/mobile/src/mobile-web-shell/ShellStateScreen.tsx b/mobile/src/mobile-web-shell/ShellStateScreen.tsx index d6d39a69553..81e3ee082b8 100644 --- a/mobile/src/mobile-web-shell/ShellStateScreen.tsx +++ b/mobile/src/mobile-web-shell/ShellStateScreen.tsx @@ -21,6 +21,8 @@ function failureMessage(reason: MobileWebShellFailureCause): string { return "This device's WebView is too old to open the workspace safely." case 'download-failed': return 'The latest app could not be downloaded from this host.' + case 'host-profile-unreadable': + return 'Could not read this host on your device. Try again.' case 'status-unreadable': return "Could not read this host's status. Go back and reopen it." case 'render-process-gone': diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-profile-recovery.test.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-profile-recovery.test.tsx new file mode 100644 index 00000000000..c35c156df6a --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-profile-recovery.test.tsx @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness')) +const dependencies = vi.hoisted(() => harness.createScreenDependencies()) + +const { screenModuleMocks } = await vi.hoisted( + async () => await import('./mobile-web-shell-screen-test-mocks') +) +const mocks = vi.hoisted(() => screenModuleMocks(dependencies)) +vi.mock('react-native', () => ({ + ...mocks['react-native'](), + useWindowDimensions: () => ({ width: profile.wide ? 1200 : 390, height: 844 }) +})) +vi.mock('expo-clipboard', mocks['expo-clipboard']) +vi.mock('expo-haptics', mocks['expo-haptics']) +vi.mock('expo-document-picker', mocks['expo-document-picker']) +vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio']) +vi.mock('expo-keep-awake', mocks['expo-keep-awake']) +vi.mock('expo-image-picker', mocks['expo-image-picker']) +vi.mock('expo-file-system', mocks['expo-file-system']) +vi.mock('lucide-react-native', mocks['lucide-react-native']) +vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) +vi.mock('expo-router', mocks['expo-router']) +vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) +vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) +vi.mock('../transport/host-client-hooks', () => ({ + useHostClient: () => ({ client: dependencies.client, state: 'connected' }) +})) + +const profile = vi.hoisted(() => ({ + fails: true, + reads: 0, + downloads: 0, + wide: false, + hold: false, + canOwnHostArea: false, + release: (): void => {} +})) +vi.mock('../transport/host-store', () => ({ + loadHosts: async () => { + profile.reads += 1 + if (profile.hold) { + await new Promise((resolve) => { + profile.release = resolve + }) + } + if (profile.fails) { + throw new Error('the keychain is temporarily unavailable') + } + return [{ id: 'host-1', name: 'Host One', endpoint: 'ws://host-1', lastConnected: 3 }] + } +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + multiGet: async () => [], + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined + } +})) +vi.mock('../components/host-protocol-gates-context', () => ({ + useHostProtocolGates: () => ({ + statusPending: false, + statusReadable: true, + hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY], + hostProtocolWindow: { protocolVersion: 10, minCompatibleMobileVersion: 1 } + }) +})) +vi.mock('../transport/rpc-operation', async (importOriginal) => ({ + ...(await importOriginal()), + runRpcOperation: async () => ({ manifest: currentManifest() }) +})) +vi.mock('../transport/mobile-web-bundle-fetch', () => ({ + fetchMobileWebBundle: async () => { + profile.downloads += 1 + return { + manifest: currentManifest(), + assets: new Map(), + reused: [], + totalBytes: 4096, + elapsedMs: 0 + } + } +})) + +import type * as RpcOperationModule from '../transport/rpc-operation' +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' +import { MobileWebShellScreen } from './MobileWebShellScreen' +import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes' +import { byName, trackRenderedScreen, textOf } from './mobile-web-shell-screen-test-harness' +import { MANIFEST_WIRE } from './mobile-web-shell-session-test-fixtures' +import { readBridgeHostMessage } from './bridge/bridge-envelope' +import type { ActiveGeneration, GenerationStore } from './generation-store' + +afterEach(harness.unmountRenderedScreens) +beforeEach(() => { + harness.resetScreenDependencies(dependencies) + profile.fails = true + profile.reads = 0 + profile.downloads = 0 + profile.wide = false + profile.hold = false + profile.canOwnHostArea = false +}) + +function currentManifest() { + return profile.canOwnHostArea + ? { + ...MANIFEST_WIRE, + routes: [{ pathname: '/h/[hostId]', grants: ['navigate'], canOwnHostArea: true }] + } + : MANIFEST_WIRE +} + +function cachedStore() { + const active = { + buildId: MANIFEST_WIRE.buildId, + directory: 'file:///cache/generation', + manifest: currentManifest() + } + let cached: ActiveGeneration | null = active + const heldReads: (() => void)[] = [] + const cache = { + holdReads: false, + releaseReads: () => { + for (const release of heldReads.splice(0)) { + release() + } + }, + deletes: vi.fn(async () => { + cached = null + }) + } + const store: GenerationStore = { + readActiveGeneration: async () => { + const answer = cached + if (cache.holdReads) { + await new Promise((resolve) => heldReads.push(resolve)) + } + return answer + }, + openReusableAssets: async () => ({ has: () => false, check: async () => false }), + stageGeneration: async () => ({ ...active, hostKey: 'host-key' }), + commitGeneration: async () => { + cached = active + return active + }, + abortStagedGeneration: async () => undefined, + sweepStagedGenerations: async () => undefined, + deleteHostCache: cache.deletes, + recordUpdateFailure: async () => undefined, + readUpdateFailures: async () => [], + forgetHostUpdateFailures: async () => undefined + } + return Object.assign(cache, { store }) +} + +async function mount(cache: ReturnType) { + dependencies.client = createFakeRpcClient() + const timers: { run: () => void; cancelled: boolean }[] = [] + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + await act(async () => { + rendered.tree = create( + createElement(MobileWebShellScreen, { + hostId: 'host-1', + route: { pathname: '/h/host-1' }, + runtime: { + createStore: () => cache.store, + mintSessionId: () => 'session-one', + now: () => 0, + setTimer: (run) => { + const timer = { run, cancelled: false } + timers.push(timer) + return () => { + timer.cancelled = true + } + } + } + }) + ) + trackRenderedScreen(rendered.tree) + }) + const tree = rendered.tree + if (tree === null) { + throw new Error('the screen did not mount') + } + return { tree, timers } +} + +async function retryWithReadableProfile(tree: ReactTestRenderer): Promise { + profile.fails = false + const retry = tree.root.find( + (node) => node.props.testID === 'mobile-web-shell-retry' && String(node.type) === 'Pressable' + ) + await act(async () => { + retry.props.onPress() + }) + expect(profile.reads).toBe(2) + await act(async () => { + byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({ + nativeEvent: { json: clientFrame({ type: 'ready' }) } + }) + }) + const inits = dependencies.posted.flatMap((json) => { + const frame = readBridgeHostMessage(json) + return frame.ok && frame.message.type === 'init' ? [frame.message] : [] + }) + expect(inits).toHaveLength(1) + expect(inits[0]?.host?.id).toBe('host-1') + expect(inits[0]?.ownsHostArea).toBeUndefined() +} + +it('retries an unavailable device profile without deleting or downloading verified bytes', async () => { + const cache = cachedStore() + const { tree, timers } = await mount(cache) + expect(cache.deletes).not.toHaveBeenCalled() + expect(profile.downloads).toBe(0) + expect(textOf(tree)).toContain('Could not read this host on your device') + expect(byName(tree, 'ShellViewProbe')).toHaveLength(0) + expect(timers.every((timer) => timer.cancelled)).toBe(true) + await retryWithReadableProfile(tree) + expect(cache.deletes).not.toHaveBeenCalled() + expect(profile.downloads).toBe(0) +}) + +it('ends checking on profile failure and ignores cache work completed after that failure', async () => { + const cache = cachedStore() + cache.holdReads = true + const { tree } = await mount(cache) + expect(textOf(tree)).toContain('Could not read this host on your device') + await act(async () => { + cache.releaseReads() + }) + expect(textOf(tree)).toContain('Could not read this host on your device') + expect(byName(tree, 'ShellViewProbe')).toHaveLength(0) + cache.holdReads = false + await retryWithReadableProfile(tree) + expect(profile.downloads).toBe(0) +}) + +it('keeps profile failure parked across layout changes and retries with the latest layout', async () => { + profile.wide = true + profile.hold = true + profile.canOwnHostArea = true + const cache = cachedStore() + const { tree } = await mount(cache) + expect(byName(tree, 'ShellViewProbe')).toHaveLength(1) + await act(async () => { + profile.release() + }) + expect(textOf(tree)).toContain('Could not read this host on your device') + for (const wide of [false, true, false]) { + profile.wide = wide + await act(async () => { + tree.update( + createElement(MobileWebShellScreen, { + hostId: 'host-1', + route: { pathname: '/h/host-1' } + }) + ) + }) + expect(textOf(tree)).toContain('Could not read this host on your device') + expect(byName(tree, 'ShellViewProbe')).toHaveLength(0) + expect(profile.reads).toBe(1) + } + profile.hold = false + await retryWithReadableProfile(tree) + expect(cache.deletes).not.toHaveBeenCalled() + expect(profile.downloads).toBe(0) +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx index 720a5550ada..e66d2d72bd4 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx @@ -169,6 +169,7 @@ export function screenModuleMocks(dependencies: ScreenDependencies) { // Keep preference identity stable like useState; fresh objects would rebuild the bridge on every render and settle pending requests. snapshot: SCREEN_SNAPSHOT, unreadable: dependencies.snapshotUnreadable, + retry: () => {}, readStorage: () => ({ storage: {}, storageOversize: [] }), refreshStorage: () => { dependencies.storageRefreshes += 1 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 index 75d2912d254..2db075c90e1 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -43,8 +43,10 @@ export type MobileWebShellBlockedVerdict = | Extract | PageRouteRefusalVerdict +export type MobileWebShellReportedFailure = MobileWebShellFailureReason | 'host-profile-unreadable' + export type MobileWebShellFailureCause = - | MobileWebShellFailureReason + | MobileWebShellReportedFailure | 'download-failed' | 'status-unreadable' @@ -138,7 +140,7 @@ export type MobileWebShellSessionEvent = readonly flow: number readonly cause: MobileWebShellUpdateFailureCause } - | { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason } + | { readonly type: 'shell-failed'; readonly reason: MobileWebShellReportedFailure } | { readonly type: 'retry-pressed' } | { readonly type: 'document-started' } | { readonly type: 'document-loaded' } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts index 1c507f49222..5295afd8a88 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -1,9 +1,9 @@ -import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' import { evaluateMobileWebBundleCompat } from '../transport/mobile-web-bundle-compat' import type { CachedGeneration, MobileWebShellGates, MobileWebShellManifestFacts, + MobileWebShellReportedFailure as ShellFailure, MobileWebShellSession, MobileWebShellSessionEffect, MobileWebShellSessionEvent, @@ -158,17 +158,15 @@ function onManifestRead( } // Retry suspect disk bytes once via delete/refetch; renderer loss gets one remount without deletion; unavailable isolation is terminal. -// Ignore reports outside ready: retired-view batches must not interrupt recovery already in flight. -function onShellFailed( - session: MobileWebShellSession, - reason: MobileWebShellFailureReason -): MobileWebShellStep { - if (session.state.kind !== 'ready') { +// Retired document reports cannot interrupt recovery; profile failures can end startup before ready. +function onShellFailed(session: MobileWebShellSession, reason: ShellFailure): MobileWebShellStep { + if (session.state.kind !== 'ready' && reason !== 'host-profile-unreadable') { return step(session, {}) } const failed = { kind: 'failed', reason, retriedOnce: session.retriedOnce } as const - if (reason === 'isolation-unavailable') { - return step(session, { state: failed }) + if (reason === 'isolation-unavailable' || reason === 'host-profile-unreadable') { + // Terminal failures advance flow so pending startup work is cancelled and its completions rejected. + return step(session, { state: failed, flow: session.flow + 1 }) } if (reason === 'render-process-gone') { return session.remountedOnce @@ -227,6 +225,9 @@ function decideDownloadFailed( } function onLayoutChanged(session: MobileWebShellSession, wide: boolean): MobileWebShellStep { + if (session.state.kind === 'failed' && session.state.reason === 'host-profile-unreadable') { + return step(session, { wide }) + } const view = routeViewOf(session.routes, session.routePathname, wide) if (session.gates === null || sameRouteView(view, session)) { return step(session, { wide }) 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 index 6a2a34a7f75..cf5dab2873b 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state' import { useHostProtocolGates } from '../components/host-protocol-gates-context' import { useHostClient } from '../transport/host-client-hooks' import { @@ -32,6 +31,7 @@ import { reduceMobileWebShellSession } from './mobile-web-shell-session' import type { + MobileWebShellReportedFailure, MobileWebShellSessionEffect, MobileWebShellSessionEvent, MobileWebShellSessionState, @@ -49,7 +49,7 @@ export type MobileWebShellSessionView = { // Explain fallback beside the running page without replacing it. readonly updateNotice: MobileWebShellUpdateNotice | null readonly retry: () => void - readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void + readonly reportShellFailure: (reason: MobileWebShellReportedFailure) => void readonly reportDocumentStarted: () => void readonly reportDocumentLoaded: () => void readonly reportPageReady: () => void @@ -281,7 +281,7 @@ export function useMobileWebShellSession(args: { }, [dispatch, invalidate, runtime]) const reportShellFailure = useCallback( - (reason: MobileWebShellFailureReason) => { + (reason: MobileWebShellReportedFailure) => { dispatch(epochRef.current, { type: 'shell-failed', reason }) }, [dispatch] diff --git a/mobile/src/mobile-web-shell/use-page-host-snapshot.ts b/mobile/src/mobile-web-shell/use-page-host-snapshot.ts index 5fda409e9d8..8b1b5d96617 100644 --- a/mobile/src/mobile-web-shell/use-page-host-snapshot.ts +++ b/mobile/src/mobile-web-shell/use-page-host-snapshot.ts @@ -23,6 +23,7 @@ export type PageHostSnapshotView = { snapshot: PageHostSnapshot | null // Report rejected profile reads; no host can answer ready and nothing retries this read automatically. unreadable: boolean + retry: () => void // Keep init synchronous from a mirror updated by native writers and refreshed from the store. readStorage: () => PageStorageForInit refreshStorage: () => Promise @@ -38,6 +39,7 @@ export function usePageHostSnapshot( ): PageHostSnapshotView { const [snapshot, setSnapshot] = useState(null) const [unreadable, setUnreadable] = useState(false) + const [readAttempt, setReadAttempt] = useState(0) // Apply the shell allowlist on every read/refresh; unrelated mirrored app state must never cross. const refreshStorage = useCallback( @@ -86,7 +88,7 @@ export function usePageHostSnapshot( return () => { stale = true } - }, [hostId, refreshStorage]) + }, [hostId, readAttempt, refreshStorage]) const writeStorage = useCallback( (key: string, value: string | null): void => { @@ -113,6 +115,7 @@ export function usePageHostSnapshot( return { snapshot, unreadable, + retry: useCallback(() => setReadAttempt((attempt) => attempt + 1), []), readStorage: useCallback(() => { // Bound values here or one oversized preference rejects all of init; report omissions so defaulted preferences are diagnosable. const held = readMirroredStorage(pageStorageKeysForRoute(hostId, routePathname))