fix(mobile): recover from native screen render errors (#36121)

* fix(mobile): recover from native screen render errors

* fix(mobile): avoid replaying launch links after recovery

* fix(mobile): discard launch intent before recovery Home

* test(mobile): correct recovery fixture types
This commit is contained in:
Kelvin Amoaba
2026-10-29 22:44:22 +00:00
committed by GitHub
parent a7c63de35e
commit 41cd866fb1
28 changed files with 638 additions and 60 deletions
@@ -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",
+19 -33
View File
@@ -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<string>()
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<Set<string>>(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)
}
+16 -8
View File
@@ -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]
)
@@ -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 (
<View style={styles.container}>
<Text style={styles.title}>
{t('components.nativeScreenRecovery.title', 'Something went wrong')}
</Text>
<Pressable accessibilityRole="button" style={styles.retryButton} onPress={() => void retry()}>
<Text style={styles.retryText}>
{t('components.nativeScreenRecovery.tryAgain', 'Try again')}
</Text>
</Pressable>
<Pressable
accessibilityRole="button"
style={styles.homeButton}
onPress={() => {
discardInitialLaunchIntent()
router.replace('/')
}}
>
<Text style={styles.homeText}>{t('components.nativeScreenRecovery.home', 'Home')}</Text>
</Pressable>
</View>
)
}
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 }
}))
@@ -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())
})
@@ -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)
)
@@ -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(
@@ -40,7 +40,7 @@ export function ConnectionDiagnosticsScreen({
onBack: () => void
hostPicker?: ReactNode
}) {
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [copiedScope, setCopiedScope] = useState<{ hostId: string | null } | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
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 () => {
@@ -66,17 +66,19 @@ export function ConnectionDiagnosticsView({
</View>
{hostPicker}
{hasHost ? (
{hasHost || entries.length > 0 ? (
<>
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{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]}
</Text>
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
{copied ? (
@@ -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
@@ -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<string, string>()
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' })
])
})
@@ -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)
})
}
+5
View File
@@ -1004,6 +1004,11 @@
"folder": "Ordner",
"project": "Projekt {{repo}}",
"remoteHost": "Remote-Host"
},
"nativeScreenRecovery": {
"title": "Etwas ist schiefgelaufen",
"tryAgain": "Erneut versuchen",
"home": "Startseite"
}
},
"connection": {
+5
View File
@@ -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": {
+5
View File
@@ -1047,6 +1047,11 @@
"folder": "Carpeta",
"project": "Proyecto {{repo}}",
"remoteHost": "Host remoto"
},
"nativeScreenRecovery": {
"title": "Algo salió mal",
"tryAgain": "Volver a intentar",
"home": "Inicio"
}
},
"connection": {
+5
View File
@@ -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": {
+5
View File
@@ -1000,6 +1000,11 @@
"folder": "フォルダー",
"project": "プロジェクト {{repo}}",
"remoteHost": "リモートホスト"
},
"nativeScreenRecovery": {
"title": "問題が発生しました",
"tryAgain": "再試行",
"home": "ホーム"
}
},
"connection": {
+5
View File
@@ -1000,6 +1000,11 @@
"folder": "폴더",
"project": "프로젝트 {{repo}}",
"remoteHost": "원격 호스트"
},
"nativeScreenRecovery": {
"title": "문제가 발생했습니다",
"tryAgain": "다시 시도",
"home": "홈"
}
},
"connection": {
+5
View File
@@ -1047,6 +1047,11 @@
"folder": "Pasta",
"project": "Projeto {{repo}}",
"remoteHost": "Host remoto"
},
"nativeScreenRecovery": {
"title": "Algo deu errado",
"tryAgain": "Tentar novamente",
"home": "Início"
}
},
"connection": {
+5
View File
@@ -1090,6 +1090,11 @@
"folder": "Папка",
"project": "Проект {{repo}}",
"remoteHost": "Удалённый сервер"
},
"nativeScreenRecovery": {
"title": "Что-то пошло не так",
"tryAgain": "Повторить",
"home": "Главная"
}
},
"connection": {
+5
View File
@@ -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": {
+5
View File
@@ -1000,6 +1000,11 @@
"folder": "資料夾",
"project": "專案 {{repo}}",
"remoteHost": "遠端主機"
},
"nativeScreenRecovery": {
"title": "發生錯誤",
"tryAgain": "再試一次",
"home": "首頁"
}
},
"connection": {
+5
View File
@@ -1000,6 +1000,11 @@
"folder": "文件夹",
"project": "项目 {{repo}}",
"remoteHost": "远程主机"
},
"nativeScreenRecovery": {
"title": "出了点问题",
"tryAgain": "重试",
"home": "首页"
}
},
"connection": {
@@ -0,0 +1,17 @@
type LaunchSource = 'pairing-link' | 'notification'
const launchIntent = { discarded: false, consumedSources: new Set<LaunchSource>() }
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
}
@@ -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())
})
@@ -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<string | null>>(),
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<ReactTestRenderer> {
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())
})
@@ -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])
}
+1
View File
@@ -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'