import { useCallback, useRef, useState } from 'react' import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' import { startPreProfilePairing, type PreProfilePairingAttempt } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry } from '../src/transport/types' import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' type Status = 'awaiting-confirm' | 'connecting' | 'error' // Why: cap how long the user stares at "Connecting…" during pairing. // rpc-client retries forever by design (good for live sessions), but for // the *initial* pair we want a hard ceiling so a half-broken Tailscale // route surfaces an actionable error with the log visible, instead of // spinning silently. ~25s allows for one full connect-timeout + a retry. const PAIRING_OVERALL_TIMEOUT_MS = 25_000 export default function PairConfirmScreen() { const router = useRouter() const closeHost = useCloseHost() const insets = useSafeAreaInsets() const params = useLocalSearchParams<{ code?: string }>() const [status, setStatus] = useState('awaiting-confirm') const [errorMessage, setErrorMessage] = useState('') const [logs, setLogs] = useState([]) // Why: collect logs in a ref so the rpc-client callback (which closures // over the initial state setter) always sees the freshest list and we // batch fewer setState calls when entries arrive in bursts. const logsRef = useRef([]) const mountedRef = useRef(true) const activePairingAttemptRef = useRef(null) const routeState = resolvePairConfirmRouteState(params.code) const offer = routeState.offer const resolvedStatus = status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status const resolvedErrorMessage = status === 'awaiting-confirm' && routeState.kind === 'error' ? routeState.errorMessage : errorMessage const cancel = useCallback(() => { router.replace('/') }, [router]) useFocusEffect( useCallback(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { cancel() return true }) return () => subscription.remove() }, [cancel]) ) const setPairConfirmRootRef = useCallback((node: View | null): void => { if (node !== null) { mountedRef.current = true return } // Why: pairing attempts can outlive the visible route; dispose them when // the confirm screen detaches without a passive cleanup-only Effect. mountedRef.current = false activePairingAttemptRef.current?.dispose() activePairingAttemptRef.current = null }, []) async function confirm() { if (!offer) { return } setStatus('connecting') logsRef.current = [] setLogs([]) activePairingAttemptRef.current?.dispose() const attempt = startPreProfilePairing({ offer, timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, connectOptions: { onLog: (entry) => { if (!mountedRef.current || activePairingAttemptRef.current !== attempt) { return } logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } } }) activePairingAttemptRef.current = attempt try { const { hostId } = await attempt.result const attemptIsCurrent = activePairingAttemptRef.current === attempt attempt.dispose() if (activePairingAttemptRef.current === attempt) { activePairingAttemptRef.current = null } if (!mountedRef.current || !attemptIsCurrent) { return } // Why: re-pairing the same desktop now reuses its existing host id // (STA-1840 dedup), so a client cached under that id from an earlier // pairing would keep the stale endpoint/relay. Close it so the // destination screen opens a fresh client with the newly-paired // profile — the removeHost() path already refreshes on re-pair, and a // brand-new host has no cached entry so this is a no-op. closeHost(hostId) const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return } router.replace(mobileOnboardingDestination(onboardingSteps, hostId)) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt attempt.dispose() if (activePairingAttemptRef.current === attempt) { activePairingAttemptRef.current = null } if (!mountedRef.current || !attemptIsCurrent) { return } console.warn('[pair-confirm] connect failed', err) setStatus('error') setErrorMessage( timedOut ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` : `Pairing failed: ${err instanceof Error ? err.message : String(err)}` ) } } const containerPadding = { paddingTop: insets.top + spacing.sm } return ( {offer && resolvedStatus === 'awaiting-confirm' && ( <> Pair with this desktop? You opened a pairing link from your desktop. Confirm to add it to your hosts. void confirm()}> Pair Cancel )} {resolvedStatus === 'connecting' && ( <> Connecting… )} {resolvedStatus === 'error' && ( <> {resolvedErrorMessage} {logs.length > 0 && ( )} Back to home )} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase, padding: spacing.lg }, backButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginBottom: spacing.sm }, content: { flex: 1, justifyContent: 'center', paddingHorizontal: spacing.sm, // Why: nudges the centered group slightly above the geometric // middle so the eye reads it as visually centered above the home // indicator / nav bar. paddingBottom: spacing.xl * 2 }, title: { fontSize: typography.titleSize, fontWeight: '600', color: colors.textPrimary, marginBottom: spacing.sm, textAlign: 'center' }, subtitle: { fontSize: typography.bodySize, color: colors.textSecondary, lineHeight: 20, marginBottom: spacing.xl, textAlign: 'center', maxWidth: 520, alignSelf: 'center' }, actionStack: { width: '100%', maxWidth: 360, alignSelf: 'center' }, primaryButton: { width: '100%', backgroundColor: colors.textPrimary, paddingHorizontal: spacing.xl, paddingVertical: spacing.sm + 2, borderRadius: radii.button, alignItems: 'center', marginBottom: spacing.sm }, primaryButtonText: { color: colors.bgBase, fontSize: typography.bodySize, fontWeight: '600' }, secondaryButton: { width: '100%', paddingHorizontal: spacing.xl, paddingVertical: spacing.sm + 2, borderRadius: radii.button, alignItems: 'center' }, secondaryButtonText: { color: colors.textSecondary, fontSize: typography.bodySize, fontWeight: '500' }, connectingText: { color: colors.textSecondary, fontSize: typography.bodySize, marginTop: spacing.lg, textAlign: 'center' }, logSlot: { width: '100%', marginTop: spacing.lg, marginBottom: spacing.md }, errorText: { color: colors.statusRed, fontSize: typography.bodySize, textAlign: 'center', marginBottom: spacing.xl, lineHeight: 20 } })