mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 22:30:40 -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-hook-form": "^7.53.0",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-sound-visualizer": "^1.4.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"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/captures'
|
||||
| '/settings/mcp'
|
||||
| '/settings/mobile'
|
||||
| '/settings/gpu'
|
||||
| '/settings/logs'
|
||||
| '/settings/changelog'
|
||||
@@ -25,6 +26,9 @@ const tabs: SettingsTab[] = [
|
||||
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
||||
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
|
||||
{ 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.logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
||||
|
||||
@@ -50,6 +50,9 @@ import type {
|
||||
MCPClientBinding,
|
||||
MCPClientBindingListResponse,
|
||||
MCPClientBindingUpsert,
|
||||
HostCandidate,
|
||||
PairInitResponse,
|
||||
PairedDeviceResponse,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
@@ -920,6 +923,26 @@ class ApiClient {
|
||||
|
||||
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();
|
||||
|
||||
@@ -521,3 +521,27 @@ export interface MCPClientBindingUpsert {
|
||||
export interface MCPClientBindingListResponse {
|
||||
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 { LogsPage } from '@/components/ServerTab/LogsPage';
|
||||
import { MCPPage } from '@/components/ServerTab/MCPPage';
|
||||
import { MobilePage } from '@/components/ServerTab/MobilePage';
|
||||
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||
@@ -166,6 +167,12 @@ const settingsMCPRoute = createRoute({
|
||||
component: MCPPage,
|
||||
});
|
||||
|
||||
const settingsMobileRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/mobile',
|
||||
component: MobilePage,
|
||||
});
|
||||
|
||||
const settingsGpuRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/gpu',
|
||||
@@ -212,6 +219,7 @@ const routeTree = rootRoute.addChildren([
|
||||
settingsGenerationRoute,
|
||||
settingsCapturesRoute,
|
||||
settingsMCPRoute,
|
||||
settingsMobileRoute,
|
||||
settingsGpuRoute,
|
||||
settingsLogsRoute,
|
||||
settingsChangelogRoute,
|
||||
|
||||
Reference in New Issue
Block a user