Revert "feat(mobile): pairing paste flow + Keychain-backed device tokens (#1452)" (#1474)

This reverts commit 55d3a42079.
This commit is contained in:
Jinwoo Hong
2026-05-05 16:29:00 -07:00
committed by GitHub
parent f927d3bef9
commit 4140d6a4c0
15 changed files with 46 additions and 621 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.3",
"version": "0.0.2",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -16,7 +16,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "4",
"buildNumber": "3",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSAppTransportSecurity": {
+2 -46
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect } from 'react'
import { useCallback } from 'react'
import { View, StyleSheet } from 'react-native'
import { Stack, useRouter } from 'expo-router'
import { Stack } 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 { OrcaLogo } from '../src/components/OrcaLogo'
@@ -27,49 +26,7 @@ Notifications.setNotificationHandler({
})
})
// Why: extract the path+payload that follows the orca://pair anchor so we
// can route it to the confirm screen. Accept either a hash payload
// (`orca://pair#<base64>`, the QR / shared form) or a query param
// (`orca://pair?code=<...>`, future-proof for share sheets that strip
// fragments).
function extractPairCode(url: string): string | null {
if (!url.startsWith('orca://pair')) return null
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
return url.slice(hashIndex + 1) || null
}
const queryIndex = url.indexOf('?')
if (queryIndex !== -1) {
const params = new URLSearchParams(url.slice(queryIndex + 1))
return params.get('code')
}
return null
}
export default function RootLayout() {
const router = useRouter()
// Why: route `orca://pair#<code>` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
// covers cold-start (link tapped while app was closed); the listener
// covers warm-start (link tapped while app is in memory).
useEffect(() => {
function handleUrl(url: string) {
const code = extractPairCode(url)
if (code) {
router.push({ 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])
// Why: hide the native splash only once the navigation Stack has been laid
// out — this is the earliest moment the user will see actual app content.
// Previously the splash hid when a placeholder View rendered, leaving a
@@ -98,7 +55,6 @@ export default function RootLayout() {
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
-217
View File
@@ -1,217 +0,0 @@
import { useEffect, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { parsePairingCode } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { PairingOffer } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
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('')
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')
let client: ReturnType<typeof connect> | null = null
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
const response = await client.sendRequest('status.get')
client.close()
client = null
if (!response.ok) {
setStatus('error')
setErrorMessage(
response.error.code === 'unauthorized'
? 'Authentication failed — token may be expired'
: `Server error: ${response.error.message}`
)
return
}
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
router.replace(`/h/${hostId}`)
} catch {
setStatus('error')
setErrorMessage('Cannot connect — check that your computer is on the same network')
} finally {
client?.close()
}
}
function cancel() {
router.replace('/')
}
const containerPadding = { paddingTop: insets.top + spacing.sm }
return (
<View style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={cancel}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.content}>
<Text style={styles.title}>Pair with this desktop?</Text>
{offer && status === 'awaiting-confirm' && (
<>
<Text style={styles.subtitle}>
You opened a pairing link from your desktop. Confirm to add it to your hosts.
</Text>
<View style={styles.detailsCard}>
<Text style={styles.detailsLabel}>Endpoint</Text>
<Text style={styles.detailsValue}>{offer.endpoint}</Text>
</View>
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
<Text style={styles.primaryButtonText}>Pair</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={cancel}>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</Pressable>
</>
)}
{status === 'connecting' && (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting</Text>
</View>
)}
{status === 'error' && (
<View style={styles.centered}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Pressable style={styles.primaryButton} onPress={cancel}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
</View>
)}
</View>
</View>
)
}
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,
paddingHorizontal: spacing.sm
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
fontSize: typography.bodySize,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.lg
},
detailsCard: {
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
padding: spacing.md,
marginBottom: spacing.lg
},
detailsLabel: {
fontSize: 12,
color: colors.textMuted,
marginBottom: 4
},
detailsValue: {
fontSize: typography.bodySize,
color: colors.textPrimary,
fontFamily: 'Menlo',
fontWeight: '500'
},
primaryButton: {
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: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center'
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
}
})
+18 -99
View File
@@ -3,13 +3,12 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import { ChevronLeft } from 'lucide-react-native'
import { decodePairingUrl } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { PairingOffer } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
function Step({ number, text }: { number: number; text: string }) {
return (
@@ -28,7 +27,6 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
const [errorMessage, setErrorMessage] = useState('')
const [pasteVisible, setPasteVisible] = useState(false)
const processingRef = useRef(false)
const handleBarCodeScanned = useCallback(
@@ -49,22 +47,6 @@ export default function PairScanScreen() {
[router]
)
const handlePasteSubmit = useCallback((input: string) => {
setPasteVisible(false)
if (processingRef.current) return
processingRef.current = true
const offer = parsePairingCode(input)
if (!offer) {
setStatus('error')
setErrorMessage('Not a valid pairing code — copy it from your computer and paste again')
processingRef.current = false
return
}
void testAndSave(offer)
}, [])
async function testAndSave(offer: PairingOffer) {
setStatus('connecting')
let client: ReturnType<typeof connect> | null = null
@@ -158,29 +140,20 @@ export default function PairScanScreen() {
</View>
{status === 'scanning' && (
<>
<View style={styles.cameraWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<View style={styles.reticle} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<View style={styles.cameraWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<View style={styles.reticle} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Pressable
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
onPress={() => setPasteVisible(true)}
>
<ClipboardIcon size={16} color={colors.textSecondary} />
<Text style={styles.pasteButtonText}>Or paste pairing code</Text>
</Pressable>
</>
</View>
)}
{status === 'connecting' && (
@@ -193,34 +166,11 @@ export default function PairScanScreen() {
{status === 'error' && (
<View style={styles.centered}>
<Text style={styles.errorText}>{errorMessage}</Text>
<View style={styles.errorActions}>
<Pressable style={styles.primaryButton} onPress={retry}>
<Text style={styles.primaryButtonText}>Try Again</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.pasteButtonPressed
]}
onPress={() => {
retry()
setPasteVisible(true)
}}
>
<Text style={styles.secondaryButtonText}>Paste code instead</Text>
</Pressable>
</View>
<Pressable style={styles.primaryButton} onPress={retry}>
<Text style={styles.primaryButtonText}>Try Again</Text>
</Pressable>
</View>
)}
<TextInputModal
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="orca://pair#... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
</View>
)
}
@@ -353,36 +303,5 @@ const styles = StyleSheet.create({
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
-1
View File
@@ -22,7 +22,6 @@
"expo-linking": "^55.0.14",
"expo-notifications": "^55.0.21",
"expo-router": "^55.0.13",
"expo-secure-store": "^55.0.13",
"expo-splash-screen": "^55.0.19",
"expo-status-bar": "^55.0.5",
"lucide-react-native": "^1.11.0",
-12
View File
@@ -38,9 +38,6 @@ importers:
expo-router:
specifier: ^55.0.13
version: 55.0.13(121105c3b042d5e83ab3c1d3b84ed55f)
expo-secure-store:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.17)
expo-splash-screen:
specifier: ^55.0.19
version: 55.0.19(expo@55.0.17)(typescript@5.9.3)
@@ -3237,11 +3234,6 @@ packages:
react-server-dom-webpack:
optional: true
expo-secure-store@55.0.13:
resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==}
peerDependencies:
expo: '*'
expo-server@55.0.8:
resolution: {integrity: sha512-AoV5TKuO4biSzrhe/OVLyInfTT0pV9/OOc/g/oVq5vmCjL8SaSYTkES8PLt+67Tm7VqX+Dn0+kSx1nQcjEKaPw==}
engines: {node: '>=20.16.0'}
@@ -9573,10 +9565,6 @@ snapshots:
- expo-font
- supports-color
expo-secure-store@55.0.13(expo@55.0.17):
dependencies:
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server@55.0.8: {}
expo-splash-screen@55.0.19(expo@55.0.17)(typescript@5.9.3):
+10 -81
View File
@@ -1,73 +1,16 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import {
HostProfileSchema,
StoredHostProfileSchema,
type HostProfile,
type StoredHostProfile
} from './types'
import { HostProfileSchema, type HostProfile } from './types'
const STORAGE_KEY = 'orca:hosts'
const TOKEN_KEY_PREFIX = 'orca:host-token:'
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off
// iCloud Keychain and out of iCloud/iTunes backup restores onto a
// different physical device. Reads/writes are silent (no biometric
// prompt) since we don't request access control flags.
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
export async function loadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
const out: HostProfile[] = []
for (const item of parsed) {
// Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage.
// Drop them silently — the three pre-launch users will re-pair on
// first run rather than carry a migration shim through the auth
// path.
if (item && typeof item === 'object' && 'deviceToken' in item) {
continue
}
const stored = StoredHostProfileSchema.safeParse(item)
if (!stored.success) continue
const token = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS)
if (!token) {
// Why: orphaned metadata with no matching keychain entry — most
// likely a stale record from a development install. Skip it
// rather than surface a half-broken host.
continue
}
out.push({ ...stored.data, deviceToken: token })
}
return out
}
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.flatMap((item) => {
// Why: same drop-old-records rule as loadHosts; keeps internal
// mutators from re-persisting pre-v0.0.3 entries.
if (item && typeof item === 'object' && 'deviceToken' in item) return []
const result = StoredHostProfileSchema.safeParse(item)
const result = HostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
@@ -75,39 +18,25 @@ async function loadStoredHosts(): Promise<StoredHostProfile[]> {
}
}
function toStored(host: HostProfile): StoredHostProfile {
return {
id: host.id,
name: host.name,
endpoint: host.endpoint,
publicKeyB64: host.publicKeyB64,
lastConnected: host.lastConnected
}
}
export async function saveHost(host: HostProfile): Promise<void> {
const validated = HostProfileSchema.parse(host)
const hosts = await loadStoredHosts()
const stored = toStored(validated)
const index = hosts.findIndex((h) => h.id === stored.id)
const hosts = await loadHosts()
const index = hosts.findIndex((h) => h.id === host.id)
if (index >= 0) {
hosts[index] = stored
hosts[index] = host
} else {
hosts.push(stored)
hosts.push(host)
}
await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
}
export async function removeHost(hostId: string): Promise<void> {
const hosts = await loadStoredHosts()
const hosts = await loadHosts()
const filtered = hosts.filter((h) => h.id !== hostId)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
}
export async function renameHost(hostId: string, newName: string): Promise<void> {
const hosts = await loadStoredHosts()
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (host) {
host.name = newName
@@ -116,7 +45,7 @@ export async function renameHost(hostId: string, newName: string): Promise<void>
}
export async function getNextHostName(): Promise<string> {
const hosts = await loadStoredHosts()
const hosts = await loadHosts()
const existingNumbers = hosts
.map((h) => {
const match = h.name.match(/^Host (\d+)$/)
@@ -128,7 +57,7 @@ export async function getNextHostName(): Promise<string> {
}
export async function updateLastConnected(hostId: string): Promise<void> {
const hosts = await loadStoredHosts()
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (host) {
host.lastConnected = Date.now()
+6 -28
View File
@@ -1,38 +1,16 @@
import { PairingOfferSchema, type PairingOffer } from './types'
// Why: this file mirrors src/shared/pairing.ts (which is covered by CI
// vitest) but uses atob/btoa because Metro/Hermes don't ship Node's
// Buffer. Keep the parsing semantics in sync — when one changes, update
// the other.
export function decodePairingUrl(url: string): PairingOffer | null {
try {
const hashIndex = url.indexOf('#')
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
return decodePairingBase64(url.slice(hashIndex + 1))
const base64url = url.slice(hashIndex + 1)
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(base64)
const parsed = JSON.parse(json)
return PairingOfferSchema.parse(parsed)
} catch {
return null
}
}
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
// string so the paste-pair flow can take whichever the user actually
// copied from desktop.
export function parsePairingCode(input: string): PairingOffer | null {
const trimmed = input.trim()
if (!trimmed) return null
try {
if (trimmed.startsWith('orca://pair')) {
return decodePairingUrl(trimmed)
}
return decodePairingBase64(trimmed)
} catch {
return null
}
}
function decodePairingBase64(base64url: string): PairingOffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(base64)
return PairingOfferSchema.parse(JSON.parse(json))
}
-13
View File
@@ -60,16 +60,3 @@ export const HostProfileSchema = z.object({
publicKeyB64: z.string().min(1),
lastConnected: z.number().finite()
})
// Why: persisted host record after the v0.0.3 keychain split. The
// deviceToken is held in iOS Keychain via expo-secure-store and joined
// in at load time; it must NOT appear in AsyncStorage anymore.
export const StoredHostProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
endpoint: z.string().min(1),
publicKeyB64: z.string().min(1),
lastConnected: z.number().finite()
})
export type StoredHostProfile = z.infer<typeof StoredHostProfileSchema>
-1
View File
@@ -82,7 +82,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
return {
available: true as const,
qrDataUrl,
pairingUrl: url,
endpoint,
deviceId: device.deviceId
}
+4 -8
View File
@@ -1172,15 +1172,11 @@ export type PreloadApi = {
listNetworkInterfaces: () => Promise<{
interfaces: { name: string; address: string }[]
}>
getPairingQR: (args?: { address?: string }) => Promise<
getPairingQR: (args?: {
address?: string
}) => Promise<
| { available: false }
| {
available: true
qrDataUrl: string
pairingUrl: string
endpoint: string
deviceId: string
}
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
>
listDevices: () => Promise<{
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
+1 -7
View File
@@ -2135,13 +2135,7 @@ const api = {
address?: string
}): Promise<
| { available: false }
| {
available: true
qrDataUrl: string
pairingUrl: string
endpoint: string
deviceId: string
}
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
> => ipcRenderer.invoke('mobile:getPairingQR', args),
listDevices: (): Promise<{
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Check, Copy, Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react'
import { Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react'
import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
@@ -38,14 +38,12 @@ type NetworkInterface = {
export function MobilePane(): React.JSX.Element {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
const [endpoint, setEndpoint] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [devices, setDevices] = useState<PairedDevice[]>([])
const [qrEnlarged, setQrEnlarged] = useState(false)
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(undefined)
const [codeCopied, setCodeCopied] = useState(false)
const loadDevices = useCallback(async () => {
try {
@@ -76,9 +74,7 @@ export function MobilePane(): React.JSX.Element {
)
if (result.available) {
setQrDataUrl(result.qrDataUrl)
setPairingUrl(result.pairingUrl)
setEndpoint(result.endpoint)
setCodeCopied(false)
void loadDevices()
} else {
toast.error('WebSocket transport is not running')
@@ -114,19 +110,6 @@ export function MobilePane(): React.JSX.Element {
return () => clearInterval(interval)
}, [deviceCountAtQr, devices.length, loadDevices])
async function copyPairingCode() {
if (!pairingUrl) {
return
}
try {
await navigator.clipboard.writeText(pairingUrl)
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
} catch {
toast.error('Failed to copy pairing code')
}
}
async function revokeDevice(deviceId: string) {
try {
await window.api.mobile.revokeDevice({ deviceId })
@@ -194,26 +177,6 @@ export function MobilePane(): React.JSX.Element {
<p className="text-muted-foreground max-w-xs text-center text-xs">
Scan this code with the Orca mobile app. Each code creates a unique device token.
</p>
{pairingUrl && (
<div className="flex w-full max-w-sm flex-col gap-1.5 px-4">
<div className="text-muted-foreground text-center text-xs">
Or paste this code in the mobile app:
</div>
<Button
variant="outline"
size="sm"
onClick={() => void copyPairingCode()}
className="font-mono text-[10px] leading-tight whitespace-normal break-all h-auto py-2"
>
<span className="flex-1 text-left">{pairingUrl}</span>
{codeCopied ? (
<Check className="ml-2 size-3.5 shrink-0 text-emerald-500" />
) : (
<Copy className="ml-2 size-3.5 shrink-0" />
)}
</Button>
</div>
)}
</div>
)}
+1 -46
View File
@@ -1,10 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
encodePairingOffer,
decodePairingOffer,
parsePairingCode,
type PairingOffer
} from './pairing'
import { encodePairingOffer, decodePairingOffer, type PairingOffer } from './pairing'
describe('pairing offer', () => {
const offer: PairingOffer = {
@@ -54,43 +49,3 @@ describe('pairing offer', () => {
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
})
})
describe('parsePairingCode', () => {
const offer: PairingOffer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token-abc',
publicKeyB64: 'pubkey-xyz'
}
it('parses a full orca://pair# URL', () => {
const url = encodePairingOffer(offer)
expect(parsePairingCode(url)).toEqual(offer)
})
it('parses a bare base64url payload (without scheme prefix)', () => {
const url = encodePairingOffer(offer)
const base64url = url.split('#')[1]!
expect(parsePairingCode(base64url)).toEqual(offer)
})
it('tolerates surrounding whitespace from clipboard', () => {
const url = encodePairingOffer(offer)
expect(parsePairingCode(` ${url}\n`)).toEqual(offer)
})
it('returns null for empty input', () => {
expect(parsePairingCode('')).toBeNull()
expect(parsePairingCode(' ')).toBeNull()
})
it('returns null for garbage input', () => {
expect(parsePairingCode('not a pairing code')).toBeNull()
expect(parsePairingCode('https://example.com')).toBeNull()
})
it('returns null for valid base64 of unrelated JSON', () => {
const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64')
expect(parsePairingCode(bogus)).toBeNull()
})
})
+1 -22
View File
@@ -28,28 +28,7 @@ export function decodePairingOffer(url: string): PairingOffer {
if (!url.startsWith('orca://pair') || hashIndex === -1) {
throw new Error('Invalid pairing URL: must start with orca://pair#')
}
return decodePairingBase64(url.slice(hashIndex + 1))
}
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
// string so the mobile paste-pair flow can take whichever the user
// actually copied from desktop.
export function parsePairingCode(input: string): PairingOffer | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
try {
if (trimmed.startsWith('orca://pair')) {
return decodePairingOffer(trimmed)
}
return decodePairingBase64(trimmed)
} catch {
return null
}
}
function decodePairingBase64(base64url: string): PairingOffer {
const base64url = url.slice(hashIndex + 1)
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const json = Buffer.from(base64, 'base64').toString('utf-8')
return PairingOfferSchema.parse(JSON.parse(json))