Files
voicebox/mobile/app/pair.tsx
T
James Pine f4d21504e3 Mobile companion app + paired-device backend
New iOS-first companion (Expo SDK 54 + NativeWind v4) with three tabs:
Captures (the hero — floating gold mic, live mic-meter waveform,
expand-row playback), Generate (profile picker + speak + autoplay +
recent), and Voices (searchable profile list).

Pairing (V0): backend mints a one-time token, mobile scans/pastes the
voicebox:// URL, server returns a long-lived bearer it stores only as a
SHA-256 hash. Bearer-or-loopback auth applied to every user-data router
so binding 0.0.0.0 doesn't leak existing endpoints. Loopback callers
(the desktop app) keep their friction-free access.

Desktop Settings → Mobile pane: live host picker (LAN / Tailscale auto-
detected via the App-bundle binary path on macOS), QR rendering,
5-minute expiry countdown, copyable URL fallback, paired-device list
with revoke. Auto-closes when a new device pairs.

just dev now binds the backend to 0.0.0.0 so paired phones can reach
it — and just setup-python pins mlx-audio==0.4.1 + mlx-lm so fresh
Apple Silicon worktrees get a working STT path on first install.
2026-04-25 17:09:32 -07:00

165 lines
5.7 KiB
TypeScript

import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router';
import { useRef, useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui/Button';
import { ApiError, completePair } from '@/lib/api';
import { colors } from '@/lib/colors';
import { parsePairUrl } from '@/lib/pairing';
import { useSession } from '@/lib/session';
export default function PairScreen() {
const router = useRouter();
const setSession = useSession((s) => s.setSession);
const [permission, requestPermission] = useCameraPermissions();
const [deviceName, setDeviceName] = useState('iPhone');
const [manualUrl, setManualUrl] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Guard against the camera firing onBarcodeScanned multiple times for the
// same code while the request is in flight.
const scannedRef = useRef(false);
async function handlePair(host: string, token: string) {
if (busy) return;
setBusy(true);
setError(null);
try {
const res = await completePair(host, token, deviceName.trim() || 'Mobile');
await setSession({
host,
bearer: res.bearer,
deviceId: res.device_id,
deviceName: res.device_name,
});
router.replace('/');
} catch (e) {
const msg =
e instanceof ApiError
? e.message
: e instanceof Error
? `${e.message} — is the desktop reachable at this address?`
: 'Pair failed';
setError(msg);
scannedRef.current = false;
setBusy(false);
}
}
function handleBarcode({ data }: { data: string }) {
if (scannedRef.current) return;
const payload = parsePairUrl(data);
if (!payload) return;
scannedRef.current = true;
void handlePair(payload.host, payload.token);
}
function handleManualPair() {
const payload = parsePairUrl(manualUrl);
if (!payload) {
setError("That doesn't look like a Voicebox pairing URL.");
return;
}
void handlePair(payload.host, payload.token);
}
return (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
<View className="flex-row items-center justify-between px-6 pt-2 pb-4">
<Pressable
onPress={() => router.back()}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Cancel pairing"
>
<Text className="text-muted-foreground text-sm">Cancel</Text>
</Pressable>
<Text className="text-foreground text-base font-semibold">Pair device</Text>
<View style={{ width: 60 }} />
</View>
<View className="px-6 gap-4">
<Text className="text-muted-foreground text-sm leading-snug">
On your desktop, open Voicebox Settings Mobile Pair device, then point your camera at the QR code.
</Text>
<View className="gap-2">
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
Device name
</Text>
<TextInput
value={deviceName}
onChangeText={setDeviceName}
placeholder="iPhone"
placeholderTextColor={colors.mutedForeground}
className="bg-card border border-border rounded-lg px-4 py-3 text-foreground"
autoCapitalize="words"
/>
</View>
</View>
<View className="flex-1 mx-6 mt-4 rounded-2xl overflow-hidden bg-card border border-border items-center justify-center">
{permission?.granted ? (
<CameraView
style={{ width: '100%', height: '100%' }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={busy ? undefined : handleBarcode}
/>
) : (
<View className="items-center gap-4 px-8">
<Text className="text-foreground text-base text-center">
Camera access lets you scan the pairing QR.
</Text>
<Button
label={permission ? 'Allow camera' : 'Allow camera'}
onPress={() => {
void requestPermission();
}}
/>
</View>
)}
</View>
<View className="px-6 pt-4 pb-2 gap-3 border-t border-border mt-4">
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
Or paste pairing URL
</Text>
<TextInput
value={manualUrl}
onChangeText={setManualUrl}
placeholder="voicebox://pair?host=…&token=…"
placeholderTextColor={colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
multiline
className="bg-card border border-border rounded-lg px-4 py-3 text-foreground"
style={{ minHeight: 60, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' }}
/>
{error ? (
<Text className="text-destructive text-sm">{error}</Text>
) : null}
<Button
label={busy ? 'Pairing…' : 'Pair'}
onPress={handleManualPair}
loading={busy}
disabled={!manualUrl.trim() || busy}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}