diff --git a/mobile/app.json b/mobile/app.json index ecee00a8bfc..5bfa5a4b6fb 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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": { diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index bdae84320c3..742addac1cb 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -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#`, 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#` 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() { }} /> - diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx deleted file mode 100644 index 185375eb564..00000000000 --- a/mobile/app/pair-confirm.tsx +++ /dev/null @@ -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(null) - const [status, setStatus] = useState('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 | 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 ( - - - - - - - Pair with this desktop? - - {offer && status === 'awaiting-confirm' && ( - <> - - You opened a pairing link from your desktop. Confirm to add it to your hosts. - - - Endpoint - {offer.endpoint} - - void confirm()}> - Pair - - - Cancel - - - )} - - {status === 'connecting' && ( - - - Connecting… - - )} - - {status === 'error' && ( - - {errorMessage} - - Back to home - - - )} - - - ) -} - -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 - } -}) diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 38944f88c0a..ca522e7ddae 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -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 | null = null @@ -158,29 +140,20 @@ export default function PairScanScreen() { {status === 'scanning' && ( - <> - - - - - - - - + + + + + + + - [styles.pasteButton, pressed && styles.pasteButtonPressed]} - onPress={() => setPasteVisible(true)} - > - - Or paste pairing code - - + )} {status === 'connecting' && ( @@ -193,34 +166,11 @@ export default function PairScanScreen() { {status === 'error' && ( {errorMessage} - - - Try Again - - [ - styles.secondaryButton, - pressed && styles.pasteButtonPressed - ]} - onPress={() => { - retry() - setPasteVisible(true) - }} - > - Paste code instead - - + + Try Again + )} - - setPasteVisible(false)} - /> ) } @@ -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' } }) diff --git a/mobile/package.json b/mobile/package.json index 02fa1bf57d0..6006461c802 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 9a346120f2a..11f0e80157f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -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): diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 3677c902f36..7f35fb742a2 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -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 { - 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 { 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 { } } -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 { - 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 { - 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 { - 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 } export async function getNextHostName(): Promise { - 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 { } export async function updateLastConnected(hostId: string): Promise { - const hosts = await loadStoredHosts() + const hosts = await loadHosts() const host = hosts.find((h) => h.id === hostId) if (host) { host.lastConnected = Date.now() diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 2b54f4e58db..ada3e2715eb 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -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#` 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)) -} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 2e9e8fcf7ff..c73489a9751 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -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 diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 528749c29b5..c7ee87d07ee 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -82,7 +82,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void { return { available: true as const, qrDataUrl, - pairingUrl: url, endpoint, deviceId: device.deviceId } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 50dc624ebb7..d34889c47c0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 }[] diff --git a/src/preload/index.ts b/src/preload/index.ts index db9f0d01664..e95b70a6872 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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<{ diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index acab65da46c..2dcc31ee03e 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -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(null) - const [pairingUrl, setPairingUrl] = useState(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [devices, setDevices] = useState([]) const [qrEnlarged, setQrEnlarged] = useState(false) const [networkInterfaces, setNetworkInterfaces] = useState([]) const [selectedAddress, setSelectedAddress] = useState(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 {

Scan this code with the Orca mobile app. Each code creates a unique device token.

- {pairingUrl && ( -
-
- Or paste this code in the mobile app: -
- -
- )} )} diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index cf469f6ed4b..de0738b5907 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -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() - }) -}) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 4e9b5beba80..5f8d4afc3b4 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -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#` 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))