Resolve mobile pair-confirm route state before commit (#3240)

* Resolve pair confirm route state before commit

* Preserve pair confirm back handling

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-30 19:18:32 -07:00 committed by GitHub
parent c082f21d2e
commit f2fa3ee904
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 89 additions and 23 deletions

View File

@ -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<PairingOffer | null>(null)
const [status, setStatus] = useState<Status>('awaiting-confirm')
const [errorMessage, setErrorMessage] = useState('')
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
@ -32,6 +31,15 @@ export default function PairConfirmScreen() {
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
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() {
</Pressable>
<View style={styles.content}>
{offer && status === 'awaiting-confirm' && (
{offer && resolvedStatus === 'awaiting-confirm' && (
<>
<Text style={styles.title}>Pair with this desktop?</Text>
<Text style={styles.subtitle}>
@ -156,7 +149,7 @@ export default function PairConfirmScreen() {
</>
)}
{status === 'connecting' && (
{resolvedStatus === 'connecting' && (
<>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting</Text>
@ -166,9 +159,9 @@ export default function PairConfirmScreen() {
</>
)}
{status === 'error' && (
{resolvedStatus === 'error' && (
<>
<Text style={styles.errorText}>{errorMessage}</Text>
<Text style={styles.errorText}>{resolvedErrorMessage}</Text>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />

View File

@ -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'
})
})
})

View File

@ -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: '' }
}