mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -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:
+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:
|
||||
@@ -23,22 +40,28 @@ def register_routers(app: FastAPI) -> None:
|
||||
from .speak import router as speak_router
|
||||
from .mcp_bindings import router as mcp_bindings_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(profiles_router)
|
||||
app.include_router(channels_router)
|
||||
app.include_router(generations_router)
|
||||
app.include_router(history_router)
|
||||
app.include_router(transcription_router)
|
||||
app.include_router(llm_router)
|
||||
app.include_router(captures_router)
|
||||
app.include_router(stories_router)
|
||||
app.include_router(effects_router)
|
||||
app.include_router(audio_router)
|
||||
app.include_router(models_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(cuda_router)
|
||||
app.include_router(speak_router)
|
||||
app.include_router(mcp_bindings_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(pairing_router)
|
||||
|
||||
# Protected — loopback callers pass through; LAN callers need a paired bearer.
|
||||
protected = [Depends(require_bearer_or_loopback)]
|
||||
app.include_router(profiles_router, dependencies=protected)
|
||||
app.include_router(channels_router, dependencies=protected)
|
||||
app.include_router(generations_router, dependencies=protected)
|
||||
app.include_router(history_router, dependencies=protected)
|
||||
app.include_router(transcription_router, dependencies=protected)
|
||||
app.include_router(llm_router, dependencies=protected)
|
||||
app.include_router(captures_router, dependencies=protected)
|
||||
app.include_router(stories_router, dependencies=protected)
|
||||
app.include_router(effects_router, dependencies=protected)
|
||||
app.include_router(audio_router, dependencies=protected)
|
||||
app.include_router(models_router, dependencies=protected)
|
||||
app.include_router(settings_router, dependencies=protected)
|
||||
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)
|
||||
Reference in New Issue
Block a user