Files
orca/mobile/app/pair-scan.tsx
T
Neil bb5f37c916 feat(pairing): unscanned desktop pairing codes expire (QR 10 min, link 60 min) (#37957)
* feat(pairing): unscanned desktop pairing offers expire (QR 10 min, link 60 min)

Headless serve/orcad mint offers without a TTL, so they never expire.
Paired rows and rows without offerExpiresAt are unaffected.

* feat(pairing): refresh the mobile QR before it expires and show runtime link expiry

The QR re-mints a minute before expiry while visible; an unused runtime
link shows its expiry and, once expired, is replaced by a notice. Web
clients rejected while pairing now suggest asking for a new link.

* feat(mobile): say a refused pairing code expired or was replaced

A first pairing the desktop refuses (direct or Relay) now asks for a new
code instead of showing the raw transport error.

* refactor(pairing): replace type assertions in code-refusal detection and tests

* test(mobile): re-record pairing race golden for refused-code error

* fix(pairing): keep pairing codes valid across sleep and stale registry writers

- QR refresh rechecks the wall clock every 15 s and on window focus, so a
  computer waking from sleep never keeps showing an expired code.
- A registry save from a process with an older copy can no longer reset a
  paired row back to unscanned (which would let its offer expire).
- The QR caption says how long a copied code works.

* test(pairing): annotate the IPC handler stub cast
2026-11-06 16:23:44 -08:00

429 lines
15 KiB
TypeScript

import * as reticle from '../src/pairing/pairing-scan-reticle-dimensions'
import { useState, useRef, useCallback, useEffect } from 'react'
import {
AppState,
View,
Text,
Pressable,
ActivityIndicator,
Linking,
type LayoutChangeEvent
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import { getPairingScanErrorMessage } from '../src/transport/pairing-scan-error-message'
import {
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import type { PairingOffer } from '../../src/shared/remote-runtime/pairing/mobile-relay-pairing-offer'
import { useRefreshHostClient } from '../src/transport/host-client-hooks'
import { colors, spacing, pressedStyle } from '../src/theme/mobile-theme'
import { useMobileColorScheme } from '../src/theme/use-mobile-color-scheme'
import { useMobileTranslation } from '../src/i18n/use-mobile-translation'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import { pairScanStyles as styles } from '../src/pair-scan-styles'
import {
PAIRING_SUCCESS_HOLD_MS,
PairingProgress,
SCAN_DECODE_BEAT_MS,
ScanReticle,
Step,
waitForBeat
} from '../src/pairing/pairing-visuals'
import { triggerSuccess } from '../src/platform/haptics'
import { getPairingFailureMessage } from '../src/pairing/pairing-failure-message'
// Bound initial pairing at ~25s (one connect timeout plus retry) so broken routes show an actionable error/log; live RPC retries indefinitely.
const PAIRING_OVERALL_TIMEOUT_MS = 25_000
export default function PairScanScreen() {
useMobileColorScheme()
const t = useMobileTranslation()
const router = useRouter()
const refreshHostClient = useRefreshHostClient()
const insets = useSafeAreaInsets()
const [permission, requestPermission, getPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
const [decoded, setDecoded] = useState(false)
const [paired, setPaired] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [pasteVisible, setPasteVisible] = useState(false)
const [cameraBounds, setCameraBounds] = useState({ width: 0, height: 0 })
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
const logsRef = useRef<ConnectionLogEntry[]>([])
const processingRef = useRef(false)
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
void getPermission().catch(() => {})
}
})
return () => subscription.remove()
}, [getPermission])
const setPairScanRootRef = useCallback((node: View | null): void => {
if (node !== null) {
mountedRef.current = true
return
}
// Dispose pairing on route detach: attempts can outlive the visible screen, before passive cleanup.
mountedRef.current = false
activePairingAttemptRef.current?.dispose()
activePairingAttemptRef.current = null
}, [])
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
if (processingRef.current) {
return
}
processingRef.current = true
const offer = decodePairingUrl(data)
if (!offer) {
setStatus('error')
setErrorMessage(getPairingScanErrorMessage(data, t))
processingRef.current = false
return
}
setDecoded(true)
void waitForBeat(SCAN_DECODE_BEAT_MS).then(() => testAndSave(offer))
},
[router, t]
)
const handlePasteSubmit = useCallback(
(input: string) => {
setPasteVisible(false)
if (processingRef.current) {
return
}
processingRef.current = true
const offer = parsePairingCode(input)
if (!offer) {
setStatus('error')
setErrorMessage(
t(
'app.pairScan.invalidPairingCode',
'Not a valid pairing code — copy it from your computer and paste again'
)
)
processingRef.current = false
return
}
void testAndSave(offer)
},
[t]
)
const handleCameraLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout
const nextBounds = {
width: Math.round(width),
height: Math.round(height)
}
setCameraBounds((currentBounds) =>
currentBounds.width === nextBounds.width && currentBounds.height === nextBounds.height
? currentBounds
: nextBounds
)
}, [])
async function testAndSave(offer: PairingOffer) {
// A decoded QR waits out the reticle beat; the screen may have left meanwhile.
if (!mountedRef.current) {
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
}
// Re-pair reuses host id; close cached client so it reloads the persisted endpoint/relay (STA-1840).
refreshHostClient(hostId)
setPaired(true)
triggerSuccess()
const [onboardingSteps] = await Promise.all([
loadMobileOnboardingSteps(),
waitForBeat(PAIRING_SUCCESS_HOLD_MS)
])
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] connect failed', err)
setStatus('error')
setErrorMessage(
getPairingFailureMessage({
screen: 'scan',
error: err,
timedOut,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
t
})
)
processingRef.current = false
}
}
function retry() {
setStatus('scanning')
setDecoded(false)
setErrorMessage('')
logsRef.current = []
setLogs([])
processingRef.current = false
}
// Include Android three-button/iOS home-indicator inset so paste button stays clear.
const containerPadding = {
paddingTop: insets.top + spacing.sm,
paddingBottom: insets.bottom + spacing.sm
}
// Keep QR guide square even on rectangular iPad previews.
const reticleSize = Math.min(
Math.round(Math.min(cameraBounds.width, cameraBounds.height) * reticle.SCALE),
reticle.MAX_SIZE
)
if (!permission) {
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<ActivityIndicator color={colors.textSecondary} />
</View>
)
}
if (!permission.granted && status === 'scanning') {
const canAskAgain = permission.canAskAgain !== false
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && pressedStyle]}
onPress={() => router.back()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.centered}>
<Text style={styles.title}>
{canAskAgain
? t('app.pairScan.title', 'Pair with desktop')
: t('app.pairScan.cameraDisabledTitle', 'Camera Access Disabled')}
</Text>
<Text style={styles.subtitle}>
{canAskAgain
? t(
'app.pairScan.cameraPermissionDescription',
'Scan the QR code from Orca on your desktop, or paste the pairing code instead.'
)
: t(
'app.pairScan.cameraDisabledDescription',
'Enable camera access in Settings, or paste the pairing code instead.'
)}
</Text>
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && pressedStyle]}
onPress={canAskAgain ? requestPermission : () => void Linking.openSettings()}
>
{canAskAgain && <QrCode size={16} color={colors.bgBase} />}
<Text style={styles.primaryButtonText}>
{canAskAgain
? t('common.continue', 'Continue')
: t('app.pairScan.openSettings', 'Open Settings')}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
onPress={() => setPasteVisible(true)}
>
<ClipboardIcon size={16} color={colors.textSecondary} />
<Text style={styles.pasteButtonText}>
{t('app.pairScan.pasteInstead', 'Paste code instead')}
</Text>
</Pressable>
</View>
<TextInputModal
visible={pasteVisible}
title={t('app.pairScan.pasteCodeTitle', 'Paste pairing code')}
message={t(
'app.pairScan.pasteCodeMessage',
'Copy the code shown under the QR on your computer.'
)}
placeholder={t(
'app.pairScan.pasteCodePlaceholder',
'orca://pair?code=... or paste the code'
)}
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
</View>
)
}
return (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && pressedStyle]}
onPress={() => router.back()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.steps}>
<Step number={1} text={t('app.pairScan.openDesktopStep', 'Open Orca on your computer')} />
<Step
number={2}
text={t('app.pairScan.openMobileSettingsStep', 'Go to Settings → Mobile')}
/>
<Step number={3} text={t('app.pairScan.scanCodeStep', 'Scan the QR code')} />
</View>
{status === 'scanning' && (
<>
{/* Pause camera behind paste sheet to save power and prevent unnoticed scans. */}
{!pasteVisible && (
<View style={styles.cameraWrap} onLayout={handleCameraLayout}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<ScanReticle size={reticleSize} decoded={decoded} />
</View>
)}
{pasteVisible && <View style={styles.cameraPlaceholder} />}
<Pressable
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
onPress={() => setPasteVisible(true)}
>
<ClipboardIcon size={16} color={colors.textSecondary} />
<Text style={styles.pasteButtonText}>
{t('app.pairScan.pastePairingCode', 'Or paste pairing code')}
</Text>
</Pressable>
</>
)}
{status === 'connecting' && (
<View style={styles.centered}>
<PairingProgress
succeeded={paired}
connectingLabel={t('app.pairScan.connecting', 'Connecting…')}
labelStyle={styles.connectingText}
/>
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title={t('app.pairScan.pairingLog', 'Pairing log')} />
</View>
</View>
)}
{status === 'error' && (
<View style={styles.centered}>
<Text style={styles.errorText}>{errorMessage}</Text>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title={t('app.pairScan.pairingLog', 'Pairing log')} />
</View>
)}
<View style={styles.errorActions}>
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && pressedStyle]}
onPress={retry}
>
<Text style={styles.primaryButtonText}>
{t('app.pairScan.tryAgain', 'Try Again')}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.pasteButtonPressed
]}
onPress={() => {
retry()
setPasteVisible(true)
}}
>
<Text style={styles.secondaryButtonText}>
{t('app.pairScan.pasteInstead', 'Paste code instead')}
</Text>
</Pressable>
</View>
</View>
)}
<TextInputModal
visible={pasteVisible}
title={t('app.pairScan.pasteCodeTitle', 'Paste pairing code')}
message={t(
'app.pairScan.pasteCodeMessage',
'Copy the code shown under the QR on your computer.'
)}
placeholder={t(
'app.pairScan.pasteCodePlaceholder',
'orca://pair?code=... or paste the code'
)}
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
</View>
)
}