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:
James Pine
2026-04-25 17:09:32 -07:00
parent 2bcb98d1a8
commit f4d21504e3
49 changed files with 5229 additions and 30 deletions
+58
View File
@@ -793,3 +793,61 @@ class AvailableEffectsResponse(BaseModel):
"""Response listing all available effect types."""
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