mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
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.
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"react-sound-visualizer": "^1.4.0",
|
"react-sound-visualizer": "^1.4.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"wavesurfer.js": "^7.0.0",
|
"wavesurfer.js": "^7.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Lock, MoreHorizontal, Plus, Smartphone, WifiOff } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import {
|
||||||
|
usePairedDevices,
|
||||||
|
useRevokePairedDevice,
|
||||||
|
} from '@/lib/hooks/usePairedDevices';
|
||||||
|
import type { PairedDeviceResponse } from '@/lib/api/types';
|
||||||
|
import { PairDeviceDialog } from './PairDeviceDialog';
|
||||||
|
import { SettingRow, SettingSection } from './SettingRow';
|
||||||
|
|
||||||
|
function formatRelative(iso: string | null): string {
|
||||||
|
if (!iso) return 'never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const diffSec = Math.floor((Date.now() - then) / 1000);
|
||||||
|
if (diffSec < 60) return 'just now';
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||||
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||||
|
if (diffSec < 86400 * 30) return `${Math.floor(diffSec / 86400)}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePage() {
|
||||||
|
const [pairOpen, setPairOpen] = useState(false);
|
||||||
|
const devices = usePairedDevices();
|
||||||
|
const revoke = useRevokePairedDevice();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const active = (devices.data ?? []).filter((d) => !d.revoked);
|
||||||
|
const revoked = (devices.data ?? []).filter((d) => d.revoked);
|
||||||
|
|
||||||
|
function handleRevoke(d: PairedDeviceResponse) {
|
||||||
|
revoke.mutate(d.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Device revoked',
|
||||||
|
description: `${d.name} can no longer reach this Voicebox.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast({
|
||||||
|
title: 'Revoke failed',
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-8 items-start max-w-5xl">
|
||||||
|
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
|
||||||
|
<SettingSection
|
||||||
|
title="Mobile"
|
||||||
|
description="Pair your phone to dictate, browse captures, and generate from anywhere on your network."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Paired devices"
|
||||||
|
description={
|
||||||
|
active.length === 0
|
||||||
|
? 'No devices yet — pair your phone to get started.'
|
||||||
|
: `${active.length} active${revoked.length > 0 ? `, ${revoked.length} revoked` : ''}`
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button onClick={() => setPairOpen(true)} size="sm" className="gap-1.5">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Pair device
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{devices.data && devices.data.length > 0 ? (
|
||||||
|
<div className="pt-3 space-y-2">
|
||||||
|
{[...active, ...revoked].map((d) => (
|
||||||
|
<DeviceRow
|
||||||
|
key={d.id}
|
||||||
|
device={d}
|
||||||
|
onRevoke={() => handleRevoke(d)}
|
||||||
|
revoking={revoke.isPending && revoke.variables === d.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : devices.isLoading ? (
|
||||||
|
<div className="pt-3 text-sm text-muted-foreground">Loading devices…</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState onPair={() => setPairOpen(true)} />
|
||||||
|
)}
|
||||||
|
</SettingSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">About pairing</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Pairing creates a long-lived bearer that only your phone holds.
|
||||||
|
Voicebox stores just a hash — there's no path to recover the
|
||||||
|
bearer if the device loses it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold">How it works</h3>
|
||||||
|
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<li className="flex gap-2.5">
|
||||||
|
<Smartphone className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||||
|
<span className="leading-relaxed">
|
||||||
|
<span className="text-foreground font-medium">Local-first.</span>{' '}
|
||||||
|
Your phone talks to this Voicebox over LAN or Tailscale — no cloud,
|
||||||
|
no relay.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2.5">
|
||||||
|
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||||
|
<span className="leading-relaxed">
|
||||||
|
<span className="text-foreground font-medium">Bearer-only.</span>{' '}
|
||||||
|
The bearer is shown to the device once at pairing time and
|
||||||
|
never persisted server-side in plaintext.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2.5">
|
||||||
|
<WifiOff className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||||
|
<span className="leading-relaxed">
|
||||||
|
<span className="text-foreground font-medium">Revocable.</span>{' '}
|
||||||
|
Revoke any device here — its bearer stops working immediately.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<PairDeviceDialog open={pairOpen} onOpenChange={setPairOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({
|
||||||
|
device,
|
||||||
|
onRevoke,
|
||||||
|
revoking,
|
||||||
|
}: {
|
||||||
|
device: PairedDeviceResponse;
|
||||||
|
onRevoke: () => void;
|
||||||
|
revoking: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
|
||||||
|
device.revoked ? 'border-border/50 bg-muted/20 opacity-60' : 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
device.revoked ? 'bg-muted' : 'bg-accent/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Smartphone
|
||||||
|
className={`h-4 w-4 ${device.revoked ? 'text-muted-foreground' : 'text-accent'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{device.name}
|
||||||
|
{device.revoked ? (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
revoked
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Last seen {formatRelative(device.last_seen_at)} · paired{' '}
|
||||||
|
{formatRelative(device.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!device.revoked ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" disabled={revoking}>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={onRevoke} className="text-destructive">
|
||||||
|
Revoke
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ onPair }: { onPair: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="pt-6 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-accent/10">
|
||||||
|
<Smartphone className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">No paired devices</p>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-[280px]">
|
||||||
|
Pair your phone to dictate captures, queue generations, and play back voices on the go.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onPair} size="sm" className="mt-2 gap-1.5">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Pair device
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { Check, Copy, Loader2, RefreshCw, Smartphone } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import QRCode from 'react-qr-code';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import {
|
||||||
|
PAIRED_DEVICES_KEY,
|
||||||
|
useInitPairing,
|
||||||
|
usePairedDevices,
|
||||||
|
usePairHostCandidates,
|
||||||
|
} from '@/lib/hooks/usePairedDevices';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRemaining(ms: number): string {
|
||||||
|
if (ms <= 0) return 'expired';
|
||||||
|
const total = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PairDeviceDialog({ open, onOpenChange }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const candidates = usePairHostCandidates(open);
|
||||||
|
const initPairing = useInitPairing();
|
||||||
|
const devices = usePairedDevices({ polling: open });
|
||||||
|
|
||||||
|
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
// The IDs that existed when the dialog opened — anything new is a fresh
|
||||||
|
// pairing we should celebrate.
|
||||||
|
const [baselineDeviceIds, setBaselineDeviceIds] = useState<Set<string> | null>(null);
|
||||||
|
|
||||||
|
// On open: snapshot baseline devices, default-select the first non-loopback
|
||||||
|
// candidate, and mint the first token.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setBaselineDeviceIds(null);
|
||||||
|
setSelectedHost(null);
|
||||||
|
initPairing.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (devices.data && baselineDeviceIds === null) {
|
||||||
|
setBaselineDeviceIds(new Set(devices.data.map((d) => d.id)));
|
||||||
|
}
|
||||||
|
if (candidates.data && candidates.data.length > 0 && selectedHost === null) {
|
||||||
|
const preferred =
|
||||||
|
candidates.data.find((c) => c.kind !== 'loopback') ?? candidates.data[0];
|
||||||
|
setSelectedHost(preferred.address);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, candidates.data, devices.data]);
|
||||||
|
|
||||||
|
// Re-mint the token whenever the host selection changes (the URL embeds
|
||||||
|
// the host, so a new selection means a new QR).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedHost) return;
|
||||||
|
initPairing.mutate(selectedHost);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, selectedHost]);
|
||||||
|
|
||||||
|
// Wall-clock tick for the countdown.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Detect a freshly paired device and close + toast.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !devices.data || baselineDeviceIds === null) return;
|
||||||
|
const newDevice = devices.data.find(
|
||||||
|
(d) => !baselineDeviceIds.has(d.id) && !d.revoked,
|
||||||
|
);
|
||||||
|
if (newDevice) {
|
||||||
|
toast({
|
||||||
|
title: 'Device paired',
|
||||||
|
description: `${newDevice.name} is now connected.`,
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}, [open, devices.data, baselineDeviceIds, toast, qc, onOpenChange]);
|
||||||
|
|
||||||
|
const pairing = initPairing.data;
|
||||||
|
const expiresAtMs = pairing ? new Date(pairing.expires_at).getTime() : 0;
|
||||||
|
const remainingMs = expiresAtMs - now;
|
||||||
|
const expired = pairing != null && remainingMs <= 0;
|
||||||
|
|
||||||
|
const qrValue = useMemo(() => pairing?.pairing_url ?? '', [pairing]);
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
if (!pairing) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pairing.pairing_url);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Copy failed',
|
||||||
|
description: e instanceof Error ? e.message : 'Could not access clipboard',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegenerate() {
|
||||||
|
if (!selectedHost) return;
|
||||||
|
initPairing.mutate(selectedHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Smartphone className="h-4 w-4 text-accent" />
|
||||||
|
Pair a new device
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Open Voicebox on your phone, tap <span className="text-foreground">Get started → Pair</span>,
|
||||||
|
then point its camera at this QR.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Host picker */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Reachable at
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedHost ?? ''}
|
||||||
|
onValueChange={(v) => setSelectedHost(v)}
|
||||||
|
disabled={!candidates.data || candidates.data.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={candidates.isError ? 'Could not load' : 'Detecting addresses…'}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{candidates.data?.map((c) => (
|
||||||
|
<SelectItem key={c.address} value={c.address}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs">{c.address}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">— {c.label}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{candidates.isError ? (
|
||||||
|
<p className="text-xs text-destructive leading-snug">
|
||||||
|
{(candidates.error as Error)?.message ??
|
||||||
|
'Failed to fetch /pair/host-candidates — is the backend up to date?'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR */}
|
||||||
|
<div className="flex items-center justify-center rounded-xl border border-border bg-white p-6 min-h-[260px]">
|
||||||
|
{initPairing.isPending && !pairing ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
) : initPairing.isError ? (
|
||||||
|
<p className="text-sm text-destructive text-center">
|
||||||
|
{(initPairing.error as Error)?.message ?? 'Failed to mint token'}
|
||||||
|
</p>
|
||||||
|
) : qrValue ? (
|
||||||
|
<QRCode value={qrValue} size={220} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No host selected</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown + regenerate */}
|
||||||
|
{pairing ? (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className={expired ? 'text-destructive' : 'text-muted-foreground'}>
|
||||||
|
{expired
|
||||||
|
? 'QR expired — regenerate to continue'
|
||||||
|
: `Expires in ${formatRemaining(remainingMs)}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
{expired ? 'Regenerate' : 'New QR'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Copyable URL */}
|
||||||
|
{pairing ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Or paste this URL on the phone
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||||
|
<code className="flex-1 truncate text-xs font-mono">{pairing.pairing_url}</code>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleCopy} className="h-7 px-2">
|
||||||
|
{copied ? <Check className="h-3.5 w-3.5 text-accent" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
The token is single-use and expires in 5 minutes. Once paired, your phone holds a
|
||||||
|
long-lived bearer that only it knows — Voicebox stores just a hash. Revoke any time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface SettingsTab {
|
|||||||
| '/settings/generation'
|
| '/settings/generation'
|
||||||
| '/settings/captures'
|
| '/settings/captures'
|
||||||
| '/settings/mcp'
|
| '/settings/mcp'
|
||||||
|
| '/settings/mobile'
|
||||||
| '/settings/gpu'
|
| '/settings/gpu'
|
||||||
| '/settings/logs'
|
| '/settings/logs'
|
||||||
| '/settings/changelog'
|
| '/settings/changelog'
|
||||||
@@ -25,6 +26,9 @@ const tabs: SettingsTab[] = [
|
|||||||
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
||||||
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
|
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
|
||||||
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
|
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
|
||||||
|
// Plain-string label for V0 — translation keys come when mobile graduates
|
||||||
|
// out of "experimental" status.
|
||||||
|
{ label: 'Mobile', path: '/settings/mobile' },
|
||||||
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
|
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
|
||||||
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
|
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
|
||||||
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ import type {
|
|||||||
MCPClientBinding,
|
MCPClientBinding,
|
||||||
MCPClientBindingListResponse,
|
MCPClientBindingListResponse,
|
||||||
MCPClientBindingUpsert,
|
MCPClientBindingUpsert,
|
||||||
|
HostCandidate,
|
||||||
|
PairInitResponse,
|
||||||
|
PairedDeviceResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||||
@@ -920,6 +923,26 @@ class ApiClient {
|
|||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile pairing
|
||||||
|
async getPairHostCandidates(): Promise<HostCandidate[]> {
|
||||||
|
return this.request<HostCandidate[]>('/pair/host-candidates');
|
||||||
|
}
|
||||||
|
|
||||||
|
async initPairing(host: string): Promise<PairInitResponse> {
|
||||||
|
return this.request<PairInitResponse>(
|
||||||
|
`/pair/init?host=${encodeURIComponent(host)}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPairedDevices(): Promise<PairedDeviceResponse[]> {
|
||||||
|
return this.request<PairedDeviceResponse[]>('/devices');
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokePairedDevice(deviceId: string): Promise<void> {
|
||||||
|
await this.request<void>(`/devices/${deviceId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -521,3 +521,27 @@ export interface MCPClientBindingUpsert {
|
|||||||
export interface MCPClientBindingListResponse {
|
export interface MCPClientBindingListResponse {
|
||||||
items: MCPClientBinding[];
|
items: MCPClientBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile pairing (V0) ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type HostCandidateKind = 'lan' | 'tailscale' | 'loopback';
|
||||||
|
|
||||||
|
export interface HostCandidate {
|
||||||
|
address: string; // host:port
|
||||||
|
label: string; // human-friendly name
|
||||||
|
kind: HostCandidateKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairInitResponse {
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
pairing_url: string; // voicebox://pair?host=…&token=…
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairedDeviceResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
revoked: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export const PAIRED_DEVICES_KEY = ['paired-devices'] as const;
|
||||||
|
export const PAIR_HOST_CANDIDATES_KEY = ['pair-host-candidates'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all paired (and revoked) devices. Pass ``polling: true`` while the
|
||||||
|
* pair dialog is open so the device list refreshes when the user finishes
|
||||||
|
* scanning on their phone — this is how the desktop UI detects success
|
||||||
|
* without needing an SSE stream.
|
||||||
|
*/
|
||||||
|
export function usePairedDevices({ polling = false }: { polling?: boolean } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PAIRED_DEVICES_KEY,
|
||||||
|
queryFn: () => apiClient.listPairedDevices(),
|
||||||
|
refetchInterval: polling ? 2000 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate addresses the desktop can embed in the QR (LAN, Tailscale,
|
||||||
|
* loopback). Cached for the lifetime of the dialog — interfaces don't
|
||||||
|
* change often enough to be worth re-polling.
|
||||||
|
*/
|
||||||
|
export function usePairHostCandidates(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PAIR_HOST_CANDIDATES_KEY,
|
||||||
|
queryFn: () => apiClient.getPairHostCandidates(),
|
||||||
|
enabled,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a fresh pairing token for a chosen host. The result is short-lived
|
||||||
|
* (5 min); the dialog should re-mint when expiry is hit.
|
||||||
|
*/
|
||||||
|
export function useInitPairing() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (host: string) => apiClient.initPairing(host),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a paired device. Invalidates the device list so the row disappears
|
||||||
|
* on success.
|
||||||
|
*/
|
||||||
|
export function useRevokePairedDevice() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (deviceId: string) => apiClient.revokePairedDevice(deviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: PAIRED_DEVICES_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { GenerationPage } from '@/components/ServerTab/GenerationPage';
|
|||||||
import { GpuPage } from '@/components/ServerTab/GpuPage';
|
import { GpuPage } from '@/components/ServerTab/GpuPage';
|
||||||
import { LogsPage } from '@/components/ServerTab/LogsPage';
|
import { LogsPage } from '@/components/ServerTab/LogsPage';
|
||||||
import { MCPPage } from '@/components/ServerTab/MCPPage';
|
import { MCPPage } from '@/components/ServerTab/MCPPage';
|
||||||
|
import { MobilePage } from '@/components/ServerTab/MobilePage';
|
||||||
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
|
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
|
||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||||
@@ -166,6 +167,12 @@ const settingsMCPRoute = createRoute({
|
|||||||
component: MCPPage,
|
component: MCPPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const settingsMobileRoute = createRoute({
|
||||||
|
getParentRoute: () => settingsRoute,
|
||||||
|
path: '/mobile',
|
||||||
|
component: MobilePage,
|
||||||
|
});
|
||||||
|
|
||||||
const settingsGpuRoute = createRoute({
|
const settingsGpuRoute = createRoute({
|
||||||
getParentRoute: () => settingsRoute,
|
getParentRoute: () => settingsRoute,
|
||||||
path: '/gpu',
|
path: '/gpu',
|
||||||
@@ -212,6 +219,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
settingsGenerationRoute,
|
settingsGenerationRoute,
|
||||||
settingsCapturesRoute,
|
settingsCapturesRoute,
|
||||||
settingsMCPRoute,
|
settingsMCPRoute,
|
||||||
|
settingsMobileRoute,
|
||||||
settingsGpuRoute,
|
settingsGpuRoute,
|
||||||
settingsLogsRoute,
|
settingsLogsRoute,
|
||||||
settingsChangelogRoute,
|
settingsChangelogRoute,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from .models import (
|
|||||||
GenerationSettings,
|
GenerationSettings,
|
||||||
GenerationVersion,
|
GenerationVersion,
|
||||||
MCPClientBinding,
|
MCPClientBinding,
|
||||||
|
PairedDevice,
|
||||||
|
PairingToken,
|
||||||
ProfileChannelMapping,
|
ProfileChannelMapping,
|
||||||
ProfileSample,
|
ProfileSample,
|
||||||
Project,
|
Project,
|
||||||
@@ -37,6 +39,8 @@ __all__ = [
|
|||||||
"GenerationSettings",
|
"GenerationSettings",
|
||||||
"GenerationVersion",
|
"GenerationVersion",
|
||||||
"MCPClientBinding",
|
"MCPClientBinding",
|
||||||
|
"PairedDevice",
|
||||||
|
"PairingToken",
|
||||||
"ProfileChannelMapping",
|
"ProfileChannelMapping",
|
||||||
"ProfileSample",
|
"ProfileSample",
|
||||||
"Project",
|
"Project",
|
||||||
|
|||||||
@@ -279,3 +279,37 @@ class Capture(Base):
|
|||||||
llm_model = Column(String, nullable=True)
|
llm_model = Column(String, nullable=True)
|
||||||
refinement_flags = Column(Text, nullable=True) # JSON blob
|
refinement_flags = Column(Text, nullable=True) # JSON blob
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PairedDevice(Base):
|
||||||
|
"""A mobile device paired with this Voicebox install (V0 pair flow).
|
||||||
|
|
||||||
|
Stores only the SHA-256 of the bearer token; the bearer plaintext is
|
||||||
|
returned to the device once at pairing time and never persisted
|
||||||
|
server-side. If the device loses its bearer the user must re-pair.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "paired_devices"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
bearer_hash = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
revoked = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_seen_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PairingToken(Base):
|
||||||
|
"""One-time token used to complete a device pairing.
|
||||||
|
|
||||||
|
Minted by the desktop UI via /pair/init, redeemed by the mobile
|
||||||
|
device via /pair/complete in exchange for a long-lived bearer.
|
||||||
|
Single-use; expires after ~5 minutes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "pairing_tokens"
|
||||||
|
|
||||||
|
token = Column(String, primary_key=True)
|
||||||
|
expires_at = Column(DateTime, nullable=False)
|
||||||
|
used_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|||||||
@@ -793,3 +793,61 @@ class AvailableEffectsResponse(BaseModel):
|
|||||||
"""Response listing all available effect types."""
|
"""Response listing all available effect types."""
|
||||||
|
|
||||||
effects: List[AvailableEffect]
|
effects: List[AvailableEffect]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mobile pairing (V0) -----------------------------------------------------
|
||||||
|
|
||||||
|
class HostCandidate(BaseModel):
|
||||||
|
"""A reachable address the desktop can embed in the pair QR."""
|
||||||
|
|
||||||
|
address: str # ``host:port``, e.g. "192.168.1.5:17493"
|
||||||
|
label: str # human-friendly name shown in the desktop host picker
|
||||||
|
kind: str # "lan" | "tailscale" | "loopback"
|
||||||
|
|
||||||
|
|
||||||
|
class PairInitResponse(BaseModel):
|
||||||
|
"""Response for POST /pair/init — desktop renders ``pairing_url`` as a QR."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
|
pairing_url: str # full voicebox://pair?host=…&token=… URL
|
||||||
|
|
||||||
|
|
||||||
|
class PairCompleteRequest(BaseModel):
|
||||||
|
"""Mobile-side request body for POST /pair/complete."""
|
||||||
|
|
||||||
|
token: str = Field(..., min_length=1, max_length=128)
|
||||||
|
device_name: str = Field(..., min_length=1, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class PairCompleteResponse(BaseModel):
|
||||||
|
"""One-time response after successful pairing.
|
||||||
|
|
||||||
|
``bearer`` is returned in plaintext exactly once and is never persisted
|
||||||
|
server-side; the device must save it (e.g. iOS SecureStore) immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
device_id: str
|
||||||
|
bearer: str
|
||||||
|
device_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PairedDeviceResponse(BaseModel):
|
||||||
|
"""A row in the desktop's Settings → Mobile device list."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
revoked: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
"""Identity of the bearer-authenticated caller."""
|
||||||
|
|
||||||
|
device_id: str
|
||||||
|
device_name: str
|
||||||
|
last_seen_at: Optional[datetime] = None
|
||||||
|
|||||||
+42
-19
@@ -1,6 +1,23 @@
|
|||||||
"""Route registration for the voicebox API."""
|
"""Route registration for the voicebox API.
|
||||||
|
|
||||||
from fastapi import FastAPI
|
Authentication model
|
||||||
|
--------------------
|
||||||
|
Two router groups:
|
||||||
|
|
||||||
|
* **Open** — ``health`` (status checks anyone on the LAN may probe) and
|
||||||
|
``pairing`` (pre-pair endpoints + admin endpoints with their own
|
||||||
|
loopback-only or token-only gates).
|
||||||
|
|
||||||
|
* **Protected** — everything else, gated by ``require_bearer_or_loopback``:
|
||||||
|
loopback callers (the desktop app over 127.0.0.1) pass without auth as
|
||||||
|
before; LAN/Tailscale callers must present a valid paired-device bearer.
|
||||||
|
This is what lets ``just dev`` bind to 0.0.0.0 without exposing user
|
||||||
|
data to anyone on the same network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
|
||||||
|
from ..utils.auth import require_bearer_or_loopback
|
||||||
|
|
||||||
|
|
||||||
def register_routers(app: FastAPI) -> None:
|
def register_routers(app: FastAPI) -> None:
|
||||||
@@ -23,22 +40,28 @@ def register_routers(app: FastAPI) -> None:
|
|||||||
from .speak import router as speak_router
|
from .speak import router as speak_router
|
||||||
from .mcp_bindings import router as mcp_bindings_router
|
from .mcp_bindings import router as mcp_bindings_router
|
||||||
from .events import router as events_router
|
from .events import router as events_router
|
||||||
|
from .pairing import router as pairing_router
|
||||||
|
|
||||||
|
# Open — health probes and the pre-pair / admin pairing endpoints.
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(profiles_router)
|
app.include_router(pairing_router)
|
||||||
app.include_router(channels_router)
|
|
||||||
app.include_router(generations_router)
|
# Protected — loopback callers pass through; LAN callers need a paired bearer.
|
||||||
app.include_router(history_router)
|
protected = [Depends(require_bearer_or_loopback)]
|
||||||
app.include_router(transcription_router)
|
app.include_router(profiles_router, dependencies=protected)
|
||||||
app.include_router(llm_router)
|
app.include_router(channels_router, dependencies=protected)
|
||||||
app.include_router(captures_router)
|
app.include_router(generations_router, dependencies=protected)
|
||||||
app.include_router(stories_router)
|
app.include_router(history_router, dependencies=protected)
|
||||||
app.include_router(effects_router)
|
app.include_router(transcription_router, dependencies=protected)
|
||||||
app.include_router(audio_router)
|
app.include_router(llm_router, dependencies=protected)
|
||||||
app.include_router(models_router)
|
app.include_router(captures_router, dependencies=protected)
|
||||||
app.include_router(settings_router)
|
app.include_router(stories_router, dependencies=protected)
|
||||||
app.include_router(tasks_router)
|
app.include_router(effects_router, dependencies=protected)
|
||||||
app.include_router(cuda_router)
|
app.include_router(audio_router, dependencies=protected)
|
||||||
app.include_router(speak_router)
|
app.include_router(models_router, dependencies=protected)
|
||||||
app.include_router(mcp_bindings_router)
|
app.include_router(settings_router, dependencies=protected)
|
||||||
app.include_router(events_router)
|
app.include_router(tasks_router, dependencies=protected)
|
||||||
|
app.include_router(cuda_router, dependencies=protected)
|
||||||
|
app.include_router(speak_router, dependencies=protected)
|
||||||
|
app.include_router(mcp_bindings_router, dependencies=protected)
|
||||||
|
app.include_router(events_router, dependencies=protected)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Mobile device pairing endpoints (V0 — bearer auth)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import models
|
||||||
|
from ..database import PairedDevice, get_db
|
||||||
|
from ..services import pairing
|
||||||
|
from ..utils.auth import require_loopback, require_paired_device
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/pair/init",
|
||||||
|
response_model=models.PairInitResponse,
|
||||||
|
dependencies=[Depends(require_loopback)],
|
||||||
|
)
|
||||||
|
async def pair_init(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Mint a one-time pairing token (loopback callers only).
|
||||||
|
|
||||||
|
Optional ``?host=`` query param overrides what's embedded in the QR's
|
||||||
|
pairing URL. The desktop UI should pass whatever address is reachable
|
||||||
|
from the mobile device — LAN IP, Tailscale 100.x address, or MagicDNS
|
||||||
|
name. Defaults to the request Host header for curl-driven local testing.
|
||||||
|
"""
|
||||||
|
host = request.query_params.get("host") or (
|
||||||
|
request.headers.get("host") or "127.0.0.1:17493"
|
||||||
|
)
|
||||||
|
return pairing.init_pairing_token(db, host=host)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pair/complete", response_model=models.PairCompleteResponse)
|
||||||
|
async def pair_complete(
|
||||||
|
body: models.PairCompleteRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Exchange a pairing token for a long-lived bearer.
|
||||||
|
|
||||||
|
Open endpoint — possession of the (one-time, short-TTL) token is
|
||||||
|
itself the proof of authorization.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return pairing.complete_pairing(db, body.token, body.device_name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/devices",
|
||||||
|
response_model=list[models.PairedDeviceResponse],
|
||||||
|
dependencies=[Depends(require_loopback)],
|
||||||
|
)
|
||||||
|
async def list_paired_devices(db: Session = Depends(get_db)):
|
||||||
|
"""List paired devices for the desktop Settings → Mobile pane."""
|
||||||
|
return pairing.list_devices(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/devices/{device_id}",
|
||||||
|
status_code=204,
|
||||||
|
dependencies=[Depends(require_loopback)],
|
||||||
|
)
|
||||||
|
async def revoke_paired_device(device_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Revoke a paired device's bearer."""
|
||||||
|
try:
|
||||||
|
pairing.revoke_device(db, device_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=models.MeResponse)
|
||||||
|
async def me(device: PairedDevice = Depends(require_paired_device)):
|
||||||
|
"""Identity of the bearer-authenticated caller — used by mobile to
|
||||||
|
confirm pairing succeeded and the bearer round-trips.
|
||||||
|
"""
|
||||||
|
return models.MeResponse(
|
||||||
|
device_id=device.id,
|
||||||
|
device_name=device.name,
|
||||||
|
last_seen_at=device.last_seen_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/pair/host-candidates",
|
||||||
|
response_model=list[models.HostCandidate],
|
||||||
|
dependencies=[Depends(require_loopback)],
|
||||||
|
)
|
||||||
|
async def host_candidates(request: Request):
|
||||||
|
"""Suggested host strings (LAN IP, Tailscale, loopback) for the QR."""
|
||||||
|
port = request.url.port or 17493
|
||||||
|
return pairing.list_host_candidates(port=port)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Mobile device pairing service (V0 — bearer auth, no E2E payload encryption yet).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Desktop UI (loopback) calls POST /pair/init → mints a single-use token
|
||||||
|
with a 5-minute TTL and returns a ``voicebox://pair?...`` URL.
|
||||||
|
2. Mobile scans the QR (or pastes the URL) and POSTs /pair/complete with
|
||||||
|
the token + a human-readable device name.
|
||||||
|
3. Server validates the token, mints a long-lived bearer, and stores
|
||||||
|
only SHA-256(bearer). The bearer plaintext is returned exactly once.
|
||||||
|
4. Mobile saves the bearer in SecureStore. Subsequent calls carry
|
||||||
|
``Authorization: Bearer <bearer>``.
|
||||||
|
|
||||||
|
The bearer is returned exactly once. Server has no path to recover it; if
|
||||||
|
the device loses its key the user must re-pair (and revoke the old device
|
||||||
|
from Settings → Mobile if they want to be tidy).
|
||||||
|
|
||||||
|
Phase 2 will layer XChaCha20-Poly1305 payload encryption + HKDF session
|
||||||
|
keys on top — see ``mobile/PLAN.md`` § Pairing & transport. The bearer
|
||||||
|
established here is the foundation either way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import PairedDevice, PairingToken
|
||||||
|
from ..models import (
|
||||||
|
HostCandidate,
|
||||||
|
PairCompleteResponse,
|
||||||
|
PairInitResponse,
|
||||||
|
PairedDeviceResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PAIRING_TOKEN_TTL = timedelta(minutes=5)
|
||||||
|
TOKEN_BYTES = 32 # urlsafe-b64 encoded → ~44 chars
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_bearer(bearer: str) -> str:
|
||||||
|
return hashlib.sha256(bearer.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pairing_url(token: str, host: str) -> str:
|
||||||
|
# ``host`` should be reachable from the mobile device — LAN IP, Tailscale
|
||||||
|
# 100.x address, MagicDNS hostname (e.g. ``mac.tail-xxxx.ts.net:17493``).
|
||||||
|
# The desktop UI is responsible for picking the right host; loopback is
|
||||||
|
# only useful for curl-driven local testing.
|
||||||
|
return f"voicebox://pair?host={host}&token={token}"
|
||||||
|
|
||||||
|
|
||||||
|
def init_pairing_token(db: Session, host: str) -> PairInitResponse:
|
||||||
|
"""Mint a one-time pairing token. Caller must already be authorized as loopback."""
|
||||||
|
token = secrets.token_urlsafe(TOKEN_BYTES)
|
||||||
|
expires_at = datetime.utcnow() + PAIRING_TOKEN_TTL
|
||||||
|
db.add(PairingToken(token=token, expires_at=expires_at))
|
||||||
|
db.commit()
|
||||||
|
return PairInitResponse(
|
||||||
|
token=token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
pairing_url=_build_pairing_url(token, host),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_pairing(db: Session, token: str, device_name: str) -> PairCompleteResponse:
|
||||||
|
"""Exchange a pairing token for a long-lived device bearer.
|
||||||
|
|
||||||
|
Raises ValueError on invalid / expired / already-used token.
|
||||||
|
"""
|
||||||
|
row = db.query(PairingToken).filter(PairingToken.token == token).first()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Invalid pairing token")
|
||||||
|
if row.used_at is not None:
|
||||||
|
raise ValueError("Pairing token already used")
|
||||||
|
if row.expires_at < datetime.utcnow():
|
||||||
|
raise ValueError("Pairing token expired")
|
||||||
|
|
||||||
|
row.used_at = datetime.utcnow()
|
||||||
|
|
||||||
|
bearer = secrets.token_urlsafe(TOKEN_BYTES)
|
||||||
|
device = PairedDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=device_name.strip(),
|
||||||
|
bearer_hash=_hash_bearer(bearer),
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Paired new device id=%s name=%s", device.id, device.name)
|
||||||
|
|
||||||
|
return PairCompleteResponse(
|
||||||
|
device_id=device.id,
|
||||||
|
bearer=bearer,
|
||||||
|
device_name=device.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_bearer(db: Session, bearer: str) -> Optional[PairedDevice]:
|
||||||
|
"""Look up a paired device by bearer. Bumps last_seen_at on success."""
|
||||||
|
if not bearer:
|
||||||
|
return None
|
||||||
|
bearer_hash = _hash_bearer(bearer)
|
||||||
|
device = (
|
||||||
|
db.query(PairedDevice)
|
||||||
|
.filter(PairedDevice.bearer_hash == bearer_hash, PairedDevice.revoked.is_(False))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if device is not None:
|
||||||
|
device.last_seen_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices(db: Session) -> list[PairedDeviceResponse]:
|
||||||
|
"""Return all paired devices (revoked included) for the desktop UI."""
|
||||||
|
rows = db.query(PairedDevice).order_by(PairedDevice.created_at.desc()).all()
|
||||||
|
return [PairedDeviceResponse.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_device(db: Session, device_id: str) -> None:
|
||||||
|
"""Mark a device as revoked. Idempotent on repeat calls."""
|
||||||
|
device = db.query(PairedDevice).filter(PairedDevice.id == device_id).first()
|
||||||
|
if device is None:
|
||||||
|
raise ValueError("Device not found")
|
||||||
|
device.revoked = True
|
||||||
|
db.commit()
|
||||||
|
logger.info("Revoked device id=%s name=%s", device.id, device.name)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Host discovery ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_lan_ip() -> Optional[str]:
|
||||||
|
"""Best-effort outbound IPv4 of the host. Uses the UDP-connect trick —
|
||||||
|
no packets are actually sent; the kernel just picks the source IP it
|
||||||
|
would use to reach 8.8.8.8.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tailscale_ip() -> Optional[str]:
|
||||||
|
"""Return the host's Tailscale 100.x address if Tailscale is installed
|
||||||
|
and reports one. Returns ``None`` on any failure — Tailscale is optional.
|
||||||
|
|
||||||
|
Tries ``tailscale`` on PATH first, then falls back to the Mac App Store
|
||||||
|
install location (the macOS App bundle doesn't symlink onto PATH by
|
||||||
|
default, only into the user's shell init via an alias subprocess can't see).
|
||||||
|
"""
|
||||||
|
candidate_binaries = [
|
||||||
|
"tailscale",
|
||||||
|
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||||
|
]
|
||||||
|
for binary in candidate_binaries:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[binary, "ip", "--4"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
if result.returncode != 0:
|
||||||
|
continue
|
||||||
|
ip = result.stdout.strip().splitlines()[0].strip() if result.stdout else ""
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_host_candidates(port: int) -> list[HostCandidate]:
|
||||||
|
"""Return suggested host strings the desktop can embed in the QR.
|
||||||
|
|
||||||
|
Order matters — the UI defaults to the first non-loopback entry.
|
||||||
|
"""
|
||||||
|
candidates: list[HostCandidate] = []
|
||||||
|
lan_ip = _get_lan_ip()
|
||||||
|
if lan_ip and not lan_ip.startswith("127."):
|
||||||
|
candidates.append(
|
||||||
|
HostCandidate(address=f"{lan_ip}:{port}", label="Local network", kind="lan")
|
||||||
|
)
|
||||||
|
tailscale_ip = _get_tailscale_ip()
|
||||||
|
if tailscale_ip and tailscale_ip != lan_ip:
|
||||||
|
candidates.append(
|
||||||
|
HostCandidate(address=f"{tailscale_ip}:{port}", label="Tailscale", kind="tailscale")
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
HostCandidate(address=f"127.0.0.1:{port}", label="Loopback (testing only)", kind="loopback")
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""FastAPI dependencies for the V0 mobile-pair auth model.
|
||||||
|
|
||||||
|
Two dependencies are exposed:
|
||||||
|
|
||||||
|
* ``require_loopback`` — reject calls that don't originate from a loopback
|
||||||
|
address. Used to gate desktop-only admin endpoints (pair init, devices
|
||||||
|
list, revoke). Loopback callers stay unauthenticated everywhere else
|
||||||
|
too — the desktop app talks to its own backend over 127.0.0.1.
|
||||||
|
|
||||||
|
* ``require_paired_device`` — validate ``Authorization: Bearer <token>``
|
||||||
|
against the ``paired_devices`` table. Used to identify paired mobile
|
||||||
|
callers and bumps ``last_seen_at`` on success.
|
||||||
|
|
||||||
|
Phase 2 will layer XChaCha20-Poly1305 payload encryption on top of the
|
||||||
|
bearer (see ``mobile/PLAN.md``); the bearer stays the identity primitive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import PairedDevice, get_db
|
||||||
|
from ..services import pairing as pairing_service
|
||||||
|
|
||||||
|
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||||
|
|
||||||
|
|
||||||
|
def require_loopback(request: Request) -> None:
|
||||||
|
"""Reject calls from non-loopback addresses."""
|
||||||
|
client = request.client
|
||||||
|
host = client.host if client else None
|
||||||
|
if host not in LOOPBACK_HOSTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Loopback only",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer(request: Request) -> Optional[str]:
|
||||||
|
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||||
|
if not auth:
|
||||||
|
return None
|
||||||
|
parts = auth.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
return None
|
||||||
|
return parts[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def require_paired_device(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> PairedDevice:
|
||||||
|
"""Resolve the PairedDevice authenticated by the request bearer."""
|
||||||
|
bearer = _extract_bearer(request)
|
||||||
|
if not bearer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing bearer token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
device = pairing_service.authenticate_bearer(db, bearer)
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or revoked bearer token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
def require_bearer_or_loopback(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""Loopback callers pass without auth; everyone else needs a paired bearer.
|
||||||
|
|
||||||
|
Applied as a router-level dependency on user-data endpoints so the
|
||||||
|
desktop UI (which talks over 127.0.0.1) keeps its current friction-free
|
||||||
|
access while LAN-reachable callers must be a paired mobile device.
|
||||||
|
|
||||||
|
The pre-pair endpoints (``POST /pair/complete``) and the desktop-only
|
||||||
|
admin endpoints (``POST /pair/init``, ``GET /devices``) intentionally
|
||||||
|
stay outside this gate — they have their own dependencies.
|
||||||
|
"""
|
||||||
|
client = request.client
|
||||||
|
host = client.host if client else None
|
||||||
|
if host in LOOPBACK_HOSTS:
|
||||||
|
return
|
||||||
|
bearer = _extract_bearer(request)
|
||||||
|
if not bearer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing bearer token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
device = pairing_service.authenticate_bearer(db, bearer)
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or revoked bearer token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.4.2",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
"react-sound-visualizer": "^1.4.0",
|
"react-sound-visualizer": "^1.4.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"wavesurfer.js": "^7.0.0",
|
"wavesurfer.js": "^7.0.0",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.4.2",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/space-grotesk": "^5.2.10",
|
"@fontsource/space-grotesk": "^5.2.10",
|
||||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.4.2",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
@@ -127,7 +128,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.4.2",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
@@ -991,6 +992,8 @@
|
|||||||
|
|
||||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"qr.js": ["[email protected]", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="],
|
||||||
|
|
||||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||||
@@ -1005,6 +1008,8 @@
|
|||||||
|
|
||||||
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
|
"react-loaders": ["[email protected]", "", { "dependencies": { "classnames": "^2.2.3" }, "peerDependencies": { "prop-types": ">=15.6.0", "react": ">=15" } }, "sha512-4igMNqs9Fb3d4Z+0UHIGQNJsw/37gX0nUO8QxupnEKRn1dtyYC1LGwk5GuaoDciMQCQc/MmPwb4Fn6ZfdoX1FQ=="],
|
||||||
|
|
||||||
|
"react-qr-code": ["[email protected]", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="],
|
||||||
|
|
||||||
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
"react-remove-scroll": ["[email protected]", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ setup-python:
|
|||||||
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
|
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
|
||||||
echo "Detected Apple Silicon — installing MLX dependencies..."
|
echo "Detected Apple Silicon — installing MLX dependencies..."
|
||||||
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||||
|
# mlx-audio + mlx-lm are intentionally --no-deps (their transformers>=5
|
||||||
|
# pin conflicts with our pinned 4.x). The runtime API surface we use
|
||||||
|
# works fine on transformers 4.57.x. See requirements-mlx.txt notes.
|
||||||
|
{{ pip }} install --no-deps mlx-audio==0.4.1 mlx-lm
|
||||||
fi
|
fi
|
||||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
|
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
|
||||||
@@ -100,6 +104,11 @@ setup-js:
|
|||||||
# ─── Development ──────────────────────────────────────────────────────
|
# ─── Development ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Start backend (if not already running) + frontend for development
|
# Start backend (if not already running) + frontend for development
|
||||||
|
# Binds the backend to 0.0.0.0 so paired mobile devices can reach it over the
|
||||||
|
# LAN/Tailscale. This exposes existing unauth routes (/generate, /transcribe,
|
||||||
|
# /captures, /profiles) to anyone on the same network — fine for a trusted
|
||||||
|
# home network, NOT fine on public Wi-Fi. Bearer-auth middleware on those
|
||||||
|
# routes is on the roadmap; until then, treat dev as LAN-trusted.
|
||||||
[unix]
|
[unix]
|
||||||
dev: _ensure-venv _ensure-sidecar
|
dev: _ensure-venv _ensure-sidecar
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
@@ -109,8 +118,8 @@ dev: _ensure-venv _ensure-sidecar
|
|||||||
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then
|
||||||
echo "Backend already running on http://localhost:17493"
|
echo "Backend already running on http://localhost:17493"
|
||||||
else
|
else
|
||||||
echo "Starting backend on http://localhost:17493 ..."
|
echo "Starting backend on http://0.0.0.0:17493 (LAN-reachable) ..."
|
||||||
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 &
|
{{ venv_bin }}/uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493 &
|
||||||
backend_pid=$!
|
backend_pid=$!
|
||||||
sleep 2
|
sleep 2
|
||||||
fi
|
fi
|
||||||
@@ -124,21 +133,21 @@ dev: _ensure-venv _ensure-sidecar
|
|||||||
dev: _ensure-venv _ensure-sidecar
|
dev: _ensure-venv _ensure-sidecar
|
||||||
$backendJob = $null; \
|
$backendJob = $null; \
|
||||||
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
|
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
|
||||||
Write-Host "Starting backend on http://localhost:17493 ..."; \
|
Write-Host "Starting backend on http://0.0.0.0:17493 (LAN-reachable) ..."; \
|
||||||
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
|
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--host","0.0.0.0","--port","17493"; \
|
||||||
Start-Sleep -Seconds 2; \
|
Start-Sleep -Seconds 2; \
|
||||||
}; \
|
}; \
|
||||||
Write-Host "Starting Tauri desktop app..."; \
|
Write-Host "Starting Tauri desktop app..."; \
|
||||||
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
||||||
|
|
||||||
# Start backend only
|
# Start backend only — bound to 0.0.0.0 for mobile pairing (see `dev` above)
|
||||||
[unix]
|
[unix]
|
||||||
dev-backend: _ensure-venv
|
dev-backend: _ensure-venv
|
||||||
{{ venv_bin }}/uvicorn backend.main:app --reload --port 17493
|
{{ venv_bin }}/uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493
|
||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
dev-backend: _ensure-venv
|
dev-backend: _ensure-venv
|
||||||
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
|
& "{{ python }}" -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 17493
|
||||||
|
|
||||||
# Start Tauri desktop app only (backend must be running separately)
|
# Start Tauri desktop app only (backend must be running separately)
|
||||||
[unix]
|
[unix]
|
||||||
@@ -180,6 +189,15 @@ dev-web: _ensure-venv
|
|||||||
Write-Host "Starting web app..."; \
|
Write-Host "Starting web app..."; \
|
||||||
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
||||||
|
|
||||||
|
# Start Expo dev server for the mobile companion app (run `cd mobile && bun install` once first)
|
||||||
|
[unix]
|
||||||
|
dev-mobile:
|
||||||
|
cd mobile && bunx expo start
|
||||||
|
|
||||||
|
[windows]
|
||||||
|
dev-mobile:
|
||||||
|
Set-Location "mobile"; bunx expo start
|
||||||
|
|
||||||
# Kill all dev processes
|
# Kill all dev processes
|
||||||
[unix]
|
[unix]
|
||||||
kill:
|
kill:
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
# Voicebox Mobile — V1 plan
|
||||||
|
|
||||||
|
A companion app for the Voicebox desktop. iPhone-first, dictate-anywhere, Captures as the hero. Talks to a paired desktop over Tailscale or LAN with end-to-end encryption.
|
||||||
|
|
||||||
|
V1 is entirely local — no cloud, no account. The device key minted during pairing is the root of the user's lifetime encryption identity and gets reused by the cloud phases that follow; see [`docs/plans/CLOUD_ROADMAP.md`](../docs/plans/CLOUD_ROADMAP.md) for the post-mobile arc (backup & sync → private inference → marketplace).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
- New `mobile/` at repo root, sibling to `app/`, `backend/`, `tauri/`, `landing/`, `web/`
|
||||||
|
- Standalone Expo project — not a Bun workspace member (avoids React/Tauri/Bun version drag)
|
||||||
|
- Type-sharing via OpenAPI: generate `mobile/src/api/types.ts` from backend's `/openapi.json`, commit it, regenerate on demand
|
||||||
|
- Branch off `main` once 0.5.0 ships → `feat/mobile-app`
|
||||||
|
|
||||||
|
## Stack (verified 2026-04-25)
|
||||||
|
|
||||||
|
### The SDK 54 vs SDK 55 fork
|
||||||
|
|
||||||
|
Expo SDK 55 is the current `latest` (`[email protected]`, React Native 0.83, React 19.2, New Architecture only — Legacy Architecture was dropped in 55). It also ships Expo Router v7, Hermes v1 with bytecode diffing, and `expo-brownfield`. But: **NativeWind v5 is the only NativeWind line that targets SDK 55, and v5 is still pre-release** (`5.0.0-preview.3`, with explicit "not intended for production use" warning in the v5 docs). NativeWind v4 stable (`4.2.3`) is paired with SDK 54.
|
||||||
|
|
||||||
|
Two real options:
|
||||||
|
|
||||||
|
- **Option A — Ship-fast (recommended for V1):** Expo SDK 54 + NativeWind v4.2.3. Both stable, official pairing, well-documented. Loses SDK 55's bytecode-diff updates and Expo Router v7 sugar but everything works.
|
||||||
|
- **Option B — Cutting-edge:** Expo SDK 55.0.17 + NativeWind 5.0.0-preview.3. Latest everything, but the NativeWind v5 maintainer explicitly says it's for experimentation. More breakage during dev, brittle CI.
|
||||||
|
|
||||||
|
Recommendation: **Option A**. We're trying to ship a companion app, not stress-test pre-release styling layers. We can bump to SDK 55 + NativeWind v5 once both stabilize (Expo targets stable SDK 55 mid-2026).
|
||||||
|
|
||||||
|
### Pinned versions (assuming Option A)
|
||||||
|
|
||||||
|
| Package | Version | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `expo` | `~54` (latest 54.x) | New Architecture default since SDK 51 |
|
||||||
|
| `expo-router` | bundled with SDK | file-based, typed routes |
|
||||||
|
| `nativewind` | `4.2.3` | Tailwind class parity with `app/` |
|
||||||
|
| `expo-audio` | `~54` (bundled with SDK) | stable in SDK 54+, replaces `expo-av` recording |
|
||||||
|
| `@shopify/react-native-skia` | `2.6.2` | live waveform; WaveSurfer is DOM-only |
|
||||||
|
| `expo-camera` | `~54` (bundled) | QR scan (barcode scanning built in) |
|
||||||
|
| `expo-secure-store` | `~54` (bundled) | paired device key |
|
||||||
|
| `react-native-reanimated` | `4.3.0` | rewritten for new arch |
|
||||||
|
| `react-native-gesture-handler` | `2.31.1` | |
|
||||||
|
| `zustand` | `5.0.12` | mirrors desktop |
|
||||||
|
| `@tanstack/react-query` | `5.100.5` | mirrors desktop |
|
||||||
|
|
||||||
|
(Versions for `expo-*` packages are managed by `npx expo install`, which picks the patch that matches the SDK — don't pin them by hand.)
|
||||||
|
|
||||||
|
### Other stack decisions
|
||||||
|
|
||||||
|
- **TypeScript strict**
|
||||||
|
- Theme tokens copied straight from `app/`'s shadcn theme (`hsl(43 60% 50%)` for the gold accent, dark surfaces match)
|
||||||
|
- **EAS Build** + **Dev Client** from day one — Skia and SecureStore push us off Expo Go
|
||||||
|
|
||||||
|
## Pairing & transport — Tailscale-friendly
|
||||||
|
|
||||||
|
1. Desktop: new **Settings → Mobile** with "Pair device" → renders QR + 6-digit fallback
|
||||||
|
2. QR payload: `voicebox://pair?host=<url>&secret=<b64>&fp=<sha256>`
|
||||||
|
- `host` = whatever address the user can reach: LAN IP (`192.168.x.x:17493`), Tailscale 100.x address, or MagicDNS name (`mac.tail-xxxx.ts.net:17493`) — Tailscale Just Works with zero extra code
|
||||||
|
- `secret` = one-time pairing token; mobile exchanges it for a long-lived device key on first request
|
||||||
|
- `fp` = self-signed cert fingerprint we mint at pair time, pinned on mobile
|
||||||
|
3. Auth: bearer token + XChaCha20-Poly1305 payload encryption with HKDF per-session keys — E2E layer above HTTP, survives any future cloud relay swap
|
||||||
|
|
||||||
|
## Backend additions (desktop, separate PR before mobile work)
|
||||||
|
|
||||||
|
- `POST /pair/init` — mint pairing token, return QR payload
|
||||||
|
- `POST /pair/complete` — exchange token for device-bound long-lived key, persist `paired_devices` row
|
||||||
|
- `GET/DELETE /devices` — Settings → Mobile lists & revokes paired devices, with `last_seen_at`
|
||||||
|
- Bearer middleware on `/generate`, `/transcribe`, `/profiles`, `/captures`, `/speak` (loopback callers stay unauthenticated as today; paired-device callers use the bearer)
|
||||||
|
- `/captures` upload accepts `m4a` (expo-audio's iOS default) in addition to existing formats
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
### First-run
|
||||||
|
|
||||||
|
Pair flow: scan QR → confirm desktop name + fingerprint → store creds in SecureStore.
|
||||||
|
|
||||||
|
### Tab 1 — Generate
|
||||||
|
|
||||||
|
- Profile cards horizontal scroll (top)
|
||||||
|
- Recent generations list (middle) — tap to play, long-press for version picker / regenerate / share
|
||||||
|
- Floating generate box (bottom): text input + engine indicator + speak button
|
||||||
|
|
||||||
|
### Tab 2 — Voices
|
||||||
|
|
||||||
|
- List of profiles (cloned + presets), grouped by engine compatibility
|
||||||
|
- Tap to inspect: samples (Skia waveform thumbs), language, last used
|
||||||
|
- **No** profile creation in V1
|
||||||
|
|
||||||
|
### Tab 3 — Captures (the hero)
|
||||||
|
|
||||||
|
- **Big gold mic button** bottom-front-and-center — same accent as the sponsor CTA
|
||||||
|
- Tap-to-toggle in V1 (push-to-talk fights iOS gestures, defer)
|
||||||
|
- Live mic waveform via Skia, amplitude polled at 30 Hz, scrolling buffer
|
||||||
|
- Transcript text area above the waveform — empty during recording, populated on stop
|
||||||
|
- State pill at top: `recording → uploading → transcribing → refining → done` (mirrors desktop pill semantics)
|
||||||
|
- Captures list below: each row has a Skia mini-waveform, tap to expand, scrub, edit transcript inline, "Play as voice profile"
|
||||||
|
- **Audio preserved + downloadable** — visually obvious in the UI; this is the USP
|
||||||
|
- Schema/UI built so resumable capture can land later without re-architecting (no "one capture = one continuous take" assumptions baked in)
|
||||||
|
|
||||||
|
## Out of scope for V1 (deliberately)
|
||||||
|
|
||||||
|
Stories editor · Voice profile creation / sample recording · Effects editor · Personality LLM controls · Streaming transcription (lands with resumable capture in V2) · Settings beyond pairing · Android (pipeline supports it; QA focus iOS first)
|
||||||
|
|
||||||
|
## V2+ candidates
|
||||||
|
|
||||||
|
- **Resumable capture** — pause/resume that actually appends audio AND transcript (Apple Voice Notes drops the second-half transcript on resume; Voicebox shouldn't)
|
||||||
|
- **Document mode** — refinement LLM writes/edits a markdown document live as you speak, including dictated edits ("change the second bullet to…")
|
||||||
|
- **Streaming transcription** — partial transcripts during recording
|
||||||
|
- **Voice profile creation on mobile** — record samples directly
|
||||||
|
- **Android**
|
||||||
|
- **Cloud relay** — once the Voicebox platform exists, the same E2E layer rides over a relay so pairing isn't tied to Tailscale/LAN
|
||||||
|
|
||||||
|
## Build/dev workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
bun install
|
||||||
|
bunx expo prebuild # generate native projects (Skia + SecureStore need it)
|
||||||
|
bunx expo run:ios # device on the same Tailnet
|
||||||
|
eas build --profile development # distributable Dev Client for TestFlight
|
||||||
|
```
|
||||||
|
|
||||||
|
Bundle ID: `sh.voicebox.mobile` (need to register in App Store Connect).
|
||||||
|
|
||||||
|
## Order of attack
|
||||||
|
|
||||||
|
1. Backend: pairing endpoints + bearer middleware (desktop PR, can land before mobile)
|
||||||
|
2. Mobile: Expo scaffold + NativeWind + theme tokens + Pair screen
|
||||||
|
3. Mobile: Captures tab end-to-end — validates transport, E2E layer, audio upload, waveform, pill states all in one flow
|
||||||
|
4. Mobile: Generate tab
|
||||||
|
5. Mobile: Voices tab
|
||||||
|
6. EAS dev build + TestFlight internal track
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **iOS only V1?** Default: yes.
|
||||||
|
2. **NativeWind or hand-rolled styles?** Default: NativeWind — class parity with `app/` is worth the small bundler tax.
|
||||||
|
3. **Crib patterns from the Spacedrive mobile app first**, or start clean from current Expo docs?
|
||||||
|
4. **Bundle ID + display name** — confirm `sh.voicebox.mobile` / "Voicebox" before EAS setup.
|
||||||
|
5. **Pair screen UX** — QR-only, or always offer the 6-digit code as an a11y/fallback?
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Voicebox",
|
||||||
|
"slug": "voicebox",
|
||||||
|
"scheme": "voicebox",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#0F0F0F"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "sh.voicebox.mobile"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "sh.voicebox.mobile",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#0F0F0F"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Voicebox uses the camera to scan the pairing QR shown by your desktop."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-audio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
|
import { Mic, PenTool, Users } from 'lucide-react-native';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
const { session, hydrated } = useSession();
|
||||||
|
|
||||||
|
// Wait for SecureStore hydration before deciding where to send the user.
|
||||||
|
// Without this we'd briefly render the tabs against a null session and
|
||||||
|
// every authenticated query would fire with no bearer.
|
||||||
|
if (!hydrated) {
|
||||||
|
return <View style={{ flex: 1, backgroundColor: colors.background }} />;
|
||||||
|
}
|
||||||
|
if (!session) return <Redirect href="/welcome" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
tabBarActiveTintColor: colors.accent,
|
||||||
|
tabBarInactiveTintColor: colors.mutedForeground,
|
||||||
|
tabBarLabelStyle: { fontSize: 11, fontWeight: '600' },
|
||||||
|
sceneStyle: { backgroundColor: colors.background },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Captures',
|
||||||
|
tabBarIcon: ({ color, size }) => <Mic size={size - 2} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="generate"
|
||||||
|
options={{
|
||||||
|
title: 'Generate',
|
||||||
|
tabBarIcon: ({ color, size }) => <PenTool size={size - 2} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="voices"
|
||||||
|
options={{
|
||||||
|
title: 'Voices',
|
||||||
|
tabBarIcon: ({ color, size }) => <Users size={size - 2} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||||
|
import { Loader2, Pause, Play, Sparkles, Volume2 } from 'lucide-react-native';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
authHeaders,
|
||||||
|
buildGenerationAudioUrl,
|
||||||
|
type HistoryResponse,
|
||||||
|
type VoiceProfileResponse,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
import { useGenerate, useGenerationPolling, useHistory, useProfiles } from '@/lib/hooks';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export default function GenerateTab() {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
const profiles = useProfiles();
|
||||||
|
const history = useHistory(20);
|
||||||
|
const generateMutation = useGenerate();
|
||||||
|
|
||||||
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
const [nowPlayingId, setNowPlayingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const polling = useGenerationPolling(pendingId);
|
||||||
|
const playingSource = useMemo(() => {
|
||||||
|
if (!session || !nowPlayingId) return null;
|
||||||
|
return {
|
||||||
|
uri: buildGenerationAudioUrl(session, nowPlayingId),
|
||||||
|
headers: authHeaders(session),
|
||||||
|
};
|
||||||
|
}, [session, nowPlayingId]);
|
||||||
|
const player = useAudioPlayer(playingSource ?? null);
|
||||||
|
const playerStatus = useAudioPlayerStatus(player);
|
||||||
|
|
||||||
|
// Default-select first profile when the list arrives.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProfileId === null && profiles.data && profiles.data.length > 0) {
|
||||||
|
setSelectedProfileId(profiles.data[0].id);
|
||||||
|
}
|
||||||
|
}, [profiles.data, selectedProfileId]);
|
||||||
|
|
||||||
|
// When the polled generation completes, auto-play it and clear pending.
|
||||||
|
useEffect(() => {
|
||||||
|
const data = polling.data;
|
||||||
|
if (!data) return;
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
setNowPlayingId(data.id);
|
||||||
|
setPendingId(null);
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
setPendingId(null);
|
||||||
|
}
|
||||||
|
}, [polling.data]);
|
||||||
|
|
||||||
|
// Auto-play when a new source comes in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playingSource) return;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
player.play();
|
||||||
|
} catch {
|
||||||
|
// ignore — player may not be ready yet
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [playingSource, player]);
|
||||||
|
|
||||||
|
const selectedProfile = profiles.data?.find((p) => p.id === selectedProfileId) ?? null;
|
||||||
|
const isGenerating =
|
||||||
|
generateMutation.isPending ||
|
||||||
|
(pendingId !== null && polling.data?.status !== 'completed');
|
||||||
|
|
||||||
|
function handleGenerate() {
|
||||||
|
if (!selectedProfile || !text.trim() || isGenerating) return;
|
||||||
|
generateMutation.mutate(
|
||||||
|
{
|
||||||
|
profile_id: selectedProfile.id,
|
||||||
|
text: text.trim(),
|
||||||
|
language: selectedProfile.language,
|
||||||
|
engine: selectedProfile.default_engine ?? undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (gen) => {
|
||||||
|
setPendingId(gen.id);
|
||||||
|
setText('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayHistory(item: HistoryResponse) {
|
||||||
|
if (item.status !== 'completed') return;
|
||||||
|
if (nowPlayingId === item.id) {
|
||||||
|
// Toggle play/pause
|
||||||
|
if (playerStatus.playing) player.pause();
|
||||||
|
else player.play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNowPlayingId(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
|
{/* Header */}
|
||||||
|
<View className="px-6 py-3">
|
||||||
|
<Text className="text-foreground text-2xl font-bold tracking-tight">Generate</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Profile picker */}
|
||||||
|
<View className="pb-3">
|
||||||
|
{profiles.isLoading ? (
|
||||||
|
<View className="px-6 h-24 justify-center">
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
) : profiles.data && profiles.data.length > 0 ? (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 24, gap: 10 }}
|
||||||
|
>
|
||||||
|
{profiles.data.map((p) => (
|
||||||
|
<ProfileCard
|
||||||
|
key={p.id}
|
||||||
|
profile={p}
|
||||||
|
selected={p.id === selectedProfileId}
|
||||||
|
onPress={() => setSelectedProfileId(p.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View className="px-6 py-4">
|
||||||
|
<Text className="text-muted-foreground text-sm">
|
||||||
|
No profiles found on this desktop. Create one in Voicebox first.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Text input */}
|
||||||
|
<View className="px-6 pb-3">
|
||||||
|
<View className="rounded-2xl border border-border bg-card p-4 gap-3">
|
||||||
|
<TextInput
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
placeholder={
|
||||||
|
selectedProfile
|
||||||
|
? `Speak as ${selectedProfile.name}…`
|
||||||
|
: 'Pick a voice above'
|
||||||
|
}
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
multiline
|
||||||
|
editable={!!selectedProfile && !isGenerating}
|
||||||
|
className="text-foreground text-base min-h-[80px]"
|
||||||
|
style={{ textAlignVertical: 'top' }}
|
||||||
|
/>
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
{text.length > 0 ? `${text.length} chars` : ' '}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label={isGenerating ? 'Generating' : 'Speak'}
|
||||||
|
size="sm"
|
||||||
|
loading={isGenerating}
|
||||||
|
disabled={!selectedProfile || !text.trim() || isGenerating}
|
||||||
|
onPress={handleGenerate}
|
||||||
|
leftSlot={
|
||||||
|
isGenerating ? null : <Sparkles size={14} color="#F2F2F2" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{generateMutation.isError ? (
|
||||||
|
<Text className="text-destructive text-xs">
|
||||||
|
{(generateMutation.error as Error)?.message ?? 'Generate failed'}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Recent generations */}
|
||||||
|
<View className="px-6 pt-2 pb-1">
|
||||||
|
<Text className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Recent
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={history.data?.items ?? []}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 80 }}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={history.isRefetching && !history.isLoading}
|
||||||
|
onRefresh={() => history.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
history.isLoading ? (
|
||||||
|
<View className="pt-6 items-center">
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-muted-foreground text-sm pt-4">
|
||||||
|
Nothing yet — your generations will appear here.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<HistoryRow
|
||||||
|
item={item}
|
||||||
|
isActive={nowPlayingId === item.id}
|
||||||
|
isPlaying={nowPlayingId === item.id && playerStatus.playing}
|
||||||
|
onPress={() => handlePlayHistory(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileCard({
|
||||||
|
profile,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
profile: VoiceProfileResponse;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={`w-[140px] rounded-2xl px-4 py-3 border ${
|
||||||
|
selected ? 'border-accent bg-accent/10' : 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Voice ${profile.name}`}
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`h-9 w-9 rounded-full mb-2 items-center justify-center ${
|
||||||
|
selected ? 'bg-accent' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Volume2 size={16} color={selected ? '#F2F2F2' : colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-foreground text-sm font-semibold"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{profile.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-[11px]" numberOfLines={1}>
|
||||||
|
{profile.language.toUpperCase()}
|
||||||
|
{profile.voice_type === 'preset' ? ' · preset' : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryRow({
|
||||||
|
item,
|
||||||
|
isActive,
|
||||||
|
isPlaying,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
item: HistoryResponse;
|
||||||
|
isActive: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const isCompleted = item.status === 'completed';
|
||||||
|
const isFailed = item.status === 'failed';
|
||||||
|
const isPending = !isCompleted && !isFailed;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={!isCompleted}
|
||||||
|
className={`flex-row items-center gap-3 rounded-xl px-3 py-3 border ${
|
||||||
|
isActive
|
||||||
|
? 'border-accent/50 bg-accent/10'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
} ${!isCompleted ? 'opacity-70' : ''}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`h-10 w-10 rounded-full items-center justify-center ${
|
||||||
|
isActive ? 'bg-accent' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 size={16} color={colors.mutedForeground} />
|
||||||
|
) : isPlaying ? (
|
||||||
|
<Pause size={16} color="#F2F2F2" fill="#F2F2F2" />
|
||||||
|
) : (
|
||||||
|
<Play size={16} color={isActive ? '#F2F2F2' : colors.mutedForeground} fill={isActive ? '#F2F2F2' : 'transparent'} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 min-w-0">
|
||||||
|
<Text className="text-foreground text-sm" numberOfLines={2}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-[11px] mt-0.5">
|
||||||
|
{item.profile_name}
|
||||||
|
{item.duration ? ` · ${item.duration.toFixed(1)}s` : ''}
|
||||||
|
{isFailed ? ' · failed' : ''}
|
||||||
|
{isPending ? ' · generating…' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { LogOut, Mic, Pause, Play, Square, Trash2 } from 'lucide-react-native';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { LiveWaveform } from '@/components/ui/LiveWaveform';
|
||||||
|
import {
|
||||||
|
authHeaders,
|
||||||
|
buildCaptureAudioUrl,
|
||||||
|
deleteCapture,
|
||||||
|
type CaptureResponse,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
import { useDictation, type DictationPhase } from '@/lib/dictation';
|
||||||
|
import { useCaptures, useInvalidateCaptures } from '@/lib/hooks';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
|
||||||
|
function formatRelative(iso: string): string {
|
||||||
|
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (sec < 60) return 'just now';
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||||
|
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||||
|
if (sec < 86400 * 7) return `${Math.floor(sec / 86400)}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (!ms) return '';
|
||||||
|
const total = Math.round(ms / 1000);
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(seconds));
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillCopyFor(phase: DictationPhase, durationSec: number): string {
|
||||||
|
if (phase === 'recording') {
|
||||||
|
const m = Math.floor(durationSec / 60);
|
||||||
|
const s = durationSec % 60;
|
||||||
|
return `Recording ${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (phase === 'processing') return 'Transcribing…';
|
||||||
|
if (phase === 'error') return 'Error';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CapturesTab() {
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
const clearSession = useSession((s) => s.clear);
|
||||||
|
const captures = useCaptures();
|
||||||
|
const invalidateCaptures = useInvalidateCaptures();
|
||||||
|
const dictation = useDictation();
|
||||||
|
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Single shared player instance for whichever capture is currently expanded.
|
||||||
|
const playerSource = useMemo(() => {
|
||||||
|
if (!session || !expandedId) return null;
|
||||||
|
return {
|
||||||
|
uri: buildCaptureAudioUrl(session, expandedId),
|
||||||
|
headers: authHeaders(session),
|
||||||
|
};
|
||||||
|
}, [session, expandedId]);
|
||||||
|
const player = useAudioPlayer(playerSource ?? null);
|
||||||
|
const status = useAudioPlayerStatus(player);
|
||||||
|
|
||||||
|
// Pulse the mic button while recording.
|
||||||
|
const pulse = useRef(new Animated.Value(1)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
if (dictation.phase !== 'recording') {
|
||||||
|
pulse.setValue(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulse, { toValue: 1.12, duration: 700, useNativeDriver: true }),
|
||||||
|
Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [dictation.phase, pulse]);
|
||||||
|
|
||||||
|
async function handleMicPress() {
|
||||||
|
if (dictation.phase === 'recording') {
|
||||||
|
const result = await dictation.stop();
|
||||||
|
if (result) invalidateCaptures();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dictation.phase === 'processing') return;
|
||||||
|
void dictation.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await clearSession();
|
||||||
|
router.replace('/welcome');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(captureId: string) {
|
||||||
|
if (expandedId === captureId) {
|
||||||
|
setExpandedId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedId(captureId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(capture: CaptureResponse) {
|
||||||
|
if (!session) return;
|
||||||
|
if (expandedId === capture.id) setExpandedId(null);
|
||||||
|
try {
|
||||||
|
await deleteCapture(session, capture.id);
|
||||||
|
invalidateCaptures();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Delete failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = captures.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
|
{/* Header */}
|
||||||
|
<View className="flex-row items-center justify-between px-6 py-3">
|
||||||
|
<Text className="text-foreground text-2xl font-bold tracking-tight">Captures</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSignOut}
|
||||||
|
hitSlop={12}
|
||||||
|
accessibilityLabel="Sign out"
|
||||||
|
accessibilityRole="button"
|
||||||
|
className="h-9 w-9 rounded-full items-center justify-center"
|
||||||
|
>
|
||||||
|
<LogOut size={18} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Recording HUD: live waveform + timer while recording. Falls back to a
|
||||||
|
compact pill for processing/error so we don't reserve the screen
|
||||||
|
space while transcription runs. */}
|
||||||
|
{dictation.phase === 'recording' ? (
|
||||||
|
<View className="px-6 pb-2 gap-2">
|
||||||
|
<View className="rounded-2xl bg-destructive/10 border border-destructive/30 px-4 py-3 gap-2">
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View className="h-2 w-2 rounded-full bg-destructive" />
|
||||||
|
<Text className="text-foreground text-xs font-semibold uppercase tracking-widest">
|
||||||
|
Recording
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-foreground text-xs font-semibold"
|
||||||
|
style={{ fontFamily: 'Menlo' }}
|
||||||
|
>
|
||||||
|
{`${Math.floor(dictation.durationSec / 60)}:${(dictation.durationSec % 60).toString().padStart(2, '0')}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<LiveWaveform active={true} levelDb={dictation.meteringDb} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : dictation.phase !== 'idle' ? (
|
||||||
|
<View className="px-6 pb-2">
|
||||||
|
<View
|
||||||
|
className={`self-center flex-row items-center gap-2 px-4 py-1.5 rounded-full ${
|
||||||
|
dictation.phase === 'error' ? 'bg-destructive/15' : 'bg-accent/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dictation.phase === 'processing' ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.accent} />
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-semibold ${
|
||||||
|
dictation.phase === 'error' ? 'text-destructive' : 'text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pillCopyFor(dictation.phase, dictation.durationSec)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{dictation.error ? (
|
||||||
|
<Text className="text-destructive text-xs text-center mt-2">{dictation.error}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(c) => c.id}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 8, paddingBottom: 180 }}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={captures.isRefetching && !captures.isLoading}
|
||||||
|
onRefresh={() => captures.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
captures.isLoading ? (
|
||||||
|
<View className="items-center pt-16">
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<CaptureRow
|
||||||
|
capture={item}
|
||||||
|
expanded={expandedId === item.id}
|
||||||
|
isPlaying={expandedId === item.id && status.playing}
|
||||||
|
currentTime={expandedId === item.id ? status.currentTime ?? 0 : 0}
|
||||||
|
duration={
|
||||||
|
expandedId === item.id
|
||||||
|
? status.duration ?? (item.duration_ms ? item.duration_ms / 1000 : 0)
|
||||||
|
: item.duration_ms
|
||||||
|
? item.duration_ms / 1000
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
onPress={() => toggleExpanded(item.id)}
|
||||||
|
onPlayPause={() => {
|
||||||
|
if (status.playing) player.pause();
|
||||||
|
else player.play();
|
||||||
|
}}
|
||||||
|
onDelete={() => handleDelete(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating mic */}
|
||||||
|
<View pointerEvents="box-none" className="absolute left-0 right-0" style={{ bottom: 24 }}>
|
||||||
|
<View className="items-center">
|
||||||
|
<Animated.View style={{ transform: [{ scale: pulse }] }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleMicPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={
|
||||||
|
dictation.phase === 'recording' ? 'Stop recording' : 'Start recording'
|
||||||
|
}
|
||||||
|
disabled={dictation.phase === 'processing'}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 78,
|
||||||
|
height: 78,
|
||||||
|
borderRadius: 39,
|
||||||
|
backgroundColor:
|
||||||
|
dictation.phase === 'recording' ? colors.destructive : colors.accent,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.85 : 1,
|
||||||
|
shadowColor:
|
||||||
|
dictation.phase === 'recording' ? colors.destructive : colors.accent,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 18,
|
||||||
|
elevation: 10,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{dictation.phase === 'processing' ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : dictation.phase === 'recording' ? (
|
||||||
|
<Square size={28} color="#fff" fill="#fff" />
|
||||||
|
) : (
|
||||||
|
<Mic size={32} color="#fff" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaptureRow({
|
||||||
|
capture,
|
||||||
|
expanded,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
onPress,
|
||||||
|
onPlayPause,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
capture: CaptureResponse;
|
||||||
|
expanded: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
onPress: () => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const transcript = capture.transcript_refined?.trim() || capture.transcript_raw.trim();
|
||||||
|
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={`rounded-2xl border px-4 py-3 gap-2 ${
|
||||||
|
expanded ? 'border-accent/50 bg-accent/5' : 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-muted-foreground text-[11px] font-semibold uppercase tracking-widest">
|
||||||
|
{formatRelative(capture.created_at)}
|
||||||
|
{capture.duration_ms ? ` · ${formatDuration(capture.duration_ms)}` : ''}
|
||||||
|
</Text>
|
||||||
|
{capture.transcript_refined ? (
|
||||||
|
<View className="px-1.5 py-0.5 rounded-sm bg-accent/15">
|
||||||
|
<Text className="text-accent text-[10px] font-semibold">REFINED</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-foreground text-sm leading-snug"
|
||||||
|
numberOfLines={expanded ? undefined : 6}
|
||||||
|
>
|
||||||
|
{transcript || '(empty transcript)'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<View className="pt-2 gap-2 border-t border-border/60">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<View className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<View
|
||||||
|
className="h-full bg-accent"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-muted-foreground text-[11px]" style={{ fontFamily: 'Menlo' }}>
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
hitSlop={8}
|
||||||
|
className="h-8 w-8 rounded-full items-center justify-center"
|
||||||
|
accessibilityLabel="Delete capture"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPlayPause();
|
||||||
|
}}
|
||||||
|
hitSlop={8}
|
||||||
|
className="h-9 w-9 rounded-full items-center justify-center bg-accent"
|
||||||
|
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={16} color="#F2F2F2" fill="#F2F2F2" />
|
||||||
|
) : (
|
||||||
|
<Play size={16} color="#F2F2F2" fill="#F2F2F2" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<View className="items-center pt-20 px-6 gap-3">
|
||||||
|
<View className="h-14 w-14 rounded-full bg-accent/10 items-center justify-center">
|
||||||
|
<Mic size={22} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground text-base font-semibold">No captures yet</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm text-center max-w-[260px]">
|
||||||
|
Tap the mic to dictate. Audio and transcript both stay on your desktop — Voicebox keeps every recording.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Mic, Sparkles, User2, Users, Volume2 } from 'lucide-react-native';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { type VoiceProfileResponse } from '@/lib/api';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
import { useProfiles } from '@/lib/hooks';
|
||||||
|
|
||||||
|
export default function VoicesTab() {
|
||||||
|
const profiles = useProfiles();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const items = profiles.data ?? [];
|
||||||
|
if (!query.trim()) return items;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return items.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.description?.toLowerCase().includes(q) ||
|
||||||
|
p.language.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [profiles.data, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
|
<View className="px-6 py-3 gap-3">
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-foreground text-2xl font-bold tracking-tight">Voices</Text>
|
||||||
|
{profiles.data ? (
|
||||||
|
<Text className="text-muted-foreground text-xs">
|
||||||
|
{profiles.data.length} {profiles.data.length === 1 ? 'voice' : 'voices'}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Search voices…"
|
||||||
|
placeholderTextColor={colors.mutedForeground}
|
||||||
|
className="rounded-xl border border-border bg-card px-4 py-2.5 text-foreground"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={filtered}
|
||||||
|
keyExtractor={(p) => p.id}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 24, paddingBottom: 80 }}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={profiles.isRefetching && !profiles.isLoading}
|
||||||
|
onRefresh={() => profiles.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
profiles.isLoading ? (
|
||||||
|
<View className="pt-12 items-center">
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
) : query ? (
|
||||||
|
<Text className="text-muted-foreground text-sm pt-6 text-center">
|
||||||
|
No voices match "{query}".
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ProfileRow
|
||||||
|
profile={item}
|
||||||
|
expanded={expandedId === item.id}
|
||||||
|
onPress={() => setExpandedId(expandedId === item.id ? null : item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileRow({
|
||||||
|
profile,
|
||||||
|
expanded,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
profile: VoiceProfileResponse;
|
||||||
|
expanded: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
const kindIcon =
|
||||||
|
profile.voice_type === 'preset' ? Sparkles : profile.voice_type === 'designed' ? User2 : Mic;
|
||||||
|
const KindIcon = kindIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={`rounded-2xl border px-4 py-3 ${
|
||||||
|
expanded ? 'border-accent/40 bg-accent/5' : 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View
|
||||||
|
className={`h-11 w-11 rounded-full items-center justify-center ${
|
||||||
|
profile.voice_type === 'preset' ? 'bg-accent/15' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Volume2 size={18} color={profile.voice_type === 'preset' ? colors.accent : colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 min-w-0">
|
||||||
|
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
|
||||||
|
{profile.name}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center gap-2 mt-0.5">
|
||||||
|
<Text className="text-muted-foreground text-[11px] uppercase tracking-wider">
|
||||||
|
{profile.language}
|
||||||
|
</Text>
|
||||||
|
<KindBadge kind={profile.voice_type} />
|
||||||
|
<Text className="text-muted-foreground text-[11px]">
|
||||||
|
{profile.generation_count} gens
|
||||||
|
{profile.voice_type === 'cloned' ? ` · ${profile.sample_count} samples` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<KindIcon size={16} color={colors.mutedForeground} />
|
||||||
|
</View>
|
||||||
|
{expanded && profile.description ? (
|
||||||
|
<View className="mt-3 pt-3 border-t border-border/60">
|
||||||
|
<Text className="text-muted-foreground text-sm leading-snug">
|
||||||
|
{profile.description}
|
||||||
|
</Text>
|
||||||
|
{profile.default_engine ? (
|
||||||
|
<Text className="text-muted-foreground text-[11px] uppercase tracking-wider mt-2">
|
||||||
|
engine · {profile.default_engine}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindBadge({ kind }: { kind: VoiceProfileResponse['voice_type'] }) {
|
||||||
|
const label =
|
||||||
|
kind === 'preset' ? 'PRESET' : kind === 'designed' ? 'DESIGNED' : 'CLONED';
|
||||||
|
return (
|
||||||
|
<View className="px-1.5 py-0.5 rounded-sm bg-muted">
|
||||||
|
<Text className="text-muted-foreground text-[9px] font-semibold tracking-widest">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<View className="items-center pt-16 px-6 gap-3">
|
||||||
|
<View className="h-14 w-14 rounded-full bg-accent/10 items-center justify-center">
|
||||||
|
<Users size={22} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-foreground text-base font-semibold">No voices yet</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm text-center max-w-[260px]">
|
||||||
|
Create voice profiles in your Voicebox desktop — they'll appear here automatically.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import '../global.css';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
import { useSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const hydrate = useSession((s) => s.hydrate);
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrate();
|
||||||
|
}, [hydrate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.background },
|
||||||
|
animation: 'fade',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { WelcomeScreen } from '@/screens/WelcomeScreen';
|
||||||
|
|
||||||
|
export default function WelcomeRoute() {
|
||||||
|
const router = useRouter();
|
||||||
|
return <WelcomeScreen onGetStarted={() => router.push('/pair')} />;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
|
||||||
|
'nativewind/babel',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
+1763
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: './global.css' });
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.100.5",
|
||||||
|
"expo": "~54.0.33",
|
||||||
|
"expo-audio": "~1.1.1",
|
||||||
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"lucide-react-native": "^1.11.0",
|
||||||
|
"nativewind": "4.2.3",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.12.1",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
type PressableProps,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'ghost';
|
||||||
|
type Size = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
type Props = Omit<PressableProps, 'children'> & {
|
||||||
|
label: string;
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
loading?: boolean;
|
||||||
|
leftSlot?: React.ReactNode;
|
||||||
|
rightSlot?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerBase =
|
||||||
|
'flex-row items-center justify-center rounded-full active:opacity-90';
|
||||||
|
|
||||||
|
const variantContainer: Record<Variant, string> = {
|
||||||
|
primary: 'bg-accent',
|
||||||
|
secondary: 'bg-card border border-border',
|
||||||
|
ghost: 'bg-transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantLabel: Record<Variant, string> = {
|
||||||
|
primary: 'text-accent-foreground',
|
||||||
|
secondary: 'text-foreground',
|
||||||
|
ghost: 'text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeContainer: Record<Size, string> = {
|
||||||
|
sm: 'px-4 py-2 gap-1.5',
|
||||||
|
md: 'px-6 py-3 gap-2',
|
||||||
|
lg: 'px-8 py-4 gap-2.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeLabel: Record<Size, string> = {
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors the desktop CTA in landing/src/components/SponsorPromo.tsx —
|
||||||
|
// rounded-full, gold accent, uppercase semibold label with wide tracking.
|
||||||
|
export const Button = forwardRef<View, Props>(function Button(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
leftSlot,
|
||||||
|
rightSlot,
|
||||||
|
disabled,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const isPrimary = variant === 'primary';
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
||||||
|
disabled={isDisabled}
|
||||||
|
style={[
|
||||||
|
isPrimary && {
|
||||||
|
shadowColor: colors.accent,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
isDisabled && { opacity: 0.5 },
|
||||||
|
style as object,
|
||||||
|
]}
|
||||||
|
className={`${containerBase} ${variantContainer[variant]} ${sizeContainer[size]}`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={isPrimary ? colors.accentForeground : colors.foreground}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{leftSlot}
|
||||||
|
<Text
|
||||||
|
className={`font-semibold uppercase ${variantLabel[variant]} ${sizeLabel[size]}`}
|
||||||
|
style={{ letterSpacing: 1.2 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{rightSlot}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { colors } from '@/lib/colors';
|
||||||
|
|
||||||
|
const BAR_COUNT = 36;
|
||||||
|
const MIN_BAR = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolling-buffer waveform driven by mic metering. Each metering tick
|
||||||
|
* pushes a new bar onto the right and drops the oldest from the left, so
|
||||||
|
* the waveform reads as a 36-bar sliding window of the last few seconds
|
||||||
|
* of audio. Pure React Native — no Skia, no native modules.
|
||||||
|
*
|
||||||
|
* ``levelDb`` should be the value out of ``RecorderState.metering`` (a
|
||||||
|
* dB-scale instantaneous level, typically -160 to 0).
|
||||||
|
*/
|
||||||
|
export function LiveWaveform({
|
||||||
|
active,
|
||||||
|
levelDb,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
levelDb: number | undefined;
|
||||||
|
}) {
|
||||||
|
const [buffer, setBuffer] = useState<number[]>(() => Array(BAR_COUNT).fill(MIN_BAR));
|
||||||
|
|
||||||
|
// Push a new bar each time the metering value changes (which happens
|
||||||
|
// every poll interval — set to ~80ms in dictation.ts).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const norm = normalize(levelDb);
|
||||||
|
setBuffer((prev) => [...prev.slice(1), norm]);
|
||||||
|
}, [levelDb, active]);
|
||||||
|
|
||||||
|
// Decay back to flat when not recording.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) return;
|
||||||
|
setBuffer(Array(BAR_COUNT).fill(MIN_BAR));
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-row items-center justify-center"
|
||||||
|
style={{ height: 48, gap: 3 }}
|
||||||
|
>
|
||||||
|
{buffer.map((h, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 3,
|
||||||
|
height: `${Math.round(h * 100)}%`,
|
||||||
|
backgroundColor: active ? colors.destructive : colors.mutedForeground,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
opacity: active ? 0.85 : 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map dB (-160 → 0) to display height (0.1 → 1.0).
|
||||||
|
*
|
||||||
|
* Speech typically lives in the -40 dB → -10 dB range; we compress that
|
||||||
|
* into the visible part of the bar so quiet speech still moves the bars
|
||||||
|
* visibly. Returns MIN_BAR for null/undefined so a missing metering field
|
||||||
|
* just shows a flat baseline rather than breaking the waveform.
|
||||||
|
*/
|
||||||
|
function normalize(db: number | undefined): number {
|
||||||
|
if (db == null || !Number.isFinite(db)) return MIN_BAR;
|
||||||
|
// Clamp to a useful range and normalize.
|
||||||
|
const clamped = Math.max(-60, Math.min(0, db));
|
||||||
|
// Linear in dB (close enough — psychoacoustic-correct curves are V2).
|
||||||
|
const linear = (clamped + 60) / 60;
|
||||||
|
// Power curve to favor mid-range — keeps the bars feeling responsive.
|
||||||
|
const shaped = Math.pow(linear, 1.6);
|
||||||
|
return Math.max(MIN_BAR, Math.min(1, shaped));
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import type { StoredSession } from './storage';
|
||||||
|
|
||||||
|
// Voicebox HTTP client. V0 transport is plain HTTP-over-LAN/Tailscale with a
|
||||||
|
// bearer header. Phase 2 will wrap payloads in XChaCha20-Poly1305 — this
|
||||||
|
// module is the single chokepoint for that future change.
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PairCompleteResponse = {
|
||||||
|
device_id: string;
|
||||||
|
bearer: string;
|
||||||
|
device_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeResponse = {
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildUrl(host: string, path: string): string {
|
||||||
|
// host may include a port (e.g. "192.168.1.5:17494"); just slap http:// on.
|
||||||
|
// Phase 2 should require https or self-signed-with-pinned-fp; for V0 plain
|
||||||
|
// HTTP over a trusted LAN/Tailnet is the deal we made.
|
||||||
|
return `http://${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (typeof body?.detail === 'string') return body.detail;
|
||||||
|
return JSON.stringify(body);
|
||||||
|
} catch {
|
||||||
|
return res.statusText || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Unauthenticated (pre-pair) ---------------------------------------------
|
||||||
|
|
||||||
|
export async function completePair(
|
||||||
|
host: string,
|
||||||
|
token: string,
|
||||||
|
deviceName: string,
|
||||||
|
): Promise<PairCompleteResponse> {
|
||||||
|
const res = await fetch(buildUrl(host, '/pair/complete'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, device_name: deviceName }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
||||||
|
return (await res.json()) as PairCompleteResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Authenticated (post-pair) ----------------------------------------------
|
||||||
|
|
||||||
|
async function authedFetch(
|
||||||
|
session: StoredSession,
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
headers.set('Authorization', `Bearer ${session.bearer}`);
|
||||||
|
if (init.body && !headers.has('Content-Type') && !(init.body instanceof FormData)) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
const res = await fetch(buildUrl(session.host, path), { ...init, headers });
|
||||||
|
if (!res.ok) throw new ApiError(res.status, await parseError(res));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(session: StoredSession): Promise<MeResponse> {
|
||||||
|
const res = await authedFetch(session, '/me');
|
||||||
|
return (await res.json()) as MeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Captures ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CaptureSource = 'dictation' | 'recording' | 'file';
|
||||||
|
|
||||||
|
export type CaptureResponse = {
|
||||||
|
id: string;
|
||||||
|
audio_path: string;
|
||||||
|
source: CaptureSource;
|
||||||
|
language: string | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
transcript_raw: string;
|
||||||
|
transcript_refined: string | null;
|
||||||
|
stt_model: string | null;
|
||||||
|
llm_model: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaptureListResponse = {
|
||||||
|
items: CaptureResponse[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaptureCreateResponse = CaptureResponse & {
|
||||||
|
auto_refine: boolean;
|
||||||
|
allow_auto_paste: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listCaptures(
|
||||||
|
session: StoredSession,
|
||||||
|
opts: { limit?: number; offset?: number } = {},
|
||||||
|
): Promise<CaptureListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||||
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await authedFetch(session, `/captures${qs ? `?${qs}` : ''}`);
|
||||||
|
return (await res.json()) as CaptureListResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCapture(
|
||||||
|
session: StoredSession,
|
||||||
|
audioUri: string,
|
||||||
|
opts: { filename?: string; mimeType?: string; source?: CaptureSource } = {},
|
||||||
|
): Promise<CaptureCreateResponse> {
|
||||||
|
const filename = opts.filename ?? 'capture.m4a';
|
||||||
|
const mimeType = opts.mimeType ?? 'audio/m4a';
|
||||||
|
const source = opts.source ?? 'dictation';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
// React Native's FormData accepts {uri, name, type} for file fields; the
|
||||||
|
// typing pretends it doesn't, so we cast.
|
||||||
|
form.append('file', {
|
||||||
|
uri: audioUri,
|
||||||
|
name: filename,
|
||||||
|
type: mimeType,
|
||||||
|
} as unknown as Blob);
|
||||||
|
form.append('source', source);
|
||||||
|
|
||||||
|
const res = await authedFetch(session, '/captures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
return (await res.json()) as CaptureCreateResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCapture(
|
||||||
|
session: StoredSession,
|
||||||
|
captureId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await authedFetch(session, `/captures/${captureId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCaptureAudioUrl(session: StoredSession, captureId: string): string {
|
||||||
|
return buildUrl(session.host, `/captures/${captureId}/audio`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Voice profiles ---------------------------------------------------------
|
||||||
|
|
||||||
|
export type VoiceProfileResponse = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
language: string;
|
||||||
|
avatar_path: string | null;
|
||||||
|
voice_type: 'cloned' | 'preset' | 'designed';
|
||||||
|
preset_engine: string | null;
|
||||||
|
preset_voice_id: string | null;
|
||||||
|
default_engine: string | null;
|
||||||
|
generation_count: number;
|
||||||
|
sample_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listProfiles(session: StoredSession): Promise<VoiceProfileResponse[]> {
|
||||||
|
const res = await authedFetch(session, '/profiles');
|
||||||
|
return (await res.json()) as VoiceProfileResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generations ------------------------------------------------------------
|
||||||
|
|
||||||
|
export type GenerationStatus = 'pending' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export type GenerationResponse = {
|
||||||
|
id: string;
|
||||||
|
profile_id: string;
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
audio_path: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
seed: number | null;
|
||||||
|
engine: string | null;
|
||||||
|
status: GenerationStatus | string;
|
||||||
|
error: string | null;
|
||||||
|
is_favorited: boolean;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryResponse = GenerationResponse & {
|
||||||
|
profile_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryListResponse = {
|
||||||
|
items: HistoryResponse[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateRequest = {
|
||||||
|
profile_id: string;
|
||||||
|
text: string;
|
||||||
|
language?: string;
|
||||||
|
engine?: string;
|
||||||
|
seed?: number;
|
||||||
|
personality?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generate(
|
||||||
|
session: StoredSession,
|
||||||
|
req: GenerateRequest,
|
||||||
|
): Promise<GenerationResponse> {
|
||||||
|
const res = await authedFetch(session, '/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
return (await res.json()) as GenerationResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistoryItem(
|
||||||
|
session: StoredSession,
|
||||||
|
generationId: string,
|
||||||
|
): Promise<HistoryResponse> {
|
||||||
|
const res = await authedFetch(session, `/history/${generationId}`);
|
||||||
|
return (await res.json()) as HistoryResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listHistory(
|
||||||
|
session: StoredSession,
|
||||||
|
opts: { limit?: number; offset?: number; profile_id?: string } = {},
|
||||||
|
): Promise<HistoryListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||||
|
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||||
|
if (opts.profile_id) params.set('profile_id', opts.profile_id);
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await authedFetch(session, `/history${qs ? `?${qs}` : ''}`);
|
||||||
|
return (await res.json()) as HistoryListResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGenerationAudioUrl(
|
||||||
|
session: StoredSession,
|
||||||
|
generationId: string,
|
||||||
|
): string {
|
||||||
|
return buildUrl(session.host, `/audio/${generationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication header for streaming sources where a separate fetch isn't
|
||||||
|
* possible (expo-audio's source object accepts headers directly).
|
||||||
|
*/
|
||||||
|
export function authHeaders(session: StoredSession): Record<string, string> {
|
||||||
|
return { Authorization: `Bearer ${session.bearer}` };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Color tokens mirroring the desktop dark theme (`app/src/index.css` `.dark`).
|
||||||
|
// Use NativeWind classes (`bg-accent`, `text-foreground`, …) in components;
|
||||||
|
// this constants file is for places NativeWind doesn't reach — StatusBar tint,
|
||||||
|
// native module props, Skia, animated colors.
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
background: '#0F0F0F', // hsl(0 0% 6%)
|
||||||
|
foreground: '#F2F2F2', // hsl(0 0% 95%)
|
||||||
|
card: '#141414', // hsl(0 0% 8%)
|
||||||
|
border: '#1F1F1F', // hsl(0 0% 12%)
|
||||||
|
muted: '#1F1F1F',
|
||||||
|
mutedForeground: '#999999', // hsl(0 0% 60%)
|
||||||
|
accent: '#AC8C39', // hsl(43 50% 45%) — the gold
|
||||||
|
accentFaint: '#916F2D', // hsl(43 50% 38%)
|
||||||
|
accentForeground: '#F2F2F2',
|
||||||
|
destructive: '#CD3535', // hsl(0 62.8% 50%)
|
||||||
|
ring: '#666666', // hsl(0 0% 40%)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ColorKey = keyof typeof colors;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
AudioModule,
|
||||||
|
RecordingPresets,
|
||||||
|
setAudioModeAsync,
|
||||||
|
useAudioRecorder,
|
||||||
|
useAudioRecorderState,
|
||||||
|
} from 'expo-audio';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { uploadCapture, type CaptureCreateResponse } from './api';
|
||||||
|
import { useSession } from './session';
|
||||||
|
|
||||||
|
export type DictationPhase = 'idle' | 'recording' | 'processing' | 'error';
|
||||||
|
|
||||||
|
export type DictationFlow = {
|
||||||
|
phase: DictationPhase;
|
||||||
|
durationSec: number;
|
||||||
|
/** Instantaneous mic level in dB (typically -160 → 0). undefined when not recording or metering disabled. */
|
||||||
|
meteringDb: number | undefined;
|
||||||
|
error: string | null;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<CaptureCreateResponse | null>;
|
||||||
|
isBusy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// HIGH_QUALITY + metering enabled so the screen can render a live waveform
|
||||||
|
// while recording. Metering is per-poll-interval, so we ask for ~80ms ticks.
|
||||||
|
const RECORDING_OPTIONS = {
|
||||||
|
...RecordingPresets.HIGH_QUALITY,
|
||||||
|
isMeteringEnabled: true,
|
||||||
|
};
|
||||||
|
const RECORDER_POLL_INTERVAL_MS = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps expo-audio's recorder + the /captures upload into a single
|
||||||
|
* lifecycle: tap-to-start → tap-to-stop → upload → transcribed capture.
|
||||||
|
*
|
||||||
|
* Synchronous from the caller's POV: ``stop()`` resolves with the new
|
||||||
|
* capture (transcript and all) or null on failure.
|
||||||
|
*/
|
||||||
|
export function useDictation(): DictationFlow {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
const recorder = useAudioRecorder(RECORDING_OPTIONS);
|
||||||
|
const recState = useAudioRecorderState(recorder, RECORDER_POLL_INTERVAL_MS);
|
||||||
|
const [phase, setPhase] = useState<DictationPhase>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
if (!session) {
|
||||||
|
setError('Not paired');
|
||||||
|
setPhase('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const perm = await AudioModule.requestRecordingPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
setError('Microphone permission denied');
|
||||||
|
setPhase('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
||||||
|
await recorder.prepareToRecordAsync();
|
||||||
|
recorder.record();
|
||||||
|
setPhase('recording');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setPhase('error');
|
||||||
|
}
|
||||||
|
}, [recorder, session]);
|
||||||
|
|
||||||
|
const stop = useCallback(async (): Promise<CaptureCreateResponse | null> => {
|
||||||
|
if (!session) return null;
|
||||||
|
setPhase('processing');
|
||||||
|
try {
|
||||||
|
await recorder.stop();
|
||||||
|
const uri = recorder.uri;
|
||||||
|
if (!uri) throw new Error('No audio captured');
|
||||||
|
const result = await uploadCapture(session, uri, {
|
||||||
|
filename: `dictation-${Date.now()}.m4a`,
|
||||||
|
mimeType: 'audio/m4a',
|
||||||
|
source: 'dictation',
|
||||||
|
});
|
||||||
|
setPhase('idle');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
setError(
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'Upload failed — is the desktop reachable?',
|
||||||
|
);
|
||||||
|
setPhase('error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [recorder, session]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
durationSec: Math.floor((recState.durationMillis ?? 0) / 1000),
|
||||||
|
meteringDb: recState.metering,
|
||||||
|
error,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
isBusy: phase === 'processing',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
generate,
|
||||||
|
getHistoryItem,
|
||||||
|
listCaptures,
|
||||||
|
listHistory,
|
||||||
|
listProfiles,
|
||||||
|
type CaptureListResponse,
|
||||||
|
type GenerateRequest,
|
||||||
|
type GenerationResponse,
|
||||||
|
type HistoryListResponse,
|
||||||
|
type HistoryResponse,
|
||||||
|
type VoiceProfileResponse,
|
||||||
|
} from './api';
|
||||||
|
import { useSession } from './session';
|
||||||
|
|
||||||
|
export const CAPTURES_KEY = ['captures'] as const;
|
||||||
|
export const PROFILES_KEY = ['profiles'] as const;
|
||||||
|
export const HISTORY_KEY = ['history'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated capture list. V0 just fetches the first 50 and refetches on
|
||||||
|
* window focus / explicit invalidation. Pagination + virtualized list
|
||||||
|
* land when the user actually accumulates enough captures to need them.
|
||||||
|
*/
|
||||||
|
export function useCaptures() {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: CAPTURES_KEY,
|
||||||
|
queryFn: async (): Promise<CaptureListResponse> => {
|
||||||
|
if (!session) return { items: [], total: 0 };
|
||||||
|
return listCaptures(session, { limit: 50 });
|
||||||
|
},
|
||||||
|
enabled: !!session,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvalidateCaptures() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () => qc.invalidateQueries({ queryKey: CAPTURES_KEY });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Voice profiles (cloned + preset). */
|
||||||
|
export function useProfiles() {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PROFILES_KEY,
|
||||||
|
queryFn: async (): Promise<VoiceProfileResponse[]> => {
|
||||||
|
if (!session) return [];
|
||||||
|
return listProfiles(session);
|
||||||
|
},
|
||||||
|
enabled: !!session,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recent generations (most recent first). */
|
||||||
|
export function useHistory(limit = 20) {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...HISTORY_KEY, limit] as const,
|
||||||
|
queryFn: async (): Promise<HistoryListResponse> => {
|
||||||
|
if (!session) return { items: [], total: 0 };
|
||||||
|
return listHistory(session, { limit });
|
||||||
|
},
|
||||||
|
enabled: !!session,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a new generation. The mutation returns the generation row in its
|
||||||
|
* initial state — callers should follow up with ``useGenerationPolling``
|
||||||
|
* to await completion.
|
||||||
|
*/
|
||||||
|
export function useGenerate() {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (req: GenerateRequest): Promise<GenerationResponse> => {
|
||||||
|
if (!session) throw new Error('Not paired');
|
||||||
|
return generate(session, req);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: HISTORY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll a single generation row until it completes or fails. Stops polling
|
||||||
|
* automatically once a terminal state is reached, so it's cheap to leave
|
||||||
|
* mounted with a null id.
|
||||||
|
*/
|
||||||
|
export function useGenerationPolling(generationId: string | null) {
|
||||||
|
const session = useSession((s) => s.session);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['generation', generationId] as const,
|
||||||
|
queryFn: async (): Promise<HistoryResponse | null> => {
|
||||||
|
if (!session || !generationId) return null;
|
||||||
|
return getHistoryItem(session, generationId);
|
||||||
|
},
|
||||||
|
enabled: !!session && !!generationId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data;
|
||||||
|
if (!data) return 1500;
|
||||||
|
if (data.status === 'completed' || data.status === 'failed') return false;
|
||||||
|
return 1500;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Parsers for the voicebox:// pair URL scheme. The desktop UI generates
|
||||||
|
// these as QR codes; the mobile app accepts them via QR scan or manual paste.
|
||||||
|
|
||||||
|
export type PairPayload = {
|
||||||
|
host: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHEME_RE = /^voicebox:\/\/pair\?(.+)$/;
|
||||||
|
|
||||||
|
export function parsePairUrl(input: string): PairPayload | null {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const match = trimmed.match(SCHEME_RE);
|
||||||
|
if (!match) return null;
|
||||||
|
const params = new URLSearchParams(match[1]);
|
||||||
|
const host = params.get('host');
|
||||||
|
const token = params.get('token');
|
||||||
|
if (!host || !token) return null;
|
||||||
|
return { host, token };
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import {
|
||||||
|
clearStoredSession,
|
||||||
|
loadSession,
|
||||||
|
saveSession,
|
||||||
|
type StoredSession,
|
||||||
|
} from './storage';
|
||||||
|
|
||||||
|
type SessionState = {
|
||||||
|
session: StoredSession | null;
|
||||||
|
hydrated: boolean; // true after the first SecureStore read completes
|
||||||
|
hydrate: () => Promise<void>;
|
||||||
|
setSession: (s: StoredSession) => Promise<void>;
|
||||||
|
clear: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSession = create<SessionState>((set, get) => ({
|
||||||
|
session: null,
|
||||||
|
hydrated: false,
|
||||||
|
async hydrate() {
|
||||||
|
if (get().hydrated) return;
|
||||||
|
const session = await loadSession();
|
||||||
|
set({ session, hydrated: true });
|
||||||
|
},
|
||||||
|
async setSession(s) {
|
||||||
|
await saveSession(s);
|
||||||
|
set({ session: s });
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
await clearStoredSession();
|
||||||
|
set({ session: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
const SESSION_KEY = 'voicebox.session.v1';
|
||||||
|
|
||||||
|
export type StoredSession = {
|
||||||
|
host: string; // e.g. "192.168.1.5:17494" or "mac.tail-xxxx.ts.net:17493"
|
||||||
|
bearer: string; // long-lived bearer returned from POST /pair/complete
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadSession(): Promise<StoredSession | null> {
|
||||||
|
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as StoredSession;
|
||||||
|
} catch {
|
||||||
|
// Stored blob is corrupt — wipe so the user re-pairs cleanly
|
||||||
|
await SecureStore.deleteItemAsync(SESSION_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(session: StoredSession): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearStoredSession(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(SESSION_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Image, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onGetStarted: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WelcomeScreen({ onGetStarted }: Props) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background">
|
||||||
|
<View className="flex-1 px-8 items-center justify-between py-12">
|
||||||
|
<View className="flex-1 items-center justify-center gap-8">
|
||||||
|
<View
|
||||||
|
className="items-center justify-center"
|
||||||
|
style={{
|
||||||
|
shadowColor: '#AC8C39',
|
||||||
|
shadowOpacity: 0.45,
|
||||||
|
shadowRadius: 40,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={require('../../assets/icon.png')}
|
||||||
|
style={{ width: 120, height: 120, borderRadius: 28 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="items-center gap-3">
|
||||||
|
<Text className="text-foreground text-4xl font-bold tracking-tight text-center">
|
||||||
|
Welcome to Voicebox
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-base text-center max-w-[280px] leading-snug">
|
||||||
|
The open-source AI voice studio, in your pocket.
|
||||||
|
Clone voices, dictate anywhere, and talk to agents in voices you own.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="w-full items-center gap-4">
|
||||||
|
<Button label="Get started" size="lg" onPress={onGetStarted} />
|
||||||
|
<Text className="text-muted-foreground text-xs text-center">
|
||||||
|
You'll pair with your Voicebox desktop in the next step.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ['./app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
|
||||||
|
presets: [require('nativewind/preset')],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(0 0% 6%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(0 0% 8%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(0 0% 8%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(0 0% 18%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(0 0% 12%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(0 0% 12%)',
|
||||||
|
foreground: 'hsl(0 0% 60%)',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(43 50% 45%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
faint: 'hsl(43 50% 38%)',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(0 62.8% 50%)',
|
||||||
|
foreground: 'hsl(0 0% 95%)',
|
||||||
|
},
|
||||||
|
border: 'hsl(0 0% 12%)',
|
||||||
|
input: 'hsl(0 0% 12%)',
|
||||||
|
ring: 'hsl(0 0% 40%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user