mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
This reverts commit 55d3a42079.
This commit is contained in:
+2
-2
@@ -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
@@ -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 }} />
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-12
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -82,7 +82,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
return {
|
||||
available: true as const,
|
||||
qrDataUrl,
|
||||
pairingUrl: url,
|
||||
endpoint,
|
||||
deviceId: device.deviceId
|
||||
}
|
||||
|
||||
@@ -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 }[]
|
||||
|
||||
@@ -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,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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user