mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -1004,6 +1004,11 @@
|
||||
"folder": "Ordner",
|
||||
"project": "Projekt {{repo}}",
|
||||
"remoteHost": "Remote-Host"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "Etwas ist schiefgelaufen",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"home": "Startseite"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1047,6 +1047,11 @@
|
||||
"folder": "Carpeta",
|
||||
"project": "Proyecto {{repo}}",
|
||||
"remoteHost": "Host remoto"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "Algo salió mal",
|
||||
"tryAgain": "Volver a intentar",
|
||||
"home": "Inicio"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1000,6 +1000,11 @@
|
||||
"folder": "フォルダー",
|
||||
"project": "プロジェクト {{repo}}",
|
||||
"remoteHost": "リモートホスト"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "問題が発生しました",
|
||||
"tryAgain": "再試行",
|
||||
"home": "ホーム"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -1000,6 +1000,11 @@
|
||||
"folder": "폴더",
|
||||
"project": "프로젝트 {{repo}}",
|
||||
"remoteHost": "원격 호스트"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "문제가 발생했습니다",
|
||||
"tryAgain": "다시 시도",
|
||||
"home": "홈"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -1047,6 +1047,11 @@
|
||||
"folder": "Pasta",
|
||||
"project": "Projeto {{repo}}",
|
||||
"remoteHost": "Host remoto"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "Algo deu errado",
|
||||
"tryAgain": "Tentar novamente",
|
||||
"home": "Início"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -1090,6 +1090,11 @@
|
||||
"folder": "Папка",
|
||||
"project": "Проект {{repo}}",
|
||||
"remoteHost": "Удалённый сервер"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "Что-то пошло не так",
|
||||
"tryAgain": "Повторить",
|
||||
"home": "Главная"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1000,6 +1000,11 @@
|
||||
"folder": "資料夾",
|
||||
"project": "專案 {{repo}}",
|
||||
"remoteHost": "遠端主機"
|
||||
},
|
||||
"nativeScreenRecovery": {
|
||||
"title": "發生錯誤",
|
||||
"tryAgain": "再試一次",
|
||||
"home": "首頁"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user