diff --git a/config/mobile-localization-coverage-allowlist.json b/config/mobile-localization-coverage-allowlist.json index 3251f791639..3751e7f4aae 100644 --- a/config/mobile-localization-coverage-allowlist.json +++ b/config/mobile-localization-coverage-allowlist.json @@ -167,6 +167,13 @@ "dynamic": true, "count": 1 }, + { + "filePath": "mobile/src/diagnostics/native-render-log.ts", + "kind": "object-property:message", + "text": "Native screen failed to render", + "dynamic": false, + "count": 1 + }, { "filePath": "mobile/src/files/mobile-file-preview-route.ts", "kind": "object-property:message", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 8aa9a49cfb9..bceaf710cf9 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -2,13 +2,12 @@ import { startAndroidForegroundPushPresentation } from '../src/notifications/and import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal' import { readNativeNotificationData } from '../src/notifications/native-notification-data' import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect } from 'react' import { View } from 'react-native' -import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router' +import { Stack, useGlobalSearchParams, usePathname } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' -import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { startMobileAppearance } from '../src/theme/mobile-appearance' import { themedStyleSheet } from '../src/theme/themed-styles' @@ -25,7 +24,8 @@ import { import { startPushTokenSync } from '../src/notifications/push-registration' import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel' import { loadHostCatalog } from '../src/transport/host-store' -import { extractPairingCodeFromUrl } from '../src/transport/pairing' +import { usePairingLinks } from '../src/navigation/use-pairing-links' +import { consumeInitialLaunchIntent } from '../src/navigation/initial-launch-intent' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' import { appUpdateChecker } from '../src/app-update/app-update-runtime' import { SideSafeArea } from '../src/layout/side-safe-area' @@ -33,6 +33,8 @@ import { useFollowDeviceUiLocale } from '../src/i18n/use-follow-device-ui-locale import { startMobileUiLanguage } from '../src/i18n/ui-language-preference' import { subscribeMobileUiLanguage } from '../src/i18n/mobile-i18n' +export { NativeScreenRecovery as ErrorBoundary } from '../src/components/NativeScreenRecovery' + // Keep splash visible until React content is ready to avoid a blank frame. SplashScreen.preventAutoHideAsync() // Apply the phone's scheme before the first render; the saved choice lands under the splash. @@ -53,10 +55,12 @@ Notifications.setNotificationHandler({ handleNotification: foregroundNotificationBehavior }) +// Survive root recovery, including shells without clearLastNotificationResponse. +const handledNotificationIds = new Set() + export default function RootLayout() { const colorScheme = useMobileColorScheme() useFollowDeviceUiLocale() - const router = useRouter() const pathname = usePathname() const { hostId, worktreeId, executionHost } = useGlobalSearchParams<{ hostId?: string @@ -72,7 +76,7 @@ export default function RootLayout() { return () => setNotificationViewingWorkspace(null) }, [pathname, hostId, worktreeId, executionHost]) const openNotificationRoute = useOpenNotificationRoute() - const handledNotificationIdsRef = useRef>(new Set()) + usePairingLinks() useEffect(() => { // Reconcile the crash-persistent pairing journal before another scan can replace it. @@ -85,26 +89,6 @@ export default function RootLayout() { useEffect(() => appUpdateChecker.start(), []) useEffect(() => startAndroidForegroundPushPresentation(), []) - // Route pair links through confirmation; getInitialURL covers cold starts and the listener covers warm starts. - useEffect(() => { - function handleUrl(url: string) { - const code = extractPairingCodeFromUrl(url) - if (code) { - // Replace Android camera’s unmatched orca://pair route so Cancel/Back cannot reveal the router error. - router.replace({ pathname: '/pair-confirm', params: { code } }) - } - } - - void Linking.getInitialURL().then((url) => { - if (url) { - handleUrl(url) - } - }) - - const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url)) - return () => sub.remove() - }, [router]) - // Notification taps arrive through expo-notifications, not Linking; route both cold and warm responses to their host/worktree. useEffect(() => { let disposed = false @@ -149,15 +133,15 @@ export default function RootLayout() { } const notificationId = response.notification.request.identifier - if (handledNotificationIdsRef.current.has(notificationId)) { + if (handledNotificationIds.has(notificationId)) { return } - handledNotificationIdsRef.current.add(notificationId) - // RootLayout never unmounts; bound tap dedup with FIFO eviction. - if (handledNotificationIdsRef.current.size > 256) { - const oldest = handledNotificationIdsRef.current.values().next().value + handledNotificationIds.add(notificationId) + // Bound process-lifetime tap dedup with FIFO eviction. + if (handledNotificationIds.size > 256) { + const oldest = handledNotificationIds.values().next().value if (oldest !== undefined) { - handledNotificationIdsRef.current.delete(oldest) + handledNotificationIds.delete(oldest) } } @@ -171,7 +155,9 @@ export default function RootLayout() { } } - const initialResponse = getInitialNotificationResponse() + const initialResponse = consumeInitialLaunchIntent('notification') + ? getInitialNotificationResponse() + : null if (initialResponse) { void handleNotificationResponse(initialResponse) } diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx index 065974d4e66..970f0bc3835 100644 --- a/mobile/app/connection-log.tsx +++ b/mobile/app/connection-log.tsx @@ -20,10 +20,14 @@ import { } from '../src/diagnostics/connection-diagnostics-screen-data' import { connectionDiagnosticsScreenStyles as styles } from '../src/diagnostics/connection-diagnostics-screen-styles' import { useMobileColorScheme } from '../src/theme/use-mobile-color-scheme' +import { NATIVE_APP_LOG_ID } from '../src/diagnostics/native-render-log' import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' // Keep empty snapshot identity stable or useSyncExternalStore rerenders forever. const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] +const subscribeAppLog = (listener: () => void) => + connectionLogStore.subscribe(NATIVE_APP_LOG_ID, listener) +const getAppLog = () => connectionLogStore.get(NATIVE_APP_LOG_ID) // Acquire client here so opening logs during host failure starts dialing and live logging. export default function NativeConnectionLogRoute() { @@ -59,6 +63,7 @@ export default function NativeConnectionLogRoute() { const { activePath, pendingPath } = useConnectionPathStatus(selected?.id) useEffect(() => { + void readHydratedConnectionLog(connectionLogStore, NATIVE_APP_LOG_ID) if (selectedId) { void readHydratedConnectionLog(connectionLogStore, selectedId) } @@ -73,16 +78,19 @@ export default function NativeConnectionLogRoute() { () => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES), [selectedId] ) - const entries = useSyncExternalStore(subscribe, getSnapshot) + const hostEntries = useSyncExternalStore(subscribe, getSnapshot) + const appEntries = useSyncExternalStore(subscribeAppLog, getAppLog) + const entries = useMemo( + () => [...hostEntries, ...appEntries].sort((a, b) => a.ts - b.ts), + [hostEntries, appEntries] + ) const device = useMemo( () => - selected - ? createNativeDiagnosticsOperations( - selected, - clientContext, - statusPending ? null : desktopAppVersion - ) - : null, + createNativeDiagnosticsOperations( + selected, + clientContext, + statusPending ? null : desktopAppVersion + ), [selected, clientContext, desktopAppVersion, statusPending] ) diff --git a/mobile/src/components/NativeScreenRecovery.tsx b/mobile/src/components/NativeScreenRecovery.tsx new file mode 100644 index 00000000000..fa2479d12dd --- /dev/null +++ b/mobile/src/components/NativeScreenRecovery.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react' +import { Pressable, Text, View } from 'react-native' +import { useRouter, useSegments, type ErrorBoundaryProps } from 'expo-router' +import { discardInitialLaunchIntent } from '../navigation/initial-launch-intent' +import { recordNativeRenderError } from '../diagnostics/native-render-log' +import { useMobileTranslation } from '../i18n/use-mobile-translation' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { themedStyleSheet } from '../theme/themed-styles' +import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' + +export function NativeScreenRecovery({ error, retry }: ErrorBoundaryProps) { + useMobileColorScheme() + const t = useMobileTranslation() + const router = useRouter() + const route = useRef(useSegments().join('/')) + + useEffect(() => { + recordNativeRenderError(error, route.current) + }, [error]) + + return ( + + + {t('components.nativeScreenRecovery.title', 'Something went wrong')} + + void retry()}> + + {t('components.nativeScreenRecovery.tryAgain', 'Try again')} + + + { + discardInitialLaunchIntent() + router.replace('/') + }} + > + {t('components.nativeScreenRecovery.home', 'Home')} + + + ) +} + +const styles = themedStyleSheet(() => ({ + container: { + flex: 1, + justifyContent: 'center', + backgroundColor: colors.bgBase, + padding: spacing.xl, + gap: spacing.md + }, + title: { + color: colors.textPrimary, + fontSize: typography.titleSize, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.sm + }, + retryButton: { + backgroundColor: colors.surfaceBright, + borderRadius: radii.button, + padding: spacing.md, + alignItems: 'center' + }, + retryText: { color: colors.bgBase, fontSize: typography.bodySize, fontWeight: '600' }, + homeButton: { padding: spacing.md, alignItems: 'center' }, + homeText: { color: colors.textPrimary, fontSize: typography.bodySize } +})) diff --git a/mobile/src/components/native-screen-recovery.test.tsx b/mobile/src/components/native-screen-recovery.test.tsx new file mode 100644 index 00000000000..fa1fb35d617 --- /dev/null +++ b/mobile/src/components/native-screen-recovery.test.tsx @@ -0,0 +1,39 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { NativeScreenRecovery } from './NativeScreenRecovery' +import { connectionLogStore } from '../transport/persisted-connection-log-store' +import { NATIVE_APP_LOG_ID } from '../diagnostics/native-render-log' + +const navigation = vi.hoisted(() => ({ segments: ['about'] })) +vi.mock('expo-router', () => ({ + useRouter: () => ({ replace: () => {} }), + useSegments: () => navigation.segments +})) +vi.mock('react-native', () => ({ + Text: 'Text', + View: 'View', + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles } +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {} } +})) + +it('records the original failed route once when the unmounted layout resets router state', async () => { + const props = { error: new Error('render failed'), retry: async () => {} } + const mounted: { tree?: ReactTestRenderer } = {} + await act(async () => { + mounted.tree = create(createElement(NativeScreenRecovery, props)) + }) + const tree = mounted.tree + if (!tree) { + throw new Error('NativeScreenRecovery did not mount') + } + navigation.segments = [] + await act(async () => tree.update(createElement(NativeScreenRecovery, props))) + const entries = connectionLogStore.get(NATIVE_APP_LOG_ID) + expect(entries).toHaveLength(1) + expect(entries[0]?.detail).toContain('/about: Error') + await act(async () => tree.unmount()) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-analysis.ts b/mobile/src/diagnostics/connection-diagnostics-analysis.ts index 467ff1ad584..d0da5f2cc7d 100644 --- a/mobile/src/diagnostics/connection-diagnostics-analysis.ts +++ b/mobile/src/diagnostics/connection-diagnostics-analysis.ts @@ -254,6 +254,9 @@ function diagnosticEvidence(entry: ConnectionLogEntry): string { } function isDiagnosticFailure(entry: ConnectionLogEntry): boolean { + if (entry.code === 'native-render-failed') { + return false + } return /suspended-dial|relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-dial-failed|relay dial failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test( diagnosticEvidence(entry) ) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts index 824bfa064c0..7094a32574c 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -67,11 +67,18 @@ export function buildConnectionDiagnosticsReport(args: { lines.push(`Likely cause: ${diagnosis.likelyCause}`) lines.push(`Next step: ${diagnosis.nextStep}`) lines.push('') + lines.push(formatConnectionLogHistory(entries)) + return lines.join('\n') +} + +export function formatConnectionLogHistory(entries: readonly ConnectionLogEntry[]): string { + const lines: string[] = [] if (entries.length === 0) { lines.push('No connection events recorded.') } else { lines.push(`Recent connection history (${entries.length} events, oldest first):`) - for (const entry of entries) { + for (const rawEntry of entries) { + const entry = redactConnectionLogEntry(rawEntry) const detail = entry.detail ? ` — ${entry.detail}` : '' const evidence = [entry.code, entry.path].filter(Boolean).join(' · ') lines.push( diff --git a/mobile/src/diagnostics/connection-diagnostics-screen.tsx b/mobile/src/diagnostics/connection-diagnostics-screen.tsx index 063f2d0dcad..c93dd87aaec 100644 --- a/mobile/src/diagnostics/connection-diagnostics-screen.tsx +++ b/mobile/src/diagnostics/connection-diagnostics-screen.tsx @@ -40,7 +40,7 @@ export function ConnectionDiagnosticsScreen({ onBack: () => void hostPicker?: ReactNode }) { - const [copiedHostId, setCopiedHostId] = useState(null) + const [copiedScope, setCopiedScope] = useState<{ hostId: string | null } | null>(null) const [submissionStates, setSubmissionStates] = useState({}) const diagnosisArgs = host @@ -51,16 +51,19 @@ export function ConnectionDiagnosticsScreen({ const hostId = host?.id ?? null const submissionKey = hostId && incidentId ? `${hostId}:${incidentId}` : null const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey) - const copied = copiedHostId !== null && copiedHostId === hostId + const copied = copiedScope !== null && copiedScope.hostId === hostId const copyDiagnostics = useCallback(async () => { - if (!device || !hostId) { + if (!device) { return } const { report } = await device.report() await writeClipboard(report) - setCopiedHostId(hostId) - setTimeout(() => setCopiedHostId((current) => (current === hostId ? null : current)), 2000) + setCopiedScope({ hostId }) + setTimeout( + () => setCopiedScope((current) => (current?.hostId === hostId ? null : current)), + 2000 + ) }, [device, hostId, writeClipboard]) const sendDiagnostics = useCallback(async () => { diff --git a/mobile/src/diagnostics/connection-diagnostics-view.tsx b/mobile/src/diagnostics/connection-diagnostics-view.tsx index b7899eda6c6..f2bcc0f9437 100644 --- a/mobile/src/diagnostics/connection-diagnostics-view.tsx +++ b/mobile/src/diagnostics/connection-diagnostics-view.tsx @@ -66,17 +66,19 @@ export function ConnectionDiagnosticsView({ {hostPicker} - {hasHost ? ( + {hasHost || entries.length > 0 ? ( <> - {reconnectAttempts > 0 - ? t( - 'diagnostics.connectionDiagnosticsView.stateWithAttempt', - '{{state}} · attempt {{attempts}}', - { state: stateLabels[state], attempts: reconnectAttempts } - ) - : stateLabels[state]} + {!hasHost + ? t('diagnostics.connectionDiagnosticsView.noHosts', 'No paired hosts.') + : reconnectAttempts > 0 + ? t( + 'diagnostics.connectionDiagnosticsView.stateWithAttempt', + '{{state}} · attempt {{attempts}}', + { state: stateLabels[state], attempts: reconnectAttempts } + ) + : stateLabels[state]} void copyDiagnostics()}> {copied ? ( diff --git a/mobile/src/diagnostics/native-diagnostics-operations.ts b/mobile/src/diagnostics/native-diagnostics-operations.ts index 2fb79ae35f1..f81181e72c2 100644 --- a/mobile/src/diagnostics/native-diagnostics-operations.ts +++ b/mobile/src/diagnostics/native-diagnostics-operations.ts @@ -3,15 +3,22 @@ import Constants from 'expo-constants' import type { HostProfile } from '../transport/types' import type { RpcClientContextValue } from '../transport/rpc-client-context-contract' import { connectionLogStore } from '../transport/persisted-connection-log-store' -import { readConnectionDiagnosticsSnapshot } from './connection-diagnostics-screen-data' +import { + readConnectionDiagnosticsSnapshot, + readHydratedConnectionLog +} from './connection-diagnostics-screen-data' import { getReportableConnectionIncidentId } from './connection-diagnostics-analysis' -import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report' +import { + buildConnectionDiagnosticsReport, + formatConnectionLogHistory +} from './connection-diagnostics-report' +import { NATIVE_APP_LOG_ID } from './native-render-log' import { submitConnectionDiagnostics } from './connection-diagnostics-submission' import type { DiagnosticsDeviceOperations } from './diagnostics-device-operations' import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-frame-fields' export function createNativeDiagnosticsOperations( - host: HostProfile, + host: HostProfile | null, context: Pick< RpcClientContextValue, 'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath' @@ -19,11 +26,20 @@ export function createNativeDiagnosticsOperations( liveDesktopAppVersion?: string | null ): DiagnosticsDeviceOperations { const versionConnectedAt = - context.getState(host.id) === 'connected' ? context.getLastConnectedAt(host.id) : null + host && context.getState(host.id) === 'connected' ? context.getLastConnectedAt(host.id) : null return { async report() { const appVersion = Constants.expoConfig?.version ?? 'unknown' const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim() + const appEntries = await readHydratedConnectionLog(connectionLogStore, NATIVE_APP_LOG_ID) + if (!host) { + return { + report: `Orca Mobile connection diagnostics\nApp: Orca Mobile ${appVersion} · ${platform}\nNo paired hosts.\n\n${formatConnectionLogHistory(appEntries)}`, + appVersion, + platform, + incidentId: null + } + } const snapshot = await readConnectionDiagnosticsSnapshot(context, connectionLogStore, host.id) return { report: buildConnectionDiagnosticsReport({ @@ -38,7 +54,7 @@ export function createNativeDiagnosticsOperations( versionConnectedAt !== null && snapshot.lastConnectedAt === versionConnectedAt ? liveDesktopAppVersion : null, - entries: snapshot.entries, + entries: [...snapshot.entries, ...appEntries].sort((a, b) => a.ts - b.ts), activePath: snapshot.activePath, pendingPath: snapshot.pendingPath, bridgeProtocolVersion: BRIDGE_PROTOCOL_VERSION diff --git a/mobile/src/diagnostics/native-render-log.test.ts b/mobile/src/diagnostics/native-render-log.test.ts new file mode 100644 index 00000000000..87fc8ba9272 --- /dev/null +++ b/mobile/src/diagnostics/native-render-log.test.ts @@ -0,0 +1,73 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { beforeEach, expect, it, vi } from 'vitest' +import type { HostProfile } from '../transport/types' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios', Version: '27' } })) +vi.mock('expo-constants', () => ({ default: { expoConfig: { version: '0.0.60' } } })) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(), setItem: vi.fn() } +})) + +const context = { + getState: () => 'disconnected' as const, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + getActivePath: () => 'lan' as const, + getPendingPath: () => null +} +const pairedHost: HostProfile = { + id: 'desktop', + name: 'Desktop', + endpoint: 'ws://desktop.invalid:6768', + deviceToken: 'device-secret', + publicKeyB64: 'key', + lastConnected: 0 +} + +beforeEach(() => { + vi.resetModules() + const storage = new Map() + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => storage.get(key) ?? null) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { + storage.set(key, value) + }) +}) + +it.each([null, pairedHost])( + 'retains a native render failure in the copied report after restart, host %s', + async (host) => { + const { recordNativeRenderError, NATIVE_APP_LOG_ID } = await import('./native-render-log') + const { connectionLogStore } = await import('../transport/persisted-connection-log-store') + const failure = new Error('Private prompt\n at PRIVATE PROMPT\ntoken=do-not-share') + failure.stack = `${failure.name}: ${failure.message}\n at UnauthorizedAboutRoute (/Users/private/project/about.tsx:12:3)` + recordNativeRenderError(failure, 'about') + await connectionLogStore.hydrate(NATIVE_APP_LOG_ID) + await connectionLogStore.flush() + vi.resetModules() + + const { createNativeDiagnosticsOperations } = await import('./native-diagnostics-operations') + const { report, incidentId } = await createNativeDiagnosticsOperations(host, context).report() + expect(report).toContain('Native screen failed to render') + expect(report).toContain('UnauthorizedAboutRoute') + expect(report).not.toContain('Private prompt') + expect(report).not.toContain('PRIVATE PROMPT') + expect(report).not.toContain('do-not-share') + expect(report).not.toContain('/Users/private') + expect(incidentId).toBeNull() + } +) + +it('keeps a log entry when the thrown error has an unreadable stack', async () => { + const { recordNativeRenderError, NATIVE_APP_LOG_ID } = await import('./native-render-log') + const failure = new Error('bad stack') + Object.defineProperty(failure, 'stack', { + get() { + throw new Error('unreadable') + } + }) + recordNativeRenderError(failure, 'pair-scan') + const { connectionLogStore } = await import('../transport/persisted-connection-log-store') + expect(connectionLogStore.get(NATIVE_APP_LOG_ID)).toEqual([ + expect.objectContaining({ level: 'error', detail: '/pair-scan: Error\n' }) + ]) +}) diff --git a/mobile/src/diagnostics/native-render-log.ts b/mobile/src/diagnostics/native-render-log.ts new file mode 100644 index 00000000000..b7ed76992bc --- /dev/null +++ b/mobile/src/diagnostics/native-render-log.ts @@ -0,0 +1,32 @@ +import { sanitizeCrashReportString } from '../../../src/shared/crash-report-redaction' +import { captureBridgeError } from '../mobile-web-shell/bridge/bridge-error-capture' +import { connectionLogStore } from '../transport/persisted-connection-log-store' + +export const NATIVE_APP_LOG_ID = 'orca:native-app' +let sequence = 0 + +export function recordNativeRenderError(error: unknown, route: string): void { + const ts = Date.now() + const category = captureBridgeError(error).category + let frames = '' + try { + if (error instanceof Error && typeof error.stack === 'string') { + // Only frames: multiline error messages can contain user content. + const prefix = `${error.name}: ${error.message}` + frames = (error.stack.startsWith(prefix) ? error.stack.slice(prefix.length) : '') + .split(/\r?\n/) + .filter((line) => /^\s+at /.test(line)) + .join('\n') + } + } catch { + // A thrown value can have an unreadable stack getter. + } + connectionLogStore.append(NATIVE_APP_LOG_ID, { + id: `native-render-${ts}-${++sequence}`, + ts, + level: 'error', + code: 'native-render-failed', + message: 'Native screen failed to render', + detail: sanitizeCrashReportString(`/${route || 'index'}: ${category}\n${frames}`, 2_000) + }) +} diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 2def28b8004..7b4c432e268 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -1004,6 +1004,11 @@ "folder": "Ordner", "project": "Projekt {{repo}}", "remoteHost": "Remote-Host" + }, + "nativeScreenRecovery": { + "title": "Etwas ist schiefgelaufen", + "tryAgain": "Erneut versuchen", + "home": "Startseite" } }, "connection": { diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 6b73b9ae74b..dd50380a053 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -1276,6 +1276,11 @@ "detailsChangedMessage": "The account or reset offer changed before the host contacted Codex. Review the updated details, then confirm again.", "resetFailed": "Could not reset rate limits", "confirmTitle": "Use a rate-limit reset?" + }, + "nativeScreenRecovery": { + "title": "Something went wrong", + "tryAgain": "Try again", + "home": "Home" } }, "pullRequest": { diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index a1f6abce474..eb9995e1bc3 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -1047,6 +1047,11 @@ "folder": "Carpeta", "project": "Proyecto {{repo}}", "remoteHost": "Host remoto" + }, + "nativeScreenRecovery": { + "title": "Algo salió mal", + "tryAgain": "Volver a intentar", + "home": "Inicio" } }, "connection": { diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index f76747d7481..2931691544b 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -1047,6 +1047,11 @@ "folder": "Dossier", "project": "Projet {{repo}}", "remoteHost": "Hôte distant" + }, + "nativeScreenRecovery": { + "title": "Une erreur est survenue", + "tryAgain": "Réessayer", + "home": "Accueil" } }, "connection": { diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 0f7d4201b8c..3b1525f7aa9 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -1000,6 +1000,11 @@ "folder": "フォルダー", "project": "プロジェクト {{repo}}", "remoteHost": "リモートホスト" + }, + "nativeScreenRecovery": { + "title": "問題が発生しました", + "tryAgain": "再試行", + "home": "ホーム" } }, "connection": { diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index a7e9e477d95..74fed1fcdd3 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -1000,6 +1000,11 @@ "folder": "폴더", "project": "프로젝트 {{repo}}", "remoteHost": "원격 호스트" + }, + "nativeScreenRecovery": { + "title": "문제가 발생했습니다", + "tryAgain": "다시 시도", + "home": "홈" } }, "connection": { diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index 21fed3071fd..3757abf34f0 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -1047,6 +1047,11 @@ "folder": "Pasta", "project": "Projeto {{repo}}", "remoteHost": "Host remoto" + }, + "nativeScreenRecovery": { + "title": "Algo deu errado", + "tryAgain": "Tentar novamente", + "home": "Início" } }, "connection": { diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 7bb106bdca2..7f64f43f476 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -1090,6 +1090,11 @@ "folder": "Папка", "project": "Проект {{repo}}", "remoteHost": "Удалённый сервер" + }, + "nativeScreenRecovery": { + "title": "Что-то пошло не так", + "tryAgain": "Повторить", + "home": "Главная" } }, "connection": { diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index c330255e254..23ec987e1d8 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -1004,6 +1004,11 @@ "folder": "Klasör", "project": "{{repo}} projesi", "remoteHost": "Uzak sunucu" + }, + "nativeScreenRecovery": { + "title": "Bir sorun oluştu", + "tryAgain": "Tekrar dene", + "home": "Ana sayfa" } }, "connection": { diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 837f848967b..1d7dbfe242e 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -1000,6 +1000,11 @@ "folder": "資料夾", "project": "專案 {{repo}}", "remoteHost": "遠端主機" + }, + "nativeScreenRecovery": { + "title": "發生錯誤", + "tryAgain": "再試一次", + "home": "首頁" } }, "connection": { diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 31f8c761191..2d25a6f0cdb 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -1000,6 +1000,11 @@ "folder": "文件夹", "project": "项目 {{repo}}", "remoteHost": "远程主机" + }, + "nativeScreenRecovery": { + "title": "出了点问题", + "tryAgain": "重试", + "home": "首页" } }, "connection": { diff --git a/mobile/src/navigation/initial-launch-intent.ts b/mobile/src/navigation/initial-launch-intent.ts new file mode 100644 index 00000000000..8804d7834fc --- /dev/null +++ b/mobile/src/navigation/initial-launch-intent.ts @@ -0,0 +1,17 @@ +type LaunchSource = 'pairing-link' | 'notification' + +const launchIntent = { discarded: false, consumedSources: new Set() } + +export function consumeInitialLaunchIntent( + source: LaunchSource +): { readonly discarded: boolean } | null { + if (launchIntent.discarded || launchIntent.consumedSources.has(source)) { + return null + } + launchIntent.consumedSources.add(source) + return launchIntent +} + +export function discardInitialLaunchIntent(): void { + launchIntent.discarded = true +} diff --git a/mobile/src/navigation/native-root-recovery.test.tsx b/mobile/src/navigation/native-root-recovery.test.tsx new file mode 100644 index 00000000000..eb0570a58d4 --- /dev/null +++ b/mobile/src/navigation/native-root-recovery.test.tsx @@ -0,0 +1,141 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { findAllByHostType } from '../test-support/react-test-renderer-host-nodes' + +const startup = vi.hoisted(() => ({ + fail: true, + replace: vi.fn(), + openNotification: vi.fn(), + readLink: vi.fn(async () => 'orca://pair?code=cold-code'), + readNotification: vi.fn(() => ({ + actionIdentifier: 'default', + notification: { + request: { identifier: 'cold-tap', content: { data: { hostId: 'desktop' } }, trigger: null } + } + })) +})) +const router = { replace: startup.replace } +vi.mock('expo-router', () => ({ + Stack: Object.assign( + () => { + if (startup.fail) { + throw new Error('first native render failed') + } + return null + }, + { Screen: () => null } + ), + useRouter: () => router, + useSegments: () => ['about'], + useGlobalSearchParams: () => ({}), + usePathname: () => '/about' +})) +vi.mock('react-native', () => ({ + Text: 'Text', + View: 'View', + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles } +})) +vi.mock('expo-linking', () => ({ + getInitialURL: startup.readLink, + addEventListener: () => ({ remove: () => {} }) +})) +vi.mock('expo-notifications', () => ({ + DEFAULT_ACTION_IDENTIFIER: 'default', + getLastNotificationResponse: startup.readNotification, + clearLastNotificationResponse: () => {}, + setNotificationHandler: () => {}, + addNotificationResponseReceivedListener: () => ({ remove: () => {} }) +})) +vi.mock('expo-splash-screen', () => ({ + preventAutoHideAsync: async () => {}, + hideAsync: async () => {} +})) +vi.mock('expo-status-bar', () => ({ StatusBar: () => null })) +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null, setItem: async () => {} } +})) +vi.mock('../notifications/android-foreground-push', () => ({ + startAndroidForegroundPushPresentation: () => () => {} +})) +vi.mock('../notifications/push-background-dismissal', () => ({ + registerPushDismissalTask: async () => {} +})) +vi.mock('../notifications/notification-viewing-policy', () => ({ + setNotificationViewingWorkspace: () => {} +})) +vi.mock('../notifications/use-open-notification-route', () => ({ + useOpenNotificationRoute: () => startup.openNotification +})) +vi.mock('../notifications/push-receive', () => ({ + foregroundNotificationBehavior: async () => ({}), + isRemotePushTrigger: () => false, + pushNotificationRouteData: (data: unknown) => data +})) +vi.mock('../notifications/push-registration', () => ({ startPushTokenSync: () => () => {} })) +vi.mock('../notifications/desktop-notification-channel', () => ({ + ensureDesktopNotificationChannel: async () => {} +})) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [{ id: 'desktop' }] })) +vi.mock('../transport/mobile-relay-pairing-recovery', () => ({ + recoverMobileRelayPairing: async () => {} +})) +vi.mock('../transport/client-context', () => ({ + RpcClientProvider: ({ children }: { children: ReactNode }) => children +})) +vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: () => null })) +vi.mock('../layout/side-safe-area', () => ({ + SideSafeArea: ({ children }: { children: ReactNode }) => children +})) +vi.mock('../theme/mobile-appearance', () => ({ startMobileAppearance: async () => {} })) +vi.mock('../i18n/use-follow-device-ui-locale', () => ({ useFollowDeviceUiLocale: () => {} })) +vi.mock('../i18n/ui-language-preference', () => ({ startMobileUiLanguage: async () => {} })) +vi.mock('../app-update/app-update-runtime', () => ({ appUpdateChecker: { start: () => () => {} } })) + +import RootLayout, { ErrorBoundary } from '../../app/_layout' + +it('recovery Home discards unread cold links and notification taps after a first-render failure', async () => { + await expect( + act(async () => { + create(createElement(RootLayout)) + }) + ).rejects.toThrow('first native render failed') + expect(startup.readLink).not.toHaveBeenCalled() + expect(startup.readNotification).not.toHaveBeenCalled() + + const mounted: { fallback?: ReactTestRenderer; recovered?: ReactTestRenderer } = {} + await act(async () => { + mounted.fallback = create( + createElement(ErrorBoundary, { + error: new Error('first native render failed'), + retry: async () => {} + }) + ) + }) + const fallback = mounted.fallback + if (!fallback) { + throw new Error('native recovery fallback did not mount') + } + const home = findAllByHostType(fallback.root, 'Pressable').find((button) => + findAllByHostType(button, 'Text').some((label) => label.props.children === 'Home') + ) + expect(home).toBeDefined() + await act(async () => home?.props.onPress()) + expect(startup.replace).toHaveBeenCalledExactlyOnceWith('/') + await act(async () => fallback.unmount()) + + startup.fail = false + await act(async () => { + mounted.recovered = create(createElement(RootLayout)) + }) + const recovered = mounted.recovered + if (!recovered) { + throw new Error('native root did not mount after recovery') + } + expect(startup.openNotification).not.toHaveBeenCalled() + expect(startup.readNotification).not.toHaveBeenCalled() + expect(startup.readLink).not.toHaveBeenCalled() + expect(startup.replace).toHaveBeenCalledTimes(1) + await act(async () => recovered.unmount()) +}) diff --git a/mobile/src/navigation/use-pairing-links.test.tsx b/mobile/src/navigation/use-pairing-links.test.tsx new file mode 100644 index 00000000000..86612437928 --- /dev/null +++ b/mobile/src/navigation/use-pairing-links.test.tsx @@ -0,0 +1,81 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, expect, it, vi } from 'vitest' + +const links = vi.hoisted(() => ({ + initial: vi.fn<() => Promise>(), + replace: vi.fn(), + listeners: new Set<(event: { url: string }) => void>() +})) +const router = { replace: links.replace } +vi.mock('expo-router', () => ({ useRouter: () => router })) +vi.mock('expo-linking', () => ({ + getInitialURL: links.initial, + addEventListener: (_type: string, listener: (event: { url: string }) => void) => { + links.listeners.add(listener) + return { remove: () => links.listeners.delete(listener) } + } +})) + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + links.listeners.clear() +}) + +async function mountPairingRoot(): Promise { + const { usePairingLinks } = await import('./use-pairing-links') + function Root() { + usePairingLinks() + return null + } + const mounted: { tree?: ReactTestRenderer } = {} + await act(async () => { + mounted.tree = create(createElement(Root)) + }) + if (!mounted.tree) { + throw new Error('pairing root did not mount') + } + return mounted.tree +} + +it('handles a cold link once and keeps warm links working after recovery remount', async () => { + links.initial.mockResolvedValue('orca://pair?code=cold-code') + const first = await mountPairingRoot() + expect(links.replace).toHaveBeenCalledExactlyOnceWith({ + pathname: '/pair-confirm', + params: { code: 'cold-code' } + }) + await act(async () => first.unmount()) + expect(links.listeners.size).toBe(0) + + const recovered = await mountPairingRoot() + expect(links.replace).toHaveBeenCalledTimes(1) + expect(links.initial).toHaveBeenCalledTimes(1) + await act(async () => { + for (const listener of links.listeners) { + listener({ url: 'orca://pair?code=warm-code' }) + } + }) + expect(links.replace).toHaveBeenLastCalledWith({ + pathname: '/pair-confirm', + params: { code: 'warm-code' } + }) + await act(async () => recovered.unmount()) +}) + +it('does not redirect Home when a pending launch-link read completes after recovery', async () => { + let resolveUrl: (url: string) => void = () => {} + links.initial.mockImplementation( + () => + new Promise((resolve) => { + resolveUrl = resolve + }) + ) + const first = await mountPairingRoot() + await act(async () => first.unmount()) + const recovered = await mountPairingRoot() + await act(async () => resolveUrl('orca://pair?code=stale-code')) + expect(links.replace).not.toHaveBeenCalled() + await act(async () => recovered.unmount()) +}) diff --git a/mobile/src/navigation/use-pairing-links.ts b/mobile/src/navigation/use-pairing-links.ts new file mode 100644 index 00000000000..ce81c4be1fa --- /dev/null +++ b/mobile/src/navigation/use-pairing-links.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react' +import { useRouter } from 'expo-router' +import * as Linking from 'expo-linking' +import { extractPairingCodeFromUrl } from '../transport/pairing' +import { consumeInitialLaunchIntent } from './initial-launch-intent' + +export function usePairingLinks(): void { + const router = useRouter() + useEffect(() => { + let disposed = false + function handleUrl(url: string) { + if (disposed) { + return + } + const code = extractPairingCodeFromUrl(url) + if (code) { + // Replace the camera's unmatched pair route so Back cannot reveal the router error. + router.replace({ pathname: '/pair-confirm', params: { code } }) + } + } + + const intent = consumeInitialLaunchIntent('pairing-link') + if (intent) { + void Linking.getInitialURL() + .then((url) => { + if (url && !intent.discarded) { + handleUrl(url) + } + }) + .catch(() => {}) + } + const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url)) + return () => { + disposed = true + sub.remove() + } + }, [router]) +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index bc7cefca841..e8fb5c93edd 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -43,6 +43,7 @@ export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error' export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay' export type ConnectionDiagnosticCode = + | 'native-render-failed' | 'client-session-started' | 'app-resumed' | 'network-changed'