diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 32decd093..78e9627a9 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +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 { parsePairingCode } from '../src/transport/pairing' +import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' +import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -23,7 +23,6 @@ export default function PairConfirmScreen() { const router = useRouter() const insets = useSafeAreaInsets() const params = useLocalSearchParams<{ code?: string }>() - const [offer, setOffer] = useState(null) const [status, setStatus] = useState('awaiting-confirm') const [errorMessage, setErrorMessage] = useState('') const [logs, setLogs] = useState([]) @@ -32,6 +31,15 @@ export default function PairConfirmScreen() { // batch fewer setState calls when entries arrive in bursts. const logsRef = useRef([]) + 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]) @@ -46,21 +54,6 @@ export default function PairConfirmScreen() { }, [cancel]) ) - useEffect(() => { - if (!params.code) { - setStatus('error') - setErrorMessage('Missing pairing code') - return - } - const parsed = parsePairingCode(params.code) - if (!parsed) { - setStatus('error') - setErrorMessage('Not a valid pairing code') - return - } - setOffer(parsed) - }, [params.code]) - async function confirm() { if (!offer) return setStatus('connecting') @@ -141,7 +134,7 @@ export default function PairConfirmScreen() { - {offer && status === 'awaiting-confirm' && ( + {offer && resolvedStatus === 'awaiting-confirm' && ( <> Pair with this desktop? @@ -156,7 +149,7 @@ export default function PairConfirmScreen() { )} - {status === 'connecting' && ( + {resolvedStatus === 'connecting' && ( <> Connecting… @@ -166,9 +159,9 @@ export default function PairConfirmScreen() { )} - {status === 'error' && ( + {resolvedStatus === 'error' && ( <> - {errorMessage} + {resolvedErrorMessage} {logs.length > 0 && ( diff --git a/mobile/src/transport/pair-confirm-state.test.ts b/mobile/src/transport/pair-confirm-state.test.ts new file mode 100644 index 000000000..1a318dd7e --- /dev/null +++ b/mobile/src/transport/pair-confirm-state.test.ts @@ -0,0 +1,54 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' + +import { resolvePairConfirmRouteState } from './pair-confirm-state' +import type { PairingOffer } from './types' + +function encodeOffer(offer: PairingOffer): string { + return Buffer.from(JSON.stringify(offer)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +describe('resolvePairConfirmRouteState', () => { + const offer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token-abc', + publicKeyB64: 'pubkey-xyz' + } + + it('accepts a valid pairing code', () => { + expect(resolvePairConfirmRouteState(encodeOffer(offer))).toEqual({ + kind: 'ready', + offer, + errorMessage: '' + }) + }) + + it('accepts a full pairing URL', () => { + expect(resolvePairConfirmRouteState(`orca://pair#${encodeOffer(offer)}`)).toEqual({ + kind: 'ready', + offer, + errorMessage: '' + }) + }) + + it('reports a missing pairing code', () => { + expect(resolvePairConfirmRouteState(undefined)).toEqual({ + kind: 'error', + offer: null, + errorMessage: 'Missing pairing code' + }) + }) + + it('reports an invalid pairing code', () => { + expect(resolvePairConfirmRouteState('not a pairing code')).toEqual({ + kind: 'error', + offer: null, + errorMessage: 'Not a valid pairing code' + }) + }) +}) diff --git a/mobile/src/transport/pair-confirm-state.ts b/mobile/src/transport/pair-confirm-state.ts new file mode 100644 index 000000000..242e67d7f --- /dev/null +++ b/mobile/src/transport/pair-confirm-state.ts @@ -0,0 +1,19 @@ +import { parsePairingCode } from './pairing' +import type { PairingOffer } from './types' + +export type PairConfirmRouteState = + | { kind: 'ready'; offer: PairingOffer; errorMessage: '' } + | { kind: 'error'; offer: null; errorMessage: string } + +export function resolvePairConfirmRouteState(code: string | undefined): PairConfirmRouteState { + if (!code) { + return { kind: 'error', offer: null, errorMessage: 'Missing pairing code' } + } + + const offer = parsePairingCode(code) + if (!offer) { + return { kind: 'error', offer: null, errorMessage: 'Not a valid pairing code' } + } + + return { kind: 'ready', offer, errorMessage: '' } +}