From 1f03f5632c71f0c70f02d561975d344ef5a7fb24 Mon Sep 17 00:00:00 2001 From: velixio <270455167+velixio@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:45:34 +0530 Subject: [PATCH 01/15] perf(gallery): build previews concurrently and resume from disk Rendering 1126 previews ran one clip at a time, and only the first of a clip's five stages is on the GPU: render, then watermark embed, MP3 encode, decode, and detection. The card idled through four CPU stages per clip. The embed and detection are neural forward passes that ran inline on the event loop, so they held it for the whole clip -- concurrency would have queued behind a busy loop and bought nothing. They now go through asyncio.to_thread, which is what makes threads the right tool here: torch releases the GIL inside those passes, so there is no second model copy and no IPC for the tensors. Clips then build --jobs at a time (4 by default, 1 restores the old serial behaviour) under a semaphore, because every clip in flight holds decoded audio. A lost watermark still stops the entire run rather than only its own clip. --resume now also adopts MP3s already on disk. The manifest is written once, at the end, so a run interrupted at clip 900 left 900 correct files that --resume could not see and re-rendered every one of them. Everything an entry needs -- sha256, byte length, duration, featured flag -- is recoverable from the file and the catalog, so recover it. Also add a watermark preflight. Every clip was already verified individually, but only after the first full render, and the message blamed the bitrate when the cause can be unrelated to audio: on a host without python3-dev, AudioSeal's forward pass dies inside Inductor, embed_watermark catches it, and the clip is returned unmarked. Two seconds up front, with the actual cause named. It also warms the lazy generator/detector globals single-threaded, before --jobs fans out. --- scripts/render_gallery.py | 189 +++++++++++++++++++++++++++++++++----- 1 file changed, 166 insertions(+), 23 deletions(-) diff --git a/scripts/render_gallery.py b/scripts/render_gallery.py index 845381f5..01e19d31 100644 --- a/scripts/render_gallery.py +++ b/scripts/render_gallery.py @@ -32,6 +32,12 @@ Usage (from the repo root, with the model cached): python3 scripts/render_gallery.py --out dist/gallery python3 scripts/render_gallery.py --out dist/gallery --featured-only + +Only the render step is on the GPU; marking, encoding, decoding and detection +are all CPU, so clips are built ``--jobs`` at a time (4 by default) and the card +does not sit idle through four stages per clip. ``--resume`` picks up whatever +is already in the output directory, including MP3s from a run that was +interrupted before it could write a manifest. """ from __future__ import annotations @@ -110,21 +116,30 @@ async def _build_one(archetype: dict, key: str, work: Path, out_previews: Path) mp3_path = out_previews / f"{key}.mp3" await _render_archetype_wav(archetype, raw_wav) - wav, sr = _load(raw_wav) + + # Everything below that is CPU-bound goes through asyncio.to_thread. The + # AudioSeal embed and detection are each a real neural forward pass, and run + # inline they hold the event loop for the whole clip — so --jobs above 1 + # would queue work behind a busy loop and buy nothing. torch releases the + # GIL inside those passes, which is what makes threads (rather than + # processes) the right tool: no second model copy, no IPC for the tensors. + wav, sr = await asyncio.to_thread(_load, raw_wav) # force=True: the published clip carries the mark regardless of whether the # machine doing the publishing has invisible watermarking switched on. Same # contract as persona_bundle's preview embed. - marked = mark_synthetic(wav, sr, force=True, context="gallery.publish") + marked = await asyncio.to_thread( + mark_synthetic, wav, sr, force=True, context="gallery.publish" + ) from api.routers.generation import _safe_torchaudio_save - _safe_torchaudio_save(str(marked_wav), marked, sr) + await asyncio.to_thread(_safe_torchaudio_save, str(marked_wav), marked, sr) await _encode_mp3(marked_wav, mp3_path) check_wav = work / f"{key}.check.wav" await _decode_wav(mp3_path, check_wav) - decoded, decoded_sr = _load(check_wav) - verdict = detect_watermark(decoded, decoded_sr) + decoded, decoded_sr = await asyncio.to_thread(_load, check_wav) + verdict = await asyncio.to_thread(detect_watermark, decoded, decoded_sr) if not verdict.get("is_watermarked"): mp3_path.unlink(missing_ok=True) raise AssertionError( @@ -146,6 +161,90 @@ async def _build_one(archetype: dict, key: str, work: Path, out_previews: Path) } +async def _preflight_watermark() -> None: + """Prove the watermark works before rendering a thousand clips. + + Every clip is verified individually, so a broken embed was always caught — + but only after the first full render, and the failure named the bitrate + ("raise the bitrate or fix the embed") when the real cause can be nothing to + do with audio at all. On a machine missing ``python3-dev``, AudioSeal's + forward pass dies inside Inductor (``Python.h: No such file``), + ``embed_watermark`` catches it, and the clip is returned *unmarked*. Five + seconds here beats discovering that at clip 1 of 1126. + + Doubles as a single-threaded warm-up: the generator and detector are lazy + module globals, so touching them once before --jobs fans out avoids several + threads racing to load the same model. + """ + import torch + from services.watermark import detect_watermark, mark_synthetic + + sample_rate = 24000 + tone = torch.sin( + 2 * 3.14159 * 220 * torch.arange(sample_rate * 2) / sample_rate + ).unsqueeze(0) * 0.3 + marked = await asyncio.to_thread( + mark_synthetic, tone, sample_rate, force=True, context="gallery.preflight" + ) + verdict = await asyncio.to_thread(detect_watermark, marked, sample_rate) + if not verdict.get("is_watermarked"): + raise SystemExit( + "watermark preflight failed: mark_synthetic returned audio the " + f"detector does not recognise (confidence {verdict.get('confidence')}). " + "Publishing would ship unmarked audio, so this build stops here.\n" + "Most common cause: torch.compile/Inductor cannot build its helper " + "(missing Python headers — install python3-dev), which makes the " + "embed raise and silently pass the audio through unchanged. " + "TORCHDYNAMO_DISABLE=1 is the quick workaround." + ) + + +def _resume_from_disk(out_previews: Path, by_key: dict) -> dict: + """Rebuild manifest entries for previews already rendered. + + The manifest is written once, at the end, so a run interrupted at clip 900 + leaves 900 perfectly good MP3s that ``--resume`` cannot see — it keys off + the manifest, so it would render every one of them again. Everything an + entry needs is recoverable from the file itself, so recover it. + """ + recovered: dict = {} + for mp3_path in sorted(out_previews.glob("*.mp3")): + key = mp3_path.stem + archetype = by_key.get(key) + if archetype is None: + continue # a key from some older catalog — leave it out of the index + data = mp3_path.read_bytes() + if not data: + mp3_path.unlink(missing_ok=True) + continue + recovered[key] = { + "filename": mp3_path.name, + "sha256": _sha256(data), + "bytes": len(data), + "duration": _probe_duration(mp3_path), + "featured": bool(archetype.get("is_featured")), + } + return recovered + + +def _probe_duration(path: Path) -> float: + """Duration in seconds, straight from the encoded file.""" + import subprocess + + from services.ffmpeg_utils import find_ffmpeg + + ffprobe = str(Path(find_ffmpeg()).with_name("ffprobe")) + try: + out = subprocess.run( + [ffprobe, "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", str(path)], + capture_output=True, text=True, timeout=30, + ).stdout.strip() + return round(float(out), 3) + except (OSError, ValueError, subprocess.SubprocessError): + return 0.0 + + def _write_featured_tarball(out: Path, previews: dict) -> dict: """Bundle the featured previews so a first run costs one request, not 51.""" featured = sorted(k for k, e in previews.items() if e["featured"]) @@ -197,28 +296,67 @@ async def _main(args: argparse.Namespace) -> int: previews: dict[str, dict] = {} manifest_path = out / "manifest.json" - if args.resume and manifest_path.is_file(): - previous = json.loads(manifest_path.read_text(encoding="utf-8")) - previews = { - k: e for k, e in (previous.get("previews") or {}).items() - if (out_previews / f"{k}.mp3").is_file() - } + if args.resume: + if manifest_path.is_file(): + previous = json.loads(manifest_path.read_text(encoding="utf-8")) + previews = { + k: e for k, e in (previous.get("previews") or {}).items() + if (out_previews / f"{k}.mp3").is_file() + } + # Also adopt clips on disk the manifest never got to describe — an + # interrupted run has no manifest at all, and re-rendering audio that + # is already correct is the most expensive way to do nothing. + for key, entry in _resume_from_disk(out_previews, by_key).items(): + previews.setdefault(key, entry) + if previews: + print(f"resuming: {len(previews)} preview(s) already rendered", flush=True) + await _preflight_watermark() + + pending = [k for k in keys if k not in previews] failures: list[str] = [] with tempfile.TemporaryDirectory(prefix="gallery-render-") as tmp: work = Path(tmp) - for index, key in enumerate(keys, 1): - if key in previews: - continue + # Bounded fan-out. Each clip is render → embed → encode → decode → + # detect, and only the first of those is on the GPU: with one clip in + # flight the card idles through four CPU stages. The cap keeps that + # overlap from turning into unbounded memory (every concurrent clip + # holds decoded audio) and matches how the app itself bounds GPU work. + limit = asyncio.Semaphore(max(1, args.jobs)) + completed = 0 + state = asyncio.Lock() + + async def build(key: str) -> None: + nonlocal completed archetype = by_key[key] - print(f"[{index}/{len(keys)}] {key} {archetype['name']}", flush=True) - try: - previews[key] = await _build_one(archetype, key, work, out_previews) - except AssertionError: - raise # a lost watermark is a build failure, not a bad voice - except Exception as exc: - failures.append(f"{key} ({archetype['id']}): {type(exc).__name__}: {exc}") - print(f" FAILED: {exc}", file=sys.stderr, flush=True) + async with limit: + entry = await _build_one(archetype, key, work, out_previews) + async with state: + previews[key] = entry + completed += 1 + print(f"[{completed}/{len(pending)}] {key} {archetype['name']}", flush=True) + + tasks = [asyncio.create_task(build(key), name=key) for key in pending] + try: + for task in asyncio.as_completed(tasks): + try: + await task + except AssertionError: + # A lost watermark is a build failure, not a bad voice — + # stop the whole run rather than let the remaining jobs + # keep writing clips nobody has verified. + for other in tasks: + other.cancel() + raise + except asyncio.CancelledError: + pass + except Exception as exc: + failures.append(f"{type(exc).__name__}: {exc}") + print(f" FAILED: {exc}", file=sys.stderr, flush=True) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) if not previews: print("nothing rendered", file=sys.stderr) @@ -258,8 +396,13 @@ def main() -> int: parser.add_argument("--featured-only", action="store_true", help="render only the 51 featured archetypes") parser.add_argument("--limit", type=int, default=0, help="stop after N keys") + parser.add_argument( + "--jobs", type=int, default=4, + help="clips built concurrently (default 4); 1 restores serial rendering", + ) parser.add_argument("--resume", action="store_true", - help="keep previews already described by /manifest.json") + help="keep previews already in (manifest entries " + "and any MP3s an interrupted run left behind)") return asyncio.run(_main(parser.parse_args())) From 9832fbd693d0b9cd9e988b61167cb560391f6801 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:21:05 +0000 Subject: [PATCH 02/15] =?UTF-8?q?feat(engines):=20switch=20engines=20from?= =?UTF-8?q?=20anywhere=20=E2=80=94=20footer=20quick=20switch,=20workspace?= =?UTF-8?q?=20chips,=20shortcuts=20(#1530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(engines): add quick switching controls * docs(changelog): note engine quick switching (#1530) * fix(support): theme amount cards * fix(support): restore themed amount cards * fix(engines): address quick switch review findings * fix(engines): green the full frontend suite around the quick switch Three failure classes the targeted runs missed: - the popover referenced --chrome-radius, which does not exist; it now wears the footer's shared MENU_SURFACE like the compute popover - LogsFooter tests hand-wrote their api/system and api/hooks mocks, which drop every export the footer gains next; they are partial mocks now - DubHeader/AudiobookHero tests rendered without a QueryClientProvider, which useEngines needs Also: workspace-header chips open the popover downward (dropUp stays on the footer instance) so it cannot clip off the top of the viewport. Co-Authored-By: Claude Fable 5 * test(engines): one QueryClient per test module, not per render CodeRabbit: the inline client made every wrapper render a fresh cache, so rerender() restarted the /engines query mid-test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 2 + backend/api/routers/engines.py | 30 +-- frontend/src/App.jsx | 17 ++ frontend/src/api/hooks.ts | 40 ++++ frontend/src/api/types.ts | 6 +- .../components/EngineCompatibilityMatrix.jsx | 75 ++++++-- frontend/src/components/EngineQuickSwitch.jsx | 180 ++++++++++++++++++ .../src/components/EngineQuickSwitch.test.jsx | 90 +++++++++ .../src/components/KeyboardCheatsheet.jsx | 2 + frontend/src/components/LogsFooter.jsx | 2 + frontend/src/components/WizardLibrary.jsx | 26 +-- .../components/audiobook/AudiobookHero.jsx | 2 + frontend/src/components/dub/DubHeader.jsx | 2 + .../src/components/dub/DubHeader.test.jsx | 10 + .../src/components/settings/EnginesTab.jsx | 31 +-- .../components/settings/EnginesTab.test.jsx | 46 ++++- frontend/src/i18n/locales/ar.json | 2 + frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/es.json | 2 + frontend/src/i18n/locales/fr.json | 2 + frontend/src/i18n/locales/hi.json | 2 + frontend/src/i18n/locales/id.json | 2 + frontend/src/i18n/locales/it.json | 2 + frontend/src/i18n/locales/ja.json | 2 + frontend/src/i18n/locales/ko.json | 2 + frontend/src/i18n/locales/nl.json | 2 + frontend/src/i18n/locales/pl.json | 2 + frontend/src/i18n/locales/pt.json | 2 + frontend/src/i18n/locales/ru.json | 2 + frontend/src/i18n/locales/sv.json | 2 + frontend/src/i18n/locales/th.json | 2 + frontend/src/i18n/locales/tr.json | 2 + frontend/src/i18n/locales/uk.json | 2 + frontend/src/i18n/locales/vi.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + frontend/src/i18n/locales/zh-TW.json | 2 + frontend/src/pages/AudiobookTab.jsx | 25 +-- frontend/src/pages/CloneDesignTab.jsx | 49 +++-- frontend/src/pages/ModelCatalogue.jsx | 41 +++- frontend/src/pages/ModelCatalogue.test.jsx | 25 ++- frontend/src/pages/SupportPage.jsx | 12 +- frontend/src/pages/Transcriptions.jsx | 2 + frontend/src/pages/Transcriptions.test.jsx | 1 + frontend/src/store/uiSlice.ts | 15 +- frontend/src/test/DubHeaderActions.test.jsx | 15 +- .../src/test/LogsFooterClearFailure.test.jsx | 10 +- .../src/test/LogsFooterDonatePopover.test.jsx | 8 +- .../src/test/LogsFooterNotifications.test.jsx | 5 +- frontend/src/test/audiobookHero.test.jsx | 10 + tests/backend/api/test_engines_route_shape.py | 8 + 51 files changed, 687 insertions(+), 142 deletions(-) create mode 100644 frontend/src/components/EngineQuickSwitch.jsx create mode 100644 frontend/src/components/EngineQuickSwitch.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8b4353..d6ad867d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ the frozen-backend fallback mirror it for their toolchains. **Highlights** +- Switch TTS, ASR and LLM engines from the status bar or workspace, with ready-only choices, memory status and environment-pin protection. (#1530) - Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) - The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) - A faster, cleaner Dub workspace for multilingual production (#1489) @@ -52,6 +53,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Changed +- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530) - Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522) - Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522) - Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522) diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index 0b0cdd6f..42e8e966 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -41,6 +41,15 @@ _FAMILIES = { "llm": (llm_backend, "llm_backend"), } + +def _family_payload(family: str, module): + """Public inventory plus whether an environment pin owns this family.""" + return { + "active": module.active_backend_id(), + "env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")), + "backends": public_backends(module.list_backends()), + } + def _is_hf_repo_id(value: str) -> bool: """Validate the route's ``owner/repo`` contract in bounded time.""" if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1: @@ -55,34 +64,25 @@ def _is_hf_repo_id(value: str) -> bool: @router.get("/engines") def list_all_engines(): return { - "tts": { - "active": tts_backend.active_backend_id(), - "backends": public_backends(tts_backend.list_backends()), - }, - "asr": { - "active": asr_backend.active_backend_id(), - "backends": public_backends(asr_backend.list_backends()), - }, - "llm": { - "active": llm_backend.active_backend_id(), - "backends": public_backends(llm_backend.list_backends()), - }, + "tts": _family_payload("tts", tts_backend), + "asr": _family_payload("asr", asr_backend), + "llm": _family_payload("llm", llm_backend), } @router.get("/engines/tts") def list_tts_backends(): - return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())} + return _family_payload("tts", tts_backend) @router.get("/engines/asr") def list_asr_backends(): - return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())} + return _family_payload("asr", asr_backend) @router.get("/engines/llm") def list_llm_backends(): - return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())} + return _family_payload("llm", llm_backend) @router.get("/engines/effects/presets", response_model=EffectPresetsResponse) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 209ffc05..816ebc49 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import React, { } from 'react'; import './index.css'; import { useAppStore, FONT_STACKS } from './store'; +import { NAV_ITEMS } from './components/navItems'; import SearchableSelect from './components/SearchableSelect'; import DirectionDialog from './components/DirectionDialog'; @@ -803,6 +804,22 @@ function App() { // ── KEYBOARD SHORTCUTS ── useEffect(() => { const handler = (e) => { + // In-webview navigation only: using DOM keydown keeps this identical in + // browser, macOS, Windows and Linux builds (unlike OS-level hotkeys). + if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) { + const key = e.key.toLowerCase(); + if (key === 'e') { + e.preventDefault(); + window.dispatchEvent(new Event('engine-quick-switch')); + return; + } + const index = Number(key); + if (index >= 1 && index <= NAV_ITEMS.length) { + e.preventDefault(); + setMode(NAV_ITEMS[index - 1].id); + return; + } + } // ⌘+Enter or Ctrl+Enter → Generate if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index a13e45a7..18187307 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -14,6 +14,8 @@ import * as archetypesApi from './archetypes'; import type { ArchetypeFilters } from './archetypes'; import * as communityApi from './community'; import type { CommunityFilters } from './community'; +import * as enginesApi from './engines'; +import type { AllEnginesResponse, EngineFamily } from './types'; // ── Keys (prevents typos, enables targeted invalidation) ───────────────── export const queryKeys = { @@ -26,6 +28,7 @@ export const queryKeys = { models: ['models'] as const, recommendations: ['recommendations'] as const, preflight: ['preflight'] as const, + engines: ['engines'] as const, setupStatus: ['setup-status'] as const, galleryVoices: (params?: any) => ['gallery-voices', params] as const, galleryCategories: ['gallery-categories'] as const, @@ -171,6 +174,39 @@ export function usePreflight() { }); } +/** + * The app-wide engine inventory. Engine selection affects more than the + * catalogue (for example Audiobook's expressive controls), so every consumer + * must share this cache rather than take its own one-off snapshot. + * + * `queryFn` is injectable for the compatibility-matrix test seam. + */ +export function useEngines(queryFn: () => Promise = enginesApi.listEngines) { + return useQuery({ + queryKey: queryKeys.engines, + queryFn, + staleTime: 30_000, + retry: 1, + }); +} + +/** Select an engine and invalidate every view derived from `/engines`. */ +export function useSelectEngine() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + family, + backendId, + modelId, + }: { + family: EngineFamily; + backendId: string; + modelId?: string; + }) => enginesApi.selectEngine(family, backendId, modelId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.engines }), + }); +} + export function useSetupStatus() { return useQuery({ queryKey: queryKeys.setupStatus, @@ -230,6 +266,9 @@ export function useInstallModel() { qc.invalidateQueries({ queryKey: queryKeys.models }); qc.invalidateQueries({ queryKey: queryKeys.setupStatus }); qc.invalidateQueries({ queryKey: queryKeys.recommendations }); + // Some model installs make an engine selectable; refresh every engine + // indicator rather than leaving a stale unavailable snapshot behind. + qc.invalidateQueries({ queryKey: queryKeys.engines }); }, }); } @@ -242,6 +281,7 @@ export function useDeleteModel() { qc.invalidateQueries({ queryKey: queryKeys.models }); qc.invalidateQueries({ queryKey: queryKeys.setupStatus }); qc.invalidateQueries({ queryKey: queryKeys.recommendations }); + qc.invalidateQueries({ queryKey: queryKeys.engines }); }, }); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6c6e43dc..e7dd7dfe 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -24,7 +24,7 @@ type EffectiveDevice = GPUTarget | 'network'; // `n/a` is LLM-only; resolve_routing only ever returns the first four. type RoutingStatus = 'accelerated' | 'cpu_fallback' | 'cpu_only' | 'unavailable' | 'n/a'; -interface EngineBackend { +export interface EngineBackend { id: string; display_name: string; available: boolean; @@ -76,8 +76,10 @@ export interface CuratedModel { repo_id: string; } -interface EngineFamilyResponse { +export interface EngineFamilyResponse { active: string; + /** A process environment pin wins over a saved UI selection. */ + env_override?: boolean; backends: EngineBackend[]; } diff --git a/frontend/src/components/EngineCompatibilityMatrix.jsx b/frontend/src/components/EngineCompatibilityMatrix.jsx index 9397602a..0923d0de 100644 --- a/frontend/src/components/EngineCompatibilityMatrix.jsx +++ b/frontend/src/components/EngineCompatibilityMatrix.jsx @@ -226,6 +226,10 @@ export default function EngineCompatibilityMatrix({ showFamilyTabs = true, onFamilyChange = null, reloadToken = 0, + // The catalogue passes its app-wide query here. Keeping the standalone + // fallback preserves the matrix's injectable API seam for isolated hosts + // and its extensive focused test suite. + sharedEngines = null, // Injectable API layer — lets the RTL suite mock it without module-level // vi.mock incantations, and keeps the "one GET /engines per Settings open" // contract overridable by hosts. @@ -242,9 +246,15 @@ export default function EngineCompatibilityMatrix({ apiInstallStatus = getSidecarInstallStatus, }) { const { t } = useTranslation(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [localData, setLocalData] = useState(null); + const [localLoading, setLocalLoading] = useState(true); + const [localError, setLocalError] = useState(null); + const sharedRefetch = sharedEngines?.refetch; + const isShared = Boolean(sharedEngines); + const sharedReloadToken = useRef(reloadToken); + const data = sharedEngines?.data ?? localData; + const loading = isShared ? sharedEngines.isLoading : localLoading; + const error = sharedEngines?.error ?? localError; const [activeFamily, setActiveFamily] = useState(family); // Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog // currently open, or null. Only one dialog is ever open at a time. @@ -288,26 +298,42 @@ export default function EngineCompatibilityMatrix({ }, [apiListLoadedModels]); const reload = useCallback(async () => { - setLoading(true); - setError(null); - try { - const fresh = await apiListEngines(); - setData(fresh); - } catch (e) { - const msg = e?.message || String(e); - setError(msg); - toastErrorWithReport(t('engines.loadFailed', { message: msg }), e); - } finally { - setLoading(false); + if (sharedRefetch) { + const result = await sharedRefetch(); + if (result.error) { + const message = result.error?.message || String(result.error); + toastErrorWithReport(t('engines.loadFailed', { message }), result.error); + } + } else { + setLocalLoading(true); + setLocalError(null); + try { + setLocalData(await apiListEngines()); + } catch (requestError) { + const message = requestError?.message || String(requestError); + setLocalError(requestError); + toastErrorWithReport(t('engines.loadFailed', { message }), requestError); + } finally { + setLocalLoading(false); + } } refreshResidency(); - }, [apiListEngines, refreshResidency, t]); + }, [apiListEngines, refreshResidency, sharedRefetch, t]); useEffect(() => { - reload(); + if (isShared) { + if (sharedReloadToken.current !== reloadToken) { + sharedReloadToken.current = reloadToken; + void reload(); + return; + } + refreshResidency(); + return; + } + void reload(); // reloadToken: an external bump (e.g. the ASR config panel just saved a // server URL) refetches so availability + "Use" reflect the new config. - }, [reload, reloadToken]); + }, [reload, reloadToken, refreshResidency, isShared]); // Unload a resident engine's model/sidecar by its /model/loaded id. Safe by // contract: the model reloads lazily on the next generation. @@ -598,7 +624,8 @@ export default function EngineCompatibilityMatrix({ className="engine-matrix engine-matrix--error flex flex-col gap-[8px] items-center p-[16px]" role="alert" > - {t('engines.couldNotLoad', { message: error })} + {' '} + {t('engines.couldNotLoad', { message: error.message || String(error) })} @@ -726,7 +753,7 @@ export default function EngineCompatibilityMatrix({ data-testid="engine-list-scroll" aria-label={t('engines.engineCompatLabel', { family: activeFamily })} > - {backends.map((b) => { + {backends.map((b, index) => { const isActive = b.id === activeBackendId; const health = healthByEngine[b.id]; const selfTest = selfTestByEngine[b.id]; @@ -781,6 +808,16 @@ export default function EngineCompatibilityMatrix({ ) : null; return ( + {(index === 0 || (backends[index - 1]?.available && !b.available)) && ( +
+ {b.available ? t('engines.available') : t('engines.notInstalled')} +
+ )}
engine.id === familyData.active); + const available = useMemo( + () => (familyData?.backends || []).filter((engine) => engine.available), + [familyData], + ); + const residentIds = useMemo( + () => + new Set( + (residency?.models || []).flatMap((model) => (model.engine_id ? [model.engine_id] : [])), + ), + [residency], + ); + + useEffect(() => { + if (!open) return undefined; + const close = (event) => { + if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false); + }; + const escape = (event) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', close); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', close); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + // In-webview shortcut bridge. This stays a DOM event (not a Tauri global + // shortcut), so every desktop and browser build behaves the same way. + useEffect(() => { + if (!shortcutTarget) return undefined; + const show = () => { + setSwitchError(''); + setOpen(true); + }; + window.addEventListener('engine-quick-switch', show); + return () => window.removeEventListener('engine-quick-switch', show); + }, [shortcutTarget]); + + if (!active || available.length === 0) return null; + + const locked = Boolean(familyData.env_override); + const choose = async (backendId) => { + if (backendId === familyData.active || locked) return; + setSwitchError(''); + try { + const result = await selectMutation.mutateAsync({ family, backendId }); + if (result.env_override) { + setSwitchError(t('settings.llmp_env_override')); + return; + } + notifyEngineSelected(result, t, family); + setOpen(false); + } catch (error) { + setSwitchError(error?.message || t('engines.switch_failed')); + } + }; + + return ( +
+ + + {open && ( +
+ {locked && ( +

+ {t('settings.llmp_env_override')} +

+ )} + {available.map((engine) => { + const isActive = engine.id === familyData.active; + const warm = residentIds.has(engine.id); + return ( + + ); + })} + {switchError && ( +

+ {switchError} +

+ )} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/EngineQuickSwitch.test.jsx b/frontend/src/components/EngineQuickSwitch.test.jsx new file mode 100644 index 00000000..8083c842 --- /dev/null +++ b/frontend/src/components/EngineQuickSwitch.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn() } })); + +const { listEngines, selectEngine, listLoadedModels } = vi.hoisted(() => ({ + listEngines: vi.fn(), + selectEngine: vi.fn(), + listLoadedModels: vi.fn(), +})); +vi.mock('../api/engines', () => ({ listEngines, selectEngine })); +vi.mock('../api/system', () => ({ listLoadedModels })); + +import EngineQuickSwitch from './EngineQuickSwitch'; + +const inventory = (env_override = false) => ({ + tts: { + active: 'omnivoice', + env_override, + backends: [ + { id: 'omnivoice', display_name: 'OmniVoice', available: true }, + { id: 'indextts2', display_name: 'IndexTTS 2', available: true }, + { id: 'offline', display_name: 'Offline', available: false }, + ], + }, + asr: { active: 'whisper', backends: [] }, + llm: { active: 'off', backends: [] }, +}); + +function renderPicker(props = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('EngineQuickSwitch', () => { + beforeEach(() => { + vi.clearAllMocks(); + listEngines.mockResolvedValue(inventory()); + listLoadedModels.mockResolvedValue({ models: [{ engine_id: 'omnivoice' }] }); + }); + + it('lists only available engines and annotates residency', async () => { + renderPicker(); + fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i })); + + expect(screen.getByText('IndexTTS 2')).toBeInTheDocument(); + expect(screen.queryByText('Offline')).not.toBeInTheDocument(); + expect(await screen.findByText('In memory')).toBeInTheDocument(); + }); + + it('selects through the shared mutation', async () => { + selectEngine.mockResolvedValue({ family: 'tts', active: 'indextts2', env_override: false }); + renderPicker(); + fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i })); + fireEvent.click(screen.getByText('IndexTTS 2')); + + await waitFor(() => expect(selectEngine).toHaveBeenCalledWith('tts', 'indextts2', undefined)); + }); + + it('locks a family owned by an environment variable', async () => { + listEngines.mockResolvedValue(inventory(true)); + renderPicker(); + fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i })); + + expect(screen.getByText(/set via an environment variable/i)).toBeInTheDocument(); + expect(screen.getByText('IndexTTS 2').closest('button')).toBeDisabled(); + }); + + it('opens only the designated picker from the global shortcut bridge', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + , + ); + await screen.findAllByRole('button', { name: /active tts: omnivoice/i }); + + fireEvent(window, new Event('engine-quick-switch')); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/KeyboardCheatsheet.jsx b/frontend/src/components/KeyboardCheatsheet.jsx index d1af3ce0..998d02bb 100644 --- a/frontend/src/components/KeyboardCheatsheet.jsx +++ b/frontend/src/components/KeyboardCheatsheet.jsx @@ -19,6 +19,8 @@ export default function KeyboardCheatsheet({ open, onClose }) { title: t('keyboard.nav'), items: [ ['?', t('keyboard.nav_cheatsheet')], + [t('keyboard.nav_enginePickerKey'), t('engines.matrixTitle')], + [t('keyboard.nav_workspacesKey'), t('keyboard.nav')], ['Esc', t('keyboard.nav_closeModal')], ['Cmd/Ctrl+S', t('keyboard.nav_save')], ], diff --git a/frontend/src/components/LogsFooter.jsx b/frontend/src/components/LogsFooter.jsx index d6d89610..abff272a 100644 --- a/frontend/src/components/LogsFooter.jsx +++ b/frontend/src/components/LogsFooter.jsx @@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next'; import { useAppStore } from '../store'; import NetworkToggle from './NetworkToggle'; import ComputeQuickSettings from './ComputeQuickSettings'; +import EngineQuickSwitch from './EngineQuickSwitch'; import { APP_VERSION, whatsNewPending } from '../utils/appVersion'; import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover'; import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments'; @@ -639,6 +640,7 @@ export default function LogsFooter() { )} +
diff --git a/frontend/src/components/dub/DubHeader.jsx b/frontend/src/components/dub/DubHeader.jsx index e1065676..b4902c7f 100644 --- a/frontend/src/components/dub/DubHeader.jsx +++ b/frontend/src/components/dub/DubHeader.jsx @@ -12,6 +12,7 @@ import { Button } from '../../ui'; import FooterBtn from './FooterBtn'; import DubPipelineStepper from './DubPipelineStepper'; import { formatTime } from '../../utils/format'; +import EngineQuickSwitch from '../EngineQuickSwitch'; export default function DubHeader({ t, @@ -89,6 +90,7 @@ export default function DubHeader({
+
{defineMethod === 'audio' ? ( diff --git a/frontend/src/pages/ModelCatalogue.jsx b/frontend/src/pages/ModelCatalogue.jsx index a9ab9ce4..4932a0e4 100644 --- a/frontend/src/pages/ModelCatalogue.jsx +++ b/frontend/src/pages/ModelCatalogue.jsx @@ -13,8 +13,8 @@ * Deliberately a COMPOSITION, not a rewrite: the panes mount the existing * `EnginesTab` (engine matrix + the OpenAI-compatible ASR config) and * `ModelStoreTab` unchanged, so their data contracts, tests and behaviour carry - * over untouched — one GET /engines + one GET /model/loaded per open, the same - * install/delete flows, the same env-var-wins semantics. + * over untouched — the shared engine cache, the same install/delete flows, and + * the same env-var-wins semantics. * * `pendingCatalogueTab` is the one-shot deep-link hand-off (mirrors Settings' * `pendingSettingsTab`): a caller sets the pane and navigates here, this page @@ -31,7 +31,9 @@ import ModelStoreTab from '../components/settings/ModelStoreTab'; /** Persisted across visits so the workspace reopens where you left it. */ const PANE_KEY = 'omnivoice.catalogue.pane'; +const FAMILY_KEY = 'omnivoice.catalogue.engine-family'; const PANES = ['engines', 'models']; +const FAMILIES = ['tts', 'asr', 'llm']; function readStoredPane() { try { @@ -42,15 +44,29 @@ function readStoredPane() { } } +function readStoredFamily() { + try { + const stored = localStorage.getItem(FAMILY_KEY); + return FAMILIES.includes(stored) ? stored : 'tts'; + } catch { + return 'tts'; + } +} + export default function ModelCatalogue() { const { t } = useTranslation(); const pendingCatalogueTab = useAppStore((s) => s.pendingCatalogueTab); + const pendingCatalogueFamily = useAppStore((s) => s.pendingCatalogueFamily); const setPendingCatalogueTab = useAppStore((s) => s.setPendingCatalogueTab); + const setPendingCatalogueFamily = useAppStore((s) => s.setPendingCatalogueFamily); // Seed from the deep-link so the first paint is already the requested pane — // seeding from storage and correcting in an effect would flash the wrong one. const [pane, setPaneRaw] = useState(() => PANES.includes(pendingCatalogueTab) ? pendingCatalogueTab : readStoredPane(), ); + const [family, setFamilyRaw] = useState(() => + FAMILIES.includes(pendingCatalogueFamily) ? pendingCatalogueFamily : readStoredFamily(), + ); const setPane = useCallback((next) => { setPaneRaw(next); @@ -60,14 +76,31 @@ export default function ModelCatalogue() { /* private mode / quota — the pane still switches, it just won't persist */ } }, []); + const setFamily = useCallback((next) => { + setFamilyRaw(next); + try { + localStorage.setItem(FAMILY_KEY, next); + } catch { + /* private mode / quota — the family still switches */ + } + }, []); // Consume the one-shot deep-link (including a repeat request for the pane // we're already on, which must still clear). useEffect(() => { if (!pendingCatalogueTab) return; if (PANES.includes(pendingCatalogueTab)) setPane(pendingCatalogueTab); + if (FAMILIES.includes(pendingCatalogueFamily)) setFamily(pendingCatalogueFamily); setPendingCatalogueTab(null); - }, [pendingCatalogueTab, setPendingCatalogueTab, setPane]); + setPendingCatalogueFamily(null); + }, [ + pendingCatalogueTab, + pendingCatalogueFamily, + setPendingCatalogueFamily, + setPendingCatalogueTab, + setFamily, + setPane, + ]); const { data: info } = useSystemInfo(); const { data: status } = useModelStatus(); @@ -141,7 +174,7 @@ export default function ModelCatalogue() { className="min-w-0 [&>*:first-child]:mt-0 @min-[760px]/catalogue-shell:min-h-0 @min-[760px]/catalogue-shell:flex-1 @min-[760px]/catalogue-shell:overflow-y-auto @min-[760px]/catalogue-shell:overscroll-contain" > {pane === 'engines' ? ( - + ) : ( )} diff --git a/frontend/src/pages/ModelCatalogue.test.jsx b/frontend/src/pages/ModelCatalogue.test.jsx index 7f4375eb..96ba789d 100644 --- a/frontend/src/pages/ModelCatalogue.test.jsx +++ b/frontend/src/pages/ModelCatalogue.test.jsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react'; vi.mock('../components/settings/EnginesTab', () => ({ - default: () =>
, + default: ({ initialFamily }) =>
{initialFamily}
, })); vi.mock('../components/settings/ModelStoreTab', () => ({ default: ({ modelBadge }) =>
{modelBadge}
, @@ -35,7 +35,11 @@ describe('ModelCatalogue', () => { beforeEach(() => { localStorage.clear(); act(() => { - useAppStore.setState({ mode: 'catalogue', pendingCatalogueTab: null }); + useAppStore.setState({ + mode: 'catalogue', + pendingCatalogueTab: null, + pendingCatalogueFamily: null, + }); }); }); @@ -78,6 +82,15 @@ describe('ModelCatalogue', () => { expect(useAppStore.getState().pendingCatalogueTab).toBeNull(); }); + it('honours an engine-family deep link and clears it after hand-off', () => { + act(() => { + useAppStore.getState().openCatalogue({ pane: 'engines', family: 'asr' }); + }); + render(); + expect(screen.getByTestId('stub-engines')).toHaveTextContent('asr'); + expect(useAppStore.getState().pendingCatalogueFamily).toBeNull(); + }); + it('passes the loaded-model badge down to the model store pane', () => { act(() => { useAppStore.setState({ pendingCatalogueTab: 'models' }); @@ -105,4 +118,12 @@ describe('openCatalogue', () => { }); expect(useAppStore.getState().pendingCatalogueTab).toBe('engines'); }); + + it('accepts an object deep link with a family', () => { + act(() => { + useAppStore.getState().openCatalogue({ pane: 'engines', family: 'llm' }); + }); + expect(useAppStore.getState().pendingCatalogueTab).toBe('engines'); + expect(useAppStore.getState().pendingCatalogueFamily).toBe('llm'); + }); }); diff --git a/frontend/src/pages/SupportPage.jsx b/frontend/src/pages/SupportPage.jsx index 99f7f9e1..fc65515c 100644 --- a/frontend/src/pages/SupportPage.jsx +++ b/frontend/src/pages/SupportPage.jsx @@ -313,10 +313,10 @@ function SupportView() { type="button" aria-pressed={selected} onClick={() => setAmount(selected ? null : a.value)} - className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border px-1.5 py-2 transition-colors ${ + className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border-0 bg-[color-mix(in_srgb,var(--chrome-fg)_5%,transparent)] px-1.5 py-2 shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-fg-muted)_18%,transparent)] transition-[background,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)] ${ selected - ? 'border-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)]' - : `${a.common ? 'border-transparent' : 'border-border'} hover:border-transparent hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]` + ? 'bg-[var(--chrome-accent-bg)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-accent)_45%,transparent)]' + : `${a.common ? 'bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-accent)_30%,transparent)]' : ''} hover:bg-[var(--chrome-accent-bg)] hover:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-accent)_35%,transparent)]` }`} > @@ -334,10 +334,10 @@ function SupportView() { type="button" aria-pressed={amount === 'custom'} onClick={() => setAmount(amount === 'custom' ? null : 'custom')} - className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border px-1.5 py-2 transition-colors ${ + className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border-0 bg-[color-mix(in_srgb,var(--chrome-fg)_5%,transparent)] px-1.5 py-2 shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-fg-muted)_18%,transparent)] transition-[background,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)] ${ amount === 'custom' - ? 'border-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)]' - : 'border-border hover:border-transparent hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]' + ? 'bg-[var(--chrome-accent-bg)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-accent)_45%,transparent)]' + : 'hover:bg-[var(--chrome-accent-bg)] hover:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--chrome-accent)_35%,transparent)]' }`} > diff --git a/frontend/src/pages/Transcriptions.jsx b/frontend/src/pages/Transcriptions.jsx index cb36ad3c..9ab3237e 100644 --- a/frontend/src/pages/Transcriptions.jsx +++ b/frontend/src/pages/Transcriptions.jsx @@ -11,6 +11,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Mic, Copy, Trash2, Search, Clock, Languages, FileText, Download } from 'lucide-react'; import { Button } from '../ui'; +import EngineQuickSwitch from '../components/EngineQuickSwitch'; import { toast } from 'react-hot-toast'; import { toMillis } from '../utils/relativeTime'; import { useEffectiveDictationShortcut } from '../hooks/useEffectiveDictationShortcut'; @@ -159,6 +160,7 @@ export default function TranscriptionsPage() {
+ diff --git a/frontend/src/pages/Transcriptions.test.jsx b/frontend/src/pages/Transcriptions.test.jsx index 62f98ebd..fd4cd3de 100644 --- a/frontend/src/pages/Transcriptions.test.jsx +++ b/frontend/src/pages/Transcriptions.test.jsx @@ -7,6 +7,7 @@ const { requestDictationCapture, toast } = vi.hoisted(() => ({ })); vi.mock('../utils/dictationCapture', () => ({ requestDictationCapture })); +vi.mock('../components/EngineQuickSwitch', () => ({ default: () => null })); vi.mock('../hooks/useEffectiveDictationShortcut', () => ({ useEffectiveDictationShortcut: () => ({ info: { diff --git a/frontend/src/store/uiSlice.ts b/frontend/src/store/uiSlice.ts index 289019af..a021057c 100644 --- a/frontend/src/store/uiSlice.ts +++ b/frontend/src/store/uiSlice.ts @@ -12,6 +12,7 @@ * to the launchpad rather than half-load a stale project state. */ import type { StateCreator } from 'zustand'; +import type { EngineFamily } from '../api/types'; export type AppMode = | 'launchpad' @@ -33,6 +34,7 @@ export type AppMode = /** Which pane the Model Catalogue workspace opens on. */ export type CatalogueTab = 'engines' | 'models'; +export type CatalogueTarget = CatalogueTab | { pane?: CatalogueTab; family?: EngineFamily }; /** * The Voice workspace's "Define voice" method (was the Clone/Design tab @@ -79,6 +81,8 @@ export interface UiSlice { * replaced the old Engines / Model Store panels. */ pendingCatalogueTab: CatalogueTab | null; + /** Optional engine family to focus after entering the catalogue. */ + pendingCatalogueFamily: EngineFamily | null; isSidebarCollapsed: boolean; isSidebarProjectsCollapsed: boolean; sidebarTab: SidebarTab; @@ -98,10 +102,11 @@ export interface UiSlice { setPendingProfileId: (id: string | null) => void; setPendingSettingsTab: (tab: string | null) => void; setPendingCatalogueTab: (tab: CatalogueTab | null) => void; + setPendingCatalogueFamily: (family: EngineFamily | null) => void; /** Navigate to Settings on a specific tab in one call. */ openSettingsTab: (tab: string) => void; /** Navigate to the Model Catalogue on a specific pane in one call. */ - openCatalogue: (tab?: CatalogueTab) => void; + openCatalogue: (target?: CatalogueTarget) => void; setIsSidebarCollapsed: (collapsed: boolean) => void; setIsSidebarProjectsCollapsed: (collapsed: boolean) => void; setSidebarTab: (tab: SidebarTab) => void; @@ -127,6 +132,7 @@ export const createUiSlice: StateCreator = (set, get) pendingProfileId: null, pendingSettingsTab: null, pendingCatalogueTab: null, + pendingCatalogueFamily: null, isSidebarCollapsed: false, isSidebarProjectsCollapsed: false, sidebarTab: 'projects', @@ -148,8 +154,13 @@ export const createUiSlice: StateCreator = (set, get) setPendingProfileId: (id) => set({ pendingProfileId: id }), setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }), setPendingCatalogueTab: (tab) => set({ pendingCatalogueTab: tab }), + setPendingCatalogueFamily: (family) => set({ pendingCatalogueFamily: family }), openSettingsTab: (tab) => set({ pendingSettingsTab: tab, mode: 'settings' }), - openCatalogue: (tab = 'engines') => set({ pendingCatalogueTab: tab, mode: 'catalogue' }), + openCatalogue: (target = 'engines') => { + const { pane = 'engines', family = null } = + typeof target === 'string' ? { pane: target } : target; + set({ pendingCatalogueTab: pane, pendingCatalogueFamily: family, mode: 'catalogue' }); + }, setIsSidebarCollapsed: (collapsed) => set({ isSidebarCollapsed: collapsed }), setIsSidebarProjectsCollapsed: (collapsed) => set({ isSidebarProjectsCollapsed: collapsed }), setSidebarTab: (tab) => set({ sidebarTab: tab }), diff --git a/frontend/src/test/DubHeaderActions.test.jsx b/frontend/src/test/DubHeaderActions.test.jsx index f9a9af2d..72fea408 100644 --- a/frontend/src/test/DubHeaderActions.test.jsx +++ b/frontend/src/test/DubHeaderActions.test.jsx @@ -1,12 +1,21 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, within } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import i18n from '../i18n'; import DubHeader from '../components/dub/DubHeader'; const t = i18n.t.bind(i18n); +// DubHeader mounts EngineQuickSwitch, whose useEngines query needs a client. +// One client at module scope: an inline `new QueryClient()` would be a fresh +// client on every wrapper render, so a rerender() drops the query cache. +const queryClient = new QueryClient(); +const wrapper = ({ children }) => ( + {children} +); + function makeProps(overrides = {}) { return { t, @@ -37,7 +46,7 @@ function makeProps(overrides = {}) { describe('DubHeader — polished workflow actions', () => { it('keeps all three actions visible, ordered, accessible, and wired', () => { const props = makeProps(); - render(); + render(, { wrapper }); const group = screen.getByTestId('dub-primary-actions'); const buttons = within(group).getAllByRole('button'); @@ -67,7 +76,7 @@ describe('DubHeader — polished workflow actions', () => { }); it('wraps and stretches actions in mini shells without changing their labels', () => { - render(); + render(, { wrapper }); const group = screen.getByTestId('dub-primary-actions'); expect(group).toHaveClass('flex-wrap', '[.shell-mini_&]:w-full'); @@ -77,7 +86,7 @@ describe('DubHeader — polished workflow actions', () => { }); it('exposes verification progress and preserves disabled action guards', () => { - const { rerender } = render(); + const { rerender } = render(, { wrapper }); const verify = screen.getByRole('button', { name: t('dub.qc_btn') }); expect(verify).toBeDisabled(); expect(verify).toHaveAttribute('aria-busy', 'true'); diff --git a/frontend/src/test/LogsFooterClearFailure.test.jsx b/frontend/src/test/LogsFooterClearFailure.test.jsx index 68f09a2d..fda0480e 100644 --- a/frontend/src/test/LogsFooterClearFailure.test.jsx +++ b/frontend/src/test/LogsFooterClearFailure.test.jsx @@ -9,11 +9,17 @@ const { clearTauriLogs, toastError, toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn(), })); -vi.mock('../api/system', () => ({ +// Partial mocks: only what the test drives is stubbed. The footer renders +// more consumers of these modules than this test cares about (engine quick +// switch, compute chip), and a hand-written module object silently drops +// whichever export they gain next. +vi.mock('../api/system', async (importOriginal) => ({ + ...(await importOriginal()), clearSystemLogs: vi.fn(), clearTauriLogs, })); -vi.mock('../api/hooks', () => ({ +vi.mock('../api/hooks', async (importOriginal) => ({ + ...(await importOriginal()), useSystemLogs: () => ({ data: null, refetch: vi.fn() }), useTauriLogs: () => ({ data: null, refetch: vi.fn() }), useVisibleNotifications: () => ({ notifications: [] }), diff --git a/frontend/src/test/LogsFooterDonatePopover.test.jsx b/frontend/src/test/LogsFooterDonatePopover.test.jsx index 7e7a71f7..4ce4994a 100644 --- a/frontend/src/test/LogsFooterDonatePopover.test.jsx +++ b/frontend/src/test/LogsFooterDonatePopover.test.jsx @@ -8,14 +8,18 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, act, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -vi.mock('../api/hooks', () => ({ +// Partial mocks: the footer's other chrome (engine quick switch, compute +// chip) keeps whatever these modules gain next without this file tracking it. +vi.mock('../api/hooks', async (importOriginal) => ({ + ...(await importOriginal()), useSystemLogs: () => ({ data: null, refetch: vi.fn() }), useTauriLogs: () => ({ data: null, refetch: vi.fn() }), useNotifications: () => ({ data: null }), useVisibleNotifications: () => ({ data: null, notifications: [] }), isDismissibleNotification: () => false, })); -vi.mock('../api/system', () => ({ +vi.mock('../api/system', async (importOriginal) => ({ + ...(await importOriginal()), clearSystemLogs: vi.fn(), clearTauriLogs: vi.fn(), })); diff --git a/frontend/src/test/LogsFooterNotifications.test.jsx b/frontend/src/test/LogsFooterNotifications.test.jsx index 52aaefe1..da8721d1 100644 --- a/frontend/src/test/LogsFooterNotifications.test.jsx +++ b/frontend/src/test/LogsFooterNotifications.test.jsx @@ -44,7 +44,10 @@ vi.mock('../api/hooks', async (importOriginal) => { useTauriLogs: () => ({ data: null, refetch: vi.fn() }), }; }); -vi.mock('../api/system', () => ({ +vi.mock('../api/system', async (importOriginal) => ({ + // Partial: the footer's other chrome (engine quick switch, compute chip) + // keeps whatever it imports from here without this file tracking it. + ...(await importOriginal()), clearSystemLogs: vi.fn(), clearTauriLogs: vi.fn(), // The poll behind useNotifications — the filter under test runs REAL. diff --git a/frontend/src/test/audiobookHero.test.jsx b/frontend/src/test/audiobookHero.test.jsx index 8dac815f..f3398368 100644 --- a/frontend/src/test/audiobookHero.test.jsx +++ b/frontend/src/test/audiobookHero.test.jsx @@ -1,11 +1,20 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AudiobookHero from '../components/audiobook/AudiobookHero'; const t = (key) => key; +// AudiobookHero mounts EngineQuickSwitch, whose useEngines query needs a client. +// One client at module scope: an inline `new QueryClient()` would be a fresh +// client on every wrapper render, so a rerender() drops the query cache. +const queryClient = new QueryClient(); +const wrapper = ({ children }) => ( + {children} +); + function renderHero(overrides = {}) { return render( , + { wrapper }, ); } diff --git a/tests/backend/api/test_engines_route_shape.py b/tests/backend/api/test_engines_route_shape.py index 90834274..a30d28d2 100644 --- a/tests/backend/api/test_engines_route_shape.py +++ b/tests/backend/api/test_engines_route_shape.py @@ -100,6 +100,14 @@ def test_engines_response_includes_new_fields(fresh_app): assert entry["isolation_mode"] in {"in-process", "subprocess"} +def test_engines_response_marks_environment_pinned_families(fresh_app, monkeypatch): + monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "pytorch-whisper") + body = _client(fresh_app).get("/engines").json() + + assert body["asr"]["env_override"] is True + assert body["tts"]["env_override"] is False + + def test_all_families_share_the_11_key_shape(fresh_app): client = _client(fresh_app) body = client.get("/engines").json() From f81ace68d144b3d78e09bc9af8af369139cc39f3 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:38:35 +0000 Subject: [PATCH 03/15] feat(engines): frame uninstalled engines as headroom, not failures (#1531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue's group captions read "Available" / "Not installed", which renders a fresh install (3 of 16 engines ready) as a mostly-broken app. The sections now say "Ready to use" / "Add more engines", and the unavailable-row details toggle asks "What it needs" instead of "Why unavailable?" — same information, framed as headroom to unlock. Council outcome (first-run seat): unavailable engines must read as more you could install, never as brokenness. Keys added to all 21 locales. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + frontend/src/components/EngineCompatibilityMatrix.jsx | 6 +++++- frontend/src/i18n/locales/ar.json | 4 +++- frontend/src/i18n/locales/de.json | 4 +++- frontend/src/i18n/locales/en.json | 4 +++- frontend/src/i18n/locales/es.json | 4 +++- frontend/src/i18n/locales/fr.json | 4 +++- frontend/src/i18n/locales/hi.json | 4 +++- frontend/src/i18n/locales/id.json | 4 +++- frontend/src/i18n/locales/it.json | 4 +++- frontend/src/i18n/locales/ja.json | 4 +++- frontend/src/i18n/locales/ko.json | 4 +++- frontend/src/i18n/locales/nl.json | 4 +++- frontend/src/i18n/locales/pl.json | 4 +++- frontend/src/i18n/locales/pt.json | 4 +++- frontend/src/i18n/locales/ru.json | 4 +++- frontend/src/i18n/locales/sv.json | 4 +++- frontend/src/i18n/locales/th.json | 4 +++- frontend/src/i18n/locales/tr.json | 4 +++- frontend/src/i18n/locales/uk.json | 4 +++- frontend/src/i18n/locales/vi.json | 4 +++- frontend/src/i18n/locales/zh-CN.json | 4 +++- frontend/src/i18n/locales/zh-TW.json | 4 +++- 23 files changed, 69 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ad867d..d82353df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ the frozen-backend fallback mirror it for their toolchains. **Highlights** - Switch TTS, ASR and LLM engines from the status bar or workspace, with ready-only choices, memory status and environment-pin protection. (#1530) +- The engine catalogue frames uninstalled engines as "Add more engines" with a "What it needs" explainer, instead of a wall of unavailable rows. (#1531) - Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) - The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) - A faster, cleaner Dub workspace for multilingual production (#1489) diff --git a/frontend/src/components/EngineCompatibilityMatrix.jsx b/frontend/src/components/EngineCompatibilityMatrix.jsx index 0923d0de..d9998a3d 100644 --- a/frontend/src/components/EngineCompatibilityMatrix.jsx +++ b/frontend/src/components/EngineCompatibilityMatrix.jsx @@ -815,7 +815,11 @@ export default function EngineCompatibilityMatrix({ MUTED, )} > - {b.available ? t('engines.available') : t('engines.notInstalled')} + {/* Section framing, not status: "ready to use" vs "add + more" frames the grey majority as headroom to unlock + rather than a mostly-broken app (13 of 16 rows read as + failures under a plain "Not installed" caption). */} + {b.available ? t('engines.sectionReady') : t('engines.sectionMore')}
)}
Date: Thu, 13 Aug 2026 17:41:22 +0000 Subject: [PATCH 04/15] fix(crash): bound crash evidence to the run that produced it (#1532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(crash): bound crash evidence to the run that produced it backend_err.log is one file shared by every backend run, and it was TRUNCATED on each spawn. Both properties destroyed the evidence a crash marker exists to carry: a respawn wiped the dead process's final words, and an unbounded tail read afterwards attached the replacement's healthy startup to the old run's crash marker — the undiagnosable report in #1510 (startup lines, no traceback, timestamps after the recorded crash). The file is append-only now with a run-start header; each spawn records the byte offset where its run begins; and every death path (crash markers, venv-heal detection, restart-budget message, the 300s startup timeout) reads through read_error_log_tail_for_run(), which cannot see another run's output. The log rotates to backend_err.log.1 past 1 MiB so append-only cannot grow unbounded. Bootstrap-phase reads (uv sync) keep the whole-file reader — no backend run exists yet there. Fail-before/pass-after: the new tests fail under the old File::create truncation and unbounded tail. Fixes #1510 Co-Authored-By: Claude Fable 5 * fix(crash): flush the dying run's stderr before the next offset; redact home paths CodeRabbit on #1532: - the stderr drainer is tracked now and joined (2s bound) before a new spawn records its offset, so a dead run's buffered tail cannot be appended after the new run's start and misattributed. Full per-child offset binding is unnecessary: spawns are serialized by the #1223 spawn-once flow; the buffered tail was the only remaining window. - the spawn-failure diagnostic redacts the home-directory prefix — it is retained across runs now and lands verbatim in bug reports. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + frontend/src-tauri/src/backend.rs | 292 +++++++++++++++++++++++++++- frontend/src-tauri/src/bootstrap.rs | 10 +- 3 files changed, 288 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d82353df..ff16f3d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510) - Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526) - The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520! - The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518) diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index a207552d..fe133ced 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -262,18 +262,117 @@ pub fn backend_log_path() -> PathBuf { } /// Read the last N lines from backend_err.log for diagnostic messages. +/// +/// Whole-file view — bootstrap phases (uv sync et al.) that predate any +/// backend run use this. Anything reporting on a specific backend process +/// (crash markers, death diagnostics) must use [`read_error_log_tail_for_run`] +/// instead: the file outlives runs, so an unbounded tail can attribute one +/// run's output to another (#1510). pub fn read_error_log_tail(max_lines: usize) -> String { let err_path = backend_log_path().with_file_name("backend_err.log"); - match fs::read_to_string(&err_path) { - Ok(content) => { - let lines: Vec<&str> = content.lines().collect(); - let start = lines.len().saturating_sub(max_lines); - lines[start..].join("\n") + read_error_log_tail_at(&err_path, 0, max_lines) +} + +// ── Per-run crash evidence (#1510) ──────────────────────────────────────── +// +// backend_err.log is one file shared by every backend run in an app session, +// and it used to be TRUNCATED on each spawn. Both properties destroyed crash +// evidence: a respawn wiped the dead process's final words, and any tail read +// after the replacement started could attach the new run's healthy startup to +// the old run's crash marker — exactly the undiagnosable report in #1510. +// The file is append-only now, each spawn records where its run begins, and +// death paths read only their own run's slice. + +/// Byte offset in backend_err.log where the CURRENT run's output begins. +/// Set by `spawn_backend` before the child starts writing. +static ERR_LOG_RUN_START: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Rotate once the shared file gets this big (append-only would otherwise +/// grow across runs forever). Generous: evidence beats disk here. +const ERR_LOG_ROTATE_BYTES: u64 = 1024 * 1024; + +/// Where the current backend run's slice of backend_err.log begins. +pub fn err_log_run_start() -> u64 { + ERR_LOG_RUN_START.load(std::sync::atomic::Ordering::SeqCst) +} + +/// Last N lines of the CURRENT run's slice of backend_err.log. +/// +/// This is the reader every death path must use: it cannot see another run's +/// output, so a crash marker carries the dying process's words or nothing. +pub fn read_error_log_tail_for_run(max_lines: usize) -> String { + let err_path = backend_log_path().with_file_name("backend_err.log"); + read_error_log_tail_at(&err_path, err_log_run_start(), max_lines) +} + +/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or +/// no longer valid — an externally replaced/shrunk file must degrade to the +/// old whole-file behaviour, never to a silent empty capture). +fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return String::new(), + }; + let start = usize::try_from(start).unwrap_or(0); + let slice = if start > 0 && start <= content.len() && content.is_char_boundary(start) { + &content[start..] + } else { + &content[..] + }; + let lines: Vec<&str> = slice.lines().collect(); + let from = lines.len().saturating_sub(max_lines); + lines[from..].join("\n") +} + +/// The previous run's stderr-drainer thread. Joined (bounded) before a new +/// spawn records its offset, so a dying run's still-buffered stderr cannot be +/// appended AFTER the new run's start offset and get attributed to the new +/// run. (Full per-child offset binding isn't needed: spawns are serialized by +/// the #1223 spawn-once flow, so the only race left was this buffered tail.) +static ERR_LOG_DRAINER: Mutex>> = Mutex::new(None); + +/// Wait briefly for the previous run's stderr drainer to flush. A wedged +/// drainer (pipe held open by an orphaned grandchild) must not block a +/// respawn forever — after the bound we proceed; the offset then simply +/// includes whatever the old run still manages to write, which degrades to +/// attributing too MUCH to the new run, never to destroying evidence. +fn join_previous_err_drainer(bound: Duration) { + let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take()); + if let Some(handle) = handle { + let deadline = std::time::Instant::now() + bound; + while !handle.is_finished() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + if handle.is_finished() { + let _ = handle.join(); } - Err(_) => String::new(), } } +/// Open backend_err.log for a new run: append-only (a respawn must not +/// destroy the previous run's evidence), rotated when oversized, with the +/// run's start offset returned for `ERR_LOG_RUN_START`. +fn open_err_log_for_run(err_path: &Path) -> (Option, u64) { + let len = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0); + if len > ERR_LOG_ROTATE_BYTES { + let rotated = err_path.with_file_name("backend_err.log.1"); + // Rename preferred (keeps the old evidence in .1); on failure — + // e.g. the file is still held open on Windows — fall back to + // truncating, which is exactly the pre-#1510 behaviour. + if fs::rename(err_path, &rotated).is_err() { + let file = fs::File::create(err_path).ok(); + return (file, 0); + } + } + let file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(err_path) + .ok(); + let start = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0); + (file, start) +} + /// Human-readable diagnostic for a failed `Command::spawn()` of the backend. /// /// #144 / #127: when the bundled venv Python can't exec (the common Linux/ @@ -281,6 +380,21 @@ pub fn read_error_log_tail(max_lines: usize) -> String { /// process "never started" and we previously surfaced "no error output /// captured". Writing this to backend_err.log lets read_error_log_tail show the /// real OS error + an actionable hint instead. +/// Replace the user's home-directory prefix with `~`. This diagnostic is +/// retained in backend_err.log across runs and lands verbatim in bug +/// reports, so the username must not travel with it. +fn redact_home(text: &str) -> String { + for var in ["HOME", "USERPROFILE"] { + if let Ok(home) = std::env::var(var) { + let home = home.trim_end_matches(['/', '\\']); + if home.len() > 1 && text.starts_with(home) { + return format!("~{}", &text[home.len()..]); + } + } + } + text.to_string() +} + fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String { // Platform-specific tail (cfg! resolves to this build's target OS, i.e. the // OS it runs on) — don't show AppImage/loader wording to macOS/Windows users. @@ -305,7 +419,7 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String { Interpreter present on disk: {}\n\ OS error: {}\n\n\ {} Use \"Clean & Retry\" to rebuild the environment.", - python.display(), + redact_home(&python.display().to_string()), python.exists(), err, os_hint, @@ -379,7 +493,24 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt } let stdout_file = fs::File::create(&log_path).ok(); - let err_log_file = fs::File::create(&err_path).ok(); + // Append + per-run offset, never truncate: the previous run's stderr is + // crash evidence until someone reads it (#1510). Flush the previous + // drainer first so old buffered lines land BEFORE this run's offset. + join_previous_err_drainer(Duration::from_secs(2)); + let (err_log_file, err_log_start) = open_err_log_for_run(&err_path); + ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst); + if let Some(ref f) = err_log_file { + use std::io::Write; + let mut f = f; + let _ = writeln!( + f, + "──── backend run starting (unix {}s) ────", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ); + } let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())]; // Pin the child's OMNIVOICE_PORT to the value Rust resolved so Python's @@ -493,7 +624,16 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt // real exec error instead of "no error output captured". let diag = spawn_failure_diagnostic(&python, &e); log::error!("{}", diag); - let _ = fs::write(&err_path, &diag); + // Append (not overwrite): the run header above already marks this + // run's slice, and earlier runs' evidence stays intact. + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&err_path) + { + use std::io::Write; + let _ = writeln!(f, "{}", diag); + } return None; } }; @@ -516,7 +656,9 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt if let Some(stderr_pipe) = child.stderr.take() { let app_clone = app.clone(); - std::thread::spawn(move || { + // Tracked (not detached): the next spawn joins this handle so this + // run's buffered tail flushes before the next run's offset is taken. + let drainer = std::thread::spawn(move || { use std::io::Write; let reader = BufReader::new(stderr_pipe); let mut log_file = err_log_file; @@ -528,6 +670,9 @@ pub fn spawn_backend(app: &tauri::AppHandle, progress: Opt } } }); + if let Ok(mut guard) = ERR_LOG_DRAINER.lock() { + *guard = Some(drainer); + } } Some(child) @@ -647,4 +792,131 @@ mod tests { // unversioned (pre-app_version backend) is stale by definition assert!(!same_app_version("")); } + + // ── Per-run crash evidence (#1510) ─────────────────────────────────── + // The reported failure shape: a crash marker whose stderr tail was the + // REPLACEMENT process's healthy startup, because the shared err log was + // truncated on respawn and read unbounded afterwards. + + #[test] + fn a_respawn_preserves_the_previous_runs_evidence() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + + let (file, start) = open_err_log_for_run(&path); + assert_eq!(start, 0); + writeln!(file.unwrap(), "run1: fatal abort, last words").unwrap(); + + // Respawn: pre-#1510 this truncated the file (File::create), turning + // the dead run's final output into nothing. + let (file2, start2) = open_err_log_for_run(&path); + let content = fs::read_to_string(&path).unwrap(); + assert!( + content.contains("run1: fatal abort"), + "respawn destroyed the previous run's evidence: {content:?}" + ); + assert_eq!( + start2 as usize, + content.len(), + "run2 must begin at the old EOF" + ); + drop(file2); + } + + #[test] + fn a_run_bounded_tail_cannot_show_another_runs_output() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + + let (file, _) = open_err_log_for_run(&path); + writeln!(file.unwrap(), "run1: Traceback — the actual crash").unwrap(); + let (file2, start2) = open_err_log_for_run(&path); + writeln!(file2.unwrap(), "run2: OmniVoice model loaded successfully.").unwrap(); + + // The dead run's slice: only its own words. + let run1 = read_error_log_tail_at(&path, 0, 10); + assert!(run1.contains("the actual crash")); + // The replacement's slice: its startup, and NEVER run1's crash — + // and, symmetrically, a marker bounded to run1's slice could never + // have contained run2's healthy startup (the #1510 report). + let run2 = read_error_log_tail_at(&path, start2, 10); + assert!(run2.contains("model loaded successfully")); + assert!( + !run2.contains("the actual crash"), + "run-bounded tail leaked another run's output: {run2:?}" + ); + } + + #[test] + fn an_invalid_offset_degrades_to_the_whole_file_not_to_silence() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + fs::write(&path, "only line\n").unwrap(); + // Offset beyond EOF (file replaced/shrunk externally): evidence + // beats precision — degrade to the whole file, never to "". + assert_eq!(read_error_log_tail_at(&path, 10_000, 10), "only line"); + } + + #[test] + fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + fs::write(&path, "run1: early line\n").unwrap(); + + // A drainer still flushing the dead run's buffered tail… + let p = path.clone(); + let late = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(120)); + let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap(); + writeln!(f, "run1: buffered last words").unwrap(); + }); + *ERR_LOG_DRAINER.lock().unwrap() = Some(late); + + // …must land BEFORE the next run records where its output begins. + join_previous_err_drainer(Duration::from_secs(2)); + let (_file, start) = open_err_log_for_run(&path); + let run2 = read_error_log_tail_at(&path, start, 10); + assert!( + !run2.contains("buffered last words"), + "old run's buffered stderr was attributed to the new run: {run2:?}" + ); + assert!(fs::read_to_string(&path) + .unwrap() + .contains("buffered last words")); + } + + #[test] + fn the_spawn_diagnostic_never_carries_the_users_home_path() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved = std::env::var("HOME").ok(); + std::env::set_var("HOME", "/home/realname"); + let diag = spawn_failure_diagnostic( + Path::new("/home/realname/.local/share/app/venv/bin/python"), + &io::Error::new(io::ErrorKind::NotFound, "nope"), + ); + match saved { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + assert!(!diag.contains("/home/realname"), "home path leaked: {diag}"); + assert!(diag.contains("~/.local/share/app/venv/bin/python")); + } + + #[test] + fn an_oversized_log_rotates_instead_of_growing_forever() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("backend_err.log"); + fs::write(&path, "x".repeat((ERR_LOG_ROTATE_BYTES + 1) as usize)).unwrap(); + + let (_file, start) = open_err_log_for_run(&path); + assert_eq!(start, 0, "a rotated log starts the new run at offset 0"); + let rotated = path.with_file_name("backend_err.log.1"); + assert!( + rotated.exists(), + "old evidence must survive rotation in the sibling file" + ); + } } diff --git a/frontend/src-tauri/src/bootstrap.rs b/frontend/src-tauri/src/bootstrap.rs index a1c04929..e02806e5 100644 --- a/frontend/src-tauri/src/bootstrap.rs +++ b/frontend/src-tauri/src/bootstrap.rs @@ -338,7 +338,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc Date: Thu, 13 Aug 2026 17:48:38 +0000 Subject: [PATCH 05/15] feat(launchpad): wear the signal-field waveform in the hero (#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same cover artwork the project uses on the web, bleeding in from the hero's right — where the layout holds only air — behind a radial feather plus right-edge fade so no box edge survives, screen-blended so its dark field vanishes into the chrome. Decorative: aria-hidden, empty alt, pointer-inert; 22 KB webp bundled via vite. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + frontend/src/assets/signal-field.webp | Bin 0 -> 22862 bytes frontend/src/pages/Launchpad.jsx | 17 +++++++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/signal-field.webp diff --git a/CHANGELOG.md b/CHANGELOG.md index ff16f3d1..83497edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ the frozen-backend fallback mirror it for their toolchains. **Highlights** +- The Launchpad hero wears the project's signal-field waveform artwork, feathered into the dark chrome. (#1533) - Switch TTS, ASR and LLM engines from the status bar or workspace, with ready-only choices, memory status and environment-pin protection. (#1530) - The engine catalogue frames uninstalled engines as "Add more engines" with a "What it needs" explainer, instead of a wall of unavailable rows. (#1531) - Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) diff --git a/frontend/src/assets/signal-field.webp b/frontend/src/assets/signal-field.webp new file mode 100644 index 0000000000000000000000000000000000000000..42c862c1fbb63990fd006db0a1d00d88b740f21d GIT binary patch literal 22862 zcmY(pQ&MFhq72z~7Jjy>;YY9hDUPXE2R z-v5Qoq)pxO>6ZTK33v@I-F1Q&iv8(9@O=-4`f|?&kxv|aXh%JEFg*X)pr1H0x%B!f zver$%Gg^M$Y)35mJQaVPNxaP_-Q|$&2`?J@!gjJ-H^L=FXXbZ99=c>UL5hUsS_bFW zGB(xwy4L7r+#7fAPk0O@KBXy@Kqq54dDl_ytfUH36ikanhaqha758s!zRv@!(EQ&~ zK_q98m}-RId+iKN2UEGvU^04mR1)>4pyUkebGL}Zyo=f#Qx$#ft~fugOJHr}nW5B7 z`xBlOBmdj=$otQ8+eImo=QTwcLWOP}E8xlom4I}SzGM*u(NM{;hOo5?<7qhR-V5;i zX5WAFJUUn`zh{%@f)If%2J+*NY30pBXYa*-3A4oc^Iw25<{Hi&OLfWYB%z^};=i7~ z|Bw!KsUW8teAX{P9hLDXy` z9V$yqzQWQhB!-k|;}Jdg61FJ;iNQz1&-6im!i}uudY$vUFZdAqZ6ZESeRFrhPTbfe zzN3LEn4tkSK9^1}1M+fgQxy7VIRFS1tMVVC8b5Ggn?_-L7`Q#;cM&813$AN#h)?{# zQ|>4lx~-K?iErZ-u~s$zw}^-6FR}w?x#u|!rdOjd%VGp(RmycPyWpg9v?uEuF~K#inp|G%3HXwD~a^Zi)#EzYYP=6)sbN8@s0 ziRHOyv#i|7r~7qUkZEpxcJlp!k{TQ<{j*tGJ{C2{RtyywJyq^U!&jKCJul(^7*w4r z+B9P-w4SP|;pigZK+Uqo;Py3F&`YHoQFFq!^DBe5VK_x(h_tgwr~D@tsK&sFxS|79 z$bbn>u$uXkn|iBZT9~Gsol|)uLuq7PBhbh#a5kLWv`N4|*_ye!o7oMs0}dQCe}`Ri z^dw=k%?bCxP48H9?ee+9)TyACGTuj(;A{KeYbBL{f(2X88$a6NAz2tmX1Q@8sy1lx zayQBMnPQDp5(FoJWP{u3x4&RiZb?&>c}9Jk|3HIMT{gRuaU!~62shNDDrpmtK5x_$3y)oT zL!E$EHQF8+qY!UnbGk#1irF|teJ%)jy~Id02;M}ySuztv$Wb!30OC*dOai>k_%0iKS0VraC6R|1tx<$k~dE^(i5 z^~PorUa@pIy{3NK0TmlJ4OsH%2yt?&F@2j@boK5r7v?L8;&^u5MQ*!fm?f2P3bh!- z%To1N;}t45MkZY6Nf3em4^)F}W2#=q2*2wFF{d76E-i;XH%WCjG&2%I@mZUrI`gxI zGoQ=I@zI`0;$XL6bHjoK7>7wBBo{79rDjzBmx>Ka=-4?L z{i7c7m`K6M$m{xz8n(<@UMv9+!3^fXTrY;69+Uc5Qq-CZ&p2cFfe4|P43*5lhhbft z#uB*66-kOlz6SbL7%KpQOc0ObiR|_KeamP!KV3Qu3H;84`X#v1|K!k^lN!zRC@xU~ zMK!&;JxL$&(o$K;m>GDFN6OD%B_p7cH3=s?ty(kxKQiDnUddk{*({UGT(Kbwx=gG@an}C1%JS!mG5j_iLC?@6kxK=?DGAC(P(=q_QPD#3BHXUkrd#$ zuV7hc=mpNvA^QON;b=wSwcrC*8gFzZJuSD_@st`EVu|jkBKN*cj_-ZOW-SU;G&vwQ zeUs+ZM@Xtvi&iVY2n{>zIx%cx3V+m<27^{R^~Rv%WF6U5dWV@9khsTBYjOZJrgeJo7QRxN9}2Qia;iKp9Snp)PLCEmJQDayvE)6uSJEnzol z(f9dkkm+mXTV3%r4p|kUl3SX zP=>B`{z!k|Uml{I3Eg~m)m+(SYYl{ByWpaETO=>q5)|Hg@vV)Y_`hfp&z`o?<@)v( zZKdYx?hUD*n1eaoY6+`t=VDyc`dp}3DPY1)8iTbg)Jt*K%K$=)R3{+WI^?zUGV%e-(lEX@C@RckwnLwHF7!)npK>b61o7PCF zd~s`D(;qIobpa&ItcAJO%=07L2jl->xb+&6*(ccX;{1gc$R}l!My!$b^AUvu2WQ&S z%Tr$d(d|Xwo}-AWWlRkJV^4G%3Xtt?Q_>&fo+YD1k#pc0dcx^=7L_R?`h&Hon1Zkh z2{-LryA$yL__Q6$6!fQ9y!#WxN`P5ZGBTw%Su^Ii4^Hm{&4UqtyFGgw>i1J(@b03= zUVo2m$MJ~VhH&a(P$WZ<;LdR!e?h(dUHW*C#$ODs=DwFs)cqd-qDfSmMrZF=v zIjB=}IP zP0orX13xt7PeXO?MG7!tE?(K+&qlR${d3vFX~YLj>d3LwUDm5ds{$# zV$c*U-_qJQU;f)OnrtR0PRvWMZl#gxLol4=iYD&gq`Alg%k$jhEP8UXH>&J_0F?ro z;3D*$NOFn9UwqWC32@ji)kF;8%VPSs;sPcSW)EW6rQ$0FDJmQvH+&$E9oH}tKSICU zpHLN{OH`>PZWSn?6)P-_TBNVYH`g-XLf|kaNxTzpGY$$6Y{GGk4w2ArkXJxflLBcR zLJVf2+k{_GyTUYG-Menz!TPE0^&}wC%f|Nh2^Wx7?P=F(C?Xcp)YxIm=yIIYMQHTg zb=Qkc0fU`PI){zaz^T9?%AP~6?ECR9we3xJC$Aj#4m|VL!NAS2F#$%>P0&_G8{WbB zCnZaCfj6`?S08RQn_Y73h0q~0=_fp3NS=0@S$wkf`aRHG{7QPujt0&%>Zk3n0aeuE zvk; zW+u{J6oje%iBS_j(J+0JXsKW$GX6hdxij9!h9fH783prQB312eUy=(397~Q%bbiQ) zhAEKi)f9NbE@A9rTgJJ3N)jgWi2McQQ|!$|veYTo0rB|HNSKFeqoudUJ2tAmJ@b%W z7FA;0Ux2J|jrwMFtu-k)Lg=i4BG}9?-0)&(QLH2%#p7#)jjdcu^Gt43%V0@My?HMr zPD>jg`q`a0a7DGkZrivIKekXyJp5|Wkq zXW|KYjx`Uq&at+a6BHVT?T&t0rC7bez2r$LN2^?Zm(7|#skA|e2eM)lu&H2*PpKtE z`H?jOn*jQP9DskALGb`87x?<4A(~-a7x+ck`rv&94cY(X}EB_ zhJWn2dqAnGdwRL)7PLOnHIGYwQe17k^C*TO9$mD!_V|CKZ9*~6Nz|j5)7^b%a%6qo zQ8kc~4j~juJZN`kcyQmuOvc;sk`s@n_H0jjX7azOtF&M)x|0E^+Cx{%CAJSVJ3J-E zPo2-T$00?M`9DlSRL^geX))ajeolgal_N7^6V2ch?NOw_Rx`r;|PE`0{rECK*JfBNhhHfjpJiimE2)fYc~-X_k(VbB#lvtg2W8mhmF6x{%#9S1X2!@##Q`n z-l&xlTF)1UzLzx5UvcC|j@2xnVp8t^aflGEZ94^)56L9i*#Ep1r`#L@>j+e>R9$a8 zf&Vfx%*IIVNAuG7oxqlO^pDy;pKJzHmD!o!IitBQ;y%x$GNFdo-^W@DuusXE4-s|} z#;8O!fFhr#>UFx63Y?4g>}VTN(?B!0A{0OAfK`y=4Ziy<@~Jyg8V$Fi;tFuCN;VLT z8e&hO#q57#D~Ie5F8|SFfaDB#hXb{64dbwMA(>)VLLN^^r+mr;oXxM%?}AUAKcddQ zpoO>}8@LCPVpj28Jf3(r?H8ChlHkU3Z*?R;;dyLs$CpIG82F5Um#C!$V*b{{Elc6- z9NeCM;hbZu>=<+?%QT>|1C0at|Ej&PiIS7dJ{#0t=lr}RN`*b*aF0$FMIZHRMJ9}l zpMw%LNdwtZH^T*gNB*wKWD|{*OLIm+78y}o+$lCVOG4|F#0@zfZ`W+#Dn=ym4RE+$ ziSpxX8ZtoMt3yaZx%LO-+|H~2`P`_7uTSU$9pbv?g5FQ`9FFC*j`L+dofly05tbT@ z=V{h#^~jAB!q0y*L&6=Wbe;6=t7<0{dv|E$PqcUm?^fxBpHmd6P;e~UP@P;e&_&~6{QD`=kMaBsfX_iTKMYl3k(J(i1$>=h>#;q>0#OgPuEY(Mzp8muYRKpWWHr z#GCRL_5g(Rm{y^}9tqw>QVPJBBNhPQl3v>PNj8-c8U zVeV$L`XuRw{2XZm-xR@G~s!(M>s0E0q*Z#&>Gl5C7-vc|S?# z4hf5_0QLq&Q>HN9p3D-WmEo%P%MRJm6fv!=fc_-|bgAuR+$a3E{!^K93s05WO{^qU zV<#XvJ<=sNA(cP}Ab^Q>Z3ABC)NdW`(Qe7SAQ}I()Td5zm{xMBH^!%SGDtoxO7*m4 z%csD*RL!?7Yt#pwOOvbFg~lQ4RNgRwKfv$zf|Du&=y6~V1gdOk%yKZ-zp2p#1=4w_ z&WwByIKKDeksswSze~@^s}qUg1f_ncSZc>=%v5iO&^v8f+VpM!TVn*$1Xg?;aSBoJ zn2pN@Gxea^K}`5*QV`bZh`9(qO0Zk=eMjLyoKgo0bZAc}??~Np97j=l z05@>}3P5|t?bjcuLn{!ko6&MFeMkM{>_@Bywl)q(tIPrS$|XH_^-S@3DZJq4at?z`p1pqG*t@B}(zDasAuIGYBcuS3T07j-Xje^d4at zdb(@?0ATRuKINWi!L|&`onzCTTgD@P4j&eN0qzrNs7>1DD6Ln;h@O7>7W-#vIp(yd z=L@9^P}@Z3Vs!u2S*`N&ZcAUNcB2F#gJ&x8le6|Js;|xiWAh0NCY9sse8#&gAe&`= zKkTJA$7Rets4NkVIHsUZfSi!+c1OKJAcai8Gwa zngrBS$|D|M%hUbEi>Lms8h?qEKs#|}xR=TEJ)y_!vnLh=>8xxMy?o)2red43)-VmA zVC2VpOASP7<{*x|HfYCY+=wK*W~&AhdT{h^_LEl_Pan2qWv24>3;np@I8}>+50@oo zT8=l2cb~D{`%=jr48Ld~ zT)BGf&QZ;8i-mqq1C#~#duhg*Av8Sr!){mA-0Du08R^a@T+?i+aLS2Lb=6xDZBPWSb=VoEbU@0t+JaM4_=E!xNL=kA&>XfJ!zyL%25^j0U$m$8zD7lIL3qRJKw$& z>VU0EfOo+bLa7S@73i!ZZS@FeX&Wz%PHD#-*^E znfVZiadJZXh265b${(lcDOlfZdi=L8L?*S#hQX&H{eKT&xgcYN8+vL_Y#EC1YIgnt z{)VvY>I$4d`s)rTQ=M5oArVn5g&a+eAUOlFAcp550H)KGOtgf&_x*1(dYuXFL?iox zBwIp0L_|B;zL{fS$-)^ZJR{JL3gJsa!jJ#X~)gQu6$!|9<~byqqZ_7(VW!T=vVF>KfAn9)Eg>2XJ*t|A5i z)4>p>iKy{vA>gLLa}>SY(bU7~{~73#y`X)1Z}1 zTxYPw7!Rq3+Pom;M+)%A;U>BkMv+|b)HaZ1ZgTCLdf>*}px2qYc90F;vFrXEgPz1C znN=X2#Q9+oLg=Z^?3GeH4yts@8of%d-lh=S?spNziT)T}V8S0H^5#`O^Yie-Mb-kK z0|Dsw;|2`Ez>q1?qB6FP8I@1s9*N=<7KJjNFIFNYn)|{FEWYb<6?Ed*;$(DM#ao3wt%`bH$DyGwuKnJwBO< zf~zk^9rTvn4J1eZ5yJ5)Wk+>gL;N6d&sjCPK6Wp}LRqihUrQ@)7xJlvKmo$W6) z0DOQcCI$~qa5xD7R)t~{$PiN4x2MwYM?PG^QR>NJLo{5A#FfGiT$);6x3EAHL# zUDPz^V-Ci0X%e*Ch_ut7ObHeFAe7M+VrzF_#X;zVkC$l?8iT~Uux21SMA`^Ac9`ZJ zF*L``LFQvvr9Z)_8iYe29p1ycGh4wa*tO(RyVS>}rK#ub()#bgQQ|ZcRlW?HXow&A zEh~Hj^v^pG)eMs-D$kQgoz&_2uHr#h+wCyK>K%)#COYXr8FT?iqB?n9j};K z28+axnb#yc^eL>YG~XN&%(YWVlr9KQf>lQa`rNt@%NF58)Cxi5z(OLnrger9Jpmj2 zyfbmw{NRM*1psRNCL;*l z2W7N@QWTuDL3O4_cB6=x$oa^CO!4fH{$?!yZ^G4ZW;w_o}l(CaU&PP1D4B zH>G-}p3G`D+aZwF3VD{R5ynO5J=0`%o~As+c*8%rhrBvoYv;|+aFI?4U!T_z_kR|_ zUQJxjrE~RxES^liR}p*-H4)J`#2iX~Rk)mdQuojeRlGFomYlFkun=-a zwqKCvx#4dHzLDYqhTRwriUanuQ$pk~2OmB87tjJ2^908vQvuVVLt!E*v_FJ8FzB5< zXa;~t-m2`Y=`1Z+&J!#9_N8?}?ywiUSh6a)l)jIeG8%3*6FHlkC{iec@kYHOGaw>q ztlE@-y-zI{UJ|O;4*MCsI0eDjwX|Vz4Hz6R8~TC~tBmGyZ;;uy-yq^k5a&P1E04J$@UAm5o}~s9MfIw zQSaj?dT5(bO^o3NynI$J;wj@aZY zn+O#@d>CR1cmeK6FdW)lK z#R!BoMifwOloyl$Q~nLVMZ8lwOY^BeUNe&Oyp~d3qACpBwIxBa<}h+50nZ@qEZe^%0Zfi6w7R5)z+K93jg6*ZdRKM*Ci zUO}u)_P6HSRgOB~8&O>`2lzmQ-HHcTigf_k+>E{+JfbFxNfYpQ+zcmwu&#-AHh2sq zU=Uu+oG%U0flqFy$!Vdq+-sI1-(R<7kD{>i+Mfh(fg|2Ra0517@VbS!g0?(B zyr%B^EMl2BQb-PDz6e21#vW(`emdd#BMNWrluj94HVMv}W=V?0yT1aUY)yEb-LCOR z13C=W4Q}M9?#7+{8WJ>~9JsJ4+(tB&&Sv_Xu|cRN;AswL5{@CG^T@YqfBJp2oi2;M+0Bq_8l(GP8arKPEwKQEl_r*sZo$lLC=6Oj(=D(Ri=K7ChS!drH@1x zdBTy__nI#1>LeJQn2P>O{w=OHM&&-Nmqx#;vAig?hU-%fC~dP{>FS?9E>NY2paly~ z(aL~}t)9be@80rf-^z0!ny{e5`QgLe5?}OC`>anADC$>Z%80MXw`?mg*`rB-Z}*{< zDkWXS!{vD=``-z@ig$@{%w*bppowCo&N7-=wL#vQ`^65$hNt|nVcZidBv!> z#iCU|@X&C1iOm8Tp%>`{(J*W{hHd?B4%5<*5+Vr$liHK!&CXva2Ukk?-;nY~%9SkA zUN@XNeg&R9 zv%@X3I-D&n*Z4Of=!12EW$)4kE2;H~s&l-j6qsv9WU}qI+Go?2j;I{QG6*R1%WDu9 zz8Rc5kp#%I_wB9m19I$}gTFAdL0Dwo;Ka zg|Z`B@bn|tDA{5eE9g0_pJ8e6RTfSzBPGRfd39pWaRa^L4;t-rwBc)q$=m$e{uSJ` zEN%D-!f5DB4QrtY-A?D-rlTeyCS?>cavT@$d-p=N$irzC4dGD>rjO5}Wl48A;s@X- zPdD-+Fa4Uh_dUWwnpUfb^Y3w$hPO+;CnBR2Glh{iG#U%ti@JFLOT3C!H~Y1>fOAJvcV2^Gp~VIw9l)R_z~X-@+()yw zn1Mz<<4)>REFY5Yoej{vdEGc!nZCiY!{5tfT6VBJV~h#I3Z*{OJj;6EVFzK(ARf+< zv!-60H1)tA2iw%_+6J1;Mo-_(f1L`YU`G6=`GV>2hudpEXo#76ROG_=Z~QkD){S6U z7hoa5khU>#J$hx`&mXv|k_ZBe%`cEIo5DIh%I$iuN&m*tHn?X&nJQ?palp!RPMehe zC33rzVpKV7Nm07iwK&M8YHXnW@#h~b;b8}Gm4tg9tE~r3riQz@aep%+Hc4G@&lkV zrqqW3a=leV3~L=k#Y^!HIwvI&h61Z^JN-RWw^5rPbMAw3+~7T_E0DN}O&N&x(PB#6 zpxChAeh=5Ij8|8*CZQ+J%yo*DS}Ol|ibPuChnaIh1C-TXE_%TdSmXhT`DSUq2P5q*ehLzMz0ee}@rJoW$REN$Mbd-jB~ zW?*|majOrVFgl?sddA?Y0rN`O0s6CtZ*lbuS}tdrn0kIS70uq4`NMvhI!y1t|1*Z` z$Dx|l=RW8OY&bO`rXvZgcGjpEv9^&q)P%ndL(}`d&b` zqyYL!b4+8+l>+ewb!y($BttSmwqF5SGRaQFXbml|Mfyv~dZpAuNJzsjlkMcs807Gnv406}W8-Dl(kNefWf{hMk~vEj~m-4b_qth^~7`N8mF^QqAj1(eLW@MihdTXz^JPmYTOOdAcdd?SIRhQ5rLZRqxvSD9TnM&Qh~t-TxqV0 z$9yTu;%xmoA@=ZU>n_Xu;_Doe#~pX)_Z3{JHMX)7#i-ELg_*gcHaz+9W9a4i*y4W_ zlZzeo2gX6j#zlB;$~I&&sc~dri0HtP{@SPQmTDCNSbO0i!{I5)K$o2|!xey(P}_7M zMWzzIJ7}qsbI_g`e*N5qR=^b#3WkM@d2pfY=X$iufm}QyV!CmU;21Y>+Qw^DFoUX? zNxWT47+v5_$yV*0%;nNUfG!JF=@wfEr#qpwCdG0CZoJgZp)Q9>k!K;uld=rAk$Xe2 zVcw7PQ&f*=8n^9hf-w_W@I*-`;pOa-1q-ZEud))t0jn{*Oo|@C(j>AewBJY5QA~p> zjZ&==`v5zJ<9w1e784c453M(l?@YATz}q}SgitVkh8*ui8Wzv=ku%ba8ePl;>P{@4 zO{4)OPvVCyEyK)!ZXnxUzVhy&+ESp_XQE%uq7gAyaM8iwtYkMZIkHt3%_Baj-LmPp0c}^H%UfEzE2n47L(^eL0DAGS?{m zm__d63REXkfeeCcv+Y<@QB775HQnijvN5b_AyxvDP`v;%<=>4w?fFEn07Lz(Kbgnq zQJ1j7rztDOc)h?LH@Rlj%C2Q?ORgI_paXR4M`oDnZ@w> zrV{E^n*1=`^IaYi4X$TUyKqkpV?{J`H-=J3MCi#-YwfG+G3OX@iaBxMz`mG{HM@#< zZ^qITT0!yw3ol|UlL%)-KwTCqK*`{^^Jn^ivaDaJ@~(Sq4JBgQR2cjCJjaEfWO+{_YmMx^g!T(x=W26A(jH<34bpa(*KR zey}hQ=g=%0iGaR99PJS5$EQ^eua8T7PQFx6SYom}SAC5xtqF>^0s04oJ9Qkf+<2&A zFA$?#X!7A!E+BYWZ5Lv_VVw)&QvSEN;?=%ekcL*cGDB%8F~0?0bZmu_PiAoz?OLuM z{}V>4@q@Mt6Zf0n`?(0+dT_M73q*z|PUb0V*i$Q2;dTx~q6L`WM%z-hs2U6lFQ}Q`|)% znJg&(LoFXh^c2rKcZYrUG+TfI%O89luKMNR+3!}l&2|~%e zSPBi4xtoX@b)dY2gh}=Gv|a2mV6T4kBf79pEd5PbNjLjlyx>g0MSsXPYl1@#V)c)9 z6XezT1hBi@<`!%l#ILZZc_Kb5gT@@*RL!XYe%2T~#xG*Hg;Y2Og^fZyfLp`&#wqNp z^wv6bdkCWnwvEP|Zg$TmHV}m*0$J}?z)m;R)LKiOZx@*bl{OjWAht3@eX2A+ar*?g z615My*)`eEh@=R;1xm4A}RH7n-5=*R5>05s9$r!!UL4eEytVn{9(!saT zM#>vJ^fd-`wRbB!51FraTI;*6197$+HfH9;#w79$A<-~;%XH6MP}HdQ4gvxvZ=YUq zBYr2?I9R0t`uPg3YuB!Uu-Nlhv?!UPUk3#H4G>OGu!xe;*eu}rg zKukNUd|>igjef|Kvl^htwwG00m#-3S-(E$!AzIK3f@csvSp$DXnF*W=TH71?08#DT zr?Qa|ehy;Sog6Nxdic|Ny8VXb%$sUr5But=G-#Ha@ye+0BiBx2eSwln&Rz(>6M;H_ zpemU10cLdIB8y9Wpn6U`5d6H1d_6NG#L9`7JH@14ekhn2f+Q@;M?!?uLhvRA@oVp( z#xb@u5QXz#;IaPm3eItoQd7Q48f@&1eYnKjambgxD+|Lw7h3`wYk8&3VdUSQ znP37fM{VP0b8C(6rPs+9{NiA&G6_ox636m5Z{yef{YY}C4KJdRG5*PUI)Lzteb0+ac> zd;L_{B1#IA-QD7B-{A=G$0wb4aSI60#1%T*37UaJsRf^=*VA#zWv+XnEmQibiRHj_ zIu)Q2-nMqEh2x(cQbtwV7QbeO!mAN7su1oLcP#8(()O`h91W9vCMj%MLse@@9#X~` ze9RXP&CTWB5fn@_O9@$%O19^1)HWpalvi?ZxuU$Exk~`|3_Ie&dHQP!7#cyBkyxg& z&{&LIl5rzNO}7idRkVFQX6!C-^hP~C_?42^4faXM2@>JwU07qi>hz{JULi~m!6I%1 zNVH9=>NIhc%z`g7V|dLaS(JiryHE?ayysK^F7&IVSJ<`t0z4M*Nn;hnWyUr&h7JW? z@|!vCV^GE0zr!dOE`v+Ep;I6l+=!Vs3c49=MqUnrz~hEE^^V9bormP|6vkM?czoL9 zvAj2rmG?A*)-I`w7^U2VSO5<{6d%NIf{xH z-IT!Zs04v?goWj#(3Fz3WrF;~Vj&EzJ@PkPTNFe|M#35O@dGfzp1Hws0IzE9HPce2P zdF9+)gsgEDxfNJy&;8OJ*6Jdhmp@bFS8xlk>sTlx z6gs}mmr+;@Y!_s@n(TIk+1i|CtWgAq)M7}_#~?59SzVnWF3rMZOX!h$cYT2&L4R^OE5-w-$dO?rk8X8B#?XjG$kn1x1qh4mt-rK2Hv9DdbJ$eeff8Bd$H$d$1LS7W z1PgTojjY|U)^=hTwR#Rot#hp^I-9Ds-HSuPihnF*z!J|~pn6dHO0LP`q}6auXdx=y zZzt_iG~!XB7BIO~*nA18yC%S3A!2zOM9~lgqdZ5ca_z{5Vhw~c!Gam_ zkB$h?1I>dtVP1C=_YrNPx3s3OEn6WUqGWbpQ^lIb-L{>OAcM z^D>T7BK1a)DS@6%HNg)glkZ3l$yN6$nS=mK2}RGdSXCY$Kc~o<7QFt9i6Xd! z3hE^92ZehIi`UQqU8q9QK*1bVe3LZ(_mDv5tZ*iS(h=kNoNp&+rZ2mc>Z463;isNm z;`O!^+|`&qD>tVW3a@;7i@na}QTrPlOLyJL!`1wV=j5j4jN(!c+2$%*`XEc4`0nW+ zu7`?u+@tDE&gTtqH-%POTw}Y~P71(AQG>bNGP(*psE=Ctn~0mRZYr_s1cZ&v7`!C( zM|kAH%($FN)fli-8PC`_byyy=#o3+fn7td$DIHFIEc#P8Y{0Q-gQ2+6b#1!RA$ z>I3hj+aCpIQzat#Zs0t3`WT*Aeo@+?z;r$UZBuoi)W#Jwb3iMOmI~J&a**jajwDbA zW}T|YCmrGb71#NhI&nUosnw?|vRq8`?1S?qzhQ%CFc9~5_u^m#wwh9IcO$1*KDx`e zE9zzsBwuOxnVEP306Ru&u0VQBKIoc3Pw$*m zJ~MI(pKB6Hklb1c*KKl$}DFL;p%G$3v*?PQ5mcL=kc zb$dk7Y<`bSKgcumX5ibwEdG{3f(@jAIOVxhW2_VEsmA!9xH_s^LiN@#iovk#u3FrO zES$7*QSe?z#4awuB1!PBe<}EGi{f6ALA54bW~vndG3?1zRg_()f^>`Gm&wect>6y% z@L>KLjysx{T+_k=gRL?>u&D-D++@F#E)r7Ld+qOJb5+0ag-R%6^qQYtM!~OP3!pu02a=DQv)I;+#rWi4E1D zN+7hNvJLGM#ZP?{0&SP>G(nH(u)Kt;nEzTy5g`n~@q<~=kVzYCEW$B)J zHu?m<>dcb1qienSk)DI=M-CnR{cu)h3Im@k@a*+@)hDsWe!%d>2@2G+aqH~k?C>@{ zk^bOUh_>U!2q{k5CRC!! zq&I;$tEm)(-LJMmiuqLXyu*ADtZErfFn%GbLA|5YW^p5%RVv^7O7|k&=b@e@Z>*|c zKH{LTjenjODt5SOn2om%+Xlpv<?)vtt1lWC=lQ8m)VaM@@)ATd=h zT3}rIu{0!|rJcVJQmj#82px?avv<0vH5l|tJ3U_JV(P5PLXjW&rqo)m9^g*zuF6AE z_xRdxT5bDh8-|r+8Smb<(iJMhjCXHp)2wNauTs$QG`MudU>oxVAavvzcYl*aGxn0q zJmZa?X|geEwLbl9r*wsGwcU4>rg&Sjp^^nXT#hz7@ln-dzLx<3s@Z{kp=fF+N1%A< z_Pw%zE)>MbPd`)XPs1o=E=B42aYSFo`kH?MxyUj*%l}1NxkPchE4e*?``1R7hFMzI>#l*X6F;Ahte*DAqZJ;O#hGAFK53k`Z6#NwQ6 zWoYu9Ug4nodS_3Lv>)09nhfMrNSbiQE4wd|%h0uH{ZalLwJJF3qkC3R=<90r&Iu@X zE?0pf0E>z&t}=VpO|)3i#FCqHHcBtSzSq(ZDE)6x@EO^6i^e)dbMK8o23f}CR7l&I z=nFPi{NpaGt8F*y>m?uCkELuCIFD2%#avZ#9 zRrfsP4Yx5_Tk=%~2mc%Pq@21Kt zc1vT>Ap4*xwL}%hH@#nmn^Q zaA05%4kA7Zcj;@XLc`7dxn>nb8*8f+Rqqs5OythB7Z&~_;OaB^-yxr)Ftr`YAr zk#y#-LR2CNnp(X3#r+bCujtF1T^`tmccRb4;RWTLIjh$`rDrmga$RYq-N~5wV6*nXaa{D3|-h zKxu+@O+}7SQecn(WRAqz9T_85a9-CR=M*<1KA&%j@t=f|_-KBsWY`228VP zXM~mcRovErux~7$8VGc_=?F+7se9rso2n%F{{k5~7);6|d17CSo; zR8ih|%i!1Eu^gCjb<)%bh5sko7$xVOTXK26sG=%kkCHI@$|JQQ1_8C5=Jc(b+>Qf} zInFM1{flDu#IMNXiS6lcCAEFrCBsH?> z(Prw`9v|$GO9x6y0_4^Dl;fW%_|^r0SAQ_SV(zYea2PtED_d;e{e!~6qXthxAbOX(4m#+bXRJ}+4mthu*c4Ch0W;a`PY{h3Tfj`*g&4@G(fqY5YPL8H z6)6OG;7{q|iH06tMH%jkNBGh+A_$H68X+a8BeMFPq_`#?Y;hW{k>U?7|9N3d--(Z+ zKJ_GLiWMl@CvcSikT>WyVD60Z`h6j3mgMvNPm5iL$tSRV#@N^6N@}bBkx*5K1^1x# zvVzq$;hsE#Pv=Fy^2Xy_(aXAqUg2}rjOCOkUP2QR-qrGEjc=Yqv$eM~gY24@_Zl{r zc#F_u>ADUch=8}7_5hg*f)tP;ssPJRN2zy;TY9ysR;b<4jW60RMP2{mSkA&rYq+wsHt#i5mV>A$W zzAHtgHRvr|)}O23Qn^k(=Sv?P4$g_7ey^_oF@2{-!mIb74>z4Kll^DQ~w@>@)4{%z8k%g`$9H*ATa9A)i zW}wx&>21pRJj9AaRKgAhoT8h3C&eZBpGJw(kYXT0!HM(da>6HC7AeuH(efZ)NgEc_ z)1ER)q@h@cY|rzNg#C<%P3L9j-BM0;WsLITkuekZT&|U6!elm>ICFI8j)V^t*C90g z$+A}Ad=ukOvBaQiKA#0;EAp3%_0rN(7+`ADJ#R>K6Wg+#`#&#SV_%L((j6R)-tH|n zf`cr*T;72Q6y#;W#Y|P8p3Z_AET>f!mH$mt266V%>S>+Ezy?t>8_8&}wLDM?ODi+Z z&9o>A-U)}sGNiH}?KE@6w40()3ZeLOkwn>7&*r19dckev^*IC^!$+|_G#0hZ4*NCk zM!EoM-1;oPtmU!UIeQ+97@GKzJ9G zU~@<1s}3BNZ`UE%0q)WGUC<=U$%!jwEb5%El`s`oo3#gm+qD$^*xrsWaSf5R?23vn z6oIOZKbK4aKbq&IG-~xDfKDdPU-5@jsn&UupMJ6lRJ^C*d!;JH8ZiW;8AhATk!Boj zM$gP>u1r*t>TVXC+(S z#8W`&j!cCpx2Pe&c0D_{Ap;84VVNU??sN6FipaaW)43Mf;Z?nyRM&JfyX@70xt%a1 zDT$?z};v~!Pu;j2$-$Ex9&66L4&JkH+u{+&b3V@LCPQqyi8-j3@E=T6<&6*WF~4J4GFU7E8L2A z`M+w{s1ob4S*h)-Bn&8*+_e9gR+S7-?7;LUm4yu#y=f!}tn>M{uQ5SJeOArRS(f`b zH(0nXRrl!kb)vn^N-)Ea!o*ATmNlON<5rzMuO%e!^`CI`eCqoEySyn$2D1SyJ+s6*CM_NNn`Jf zPv3h#>!~^~<@5p2eN^oE)~#}eJ&E8H@Bj*^=ld$^rxu)T26mNQaJ~fu&pWs5Tfr>` z&D3$EFe?x8csEJE*AjYZWegACnX3P;#}HGCN7e#{*JQ#)F4j73oY>J(^&4i%yZ$S|zBW z5S(_!y9cA9gEF%gB;()X=)+xu@U`IS{kp$58_>n2uS~lYxSG^>ttQIJ%|xHROAY-# zo~2rv?y?~Ut4rsDOfxljBr8$~F>lH$V>GkDC_0iwXM5RPTx2^5yevYWnMOT}ZfwBs z6hwbWQmFu&4~Tdzn(1~i=ekXC9rOv5f5IUG#-jIA1V>01$25I@}(vItj8%PSA3Y9jZ@0MlECK@tc8 z)rYKa`zK^h(@pnL>-0sbf*X>T)4}Y#&t-#c3{rE0b807hJ%+6OEr(N!<=Cn{#@jC^ zJMgGBIO7=wU(8X-|CH8216UYeX_@_3x1MTJfkGql=lcCh5I<@LBXLspn)M&}RXD4> zl3p#T30X=(DYjc(2(aXfEwjgA$*N}K4Bdh!{;9Kxc}#Aj!i2F+cMl@ZvXAbnI)2L{ z2prqqyubb5MmN>^!8$9{)$MM;CTgE@_?oaZr%G|GXOnN)cX%X4S1-B9WLv1H9e<BG!F-hYhk#$>AzUZS(Pz)-H-S)>cNf-yOJ!z9^eLJg9JUFj}I;-GoN}6wTF$8QN z+OcNv<{BbX7Pc)@S73zw`ZNHdeP?#1yi~#GHgg0RMH_l_Qa>|kw9P}1k;O#ntTzF0 ztGVm|pb)Wr!|l;`arfA@$rx6G$(t*8HH~Yc@|*C0B47*Sy_w%AteIV1S^>zc4ET~y zwQ8{z?A{Q>Bv#-i2GxrFpD!NK9_#IAV@#wJq_p!7pUw&m+5@9=Br7^BwqajC+>io; zP#X5de8Q1y24VJZ*=f^Hzr6=EQt};qiN6wE^hX{F*w*??XXqVqF4(0w!s~PuIDFuY zPr%?~$S9btYg4`tp>DA^>rIpT3(R^i7Kamxdr$Rc>nZr3otp}+fQrQskNe;z#w^*c~VruaqCu4O$;Hm zG>K1-Qq=%krn6LQH?!cO|EH}Pn%M^h>cq(M^lPw69cv6J?MQ`W)}xmYUqa{!){->y zStOXeF`UHw7Jz1{Q&kk4ZrMVRiGf^U3UNeFWvr0LYhWiH)iIE~-9f%Koo&fyP2-AF z40ySuZ)yYB80^;~sn?mM*@0ou$s>jcP3&WQK=OsT_-UT`za12v(oGhn_*cRnCpj7`up2?SJ8!b0?9cOCq8ZEmA90&693dq`)C4Tmc}DE^l3q!a91ap}F zB}ucHr=zwo)#QboA;R<8L6Z3uy-7n3mBO}Czy%{pAx?vpM)uc>_-f0@GgCK-a@C_| z@ndf)BMB}4QtjW;|!uos)tJ);C5c6 zMx;o$*lYuA(9uf6yaQ2_S9{Itw2~@%i{fBTh$#;$Krb6QsT?9KvF{P=^DOdJCfSI7 zxch(gEgcy)<e$PARYSIdrf=Q)1i2nY=pMlp! zP=`w`g00RtOw_0m0mwPnS38(vew%yM-%K$Zpe%E=)_{CAKB1|$m{kU9rhcglt3)4! zy3yGF6%l+_MrWIkDW}kf8A(L?1piJ_FXV#1IEuG7;p8@aI*Ue}u#fSvHwmR-#|P8V zrAZzS%vF#%RQ6XmHa0zC69B-(6)+ife}JekLd00y7UG>-4~v-O2$JEE4XTAK00_aD z=`!(;y;{8rnjMS(D^CIXEv=kT9pPaJy9xM>Bf&b_eM*=2HDJSUsV!%w2f(L&j9g`# z3A<3V=~lGEnxrT;F#;8X?^Q=O7hZsPT?AM%WuaLHh031)5> zXkBBTmSUvRwmr>-FlKdpVbVaiZJjVlpGe}(ITQIH`)nxTl#^fOqxReu?W9R7Hl-&l zaV5TGR~dhl==O=|1)C`_INiqO2O>I8_|M~NkLC^*%pMCMPti_=rwm`{GjZgII<}M1 z<0*Qcp;V=S@gm+61ULOQ($OP_OhcUi<7XgwifN&mByF<5k`ddVOEs?|jR?g)Ed5H!gO z6R2X#d_(im|KOF{ZoE*#_VS?n&mzMmy{2VvotgtGK}a&E9RYUGof7_MWmtw*|_qZQ%fmO2vev z8JK6IiH#`Rs*c2VzhbSq9BG|~&RILMk&_Gp?rwq^h?#EMq$nP^7Un#Xw+d1*5&ayw zCiMm~1(9+`vH-zELMzu4LLuVzf&|j<^)I#afw2=gb=mC3YxhWl z7j(Q%;ZH`0Q0|C;>6*x?et=0da}6&E3pK;p8>3xsbyQCy3g|Or;xc{vzdPGDp?z3s zalOks3d$QPyxsls3eq8)w>~ACQ8S;q8Q{4b(qfUI#u&Z0fG6NyJlx23ZipQX91CE> z#2>WE&{}R@pX_5bByCGr011RkHGobTzPFFnZL+Sel9vaBTB-+VYnOXs8brJ%J0-Ksi*kkG zx+7!k8C~dzSRA^n7p$x;_w#Z_4DAxCm9E%>g_xr}!kzfs2VQLG6ymCYJIlEr;gUDV z`AXfH-tWnw50OBP+?tSVtTmTXRwLkcg<3oUNon|)BUV-Z!bJ_Hzvl{We?U<+dU7R1 zM_!R{OuL%0PvJyqtOP%UBVr^9C78jbK^2CGfuVCxydro?i2wv5h^zo1lW555UeHh! z0bqtciu=Xm*=Sm0PbYKcZKA(UDi(m+qa@nbNFa>wSc;qq>=c zvXszlSO&~OT_b+w14Kh!BtR|tPwp-yJ~gIeZe6=2YM$x z^YmeEd1yMin9jFmCo}$5xe}cG?p&fWjguCRLpim9xFL(kbF2XX15)x4ABu5<(r0s+ z?hzzEYSkug4N(r~!J+%>P^*)yZw;QXl`=n>!|w#5?mAiKt?}~POPdL{a=rVfhP-2h zW!o^%DEJOIy!>U_`;gyv{%u*YMk)(SwQml!qvKot3{4BEGP@>Bd17N@wJl1c(Pf}L z8>167My-4I(QVr^C&=n;Urrr9P(gSxii#TXt&W;;F{T!zjt1q=?bK)1bF^2e>)%04 z?fOm?N5~oeD2mmh7yDbUtf(qSSp7o9rhSGXl{-4iO7~m zaG!tx0SQb4Q!UxM^ciQWVxd!f5#iGk8@~yd;4qC-3M&#MGlb~&ixuf80~)Zp>-`E* zg38iWc)tId&rsqaM2^PMH^G58fc*1mNLRs|IX_y2(pT(oJ z<7ACOKECkf|FCiY%l2Yk&N5c&uSNp725os^K?f5sRDGESAceLI?sdMRL)3vSl7h_I zqDCt5eIhu~Zqt2hwd20%m&z(@HDmrDX=8oNOgW<1Q5JGii_N@u`S2GzlII z0_o%YfKreE01M3V2mlO7WPB_sm4WL+adQ`*DA1U>TL}v~@~}Pu+h>E>HQl8Z4k~PxAH`mWNNTkMn{9U{)06_o)5&!@I5Q)sm)KJ&Y{_X0FWk&9VrG@Tc zc#QJ5>~uq^&xR*n%Ed9BP}8|H?lswThVa8Y!7$fVindn<>epCm-=7h))EXO{^*a{E zOaXcxi=PN8xPSlv02S1bkPK^FY8$7Er7V=XZo)!&9~<%p89YKFntstiO2u|02VlcZ~y=R literal 0 HcmV?d00001 diff --git a/frontend/src/pages/Launchpad.jsx b/frontend/src/pages/Launchpad.jsx index 7e0c3af0..ce6c400f 100644 --- a/frontend/src/pages/Launchpad.jsx +++ b/frontend/src/pages/Launchpad.jsx @@ -19,6 +19,7 @@ import { useAppStore } from '../store'; import ReadinessChecklist from '../components/ReadinessChecklist'; import LaunchpadDeck from '../components/LaunchpadDeck'; import useShellNarrow from '../hooks/useShellNarrow'; +import signalField from '../assets/signal-field.webp'; // Shared utility-class strings for the Launchpad project/section rows. Migrated // from the former `.lp-project-card`/`.lp-section-title`/`.proj-*` global rules @@ -168,8 +169,20 @@ export default function Launchpad({ {/* Hero — an eyebrow, one serif line, one sentence. Everything that used to compete with it (boxed number pill, filled CTA) is now quiet type; a hairline underneath does the separating that a card would have. */} -
-
+
+ {/* The signal-field waveform (the same cover the project wears on the + web) bleeds in from the right, where the hero has only air — the + mask ends it well before the text column, and a bottom fade keeps + the hairline underneath crisp. Decorative: hidden from readers, + inert to the pointer. */} + +
Date: Thu, 13 Aug 2026 18:27:27 +0000 Subject: [PATCH 06/15] fix(i18n): 53 default-value-only strings now speak all 21 languages (#1534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): 53 default-value-only strings now speak all 21 languages Every string used via t(key, { defaultValue }) without a locale entry rendered English for every non-English user — the worker/compute chrome (quick settings, join panel, QR enrolment), clone/design labels, workspace voice strip, and two crash explainers. All 53 keys now exist in en.json and carry reviewed translations in the 20 other locales, matching each file's established terminology (existing worker/token/engine vocabulary, catalogue tab names for the in-text path references, registers preserved, {{placeholders}} byte-identical, the ovw_ token prefix untranslated). The two crash explainers are translated from their FULL concatenated source text — the extraction initially captured only the first string segment, which src/test/streamDropError.test.ts caught by failing on the missing proxy/buffering guidance. Co-Authored-By: Claude Fable 5 * fix(i18n): reference localized UI labels inside diagnostic strings CodeRabbit on #1534, fixed as the class: every locale's crash_broken_env quoted the English "Clean & Retry" although the button itself is localized — all 19 now quote each file's own clean_retry label. Plus the flagged singles: es unload verb disambiguated from downloading, hi unload verb aligned with crash_oom_kill, sv kontrollplan gender agreement, de crash_broken_env moved to the file's Sie register, ru seed_reroll_hint mistranslation, zh-TW path label matched to the real 系統日誌 section name. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- frontend/src/i18n/locales/ar.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/de.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/en.json | 63 ++++++++++++++++++++++++++-- frontend/src/i18n/locales/es.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/fr.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/hi.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/id.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/it.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/ja.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/ko.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/nl.json | 63 ++++++++++++++++++++++++++-- frontend/src/i18n/locales/pl.json | 63 ++++++++++++++++++++++++++-- frontend/src/i18n/locales/pt.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/ru.json | 63 ++++++++++++++++++++++++++-- frontend/src/i18n/locales/sv.json | 63 ++++++++++++++++++++++++++-- frontend/src/i18n/locales/th.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/tr.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/uk.json | 63 ++++++++++++++++++++++++++-- frontend/src/i18n/locales/vi.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/zh-CN.json | 61 ++++++++++++++++++++++++++- frontend/src/i18n/locales/zh-TW.json | 61 ++++++++++++++++++++++++++- 21 files changed, 1245 insertions(+), 48 deletions(-) diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 5891e598..2c2022cf 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -282,7 +282,36 @@ "models_dir_effective": "المستخدم الآن", "models_dir_configured": "المُعَدّ", "models_dir_default": "استخدام الإعداد الافتراضي", - "models_dir_restart": "↻ أعد تشغيل VoiceStudio لاستخدام الموقع الجديد." + "models_dir_restart": "↻ أعد تشغيل VoiceStudio لاستخدام الموقع الجديد.", + "worker_join": "انضمام", + "worker_join_code": "رمز الانضمام", + "worker_join_code_hint": "يُستخدم مرة واحدة وتنتهي صلاحيته خلال 15 دقيقة. أنشئه على الجهاز الذي سيرسل العمل.", + "worker_join_desc": "اسمح لنسخة أخرى من VoiceStudio بإرسال المهام إلى هذا الجهاز. الصق رمز الانضمام الذي عرضته — أو امسح رمز QR الخاص بها بهاتفك والصقه هنا.", + "worker_join_env": "المتغير OMNIVOICE_WORKER_MODE مضبوط في بيئة هذا الجهاز، وهو من يقرر — غيّره هناك.", + "worker_join_no_endpoint": "لا توجد جهة تحكم محفوظة.", + "worker_join_ok": "تم الانضمام. هذا الجهاز يستقبل العمل الآن.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "الانضمام إلى جهاز آخر", + "worker_join_stopped": "متوقف", + "worker_join_take_work": "استقبال العمل من", + "worker_join_title": "إعارة وحدة معالجة الرسومات في هذا الجهاز", + "worker_join_working": "يعمل", + "workers_add_hint_qr": "أنشئ رمزًا، ثم امسح رمز QR من الجهاز الآخر أو الصق الرمز في إعدادات العاملين البعيدين هناك.", + "workers_approve": "موافقة", + "workers_last_seen": "آخر ظهور {{when}}", + "workers_qr_alt": "رمز QR يحمل هذا الرمز — امسحه من الجهاز الآخر", + "workers_secret_done": "تم", + "workers_seen_hr": "قبل {{count}} ساعة", + "workers_seen_min": "قبل {{count}} دقيقة", + "workers_seen_now": "الآن", + "workers_step_1": "ثبّت VoiceStudio على الجهاز الذي يحتوي وحدة معالجة الرسومات.", + "workers_step_2": "أنشئ رمزًا في الأعلى.", + "workers_step_3": "امسح رمز QR هناك، أو الصق الرمز في إعدادات العاملين البعيدين لديه.", + "workers_summary_none": "لا أحد متصل", + "workers_summary_online": "{{count}} متصل", + "workers_token_expired": "انتهت الصلاحية — أنشئ رمزًا جديدًا", + "workers_token_expires_in": "تنتهي الصلاحية خلال {{time}}", + "workers_token_qr_hint": "على الجهاز الآخر: الإعدادات ← النظام ← العاملون البعيدون ← انضمام، ثم امسح أو الصق." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "من الصوت", "define_voice": "تحديد الصوت", "save_design_as_profile": "حفظ التصميم كملف تعريف صوتي", + "generating_done_status": "انتهى التوليد", + "generating_status": "جارٍ توليد الصوت…", + "identity": "الهوية", + "identity_auto": "تلقائي — النموذج يقرر", + "insert": "إدراج", + "insert_token": "إدراج رمز تعبير", + "script": "النص", + "starting_points": "نقاط البداية", + "voice_kicker": "الصوت", "seed_label": "بذرة", "seed_placeholder": "عشوائية في كل مرة", "seed_keep": "احتفظ بهذه البذرة", @@ -1590,7 +1628,9 @@ "searchIssues": "البحث عن مشكلات مشابهة", "unexpected": "خطأ غير متوقع: {{message}}", "backend_shutting_down": "يجري إغلاق VoiceStudio. أعد فتح التطبيق وحاول مرة أخرى.", - "crash_broken_env": "توقّف أثناء تحميل اعتمادات Python الخاصة به، فالمشكلة ليست في الذاكرة ولا في كرت الرسوميات — البيئة ناقصة أو بقيت نصف محدَّثة. استخدم «Clean & Retry» في الإعدادات ← السجلات ← الخادم الخلفي، فهو يعيد بناءها من الصفر ويصلحها في مكانها دون المساس بأصواتك أو مشاريعك. إذا استمر الفشل، فإن تفاصيل الانهيار تذكر اسم الحزمة التي تعذّر استيرادها." + "crash_broken_env": "توقّف أثناء تحميل اعتمادات Python الخاصة به، فالمشكلة ليست في الذاكرة ولا في كرت الرسوميات — البيئة ناقصة أو بقيت نصف محدَّثة. استخدم «تنظيف وإعادة المحاولة» في الإعدادات ← السجلات ← الخادم الخلفي، فهو يعيد بناءها من الصفر ويصلحها في مكانها دون المساس بأصواتك أو مشاريعك. إذا استمر الفشل، فإن تفاصيل الانهيار تذكر اسم الحزمة التي تعذّر استيرادها.", + "crash_vram_default": "على وحدات معالجة الرسومات الأصغر، السبب المعتاد هو نفاد ذاكرة VRAM أثناء تحميل نموذج ASR فوق نموذج TTS: أفرغ نموذج TTS أولاً، أو اختر نموذج ASR أصغر من كتالوج النماذج ← النماذج.", + "stream_cut_backend_alive": "انتهى البث مبكرًا، لكن الخادم الخلفي ما يزال يعمل — أي أنه لم ينهر. في بيئة مقدَّمة عبر خادم أو حاويات، يكون السبب عادةً وكيلًا عكسيًا أو موزع حمل يخزّن الاتصال مؤقتًا أو ينهي مهلته: عطّل التخزين المؤقت للاستجابة على هذا المسار (nginx: proxy_buffering off; X-Accel-Buffering: no) وارفع مهلة القراءة لديه. تشغيل تطبيق سطح المكتب مباشرة، أو على localhost دون وكيل، سيؤكد ذلك." }, "keyboard": { "title": "اختصارات لوحة المفاتيح", @@ -2535,5 +2575,22 @@ "longform": "سرد القصص", "asr": "التفريغ النصي" } + }, + "compute": { + "add_machine": "إضافة جهاز", + "manage": "إعدادات العاملين البعيدين", + "off_hint": "كل شيء يعمل على هذا الجهاز. فعّل «بعيد» لاستخدام جهاز آخر.", + "quick_settings": "الحوسبة — أين تُنفَّذ المهام", + "remote": "بعيد", + "title": "أين تُنفَّذ المهام", + "token_once": "امسحه ضوئيًا أو الصقه على الجهاز الآخر. يُعرض مرة واحدة فقط." + }, + "voices": { + "active": "الصوت النشط", + "active_clone_recipe": "مستنسخ من مقطعك المرجعي", + "cta_clone": "أسقط مقطعًا مدته 3 ثوانٍ في «صوت» ← لاستنساخ صوت", + "cta_design": "صِف صوتًا في «صوت» ← لتصميمه", + "new": "صوت جديد", + "none_selected": "لم يُحدد أي صوت — صِف واحدًا، أو أسقط ملفًا صوتيًا، أو اختر من الأسفل." } } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e67ea221..7ac3812b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -282,7 +282,36 @@ "models_dir_effective": "Derzeit verwendet", "models_dir_configured": "Konfiguriert", "models_dir_default": "Standard wird verwendet", - "models_dir_restart": "↻ Starte VoiceStudio neu, um den neuen Speicherort zu verwenden." + "models_dir_restart": "↻ Starte VoiceStudio neu, um den neuen Speicherort zu verwenden.", + "worker_join": "Beitreten", + "worker_join_code": "Beitrittscode", + "worker_join_code_hint": "Nur einmal gültig, läuft in 15 Minuten ab. Erzeugen Sie ihn auf dem Rechner, der die Aufträge senden wird.", + "worker_join_desc": "Lassen Sie eine andere VoiceStudio-Instanz Aufträge an diesen Rechner senden. Fügen Sie den dort angezeigten Beitrittscode ein — oder scannen Sie den QR-Code mit Ihrem Handy und fügen Sie ihn hier ein.", + "worker_join_env": "OMNIVOICE_WORKER_MODE ist in der Umgebung dieses Rechners gesetzt und hat daher Vorrang — ändern Sie es dort.", + "worker_join_no_endpoint": "Keine Control Plane gespeichert.", + "worker_join_ok": "Beigetreten. Dieser Rechner nimmt jetzt Aufträge an.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Einem anderen beitreten", + "worker_join_stopped": "Gestoppt", + "worker_join_take_work": "Aufträge annehmen von", + "worker_join_title": "Die GPU dieses Rechners verleihen", + "worker_join_working": "Arbeitet", + "workers_add_hint_qr": "Erzeugen Sie ein Token, scannen Sie dann den QR-Code vom anderen Rechner oder fügen Sie den Code dort in den Remote-Worker-Einstellungen ein.", + "workers_approve": "Freigeben", + "workers_last_seen": "zuletzt gesehen {{when}}", + "workers_qr_alt": "QR-Code mit diesem Code — vom anderen Rechner scannen", + "workers_secret_done": "Fertig", + "workers_seen_hr": "vor {{count}} h", + "workers_seen_min": "vor {{count}} min", + "workers_seen_now": "gerade eben", + "workers_step_1": "Installieren Sie VoiceStudio auf dem Rechner mit der GPU.", + "workers_step_2": "Erzeugen Sie oben ein Token.", + "workers_step_3": "Scannen Sie dort den QR-Code oder fügen Sie den Code in die dortigen Remote-Worker-Einstellungen ein.", + "workers_summary_none": "Niemand verbunden", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Abgelaufen — erzeugen Sie ein neues", + "workers_token_expires_in": "Läuft ab in {{time}}", + "workers_token_qr_hint": "Auf dem anderen Rechner: Einstellungen → System → Remote-Worker → Beitreten, dann scannen oder einfügen." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "Aus Audio", "define_voice": "Stimme definieren", "save_design_as_profile": "Design als Profil speichern", + "generating_done_status": "Generierung abgeschlossen", + "generating_status": "Audio wird generiert…", + "identity": "Identität", + "identity_auto": "Auto — das Modell entscheidet", + "insert": "Einfügen", + "insert_token": "Ausdrucks-Token einfügen", + "script": "Skript", + "starting_points": "Ausgangspunkte", + "voice_kicker": "Stimme", "seed_label": "Samen", "seed_placeholder": "jedes Mal zufällig", "seed_keep": "Behalte diesen Samen", @@ -1590,7 +1628,9 @@ "searchIssues": "Ähnliche Probleme suchen", "unexpected": "Unerwarteter Fehler: {{message}}", "backend_shutting_down": "VoiceStudio wird beendet. Öffnen Sie die App erneut und versuchen Sie es noch einmal.", - "crash_broken_env": "Er ist beim Laden seiner eigenen Python-Abhängigkeiten gestorben — es geht also weder um Speicher noch um deine GPU, sondern um eine unvollständige oder halb aktualisierte Umgebung. Nutze „Clean & Retry“ unter Einstellungen → Logs → Backend: Das baut sie von Grund auf neu und repariert sie an Ort und Stelle, ohne deine Stimmen oder Projekte anzurühren. Schlägt es danach weiter fehl, nennen die Absturzdetails das Paket, das sich nicht importieren ließ." + "crash_broken_env": "Er ist beim Laden seiner eigenen Python-Abhängigkeiten gestorben — es geht also weder um Speicher noch um Ihre GPU, sondern um eine unvollständige oder halb aktualisierte Umgebung. Nutzen Sie „Bereinigen & Wiederholen“ unter Einstellungen → Logs → Backend: Das baut sie von Grund auf neu und repariert sie an Ort und Stelle, ohne Ihre Stimmen oder Projekte anzurühren. Schlägt es danach weiter fehl, nennen die Absturzdetails das Paket, das sich nicht importieren ließ.", + "crash_vram_default": "Auf kleineren GPUs ist die übliche Ursache, dass beim Laden des ASR-Modells zusätzlich zum TTS-Modell der VRAM ausgeht: Entladen Sie zuerst das TTS-Modell, oder wählen Sie unter Modellkatalog → Modelle ein kleineres ASR-Modell.", + "stream_cut_backend_alive": "Der Stream endete vorzeitig, aber das Backend läuft noch — es ist also nicht abgestürzt. In einem Server- oder Container-Setup liegt das meist an einem Reverse-Proxy oder Load-Balancer, der die Verbindung puffert oder per Timeout beendet: Deaktivieren Sie das Response-Buffering für diese Route (nginx: proxy_buffering off; X-Accel-Buffering: no) und erhöhen Sie das Lese-Timeout. Wenn Sie die Desktop-App direkt oder auf localhost ohne Proxy ausführen, lässt sich das bestätigen." }, "keyboard": { "title": "Tastaturkürzel", @@ -2535,5 +2575,22 @@ "longform": "Story-Vertonung", "asr": "Transkription" } + }, + "compute": { + "add_machine": "Rechner hinzufügen", + "manage": "Remote-Worker-Einstellungen", + "off_hint": "Alles läuft auf diesem Rechner. Schalten Sie Remote ein, um einen anderen zu nutzen.", + "quick_settings": "Rechenleistung — wo Aufträge laufen", + "remote": "Remote", + "title": "Wo Aufträge laufen", + "token_once": "Auf dem anderen Rechner scannen oder einfügen. Wird nur einmal angezeigt." + }, + "voices": { + "active": "Aktive Stimme", + "active_clone_recipe": "Aus Ihrem Referenzclip geklont", + "cta_clone": "Legen Sie einen 3-Sekunden-Clip in Stimme ← ab, um eine zu klonen", + "cta_design": "Beschreiben Sie eine in Stimme ←, um sie zu designen", + "new": "Neue Stimme", + "none_selected": "Keine Stimme ausgewählt — beschreiben Sie eine, legen Sie Audio ab oder wählen Sie unten eine aus." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 17a9ccb2..7367d920 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -373,7 +373,16 @@ "define_by_design": "By design", "define_from_audio": "From audio", "define_voice": "Define voice", - "save_design_as_profile": "Save design as profile" + "save_design_as_profile": "Save design as profile", + "generating_done_status": "Generation finished", + "generating_status": "Generating audio…", + "identity": "Identity", + "identity_auto": "Auto — the model decides", + "insert": "Insert", + "insert_token": "Insert expression token", + "script": "Script", + "starting_points": "Starting points", + "voice_kicker": "Voice" }, "settings": { "title": "Settings", @@ -850,7 +859,36 @@ "models_dir_effective": "Effective now", "models_dir_configured": "Configured", "models_dir_default": "Using default", - "models_dir_restart": "↻ Restart VoiceStudio to use the new location." + "models_dir_restart": "↻ Restart VoiceStudio to use the new location.", + "worker_join": "Join", + "worker_join_code": "Join code", + "worker_join_code_hint": "Single-use and expires in 15 minutes. Generate it on the machine that will send the work.", + "worker_join_desc": "Let another copy of VoiceStudio send jobs to this machine. Paste the join code it showed you — or scan its QR with your phone and paste it here.", + "worker_join_env": "OMNIVOICE_WORKER_MODE is set in this machine’s environment, so it decides — change it there.", + "worker_join_no_endpoint": "No control plane remembered.", + "worker_join_ok": "Joined. This machine is now taking work.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Join a different one", + "worker_join_stopped": "Stopped", + "worker_join_take_work": "Take work from", + "worker_join_title": "Lend this machine's GPU", + "worker_join_working": "Working", + "workers_add_hint_qr": "Generate a token, then scan the QR from the other machine or paste the code into its Remote workers settings.", + "workers_approve": "Approve", + "workers_last_seen": "last seen {{when}}", + "workers_qr_alt": "QR code carrying this code — scan it from the other machine", + "workers_secret_done": "Done", + "workers_seen_hr": "{{count}}h ago", + "workers_seen_min": "{{count}}m ago", + "workers_seen_now": "just now", + "workers_step_1": "Install VoiceStudio on the machine with the GPU.", + "workers_step_2": "Generate a token above.", + "workers_step_3": "Scan the QR there, or paste the code into its Remote workers settings.", + "workers_summary_none": "Nobody connected", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Expired — generate a new one", + "workers_token_expires_in": "Expires in {{time}}", + "workers_token_qr_hint": "On the other machine: Settings → System → Remote workers → Join, then scan or paste." }, "about": { "app": "App", @@ -2051,7 +2089,9 @@ "searchIssues": "Search similar issues", "unexpected": "Unexpected error: {{message}}", "backend_shutting_down": "VoiceStudio is shutting down. Reopen the app and try again.", - "crash_broken_env": "It died while loading its own Python dependencies, so this is not about memory or your GPU — the environment is incomplete or was left half-updated. Use \"Clean & Retry\" in Settings → Logs → Backend, which rebuilds it from scratch; that repairs it in place, without touching your voices or projects. If it still fails afterwards, the crash details name the exact package that would not import." + "crash_broken_env": "It died while loading its own Python dependencies, so this is not about memory or your GPU — the environment is incomplete or was left half-updated. Use \"Clean & Retry\" in Settings → Logs → Backend, which rebuilds it from scratch; that repairs it in place, without touching your voices or projects. If it still fails afterwards, the crash details name the exact package that would not import.", + "crash_vram_default": "On smaller GPUs the usual cause is running out of VRAM while loading the ASR model on top of the TTS model: flush the TTS model first, or pick a smaller ASR model in Model Catalogue → Models.", + "stream_cut_backend_alive": "The stream ended early, but the backend is still running — so it did not crash. In a served or containerised setup this is usually a reverse proxy or load balancer buffering or timing out the connection: disable response buffering for this route (nginx: proxy_buffering off; X-Accel-Buffering: no) and raise its read timeout. Running the desktop app directly, or on localhost without a proxy, will confirm it." }, "crash": { "notice": "The voice backend crashed ({{exit}}) {{ago}} ago and is being restarted automatically.", @@ -3040,5 +3080,22 @@ "longform": "story narration", "asr": "transcription" } + }, + "compute": { + "add_machine": "Add a machine", + "manage": "Remote worker settings", + "off_hint": "Everything runs on this machine. Turn Remote on to use another.", + "quick_settings": "Compute — where jobs run", + "remote": "Remote", + "title": "Where jobs run", + "token_once": "Scan or paste this on the other machine. Shown once." + }, + "voices": { + "active": "Active voice", + "active_clone_recipe": "Cloned from your reference clip", + "cta_clone": "Drop a 3s clip in Voice ← to clone one", + "cta_design": "Describe one in Voice ← to design it", + "new": "New voice", + "none_selected": "No voice selected — describe one, drop audio, or pick below." } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 01b8b8ff..566919a7 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -282,7 +282,36 @@ "models_dir_effective": "En uso ahora", "models_dir_configured": "Configurada", "models_dir_default": "Usando la predeterminada", - "models_dir_restart": "↻ Reinicia VoiceStudio para usar la nueva ubicación." + "models_dir_restart": "↻ Reinicia VoiceStudio para usar la nueva ubicación.", + "worker_join": "Unirse", + "worker_join_code": "Código de unión", + "worker_join_code_hint": "De un solo uso y caduca en 15 minutos. Genéralo en el equipo que enviará el trabajo.", + "worker_join_desc": "Permite que otra copia de VoiceStudio envíe trabajos a este equipo. Pega el código de unión que te mostró — o escanea su QR con el móvil y pégalo aquí.", + "worker_join_env": "OMNIVOICE_WORKER_MODE está definido en el entorno de este equipo, así que decide él — cámbialo allí.", + "worker_join_no_endpoint": "Ningún plano de control recordado.", + "worker_join_ok": "Unido. Este equipo ya está aceptando trabajo.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Unirse a otro", + "worker_join_stopped": "Detenido", + "worker_join_take_work": "Aceptar trabajo de", + "worker_join_title": "Prestar la GPU de este equipo", + "worker_join_working": "Trabajando", + "workers_add_hint_qr": "Genera un token y luego escanea el QR desde el otro equipo o pega el código en sus ajustes de Trabajadores remotos.", + "workers_approve": "Aprobar", + "workers_last_seen": "visto por última vez {{when}}", + "workers_qr_alt": "Código QR con este código — escanéalo desde el otro equipo", + "workers_secret_done": "Hecho", + "workers_seen_hr": "hace {{count}} h", + "workers_seen_min": "hace {{count}} min", + "workers_seen_now": "ahora mismo", + "workers_step_1": "Instala VoiceStudio en el equipo con la GPU.", + "workers_step_2": "Genera un token arriba.", + "workers_step_3": "Escanea el QR allí, o pega el código en sus ajustes de Trabajadores remotos.", + "workers_summary_none": "Nadie conectado", + "workers_summary_online": "{{count}} en línea", + "workers_token_expired": "Caducado — genera uno nuevo", + "workers_token_expires_in": "Caduca en {{time}}", + "workers_token_qr_hint": "En el otro equipo: Ajustes → Sistema → Trabajadores remotos → Unirse, y luego escanea o pega." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "Desde audio", "define_voice": "Definir voz", "save_design_as_profile": "Guardar diseño como perfil", + "generating_done_status": "Generación terminada", + "generating_status": "Generando audio…", + "identity": "Identidad", + "identity_auto": "Auto — el modelo decide", + "insert": "Insertar", + "insert_token": "Insertar token de expresión", + "script": "Guión", + "starting_points": "Puntos de partida", + "voice_kicker": "Voz", "seed_label": "semilla", "seed_placeholder": "aleatorio cada vez", "seed_keep": "Mantén esta semilla", @@ -1590,7 +1628,9 @@ "searchIssues": "Buscar problemas similares", "unexpected": "Error inesperado: {{message}}", "backend_shutting_down": "VoiceStudio se está cerrando. Vuelve a abrir la aplicación e inténtalo de nuevo.", - "crash_broken_env": "Murió mientras cargaba sus propias dependencias de Python, así que no es cuestión de memoria ni de tu GPU: el entorno está incompleto o quedó a medio actualizar. Usa «Clean & Retry» en Configuración → Registros → Backend, que lo reconstruye desde cero y lo repara sin tocar tus voces ni tus proyectos. Si sigue fallando, los detalles del fallo indican el paquete exacto que no se pudo importar." + "crash_broken_env": "Murió mientras cargaba sus propias dependencias de Python, así que no es cuestión de memoria ni de tu GPU: el entorno está incompleto o quedó a medio actualizar. Usa «Limpiar y reintentar» en Configuración → Registros → Backend, que lo reconstruye desde cero y lo repara sin tocar tus voces ni tus proyectos. Si sigue fallando, los detalles del fallo indican el paquete exacto que no se pudo importar.", + "crash_vram_default": "En GPU más pequeñas, la causa habitual es quedarse sin VRAM al cargar el modelo ASR junto con el modelo TTS: libera primero el modelo TTS de la memoria, o elige un modelo ASR más pequeño en Catálogo de modelos → Modelos.", + "stream_cut_backend_alive": "El stream terminó antes de tiempo, pero el backend sigue en ejecución — así que no se bloqueó. En una instalación servida o en contenedor, esto suele deberse a un proxy inverso o balanceador de carga que almacena en búfer la conexión o la corta por tiempo de espera: desactiva el almacenamiento en búfer de la respuesta para esta ruta (nginx: proxy_buffering off; X-Accel-Buffering: no) y aumenta su tiempo de espera de lectura. Ejecutar la aplicación de escritorio directamente, o en localhost sin proxy, lo confirmará." }, "keyboard": { "title": "Atajos de teclado", @@ -2535,5 +2575,22 @@ "longform": "narración de historias", "asr": "transcripción" } + }, + "compute": { + "add_machine": "Añadir un equipo", + "manage": "Ajustes de trabajadores remotos", + "off_hint": "Todo se ejecuta en este equipo. Activa Remoto para usar otro.", + "quick_settings": "Cómputo — dónde se ejecutan los trabajos", + "remote": "Remoto", + "title": "Dónde se ejecutan los trabajos", + "token_once": "Escanéalo o pégalo en el otro equipo. Se muestra una sola vez." + }, + "voices": { + "active": "Voz activa", + "active_clone_recipe": "Clonada a partir de tu clip de referencia", + "cta_clone": "Suelta un clip de 3 s en Voz ← para clonar una", + "cta_design": "Describe una en Voz ← para diseñarla", + "new": "Nueva voz", + "none_selected": "Ninguna voz seleccionada — describe una, suelta un audio o elige abajo." } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index ac644ec8..6aeefd7f 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -282,7 +282,36 @@ "models_dir_effective": "Utilisé actuellement", "models_dir_configured": "Configuré", "models_dir_default": "Valeur par défaut", - "models_dir_restart": "↻ Redémarrez VoiceStudio pour utiliser le nouvel emplacement." + "models_dir_restart": "↻ Redémarrez VoiceStudio pour utiliser le nouvel emplacement.", + "worker_join": "Rejoindre", + "worker_join_code": "Code d'association", + "worker_join_code_hint": "À usage unique, expire dans 15 minutes. Générez-le sur la machine qui enverra les tâches.", + "worker_join_desc": "Autorisez une autre copie de VoiceStudio à envoyer des tâches à cette machine. Collez le code d'association qu'elle vous a montré — ou scannez son QR avec votre téléphone et collez-le ici.", + "worker_join_env": "OMNIVOICE_WORKER_MODE est défini dans l'environnement de cette machine, c'est donc lui qui décide — modifiez-le là-bas.", + "worker_join_no_endpoint": "Aucun plan de contrôle mémorisé.", + "worker_join_ok": "Association réussie. Cette machine accepte désormais des tâches.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "En rejoindre une autre", + "worker_join_stopped": "Arrêté", + "worker_join_take_work": "Accepter des tâches de", + "worker_join_title": "Prêter le GPU de cette machine", + "worker_join_working": "Au travail", + "workers_add_hint_qr": "Générez un jeton, puis scannez le QR depuis l'autre machine ou collez le code dans ses paramètres Workers distants.", + "workers_approve": "Approuver", + "workers_last_seen": "vu pour la dernière fois {{when}}", + "workers_qr_alt": "Code QR contenant ce code — à scanner depuis l'autre machine", + "workers_secret_done": "Terminé", + "workers_seen_hr": "il y a {{count}} h", + "workers_seen_min": "il y a {{count}} min", + "workers_seen_now": "à l'instant", + "workers_step_1": "Installez VoiceStudio sur la machine équipée du GPU.", + "workers_step_2": "Générez un jeton ci-dessus.", + "workers_step_3": "Scannez-y le QR, ou collez le code dans ses paramètres Workers distants.", + "workers_summary_none": "Personne n'est connecté", + "workers_summary_online": "{{count}} en ligne", + "workers_token_expired": "Expiré — générez-en un nouveau", + "workers_token_expires_in": "Expire dans {{time}}", + "workers_token_qr_hint": "Sur l'autre machine : Paramètres → Système → Workers distants → Rejoindre, puis scannez ou collez." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "À partir de l'audio", "define_voice": "Définir la voix", "save_design_as_profile": "Enregistrer la conception en tant que profil", + "generating_done_status": "Génération terminée", + "generating_status": "Génération de l'audio…", + "identity": "Identité", + "identity_auto": "Auto — le modèle décide", + "insert": "Insérer", + "insert_token": "Insérer un jeton d'expression", + "script": "Scénario", + "starting_points": "Points de départ", + "voice_kicker": "Voix", "seed_label": "Semence", "seed_placeholder": "aléatoire à chaque fois", "seed_keep": "Gardez cette graine", @@ -1590,7 +1628,9 @@ "searchIssues": "Rechercher des problèmes similaires", "unexpected": "Erreur inattendue : {{message}}", "backend_shutting_down": "VoiceStudio est en cours de fermeture. Rouvrez l’application et réessayez.", - "crash_broken_env": "Il est mort en chargeant ses propres dépendances Python : ce n'est donc ni la mémoire ni votre GPU, mais un environnement incomplet ou à moitié mis à jour. Utilisez « Clean & Retry » dans Paramètres → Journaux → Backend, qui le reconstruit de zéro et le répare sur place, sans toucher à vos voix ni à vos projets. Si l'échec persiste, les détails du plantage nomment le paquet qui refusait de s'importer." + "crash_broken_env": "Il est mort en chargeant ses propres dépendances Python : ce n'est donc ni la mémoire ni votre GPU, mais un environnement incomplet ou à moitié mis à jour. Utilisez « Nettoyer et réessayer » dans Paramètres → Journaux → Backend, qui le reconstruit de zéro et le répare sur place, sans toucher à vos voix ni à vos projets. Si l'échec persiste, les détails du plantage nomment le paquet qui refusait de s'importer.", + "crash_vram_default": "Sur les GPU plus modestes, la cause habituelle est un manque de VRAM lors du chargement du modèle ASR en plus du modèle TTS : déchargez d'abord le modèle TTS, ou choisissez un modèle ASR plus petit dans Catalogue de modèles → Modèles.", + "stream_cut_backend_alive": "Le flux s'est terminé prématurément, mais le backend tourne toujours — il n'a donc pas planté. Dans une installation servie ou conteneurisée, c'est généralement un reverse proxy ou un répartiteur de charge qui met la connexion en tampon ou la coupe par timeout : désactivez la mise en tampon des réponses pour cette route (nginx: proxy_buffering off; X-Accel-Buffering: no) et augmentez son délai de lecture. Lancer l'application de bureau directement, ou sur localhost sans proxy, permettra de le confirmer." }, "keyboard": { "title": "Raccourcis clavier", @@ -2535,5 +2575,22 @@ "longform": "narration d'histoires", "asr": "transcription" } + }, + "compute": { + "add_machine": "Ajouter une machine", + "manage": "Paramètres des workers distants", + "off_hint": "Tout s'exécute sur cette machine. Activez Distant pour en utiliser une autre.", + "quick_settings": "Calcul — où les tâches s'exécutent", + "remote": "Distant", + "title": "Où les tâches s'exécutent", + "token_once": "Scannez-le ou collez-le sur l'autre machine. Affiché une seule fois." + }, + "voices": { + "active": "Voix active", + "active_clone_recipe": "Clonée à partir de votre extrait de référence", + "cta_clone": "Déposez un extrait de 3 s dans Voix ← pour en cloner une", + "cta_design": "Décrivez-en une dans Voix ← pour la concevoir", + "new": "Nouvelle voix", + "none_selected": "Aucune voix sélectionnée — décrivez-en une, déposez un audio ou choisissez ci-dessous." } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 7939713e..660089b0 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -282,7 +282,36 @@ "models_dir_effective": "अभी प्रभावी", "models_dir_configured": "कॉन्फ़िगर किया गया", "models_dir_default": "डिफ़ॉल्ट उपयोग हो रहा है", - "models_dir_restart": "↻ नया स्थान उपयोग करने के लिए VoiceStudio रीस्टार्ट करें।" + "models_dir_restart": "↻ नया स्थान उपयोग करने के लिए VoiceStudio रीस्टार्ट करें।", + "worker_join": "जुड़ें", + "worker_join_code": "जॉइन कोड", + "worker_join_code_hint": "एक बार उपयोग होता है और 15 मिनट में समाप्त हो जाता है। इसे उस मशीन पर बनाएँ जो काम भेजेगी।", + "worker_join_desc": "VoiceStudio की दूसरी कॉपी को इस मशीन पर काम भेजने दें। उसने जो जॉइन कोड दिखाया था उसे पेस्ट करें — या उसका QR अपने फ़ोन से स्कैन करके यहाँ पेस्ट करें।", + "worker_join_env": "इस मशीन के एनवायरनमेंट में OMNIVOICE_WORKER_MODE सेट है, इसलिए वही तय करता है — उसे वहीं बदलें।", + "worker_join_no_endpoint": "कोई कंट्रोल प्लेन याद नहीं है।", + "worker_join_ok": "जुड़ गए। यह मशीन अब काम ले रही है।", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "किसी दूसरे से जुड़ें", + "worker_join_stopped": "रुका हुआ", + "worker_join_take_work": "इससे काम लें", + "worker_join_title": "इस मशीन का GPU उधार दें", + "worker_join_working": "काम कर रहा है", + "workers_add_hint_qr": "टोकन बनाएँ, फिर दूसरी मशीन से QR स्कैन करें या कोड उसकी रिमोट वर्कर सेटिंग्स में पेस्ट करें।", + "workers_approve": "स्वीकृत करें", + "workers_last_seen": "आख़िरी बार देखा गया {{when}}", + "workers_qr_alt": "इस कोड वाला QR कोड — इसे दूसरी मशीन से स्कैन करें", + "workers_secret_done": "हो गया", + "workers_seen_hr": "{{count}} घं. पहले", + "workers_seen_min": "{{count}} मि. पहले", + "workers_seen_now": "अभी-अभी", + "workers_step_1": "GPU वाली मशीन पर VoiceStudio इंस्टॉल करें।", + "workers_step_2": "ऊपर एक टोकन बनाएँ।", + "workers_step_3": "वहाँ QR स्कैन करें, या कोड उसकी रिमोट वर्कर सेटिंग्स में पेस्ट करें।", + "workers_summary_none": "कोई जुड़ा नहीं है", + "workers_summary_online": "{{count}} ऑनलाइन", + "workers_token_expired": "समाप्त हो गया — नया बनाएँ", + "workers_token_expires_in": "{{time}} में समाप्त होगा", + "workers_token_qr_hint": "दूसरी मशीन पर: सेटिंग्स → सिस्टम → रिमोट वर्कर → जुड़ें, फिर स्कैन या पेस्ट करें।" }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "ऑडियो से", "define_voice": "आवाज़ परिभाषित करें", "save_design_as_profile": "डिज़ाइन को प्रोफ़ाइल के रूप में सहेजें", + "generating_done_status": "जनरेशन पूरा हुआ", + "generating_status": "ऑडियो जनरेट हो रहा है…", + "identity": "पहचान", + "identity_auto": "ऑटो — मॉडल तय करता है", + "insert": "डालें", + "insert_token": "एक्सप्रेशन टोकन डालें", + "script": "स्क्रिप्ट", + "starting_points": "शुरुआती बिंदु", + "voice_kicker": "आवाज़", "seed_label": "बीज", "seed_placeholder": "हर बार यादृच्छिक", "seed_keep": "इस बीज को अपने पास रखें", @@ -1590,7 +1628,9 @@ "searchIssues": "मिलते-जुलते मुद्दे खोजें", "unexpected": "अप्रत्याशित त्रुटि: {{message}}", "backend_shutting_down": "VoiceStudio बंद हो रहा है। ऐप दोबारा खोलें और फिर कोशिश करें।", - "crash_broken_env": "यह अपनी ही Python निर्भरताएँ लोड करते समय बंद हो गया, इसलिए मामला मेमोरी या GPU का नहीं है — एनवायरनमेंट अधूरा है या आधा-अधूरा अपडेट रह गया। सेटिंग्स → लॉग → बैकएंड में \"Clean & Retry\" चलाएँ; यह उसे नए सिरे से बनाकर वहीं ठीक कर देता है, आपकी आवाज़ों या प्रोजेक्ट्स को छुए बिना। फिर भी विफल हो तो क्रैश विवरण उस पैकेज का नाम बताता है जो इम्पोर्ट नहीं हो पाया।" + "crash_broken_env": "यह अपनी ही Python निर्भरताएँ लोड करते समय बंद हो गया, इसलिए मामला मेमोरी या GPU का नहीं है — एनवायरनमेंट अधूरा है या आधा-अधूरा अपडेट रह गया। सेटिंग्स → लॉग → बैकएंड में \"साफ़ करें और पुनः प्रयास करें\" चलाएँ; यह उसे नए सिरे से बनाकर वहीं ठीक कर देता है, आपकी आवाज़ों या प्रोजेक्ट्स को छुए बिना। फिर भी विफल हो तो क्रैश विवरण उस पैकेज का नाम बताता है जो इम्पोर्ट नहीं हो पाया।", + "crash_vram_default": "छोटे GPU पर आम कारण है TTS मॉडल के ऊपर ASR मॉडल लोड करते समय VRAM का ख़त्म हो जाना: पहले TTS मॉडल को हटाएँ, या मॉडल कैटलॉग → मॉडल में कोई छोटा ASR मॉडल चुनें।", + "stream_cut_backend_alive": "स्ट्रीम जल्दी ख़त्म हो गई, लेकिन बैकएंड अभी भी चल रहा है — यानी वह क्रैश नहीं हुआ। सर्वर या कंटेनर सेटअप में इसका कारण आमतौर पर कोई रिवर्स प्रॉक्सी या लोड बैलेंसर होता है जो कनेक्शन को बफ़र करता है या टाइमआउट पर काट देता है: इस रूट के लिए रिस्पॉन्स बफ़रिंग बंद करें (nginx: proxy_buffering off; X-Accel-Buffering: no) और उसका रीड टाइमआउट बढ़ाएँ। डेस्कटॉप ऐप को सीधे चलाना, या बिना प्रॉक्सी के localhost पर चलाना, इसकी पुष्टि कर देगा।" }, "keyboard": { "title": "कीबोर्ड शॉर्टकट", @@ -2535,5 +2575,22 @@ "longform": "कहानी वर्णन", "asr": "ट्रांसक्रिप्शन" } + }, + "compute": { + "add_machine": "मशीन जोड़ें", + "manage": "रिमोट वर्कर सेटिंग्स", + "off_hint": "सब कुछ इसी मशीन पर चलता है। दूसरी मशीन उपयोग करने के लिए रिमोट चालू करें।", + "quick_settings": "कंप्यूट — काम कहाँ चलते हैं", + "remote": "रिमोट", + "title": "काम कहाँ चलते हैं", + "token_once": "इसे दूसरी मशीन पर स्कैन करें या पेस्ट करें। सिर्फ़ एक बार दिखाया जाता है।" + }, + "voices": { + "active": "सक्रिय आवाज़", + "active_clone_recipe": "आपकी संदर्भ क्लिप से क्लोन की गई", + "cta_clone": "एक क्लोन करने के लिए आवाज ← में 3 सेकंड की क्लिप छोड़ें", + "cta_design": "डिज़ाइन करने के लिए आवाज ← में उसका वर्णन करें", + "new": "नई आवाज़", + "none_selected": "कोई आवाज़ चुनी नहीं गई — किसी का वर्णन करें, ऑडियो छोड़ें, या नीचे से चुनें।" } } diff --git a/frontend/src/i18n/locales/id.json b/frontend/src/i18n/locales/id.json index 53ef9fee..52e3475b 100644 --- a/frontend/src/i18n/locales/id.json +++ b/frontend/src/i18n/locales/id.json @@ -282,7 +282,36 @@ "models_dir_effective": "Sedang digunakan", "models_dir_configured": "Dikonfigurasi", "models_dir_default": "Menggunakan bawaan", - "models_dir_restart": "↻ Mulai ulang VoiceStudio untuk memakai lokasi baru." + "models_dir_restart": "↻ Mulai ulang VoiceStudio untuk memakai lokasi baru.", + "worker_join": "Gabung", + "worker_join_code": "Kode gabung", + "worker_join_code_hint": "Sekali pakai dan kedaluwarsa dalam 15 menit. Buat di mesin yang akan mengirim pekerjaan.", + "worker_join_desc": "Izinkan salinan VoiceStudio lain mengirim pekerjaan ke mesin ini. Tempelkan kode gabung yang ditampilkannya — atau pindai QR-nya dengan ponsel Anda lalu tempelkan di sini.", + "worker_join_env": "OMNIVOICE_WORKER_MODE disetel di lingkungan mesin ini, jadi itulah yang menentukan — ubah di sana.", + "worker_join_no_endpoint": "Tidak ada control plane yang tersimpan.", + "worker_join_ok": "Bergabung. Mesin ini sekarang menerima pekerjaan.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Gabung ke yang lain", + "worker_join_stopped": "Berhenti", + "worker_join_take_work": "Menerima pekerjaan dari", + "worker_join_title": "Pinjamkan GPU mesin ini", + "worker_join_working": "Bekerja", + "workers_add_hint_qr": "Buat token, lalu pindai QR dari mesin yang lain atau tempelkan kodenya ke pengaturan Pekerja jarak jauh di sana.", + "workers_approve": "Setujui", + "workers_last_seen": "terakhir terlihat {{when}}", + "workers_qr_alt": "Kode QR yang memuat kode ini — pindai dari mesin yang lain", + "workers_secret_done": "Selesai", + "workers_seen_hr": "{{count}} jam lalu", + "workers_seen_min": "{{count}} mnt lalu", + "workers_seen_now": "baru saja", + "workers_step_1": "Pasang VoiceStudio di mesin yang memiliki GPU.", + "workers_step_2": "Buat token di atas.", + "workers_step_3": "Pindai QR di sana, atau tempelkan kodenya ke pengaturan Pekerja jarak jauh di sana.", + "workers_summary_none": "Tidak ada yang terhubung", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Kedaluwarsa — buat yang baru", + "workers_token_expires_in": "Kedaluwarsa dalam {{time}}", + "workers_token_qr_hint": "Di mesin yang lain: Pengaturan → Sistem → Pekerja jarak jauh → Gabung, lalu pindai atau tempelkan." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "Dari audio", "define_voice": "Tentukan suara", "save_design_as_profile": "Simpan desain sebagai profil", + "generating_done_status": "Pembuatan selesai", + "generating_status": "Membuat audio…", + "identity": "Identitas", + "identity_auto": "Otomatis — model yang menentukan", + "insert": "Sisipkan", + "insert_token": "Sisipkan token ekspresi", + "script": "Naskah", + "starting_points": "Titik awal", + "voice_kicker": "Suara", "seed_label": "Benih", "seed_placeholder": "acak setiap saat", "seed_keep": "Simpan benih ini", @@ -1590,7 +1628,9 @@ "searchIssues": "Cari masalah serupa", "unexpected": "Kesalahan tak terduga: {{message}}", "backend_shutting_down": "VoiceStudio sedang ditutup. Buka kembali aplikasinya lalu coba lagi.", - "crash_broken_env": "Ia mati saat memuat dependensi Python-nya sendiri, jadi ini bukan soal memori atau GPU Anda — lingkungannya tidak lengkap atau tertinggal setengah diperbarui. Gunakan \"Clean & Retry\" di Pengaturan → Log → Backend, yang membangunnya ulang dari nol dan memperbaikinya di tempat, tanpa menyentuh suara atau proyek Anda. Jika masih gagal, detail crash menyebutkan paket persis yang gagal diimpor." + "crash_broken_env": "Ia mati saat memuat dependensi Python-nya sendiri, jadi ini bukan soal memori atau GPU Anda — lingkungannya tidak lengkap atau tertinggal setengah diperbarui. Gunakan \"Bersihkan & Coba Lagi\" di Pengaturan → Log → Backend, yang membangunnya ulang dari nol dan memperbaikinya di tempat, tanpa menyentuh suara atau proyek Anda. Jika masih gagal, detail crash menyebutkan paket persis yang gagal diimpor.", + "crash_vram_default": "Pada GPU yang lebih kecil, penyebab umumnya adalah kehabisan VRAM saat memuat model ASR di atas model TTS: kosongkan model TTS terlebih dahulu, atau pilih model ASR yang lebih kecil di Katalog model → Model.", + "stream_cut_backend_alive": "Stream berakhir lebih awal, tetapi backend masih berjalan — jadi backend tidak mogok. Pada penyiapan server atau kontainer, ini biasanya karena reverse proxy atau load balancer yang mem-buffer atau memutus koneksi karena batas waktu: nonaktifkan buffering respons untuk rute ini (nginx: proxy_buffering off; X-Accel-Buffering: no) dan naikkan batas waktu bacanya. Menjalankan aplikasi desktop secara langsung, atau di localhost tanpa proxy, akan memastikannya." }, "keyboard": { "title": "Pintasan keyboard", @@ -2535,5 +2575,22 @@ "longform": "narasi cerita", "asr": "transkripsi" } + }, + "compute": { + "add_machine": "Tambahkan mesin", + "manage": "Pengaturan pekerja jarak jauh", + "off_hint": "Semuanya berjalan di mesin ini. Aktifkan Jarak jauh untuk memakai mesin lain.", + "quick_settings": "Komputasi — tempat pekerjaan berjalan", + "remote": "Jarak jauh", + "title": "Tempat pekerjaan berjalan", + "token_once": "Pindai atau tempelkan ini di mesin yang lain. Hanya ditampilkan sekali." + }, + "voices": { + "active": "Suara aktif", + "active_clone_recipe": "Dikloning dari klip referensi Anda", + "cta_clone": "Letakkan klip 3 detik di Suara ← untuk mengkloning suara", + "cta_design": "Deskripsikan suara di Suara ← untuk mendesainnya", + "new": "Suara baru", + "none_selected": "Belum ada suara yang dipilih — deskripsikan satu, letakkan audio, atau pilih di bawah." } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index d5fbb980..d665480b 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -282,7 +282,36 @@ "models_dir_effective": "In uso ora", "models_dir_configured": "Configurata", "models_dir_default": "Uso predefinito", - "models_dir_restart": "↻ Riavvia VoiceStudio per usare la nuova posizione." + "models_dir_restart": "↻ Riavvia VoiceStudio per usare la nuova posizione.", + "worker_join": "Collegati", + "worker_join_code": "Codice di collegamento", + "worker_join_code_hint": "Monouso e scade tra 15 minuti. Generalo sul computer che invierà il lavoro.", + "worker_join_desc": "Consenti a un'altra copia di VoiceStudio di inviare lavori a questo computer. Incolla il codice di collegamento che ti ha mostrato — oppure scansiona il suo QR con il telefono e incollalo qui.", + "worker_join_env": "OMNIVOICE_WORKER_MODE è impostato nell'ambiente di questo computer, quindi decide lui — modificalo lì.", + "worker_join_no_endpoint": "Nessun piano di controllo memorizzato.", + "worker_join_ok": "Collegato. Questo computer ora accetta lavoro.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Collegati a un altro", + "worker_join_stopped": "Fermato", + "worker_join_take_work": "Accetta lavoro da", + "worker_join_title": "Presta la GPU di questo computer", + "worker_join_working": "Al lavoro", + "workers_add_hint_qr": "Genera un token, poi scansiona il QR dall'altro computer o incolla il codice nelle sue impostazioni Worker remoti.", + "workers_approve": "Approva", + "workers_last_seen": "visto l'ultima volta {{when}}", + "workers_qr_alt": "Codice QR con questo codice — scansionalo dall'altro computer", + "workers_secret_done": "Fatto", + "workers_seen_hr": "{{count}} h fa", + "workers_seen_min": "{{count}} min fa", + "workers_seen_now": "proprio ora", + "workers_step_1": "Installa VoiceStudio sul computer con la GPU.", + "workers_step_2": "Genera un token qui sopra.", + "workers_step_3": "Scansiona lì il QR, oppure incolla il codice nelle sue impostazioni Worker remoti.", + "workers_summary_none": "Nessuno connesso", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Scaduto — generane uno nuovo", + "workers_token_expires_in": "Scade tra {{time}}", + "workers_token_qr_hint": "Sull'altro computer: Impostazioni → Sistema → Worker remoti → Collegati, poi scansiona o incolla." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "Da audio", "define_voice": "Definisci la voce", "save_design_as_profile": "Salva il progetto come profilo", + "generating_done_status": "Generazione completata", + "generating_status": "Generazione dell'audio…", + "identity": "Identità", + "identity_auto": "Auto — decide il modello", + "insert": "Inserisci", + "insert_token": "Inserisci token di espressione", + "script": "Copione", + "starting_points": "Punti di partenza", + "voice_kicker": "Voce", "seed_label": "Seme", "seed_placeholder": "casuale ogni volta", "seed_keep": "Conserva questo seme", @@ -1590,7 +1628,9 @@ "searchIssues": "Cerca problemi simili", "unexpected": "Errore imprevisto: {{message}}", "backend_shutting_down": "VoiceStudio si sta chiudendo. Riapri l’app e riprova.", - "crash_broken_env": "È morto mentre caricava le proprie dipendenze Python, quindi non c'entrano né la memoria né la GPU: l'ambiente è incompleto o è rimasto aggiornato a metà. Usa «Clean & Retry» in Impostazioni → Log → Backend, che lo ricostruisce da zero e lo ripara sul posto, senza toccare le tue voci o i tuoi progetti. Se continua a fallire, i dettagli del crash indicano il pacchetto che non si importava." + "crash_broken_env": "È morto mentre caricava le proprie dipendenze Python, quindi non c'entrano né la memoria né la GPU: l'ambiente è incompleto o è rimasto aggiornato a metà. Usa «Pulisci e riprova» in Impostazioni → Log → Backend, che lo ricostruisce da zero e lo ripara sul posto, senza toccare le tue voci o i tuoi progetti. Se continua a fallire, i dettagli del crash indicano il pacchetto che non si importava.", + "crash_vram_default": "Sulle GPU più piccole la causa più comune è l'esaurimento della VRAM quando il modello ASR viene caricato insieme al modello TTS: scarica prima il modello TTS, oppure scegli un modello ASR più piccolo in Catalogo modelli → Modelli.", + "stream_cut_backend_alive": "Lo stream è terminato in anticipo, ma il backend è ancora in esecuzione — quindi non è andato in crash. In una configurazione servita o containerizzata di solito è un reverse proxy o un load balancer che bufferizza la connessione o la interrompe per timeout: disattiva il buffering delle risposte per questa route (nginx: proxy_buffering off; X-Accel-Buffering: no) e aumenta il suo timeout di lettura. Eseguire l'app desktop direttamente, o su localhost senza proxy, lo confermerà." }, "keyboard": { "title": "Scorciatoie da tastiera", @@ -2535,5 +2575,22 @@ "longform": "narrazione di storie", "asr": "trascrizione" } + }, + "compute": { + "add_machine": "Aggiungi un computer", + "manage": "Impostazioni dei worker remoti", + "off_hint": "Tutto viene eseguito su questo computer. Attiva Remoto per usarne un altro.", + "quick_settings": "Calcolo — dove vengono eseguiti i lavori", + "remote": "Remoto", + "title": "Dove vengono eseguiti i lavori", + "token_once": "Scansionalo o incollalo sull'altro computer. Mostrato una sola volta." + }, + "voices": { + "active": "Voce attiva", + "active_clone_recipe": "Clonata dalla tua clip di riferimento", + "cta_clone": "Trascina una clip di 3 s in Voce ← per clonarne una", + "cta_design": "Descrivine una in Voce ← per progettarla", + "new": "Nuova voce", + "none_selected": "Nessuna voce selezionata — descrivine una, trascina un audio o scegli qui sotto." } } diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index efb088b0..3a0d2d5f 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -282,7 +282,36 @@ "models_dir_effective": "現在使用中", "models_dir_configured": "設定済み", "models_dir_default": "既定値を使用", - "models_dir_restart": "↻ 新しい場所を使用するには VoiceStudio を再起動してください。" + "models_dir_restart": "↻ 新しい場所を使用するには VoiceStudio を再起動してください。", + "worker_join": "参加", + "worker_join_code": "参加コード", + "worker_join_code_hint": "使い切りで、15 分で期限切れになります。作業を送る側のマシンで生成してください。", + "worker_join_desc": "別の VoiceStudio からこのマシンにジョブを送れるようにします。表示された参加コードを貼り付けるか、その QR をスマートフォンでスキャンしてここに貼り付けてください。", + "worker_join_env": "このマシンの環境変数に OMNIVOICE_WORKER_MODE が設定されているため、そちらが優先されます。変更はそちらで行ってください。", + "worker_join_no_endpoint": "記憶されたコントロールプレーンはありません。", + "worker_join_ok": "参加しました。このマシンは作業を受け付けています。", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "別のものに参加", + "worker_join_stopped": "停止中", + "worker_join_take_work": "作業の受け取り元", + "worker_join_title": "このマシンの GPU を貸す", + "worker_join_working": "作業中", + "workers_add_hint_qr": "トークンを生成し、もう一方のマシンから QR をスキャンするか、そのマシンの「リモートワーカー」設定にコードを貼り付けてください。", + "workers_approve": "承認", + "workers_last_seen": "最終確認 {{when}}", + "workers_qr_alt": "このコードを含む QR コード — もう一方のマシンからスキャンしてください", + "workers_secret_done": "完了", + "workers_seen_hr": "{{count}} 時間前", + "workers_seen_min": "{{count}} 分前", + "workers_seen_now": "たった今", + "workers_step_1": "GPU のあるマシンに VoiceStudio をインストールします。", + "workers_step_2": "上でトークンを生成します。", + "workers_step_3": "そのマシンで QR をスキャンするか、そのマシンの「リモートワーカー」設定にコードを貼り付けます。", + "workers_summary_none": "接続なし", + "workers_summary_online": "{{count}} 台オンライン", + "workers_token_expired": "期限切れ — 新しく生成してください", + "workers_token_expires_in": "あと {{time}} で期限切れ", + "workers_token_qr_hint": "もう一方のマシンで: 設定 → システム → リモートワーカー → 参加 を開き、スキャンするか貼り付けてください。" }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "音声から", "define_voice": "音声の定義", "save_design_as_profile": "デザインをプロファイルとして保存", + "generating_done_status": "生成が完了しました", + "generating_status": "音声を生成中…", + "identity": "声の個性", + "identity_auto": "自動 — モデルが判断します", + "insert": "挿入", + "insert_token": "表現トークンを挿入", + "script": "台本", + "starting_points": "出発点", + "voice_kicker": "声", "seed_label": "種子", "seed_placeholder": "毎回ランダム", "seed_keep": "この種を保管しておいてください", @@ -1590,7 +1628,9 @@ "searchIssues": "類似の問題を検索", "unexpected": "予期しないエラー: {{message}}", "backend_shutting_down": "VoiceStudio を終了しています。アプリを開き直してからもう一度お試しください。", - "crash_broken_env": "自身の Python 依存関係を読み込んでいる最中に停止しました。メモリや GPU の問題ではなく、環境が不完全か、更新が中途半端なまま残っています。設定 → ログ → バックエンド の「Clean & Retry」を実行してください。環境をゼロから作り直してその場で修復し、音声やプロジェクトには手を触れません。それでも失敗する場合は、クラッシュ詳細に読み込めなかったパッケージ名が出ています。" + "crash_broken_env": "自身の Python 依存関係を読み込んでいる最中に停止しました。メモリや GPU の問題ではなく、環境が不完全か、更新が中途半端なまま残っています。設定 → ログ → バックエンド の「クリーンアップして再試行」を実行してください。環境をゼロから作り直してその場で修復し、音声やプロジェクトには手を触れません。それでも失敗する場合は、クラッシュ詳細に読み込めなかったパッケージ名が出ています。", + "crash_vram_default": "小さめの GPU では、TTS モデルを読み込んだまま ASR モデルを読み込む際に VRAM が不足するのがよくある原因です。先に TTS モデルをアンロードするか、モデルカタログ → モデル でより小さい ASR モデルを選んでください。", + "stream_cut_backend_alive": "ストリームは途中で終了しましたが、バックエンドはまだ動作しています。つまりクラッシュではありません。サーバー経由やコンテナ環境では、リバースプロキシやロードバランサーが接続をバッファリングまたはタイムアウトさせているのがよくある原因です。このルートのレスポンスバッファリングを無効にし(nginx: proxy_buffering off; X-Accel-Buffering: no)、読み取りタイムアウトを延ばしてください。デスクトップアプリを直接実行するか、プロキシなしの localhost で実行すれば確認できます。" }, "keyboard": { "title": "キーボードショートカット", @@ -2535,5 +2575,22 @@ "longform": "ストーリー朗読", "asr": "文字起こし" } + }, + "compute": { + "add_machine": "マシンを追加", + "manage": "リモートワーカー設定", + "off_hint": "すべてこのマシンで実行されます。他のマシンを使うにはリモートをオンにしてください。", + "quick_settings": "コンピュート — ジョブの実行場所", + "remote": "リモート", + "title": "ジョブの実行場所", + "token_once": "もう一方のマシンでこれをスキャンするか貼り付けてください。表示は一度だけです。" + }, + "voices": { + "active": "アクティブな声", + "active_clone_recipe": "リファレンスクリップからクローンされました", + "cta_clone": "「声」← に 3 秒のクリップをドロップしてクローン", + "cta_design": "「声」← で説明してデザイン", + "new": "新しい声", + "none_selected": "声が選択されていません — 説明するか、音声をドロップするか、下から選んでください。" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 6654a2d2..9c4385ae 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -282,7 +282,36 @@ "models_dir_effective": "현재 사용 중", "models_dir_configured": "설정됨", "models_dir_default": "기본값 사용", - "models_dir_restart": "↻ 새 위치를 사용하려면 VoiceStudio를 다시 시작하세요." + "models_dir_restart": "↻ 새 위치를 사용하려면 VoiceStudio를 다시 시작하세요.", + "worker_join": "참여", + "worker_join_code": "참여 코드", + "worker_join_code_hint": "일회용이며 15분 후 만료됩니다. 작업을 보낼 컴퓨터에서 생성하세요.", + "worker_join_desc": "다른 VoiceStudio가 이 컴퓨터로 작업을 보낼 수 있게 합니다. 그쪽에 표시된 참여 코드를 붙여 넣거나, QR을 휴대폰으로 스캔해 여기에 붙여 넣으세요.", + "worker_join_env": "이 컴퓨터의 환경에 OMNIVOICE_WORKER_MODE가 설정되어 있어 그 값이 우선합니다. 변경은 거기에서 하세요.", + "worker_join_no_endpoint": "기억된 컨트롤 플레인이 없습니다.", + "worker_join_ok": "참여했습니다. 이 컴퓨터가 이제 작업을 받고 있습니다.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "다른 곳에 참여", + "worker_join_stopped": "중지됨", + "worker_join_take_work": "작업을 받는 곳", + "worker_join_title": "이 컴퓨터의 GPU 빌려주기", + "worker_join_working": "작업 중", + "workers_add_hint_qr": "토큰을 생성한 뒤 다른 컴퓨터에서 QR을 스캔하거나, 그 컴퓨터의 원격 워커 설정에 코드를 붙여 넣으세요.", + "workers_approve": "승인", + "workers_last_seen": "마지막 접속 {{when}}", + "workers_qr_alt": "이 코드를 담은 QR 코드 — 다른 컴퓨터에서 스캔하세요", + "workers_secret_done": "완료", + "workers_seen_hr": "{{count}}시간 전", + "workers_seen_min": "{{count}}분 전", + "workers_seen_now": "방금 전", + "workers_step_1": "GPU가 있는 컴퓨터에 VoiceStudio를 설치하세요.", + "workers_step_2": "위에서 토큰을 생성하세요.", + "workers_step_3": "거기에서 QR을 스캔하거나, 그 컴퓨터의 원격 워커 설정에 코드를 붙여 넣으세요.", + "workers_summary_none": "연결된 워커 없음", + "workers_summary_online": "{{count}}대 온라인", + "workers_token_expired": "만료됨 — 새로 생성하세요", + "workers_token_expires_in": "{{time}} 후 만료", + "workers_token_qr_hint": "다른 컴퓨터에서: 설정 → 시스템 → 원격 워커 → 참여로 이동한 뒤 스캔하거나 붙여 넣으세요." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "오디오에서", "define_voice": "음성 정의", "save_design_as_profile": "디자인을 프로필로 저장", + "generating_done_status": "생성 완료", + "generating_status": "오디오 생성 중…", + "identity": "목소리 특성", + "identity_auto": "자동 — 모델이 결정합니다", + "insert": "삽입", + "insert_token": "표현 토큰 삽입", + "script": "대본", + "starting_points": "시작점", + "voice_kicker": "음성", "seed_label": "종자", "seed_placeholder": "매번 무작위로", "seed_keep": "이 씨앗을 보관하세요", @@ -1590,7 +1628,9 @@ "searchIssues": "유사한 문제 검색", "unexpected": "예기치 않은 오류: {{message}}", "backend_shutting_down": "VoiceStudio를 종료하는 중입니다. 앱을 다시 열고 시도하세요.", - "crash_broken_env": "자체 Python 의존성을 불러오는 도중에 종료됐습니다. 메모리나 GPU 문제가 아니라 환경이 불완전하거나 업데이트가 중간에 멈춘 상태입니다. 설정 → 로그 → 백엔드 의 \"Clean & Retry\"를 사용하세요. 환경을 처음부터 다시 만들어 그 자리에서 복구하며, 음성이나 프로젝트는 건드리지 않습니다. 그래도 실패하면 크래시 세부 정보에 가져오지 못한 패키지 이름이 나옵니다." + "crash_broken_env": "자체 Python 의존성을 불러오는 도중에 종료됐습니다. 메모리나 GPU 문제가 아니라 환경이 불완전하거나 업데이트가 중간에 멈춘 상태입니다. 설정 → 로그 → 백엔드 의 \"정리 후 재시도\"를 사용하세요. 환경을 처음부터 다시 만들어 그 자리에서 복구하며, 음성이나 프로젝트는 건드리지 않습니다. 그래도 실패하면 크래시 세부 정보에 가져오지 못한 패키지 이름이 나옵니다.", + "crash_vram_default": "작은 GPU에서는 TTS 모델이 로드된 상태에서 ASR 모델을 불러오는 동안 VRAM이 부족한 것이 흔한 원인입니다. 먼저 TTS 모델을 언로드하거나, 모델 카탈로그 → 모델에서 더 작은 ASR 모델을 선택하세요.", + "stream_cut_backend_alive": "스트림이 일찍 끝났지만 백엔드는 계속 실행 중입니다. 즉, 크래시는 아닙니다. 서버 또는 컨테이너 환경에서는 보통 리버스 프록시나 로드 밸런서가 연결을 버퍼링하거나 시간 초과시키는 것이 원인입니다. 이 경로의 응답 버퍼링을 끄고(nginx: proxy_buffering off; X-Accel-Buffering: no) 읽기 시간 제한을 늘리세요. 데스크톱 앱을 직접 실행하거나 프록시 없이 localhost에서 실행해 보면 확인할 수 있습니다." }, "keyboard": { "title": "키보드 단축키", @@ -2535,5 +2575,22 @@ "longform": "스토리 내레이션", "asr": "전사" } + }, + "compute": { + "add_machine": "컴퓨터 추가", + "manage": "원격 워커 설정", + "off_hint": "모든 작업이 이 컴퓨터에서 실행됩니다. 다른 컴퓨터를 사용하려면 원격을 켜세요.", + "quick_settings": "컴퓨팅 — 작업 실행 위치", + "remote": "원격", + "title": "작업 실행 위치", + "token_once": "다른 컴퓨터에서 이 코드를 스캔하거나 붙여 넣으세요. 한 번만 표시됩니다." + }, + "voices": { + "active": "사용 중인 음성", + "active_clone_recipe": "참조 클립에서 복제됨", + "cta_clone": "음성 ← 에 3초 클립을 놓아 복제하세요", + "cta_design": "음성 ← 에서 설명해 디자인하세요", + "new": "새 음성", + "none_selected": "선택된 음성이 없습니다 — 설명하거나, 오디오를 놓거나, 아래에서 선택하세요." } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index b31d5da9..a7a3d2c8 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -282,7 +282,36 @@ "models_dir_effective": "Nu in gebruik", "models_dir_configured": "Geconfigureerd", "models_dir_default": "Standaard wordt gebruikt", - "models_dir_restart": "↻ Start VoiceStudio opnieuw om de nieuwe locatie te gebruiken." + "models_dir_restart": "↻ Start VoiceStudio opnieuw om de nieuwe locatie te gebruiken.", + "worker_join": "Koppelen", + "worker_join_code": "Koppelcode", + "worker_join_code_hint": "Eenmalig te gebruiken en verloopt over 15 minuten. Genereer hem op de machine die het werk gaat versturen.", + "worker_join_desc": "Laat een andere kopie van VoiceStudio taken naar deze machine sturen. Plak de koppelcode die daar werd getoond — of scan de QR met je telefoon en plak hem hier.", + "worker_join_env": "OMNIVOICE_WORKER_MODE is ingesteld in de omgeving van deze machine, dus die bepaalt het — wijzig het daar.", + "worker_join_no_endpoint": "Geen control plane onthouden.", + "worker_join_ok": "Gekoppeld. Deze machine neemt nu werk aan.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Aan een andere koppelen", + "worker_join_stopped": "Gestopt", + "worker_join_take_work": "Werk aannemen van", + "worker_join_title": "De GPU van deze machine uitlenen", + "worker_join_working": "Aan het werk", + "workers_add_hint_qr": "Genereer een token, scan daarna de QR vanaf de andere machine of plak de code in de instellingen voor Externe workers van die machine.", + "workers_approve": "Goedkeuren", + "workers_last_seen": "laatst gezien {{when}}", + "workers_qr_alt": "QR-code met deze code — scan hem vanaf de andere machine", + "workers_secret_done": "Klaar", + "workers_seen_hr": "{{count}} u geleden", + "workers_seen_min": "{{count}} min geleden", + "workers_seen_now": "zojuist", + "workers_step_1": "Installeer VoiceStudio op de machine met de GPU.", + "workers_step_2": "Genereer hierboven een token.", + "workers_step_3": "Scan daar de QR, of plak de code in de instellingen voor Externe workers van die machine.", + "workers_summary_none": "Niemand verbonden", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Verlopen — genereer een nieuwe", + "workers_token_expires_in": "Verloopt over {{time}}", + "workers_token_qr_hint": "Op de andere machine: Instellingen → Systeem → Externe workers → Koppelen, en scan of plak daar." }, "bootstrap": { "title": "VoiceStudio", @@ -552,7 +581,16 @@ "seed_placeholder": "elke keer willekeurig", "seed_keep": "Bewaar dit zaad", "seed_reroll": "Nieuw zaad", - "seed_reroll_hint": "Rol een nieuw willekeurig zaadje en bewaar het" + "seed_reroll_hint": "Rol een nieuw willekeurig zaadje en bewaar het", + "generating_done_status": "Generatie voltooid", + "generating_status": "Audio genereren…", + "identity": "Identiteit", + "identity_auto": "Automatisch — het model beslist", + "insert": "Invoegen", + "insert_token": "Expressietoken invoegen", + "script": "Script", + "starting_points": "Startpunten", + "voice_kicker": "Stem" }, "about": { "app": "App", @@ -1590,7 +1628,9 @@ "searchIssues": "Vergelijkbare problemen zoeken", "unexpected": "Onverwachte fout: {{message}}", "backend_shutting_down": "VoiceStudio wordt afgesloten. Open de app opnieuw en probeer het nog eens.", - "crash_broken_env": "Hij stierf tijdens het laden van zijn eigen Python-afhankelijkheden, dus dit gaat niet over geheugen of je GPU — de omgeving is onvolledig of half bijgewerkt blijven staan. Gebruik \"Clean & Retry\" bij Instellingen → Logs → Backend: dat bouwt hem helemaal opnieuw op en repareert hem ter plekke, zonder je stemmen of projecten aan te raken. Blijft het misgaan, dan noemen de crashdetails het pakket dat niet te importeren was." + "crash_broken_env": "Hij stierf tijdens het laden van zijn eigen Python-afhankelijkheden, dus dit gaat niet over geheugen of je GPU — de omgeving is onvolledig of half bijgewerkt blijven staan. Gebruik \"Wissen & Opnieuw proberen\" bij Instellingen → Logs → Backend: dat bouwt hem helemaal opnieuw op en repareert hem ter plekke, zonder je stemmen of projecten aan te raken. Blijft het misgaan, dan noemen de crashdetails het pakket dat niet te importeren was.", + "crash_vram_default": "Op kleinere GPU's is de gebruikelijke oorzaak dat het VRAM-geheugen opraakt bij het laden van het ASR-model bovenop het TTS-model: ontlaad eerst het TTS-model, of kies een kleiner ASR-model in Modelcatalogus → Modellen.", + "stream_cut_backend_alive": "De stream stopte te vroeg, maar de backend draait nog — hij is dus niet gecrasht. In een server- of containeropstelling is dit meestal een reverse proxy of load balancer die de verbinding buffert of door een time-out afbreekt: schakel responsbuffering voor deze route uit (nginx: proxy_buffering off; X-Accel-Buffering: no) en verhoog de leestime-out ervan. Draai je de desktopapp direct, of op localhost zonder proxy, dan bevestigt dat het." }, "keyboard": { "title": "Sneltoetsen", @@ -2535,5 +2575,22 @@ "longform": "verhaalvertelling", "asr": "transcriptie" } + }, + "compute": { + "add_machine": "Machine toevoegen", + "manage": "Instellingen voor externe workers", + "off_hint": "Alles draait op deze machine. Zet Extern aan om een andere te gebruiken.", + "quick_settings": "Rekenkracht — waar taken draaien", + "remote": "Extern", + "title": "Waar taken draaien", + "token_once": "Scan of plak dit op de andere machine. Wordt maar één keer getoond." + }, + "voices": { + "active": "Actieve stem", + "active_clone_recipe": "Gekloond van je referentieclip", + "cta_clone": "Zet een clip van 3 s neer in Stem ← om er een te klonen", + "cta_design": "Beschrijf een stem in Stem ← om die te ontwerpen", + "new": "Nieuwe stem", + "none_selected": "Geen stem geselecteerd — beschrijf er een, zet audio neer of kies hieronder." } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index e42fe627..2280282c 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -282,7 +282,36 @@ "models_dir_effective": "Używane teraz", "models_dir_configured": "Skonfigurowano", "models_dir_default": "Używana domyślna", - "models_dir_restart": "↻ Uruchom ponownie VoiceStudio, aby użyć nowej lokalizacji." + "models_dir_restart": "↻ Uruchom ponownie VoiceStudio, aby użyć nowej lokalizacji.", + "worker_join": "Dołącz", + "worker_join_code": "Kod dołączania", + "worker_join_code_hint": "Jednorazowy, wygasa po 15 minutach. Wygeneruj go na komputerze, który będzie wysyłać zadania.", + "worker_join_desc": "Pozwól innej kopii VoiceStudio wysyłać zadania na ten komputer. Wklej kod dołączania, który ci pokazała — albo zeskanuj jej kod QR telefonem i wklej go tutaj.", + "worker_join_env": "W środowisku tego komputera ustawiono OMNIVOICE_WORKER_MODE, więc to ta zmienna decyduje — zmień ją tam.", + "worker_join_no_endpoint": "Brak zapamiętanego węzła sterującego.", + "worker_join_ok": "Dołączono. Ten komputer przyjmuje teraz zadania.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Dołącz do innego", + "worker_join_stopped": "Zatrzymano", + "worker_join_take_work": "Przyjmuj zadania od", + "worker_join_title": "Użycz GPU tego komputera", + "worker_join_working": "Pracuje", + "workers_add_hint_qr": "Wygeneruj token, a następnie zeskanuj kod QR z drugiego komputera albo wklej kod w jego ustawieniach „Zdalne workery”.", + "workers_approve": "Zatwierdź", + "workers_last_seen": "ostatnio widziany {{when}}", + "workers_qr_alt": "Kod QR z tym kodem — zeskanuj go z drugiego komputera", + "workers_secret_done": "Gotowe", + "workers_seen_hr": "{{count}} godz. temu", + "workers_seen_min": "{{count}} min temu", + "workers_seen_now": "przed chwilą", + "workers_step_1": "Zainstaluj VoiceStudio na komputerze z GPU.", + "workers_step_2": "Wygeneruj token powyżej.", + "workers_step_3": "Zeskanuj tam kod QR albo wklej kod w jego ustawieniach „Zdalne workery”.", + "workers_summary_none": "Nikt nie jest połączony", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Wygasł — wygeneruj nowy", + "workers_token_expires_in": "Wygasa za {{time}}", + "workers_token_qr_hint": "Na drugim komputerze: Ustawienia → Systemu → Zdalne workery → Dołącz, potem zeskanuj albo wklej." }, "bootstrap": { "title": "VoiceStudio", @@ -552,7 +581,16 @@ "seed_placeholder": "za każdym razem losowo", "seed_keep": "Zachowaj to ziarno", "seed_reroll": "Nowe ziarno", - "seed_reroll_hint": "Rzuć nowe losowe ziarno i zachowaj je" + "seed_reroll_hint": "Rzuć nowe losowe ziarno i zachowaj je", + "generating_done_status": "Generowanie zakończone", + "generating_status": "Generowanie dźwięku…", + "identity": "Tożsamość", + "identity_auto": "Auto — decyduje model", + "insert": "Wstaw", + "insert_token": "Wstaw token ekspresji", + "script": "Skrypt", + "starting_points": "Punkty wyjścia", + "voice_kicker": "Głos" }, "about": { "app": "Aplikacja", @@ -1590,7 +1628,9 @@ "searchIssues": "Szukaj podobnych problemów", "unexpected": "Nieoczekiwany błąd: {{message}}", "backend_shutting_down": "VoiceStudio się zamyka. Otwórz aplikację ponownie i spróbuj jeszcze raz.", - "crash_broken_env": "Zakończył się podczas ładowania własnych zależności Pythona, więc nie chodzi o pamięć ani o kartę graficzną — środowisko jest niekompletne albo zostało zaktualizowane w połowie. Użyj „Clean & Retry” w Ustawienia → Logi → Backend: odbudowuje je od zera i naprawia w miejscu, nie ruszając twoich głosów ani projektów. Jeśli nadal się nie udaje, szczegóły awarii wskazują pakiet, którego nie dało się zaimportować." + "crash_broken_env": "Zakończył się podczas ładowania własnych zależności Pythona, więc nie chodzi o pamięć ani o kartę graficzną — środowisko jest niekompletne albo zostało zaktualizowane w połowie. Użyj „Wyczyść i ponów” w Ustawienia → Logi → Backend: odbudowuje je od zera i naprawia w miejscu, nie ruszając twoich głosów ani projektów. Jeśli nadal się nie udaje, szczegóły awarii wskazują pakiet, którego nie dało się zaimportować.", + "crash_vram_default": "Na mniejszych GPU zwykłą przyczyną jest brak pamięci VRAM podczas ładowania modelu ASR obok już załadowanego modelu TTS: najpierw zwolnij model TTS albo wybierz mniejszy model ASR w Katalogu modeli → Modele.", + "stream_cut_backend_alive": "Strumień urwał się przedwcześnie, ale backend nadal działa — a więc nie uległ awarii. W konfiguracji serwerowej lub kontenerowej zwykle oznacza to, że odwrotne proxy albo load balancer buforuje połączenie lub przerywa je po limicie czasu: wyłącz buforowanie odpowiedzi dla tej trasy (nginx: proxy_buffering off; X-Accel-Buffering: no) i zwiększ jego limit czasu odczytu. Uruchomienie aplikacji desktopowej bezpośrednio albo na localhost bez proxy pozwoli to potwierdzić." }, "keyboard": { "title": "Skróty klawiaturowe", @@ -2535,5 +2575,22 @@ "longform": "narracja opowiadań", "asr": "transkrypcja" } + }, + "compute": { + "add_machine": "Dodaj komputer", + "manage": "Ustawienia zdalnych workerów", + "off_hint": "Wszystko działa na tym komputerze. Włącz „Zdalnie”, aby użyć innego.", + "quick_settings": "Obliczenia — gdzie wykonywane są zadania", + "remote": "Zdalnie", + "title": "Gdzie wykonywane są zadania", + "token_once": "Zeskanuj lub wklej to na drugim komputerze. Pokazywane tylko raz." + }, + "voices": { + "active": "Aktywny głos", + "active_clone_recipe": "Sklonowany z twojego klipu referencyjnego", + "cta_clone": "Upuść 3-sekundowy klip w „Głos” ←, aby sklonować głos", + "cta_design": "Opisz głos w „Głos” ←, aby go zaprojektować", + "new": "Nowy głos", + "none_selected": "Nie wybrano głosu — opisz go, upuść audio albo wybierz poniżej." } } diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 6bfb793a..7d9e6fdd 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -282,7 +282,36 @@ "models_dir_effective": "Em uso agora", "models_dir_configured": "Configurado", "models_dir_default": "Usando o padrão", - "models_dir_restart": "↻ Reinicie o VoiceStudio para usar o novo local." + "models_dir_restart": "↻ Reinicie o VoiceStudio para usar o novo local.", + "worker_join": "Associar", + "worker_join_code": "Código de associação", + "worker_join_code_hint": "De uso único e expira em 15 minutos. Gere-o na máquina que vai enviar o trabalho.", + "worker_join_desc": "Permita que outra cópia do VoiceStudio envie trabalhos para esta máquina. Cole o código de associação que ela mostrou — ou escaneie o QR dela com o telefone e cole-o aqui.", + "worker_join_env": "OMNIVOICE_WORKER_MODE está definido no ambiente desta máquina, então é ele que decide — altere-o lá.", + "worker_join_no_endpoint": "Nenhum plano de controle memorizado.", + "worker_join_ok": "Associado. Esta máquina agora aceita trabalho.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Associar a outro", + "worker_join_stopped": "Parado", + "worker_join_take_work": "Aceitar trabalho de", + "worker_join_title": "Emprestar a GPU desta máquina", + "worker_join_working": "Em atividade", + "workers_add_hint_qr": "Gere um token, depois escaneie o QR a partir da outra máquina ou cole o código nas configurações de Workers remotos dela.", + "workers_approve": "Aprovar", + "workers_last_seen": "visto pela última vez {{when}}", + "workers_qr_alt": "Código QR com este código — escaneie-o a partir da outra máquina", + "workers_secret_done": "Concluído", + "workers_seen_hr": "há {{count}} h", + "workers_seen_min": "há {{count}} min", + "workers_seen_now": "agora mesmo", + "workers_step_1": "Instale o VoiceStudio na máquina com a GPU.", + "workers_step_2": "Gere um token acima.", + "workers_step_3": "Escaneie o QR lá, ou cole o código nas configurações de Workers remotos dela.", + "workers_summary_none": "Ninguém conectado", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Expirado — gere um novo", + "workers_token_expires_in": "Expira em {{time}}", + "workers_token_qr_hint": "Na outra máquina: Configurações → Sistema → Workers remotos → Associar, depois escaneie ou cole." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "A partir de áudio", "define_voice": "Definir voz", "save_design_as_profile": "Salvar design como perfil", + "generating_done_status": "Geração concluída", + "generating_status": "Gerando áudio…", + "identity": "Identidade", + "identity_auto": "Auto — o modelo decide", + "insert": "Inserir", + "insert_token": "Inserir token de expressão", + "script": "Roteiro", + "starting_points": "Pontos de partida", + "voice_kicker": "Voz", "seed_label": "Semente", "seed_placeholder": "aleatório de cada vez", "seed_keep": "Guarde esta semente", @@ -1590,7 +1628,9 @@ "searchIssues": "Pesquisar problemas semelhantes", "unexpected": "Erro inesperado: {{message}}", "backend_shutting_down": "O VoiceStudio está sendo encerrado. Reabra o aplicativo e tente novamente.", - "crash_broken_env": "Ele morreu enquanto carregava as próprias dependências de Python, então não é questão de memória nem da sua GPU — o ambiente está incompleto ou ficou atualizado pela metade. Use \"Clean & Retry\" em Configurações → Logs → Backend, que o reconstrói do zero e o repara no lugar, sem tocar nas suas vozes ou projetos. Se continuar falhando, os detalhes da falha nomeiam o pacote exato que não importava." + "crash_broken_env": "Ele morreu enquanto carregava as próprias dependências de Python, então não é questão de memória nem da sua GPU — o ambiente está incompleto ou ficou atualizado pela metade. Use \"Limpar e Repetir\" em Configurações → Logs → Backend, que o reconstrói do zero e o repara no lugar, sem tocar nas suas vozes ou projetos. Se continuar falhando, os detalhes da falha nomeiam o pacote exato que não importava.", + "crash_vram_default": "Em GPUs menores, a causa habitual é ficar sem VRAM ao carregar o modelo ASR junto com o modelo TTS: descarregue primeiro o modelo TTS, ou escolha um modelo ASR menor em Catálogo de modelos → Modelos.", + "stream_cut_backend_alive": "O stream terminou mais cedo, mas o backend continua em execução — portanto, não travou. Em uma instalação servida ou em contêiner, isso geralmente é um proxy reverso ou balanceador de carga fazendo buffer da conexão ou encerrando-a por tempo limite: desative o buffering de resposta para esta rota (nginx: proxy_buffering off; X-Accel-Buffering: no) e aumente o tempo limite de leitura. Executar o aplicativo desktop diretamente, ou em localhost sem proxy, confirmará isso." }, "keyboard": { "title": "Atalhos de teclado", @@ -2535,5 +2575,22 @@ "longform": "narração de histórias", "asr": "transcrição" } + }, + "compute": { + "add_machine": "Adicionar uma máquina", + "manage": "Configurações de workers remotos", + "off_hint": "Tudo é executado nesta máquina. Ative Remoto para usar outra.", + "quick_settings": "Computação — onde os trabalhos são executados", + "remote": "Remoto", + "title": "Onde os trabalhos são executados", + "token_once": "Escaneie ou cole isto na outra máquina. Mostrado apenas uma vez." + }, + "voices": { + "active": "Voz ativa", + "active_clone_recipe": "Clonada a partir do seu clipe de referência", + "cta_clone": "Solte um clipe de 3 s em Voz ← para clonar uma", + "cta_design": "Descreva uma em Voz ← para desenhá-la", + "new": "Nova voz", + "none_selected": "Nenhuma voz selecionada — descreva uma, solte um áudio ou escolha abaixo." } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 5a023f9c..52b949d0 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -282,7 +282,36 @@ "models_dir_effective": "Используется сейчас", "models_dir_configured": "Настроено", "models_dir_default": "Используется по умолчанию", - "models_dir_restart": "↻ Перезапустите VoiceStudio, чтобы использовать новое расположение." + "models_dir_restart": "↻ Перезапустите VoiceStudio, чтобы использовать новое расположение.", + "worker_join": "Подключиться", + "worker_join_code": "Код подключения", + "worker_join_code_hint": "Одноразовый и истекает через 15 минут. Создайте его на машине, которая будет отправлять задачи.", + "worker_join_desc": "Разрешите другой копии VoiceStudio отправлять задачи на эту машину. Вставьте код подключения, который она показала, — или отсканируйте её QR-код телефоном и вставьте его сюда.", + "worker_join_env": "В окружении этой машины задана переменная OMNIVOICE_WORKER_MODE, поэтому решает она — меняйте её там.", + "worker_join_no_endpoint": "Управляющий узел не запомнен.", + "worker_join_ok": "Подключено. Эта машина теперь принимает задачи.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Подключиться к другой машине", + "worker_join_stopped": "Остановлено", + "worker_join_take_work": "Принимать задачи от", + "worker_join_title": "Одолжить GPU этой машины", + "worker_join_working": "Работает", + "workers_add_hint_qr": "Создайте токен, затем отсканируйте QR-код с другой машины или вставьте код в её настройки «Удалённые воркеры».", + "workers_approve": "Одобрить", + "workers_last_seen": "последний раз в сети {{when}}", + "workers_qr_alt": "QR-код с этим кодом — отсканируйте его с другой машины", + "workers_secret_done": "Готово", + "workers_seen_hr": "{{count}} ч назад", + "workers_seen_min": "{{count}} мин назад", + "workers_seen_now": "только что", + "workers_step_1": "Установите VoiceStudio на машину с GPU.", + "workers_step_2": "Создайте токен выше.", + "workers_step_3": "Отсканируйте там QR-код или вставьте код в её настройки «Удалённые воркеры».", + "workers_summary_none": "Никто не подключён", + "workers_summary_online": "{{count}} в сети", + "workers_token_expired": "Истёк — создайте новый", + "workers_token_expires_in": "Истекает через {{time}}", + "workers_token_qr_hint": "На другой машине: Настройки → Система → Удалённые воркеры → Подключиться, затем отсканируйте или вставьте." }, "bootstrap": { "title": "VoiceStudio", @@ -552,7 +581,16 @@ "seed_placeholder": "случайный каждый раз", "seed_keep": "Держи это семя", "seed_reroll": "Новое семя", - "seed_reroll_hint": "Сверните новое случайное семя и сохраните его." + "seed_reroll_hint": "Сгенерируйте новое случайное семя и сохраните его.", + "generating_done_status": "Генерация завершена", + "generating_status": "Генерируем аудио…", + "identity": "Идентичность", + "identity_auto": "Авто — решает модель", + "insert": "Вставить", + "insert_token": "Вставить токен экспрессии", + "script": "Скрипт", + "starting_points": "Отправные точки", + "voice_kicker": "Голос" }, "about": { "app": "Приложение", @@ -1590,7 +1628,9 @@ "searchIssues": "Искать похожие проблемы", "unexpected": "Непредвиденная ошибка: {{message}}", "backend_shutting_down": "VoiceStudio завершает работу. Откройте приложение заново и повторите попытку.", - "crash_broken_env": "Он завершился при загрузке собственных зависимостей Python, так что дело не в памяти и не в видеокарте — окружение неполное или обновилось наполовину. Используйте «Clean & Retry» в Настройки → Логи → Бэкенд: это пересоберёт окружение с нуля и починит его на месте, не трогая ваши голоса и проекты. Если ошибка останется, в подробностях сбоя указан пакет, который не импортировался." + "crash_broken_env": "Он завершился при загрузке собственных зависимостей Python, так что дело не в памяти и не в видеокарте — окружение неполное или обновилось наполовину. Используйте «Очистить и повторить» в Настройки → Логи → Бэкенд: это пересоберёт окружение с нуля и починит его на месте, не трогая ваши голоса и проекты. Если ошибка останется, в подробностях сбоя указан пакет, который не импортировался.", + "crash_vram_default": "На небольших видеокартах обычная причина — нехватка видеопамяти (VRAM) при загрузке модели ASR поверх модели TTS: сначала выгрузите модель TTS или выберите меньшую модель ASR в Каталоге моделей → Модели.", + "stream_cut_backend_alive": "Поток оборвался раньше времени, но бэкенд всё ещё работает — значит, он не падал. В серверной или контейнерной установке причиной обычно является обратный прокси или балансировщик нагрузки, который буферизует соединение или обрывает его по тайм-ауту: отключите буферизацию ответов для этого маршрута (nginx: proxy_buffering off; X-Accel-Buffering: no) и увеличьте его тайм-аут чтения. Запуск настольного приложения напрямую или на localhost без прокси подтвердит это." }, "keyboard": { "title": "Сочетания клавиш", @@ -2535,5 +2575,22 @@ "longform": "озвучивание историй", "asr": "расшифровка" } + }, + "compute": { + "add_machine": "Добавить машину", + "manage": "Настройки удалённых воркеров", + "off_hint": "Всё выполняется на этой машине. Включите «Удалённо», чтобы использовать другую.", + "quick_settings": "Вычисления — где выполняются задачи", + "remote": "Удалённо", + "title": "Где выполняются задачи", + "token_once": "Отсканируйте или вставьте это на другой машине. Показывается один раз." + }, + "voices": { + "active": "Активный голос", + "active_clone_recipe": "Клонирован из вашего референсного клипа", + "cta_clone": "Перетащите клип на 3 с в «Голос» ←, чтобы клонировать голос", + "cta_design": "Опишите голос в «Голос» ←, чтобы создать его", + "new": "Новый голос", + "none_selected": "Голос не выбран — опишите его, перетащите аудио или выберите ниже." } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 560e1ac3..cd9784b8 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -282,7 +282,36 @@ "models_dir_effective": "Används nu", "models_dir_configured": "Konfigurerad", "models_dir_default": "Använder standard", - "models_dir_restart": "↻ Starta om VoiceStudio för att använda den nya platsen." + "models_dir_restart": "↻ Starta om VoiceStudio för att använda den nya platsen.", + "worker_join": "Anslut", + "worker_join_code": "Anslutningskod", + "worker_join_code_hint": "Engångskod som går ut om 15 minuter. Skapa den på maskinen som ska skicka jobben.", + "worker_join_desc": "Låt en annan kopia av VoiceStudio skicka jobb till den här maskinen. Klistra in anslutningskoden den visade — eller skanna dess QR-kod med telefonen och klistra in den här.", + "worker_join_env": "OMNIVOICE_WORKER_MODE är satt i den här maskinens miljö, så den avgör — ändra den där.", + "worker_join_no_endpoint": "Ingen kontrollplan sparad.", + "worker_join_ok": "Ansluten. Den här maskinen tar nu emot jobb.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Anslut till en annan", + "worker_join_stopped": "Stoppad", + "worker_join_take_work": "Ta emot jobb från", + "worker_join_title": "Låna ut den här maskinens GPU", + "worker_join_working": "Arbetar", + "workers_add_hint_qr": "Skapa en token, skanna sedan QR-koden från den andra maskinen eller klistra in koden i dess inställningar för Fjärrarbetare.", + "workers_approve": "Godkänn", + "workers_last_seen": "senast sedd {{when}}", + "workers_qr_alt": "QR-kod med den här koden — skanna den från den andra maskinen", + "workers_secret_done": "Klar", + "workers_seen_hr": "för {{count}} tim sedan", + "workers_seen_min": "för {{count}} min sedan", + "workers_seen_now": "nyss", + "workers_step_1": "Installera VoiceStudio på maskinen med GPU:n.", + "workers_step_2": "Skapa en token ovan.", + "workers_step_3": "Skanna QR-koden där, eller klistra in koden i dess inställningar för Fjärrarbetare.", + "workers_summary_none": "Ingen ansluten", + "workers_summary_online": "{{count}} online", + "workers_token_expired": "Har gått ut — skapa en ny", + "workers_token_expires_in": "Går ut om {{time}}", + "workers_token_qr_hint": "På den andra maskinen: Inställningar → System → Fjärrarbetare → Anslut, skanna eller klistra sedan in." }, "bootstrap": { "title": "VoiceStudio", @@ -552,7 +581,16 @@ "seed_placeholder": "slumpmässigt varje gång", "seed_keep": "Behåll detta frö", "seed_reroll": "Nytt frö", - "seed_reroll_hint": "Rulla ett nytt slumpmässigt frö och behåll det" + "seed_reroll_hint": "Rulla ett nytt slumpmässigt frö och behåll det", + "generating_done_status": "Genereringen är klar", + "generating_status": "Genererar ljud…", + "identity": "Identitet", + "identity_auto": "Auto — modellen bestämmer", + "insert": "Infoga", + "insert_token": "Infoga uttryckstoken", + "script": "Manus", + "starting_points": "Utgångspunkter", + "voice_kicker": "Röst" }, "about": { "app": "App", @@ -1590,7 +1628,9 @@ "searchIssues": "Sök liknande problem", "unexpected": "Oväntat fel: {{message}}", "backend_shutting_down": "VoiceStudio stängs av. Öppna appen igen och försök på nytt.", - "crash_broken_env": "Den dog när den läste in sina egna Python-beroenden, så det handlar varken om minne eller om din GPU — miljön är ofullständig eller halvuppdaterad. Använd ”Clean & Retry” under Inställningar → Loggar → Backend, som bygger om den från grunden och reparerar den på plats utan att röra dina röster eller projekt. Misslyckas det ändå anger kraschdetaljerna exakt vilket paket som inte gick att importera." + "crash_broken_env": "Den dog när den läste in sina egna Python-beroenden, så det handlar varken om minne eller om din GPU — miljön är ofullständig eller halvuppdaterad. Använd ”Rensa & Försök igen” under Inställningar → Loggar → Backend, som bygger om den från grunden och reparerar den på plats utan att röra dina röster eller projekt. Misslyckas det ändå anger kraschdetaljerna exakt vilket paket som inte gick att importera.", + "crash_vram_default": "På mindre GPU:er är den vanliga orsaken att VRAM-minnet tar slut när ASR-modellen läses in ovanpå TTS-modellen: ladda ur TTS-modellen först, eller välj en mindre ASR-modell under Modellkatalog → Modeller.", + "stream_cut_backend_alive": "Strömmen avbröts i förtid, men backend körs fortfarande — den kraschade alltså inte. I en serverad eller containerbaserad miljö beror det oftast på en omvänd proxy eller lastbalanserare som buffrar anslutningen eller bryter den efter en tidsgräns: stäng av svarsbuffring för den här rutten (nginx: proxy_buffering off; X-Accel-Buffering: no) och höj dess tidsgräns för läsning. Att köra skrivbordsappen direkt, eller på localhost utan proxy, bekräftar det." }, "keyboard": { "title": "Kortkommandon", @@ -2535,5 +2575,22 @@ "longform": "berättarröst", "asr": "transkribering" } + }, + "compute": { + "add_machine": "Lägg till en maskin", + "manage": "Inställningar för fjärrarbetare", + "off_hint": "Allt körs på den här maskinen. Slå på Fjärr för att använda en annan.", + "quick_settings": "Beräkning — var jobben körs", + "remote": "Fjärr", + "title": "Var jobben körs", + "token_once": "Skanna eller klistra in detta på den andra maskinen. Visas bara en gång." + }, + "voices": { + "active": "Aktiv röst", + "active_clone_recipe": "Klonad från ditt referensklipp", + "cta_clone": "Släpp ett 3-sekundersklipp i Röst ← för att klona en", + "cta_design": "Beskriv en röst i Röst ← för att designa den", + "new": "Ny röst", + "none_selected": "Ingen röst vald — beskriv en, släpp ljud eller välj nedan." } } diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 3e5751c5..74c2a361 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -282,7 +282,36 @@ "models_dir_effective": "ใช้งานอยู่ตอนนี้", "models_dir_configured": "กำหนดค่าแล้ว", "models_dir_default": "ใช้ค่าเริ่มต้น", - "models_dir_restart": "↻ เริ่ม VoiceStudio ใหม่เพื่อใช้ตำแหน่งใหม่" + "models_dir_restart": "↻ เริ่ม VoiceStudio ใหม่เพื่อใช้ตำแหน่งใหม่", + "worker_join": "เข้าร่วม", + "worker_join_code": "รหัสเข้าร่วม", + "worker_join_code_hint": "ใช้ได้ครั้งเดียวและหมดอายุใน 15 นาที สร้างบนเครื่องที่จะส่งงาน", + "worker_join_desc": "ให้ VoiceStudio อีกชุดหนึ่งส่งงานมายังเครื่องนี้ วางรหัสเข้าร่วมที่แสดงให้คุณ — หรือสแกน QR ด้วยโทรศัพท์ของคุณแล้ววางที่นี่", + "worker_join_env": "OMNIVOICE_WORKER_MODE ถูกตั้งไว้ในสภาพแวดล้อมของเครื่องนี้ จึงเป็นตัวกำหนด — เปลี่ยนได้ที่นั่น", + "worker_join_no_endpoint": "ไม่มี control plane ที่บันทึกไว้", + "worker_join_ok": "เข้าร่วมแล้ว เครื่องนี้กำลังรับงานอยู่", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "เข้าร่วมเครื่องอื่น", + "worker_join_stopped": "หยุดแล้ว", + "worker_join_take_work": "รับงานจาก", + "worker_join_title": "ให้ยืม GPU ของเครื่องนี้", + "worker_join_working": "กำลังทำงาน", + "workers_add_hint_qr": "สร้างโทเค็น แล้วสแกน QR จากเครื่องอีกเครื่อง หรือวางรหัสลงในการตั้งค่าผู้ปฏิบัติงานระยะไกลของเครื่องนั้น", + "workers_approve": "อนุมัติ", + "workers_last_seen": "เห็นล่าสุด {{when}}", + "workers_qr_alt": "รหัส QR ที่บรรจุรหัสนี้ — สแกนจากเครื่องอีกเครื่อง", + "workers_secret_done": "เสร็จสิ้น", + "workers_seen_hr": "{{count}} ชม. ที่แล้ว", + "workers_seen_min": "{{count}} นาทีที่แล้ว", + "workers_seen_now": "เมื่อครู่นี้", + "workers_step_1": "ติดตั้ง VoiceStudio บนเครื่องที่มี GPU", + "workers_step_2": "สร้างโทเค็นด้านบน", + "workers_step_3": "สแกน QR ที่นั่น หรือวางรหัสลงในการตั้งค่าผู้ปฏิบัติงานระยะไกลของเครื่องนั้น", + "workers_summary_none": "ไม่มีใครเชื่อมต่อ", + "workers_summary_online": "ออนไลน์ {{count}} เครื่อง", + "workers_token_expired": "หมดอายุแล้ว — สร้างใหม่", + "workers_token_expires_in": "หมดอายุใน {{time}}", + "workers_token_qr_hint": "บนเครื่องอีกเครื่อง: การตั้งค่า → ระบบ → ผู้ปฏิบัติงานระยะไกล → เข้าร่วม แล้วสแกนหรือวาง" }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "จากเสียง", "define_voice": "กำหนดเสียง", "save_design_as_profile": "บันทึกการออกแบบเป็นโปรไฟล์", + "generating_done_status": "สร้างเสร็จแล้ว", + "generating_status": "กำลังสร้างเสียง…", + "identity": "ตัวตน", + "identity_auto": "อัตโนมัติ — ให้โมเดลตัดสินใจ", + "insert": "แทรก", + "insert_token": "แทรกโทเค็นการแสดงออก", + "script": "สคริปต์", + "starting_points": "จุดเริ่มต้น", + "voice_kicker": "เสียง", "seed_label": "เมล็ดพันธุ์", "seed_placeholder": "สุ่มในแต่ละครั้ง", "seed_keep": "เก็บเมล็ดพันธุ์นี้ไว้", @@ -1590,7 +1628,9 @@ "searchIssues": "ค้นหาปัญหาที่คล้ายกัน", "unexpected": "ข้อผิดพลาดที่ไม่คาดคิด: {{message}}", "backend_shutting_down": "VoiceStudio กำลังปิดอยู่ เปิดแอปอีกครั้งแล้วลองใหม่", - "crash_broken_env": "มันหยุดทำงานขณะโหลดไลบรารี Python ของตัวเอง จึงไม่ใช่เรื่องหน่วยความจำหรือ GPU ของคุณ — สภาพแวดล้อมไม่สมบูรณ์หรืออัปเดตค้างอยู่ครึ่งทาง ใช้ \"Clean & Retry\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ ซึ่งจะสร้างใหม่ทั้งหมดและซ่อมให้ในที่เดิม โดยไม่แตะต้องเสียงหรือโปรเจกต์ของคุณ หากยังล้มเหลว รายละเอียดข้อขัดข้องจะระบุแพ็กเกจที่นำเข้าไม่ได้" + "crash_broken_env": "มันหยุดทำงานขณะโหลดไลบรารี Python ของตัวเอง จึงไม่ใช่เรื่องหน่วยความจำหรือ GPU ของคุณ — สภาพแวดล้อมไม่สมบูรณ์หรืออัปเดตค้างอยู่ครึ่งทาง ใช้ \"ล้างข้อมูลและลองใหม่\" ใน การตั้งค่า → บันทึก → แบ็กเอนด์ ซึ่งจะสร้างใหม่ทั้งหมดและซ่อมให้ในที่เดิม โดยไม่แตะต้องเสียงหรือโปรเจกต์ของคุณ หากยังล้มเหลว รายละเอียดข้อขัดข้องจะระบุแพ็กเกจที่นำเข้าไม่ได้", + "crash_vram_default": "บน GPU ขนาดเล็ก สาเหตุที่พบบ่อยคือ VRAM หมดขณะโหลดโมเดล ASR ซ้อนบนโมเดล TTS ให้ล้างโมเดล TTS ออกก่อน หรือเลือกโมเดล ASR ที่เล็กกว่าใน แค็ตตาล็อกโมเดล → โมเดล", + "stream_cut_backend_alive": "สตรีมจบก่อนเวลา แต่แบ็กเอนด์ยังทำงานอยู่ จึงไม่ได้ล่ม ในการติดตั้งแบบเซิร์ฟเวอร์หรือคอนเทนเนอร์ สาเหตุมักเป็น reverse proxy หรือ load balancer ที่บัฟเฟอร์หรือตัดการเชื่อมต่อเมื่อหมดเวลา ให้ปิดการบัฟเฟอร์การตอบสนองของเส้นทางนี้ (nginx: proxy_buffering off; X-Accel-Buffering: no) และเพิ่ม read timeout ของมัน การรันแอปเดสก์ท็อปโดยตรง หรือรันบน localhost โดยไม่มีพร็อกซี จะช่วยยืนยันได้" }, "keyboard": { "title": "แป้นพิมพ์ลัด", @@ -2535,5 +2575,22 @@ "longform": "การบรรยายเรื่อง", "asr": "การถอดเสียง" } + }, + "compute": { + "add_machine": "เพิ่มเครื่อง", + "manage": "การตั้งค่าผู้ปฏิบัติงานระยะไกล", + "off_hint": "ทุกอย่างทำงานบนเครื่องนี้ เปิดโหมดระยะไกลเพื่อใช้เครื่องอื่น", + "quick_settings": "การประมวลผล — งานรันที่ไหน", + "remote": "ระยะไกล", + "title": "งานรันที่ไหน", + "token_once": "สแกนหรือวางสิ่งนี้บนเครื่องอีกเครื่อง แสดงเพียงครั้งเดียว" + }, + "voices": { + "active": "เสียงที่ใช้งาน", + "active_clone_recipe": "โคลนจากคลิปอ้างอิงของคุณ", + "cta_clone": "วางคลิป 3 วินาทีในแท็บ เสียง ← เพื่อโคลนเสียง", + "cta_design": "อธิบายเสียงในแท็บ เสียง ← เพื่อออกแบบเสียง", + "new": "เสียงใหม่", + "none_selected": "ยังไม่ได้เลือกเสียง — อธิบายเสียง วางไฟล์เสียง หรือเลือกด้านล่าง" } } diff --git a/frontend/src/i18n/locales/tr.json b/frontend/src/i18n/locales/tr.json index 961305ac..70a08e0e 100644 --- a/frontend/src/i18n/locales/tr.json +++ b/frontend/src/i18n/locales/tr.json @@ -282,7 +282,36 @@ "models_dir_effective": "Şu anda kullanılan", "models_dir_configured": "Yapılandırılan", "models_dir_default": "Varsayılan kullanılıyor", - "models_dir_restart": "↻ Yeni konumu kullanmak için VoiceStudio’yu yeniden başlatın." + "models_dir_restart": "↻ Yeni konumu kullanmak için VoiceStudio’yu yeniden başlatın.", + "worker_join": "Katıl", + "worker_join_code": "Katılım kodu", + "worker_join_code_hint": "Tek kullanımlıktır ve 15 dakika içinde geçerliliğini yitirir. İşi gönderecek makinede oluşturun.", + "worker_join_desc": "Başka bir VoiceStudio kopyasının bu makineye iş göndermesine izin verin. Orada gösterilen katılım kodunu yapıştırın — veya QR'ını telefonunuzla tarayıp buraya yapıştırın.", + "worker_join_env": "Bu makinenin ortamında OMNIVOICE_WORKER_MODE ayarlı olduğundan karar onundur — değişikliği orada yapın.", + "worker_join_no_endpoint": "Kayıtlı bir kontrol düzlemi yok.", + "worker_join_ok": "Katıldı. Bu makine artık iş alıyor.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Başka birine katıl", + "worker_join_stopped": "Durduruldu", + "worker_join_take_work": "İş alınan kaynak", + "worker_join_title": "Bu makinenin GPU'sunu ödünç ver", + "worker_join_working": "Çalışıyor", + "workers_add_hint_qr": "Bir belirteç oluşturun, ardından diğer makineden QR'ı tarayın veya kodu o makinenin Uzak işçiler ayarlarına yapıştırın.", + "workers_approve": "Onayla", + "workers_last_seen": "son görülme {{when}}", + "workers_qr_alt": "Bu kodu taşıyan QR kodu — diğer makineden tarayın", + "workers_secret_done": "Bitti", + "workers_seen_hr": "{{count}} sa önce", + "workers_seen_min": "{{count}} dk önce", + "workers_seen_now": "az önce", + "workers_step_1": "GPU'lu makineye VoiceStudio'yu kurun.", + "workers_step_2": "Yukarıda bir belirteç oluşturun.", + "workers_step_3": "Orada QR'ı tarayın veya kodu o makinenin Uzak işçiler ayarlarına yapıştırın.", + "workers_summary_none": "Bağlı kimse yok", + "workers_summary_online": "{{count}} çevrimiçi", + "workers_token_expired": "Süresi doldu — yenisini oluşturun", + "workers_token_expires_in": "{{time}} içinde süresi dolacak", + "workers_token_qr_hint": "Diğer makinede: Ayarlar → Sistem → Uzak işçiler → Katıl, sonra tarayın veya yapıştırın." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "Sesten", "define_voice": "Sesi tanımla", "save_design_as_profile": "Tasarımı profil olarak kaydet", + "generating_done_status": "Oluşturma tamamlandı", + "generating_status": "Ses oluşturuluyor…", + "identity": "Ses kimliği", + "identity_auto": "Otomatik — model karar verir", + "insert": "Ekle", + "insert_token": "İfade belirteci ekle", + "script": "Metin", + "starting_points": "Başlangıç noktaları", + "voice_kicker": "Ses", "seed_label": "tohum", "seed_placeholder": "her seferinde rastgele", "seed_keep": "Bu tohumu sakla", @@ -1590,7 +1628,9 @@ "searchIssues": "Benzer sorunları ara", "unexpected": "Beklenmeyen hata: {{message}}", "backend_shutting_down": "VoiceStudio kapanıyor. Uygulamayı yeniden açıp tekrar dene.", - "crash_broken_env": "Kendi Python bağımlılıklarını yüklerken sonlandı; yani sorun bellek ya da ekran kartınız değil — ortam eksik veya yarım güncellenmiş durumda. Ayarlar → Günlükler → Arka uç bölümündeki \"Clean & Retry\" seçeneğini kullanın: ortamı sıfırdan yeniden kurar ve seslerinize ya da projelerinize dokunmadan yerinde onarır. Yine de başarısız olursa, çökme ayrıntıları içe aktarılamayan paketin adını verir." + "crash_broken_env": "Kendi Python bağımlılıklarını yüklerken sonlandı; yani sorun bellek ya da ekran kartınız değil — ortam eksik veya yarım güncellenmiş durumda. Ayarlar → Günlükler → Arka uç bölümündeki \"Temizle ve Yeniden Dene\" seçeneğini kullanın: ortamı sıfırdan yeniden kurar ve seslerinize ya da projelerinize dokunmadan yerinde onarır. Yine de başarısız olursa, çökme ayrıntıları içe aktarılamayan paketin adını verir.", + "crash_vram_default": "Küçük GPU'larda en yaygın neden, TTS modeli yüklüyken ASR modelini de yüklerken VRAM'in tükenmesidir: önce TTS modelini bellekten kaldırın veya Model kataloğu → Modeller bölümünden daha küçük bir ASR modeli seçin.", + "stream_cut_backend_alive": "Akış erken sona erdi ama arka uç hâlâ çalışıyor — yani çökmedi. Sunucu veya konteyner kurulumlarında bunun nedeni genellikle bir ters vekilin (reverse proxy) ya da yük dengeleyicinin bağlantıyı arabelleğe alması veya zaman aşımına uğratmasıdır: bu yol için yanıt arabelleğe almayı kapatın (nginx: proxy_buffering off; X-Accel-Buffering: no) ve okuma zaman aşımını artırın. Masaüstü uygulamasını doğrudan ya da vekil olmadan localhost üzerinde çalıştırmak bunu doğrular." }, "keyboard": { "title": "Klavye kısayolları", @@ -2535,5 +2575,22 @@ "longform": "hikâye seslendirme", "asr": "deşifre" } + }, + "compute": { + "add_machine": "Makine ekle", + "manage": "Uzak işçi ayarları", + "off_hint": "Her şey bu makinede çalışır. Başka bir makine kullanmak için Uzak'ı açın.", + "quick_settings": "Hesaplama — işlerin çalıştığı yer", + "remote": "Uzak", + "title": "İşlerin çalıştığı yer", + "token_once": "Bunu diğer makinede tarayın veya yapıştırın. Yalnızca bir kez gösterilir." + }, + "voices": { + "active": "Etkin ses", + "active_clone_recipe": "Referans klibinizden klonlandı", + "cta_clone": "Klonlamak için Ses ← paneline 3 sn'lik bir klip bırakın", + "cta_design": "Tasarlamak için Ses ← panelinde tarif edin", + "new": "Yeni ses", + "none_selected": "Ses seçilmedi — birini tarif edin, ses dosyası bırakın veya aşağıdan seçin." } } diff --git a/frontend/src/i18n/locales/uk.json b/frontend/src/i18n/locales/uk.json index ff74ddf4..1dc5f74e 100644 --- a/frontend/src/i18n/locales/uk.json +++ b/frontend/src/i18n/locales/uk.json @@ -282,7 +282,36 @@ "models_dir_effective": "Використовується зараз", "models_dir_configured": "Налаштовано", "models_dir_default": "Використовується типове", - "models_dir_restart": "↻ Перезапустіть VoiceStudio, щоб використовувати нове розташування." + "models_dir_restart": "↻ Перезапустіть VoiceStudio, щоб використовувати нове розташування.", + "worker_join": "Підключитися", + "worker_join_code": "Код підключення", + "worker_join_code_hint": "Одноразовий і спливає через 15 хвилин. Створіть його на машині, яка надсилатиме завдання.", + "worker_join_desc": "Дозвольте іншій копії VoiceStudio надсилати завдання на цю машину. Вставте код підключення, який вона показала, — або відскануйте її QR-код телефоном і вставте його сюди.", + "worker_join_env": "У середовищі цієї машини задано змінну OMNIVOICE_WORKER_MODE, тож вирішує саме вона — змінюйте її там.", + "worker_join_no_endpoint": "Керуючий вузол не запам'ятовано.", + "worker_join_ok": "Підключено. Ця машина тепер приймає завдання.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Підключитися до іншої машини", + "worker_join_stopped": "Зупинено", + "worker_join_take_work": "Приймати завдання від", + "worker_join_title": "Позичити GPU цієї машини", + "worker_join_working": "Працює", + "workers_add_hint_qr": "Створіть токен, потім відскануйте QR-код з іншої машини або вставте код у її налаштування «Віддалені воркери».", + "workers_approve": "Схвалити", + "workers_last_seen": "востаннє в мережі {{when}}", + "workers_qr_alt": "QR-код із цим кодом — відскануйте його з іншої машини", + "workers_secret_done": "Готово", + "workers_seen_hr": "{{count}} год тому", + "workers_seen_min": "{{count}} хв тому", + "workers_seen_now": "щойно", + "workers_step_1": "Встановіть VoiceStudio на машину з GPU.", + "workers_step_2": "Створіть токен вище.", + "workers_step_3": "Відскануйте там QR-код або вставте код у її налаштування «Віддалені воркери».", + "workers_summary_none": "Ніхто не підключений", + "workers_summary_online": "{{count}} у мережі", + "workers_token_expired": "Сплив — створіть новий", + "workers_token_expires_in": "Спливає через {{time}}", + "workers_token_qr_hint": "На іншій машині: Налаштування → система → Віддалені воркери → Підключитися, потім відскануйте або вставте." }, "bootstrap": { "title": "VoiceStudio", @@ -552,7 +581,16 @@ "seed_placeholder": "випадково кожен раз", "seed_keep": "Зберігайте це насіння", "seed_reroll": "Нове насіння", - "seed_reroll_hint": "Киньте нове випадкове насіння та збережіть його" + "seed_reroll_hint": "Киньте нове випадкове насіння та збережіть його", + "generating_done_status": "Генерацію завершено", + "generating_status": "Генерую аудіо…", + "identity": "Ідентичність", + "identity_auto": "Авто — вирішує модель", + "insert": "Вставити", + "insert_token": "Вставити токен експресії", + "script": "Сценарій", + "starting_points": "Відправні точки", + "voice_kicker": "Голос" }, "about": { "app": "додаток", @@ -1590,7 +1628,9 @@ "searchIssues": "Шукати схожі проблеми", "unexpected": "Неочікувана помилка: {{message}}", "backend_shutting_down": "VoiceStudio завершує роботу. Відкрийте застосунок знову та спробуйте ще раз.", - "crash_broken_env": "Він завершився під час завантаження власних залежностей Python, тож річ не в пам'яті й не у відеокарті — середовище неповне або оновилося наполовину. Скористайтеся «Clean & Retry» у Налаштування → Журнали → Бекенд: це перезбирає середовище з нуля й лагодить його на місці, не торкаючись ваших голосів і проєктів. Якщо помилка лишиться, у подробицях збою вказано пакет, який не імпортувався." + "crash_broken_env": "Він завершився під час завантаження власних залежностей Python, тож річ не в пам'яті й не у відеокарті — середовище неповне або оновилося наполовину. Скористайтеся «Очистити і повторити» у Налаштування → Журнали → Бекенд: це перезбирає середовище з нуля й лагодить його на місці, не торкаючись ваших голосів і проєктів. Якщо помилка лишиться, у подробицях збою вказано пакет, який не імпортувався.", + "crash_vram_default": "На невеликих відеокартах звичайна причина — брак відеопам'яті (VRAM) під час завантаження моделі ASR поверх моделі TTS: спершу вивантажте модель TTS або виберіть меншу модель ASR у Каталозі моделей → Моделі.", + "stream_cut_backend_alive": "Потік обірвався завчасно, але бекенд усе ще працює — отже, він не впав. У серверному або контейнерному розгортанні причиною зазвичай є зворотний проксі чи балансувальник навантаження, який буферизує з'єднання або розриває його за тайм-аутом: вимкніть буферизацію відповідей для цього маршруту (nginx: proxy_buffering off; X-Accel-Buffering: no) і збільште його тайм-аут читання. Запуск десктопного застосунку напряму або на localhost без проксі підтвердить це." }, "keyboard": { "title": "Комбінації клавіш", @@ -2535,5 +2575,22 @@ "longform": "озвучення історій", "asr": "транскрибування" } + }, + "compute": { + "add_machine": "Додати машину", + "manage": "Налаштування віддалених воркерів", + "off_hint": "Усе виконується на цій машині. Увімкніть «Віддалено», щоб використовувати іншу.", + "quick_settings": "Обчислення — де виконуються завдання", + "remote": "Віддалено", + "title": "Де виконуються завдання", + "token_once": "Відскануйте або вставте це на іншій машині. Показується лише раз." + }, + "voices": { + "active": "Активний голос", + "active_clone_recipe": "Клоновано з вашого референсного кліпу", + "cta_clone": "Перетягніть кліп на 3 с у «Голос» ←, щоб клонувати голос", + "cta_design": "Опишіть голос у «Голос» ←, щоб створити його", + "new": "Новий голос", + "none_selected": "Голос не вибрано — опишіть його, перетягніть аудіо або виберіть нижче." } } diff --git a/frontend/src/i18n/locales/vi.json b/frontend/src/i18n/locales/vi.json index 578a058c..241784ce 100644 --- a/frontend/src/i18n/locales/vi.json +++ b/frontend/src/i18n/locales/vi.json @@ -282,7 +282,36 @@ "models_dir_effective": "Đang sử dụng", "models_dir_configured": "Đã cấu hình", "models_dir_default": "Dùng mặc định", - "models_dir_restart": "↻ Khởi động lại VoiceStudio để dùng vị trí mới." + "models_dir_restart": "↻ Khởi động lại VoiceStudio để dùng vị trí mới.", + "worker_join": "Tham gia", + "worker_join_code": "Mã tham gia", + "worker_join_code_hint": "Dùng một lần và hết hạn sau 15 phút. Hãy tạo trên máy sẽ gửi việc.", + "worker_join_desc": "Cho phép một bản VoiceStudio khác gửi tác vụ tới máy này. Dán mã tham gia mà nó hiển thị — hoặc quét QR của nó bằng điện thoại rồi dán vào đây.", + "worker_join_env": "OMNIVOICE_WORKER_MODE đã được đặt trong môi trường của máy này, nên nó quyết định — hãy thay đổi ở đó.", + "worker_join_no_endpoint": "Chưa lưu control plane nào.", + "worker_join_ok": "Đã tham gia. Máy này hiện đang nhận việc.", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "Tham gia máy khác", + "worker_join_stopped": "Đã dừng", + "worker_join_take_work": "Nhận việc từ", + "worker_join_title": "Cho mượn GPU của máy này", + "worker_join_working": "Đang làm việc", + "workers_add_hint_qr": "Tạo mã, rồi quét QR từ máy kia hoặc dán mã vào phần cài đặt Máy phụ từ xa của máy đó.", + "workers_approve": "Phê duyệt", + "workers_last_seen": "lần cuối thấy {{when}}", + "workers_qr_alt": "Mã QR chứa mã này — hãy quét từ máy kia", + "workers_secret_done": "Xong", + "workers_seen_hr": "{{count}} giờ trước", + "workers_seen_min": "{{count}} phút trước", + "workers_seen_now": "vừa xong", + "workers_step_1": "Cài VoiceStudio trên máy có GPU.", + "workers_step_2": "Tạo mã ở phía trên.", + "workers_step_3": "Quét QR ở đó, hoặc dán mã vào phần cài đặt Máy phụ từ xa của máy đó.", + "workers_summary_none": "Chưa có ai kết nối", + "workers_summary_online": "{{count}} trực tuyến", + "workers_token_expired": "Đã hết hạn — hãy tạo mã mới", + "workers_token_expires_in": "Hết hạn sau {{time}}", + "workers_token_qr_hint": "Trên máy kia: Cài đặt → Hệ thống → Máy phụ từ xa → Tham gia, rồi quét hoặc dán." }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "Từ âm thanh", "define_voice": "Xác định giọng nói", "save_design_as_profile": "Lưu thiết kế dưới dạng hồ sơ", + "generating_done_status": "Đã tạo xong", + "generating_status": "Đang tạo âm thanh…", + "identity": "Danh tính", + "identity_auto": "Tự động — mô hình tự quyết định", + "insert": "Chèn", + "insert_token": "Chèn token biểu cảm", + "script": "Kịch bản", + "starting_points": "Điểm khởi đầu", + "voice_kicker": "Giọng nói", "seed_label": "Hạt giống", "seed_placeholder": "ngẫu nhiên mỗi lần", "seed_keep": "Giữ hạt giống này", @@ -1590,7 +1628,9 @@ "searchIssues": "Tìm các vấn đề tương tự", "unexpected": "Lỗi không mong muốn: {{message}}", "backend_shutting_down": "VoiceStudio đang tắt. Mở lại ứng dụng rồi thử lại.", - "crash_broken_env": "Nó dừng khi đang nạp các phụ thuộc Python của chính mình, nên đây không phải chuyện bộ nhớ hay GPU — môi trường bị thiếu hoặc cập nhật dở dang. Hãy dùng \"Clean & Retry\" trong Cài đặt → Nhật ký → Backend: nó dựng lại từ đầu và sửa ngay tại chỗ, không đụng đến giọng nói hay dự án của bạn. Nếu vẫn lỗi, phần chi tiết sự cố sẽ nêu đúng gói không nạp được." + "crash_broken_env": "Nó dừng khi đang nạp các phụ thuộc Python của chính mình, nên đây không phải chuyện bộ nhớ hay GPU — môi trường bị thiếu hoặc cập nhật dở dang. Hãy dùng \"Dọn dẹp & Thử lại\" trong Cài đặt → Nhật ký → Backend: nó dựng lại từ đầu và sửa ngay tại chỗ, không đụng đến giọng nói hay dự án của bạn. Nếu vẫn lỗi, phần chi tiết sự cố sẽ nêu đúng gói không nạp được.", + "crash_vram_default": "Trên các GPU nhỏ hơn, nguyên nhân thường gặp là hết VRAM khi tải mô hình ASR chồng lên mô hình TTS: hãy giải phóng mô hình TTS trước, hoặc chọn mô hình ASR nhỏ hơn trong Danh mục mô hình → Mô hình.", + "stream_cut_backend_alive": "Luồng kết thúc sớm, nhưng backend vẫn đang chạy — nên nó không hề sập. Trong môi trường chạy qua server hoặc container, nguyên nhân thường là reverse proxy hoặc load balancer đang đệm hoặc ngắt kết nối do hết thời gian chờ: hãy tắt đệm phản hồi cho tuyến này (nginx: proxy_buffering off; X-Accel-Buffering: no) và tăng thời gian chờ đọc của nó. Chạy trực tiếp ứng dụng desktop, hoặc chạy trên localhost không qua proxy, sẽ xác nhận điều đó." }, "keyboard": { "title": "Phím tắt", @@ -2535,5 +2575,22 @@ "longform": "kể chuyện", "asr": "phiên âm" } + }, + "compute": { + "add_machine": "Thêm máy", + "manage": "Cài đặt máy phụ từ xa", + "off_hint": "Mọi thứ chạy trên máy này. Bật Từ xa để dùng máy khác.", + "quick_settings": "Tính toán — nơi chạy tác vụ", + "remote": "Từ xa", + "title": "Nơi chạy tác vụ", + "token_once": "Quét hoặc dán mã này trên máy kia. Chỉ hiển thị một lần." + }, + "voices": { + "active": "Giọng đang dùng", + "active_clone_recipe": "Được nhân bản từ đoạn âm thanh mẫu của bạn", + "cta_clone": "Thả đoạn âm thanh 3 giây vào Giọng nói ← để nhân bản một giọng", + "cta_design": "Mô tả một giọng trong Giọng nói ← để thiết kế nó", + "new": "Giọng mới", + "none_selected": "Chưa chọn giọng nào — hãy mô tả một giọng, thả tệp âm thanh, hoặc chọn bên dưới." } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index c50622d3..91db4fe7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -256,6 +256,15 @@ "define_from_audio": "从音频", "define_voice": "定义声音", "save_design_as_profile": "将设计保存为声音配置", + "generating_done_status": "生成完成", + "generating_status": "正在生成音频…", + "identity": "声音特征", + "identity_auto": "自动 — 由模型决定", + "insert": "插入", + "insert_token": "插入表达标记", + "script": "文稿", + "starting_points": "起点", + "voice_kicker": "语音", "seed_label": "种子", "seed_placeholder": "每次都是随机的", "seed_keep": "保留这个种子", @@ -509,7 +518,36 @@ "models_dir_effective": "当前使用", "models_dir_configured": "已配置", "models_dir_default": "使用默认值", - "models_dir_restart": "↻ 重启 VoiceStudio 以使用新位置。" + "models_dir_restart": "↻ 重启 VoiceStudio 以使用新位置。", + "worker_join": "加入", + "worker_join_code": "加入码", + "worker_join_code_hint": "一次性使用,15 分钟后过期。请在发送任务的那台机器上生成。", + "worker_join_desc": "让另一份 VoiceStudio 把任务发送到本机。粘贴它显示的加入码——或用手机扫描它的二维码,然后粘贴到这里。", + "worker_join_env": "本机环境中设置了 OMNIVOICE_WORKER_MODE,以它为准——请到环境变量里修改。", + "worker_join_no_endpoint": "没有已记住的控制平面。", + "worker_join_ok": "已加入。本机现在开始接收任务。", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "加入另一台", + "worker_join_stopped": "已停止", + "worker_join_take_work": "任务来源", + "worker_join_title": "出借本机的 GPU", + "worker_join_working": "工作中", + "workers_add_hint_qr": "生成令牌,然后在另一台机器上扫描此二维码,或将加入码粘贴到它的“远程工作机”设置中。", + "workers_approve": "批准", + "workers_last_seen": "上次在线 {{when}}", + "workers_qr_alt": "包含此加入码的二维码——请用另一台机器扫描", + "workers_secret_done": "完成", + "workers_seen_hr": "{{count}} 小时前", + "workers_seen_min": "{{count}} 分钟前", + "workers_seen_now": "刚刚", + "workers_step_1": "在有 GPU 的机器上安装 VoiceStudio。", + "workers_step_2": "在上方生成令牌。", + "workers_step_3": "在那台机器上扫描二维码,或将加入码粘贴到它的“远程工作机”设置中。", + "workers_summary_none": "没有已连接的工作机", + "workers_summary_online": "{{count}} 台在线", + "workers_token_expired": "已过期——请重新生成", + "workers_token_expires_in": "{{time}} 后过期", + "workers_token_qr_hint": "在另一台机器上:设置 → 系统 → 远程工作机 → 加入,然后扫描或粘贴。" }, "about": { "app": "应用", @@ -1596,7 +1634,9 @@ "searchIssues": "搜索类似问题", "unexpected": "意外错误:{{message}}", "backend_shutting_down": "VoiceStudio 正在关闭。请重新打开应用后再试。", - "crash_broken_env": "它在加载自身的 Python 依赖时退出,因此与内存或显卡无关——运行环境不完整,或更新到一半就停下了。请在 设置 → 日志 → 后端 中使用“清理并重试”,它会从头重建环境并就地修复,不会动你的声音或项目。如果之后仍然失败,崩溃详情会指出无法导入的具体软件包。" + "crash_broken_env": "它在加载自身的 Python 依赖时退出,因此与内存或显卡无关——运行环境不完整,或更新到一半就停下了。请在 设置 → 日志 → 后端 中使用“清理并重试”,它会从头重建环境并就地修复,不会动你的声音或项目。如果之后仍然失败,崩溃详情会指出无法导入的具体软件包。", + "crash_vram_default": "在较小的 GPU 上,常见原因是在 TTS 模型仍占用显存时加载 ASR 模型,导致显存(VRAM)不足:请先卸载 TTS 模型,或在 模型库 → 模型 中选择更小的 ASR 模型。", + "stream_cut_backend_alive": "音频流提前结束,但后端仍在运行——因此它并没有崩溃。在服务器或容器化部署中,这通常是反向代理或负载均衡器在缓冲连接或使其超时:请为此路由禁用响应缓冲(nginx: proxy_buffering off; X-Accel-Buffering: no)并调高读取超时。直接运行桌面应用,或在没有代理的 localhost 上运行,即可确认。" }, "keyboard": { "title": "键盘快捷键", @@ -2542,5 +2582,22 @@ "longform": "故事朗读", "asr": "转录" } + }, + "compute": { + "add_machine": "添加机器", + "manage": "远程工作机设置", + "off_hint": "所有任务都在本机运行。要使用其他机器,请开启远程。", + "quick_settings": "计算 — 任务运行位置", + "remote": "远程", + "title": "任务运行位置", + "token_once": "在另一台机器上扫描或粘贴。只显示一次。" + }, + "voices": { + "active": "当前声音", + "active_clone_recipe": "从你的参考音频克隆", + "cta_clone": "在 语音 ← 中拖入 3 秒音频即可克隆", + "cta_design": "在 语音 ← 中描述即可设计", + "new": "新建声音", + "none_selected": "未选择声音——描述一个、拖入音频,或从下方选择。" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 4052846b..439420d2 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -282,7 +282,36 @@ "models_dir_effective": "目前使用", "models_dir_configured": "已設定", "models_dir_default": "使用預設值", - "models_dir_restart": "↻ 重新啟動 VoiceStudio 以使用新位置。" + "models_dir_restart": "↻ 重新啟動 VoiceStudio 以使用新位置。", + "worker_join": "加入", + "worker_join_code": "加入碼", + "worker_join_code_hint": "一次性使用,15 分鐘後失效。請在要傳送工作的那台機器上產生。", + "worker_join_desc": "讓另一份 VoiceStudio 把工作傳送到本機。貼上它顯示的加入碼——或用手機掃描它的 QR 碼後貼到這裡。", + "worker_join_env": "本機環境已設定 OMNIVOICE_WORKER_MODE,以它為準——請到那裡變更。", + "worker_join_no_endpoint": "沒有已記住的控制平面。", + "worker_join_ok": "已加入。本機現在開始接收工作。", + "worker_join_placeholder": "ovw_…", + "worker_join_rejoin": "加入另一台", + "worker_join_stopped": "已停止", + "worker_join_take_work": "工作來源", + "worker_join_title": "出借本機的 GPU", + "worker_join_working": "工作中", + "workers_add_hint_qr": "產生權杖,然後在另一台機器上掃描此 QR 碼,或將加入碼貼到它的「遠端工作機」設定中。", + "workers_approve": "核准", + "workers_last_seen": "上次上線 {{when}}", + "workers_qr_alt": "含有此加入碼的 QR 碼——請用另一台機器掃描", + "workers_secret_done": "完成", + "workers_seen_hr": "{{count}} 小時前", + "workers_seen_min": "{{count}} 分鐘前", + "workers_seen_now": "剛剛", + "workers_step_1": "在有 GPU 的機器上安裝 VoiceStudio。", + "workers_step_2": "在上方產生權杖。", + "workers_step_3": "在那台機器上掃描 QR 碼,或將加入碼貼到它的「遠端工作機」設定中。", + "workers_summary_none": "沒有已連線的工作機", + "workers_summary_online": "{{count}} 台上線", + "workers_token_expired": "已過期——請重新產生", + "workers_token_expires_in": "{{time}} 後過期", + "workers_token_qr_hint": "在另一台機器上:設定 → 系統 → 遠端工作機 → 加入,然後掃描或貼上。" }, "bootstrap": { "title": "VoiceStudio", @@ -548,6 +577,15 @@ "define_from_audio": "從音訊", "define_voice": "定義聲音", "save_design_as_profile": "將設計另存為語音設定檔", + "generating_done_status": "生成完成", + "generating_status": "正在生成音訊…", + "identity": "聲音特質", + "identity_auto": "自動 — 由模型決定", + "insert": "插入", + "insert_token": "插入表達標記", + "script": "文稿", + "starting_points": "起點", + "voice_kicker": "語音", "seed_label": "種子", "seed_placeholder": "每次都是隨機的", "seed_keep": "保留這個種子", @@ -1590,7 +1628,9 @@ "searchIssues": "搜尋類似問題", "unexpected": "未預期的錯誤:{{message}}", "backend_shutting_down": "VoiceStudio 正在關閉。請重新開啟應用程式後再試。", - "crash_broken_env": "它在載入自身的 Python 相依套件時結束,因此與記憶體或顯示卡無關——執行環境不完整,或更新到一半就停住了。請在 設定 → 記錄 → 後端 中使用「Clean & Retry」,它會從頭重建環境並就地修復,不會動到你的聲音或專案。若之後仍然失敗,當機詳情會指出無法匯入的確切套件。" + "crash_broken_env": "它在載入自身的 Python 相依套件時結束,因此與記憶體或顯示卡無關——執行環境不完整,或更新到一半就停住了。請在 設定 → 系統日誌 → 後端 中使用「清理並重試」,它會從頭重建環境並就地修復,不會動到你的聲音或專案。若之後仍然失敗,當機詳情會指出無法匯入的確切套件。", + "crash_vram_default": "在較小的 GPU 上,常見原因是在 TTS 模型仍載入時再載入 ASR 模型,導致顯示記憶體(VRAM)不足:請先卸載 TTS 模型,或在 模型庫 → 模型 中選擇較小的 ASR 模型。", + "stream_cut_backend_alive": "串流提前結束,但後端仍在執行——因此它並沒有當機。在伺服器或容器化部署中,這通常是反向代理或負載平衡器在緩衝連線或使其逾時:請為此路由停用回應緩衝(nginx: proxy_buffering off; X-Accel-Buffering: no)並調高讀取逾時。直接執行桌面應用程式,或在沒有代理的 localhost 上執行,即可確認。" }, "keyboard": { "title": "鍵盤快速鍵", @@ -2535,5 +2575,22 @@ "longform": "故事朗讀", "asr": "轉錄" } + }, + "compute": { + "add_machine": "新增機器", + "manage": "遠端工作機設定", + "off_hint": "所有工作都在本機執行。要使用其他機器,請開啟遠端。", + "quick_settings": "運算 — 工作執行位置", + "remote": "遠端", + "title": "工作執行位置", + "token_once": "在另一台機器上掃描或貼上。僅顯示一次。" + }, + "voices": { + "active": "使用中的聲音", + "active_clone_recipe": "從你的參考片段克隆而來", + "cta_clone": "在 語音 ← 中放入 3 秒片段即可克隆", + "cta_design": "在 語音 ← 中描述即可設計", + "new": "新增聲音", + "none_selected": "尚未選擇聲音——描述一個、放入音訊,或從下方挑選。" } } From b9821920118216014a7b58ebc24fda63ed8e86cd Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:19:22 +0000 Subject: [PATCH 07/15] fix(worker): reserve the capacity slot before announcing the accept (#1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worker): reserve the capacity slot before announcing the accept Two changes for #1536 (flaky test_worker_at_capacity_rejects_without_penalty): The client now inserts into _running BEFORE awaiting the accept send. Today _send enqueues synchronously so the old order could not actually interleave — but the reserve-then-announce order is the invariant that stays correct if _send ever gains backpressure (a bounded outbox is the natural evolution), instead of silently reopening an over-accept window. If the accept send fails, the reserved task is cancelled: work the scheduler never saw accepted must not run to double-execution. The test now pins the real invariant — no over-concurrency — rather than the scheduler's bookkeeping timing: on a loaded CI runner the first attempt can die environmentally (a stream hiccup fails _run, whose finally frees the slot), after which accepting the second task is the CORRECT behaviour the old flat assertion punished as a failure. The assertion now applies only while the first attempt is still running, and names the over-accept explicitly when it fires. Fixes #1536 Co-Authored-By: Claude Fable 5 * fix(worker): release the reserved slot on a cancelled accept-send CodeRabbit on #1537: - except BaseException, not Exception: a handler cancelled while the accept send is in flight must release the reserved slot too, or the unaccepted task keeps running and double-executes after reassignment. Fail-before/pass-after regression test included. - the capacity test now polls the second task out of its dispatch states instead of sleeping 0.5s, and asserts the penalty-free invariant (excluded_workers empty) unconditionally — capacity rejections never exclude the worker regardless of the first attempt's health. - TaskAccepted.envelope finding skipped: the field is read nowhere server-side (registration is the only envelope consumer) — pre-existing unused-field design, not introduced here. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- backend/worker/transport/client.py | 19 ++++++++- tests/test_worker_transport.py | 64 +++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/backend/worker/transport/client.py b/backend/worker/transport/client.py index 9f35e4a2..37dd8723 100644 --- a/backend/worker/transport/client.py +++ b/backend/worker/transport/client.py @@ -657,8 +657,25 @@ class WorkerClient: ) ) return - await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref))) + # Reserve the slot BEFORE the accept-send await: awaiting yields to + # the event loop, and a concurrently delivered assignment would read + # the un-reserved counter and over-accept past capacity (#1536 — a + # capacity-1 worker accepted a second task on a slow runner). Message + # order on the stream survives the swap: _send enqueues synchronously + # (put_nowait before any suspension), so ACCEPTED is in the outbox + # before this handler ever yields to the just-created _run task. self._running[key] = asyncio.create_task(self._run(assignment)) + try: + await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref))) + except BaseException: + # BaseException, not Exception: a handler CANCELLED mid-send must + # release the slot too, or the reserved task keeps running work + # the scheduler never saw accepted — and double-executes after + # reassignment. The stream-death case lands here as well. + task = self._running.pop(key, None) + if task is not None: + task.cancel() + raise async def _run(self, assignment: pb.TaskAssignment) -> None: key = self._key(assignment.ref) diff --git a/tests/test_worker_transport.py b/tests/test_worker_transport.py index 5a572f23..1c2a0243 100644 --- a/tests/test_worker_transport.py +++ b/tests/test_worker_transport.py @@ -540,18 +540,70 @@ async def test_worker_at_capacity_rejects_without_penalty(harness): assignment = harness.scheduler.next_assignment() if assignment is not None: await harness.servicer.dispatch(assignment) - await asyncio.sleep(0.5) - assert second.state in ( - TaskState.QUEUED, - TaskState.ASSIGNED, - TaskState.ACCEPTED, - ) + # Deterministic wait, not sleep-as-sync: the worker's answer settles + # the second task out of its dispatch states (QUEUED on a capacity + # rejection, RUNNING on an over-accept) — poll until it does. + deadline = asyncio.get_running_loop().time() + 10 + while ( + second.state in (TaskState.ASSIGNED, TaskState.ACCEPTED) + and asyncio.get_running_loop().time() < deadline + ): + await asyncio.sleep(0.05) + # Capacity rejections are penalty-free no matter how the first + # attempt is faring — the worker is never excluded for being honest. assert second.excluded_workers == set() + # The invariant under test is NO OVER-CONCURRENCY, not the + # scheduler's bookkeeping timing. On a loaded runner the first + # attempt can die environmentally (a stream hiccup fails _run, whose + # finally frees the slot) and the worker then accepts the second + # LEGITIMATELY — asserting on second's state alone flaked exactly + # that way in CI (#1536). Only both running together is the bug. + if first.state is TaskState.RUNNING: + assert second.state in ( + TaskState.QUEUED, + TaskState.ASSIGNED, + TaskState.ACCEPTED, + ), f"over-accept: second={second.state} while first is still RUNNING" release.set() await harness.await_state(first.task_id, TaskState.COMPLETED) +@pytest.mark.asyncio +async def test_cancelled_accept_send_releases_the_reserved_slot(harness): + """The slot is reserved before the accept-send await (#1536); a handler + cancelled while that send is in flight must release it again — the + scheduler never saw the accept, so keeping the reserved task running + means double-execution after reassignment.""" + await harness.connect_worker() + client = harness.client + blocker = asyncio.Event() + + async def _stuck_send(message, **kw): + await blocker.wait() + + original_send = client._send + client._send = _stuck_send + try: + assignment = pb.TaskAssignment( + ref=pb.TaskRef(task_id="t-cancel", attempt_id="a1", session_epoch=client._epoch) + ) + handler = asyncio.create_task(client._on_assignment(assignment)) + deadline = asyncio.get_running_loop().time() + 5 + while not client._running and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0) + assert client._running, "the slot was never reserved" + handler.cancel() + with pytest.raises(asyncio.CancelledError): + await handler + assert not client._running, ( + "a cancelled accept-send left the reserved task in _running" + ) + finally: + client._send = original_send + blocker.set() + + @pytest.mark.asyncio async def test_stale_epoch_messages_are_ignored(harness): """A half-open previous stream must not be able to drive a live task.""" From 19ae20111aaf4455faf6e3b5ac64d33629b90a12 Mon Sep 17 00:00:00 2001 From: Giuseppe Rojas Date: Thu, 13 Aug 2026 15:46:24 -0500 Subject: [PATCH 08/15] fix(security): replace persistent admin keys with scoped sessions (#1528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): replace persistent admin keys with sessions Exchange the remote administrator key once for bounded, revocable credentials. Canonicalize backend principals, enforce cookie CSRF and exact origins, and use path-bound one-use WebSocket tickets. Migrate the bundled UI away from durable master-key storage and credential-bearing URLs. Add unit, integration, static-hygiene, and production-browser regressions plus synchronized operator documentation. * docs: link session hardening to PR 1528 * fix(security): key session indexes with process pepper Use HMAC-SHA-256 instead of an unkeyed digest for in-memory session and WebSocket-ticket indexes. This preserves constant-size lookup identifiers, makes copied records unusable without the process pepper, and resolves CodeQL's weak sensitive-data hash finding. * fix(auth): align empty bearer migration precedence Centralize the Authorization-channel presence decision with canonical principal parsing. Bearer followed only by spaces now remains an empty channel during legacy cookie migration, while unsupported or invalid explicit credentials stay authoritative and fail closed. * fix(security): harden admin session review boundaries * fix(security): derive key generations with HKDF * fix(auth): anchor the admin-session store so module reloads cannot fork it test_master_exchange_does_not_bypass_pin_on_normal_routes failed in full-suite runs: test_mcp_bindings' client fixture purges the services.* tree from sys.modules and reloads main, so api.routers.auth re-imported a fresh services.admin_sessions (new AdminSessionStore) while core.auth kept its import-time reference to the old one — the exchange issued the cookie into one store and the middleware resolved it against another, turning the expected "PIN required" into "API key required". Root cause is the class of bug, not the one test: a process-global auth store defined as a bare module-level singleton forks under importlib.reload or purge-and-reimport. Fix at the source: admin_session_store now resolves through a synthetic sys.modules anchor (_omnivoice_admin_session_store_anchor) that reloads never re-execute and package-prefix purges never match, so every copy of the module shares the one per-process store. No consumer or behavior changes. Regression test reproduces both fork vectors (in-place reload and sys.modules purge + fresh import) and asserts previously issued sessions still resolve and the store identity is preserved; it fails before this fix and passes after. Co-Authored-By: Claude Fable 5 * fix(auth): honor X-Forwarded-Proto for CSRF origin and Secure cookies behind TLS proxies Behind Tailscale Serve (docs/remote-gpu.md) or any TLS-terminating proxy, the browser talks https while the backend hop stays http, so exact-origin CSRF compared an https Origin against an http expectation and rejected every legitimate request, and the session cookie shipped without Secure. uvicorn's ProxyHeadersMiddleware only rewrites the scope for loopback peers, which misses Docker and any non-loopback proxy topology. New core.csrf.effective_scheme derives the client-facing scheme: resolved scope first (uvicorn's trusted-proxy rewrite wins), then an upgrade-only read of X-Forwarded-Proto's first value — https/wss promotes http to https, everything else is ignored, and a genuine TLS hop can never be downgraded. Used by both the destination-origin comparison and auth._secure_cookie so the WS-ticket/logout CSRF paths and the cookie Secure flag agree. Spoofing gains nothing: the host:port half of the origin tuple is untouched, browsers cannot attach the header cross-site without a preflight this API never grants, and forging it on plain http only adds Secure (the browser then drops the cookie — self-harm only). Regression tests: proxied https origin accepted (origin check, Secure flag, logout), comma-separated chains, scope-fallback path, spoofed header still rejects cross-origin, cannot downgrade real https, junk values ignored. Co-Authored-By: Claude Fable 5 * fix(auth): consume the stored admin key only after a successful exchange A remote-backend user upgrading with their backend unreachable lost the only stored copy of OMNIVOICE_API_KEY: every migration path deleted the durable ov_api_key BEFORE the session exchange settled, stranding them until they recovered the key from the server box. Close the whole class: - client.ts bootstrap: read the legacy key, exchange first, and remove the durable copy only after the exchange succeeds; on failure the key stays so the next launch retries the migration (auth gate still rises). - authSession.ts exchangeApiKey: move removeLegacyMaster from before the fetch to the cookie/bearer success paths — the key never coexists with a live session, but a rejected or hung exchange no longer consumes it. - remoteBackendProbe.ts configuredRemoteBackend: stop wiping the key on every app mount. - RemoteBackendPanel: a connection test or an aborted save no longer wipes the pending key; only disabling the remote backend discards it. - prefKeys.js: ov_api_key moves from PREF_KEYS to PRESERVED_KEYS — factory reset preserves the pending connection credential exactly like ov_backend_url; the successful migration is what deletes it. Tighten the credential-hygiene static guard to match: it accepted sessionStorage.setItem('ov_api_key', …) — the exact class it exists to close. The guard now flags .setItem() on any storage receiver, quote style, or injected-store alias, with a self-test pinning what it catches and what stays legal. Fail-before/pass-after regression tests: backend unreachable retains the key and the next bootstrap retries it; a successful exchange removes it. Co-Authored-By: Claude Fable 5 * perf(auth): make session validation occupancy-independent * test(auth): catch optional master-key storage calls * feat(docs): add PR control document for bultodepapas in VoiceStudio * docs: keep the PR tracking board in the fork; credit the changelog line The pr-control document is excellent process discipline, but it is the contributor's own operational board (their inventory, their update commands) — it lives naturally in their fork, and docs/agents/ here is context every repo agent loads. Removed with appreciation; the changelog line gains its contributor credit. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + README.md | 4 +- backend/api/dependencies.py | 160 +-- backend/api/routers/auth.py | 231 ++++ backend/core/auth.py | 421 +++++++ backend/core/csrf.py | 140 +++ backend/main.py | 125 +- backend/services/admin_sessions.py | 402 +++++++ docs/api-auth.md | 111 +- docs/remote-gpu.md | 45 +- ...26-08-13-remote-admin-session-hardening.md | 1030 +++++++++++++++++ .../e2e-prod/admin-session-hygiene.spec.ts | 130 +++ frontend/src/App.jsx | 2 +- .../src/api/authCredentialHygiene.test.js | 94 ++ frontend/src/api/authSession.test.ts | 548 +++++++++ frontend/src/api/authSession.ts | 483 ++++++++ frontend/src/api/client.test.ts | 246 +++- frontend/src/api/client.ts | 198 +++- frontend/src/components/CaptureWidget.jsx | 18 +- .../src/components/CaptureWidget.test.jsx | 11 + frontend/src/components/RemoteAuthGate.jsx | 62 +- .../src/components/RemoteAuthGate.test.jsx | 62 +- .../src/components/RemoteBackendRecovery.jsx | 2 +- .../components/RemoteBackendRecovery.test.jsx | 8 +- .../components/settings/ApiKeysPanel.test.jsx | 3 + .../settings/RemoteBackendPanel.jsx | 164 ++- .../settings/RemoteBackendPanel.test.jsx | 121 +- frontend/src/hooks/useRealtimeEvents.js | 49 +- frontend/src/i18n/locales/ar.json | 2 + frontend/src/i18n/locales/de.json | 2 + frontend/src/i18n/locales/es.json | 2 + frontend/src/i18n/locales/fr.json | 2 + frontend/src/i18n/locales/hi.json | 2 + frontend/src/i18n/locales/id.json | 2 + frontend/src/i18n/locales/it.json | 2 + frontend/src/i18n/locales/ja.json | 2 + frontend/src/i18n/locales/ko.json | 2 + frontend/src/i18n/locales/nl.json | 2 + frontend/src/i18n/locales/pl.json | 2 + frontend/src/i18n/locales/pt.json | 2 + frontend/src/i18n/locales/ru.json | 2 + frontend/src/i18n/locales/sv.json | 2 + frontend/src/i18n/locales/th.json | 2 + frontend/src/i18n/locales/tr.json | 2 + frontend/src/i18n/locales/uk.json | 2 + frontend/src/i18n/locales/vi.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + frontend/src/i18n/locales/zh-TW.json | 2 + .../test/CaptureWidgetMicPreflight.test.jsx | 1 + .../test/CaptureWidgetPillVisible.test.jsx | 1 + .../src/test/CaptureWidgetSetupRace.test.jsx | 1 + .../test/CaptureWidgetStrandedPill.test.jsx | 1 + frontend/src/test/backendCrash.http.test.ts | 30 +- frontend/src/test/useRealtimeEvents.test.jsx | 48 +- frontend/src/utils/backendCrash.ts | 33 +- frontend/src/utils/prefKeys.js | 11 +- frontend/src/utils/prefKeys.test.js | 4 +- frontend/src/utils/remoteBackendProbe.test.ts | 71 +- frontend/src/utils/remoteBackendProbe.ts | 39 +- tests/fixtures/api_routes.txt | 3 + tests/frontend/apiClient.test.mjs | 4 +- tests/test_admin_sessions.py | 572 +++++++++ tests/test_asr_model_missing.py | 16 +- tests/test_auth_principal.py | 461 ++++++++ tests/test_auth_session_api.py | 568 +++++++++ tests/test_bearer_middleware.py | 198 +++- tests/test_csrf_origin.py | 194 ++++ tests/test_locale_parity.py | 8 +- tests/test_loopback_server_mode.py | 51 +- tests/test_network_middleware.py | 63 + 70 files changed, 6876 insertions(+), 413 deletions(-) create mode 100644 backend/api/routers/auth.py create mode 100644 backend/core/auth.py create mode 100644 backend/core/csrf.py create mode 100644 backend/services/admin_sessions.py create mode 100644 docs/specs/2026-08-13-remote-admin-session-hardening.md create mode 100644 frontend/e2e-prod/admin-session-hygiene.spec.ts create mode 100644 frontend/src/api/authCredentialHygiene.test.js create mode 100644 frontend/src/api/authSession.test.ts create mode 100644 frontend/src/api/authSession.ts create mode 100644 tests/test_admin_sessions.py create mode 100644 tests/test_auth_principal.py create mode 100644 tests/test_auth_session_api.py create mode 100644 tests/test_csrf_origin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 83497edc..723f5d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ the frozen-backend fallback mirror it for their toolchains. - Switch TTS, ASR and LLM engines from the status bar or workspace, with ready-only choices, memory status and environment-pin protection. (#1530) - The engine catalogue frames uninstalled engines as "Add more engines" with a "What it needs" explainer, instead of a wall of unavailable rows. (#1531) - Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) +- The remote UI now exchanges its administrator key for a short-lived session, keeps masters out of browser storage and WebSocket URLs, and validates bounded sessions without occupancy-dependent scans. (#1528) — thanks @bultodepapas! - The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) - A faster, cleaner Dub workspace for multilingual production (#1489) - VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity diff --git a/README.md b/README.md index 11d844e5..e65cdbbf 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Three flagships, five more headliners, and a dozen under the fold. - 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines. - 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark. - ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket. -- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth. +- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, short-lived session auth. - 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation. @@ -406,7 +406,7 @@ Ships two [skills](https://skills.sh): | **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement | | **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking | | **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client | -| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) | +| **Remote Backend** | Point the desktop UI at a remote backend with short-lived session auth (Tailscale-documented) | | **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair | diff --git a/backend/api/dependencies.py b/backend/api/dependencies.py index 25e6edaf..d825e870 100644 --- a/backend/api/dependencies.py +++ b/backend/api/dependencies.py @@ -17,67 +17,21 @@ Currently exposed: keep their own inline loopback guards. """ -import ipaddress import os -import secrets from fastapi import HTTPException, Request - -# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname. -# `request.client.host` carries an address, not a hostname, so the literal -# "localhost" entry is defensive — some upstream wrappers (TestClient with -# a custom client tuple, certain reverse-proxy headers) may pass strings -# rather than parsed addresses. We accept the broader set without weakening -# the guard: nothing here matches a non-loopback origin. -_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) - - -def _trusted_networks(): - """CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as - loopback-trusted — e.g. a reverse proxy or self-hosted LAN, so the API-key / - PIN gates don't block LAN clients that can't present the credential (a proxy - that strips the Authorization header). Read at call time (matching - `_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart - to apply changes in production.""" - nets = [] - for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","): - cidr = cidr.strip() - if cidr: - try: - nets.append(ipaddress.ip_network(cidr, strict=False)) - except ValueError: - pass # malformed entry ignored — never wedge the auth gate - return nets - - -def is_loopback(host): - """True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted - network. Admin gates (``require_admin`` → ``/system/set-env``, - ``/api/settings/*``) use this so a trusted-network CIDR exempts consumption - (TTS / dictation) but never the RCE-class admin surface.""" - return host in _LOOPBACK_HOSTS - - -def is_local_host(host): - """Loopback address, OR on a configured trusted network. The consumption - gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is - exempted. Admin gates use :func:`is_loopback` — NOT this — to preserve the - two-tier privilege model: consumption trust ≠ admin trust.""" - if is_loopback(host): - return True - try: - ip = ipaddress.ip_address(host) - except (ValueError, TypeError): - return False - # Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs — - # dual-stack proxies (Caddy, Node.js) frequently pass the mapped form. - if getattr(ip, "ipv4_mapped", None): - ip = ip.ipv4_mapped - return any(ip in net for net in _trusted_networks()) +from core.auth import ( + CredentialTransport, + PrincipalKind, + is_local_host, + is_loopback, + principal_for, + remote_api_key, +) +from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed _TRUTHY = frozenset({"1", "true", "yes", "on"}) -_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) def _server_mode() -> bool: @@ -100,34 +54,6 @@ def _server_mode() -> bool: return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY -def remote_api_key() -> str | None: - """The normalized remote-backend bearer key, or None when remote mode is - off. Surrounding whitespace is configuration noise, never a valid secret. - Read at call time so tests can monkeypatch the environment.""" - return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None - - -def presented_api_key(connection) -> str: - """Return the first non-empty normalized API key on an HTTP/WS connection. - - Authorization wins over query, which wins over cookie. Each channel is - stripped before fallback so whitespace in a higher-priority channel cannot - shadow a valid lower-priority credential. - """ - headers = getattr(connection, "headers", None) or {} - query = getattr(connection, "query_params", None) or {} - cookies = getattr(connection, "cookies", None) or {} - - auth = headers.get("authorization", "") - supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else "" - if supplied: - return supplied - supplied = (query.get("api_key") or "").strip() - if supplied: - return supplied - return (cookies.get("ov_key") or "").strip() - - def _configured_pin(request) -> str | None: """The active share PIN (``app.state.network_share.pin``) or None. Read via getattr so a bare Request stub (or a request that hit before lifespan set @@ -150,25 +76,34 @@ def _admin_credential_configured(request) -> bool: return bool(_configured_pin(request)) -def _request_presents_admin_credential(request) -> bool: - """Whether the request carries a valid **API key** via the channels the - middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key`` - cookie). +def _request_presents_admin_credential( + request, + *, + side_effectful_get: bool = False, +) -> bool: + """Whether the canonical principal carries remote admin capability. - Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the - API key — a long operator-chosen secret — unlocks it. The 6-digit share PIN - is deliberately NOT accepted here: it is a *consumption* credential for LAN - playback and is short enough to brute-force (10^6, no lockout), so it must - never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR - (``is_local_host`` — also a consumption exemption) likewise never unlocks - admin. Net: remote admin in server mode requires the API key; a PIN-only - deployment keeps admin loopback-only. getattr-defensive so a minimal Request - stub never raises.""" - api_key = remote_api_key() or "" - if not api_key: + API-key and short-lived session principals may unlock server-mode admin. + PIN and trusted-network principals remain consumption-only. + """ + principal = principal_for(request) + if principal.kind not in { + PrincipalKind.API_KEY, + PrincipalKind.ADMIN_SESSION, + }: return False - supplied = presented_api_key(request) - return bool(supplied and secrets.compare_digest(supplied, api_key)) + if principal.transport not in { + CredentialTransport.COOKIE, + CredentialTransport.LEGACY_COOKIE, + }: + return True + method = str(getattr(request, "method", "GET")).upper() + if side_effectful_get or method not in SAFE_HTTP_METHODS: + return cookie_csrf_allowed( + request, + side_effectful_get=side_effectful_get, + ) + return True def require_loopback(request: Request) -> None: @@ -209,7 +144,7 @@ def require_loopback(request: Request) -> None: return if _server_mode(): method = str(getattr(request, "method", "GET")).upper() - if method not in _READ_ONLY_METHODS: + if method not in SAFE_HTTP_METHODS: # Defense in depth. Privileged routers should declare # ``require_admin`` directly, but a missed migration must not turn # into an unauthenticated Docker write primitive. @@ -240,7 +175,7 @@ def require_admin(request: Request) -> None: return if _server_mode(): method = str(getattr(request, "method", "GET")).upper() - read_only = method in _READ_ONLY_METHODS + read_only = method in SAFE_HTTP_METHODS if read_only and not _admin_credential_configured(request): return if _request_presents_admin_credential(request): @@ -258,7 +193,10 @@ def require_admin_action(request: Request) -> None: host = request.client.host if request.client else None if is_loopback(host): return - if _server_mode() and _request_presents_admin_credential(request): + if _server_mode() and _request_presents_admin_credential( + request, + side_effectful_get=True, + ): return raise HTTPException(status_code=403, detail="loopback origin or admin API key required") @@ -307,14 +245,8 @@ def require_native_access(request: Request) -> None: def ws_remote_authorized(websocket) -> bool: - """Whether a WebSocket handshake presents the remote API key. - - Browser WebSockets cannot set an Authorization header, so the key may - arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer - middleware sets on the first authenticated HTTP request. Returns False - when remote mode is off — callers keep their loopback-only behavior. - """ - key = remote_api_key() - if not key: - return False - return secrets.compare_digest(presented_api_key(websocket), key) + """Whether the canonical WS principal has a remote admin credential.""" + return principal_for(websocket).kind in { + PrincipalKind.API_KEY, + PrincipalKind.ADMIN_SESSION, + } diff --git a/backend/api/routers/auth.py b/backend/api/routers/auth.py new file mode 100644 index 00000000..04b2b384 --- /dev/null +++ b/backend/api/routers/auth.py @@ -0,0 +1,231 @@ +"""Short-lived credentials for the first-party remote administration UI.""" + +from __future__ import annotations + +import math +import threading +import time +from collections import OrderedDict, deque +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Literal + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from core.auth import ( + CredentialTransport, + PrincipalKind, + authorization_credential_present, + legacy_master_cookie_valid, + master_header_valid, + principal_for, + remote_api_key, +) +from core.csrf import cookie_csrf_allowed, effective_scheme +from services.admin_sessions import ( + SESSION_TTL_SECONDS, + WS_TICKET_TTL_SECONDS, + admin_session_store, +) + + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +_FAILED_EXCHANGE_LIMIT = 10 +_FAILED_EXCHANGE_WINDOW_SECONDS = 60 +_MAX_TRACKED_CLIENTS = 1024 + + +class _ExchangeAttemptLimiter: + """Bounded per-client sliding window for failed pre-auth exchanges.""" + + def __init__( + self, + *, + monotonic: Callable[[], float] = time.monotonic, + limit: int = _FAILED_EXCHANGE_LIMIT, + window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS, + max_clients: int = _MAX_TRACKED_CLIENTS, + ) -> None: + if limit <= 0 or window_seconds <= 0 or max_clients <= 0: + raise ValueError("rate-limit bounds must be positive") + self._monotonic = monotonic + self._limit = limit + self._window_seconds = window_seconds + self._max_clients = max_clients + self._attempts: OrderedDict[str, deque[float]] = OrderedDict() + self._lock = threading.Lock() + + def register_failure(self, client_id: str) -> int | None: + now = self._monotonic() + cutoff = now - self._window_seconds + with self._lock: + failures = self._attempts.setdefault(client_id, deque()) + while failures and failures[0] <= cutoff: + failures.popleft() + self._attempts.move_to_end(client_id) + while len(self._attempts) > self._max_clients: + self._attempts.popitem(last=False) + if len(failures) >= self._limit: + return max( + 1, + math.ceil(self._window_seconds - (now - failures[0])), + ) + failures.append(now) + return None + + def clear(self, client_id: str) -> None: + with self._lock: + self._attempts.pop(client_id, None) + + def reset(self) -> None: + with self._lock: + self._attempts.clear() + + +_exchange_attempt_limiter = _ExchangeAttemptLimiter() + + +class SessionRequest(BaseModel): + transport: Literal["cookie", "bearer"] + + +class WebSocketTicketRequest(BaseModel): + path: str + + +def _secure_cookie(request: Request) -> bool: + # Same effective-scheme logic as the exact-origin CSRF check: the resolved + # scope first (uvicorn's trusted-proxy rewrite), upgraded — never + # downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn + # doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on + # a plain-http hop can only ADD the Secure flag, which fails safe: the + # browser drops such a cookie, so the spoofer only breaks their own + # session. See core.csrf.effective_scheme for the full analysis. + return effective_scheme(request) == "https" + + +def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None: + response.set_cookie( + "ov_session", + token, + max_age=SESSION_TTL_SECONDS, + expires=datetime.fromtimestamp(expires_at, tz=UTC), + path="/", + secure=_secure_cookie(request), + httponly=True, + samesite="strict", + ) + + +def _expire_cookie(response: Response, request: Request, name: str) -> None: + response.delete_cookie( + name, + path="/", + secure=_secure_cookie(request), + httponly=name == "ov_session", + samesite="strict", + ) + + +def _client_id(request: Request) -> str: + host = request.client.host if request.client else "unknown" + return str(host).strip().lower()[:255] or "unknown" + + +def _reject_master_exchange(request: Request) -> None: + retry_after = _exchange_attempt_limiter.register_failure(_client_id(request)) + if retry_after is not None: + raise HTTPException( + status_code=429, + detail="Too many authentication attempts", + headers={"Retry-After": str(retry_after)}, + ) + raise HTTPException(status_code=401, detail="API key required") + + +@router.post("/session") +def create_session(payload: SessionRequest, request: Request) -> Response: + configured = remote_api_key() + if not configured: + raise HTTPException(status_code=401, detail="API key required") + + authorization_present = authorization_credential_present(request) + header_authorized = master_header_valid(request) + legacy_authorized = legacy_master_cookie_valid(request) + migrating_legacy = False + + if authorization_present: + if not header_authorized: + _reject_master_exchange(request) + elif legacy_authorized: + if payload.transport != "cookie" or not cookie_csrf_allowed(request): + raise HTTPException(status_code=403, detail="browser origin rejected") + migrating_legacy = True + else: + _reject_master_exchange(request) + + _exchange_attempt_limiter.clear(_client_id(request)) + issued = admin_session_store.issue(configured) + if payload.transport == "bearer": + return JSONResponse( + { + "token": issued.token, + "expires_at": issued.expires_at, + "expires_in": SESSION_TTL_SECONDS, + }, + status_code=201, + ) + + response = Response(status_code=204) + _set_session_cookie(response, request, issued.token, issued.expires_at) + if migrating_legacy or request.cookies.get("ov_key"): + _expire_cookie(response, request, "ov_key") + return response + + +@router.delete("/session", status_code=204) +def delete_session(request: Request) -> Response: + principal = principal_for(request) + if principal.kind is PrincipalKind.ADMIN_SESSION: + if ( + principal.transport is CredentialTransport.COOKIE + and not cookie_csrf_allowed(request) + ): + raise HTTPException(status_code=403, detail="browser origin rejected") + admin_session_store.revoke_by_credential(principal.credential_id) + response = Response(status_code=204) + _expire_cookie(response, request, "ov_session") + return response + + +@router.post("/ws-ticket") +def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse: + principal = principal_for(request) + if principal.kind is not PrincipalKind.ADMIN_SESSION: + raise HTTPException(status_code=403, detail="admin session required") + if ( + principal.transport is CredentialTransport.COOKIE + and not cookie_csrf_allowed(request) + ): + raise HTTPException(status_code=403, detail="browser origin rejected") + try: + ticket = admin_session_store.issue_ws_ticket_for_credential( + principal.credential_id, + payload.path, + remote_api_key(), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from None + except PermissionError: + raise HTTPException(status_code=401, detail="admin session required") from None + return JSONResponse( + { + "ticket": ticket.token, + "expires_at": ticket.expires_at, + "expires_in": WS_TICKET_TTL_SECONDS, + }, + status_code=201, + ) diff --git a/backend/core/auth.py b/backend/core/auth.py new file mode 100644 index 00000000..83754f9b --- /dev/null +++ b/backend/core/auth.py @@ -0,0 +1,421 @@ +"""Canonical authentication identity for HTTP and WebSocket connections. + +Transport parsing belongs here; authorization remains in FastAPI dependencies. +Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so +middleware and route guards cannot disagree about credential precedence. +""" + +from __future__ import annotations + +import ipaddress +import importlib +import os +import secrets +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum + +from services.admin_sessions import ( + AdminSessionStore, +) + + +_AUTH_STATE_KEY = "auth_principal" +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + +CONSUME_CAPABILITIES = frozenset({"consume"}) +ADMIN_CAPABILITIES = frozenset({"consume", "admin"}) +LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"}) + + +class PrincipalKind(str, Enum): + ANONYMOUS = "anonymous" + LOOPBACK = "loopback" + TRUSTED_NETWORK = "trusted_network" + PIN = "pin" + API_KEY = "api_key" + ADMIN_SESSION = "admin_session" + + +class CredentialTransport(str, Enum): + NONE = "none" + HEADER = "header" + QUERY = "query" + COOKIE = "cookie" + LEGACY_COOKIE = "legacy_cookie" + WS_TICKET = "ws_ticket" + + +@dataclass(frozen=True) +class AuthPrincipal: + kind: PrincipalKind + capabilities: frozenset[str] + credential_id: str | None = None + transport: CredentialTransport = CredentialTransport.NONE + + def allows(self, capability: str) -> bool: + return capability in self.capabilities + + +@dataclass(frozen=True) +class _CredentialCandidate: + value: str = field(repr=False) + transport: CredentialTransport + allow_master: bool = False + allow_session: bool = False + allow_ticket: bool = False + + +def remote_api_key() -> str | None: + """Normalized remote operator key, read dynamically for rotation support.""" + return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None + + +def credential_matches(supplied: str | None, configured: str | None) -> bool: + """Constant-time credential comparison that accepts the full Unicode range.""" + if not supplied or not configured: + return False + return secrets.compare_digest( + supplied.encode("utf-8", errors="surrogatepass"), + configured.encode("utf-8", errors="surrogatepass"), + ) + + +def _active_admin_session_store() -> AdminSessionStore: + """Resolve mutable process state at call time so app reloads cannot split it.""" + module = importlib.import_module("services.admin_sessions") + return module.admin_session_store + + +def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: + networks = [] + for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","): + value = value.strip() + if not value: + continue + try: + networks.append(ipaddress.ip_network(value, strict=False)) + except ValueError: + # Invalid configuration never makes the gate fail open or wedge the + # backend. It simply contributes no trusted range. + continue + return tuple(networks) + + +def is_loopback(host: str | None) -> bool: + return host in _LOOPBACK_HOSTS + + +def is_local_host(host: str | None) -> bool: + if is_loopback(host): + return True + try: + address = ipaddress.ip_address(host) + except (TypeError, ValueError): + return False + if getattr(address, "ipv4_mapped", None): + address = address.ipv4_mapped + return any(address in network for network in _trusted_networks()) + + +def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str: + if not mapping: + return "" + getter = getattr(mapping, "get", None) + if callable(getter): + value = getter(name, "") + if value: + return str(value) + # Real Starlette Headers are case-insensitive. This small fallback keeps + # minimal request stubs and non-Starlette callers correct too. + items = getattr(mapping, "items", None) + if callable(items): + for key, value in items(): + if str(key).lower() == name.lower(): + return str(value or "") + return "" + + +def _scope_type(connection) -> str: + scope = getattr(connection, "scope", None) + return str(scope.get("type", "http")) if isinstance(scope, dict) else "http" + + +def _path(connection) -> str: + scope = getattr(connection, "scope", None) + if isinstance(scope, dict): + return str(scope.get("path", "")) + return str(getattr(connection, "url", "") or "") + + +def _canonical_websocket_path(connection) -> str: + """Remove only the ASGI-configured deployment prefix from a WS path.""" + path = _path(connection) + scope = getattr(connection, "scope", None) + if not isinstance(scope, dict): + return path + root_path = str(scope.get("root_path", "") or "").rstrip("/") + if not root_path or root_path == "/": + return path + root_path = "/" + root_path.lstrip("/") + if path.startswith(root_path + "/"): + return path[len(root_path) :] + return path + + +def _client_host(connection) -> str | None: + client = getattr(connection, "client", None) + if client is not None: + return getattr(client, "host", None) + scope = getattr(connection, "scope", None) + if isinstance(scope, dict) and scope.get("client"): + return scope["client"][0] + return None + + +def _credential_candidate(connection) -> _CredentialCandidate | None: + query = getattr(connection, "query_params", None) or {} + cookies = getattr(connection, "cookies", None) or {} + + raw_authorization = authorization_header(connection) + authorization = raw_authorization.strip() + if raw_authorization.lower().startswith("bearer "): + value = raw_authorization[7:].strip() + if value: + return _CredentialCandidate( + value=value, + transport=CredentialTransport.HEADER, + allow_master=True, + allow_session=True, + ) + # Preserve the legacy normalization contract: ``Bearer`` followed + # only by whitespace is equivalent to an empty credential channel. + elif authorization: + # Any non-empty explicit Authorization value is authoritative, even + # when its scheme is unsupported or its Bearer payload is missing. + # It must never fall through to a stale ambient cookie. + return _CredentialCandidate( + value=authorization, + transport=CredentialTransport.HEADER, + ) + + if _scope_type(connection) == "websocket": + ticket = _mapping_get(query, "ws_ticket").strip() + if ticket: + return _CredentialCandidate( + value=ticket, + transport=CredentialTransport.WS_TICKET, + allow_ticket=True, + ) + + query_key = _mapping_get(query, "api_key").strip() + if query_key: + return _CredentialCandidate( + value=query_key, + transport=CredentialTransport.QUERY, + allow_master=True, + ) + + session = _mapping_get(cookies, "ov_session").strip() + if session: + return _CredentialCandidate( + value=session, + transport=CredentialTransport.COOKIE, + allow_session=True, + ) + + legacy_key = _mapping_get(cookies, "ov_key").strip() + if legacy_key: + return _CredentialCandidate( + value=legacy_key, + transport=CredentialTransport.LEGACY_COOKIE, + allow_master=True, + ) + return None + + +def presented_api_key(connection) -> str: + """Compatibility extractor for the durable API-key transports only.""" + candidate = _credential_candidate(connection) + if candidate is None or not candidate.allow_master: + return "" + return candidate.value + + +def authorization_header(connection) -> str: + headers = getattr(connection, "headers", None) or {} + return _mapping_get(headers, "authorization") + + +def authorization_credential_present(connection) -> bool: + """Whether Authorization contains an authoritative credential channel. + + This deliberately mirrors :func:`_credential_candidate`: whitespace and + ``Bearer`` followed only by spaces are empty channels that may fall back to + legacy migration state. Unsupported schemes and ``Bearer`` without the + required separating space remain explicit invalid credentials. + """ + authorization = authorization_header(connection) + if authorization.lower().startswith("bearer ") and not authorization[7:].strip(): + return False + return bool(authorization.strip()) + + +def bearer_header_value(connection) -> str: + authorization = authorization_header(connection) + if not authorization.lower().startswith("bearer "): + return "" + return authorization[7:].strip() + + +def legacy_master_cookie_valid(connection) -> bool: + configured = remote_api_key() + cookies = getattr(connection, "cookies", None) or {} + supplied = _mapping_get(cookies, "ov_key").strip() + return credential_matches(supplied, configured) + + +def master_header_valid(connection) -> bool: + configured = remote_api_key() + supplied = bearer_header_value(connection) + return credential_matches(supplied, configured) + + +def _configured_pin(connection) -> str | None: + app = getattr(connection, "app", None) + state = getattr(app, "state", None) if app is not None else None + network_share = getattr(state, "network_share", None) if state is not None else None + pin = getattr(network_share, "pin", None) if network_share is not None else None + return str(pin) if pin else None + + +def _valid_pin(connection) -> bool: + configured = _configured_pin(connection) + if not configured: + return False + headers = getattr(connection, "headers", None) or {} + query = getattr(connection, "query_params", None) or {} + cookies = getattr(connection, "cookies", None) or {} + supplied = ( + _mapping_get(headers, "x-omnivoice-pin").strip() + or _mapping_get(query, "pin").strip() + or _mapping_get(cookies, "ov_pin").strip() + ) + return credential_matches(supplied, configured) + + +def _attached_principal(connection) -> AuthPrincipal | None: + scope = getattr(connection, "scope", None) + if not isinstance(scope, dict): + return None + state = scope.get("state") + if isinstance(state, dict): + principal = state.get(_AUTH_STATE_KEY) + return principal if isinstance(principal, AuthPrincipal) else None + return None + + +def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal: + scope = getattr(connection, "scope", None) + if isinstance(scope, dict): + state = scope.setdefault("state", {}) + if isinstance(state, dict): + state[_AUTH_STATE_KEY] = principal + return principal + + +def resolve_principal( + connection, + *, + store: AdminSessionStore | None = None, +) -> AuthPrincipal: + """Resolve and attach the single authentication decision for one scope.""" + attached = _attached_principal(connection) + if attached is not None: + return attached + if store is None: + store = _active_admin_session_store() + + host = _client_host(connection) + if is_loopback(host): + return _attach_principal( + connection, + AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES), + ) + + candidate = _credential_candidate(connection) + configured_key = remote_api_key() + if candidate is not None: + principal: AuthPrincipal | None = None + if ( + candidate.allow_master + and credential_matches(candidate.value, configured_key) + ): + principal = AuthPrincipal( + PrincipalKind.API_KEY, + ADMIN_CAPABILITIES, + credential_id="api-key", + transport=candidate.transport, + ) + elif candidate.allow_session: + session = store.resolve(candidate.value, configured_key) + if session is not None: + principal = AuthPrincipal( + PrincipalKind.ADMIN_SESSION, + session.capabilities, + credential_id=session.credential_id, + transport=candidate.transport, + ) + elif candidate.allow_ticket: + session = store.consume_ws_ticket( + candidate.value, + _canonical_websocket_path(connection), + configured_key, + ) + if session is not None: + principal = AuthPrincipal( + PrincipalKind.ADMIN_SESSION, + session.capabilities, + credential_id=session.credential_id, + transport=candidate.transport, + ) + if principal is not None: + return _attach_principal(connection, principal) + # An explicit, non-empty credential is authoritative. Do not silently + # fall back to network or PIN trust after an invalid higher-priority + # credential was presented. + return _attach_principal( + connection, + AuthPrincipal( + PrincipalKind.ANONYMOUS, + frozenset(), + transport=candidate.transport, + ), + ) + + if is_local_host(host): + return _attach_principal( + connection, + AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES), + ) + if _valid_pin(connection): + return _attach_principal( + connection, + AuthPrincipal( + PrincipalKind.PIN, + CONSUME_CAPABILITIES, + transport=CredentialTransport.HEADER, + ), + ) + return _attach_principal( + connection, + AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()), + ) + + +def principal_for( + connection, + *, + store: AdminSessionStore | None = None, +) -> AuthPrincipal: + return _attached_principal(connection) or resolve_principal(connection, store=store) diff --git a/backend/core/csrf.py b/backend/core/csrf.py new file mode 100644 index 00000000..c6b22ea6 --- /dev/null +++ b/backend/core/csrf.py @@ -0,0 +1,140 @@ +"""Exact-origin CSRF checks for ambient browser authentication.""" + +from __future__ import annotations + +import os +from urllib.parse import SplitResult, urlsplit + + +CSRF_HEADER = "x-voicestudio-csrf" +CSRF_VALUE = "1" +SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + +_FORWARDED_PROTO_HEADER = "x-forwarded-proto" + + +def effective_scheme(connection) -> str: + """Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence. + + Behind a TLS-terminating proxy (Tailscale Serve — the flagship remote-GPU + deployment in docs/remote-gpu.md — nginx, Caddy, ...) the browser talks + ``https`` while the backend hop is plain ``http``. uvicorn's + ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run`` + in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already + rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer + is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on + bare metal, and we prefer that signal — the scope is consulted first — but + it misses Docker (the proxy connects from the bridge gateway) and any other + non-loopback proxy topology, so the header is honored here as well. + + Spoofing analysis — why honoring it never weakens a check: the upgrade is + one-way. ``https``/``wss`` as the first forwarded value promotes ``http`` + to ``https``; every other value is ignored, so a forged header can never + downgrade a genuine TLS hop. For the exact-origin comparison the host:port + half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto + cross-site without a CORS preflight this API never grants, and a + non-browser client able to forge the header can already forge Origin + itself — it gains nothing. For cookies the upgrade can only ADD the Secure + flag (a Secure cookie set over plain http is simply dropped by the + browser — the spoofer only breaks their own session), never strip it. + """ + url = getattr(connection, "url", None) + scheme = getattr(url, "scheme", None) + if not scheme: + scope = getattr(connection, "scope", None) + scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http" + scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme) + if scheme != "https": + headers = getattr(connection, "headers", None) or {} + forwarded = ( + headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else "" + ) + if forwarded.split(",")[0].strip().lower() in {"https", "wss"}: + scheme = "https" + return scheme + + +def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None: + if not value or value == "null": + return None + try: + parsed: SplitResult = urlsplit(value) + port = parsed.port + except (TypeError, ValueError): + return None + if ( + not parsed.scheme + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + return None + scheme = parsed.scheme.lower() + if scheme not in {"http", "https", "tauri"}: + return None + if port is None: + if scheme == "http": + port = 80 + elif scheme == "https": + port = 443 + return scheme, parsed.hostname.lower(), port + + +def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]: + raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901") + try: + ui_port = int(raw_port) + except (TypeError, ValueError): + ui_port = 3901 + values = os.environ.get( + "OMNIVOICE_ALLOWED_ORIGINS", + f"http://localhost:{ui_port},http://127.0.0.1:{ui_port}," + "tauri://localhost,http://tauri.localhost", + ).split(",") + return frozenset( + origin + for value in values + if (origin := _origin_tuple(value.strip())) is not None + ) + + +def _destination_origin(connection) -> tuple[str, str, int | None] | None: + scheme = effective_scheme(connection) + url = getattr(connection, "url", None) + netloc = getattr(url, "netloc", None) + if netloc: + return _origin_tuple(f"{scheme}://{netloc}") + scope = getattr(connection, "scope", None) + headers = getattr(connection, "headers", None) or {} + if not isinstance(scope, dict): + return None + host = headers.get("host", "") if hasattr(headers, "get") else "" + return _origin_tuple(f"{scheme}://{host}") + + +def origin_allowed(connection) -> bool: + headers = getattr(connection, "headers", None) or {} + origin_value = headers.get("origin", "") if hasattr(headers, "get") else "" + presented = _origin_tuple(origin_value) + if presented is None: + return False + return presented == _destination_origin(connection) or presented in configured_allowed_origins() + + +def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool: + headers = getattr(connection, "headers", None) or {} + marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else "" + if marker != CSRF_VALUE or not origin_allowed(connection): + return False + method = getattr(connection, "method", None) + if method is None: + scope = getattr(connection, "scope", None) + method = scope.get("method", "GET") if isinstance(scope, dict) else "GET" + method = str(method).upper() + if side_effectful_get or method in SAFE_HTTP_METHODS: + fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else "" + return fetch_site == "same-origin" + return True diff --git a/backend/main.py b/backend/main.py index d88a45ed..e5c525cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -341,7 +341,6 @@ if not os.environ.get("OMNIVOICE_DISABLE_FILE_LOG"): logger = logging.getLogger("omnivoice.api") import asyncio -import secrets import time import threading from contextlib import asynccontextmanager @@ -375,11 +374,15 @@ from services.model_manager import ( ) from services import network_share -from api.dependencies import ( # loopback + OMNIVOICE_TRUSTED_NETWORKS +from core.auth import ( + CredentialTransport, + PrincipalKind, + credential_matches, is_local_host, - presented_api_key, + principal_for, remote_api_key, ) +from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed, origin_allowed from api.routers import ( system, @@ -416,6 +419,7 @@ from api.routers import ( pronunciation, # Expressive-TTS Spec 01: user pronunciation dictionary settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp management + auth as auth_router, ) from utils import hf_progress @@ -1111,7 +1115,12 @@ class NetworkAccessMiddleware: if is_local_host(client): return await self.app(scope, receive, send) path = scope["path"] - if path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon"): + if ( + path in _SHELL_PATHS + or path.startswith("/assets/") + or path.startswith("/favicon") + or path == "/api/auth/session" + ): return await self.app(scope, receive, send) supplied = ( request.headers.get("x-omnivoice-pin") @@ -1119,7 +1128,7 @@ class NetworkAccessMiddleware: or request.cookies.get("ov_pin") or "" ) - if not secrets.compare_digest(supplied, pin): + if not credential_matches(supplied, pin): resp = JSONResponse({"detail": "PIN required"}, status_code=401) return await resp(scope, receive, send) # Valid PIN. Set the cookie by wrapping send to inject Set-Cookie on the @@ -1161,9 +1170,10 @@ class BackendMarkerMiddleware: async def send_with_marker(message): if message["type"] == "http.response.start": - MutableHeaders(scope=message).setdefault( - BACKEND_MARKER_HEADER, _backend_marker_value() - ) + headers = MutableHeaders(scope=message) + headers.setdefault(BACKEND_MARKER_HEADER, _backend_marker_value()) + if str(scope.get("path", "")).startswith("/api/auth/"): + headers["cache-control"] = "no-store" await send(message) return await self.app(scope, receive, send_with_marker) @@ -1183,11 +1193,12 @@ def _backend_marker_value() -> str: class BearerKeyMiddleware: """When OMNIVOICE_API_KEY is set, non-loopback clients must present it on - every HTTP + WebSocket request: ``Authorization: Bearer ``, - ``?api_key=`` (browser WebSockets cannot set headers), or the - ``ov_key`` cookie (set on the first successful HTTP auth). Loopback - always bypasses — the desktop default is unchanged — and the SPA shell - paths stay reachable so a remote UI can load and show what's wrong. + every HTTP + WebSocket request. Durable API-key transports remain compatible; + the first-party UI may instead present a short-lived admin session. The + middleware never reflects a presented master key into browser state. + + Loopback always bypasses — the desktop default is unchanged — and the SPA + shell paths stay reachable so a remote UI can load and show what's wrong. Inert when the env var is unset (the default). Pure ASGI for the same no-buffering reason as NetworkAccessMiddleware above. Plain-HTTP caveat @@ -1204,21 +1215,25 @@ class BearerKeyMiddleware: key = remote_api_key() or "" if not key: return await self.app(scope, receive, send) - client = scope["client"][0] if scope.get("client") else None - if is_local_host(client): - return await self.app(scope, receive, send) path = scope.get("path", "") if scope["type"] == "http" and ( - path in _SHELL_PATHS or path.startswith("/assets/") or path.startswith("/favicon") + path in _SHELL_PATHS + or path.startswith("/assets/") + or path.startswith("/favicon") + or path == "/api/auth/session" ): return await self.app(scope, receive, send) from starlette.requests import HTTPConnection conn = HTTPConnection(scope) - supplied = presented_api_key(conn) - - if not secrets.compare_digest(supplied, key): + principal = principal_for(conn) + if principal.kind not in { + PrincipalKind.LOOPBACK, + PrincipalKind.TRUSTED_NETWORK, + PrincipalKind.API_KEY, + PrincipalKind.ADMIN_SESSION, + }: if scope["type"] == "websocket": # Reject the handshake; 1008 = policy violation. await receive() # consume websocket.connect @@ -1226,17 +1241,31 @@ class BearerKeyMiddleware: return resp = JSONResponse({"detail": "API key required"}, status_code=401) return await resp(scope, receive, send) - - if scope["type"] == "http" and conn.cookies.get("ov_key") != key: - async def send_with_cookie(message): - if message["type"] == "http.response.start": - headers = MutableHeaders(scope=message) - headers.append( - "set-cookie", f"ov_key={key}; Path=/; SameSite=Lax" - ) - await send(message) - - return await self.app(scope, receive, send_with_cookie) + if ( + scope["type"] == "http" + and str(scope.get("method", "GET")).upper() not in SAFE_HTTP_METHODS + and principal.transport + in {CredentialTransport.COOKIE, CredentialTransport.LEGACY_COOKIE} + and not cookie_csrf_allowed(conn) + ): + resp = JSONResponse( + {"detail": "browser origin rejected"}, + status_code=403, + ) + return await resp(scope, receive, send) + if ( + scope["type"] == "websocket" + and principal.transport + in { + CredentialTransport.COOKIE, + CredentialTransport.LEGACY_COOKIE, + CredentialTransport.WS_TICKET, + } + and not origin_allowed(conn) + ): + await receive() + await send({"type": "websocket.close", "code": 1008}) + return return await self.app(scope, receive, send) @@ -1258,6 +1287,20 @@ _allowed = os.environ.get( f"http://localhost:{_ui},http://127.0.0.1:{_ui},tauri://localhost,http://tauri.localhost", ).split(",") +# Inert unless a PIN is set. CORS is registered after both auth gates below so +# Starlette places it outside them: browser preflights carry no credentials and +# must reach CORS before either gate can reject the request. +app.add_middleware(NetworkAccessMiddleware) + +# Remote-backend bearer gate (parity program Wave 2.3 / §R2). Inert unless +# OMNIVOICE_API_KEY is set. Distinct from the PIN gate above: the PIN guards +# casual LAN-share guests for one session; the API key is the durable +# credential for running this backend remotely (Tailscale / Docker GPU box). +# Covers WebSockets too — the PIN gate never did, because every WS endpoint +# carried its own loopback guard; remote mode is exactly the case where a +# keyed non-loopback client must reach them. +app.add_middleware(BearerKeyMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in _allowed if o.strip()], @@ -1270,23 +1313,10 @@ app.add_middleware( expose_headers=["Content-Disposition", BACKEND_MARKER_HEADER], ) -# Registered AFTER CORS so CORS remains the outermost layer (CORS headers are -# applied even to the 401 PIN-required responses). Inert unless a PIN is set. -app.add_middleware(NetworkAccessMiddleware) - -# Remote-backend bearer gate (parity program Wave 2.3 / §R2). Inert unless -# OMNIVOICE_API_KEY is set. Distinct from the PIN gate above: the PIN guards -# casual LAN-share guests for one session; the API key is the durable -# credential for running this backend remotely (Tailscale / Docker GPU box). -# Covers WebSockets too — the PIN gate never did, because every WS endpoint -# carried its own loopback guard; remote mode is exactly the case where a -# keyed non-loopback client must reach them. -app.add_middleware(BearerKeyMiddleware) - # Registered LAST, which in Starlette means OUTERMOST — so the marker lands on -# every response, including the two gates' 401s above and StaticFiles' bare -# "Not Found". Its absence is what lets a client conclude "whatever answered -# me is not a VoiceStudio backend" (#1385). +# every response. CORS is immediately inside it and outside both auth gates, so +# preflights and gate-generated 401s retain the browser contract. The marker's +# absence lets a client conclude that the responder is not VoiceStudio (#1385). app.add_middleware(BackendMarkerMiddleware) # Register canonical audio MIME types before any StaticFiles mount. @@ -1363,6 +1393,7 @@ app.include_router(longform_jobs.router) app.include_router(pronunciation.router) # Expressive-TTS Spec 01: pronunciation dictionary app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints app.include_router(media_tools_router.router) # Settings → Audio tools + wizard media-engine self-heal +app.include_router(auth_router.router) # short-lived first-party remote admin sessions from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402 from api.routers import workers as workers_router # noqa: E402 app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings diff --git a/backend/services/admin_sessions.py b/backend/services/admin_sessions.py new file mode 100644 index 00000000..5a59548f --- /dev/null +++ b/backend/services/admin_sessions.py @@ -0,0 +1,402 @@ +"""Process-bound credentials for the first-party remote administration UI. + +The durable ``OMNIVOICE_API_KEY`` is an operator secret, not a browser session. +This module exchanges it for opaque, bounded-lifetime credentials without +depending on FastAPI or persisting a verifier to disk. +""" + +from __future__ import annotations + +import hmac +import re +import secrets +import sys +import threading +import time +from types import ModuleType +from base64 import urlsafe_b64encode +from collections import OrderedDict +from collections.abc import Callable +from dataclasses import dataclass, field + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + +SESSION_TTL_SECONDS = 8 * 60 * 60 +WS_TICKET_TTL_SECONDS = 30 +MAX_ADMIN_SESSIONS = 256 +MAX_WS_TICKETS = 512 + +ADMIN_SESSION_PREFIX = "ovs_admin_session_" +WS_TICKET_PREFIX = "ovs_ws_ticket_" +_TOKEN_BYTES = 32 +_ENCODED_TOKEN_LENGTH = 43 +_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$") +_ALLOWED_WS_PATHS = frozenset({"/ws/events", "/ws/transcribe"}) +_ADMIN_CAPABILITIES = frozenset({"consume", "admin"}) +_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1" + + +def _hash_token(token: str, pepper: bytes) -> str: + # These are 256-bit random values, not user-chosen passwords. A keyed, + # process-local index is the right primitive: there is no feasible password + # dictionary to slow down, and a copied record is unusable without the + # store's independently generated pepper. + return hmac.digest(pepper, token.encode("utf-8"), "sha256").hex() + + +def _encode_token(raw: bytes) -> str: + return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +@dataclass(frozen=True) +class IssuedSession: + token: str = field(repr=False) + expires_at: float + + +@dataclass(frozen=True) +class IssuedTicket: + token: str = field(repr=False) + expires_at: float + + +@dataclass(frozen=True) +class SessionRecord: + credential_id: str + capabilities: frozenset[str] + issued_at: float + expires_at: float + + +@dataclass(frozen=True) +class _StoredSession: + credential_id: str + issued_monotonic: float + expires_monotonic: float + issued_at: float + expires_at: float + + def public(self) -> SessionRecord: + return SessionRecord( + credential_id=self.credential_id, + capabilities=_ADMIN_CAPABILITIES, + issued_at=self.issued_at, + expires_at=self.expires_at, + ) + + +@dataclass(frozen=True) +class _StoredTicket: + session_hash: str + path: str + issued_monotonic: float + expires_monotonic: float + + +class AdminSessionStore: + """Thread-safe, process-local store for admin sessions and WS tickets.""" + + def __init__( + self, + *, + monotonic: Callable[[], float] = time.monotonic, + wall_time: Callable[[], float] = time.time, + token_bytes: Callable[[int], bytes] = secrets.token_bytes, + pepper: bytes | None = None, + session_ttl_seconds: int = SESSION_TTL_SECONDS, + ws_ticket_ttl_seconds: int = WS_TICKET_TTL_SECONDS, + max_sessions: int = MAX_ADMIN_SESSIONS, + max_tickets: int = MAX_WS_TICKETS, + ) -> None: + if session_ttl_seconds <= 0 or ws_ticket_ttl_seconds <= 0: + raise ValueError("credential TTLs must be positive") + if max_sessions <= 0 or max_tickets <= 0: + raise ValueError("credential store capacities must be positive") + self._monotonic = monotonic + self._wall_time = wall_time + self._token_bytes = token_bytes + self._pepper = pepper if pepper is not None else secrets.token_bytes(32) + if len(self._pepper) < 32: + raise ValueError("session-store pepper must contain at least 256 bits") + self._session_ttl = session_ttl_seconds + self._ticket_ttl = ws_ticket_ttl_seconds + self._max_sessions = max_sessions + self._max_tickets = max_tickets + self._sessions: OrderedDict[str, _StoredSession] = OrderedDict() + self._tickets: OrderedDict[str, _StoredTicket] = OrderedDict() + self._ticket_hashes_by_session: dict[str, set[str]] = {} + self._key_generation: bytes | None = None + self._lock = threading.RLock() + + def __repr__(self) -> str: + snapshot = self.debug_snapshot() + return ( + "AdminSessionStore(" + f"sessions={snapshot['sessions']}, ws_tickets={snapshot['ws_tickets']})" + ) + + @staticmethod + def _normalize_master(api_key: str | None) -> str: + return api_key.strip() if isinstance(api_key, str) else "" + + def _generation(self, api_key: str) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=self._pepper, + info=_KEY_GENERATION_INFO, + ).derive(api_key.encode("utf-8", errors="surrogatepass")) + + def _sync_key_locked(self, api_key: str | None) -> bool: + normalized = self._normalize_master(api_key) + if not normalized: + self._clear_credentials_locked() + self._key_generation = None + return False + generation = self._generation(normalized) + if self._key_generation is None: + self._key_generation = generation + return True + if not hmac.compare_digest(self._key_generation, generation): + self._clear_credentials_locked() + self._key_generation = generation + return True + + @staticmethod + def _valid_token(token: str | None, prefix: str) -> bool: + if not isinstance(token, str) or not token.startswith(prefix): + return False + return bool(_TOKEN_BODY_RE.fullmatch(token.removeprefix(prefix))) + + def _new_token_locked(self, prefix: str, existing: object) -> tuple[str, str]: + for _attempt in range(8): + raw = self._token_bytes(_TOKEN_BYTES) + if not isinstance(raw, bytes) or len(raw) != _TOKEN_BYTES: + raise RuntimeError("token source must return exactly 32 bytes") + token = prefix + _encode_token(raw) + token_hash = _hash_token(token, self._pepper) + if token_hash not in existing: + return token, token_hash + raise RuntimeError("credential token source produced repeated collisions") + + def _clear_credentials_locked(self) -> None: + self._sessions.clear() + self._tickets.clear() + self._ticket_hashes_by_session.clear() + + def _remove_ticket_locked(self, ticket_hash: str) -> _StoredTicket | None: + ticket = self._tickets.pop(ticket_hash, None) + if ticket is None: + return None + session_tickets = self._ticket_hashes_by_session.get(ticket.session_hash) + if session_tickets is not None: + session_tickets.discard(ticket_hash) + if not session_tickets: + self._ticket_hashes_by_session.pop(ticket.session_hash, None) + return ticket + + def _remove_session_locked(self, session_hash: str) -> _StoredSession | None: + record = self._sessions.pop(session_hash, None) + for ticket_hash in tuple(self._ticket_hashes_by_session.get(session_hash, ())): + self._remove_ticket_locked(ticket_hash) + # Defensive cleanup keeps a prior partial mutation from preserving a + # dangling reverse-index bucket even when the session was already gone. + self._ticket_hashes_by_session.pop(session_hash, None) + return record + + def _purge_locked(self, now: float) -> None: + # TTLs are fixed per store and monotonic issue times never decrease, so + # insertion order is expiry order. Only the expired prefix can require + # work; the common request path examines at most one record per type. + while self._sessions: + session_hash = next(iter(self._sessions)) + if now < self._sessions[session_hash].expires_monotonic: + break + self._remove_session_locked(session_hash) + + while self._tickets: + ticket_hash = next(iter(self._tickets)) + if now < self._tickets[ticket_hash].expires_monotonic: + break + self._remove_ticket_locked(ticket_hash) + + def _evict_sessions_locked(self) -> None: + while len(self._sessions) >= self._max_sessions: + self._remove_session_locked(next(iter(self._sessions))) + + def _evict_tickets_locked(self) -> None: + while len(self._tickets) >= self._max_tickets: + self._remove_ticket_locked(next(iter(self._tickets))) + + def issue(self, api_key: str) -> IssuedSession: + normalized = self._normalize_master(api_key) + if not normalized: + raise ValueError("configured API key required") + with self._lock: + self._sync_key_locked(normalized) + now = self._monotonic() + wall_now = self._wall_time() + self._purge_locked(now) + self._evict_sessions_locked() + token, token_hash = self._new_token_locked(ADMIN_SESSION_PREFIX, self._sessions) + expires_monotonic = now + self._session_ttl + expires_at = wall_now + self._session_ttl + self._sessions[token_hash] = _StoredSession( + credential_id=token_hash, + issued_monotonic=now, + expires_monotonic=expires_monotonic, + issued_at=wall_now, + expires_at=expires_at, + ) + return IssuedSession(token=token, expires_at=expires_at) + + def resolve(self, token: str | None, api_key: str | None) -> SessionRecord | None: + if not self._valid_token(token, ADMIN_SESSION_PREFIX): + return None + assert isinstance(token, str) + with self._lock: + if not self._sync_key_locked(api_key): + return None + now = self._monotonic() + self._purge_locked(now) + record = self._sessions.get(_hash_token(token, self._pepper)) + if record is None or now >= record.expires_monotonic: + return None + return record.public() + + def revoke(self, token: str | None) -> bool: + if not self._valid_token(token, ADMIN_SESSION_PREFIX): + return False + assert isinstance(token, str) + token_hash = _hash_token(token, self._pepper) + with self._lock: + return self._remove_session_locked(token_hash) is not None + + def revoke_by_credential(self, credential_id: str | None) -> bool: + if not isinstance(credential_id, str) or len(credential_id) != 64: + return False + with self._lock: + return self._remove_session_locked(credential_id) is not None + + def issue_ws_ticket( + self, + session_token: str | None, + path: str, + api_key: str | None, + ) -> IssuedTicket: + if path not in _ALLOWED_WS_PATHS: + raise ValueError("WebSocket path is not allowed") + if not self._valid_token(session_token, ADMIN_SESSION_PREFIX): + raise PermissionError("valid admin session required") + assert isinstance(session_token, str) + session_hash = _hash_token(session_token, self._pepper) + return self.issue_ws_ticket_for_credential(session_hash, path, api_key) + + def issue_ws_ticket_for_credential( + self, + credential_id: str | None, + path: str, + api_key: str | None, + ) -> IssuedTicket: + if path not in _ALLOWED_WS_PATHS: + raise ValueError("WebSocket path is not allowed") + with self._lock: + if not isinstance(credential_id, str) or len(credential_id) != 64: + raise PermissionError("valid admin session required") + if not self._sync_key_locked(api_key): + raise PermissionError("valid admin session required") + now = self._monotonic() + self._purge_locked(now) + session = self._sessions.get(credential_id) + if session is None or now >= session.expires_monotonic: + raise PermissionError("valid admin session required") + self._evict_tickets_locked() + token, token_hash = self._new_token_locked(WS_TICKET_PREFIX, self._tickets) + expires_at = self._wall_time() + self._ticket_ttl + self._tickets[token_hash] = _StoredTicket( + session_hash=credential_id, + path=path, + issued_monotonic=now, + expires_monotonic=now + self._ticket_ttl, + ) + self._ticket_hashes_by_session.setdefault(credential_id, set()).add( + token_hash + ) + return IssuedTicket(token=token, expires_at=expires_at) + + def consume_ws_ticket( + self, + ticket_token: str | None, + path: str, + api_key: str | None, + ) -> SessionRecord | None: + if not self._valid_token(ticket_token, WS_TICKET_PREFIX): + return None + assert isinstance(ticket_token, str) + with self._lock: + if not self._sync_key_locked(api_key): + return None + now = self._monotonic() + self._purge_locked(now) + ticket = self._remove_ticket_locked( + _hash_token(ticket_token, self._pepper) + ) + if ticket is None or now >= ticket.expires_monotonic or ticket.path != path: + return None + session = self._sessions.get(ticket.session_hash) + if session is None or now >= session.expires_monotonic: + return None + return session.public() + + def clear(self) -> None: + with self._lock: + self._clear_credentials_locked() + self._key_generation = None + + @property + def active_session_count(self) -> int: + with self._lock: + self._purge_locked(self._monotonic()) + return len(self._sessions) + + def debug_snapshot(self) -> dict[str, int]: + with self._lock: + self._purge_locked(self._monotonic()) + return {"sessions": len(self._sessions), "ws_tickets": len(self._tickets)} + + +#: Synthetic ``sys.modules`` key holding the one per-process store. A module +#: object in ``sys.modules`` is the only namespace that survives everything +#: test suites do to this package: ``importlib.reload`` re-executes module +#: code but never touches unrelated ``sys.modules`` entries, and the purges +#: that pop whole ``services.*`` / ``api.*`` trees match package prefixes this +#: underscore-prefixed top-level name is outside of. +_ANCHOR_MODULE_NAME = "_omnivoice_admin_session_store_anchor" + + +def _process_store() -> AdminSessionStore: + """Return THE per-process store, however this module was (re)imported. + + Auth is process-global state: the copy of this module that issues a + credential and the copy that later resolves it must always be looking at + the same store. A bare module-level ``AdminSessionStore()`` breaks that + the moment anything reloads or re-imports this module (fresh module dict → + fresh store → freshly issued sessions vanish for holders of the old + reference, and vice versa). Anchoring the instance outside the module's + own namespace makes every copy of this module share one store. + """ + anchor = sys.modules.get(_ANCHOR_MODULE_NAME) + if not isinstance(anchor, ModuleType): + anchor = ModuleType(_ANCHOR_MODULE_NAME) + anchor.__doc__ = "Process-global anchor for the VoiceStudio admin-session store." + sys.modules[_ANCHOR_MODULE_NAME] = anchor + store = getattr(anchor, "admin_session_store", None) + if store is None: + store = AdminSessionStore() + anchor.admin_session_store = store + return store + + +admin_session_store = _process_store() diff --git a/docs/api-auth.md b/docs/api-auth.md index 5b1bcb26..89c24c5d 100644 --- a/docs/api-auth.md +++ b/docs/api-auth.md @@ -12,7 +12,7 @@ env var that exempts trusted callers: | Gate | Turn on with | Guards | Applies to | |---|---|---|---| | **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** | -| **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** | +| **API key** | `OMNIVOICE_API_KEY` env var on the backend | direct clients and first-party session bootstrap | non-loopback **HTTP + WebSocket** | | **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only | Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local @@ -25,7 +25,9 @@ tools keep working unchanged whichever gate is set. > desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)). > Both gates can be active at once. The PIN and the API key are independent; when -> both are set, each is checked on the paths it covers. +> both are set, each is checked on the paths it covers. Session exchange validates +> the master key before the PIN gate so the UI can bootstrap safely; ordinary HTTP +> requests still require the PIN afterward, and the UI prompts for it next. --- @@ -42,14 +44,14 @@ present it. Supply it any one of three ways: | Where | How | |---|---| -| Header | `X-VoiceStudio-Pin: ` | +| Header | `X-OmniVoice-Pin: ` | | Query param | `?pin=` | | Cookie | `ov_pin=` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once | ```bash # From another device on the LAN — with the PIN curl http://:3900/v1/audio/voices \ - -H "X-VoiceStudio-Pin: 123456" + -H "X-OmniVoice-Pin: 123456" ``` A missing or wrong PIN returns: @@ -75,9 +77,11 @@ Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`): ## API key -The API key is the durable credential for running the backend somewhere and -driving it remotely — a GPU box on your tailnet, a Docker container, a -reverse-proxied host. Set it on the **backend** process: +The API key is the backend's durable root credential for a GPU box, Docker +container, or reverse-proxied host. Direct API clients may send it on each +request. The first-party browser/Tauri UI instead exchanges it once for a +short-lived administrator session and never stores the master. Set it on the +**backend** process: ```bash # Generate a strong key and start the backend with it @@ -87,14 +91,16 @@ uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900 ``` While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket** -request must present it (the SPA shell paths below are the only HTTP exception). -Supply it any one of three ways: +request must present an accepted credential. SPA shell paths remain public; +`POST /api/auth/session` passes through the middleware only so its route can +validate the master and perform the one-time exchange. Direct-client +compatibility accepts: | Where | How | |---|---| -| Header | `Authorization: Bearer ` — **preferred**; the one place a key isn't at risk of landing in a log | -| Cookie | `ov_key=` — set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets | -| Query param | `?api_key=` — last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie | +| Header | `Authorization: Bearer ` — **preferred** for scripts and SDKs | +| Legacy cookie | `ov_key=` — accepted only for compatibility and migrated by the first-party UI; the backend no longer creates it | +| Legacy query param | `?api_key=` — compatibility only. **A key in a URL leaks into proxy/access logs and browser history** | ```bash # Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain @@ -133,6 +139,8 @@ code **1008** (policy violation) instead of a JSON body. Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`): - The key is compared in **constant time** and is **never logged**. +- The backend never copies the master into a response cookie. Browser clients + receive only `ov_session`, an opaque, HttpOnly, SameSite=Strict credential. - The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and show what's wrong; WebSockets have no such exemption. - **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile @@ -140,6 +148,40 @@ Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`): anything beyond a fully trusted LAN. See [docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup. +### First-party administrator sessions + +The bundled UI uses a narrower protocol: + +1. `POST /api/auth/session` receives the master in an `Authorization` header + exactly once and selects `{"transport":"cookie"}` for exact same-origin + browsers or `{"transport":"bearer"}` for Tauri/cross-origin clients. +2. Cookie transport returns `204` and sets `ov_session` as HttpOnly, + SameSite=Strict, path `/`, with an eight-hour maximum lifetime. Bearer + transport returns an opaque `ovs_admin_session_…` value which the UI keeps + in **sessionStorage only**, bound to the exact backend base URL. Bearer JSON + responses include both `expires_at` and a bounded `expires_in`; the UI uses + the relative lifetime when available so clock skew between a remote GPU host + and the browser cannot reject a valid session. `expires_at` remains for + backward compatibility with older clients and servers. +3. `DELETE /api/auth/session` revokes the session. Removing or rotating + `OMNIVOICE_API_KEY`, backend restart, explicit logout, and the eight-hour + deadline also invalidate it. + +The master is never written to localStorage/sessionStorage, never returned by +the backend, and never placed in a WebSocket URL. Legacy `ov_api_key` browser +storage is deleted before migration waits on the network. All auth responses, +including errors, carry `Cache-Control: no-store`. + +Failed session exchanges are limited per client to ten attempts in a rolling +60-second window and then return `429` with `Retry-After`. A correct master key +is always evaluated and clears the failure window, so an attacker cannot lock +an operator out by deliberately exhausting the limit. + +Cookie-authenticated mutations require both an exact allowed `Origin` and +`X-VoiceStudio-CSRF: 1`. Side-effectful GET actions additionally require the +browser's `Sec-Fetch-Site: same-origin`. Bearer/header clients are not subject +to the ambient-cookie CSRF check. + --- ## Dictation WebSocket @@ -149,13 +191,22 @@ own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the API-key middleware. A non-loopback client reaches it only if it is **either**: - on a [trusted network](#trusted-networks) (`is_local_host` passes), **or** -- presenting the **API key** — as `Authorization: Bearer `, the `ov_key` - cookie, or `?api_key=` (URL keys leak into logs — prefer the cookie). +- presenting a direct-client **API key** in `Authorization`, or through a + legacy `ov_key`/`?api_key=` transport. ``` ws://gpu-box:3900/ws/transcribe?api_key= ``` +That URL form is retained for non-browser compatibility only. The first-party +UI never constructs it. A bearer administrator session first calls +`POST /api/auth/ws-ticket` and puts only the returned `ws_ticket` in the URL. +Tickets are scoped to `/ws/transcribe` or `/ws/events`, expire after 30 seconds, +return the same bounded `expires_in`/`expires_at` pair, and are consumed +atomically at most once. Same-origin UI WebSockets use the +HttpOnly session cookie and must pass exact `Origin` validation; `null`, missing, +and lookalike origins are rejected. + The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and the dictation guard checks only the API key (or trusted-network membership). A LAN guest who has only entered a PIN can use the HTTP API but **not** live @@ -215,11 +266,12 @@ requirement is dropped (issue #261, else the operator is 403'd out of their own a model, and LLM provider discovery makes a request with the saved provider credential. Set `OMNIVOICE_API_KEY` before changing settings or triggering those actions remotely. -- **An API key is configured** → admin requires that **API key** (`Authorization: - Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit - share PIN does not gate admin** (it is brute-forceable), and trusted-network - membership never does either. A **PIN-only** server-mode deployment therefore - keeps admin routes loopback-only; remote admin requires the long API key. +- **An API key is configured** → admin requires that **API key** (direct-client + `Authorization` / legacy query or cookie), a valid short-lived administrator + session, or genuine loopback. The **6-digit share PIN does not gate admin** + (it is brute-forceable), and trusted-network membership never does either. A + **PIN-only** server-mode deployment therefore keeps admin routes loopback-only; + remote admin starts from the long API key. Managed sidecar installation remains true-loopback-only even with an API key. Its installer fetches mutable source and creates an editable environment, so it @@ -268,13 +320,30 @@ the default list, so restate the loopback/Tauri origins alongside your own. (The same origin.) If you only moved the Vite dev server's port, set `OMNIVOICE_UI_PORT` instead and the default list follows it. +CORS wraps both authentication gates: credentialless browser preflights are +answered before PIN/API-key enforcement, and gate-generated `401` responses +retain CORS headers so the UI can read the actual failure and prompt for the +right credential. + +TLS-terminating proxies must establish the effective scheme at the ASGI server +boundary. Uvicorn's proxy-header handling trusts loopback by default, which +covers Tailscale Serve; a custom proxy on another address must be listed with +`--forwarded-allow-ips=` (and proxy headers must remain enabled). +VoiceStudio deliberately does not trust a raw `X-Forwarded-Proto` header inside +the application: once Uvicorn accepts a trusted proxy, the resolved ASGI scheme +drives exact-Origin checks and the session cookie's `Secure` attribute. +For a public path prefix such as `/studio`, either strip that prefix before +forwarding or configure the ASGI `root_path` to the same value. WebSocket ticket +validation removes only that trusted, configured prefix; it never accepts an +arbitrary path merely because it ends in `/ws/events` or `/ws/transcribe`. + ## Status codes | Code | Meaning | What to do | |---|---|---| | **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. | -| **403** | Authorization failed: loopback/native access was required, a server-mode mutation lacked the API key, or a native path capability was invalid, expired, or for a different operation. | A PIN cannot grant admin or filesystem access. Run native operations from the desktop app; configure and present the API key for remote server-mode mutations; reopen the native picker if a one-shot capability expired. | -| **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. | +| **403** | Authorization failed: loopback/native access was required, cookie Origin/CSRF validation failed, a server-mode mutation lacked an admin credential, or a native path capability was invalid/expired. | A PIN cannot grant admin or filesystem access. Re-authenticate the UI; scripts should use the API-key header; run native operations from the desktop app. | +| **429** | A failed administrator-session exchange exceeded its per-client limit, the GPU pool is saturated, or a model download is rate-limited. Ships with `Retry-After`; workload throttles also carry `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds. For authentication, verify the master before retrying; a correct master is never locked out. | --- diff --git a/docs/remote-gpu.md b/docs/remote-gpu.md index 724600f1..8b3ca82f 100644 --- a/docs/remote-gpu.md +++ b/docs/remote-gpu.md @@ -24,13 +24,14 @@ loopback-only exactly as before. ┌──────────────┐ tailnet (WireGuard) ┌─────────────────────┐ │ laptop │ ws/https to MagicDNS URL │ gpu-box │ │ VoiceStudio UI │ ──────────────────────────▶ │ VoiceStudio backend │ -│ (thin client) │ Authorization: Bearer … │ OMNIVOICE_API_KEY set │ +│ (thin client) │ short-lived session/ticket │ OMNIVOICE_API_KEY set │ └──────────────┘ └─────────────────────┘ ``` -The desktop app *is* the thin client — there is no separate binary. You set a -**Backend URL** and an **API key** in Settings, and every request (including -the dictation and TTS WebSockets) is sent to the remote with the key attached. +The desktop app *is* the thin client — there is no separate binary. You enter a +**Backend URL** and an **API key** in Settings. The key is exchanged once for a +short-lived session; ordinary HTTP requests use that session and WebSockets use +path-bound, single-use tickets. The master is never stored or put in a URL. ## 1. On the GPU box: run the backend with a key @@ -50,11 +51,11 @@ the backend's CORS allow-list to include that origin — see [Browsers from another origin (CORS)](api-auth.md#browsers-from-another-origin-cors); neither server mode nor trusted networks covers CORS. -When `OMNIVOICE_API_KEY` is set, **every non-loopback HTTP and WebSocket -request must present it**, as `Authorization: Bearer `, `?api_key=` -(browser WebSockets can't set headers), or the `ov_key` cookie the backend -sets after the first authenticated request. Loopback traffic on the box -itself is never gated, so local tools keep working. +When `OMNIVOICE_API_KEY` is set, every non-loopback request needs an accepted +credential. Scripts should use `Authorization: Bearer `. Legacy +`?api_key=` and `ov_key` transports remain accepted for compatibility, but the +backend no longer creates a master-key cookie and the bundled UI uses only +short-lived sessions. Loopback traffic on the box itself remains ungated. ## 2. Reach it over Tailscale @@ -79,6 +80,11 @@ Serve terminates on the node and forwards from `127.0.0.1`, so to the backend the request looks like loopback — which is why the **API key is still required** in that path (the bearer gate doesn't rely on the source address for non-local exposure; set the key and it always applies to keyed clients). +Uvicorn trusts proxy headers from loopback by default, so Serve's forwarded +HTTPS scheme becomes the authoritative ASGI scheme and browser session cookies +receive `Secure`. For a non-loopback reverse proxy, explicitly configure +Uvicorn's `--forwarded-allow-ips=`; the application never trusts an +arbitrary `X-Forwarded-Proto` header itself. > **Do not use `tailscale funnel`** (public-internet exposure) for this. Even > with a key, a voice-cloning backend should not be on the open internet. @@ -90,10 +96,11 @@ Settings → Sharing → **Remote backend**: - **Backend URL**: the MagicDNS URL from step 2 (with `:3900` if you didn't use Serve, or no port if you did). - **API key**: the value of `OMNIVOICE_API_KEY` from step 1. -- **Test connection** hits `{url}/health` and shows the remote's version and - device. -- **Save & reload** stores both in this browser/app and restarts the UI - against the remote. The URL must be a full `http://` or `https://` URL +- **Test connection** hits the auth-exempt `{url}/health` with no credential, + then exchanges the entered key for a session if health succeeds. +- **Save & reload** stores only the URL and restarts the UI against the remote. + The key input is cleared after its single exchange. The URL must be a full + `http://` or `https://` URL (`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed **Test connection** asks for confirmation first — a wrong base would leave the app unable to reach any backend until you change it back here. @@ -111,12 +118,13 @@ https://gpu-box.your-tailnet.ts.net/#api_key= Use the fragment (`#`, not `?`) deliberately: fragments are never sent to the server, so the key stays out of the GPU box's and any reverse proxy's request -logs. The key is stored for that browser and the fragment is scrubbed from the -address bar (so it doesn't linger in history or get re-applied on a reload). If +logs. The fragment is scrubbed synchronously, then the key is exchanged once +for an eight-hour maximum session; the master is not stored. If your key contains `+`, `&`, `#`, or `=`, URL-encode it (e.g. `#api_key=a%2Bb`); keys from `secrets.token_urlsafe` (above) need no encoding. -Thereafter the UI loads normally with the key attached to every request. If a -request ever 401s again (wrong/rotated key), you're prompted to re-enter it. The +Thereafter the UI loads normally with the short-lived session. Cross-origin +bearer sessions are tab-scoped; closing the tab requires re-entry. If a request +401s again (expired/wrong/rotated key), you're prompted to re-enter it. The same gate shows a LAN-share **PIN** prompt instead when network sharing — not a remote key — is what's gating access. @@ -125,6 +133,9 @@ remote key — is what's gating access. - **Plain HTTP is sniffable.** A bearer key over `http://` on a hostile network can be read off the wire. Use Tailscale (WireGuard-encrypted) or Tailscale Serve (TLS) for anything beyond a fully trusted LAN. +- The first-party UI never persists `OMNIVOICE_API_KEY`, never creates a URL + containing it, and never puts its administrator session in a WebSocket URL. + WebSocket tickets expire after 30 seconds and work once for one path. - The API key and the LAN-share **PIN** are independent: the PIN guards a casual share session, the key is the durable remote credential. Either can be active; both are checked when set. diff --git a/docs/specs/2026-08-13-remote-admin-session-hardening.md b/docs/specs/2026-08-13-remote-admin-session-hardening.md new file mode 100644 index 00000000..fbbbb009 --- /dev/null +++ b/docs/specs/2026-08-13-remote-admin-session-hardening.md @@ -0,0 +1,1030 @@ +# Remote Admin Session Hardening + +**Status:** Implemented in the working tree; pending review and Linux CI + +**Date:** 2026-08-13 + +**Priority:** High + +**Scope:** FastAPI authentication, browser/Tauri credential handling, HTTP and WebSocket authorization + +**Out of scope:** Multi-user accounts, cloud identity providers, changing the existing PIN/trusted-network policy + +## Executive decision + +The first-party UI must stop treating `OMNIVOICE_API_KEY` as a browser session. +The API key is a durable operator secret with access to the server-mode admin +surface; it must not be copied into browser storage, cookies, WebSocket URLs, +response headers, or logs. + +Implement a one-time exchange from the master API key to a short-lived, +process-bound admin session: + +- Direct API clients keep using `Authorization: Bearer `. +- The browser/Tauri UI presents the master key once to create an admin session. +- Same-origin browsers receive an `HttpOnly` session cookie. +- Cross-origin/Tauri clients retain only the short-lived session in + `sessionStorage`. +- Cross-origin WebSockets use a path-bound, single-use ticket with a 30-second + lifetime. The master key and admin session token never enter a URL. +- Session authorization grants consumption and admin capabilities, but never + native host-path access. +- Restarting the backend or rotating/removing the API key invalidates every + outstanding session. + +This is a credential-lifecycle hardening change. It must not alter the current +loopback, server-mode bootstrap, PIN, trusted-network, or direct Bearer-client +contracts. + +## Verified current state + +The following behavior exists on the current `fix/server-mode-admin-auth` +branch: + +| Surface | Current behavior | Consequence | +| ---------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| `frontend/src/api/client.ts` | Reads and writes `ov_api_key` in `localStorage` | A same-origin script can extract a durable admin secret | +| `wsUrl()` | Appends `?api_key=` to every generated WebSocket URL | The durable secret can reach proxy/access logs and browser tooling | +| Deep-link bootstrap | Reads `#api_key=...`, scrubs the fragment, then persists the value | A transient bootstrap secret becomes durable browser state | +| `RemoteAuthGate` | Calls `saveApiKey()` and reloads | The login form creates permanent JS-readable state | +| `RemoteBackendPanel` | Initializes and saves the raw key through local storage | Remote-backend configuration retains the master secret indefinitely | +| `BearerKeyMiddleware` | Reflects a valid presented key into `ov_key=` | The backend copies the master into a non-`HttpOnly`, non-expiring cookie | +| Middleware and dependencies | Parse and compare credentials independently | Authentication precedence and authorization can drift | + +Runtime reproduction with a non-loopback `TestClient` request and +`OMNIVOICE_API_KEY=s3cret-key`: + +```text +GET /v1/audio/voices +Authorization: Bearer s3cret-key + +HTTP 200 +Set-Cookie: ov_key=s3cret-key; Path=/; SameSite=Lax +``` + +The client cookie jar then contains the literal master key. + +The current access-control baseline is green: + +```text +Backend: 99 passed +Frontend: 28 passed +``` + +The backend baseline covered: + +```text +tests/test_bearer_middleware.py +tests/test_loopback_server_mode.py +tests/test_admin_route_policy.py +tests/test_mcp_bindings.py +``` + +The frontend baseline covered: + +```text +frontend/src/api/client.test.ts +frontend/src/components/RemoteAuthGate.test.jsx +frontend/src/components/settings/RemoteBackendPanel.test.jsx +``` + +Passing baseline tests prove that the current policy works. They do not prove +that the master secret has a safe lifecycle; there are no assertions covering +secret reflection, cookie hardening, expiry, revocation, or first-party URL and +storage leakage. + +## Threat model + +### Assets + +- The operator's `OMNIVOICE_API_KEY`. +- The server-mode admin surface, including RCE/filesystem-capable operations. +- Native-only host-path capabilities. +- Remote HTTP and WebSocket sessions. + +### Defended attack paths + +- Exfiltration of the master key by same-origin XSS or a compromised frontend + dependency. +- Durable replay after a browser session ends. +- Disclosure through WebSocket URLs, proxy logs, browser history, diagnostics, + response headers, or exception messages. +- CSRF and cross-site WebSocket hijacking when browser cookies authenticate the + request. +- Privilege escalation from PIN, trusted-network, or admin-session credentials + into native host-path operations. +- Reuse of a session after expiry, logout, backend restart, or API-key rotation. + +### Explicit limitations + +- `HttpOnly` does not stop active XSS from making same-origin requests while the + page is compromised. It prevents extraction and later/lateral replay of the + durable master credential. +- This change does not make plaintext HTTP confidential. Remote operators must + still use TLS or a secure overlay such as Tailscale. +- This is not an account system and does not introduce users, passwords, + refresh tokens, OAuth, or cloud dependencies. +- Durable, unattended Tauri reconnect is not implemented. That requires a + separate cross-platform secure-storage decision; it must never fall back to + `localStorage`. + +## Non-negotiable security invariants + +Use these identifiers in tests and review comments. + +1. **AUTH-S1 — No master persistence:** First-party production code never writes + the master API key to `localStorage`, `sessionStorage`, IndexedDB, a cookie, + or a persisted application setting. +2. **AUTH-S2 — No master reflection:** No response body, response header, + exception, or log record contains the presented master API key. +3. **AUTH-S3 — No durable credential in URLs:** Neither the master API key nor + an admin session token appears in HTTP or WebSocket URLs generated by the UI. +4. **AUTH-S4 — Header-only issuance:** Normal session issuance accepts the + master key only through `Authorization: Bearer`. Query parameters and an + existing admin session cannot mint or renew sessions. +5. **AUTH-S5 — Opaque random sessions:** Session tokens contain at least 256 bits + from `secrets`; the store retains only HMAC-SHA-256 indexes keyed by a + process-local 256-bit pepper. +6. **AUTH-S6 — Absolute expiry:** Admin sessions expire after eight hours. There + is no sliding expiry, refresh token, or implicit renewal. +7. **AUTH-S7 — Immediate invalidation:** Logout, backend restart, and API-key + rotation/removal invalidate sessions before the next protected operation. +8. **AUTH-S8 — Capability ceiling:** API-key and session principals have + `consume + admin`, never `native`. Only genuine loopback has `native`. +9. **AUTH-S9 — Cookie hardening:** Cookie sessions use `HttpOnly`, `Path=/`, + `SameSite=Strict`, an exact `Max-Age`, and `Secure` when the effective scheme + is HTTPS. No `Domain` attribute is set. +10. **AUTH-S10 — CSRF enforcement:** Cookie-authenticated unsafe HTTP methods + require an exact allowed `Origin` and the first-party CSRF marker header. + Cookie-authenticated side-effectful GET routes require the marker too; + cookie-authenticated WebSockets require exact origin validation. `null` and + wildcard origins fail closed. +11. **AUTH-S11 — One-use WebSocket tickets:** A ticket is scoped to one normalized + WebSocket path, expires after 30 seconds, and succeeds at most once under + concurrent redemption. +12. **AUTH-S12 — Deterministic precedence:** Credential extraction has one + canonical implementation and an explicitly tested precedence order. +13. **AUTH-S13 — Compatibility:** Loopback defaults, PIN consumption, trusted + networks, server-mode read-only bootstrap, direct Bearer clients, and native + path restrictions retain their existing behavior. + +## Authorization model + +Authentication identifies a principal. Dependencies authorize capabilities. +Do not infer admin access from HTTP method alone and do not treat network +location as an API key. + +| Principal | Consumption | Admin | Native | May mint admin session | +| ------------------------------- | --------------------------------------: | -----------------------: | -----: | -------------------------------: | +| Genuine loopback | Yes | Yes | Yes | No; master key is still required | +| Trusted network | Yes | No | No | No | +| Share PIN | Yes | No | No | No | +| Master API key | Yes | Yes | No | Yes | +| Admin session | Yes | Yes | No | No | +| Server-mode anonymous bootstrap | Existing consumption/read-only behavior | Read-only exception only | No | No | + +`ADMIN` is a credential capability, not permission to bypass deployment mode. +Desktop admin routes remain genuine-loopback-only; remote `API_KEY` and +`ADMIN_SESSION` principals can exercise `ADMIN` only under server mode. + +The existing `require_admin`, `require_admin_action`, and +`require_native_access` dependencies remain explicit route-level boundaries. +They consume the canonical principal instead of reparsing credentials. + +## Target architecture + +### New backend modules + +```text +backend/core/auth.py +backend/services/admin_sessions.py +backend/api/routers/auth.py +``` + +`backend/core/auth.py` owns transport-independent types: + +```text +Capability: CONSUME | ADMIN | NATIVE +PrincipalKind: ANONYMOUS | LOOPBACK | TRUSTED_NETWORK | PIN | API_KEY | ADMIN_SESSION +AuthPrincipal(kind, capabilities, credential_id, transport) +CredentialTransport: NONE | HEADER | QUERY | COOKIE | WS_TICKET +``` + +The principal contains no reusable secret. `credential_id` is a short identifier +derived from the credential hash and is safe for diagnostics. + +`backend/services/admin_sessions.py` owns a process-local, bounded, +thread-safe `AdminSessionStore`: + +```text +issue(current_api_key) -> IssuedSession +resolve(token, current_api_key) -> SessionRecord | None +revoke(token) -> bool +issue_ws_ticket(session, path) -> IssuedTicket +consume_ws_ticket(ticket, path) -> SessionRecord | None +clear() -> None +``` + +Design constraints: + +- Use `secrets.token_bytes(32)` and URL-safe encoding. +- Give admin sessions and WebSocket tickets distinct prefixes/namespaces. +- Store hashes, capabilities, monotonic deadlines, and non-secret identifiers; + never store raw tokens after returning them. +- Enforce expiry with an injected monotonic clock. Use wall time only to render + cookie `Expires` metadata. +- Keep at most 256 live sessions and 512 live tickets. Purge expired entries + before deterministic oldest-first eviction. +- Keep fixed-TTL credentials in expiry order and maintain a hash-only reverse + ticket index per session. Routine validation must not scan every live + credential, and every removal path must update both indexes atomically. +- Protect mutation and ticket redemption with one lock. Ticket validation and + deletion must be atomic. +- Track the current API-key generation with domain-separated HKDF-SHA-256 using + a random, process-local pepper. If the normalized key changes or disappears, + clear the store before resolving or issuing another credential. +- Reject malformed or unreasonably long tokens before hashing. +- Do not persist the store. Restart invalidation is intentional, reduces + attack lifetime, avoids a database verifier for a possibly weak operator key, + and matches VoiceStudio's single-process backend architecture. + +Do not reuse `backend.worker.identity.Session` directly. Its worker/stream +binding semantics are different. Follow its established random-token, +hash-only, TTL-tested pattern without coupling API authentication to the worker +package. + +### Canonical authentication resolver + +Add one resolver used by HTTP middleware, HTTP dependencies, and WebSocket +guards. It must: + +1. Normalize the configured API key exactly once. +2. Parse the `Authorization` header exactly once. +3. Distinguish session tokens by their prefix. +4. Preserve current API-key compatibility for header, query, and legacy cookie + transports outside session issuance. +5. Prefer a non-empty `Authorization` header, then the legacy query parameter, + then cookies. A stale cookie must not override a valid explicit header. +6. Attach `AuthPrincipal` to ASGI `scope["state"]` so `Request`, `WebSocket`, + middleware, and dependencies observe the same decision. +7. Return authentication failure without embedding any presented value in the + detail, headers, logs, or `repr` output. + +The existing middleware ordering must be covered by a test. A refactor is not +accepted if `NetworkAccessMiddleware`, SPA-shell exemptions, CORS preflight, or +backend-marker headers change behavior accidentally. + +### HTTP contract + +#### `POST /api/auth/session` + +Request: + +```http +Authorization: Bearer +Content-Type: application/json + +{"transport":"cookie"} +``` + +Allowed transports: + +- `cookie` — default; return `204` and set `ov_session`. +- `bearer` — return `201` with the session token and expiry for Tauri or an + explicitly cross-origin frontend. + +Requirements: + +- Require the master key even from loopback. +- Reject API-key query parameters, PINs, trusted-network identity, and existing + admin sessions. +- Return `Cache-Control: no-store` for every response, including errors. +- Never echo the master key. +- Apply exact CORS origin policy before a browser can submit `Authorization`. +- Set or return the issued session exactly once. + +Legacy migration is a narrowly scoped exception: a same-origin request carrying +the old `ov_key` cookie may exchange it once. A successful migration sets +`ov_session` and expires `ov_key` with `Max-Age=0`. It never accepts `api_key` +from the query string. + +#### `DELETE /api/auth/session` + +- Revoke the current session and expire `ov_session`. +- Be idempotent; a missing or already-expired session still produces `204`. +- Require valid origin when cookie-authenticated. +- Return `Cache-Control: no-store`. + +#### `POST /api/auth/ws-ticket` + +Request: + +```json +{ "path": "/ws/transcribe" } +``` + +- Require a valid admin session, not a master API key or PIN. +- Normalize and allow-list the WebSocket path. +- Return one opaque ticket with a 30-second expiry. +- Never return an admin session or master key. +- The WebSocket handshake consumes the ticket atomically. + +### Cookie and origin policy + +For `ov_session`: + +```text +HttpOnly; Path=/; SameSite=Strict; Max-Age=28800 +``` + +Add `Secure` when the effective request scheme is HTTPS. Do not trust arbitrary +forwarded scheme or host headers; use only the proxy configuration already +trusted by the application. Plain-HTTP compatibility may use a non-`Secure` +session cookie, but the documentation must state that the transport remains +sniffable. + +For cookie-authenticated `POST`, `PUT`, `PATCH`, `DELETE`, and WebSocket +handshakes: + +- Compare the parsed origin tuple `(scheme, host, port)` against the exact + configured allow-list. +- Do not use suffix matching. +- Do not allow `*` with credentials. +- Reject absent or `null` origins for unsafe cookie-authenticated operations. +- Bearer-authenticated non-browser clients do not require `Origin`; their + credential is not ambient. + +In addition, first-party cookie-authenticated HTTP requests that can mutate +state must send a fixed custom header such as `X-VoiceStudio-CSRF: 1`. Requiring +the header forces a cross-origin script through CORS preflight; it is not a +substitute for the exact origin check. `require_admin_action` must require this +header even for the known side-effectful GET routes. For same-origin GET, where +browsers may omit `Origin`, require the marker plus +`Sec-Fetch-Site: same-origin`; never accept a cross-site value. This avoids +relying on `SameSite`, which is site-based rather than origin-based. + +### WebSocket transport + +Same-origin browser WebSockets use `ov_session`; `wsUrl()` becomes a pure URL +builder and appends no credentials. + +Cross-origin/Tauri WebSockets cannot set an `Authorization` header reliably. +They must: + +1. Request a one-use ticket over authenticated HTTP. +2. Connect with `?ws_ticket=`. +3. Have the backend consume the ticket for the exact `scope["path"]` before + accepting the socket. + +A ticket may appear in access logs, but it is path-bound, single-use, and valid +for no more than 30 seconds. It cannot be exchanged for an HTTP/admin session. + +Update both known first-party consumers: + +```text +frontend/src/components/CaptureWidget.jsx +frontend/src/hooks/useRealtimeEvents.js +``` + +### Frontend credential lifecycle + +Create: + +```text +frontend/src/api/authSession.ts +frontend/src/api/authSession.test.ts +``` + +Responsibilities: + +- Determine same-origin browser versus cross-origin/Tauri transport. +- Exchange an in-memory master key for a session using a direct, non-retrying + request. Do not route the exchange through `apiFetch`; retry/reload logic must + never replay a master credential implicitly. +- Use `credentials: "include"` for cookie sessions. +- Keep bearer sessions only in `sessionStorage` and clear them on logout, + expiry, backend change, or API-key rotation response. +- Fetch WebSocket tickets for cross-origin/Tauri sessions. +- Redact credentials from all thrown errors and diagnostics. + +Modify `RemoteAuthGate` so submission is asynchronous: + +1. Hold the master key only in controlled component state. +2. Exchange it once. +3. Clear the input state immediately after completion. +4. Remove legacy `ov_api_key` storage before reload/retry. +5. Display a localized error without serializing the submitted value. +6. Reload only after a successful exchange and storage cleanup. + +Modify deep-link handling so `#api_key=...` is scrubbed synchronously before any +await, exchanged directly from memory, and never written to storage. A failed +exchange requires the operator to enter the key again; preserving a failed +master secret is forbidden. + +Modify `RemoteBackendPanel` so testing/saving a remote backend creates a session +instead of persisting its master key. A Tauri restart may require re-entry. +Adding a native keychain/Stronghold dependency is a separate decision requiring +Windows/macOS/Linux parity tests and is not hidden inside this change. + +## File-level change map + +### Create + +```text +backend/core/auth.py +backend/services/admin_sessions.py +backend/api/routers/auth.py +tests/test_admin_sessions.py +tests/test_auth_session_api.py +tests/test_auth_secret_hygiene.py +frontend/src/api/authSession.ts +frontend/src/api/authSession.test.ts +frontend/e2e/remote-auth.spec.ts +``` + +### Modify + +```text +backend/main.py +backend/api/dependencies.py +backend/api/routers/capture_ws.py +frontend/src/api/client.ts +frontend/src/api/client.test.ts +frontend/src/components/RemoteAuthGate.jsx +frontend/src/components/RemoteAuthGate.test.jsx +frontend/src/components/settings/RemoteBackendPanel.jsx +frontend/src/components/settings/RemoteBackendPanel.test.jsx +frontend/src/components/CaptureWidget.jsx +frontend/src/components/CaptureWidget.test.jsx +frontend/src/hooks/useRealtimeEvents.js +frontend/src/test/useRealtimeEvents.test.jsx +tests/test_bearer_middleware.py +tests/test_loopback_server_mode.py +tests/test_admin_route_policy.py +tests/test_capture_ws.py +docs/api-auth.md +docs/remote-gpu.md +CHANGELOG.md +frontend/src/i18n/locales/*.json # only if new user-visible text is introduced +``` + +No dependency or version change is expected. If a frontend dependency changes, +regenerate the root `bun.lock`; do not bump `frontend/package.json` without the +owner's instruction. + +## Test strategy + +Tests are part of the design, not a final verification phase. Every task below +starts with a failing regression. No production implementation is accepted +without fail-before/pass-after evidence. + +### Layer 1 — `AdminSessionStore` unit tests + +File: `tests/test_admin_sessions.py` + +Use injected token factories and fake monotonic/wall clocks. Never use `sleep()` +or wall-clock timing assertions. + +Required tests: + +- `test_issue_returns_a_namespaced_256_bit_token_once` +- `test_store_retains_only_the_token_hash` +- `test_resolve_returns_the_expected_capabilities` +- `test_expiry_boundary_is_closed_at_deadline` +- `test_wall_clock_rollback_does_not_extend_a_session` +- `test_logout_revokes_immediately_and_is_idempotent` +- `test_key_rotation_invalidates_all_sessions` +- `test_key_removal_invalidates_all_sessions` +- `test_process_store_starts_empty` +- `test_expired_sessions_are_purged_before_capacity_eviction` +- `test_capacity_evicts_the_oldest_live_session_deterministically` +- `test_malformed_and_oversized_tokens_fail_without_mutation` +- `test_session_and_worker_token_namespaces_do_not_overlap` +- `test_ticket_is_scoped_to_the_normalized_path` +- `test_ticket_expires_at_thirty_seconds` +- `test_ticket_cannot_be_redeemed_twice` +- `test_concurrent_ticket_redemption_has_exactly_one_winner` +- `test_concurrent_session_issuance_produces_unique_tokens` +- `test_repr_and_debug_snapshot_contain_no_raw_credentials` + +Do not add a timing-based "constant-time" test; it will be flaky and will not +prove constant-time behavior. Review must verify use of `secrets.compare_digest` +where secret-derived values are compared. + +### Layer 2 — principal resolver tests + +Add focused tests to `tests/test_bearer_middleware.py` or a dedicated +`tests/test_auth_principal.py` if the file becomes unwieldy. + +Required matrix: + +| Input | Expected principal/capabilities | +| --------------------------------------------------------- | ---------------------------------------------------------- | +| Genuine loopback, no credential | `LOOPBACK`; consume/admin/native | +| Trusted remote network | `TRUSTED_NETWORK`; consume only | +| Valid PIN | `PIN`; consume only | +| Valid master header | `API_KEY`; consume/admin | +| Valid master query, no header | `API_KEY`; consume/admin; legacy transport | +| Valid legacy `ov_key`, no header/query | `API_KEY`; consume/admin; legacy transport | +| Valid cookie session | `ADMIN_SESSION`; consume/admin | +| Valid Bearer session | `ADMIN_SESSION`; consume/admin | +| Empty/whitespace header plus valid fallback | Preserve the current normalized fallback contract | +| Non-empty invalid explicit header plus valid stale cookie | Fail according to the documented authoritative-header rule | +| Expired/revoked session | Authentication failure | +| Session from before key rotation | Authentication failure | + +Also assert: + +- HTTP dependencies and WebSocket guards receive the same principal object. +- Credential parsing occurs once per request/scope. +- No principal contains the raw credential. +- `request.state` is isolated between concurrent requests. +- CORS `OPTIONS`, health, and SPA-shell exemptions retain current behavior. + +### Layer 3 — session HTTP API tests + +File: `tests/test_auth_session_api.py` + +Use a sentinel master such as `MASTER_DO_NOT_LEAK_7d29` and inspect the complete +response body, all response headers, cookies, captured logs, and exception text. + +#### Issuance + +- Correct master header + cookie transport returns `204`. +- Correct master header + bearer transport returns `201` with one session and + expiry, never the master. +- Missing, wrong, whitespace-only, PIN, trusted-network, query, and existing + session credentials cannot mint a session. +- Loopback without the master cannot mint a session. +- Unsupported transport and malformed JSON return `422` without issuance. +- Successful issuance sets `Cache-Control: no-store`. +- Failed issuance also sets `Cache-Control: no-store`. +- The response never sets `ov_key`. +- Multiple successful issuances produce distinct tokens. + +#### Cookie attributes + +Parse attributes instead of comparing one formatting-specific string: + +- `HttpOnly` present. +- `SameSite=Strict` present. +- `Path=/` present. +- `Max-Age=28800` present. +- `Domain` absent. +- `Secure` present for HTTPS and absent for explicitly supported HTTP. +- Logout emits an expiry cookie with the same name/path. + +#### Legacy migration + +- Same-origin valid `ov_key` migrates to `ov_session` exactly once. +- Migration expires `ov_key` and never reflects its value. +- Cross-origin, missing-origin, query-key, and wrong-cookie migration fail. +- A stale `ov_key` cannot override a valid explicit session/header. + +#### Revocation and rotation + +- Logout makes the next HTTP request fail. +- Logout makes the next WebSocket handshake fail. +- Expiry returns `401` for HTTP and policy close `1008` for WebSocket. +- Changing or removing `OMNIVOICE_API_KEY` rejects the session on the next + request without restarting the process. + +### Layer 4 — authorization regression matrix + +Extend `tests/test_loopback_server_mode.py` and +`tests/test_admin_route_policy.py`. + +For representative read, write, and side-effectful GET routes across settings, +system, engines, media tools, MCP bindings, pronunciation, and workers, test: + +| Context | Read-only admin | Write/side-effect admin | Native host-path action | +| ----------------------------------------- | ---------------------------------------------------------: | ----------------------: | ----------------------: | +| Desktop loopback | Allow | Allow | Allow | +| Desktop remote + master/session | Deny | Deny | Deny | +| Server mode, no key/PIN | Allow existing read-only bootstrap | Deny | Deny | +| Server mode + valid master | Allow | Allow | Deny | +| Server mode + valid admin session | Allow | Allow | Deny | +| Server mode + PIN only | Deny | Deny | Deny | +| Server mode + trusted network, no key/PIN | Allow anonymous bootstrap only; trust grants nothing extra | Deny | Deny | +| Server mode + expired/revoked session | Deny | Deny | Deny | + +The AST policy test must continue proving that every privileged router declares +the intended dependency and that every side-effectful GET uses +`require_admin_action`. A runtime session test does not replace the static route +inventory guard. + +### Layer 5 — CSRF and origin tests + +Add table-driven tests covering both HTTP and WebSocket: + +- Exact allowed origin succeeds. +- Wrong scheme, host, subdomain, or port fails. +- Suffix attacks such as `trusted.example.evil.test` fail. +- `Origin: null`, wildcard, malformed, duplicated, and absent origins fail for + unsafe cookie-authenticated requests. +- Safe cookie-authenticated `GET` preserves the documented policy. +- Bearer-authenticated CLI requests without `Origin` continue to work. +- Cookie-authenticated unsafe requests without `X-VoiceStudio-CSRF: 1` fail. +- Cross-origin requests with the marker still fail origin/CORS validation. +- Side-effectful GET with marker plus `Sec-Fetch-Site: same-origin` succeeds; + missing marker or any cross-site fetch metadata fails. +- CORS preflight does not create a session and does not emit credentials. +- Untrusted forwarded scheme/host headers cannot manufacture an allowed origin + or force/strip the `Secure` decision. + +Expected distinction: + +- Authentication failure: `401` HTTP / `1008` WebSocket. +- Authenticated cookie with invalid origin: `403` HTTP / `1008` WebSocket. + +### Layer 6 — WebSocket ticket tests + +Backend tests in `tests/test_capture_ws.py` plus equivalent coverage for +`/ws/events`: + +- A valid cookie session connects without a URL credential. +- A valid cross-origin session can mint and redeem one ticket. +- Ticket A cannot open a different path. +- Ticket A cannot be reused after a successful handshake. +- Two simultaneous redemption attempts yield exactly one accepted connection. +- Expired, malformed, revoked-session, pre-rotation, and random tickets close + with `1008`. +- A failed handshake still consumes a presented valid one-use ticket once it + reaches redemption. +- A ticket cannot authenticate HTTP, mint another ticket, or reach native + actions. +- The master and session sentinels do not appear in the WebSocket URL, close + reason, server logs, or error events. + +### Layer 7 — frontend unit/component tests + +#### `frontend/src/api/authSession.test.ts` + +- Exchanges the master exactly once and never retries automatically. +- Uses cookie transport for same-origin browser execution. +- Uses bearer-session transport for explicit cross-origin/Tauri execution. +- Writes only the short-lived bearer session to `sessionStorage`. +- Never writes the master to any storage API. +- Clears session state on logout, expiry, backend base-URL change, and rotation + failure. +- Requests a WebSocket ticket only when cookie transport is unavailable. +- Redacts the master/session from thrown errors and `console` calls. +- Does not include credentials in query strings or referrers. + +#### `frontend/src/api/client.test.ts` + +- `apiFetch` uses `credentials: "include"` for cookie mode. +- Bearer-session mode adds the session, not the master. +- `wsUrl()` is a pure credential-free URL builder for `ws:` and `wss:`. +- Existing PIN handling remains unchanged. +- Existing transport retry behavior does not retry authentication exchange. +- `401` session expiry emits one `ov:auth-required` event without a reload loop. + +#### `RemoteAuthGate.test.jsx` + +- API-key submission waits for exchange success before reload. +- Failure keeps the dialog visible, clears the submitted secret, and renders a + localized generic error. +- Success removes `ov_api_key` before reload. +- The master never reaches local/session storage, event details, or console. +- Repeated submit while pending produces one request. +- PIN mode retains its existing behavior and never mints an admin session. + +#### `RemoteBackendPanel.test.jsx` + +- Existing `ov_api_key` is migrated and deleted. +- A new master remains only in component state during exchange. +- Switching backend URL clears the old session before probing the new backend. +- A failed probe does not persist either master or session. +- Tauri/cross-origin success stores only the expiring session. + +#### WebSocket consumers + +Extend `CaptureWidget.test.jsx` and `useRealtimeEvents.test.jsx`: + +- Same-origin creates credential-free WebSocket URLs. +- Cross-origin waits for ticket issuance before constructing the socket. +- Ticket failure does not open an unauthenticated socket or leak credentials. +- Reconnect obtains a new ticket; it never reuses the consumed ticket. +- Existing audio/realtime reconnection and teardown behavior remains green. + +### Layer 8 — static secret-hygiene guard + +File: `tests/test_auth_secret_hygiene.py` + +This deterministic guard should scan production frontend/backend sources and +fail if the retired first-party patterns return. Keep a narrow allow-list for +legacy parsing/migration and tests. + +Required assertions: + +- No production `localStorage.setItem(...ov_api_key...)`. +- No generated `api_key=${...}` or equivalent master-key query construction. +- No middleware `Set-Cookie` value derived from the presented master. +- No session token is interpolated into a WebSocket URL; only `ws_ticket` is + allowed. +- Credential-bearing error/log templates do not interpolate raw request values. + +The static guard supplements runtime sentinel tests; it does not replace them. + +### Layer 9 — Playwright end-to-end tests + +File: `frontend/e2e/remote-auth.spec.ts` + +Run against a disposable backend with a non-loopback test client/proxy and a +known sentinel key. + +Scenarios: + +1. Open remote UI, enter key, load an admin screen, and connect realtime events. +2. Assert no master in local/session storage, cookies, page URL, WebSocket URL, + request URLs, response headers, console, or page errors. +3. Assert `ov_session` is `HttpOnly` through browser context cookie inspection. +4. Reload and confirm cookie-session continuity. +5. Log out and confirm protected HTTP and WebSocket access stops immediately. +6. Advance/inject the test clock to expiry and confirm one clean re-auth prompt. +7. Exercise cross-origin mode and verify a one-use `ws_ticket`, never a master or + admin session, appears in the WebSocket URL. +8. Attempt a hostile-origin POST and WebSocket connection; both must fail. + +Do not assert secret absence by screenshot. Inspect browser context storage, +cookies, requests, WebSockets, console messages, and backend-captured logs. + +### Layer 10 — compatibility and full-suite gates + +Targeted backend gate while iterating: + +```bash +HF_HUB_OFFLINE=1 HF_HUB_CACHE="$(mktemp -d)" \ + uv run pytest -q \ + tests/test_admin_sessions.py \ + tests/test_auth_session_api.py \ + tests/test_auth_secret_hygiene.py \ + tests/test_bearer_middleware.py \ + tests/test_loopback_server_mode.py \ + tests/test_admin_route_policy.py \ + tests/test_capture_ws.py \ + tests/test_mcp_bindings.py +``` + +Targeted frontend gate while iterating: + +```bash +cd frontend +bun run test \ + src/api/authSession.test.ts \ + src/api/client.test.ts \ + src/components/RemoteAuthGate.test.jsx \ + src/components/settings/RemoteBackendPanel.test.jsx \ + src/components/CaptureWidget.test.jsx \ + src/test/useRealtimeEvents.test.jsx +``` + +Pre-review gate: + +```bash +HF_HUB_OFFLINE=1 HF_HUB_CACHE="$(mktemp -d)" uv run pytest -q +cd frontend +bun run lint +bun run typecheck:ci +bun run test +bun run build +bun run e2e -- remote-auth.spec.ts +``` + +Run the existing deterministic repository gates, including changelog style, +locale parity, version lockstep, and hardcoded-CJK checks. Full backend tests +must use `HF_HUB_OFFLINE=1` and a genuinely empty `HF_HUB_CACHE`; a populated +developer cache is not valid evidence. + +No wall-clock performance threshold belongs in cross-platform CI. Instead, +exercise 10,000 store resolutions in a diagnostic benchmark and report the +before/after request-auth overhead in the PR. Reject a design that performs +database or filesystem I/O per request or holds the store lock while calling +downstream application code. + +## Test-to-invariant traceability + +| Invariant | Primary evidence | +| --------- | --------------------------------------------------------------------------- | +| AUTH-S1 | Frontend storage spies, static hygiene guard, Playwright storage inspection | +| AUTH-S2 | Backend sentinel scan across headers/body/logs/exceptions | +| AUTH-S3 | `wsUrl` unit tests, request/WebSocket inspection, static guard | +| AUTH-S4 | Session issuance negative credential matrix | +| AUTH-S5 | Store token/hash unit tests | +| AUTH-S6 | Fake-clock boundary and no-renewal tests | +| AUTH-S7 | Logout, rotation, removal, and fresh-store tests | +| AUTH-S8 | Route capability matrix, native denial tests | +| AUTH-S9 | Parsed cookie-attribute matrix under HTTP/HTTPS | +| AUTH-S10 | HTTP + WebSocket exact-origin matrix | +| AUTH-S11 | Ticket path/expiry/concurrency/single-use tests | +| AUTH-S12 | Principal precedence and single-parse tests | +| AUTH-S13 | Existing targeted suites plus full backend/frontend CI | + +## Implementation order + +Keep the change in one reviewed PR so the backend contract, first-party client, +and secret-hygiene regression land atomically. Use independently green commits. + +### Task 1 — Lock the leak with failing tests + +- Add the backend sentinel test proving the master currently appears in + `Set-Cookie`. +- Add frontend tests proving the master currently reaches `localStorage` and + WebSocket URLs. +- Record fail-before output in the PR description. +- Do not weaken assertions to match current behavior. + +### Task 2 — Implement the bounded session/ticket store + +- Write all fake-clock, hash-only, expiry, rotation, capacity, and concurrency + tests first. +- Implement `AdminSessionStore` without FastAPI imports. +- Run Layer 1 only until green. + +### Task 3 — Introduce canonical principals + +- Add principal and precedence tests first. +- Refactor middleware/dependencies to consume one decision from ASGI state. +- Keep all existing authorization tests green after every edit. + +### Task 4 — Add session HTTP endpoints + +- Add issuance, cookie, migration, revocation, rotation, CSRF, and no-leak tests. +- Register the auth router. +- Confirm no master value is emitted before moving to frontend work. + +### Task 5 — Add WebSocket tickets + +- Write single-use/path/expiry/concurrency tests first. +- Integrate ticket resolution into the canonical WebSocket guard. +- Preserve direct API-key and cookie-session WebSocket compatibility. + +### Task 6 — Migrate first-party frontend flows + +- Implement `authSession.ts` from its failing unit tests. +- Migrate `RemoteAuthGate`, deep links, `RemoteBackendPanel`, `CaptureWidget`, and + realtime events. +- Remove all first-party master persistence and URL generation. +- Keep the legacy parser only for one-time migration. + +### Task 7 — Add static and browser-level regression coverage + +- Land the source hygiene guard. +- Add Playwright remote-auth scenarios. +- Inspect actual browser storage, cookies, requests, WebSockets, console, and + backend logs using sentinel credentials. + +### Task 8 — Documentation, i18n, and full validation + +- Update `docs/api-auth.md` and `docs/remote-gpu.md` with session behavior, + transport limitations, TLS requirements, logout/expiry, and direct-client + compatibility. +- Add concise `CHANGELOG.md` Unreleased entry. +- Translate any new visible strings in all 21 locale files. +- Run the complete offline backend suite, frontend lint/typecheck/test/build, + Playwright scenario, and deterministic repository gates. +- Read CodeRabbit/Greptile findings and resolve every Critical/P1 before merge. +- Merge current `main` into a stale branch before trusting CI, require + `MERGEABLE`, and watch post-merge `main` runs to green. + +## Failure and rollback behavior + +- Session-store failure must fail closed for remote authenticated requests. It + must not fall back to the master cookie or silently grant loopback identity. +- A frontend exchange failure leaves the auth gate visible with a generic, + localized error and no retained master key. +- WebSocket ticket failure does not fall back to putting the session/master in + the URL. +- Direct Bearer clients remain the operational rollback path; no feature flag + is required. +- Legacy `ov_key` remains accepted only for controlled one-time migration. The + backend stops creating it immediately. + +## Acceptance criteria + +The change is complete only when all conditions hold: + +- A sentinel master key is absent from browser durable/transient storage after + exchange, all cookies, URLs, response data, console output, and backend logs. +- In API-key mode, the only reusable browser credential after exchange is an + eight-hour admin session; the only URL credential is a 30-second, path-bound, + one-use WebSocket ticket. The independent PIN flow remains unchanged. +- Same-origin HTTP and WebSocket flows work with the hardened cookie. +- Cross-origin/Tauri HTTP and WebSocket flows work without persisting the master + or putting the admin session into a URL. +- Logout, expiry, key rotation/removal, and backend restart invalidate access. +- Admin sessions cannot reach native host-path capabilities. +- PIN and trusted networks cannot reach write/side-effect admin operations. +- Direct curl/OpenAI SDK Bearer behavior remains compatible. +- Server-mode no-credential read-only bootstrap remains compatible. +- No new required network call, platform divergence, dependency, or version + bump is introduced. +- Targeted tests, full offline backend tests, frontend lint/typecheck/test/build, + Playwright remote-auth coverage, CI, and post-merge `main` are green. + +## Implementation and verification status — 2026-08-13 + +The implementation is complete in the working tree. Merge readiness still +requires the repository's normal PR review and Linux CI gates; no claim below +substitutes for CodeRabbit/Greptile review or post-merge `main` monitoring. + +Delivered behavior: + +- Process-local, hash-only administrator sessions: 256-bit tokens, eight-hour + non-renewing lifetime, deterministic capacity, explicit revocation, and + immediate invalidation on master-key rotation/removal or process restart. +- Thirty-second, path-bound, atomically single-use tickets for `/ws/events` and + `/ws/transcribe`; the reusable session and master never enter a WebSocket URL. +- One canonical principal decision across middleware, HTTP dependencies, and + WebSockets, including authoritative rejection of malformed or invalid + explicit credentials instead of fall-through to ambient trust. +- Exact-origin cookie CSRF enforcement, side-effectful-GET protection, strict + cookie attributes, `Cache-Control: no-store`, and controlled legacy migration. +- Unicode-safe constant-time credential comparisons and a bounded, per-client + failed-exchange window that throttles brute force without locking out a + request carrying the correct master key. +- Same-origin HttpOnly-cookie and cross-origin/Tauri bearer transports. The + bearer contract prefers bounded `expires_in` values so independent browser + and server clocks cannot invalidate otherwise-valid credentials. +- CORS is outside both authentication gates, so credentialless preflights and + gate-generated `401` responses remain readable by an allowed browser origin. +- WebSocket URL construction preserves reverse-proxy base-path prefixes while + tickets remain bound to the backend's canonical `/ws/events` or + `/ws/transcribe` route. +- First-party removal of durable master-key persistence, master/session URL + credentials, accidental auth forwarding to foreign absolute URLs, and + storage-policy failures that previously could abort credential cleanup. + +Verification evidence: + +| Gate | Result | +|---|---| +| Session/principal/CSRF/HTTP contract + API/network/ASR compatibility | 324 passed, 1 expected xfail | +| Branch coverage for the four new backend modules | 95% total; 162 dedicated tests passed | +| Isolated `backend/tests/`, empty HF cache and offline | 254 passed | +| Prior Linux CI failure order (`mcp_bindings` → network middleware → principal) | 54 passed after runtime singleton resolution fix | +| Changelog, locale parity, version, CJK, route inventory, and install-doc gates | 243 passed | +| Full Vitest suite | 265 files, 2,084 passed | +| Review-focused auth/client regression | 6 files, 98 passed | +| Session lookup diagnostic | 14.2 μs median over seven 10,000-resolution samples; no database or filesystem I/O | +| Frontend TypeScript | clean | +| Frontend oxlint | zero errors; repository baseline warnings only | +| Production build | passed | +| Production-bundle Playwright | 4 passed in real Chrome, including same-origin cookie and cross-origin bearer credential-hygiene regressions | +| Real backend + cross-origin browser exercise | exchange `201`; protected call `200`; ticket `201`; WebSocket opened; logout `204`; subsequent access `401`; no master in storage, location, or request URLs | +| Session-store diagnostic | 10,000 resolutions in 92.886 ms (9.289 µs/op) on the verification host | + +Repository-wide offline `tests/` was also executed, not sampled: 5,591 tests +passed before the run exposed four change-adjacent regressions (route snapshot, +two ASR WebSocket compatibility cases, and an order-sensitive network case). +The three deterministic regressions were corrected; all four are included in +the 324-test compatibility gate above. Re-running the remaining last-failed set +produced 33 failures, three +setup errors, and one pass, all confined to unmodified engine/FFmpeg/IndexTTS, +AppRun/shell, dubbing-export, worker-permission, and Windows path/permission +tests. No failing assertion targets the new auth/session behavior, and the +affected route/middleware suites pass in the compatibility gate above. These +existing Windows/offline failures are recorded rather than hidden; the required +Linux CI gate must still be green before merge. + +The legacy Node runner passed 70/71. Its only failure is the unmodified +`clearDevPorts.test.mjs` POSIX-path fixture under a Windows host. The general +development E2E suite passed 12/17; its five Gallery cases remained on the +installer splash even with a real offline backend, while the production-bundle +gate and the dedicated real remote-auth flow both passed. No product assertion +in those five Gallery cases covers this change. + +## Rejected alternatives + +### Put the master key in a hardened `HttpOnly` cookie + +Rejected. Cookie flags reduce JavaScript extraction but retain a permanent, +full-power credential as ambient browser authority and do not provide expiry, +scope, or revocation. + +### Use JWT/stateless HMAC sessions + +Rejected. Stateless sessions complicate individual revocation and key rotation. +Deriving a signing key from a potentially human-chosen API key also creates an +offline verifier if a token is stolen. A small process-local opaque-token store +is simpler and safer for VoiceStudio's single-process architecture. + +### Persist browser sessions in SQLite + +Rejected for this change. Persistence extends credential lifetime across +backend restarts, adds a verifier and migration surface, and provides little +value for a remote admin session. Re-authentication after restart is an +intentional security boundary. + +### Store the master in the Tauri webview until a native keychain is added + +Rejected. Platform secure-storage work must be explicit and parity-tested. +Until then, the correct fallback is re-authentication, not durable plaintext +storage. + +### Disable remote admin entirely + +Rejected. Server-mode remote administration is required behavior. The solution +must harden credential lifecycle without removing a working cross-platform +capability. diff --git a/frontend/e2e-prod/admin-session-hygiene.spec.ts b/frontend/e2e-prod/admin-session-hygiene.spec.ts new file mode 100644 index 00000000..72a5a135 --- /dev/null +++ b/frontend/e2e-prod/admin-session-hygiene.spec.ts @@ -0,0 +1,130 @@ +import { expect, test, type Page, type Request, type Route } from '@playwright/test'; + +const MASTER = 'root-master-never-retained'; +const SESSION = `ovs_admin_session_${'S'.repeat(43)}`; + +async function browserCredentialSnapshot(page: Page) { + return page.evaluate(() => ({ + href: location.href, + legacyMaster: localStorage.getItem('ov_api_key'), + storedSession: sessionStorage.getItem('ov_admin_session'), + localValues: Object.values(localStorage), + sessionValues: Object.values(sessionStorage), + })); +} + +test('same-origin production bootstrap exchanges once into an HttpOnly cookie', async ({ + context, + page, +}) => { + const seen: Request[] = []; + page.on('request', (request) => seen.push(request)); + await page.addInitScript((master) => localStorage.setItem('ov_api_key', master), MASTER); + + let exchange: Request | undefined; + await page.route('**/api/auth/session', async (route) => { + exchange = route.request(); + await route.fulfill({ + status: 204, + headers: { + 'cache-control': 'no-store', + 'set-cookie': `ov_session=${SESSION}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`, + }, + }); + }); + + await page.goto(`/#api_key=${MASTER}&tab=voices`, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`); + + expect(exchange?.postDataJSON()).toEqual({ transport: 'cookie' }); + const snapshot = await browserCredentialSnapshot(page); + expect(snapshot.href).toMatch(/#tab=voices$/); + expect(snapshot.href).not.toContain(MASTER); + expect(snapshot.legacyMaster).toBeNull(); + expect(snapshot.storedSession).toBeNull(); + expect([...snapshot.localValues, ...snapshot.sessionValues].join('\n')).not.toContain(MASTER); + expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER); + + const cookies = await context.cookies(); + const cookie = cookies.find(({ name }) => name === 'ov_session'); + expect(cookie).toMatchObject({ value: SESSION, httpOnly: true, sameSite: 'Strict', path: '/' }); + expect(cookies.some(({ name }) => name === 'ov_key')).toBe(false); + expect(cookies.map(({ value }) => value).join('\n')).not.toContain(MASTER); +}); + +test('cross-origin production bootstrap stores only a backend-bound tab session', async ({ + page, +}) => { + const remote = 'http://gpu.test:3900'; + const seen: Request[] = []; + page.on('request', (request) => seen.push(request)); + await page.addInitScript( + ({ backend, master }) => { + localStorage.setItem('ov_backend_url', backend); + localStorage.setItem('ov_api_key', master); + }, + { backend: remote, master: MASTER }, + ); + + let exchange: Request | undefined; + await page.route(`${remote}/**`, async (route: Route) => { + const request = route.request(); + const corsHeaders = { + 'access-control-allow-credentials': 'true', + 'access-control-allow-headers': 'authorization,content-type,x-voicestudio-csrf', + 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', + 'access-control-allow-origin': request.headers().origin ?? 'http://localhost:4173', + 'access-control-expose-headers': 'x-omnivoice-backend', + 'x-omnivoice-backend': 'e2e', + }; + if (request.method() === 'OPTIONS') { + await route.fulfill({ status: 204, headers: corsHeaders }); + return; + } + if (new URL(request.url()).pathname === '/api/auth/session') { + exchange = request; + await route.fulfill({ + status: 201, + headers: { + ...corsHeaders, + 'cache-control': 'no-store', + 'content-type': 'application/json', + }, + body: JSON.stringify({ token: SESSION, expires_at: 1, expires_in: 3600 }), + }); + return; + } + if (new URL(request.url()).pathname === '/health') { + await route.fulfill({ + status: 200, + headers: { ...corsHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ status: 'ok', version: 'e2e', device: 'cpu' }), + }); + return; + } + await route.fulfill({ + status: 200, + headers: { ...corsHeaders, 'content-type': 'application/json' }, + body: '{}', + }); + }); + + await page.goto(`/#api_key=${MASTER}`, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`); + + expect(exchange?.postDataJSON()).toEqual({ transport: 'bearer' }); + const snapshot = await browserCredentialSnapshot(page); + expect(snapshot.href).not.toContain(MASTER); + expect(snapshot.legacyMaster).toBeNull(); + expect(snapshot.storedSession).not.toBeNull(); + expect(JSON.parse(snapshot.storedSession ?? '{}')).toMatchObject({ + token: SESSION, + apiBase: remote, + }); + expect(snapshot.localValues.join('\n')).not.toContain(MASTER); + expect(snapshot.sessionValues.join('\n')).not.toContain(MASTER); + expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER); + expect( + seen.filter((request) => request.headers().authorization === `Bearer ${MASTER}`), + ).toHaveLength(1); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 816ebc49..1ceead80 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -657,7 +657,7 @@ function App() { let cancelled = false; (async () => { if (remoteBackend) { - const result = await probeRemoteBackend(remoteBackend.url, remoteBackend.key); + const result = await probeRemoteBackend(remoteBackend.url); if (cancelled) return; setRemoteFailure(result.ok ? null : result); setSetupNeeded(false); diff --git a/frontend/src/api/authCredentialHygiene.test.js b/frontend/src/api/authCredentialHygiene.test.js new file mode 100644 index 00000000..da129749 --- /dev/null +++ b/frontend/src/api/authCredentialHygiene.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); + +function* productionFiles(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'test') continue; + yield* productionFiles(absolute); + continue; + } + if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !/\.test\.[jt]sx?$/.test(entry.name)) { + yield absolute; + } + } +} + +const sources = () => + [...productionFiles(SRC)].map((file) => ({ + file: path.relative(SRC, file).replaceAll('\\', '/'), + source: fs.readFileSync(file, 'utf8'), + })); + +// Any storage receiver counts: `sessionStorage.setItem('ov_api_key', …)` is the +// same credential-persistence class as localStorage, and production code passes +// injected stores under other names (sessionStore, localStore, legacyStorage, +// storage). Matching `.setItem(` — whatever the receiver, whatever +// the quote style, optional chaining included — closes the whole class instead +// of one spelling. getItem/removeItem (the migration/removal call sites) and +// setItem of other keys stay legal. +const PERSISTED_MASTER_RE = + /\.setItem(?:\?\.)?\(\s*(?:LS_API_KEY\b|LEGACY_API_KEY_STORAGE_KEY\b|[`'"]ov_api_key[`'"])/; + +describe('administrator credential hygiene static guard', () => { + it('has no production path that writes the legacy master key to any Web Storage', () => { + const violations = sources() + .filter(({ source }) => PERSISTED_MASTER_RE.test(source)) + .map(({ file }) => file); + + expect(violations, 'OMNIVOICE_API_KEY must never enter localStorage or sessionStorage').toEqual( + [], + ); + }); + + it('catches realistic storage receivers, aliases, and quote styles', () => { + const caught = [ + "localStorage.setItem('ov_api_key', key)", + "localStorage.setItem?.('ov_api_key', key)", + 'sessionStorage.setItem("ov_api_key", key)', + 'window.localStorage.setItem(`ov_api_key`, key)', + 'sessionStore?.setItem(LS_API_KEY, key)', + 'localStore.setItem( LEGACY_API_KEY_STORAGE_KEY, key)', + 'legacyStorage?.setItem(LS_API_KEY, master)', + ]; + const allowed = [ + "localStorage.removeItem('ov_api_key')", + 'localStore?.getItem(LS_API_KEY)', + 'storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record))', + "sessionStore?.setItem('ov_pin', pin)", + 'localStorage.setItem(LS_BACKEND_URL, normalized)', + ]; + for (const line of caught) expect(PERSISTED_MASTER_RE.test(line), line).toBe(true); + for (const line of allowed) expect(PERSISTED_MASTER_RE.test(line), line).toBe(false); + }); + + it('has no production WebSocket query builder for a master API key', () => { + const forbidden = [ + /searchParams\.set\(\s*['"]api_key['"]/, + /[?&]api_key=\$\{/, + /[?&]api_key=['"]\s*\+/, + ]; + const violations = sources() + .filter(({ source }) => forbidden.some((pattern) => pattern.test(source))) + .map(({ file }) => file); + + expect(violations, 'WebSocket URLs may contain ws_ticket, never a master key').toEqual([]); + }); + + it('keeps both WebSocket consumers behind the authenticated URL boundary', () => { + const constructors = sources() + .filter(({ source }) => source.includes('new WebSocket(')) + .map(({ file, source }) => ({ file, authenticated: source.includes('authenticatedWsUrl') })); + + expect(constructors).toEqual([ + { file: 'components/CaptureWidget.jsx', authenticated: true }, + { file: 'hooks/useRealtimeEvents.js', authenticated: true }, + ]); + }); +}); diff --git a/frontend/src/api/authSession.test.ts b/frontend/src/api/authSession.test.ts new file mode 100644 index 00000000..8b2835a7 --- /dev/null +++ b/frontend/src/api/authSession.test.ts @@ -0,0 +1,548 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ADMIN_SESSION_STORAGE_KEY, + AuthSessionError, + LEGACY_API_KEY_STORAGE_KEY, + authenticatedWsUrl, + clearAdminSession, + exchangeApiKey, + getAdminSession, + isSameOriginApi, + requestWebSocketTicket, + revokeAdminSession, +} from './authSession'; + +const SESSION = `ovs_admin_session_${'A'.repeat(43)}`; +const TICKET = `ovs_ws_ticket_${'B'.repeat(43)}`; +const MASTER = 'master-must-never-persist'; +const NOW_SECONDS = 1_800_000_000; + +const response = (body: unknown, status = 201) => + new Response(body === null ? null : JSON.stringify(body), { + status, + headers: body === null ? undefined : { 'content-type': 'application/json' }, + }); + +const sameOriginWindow = { + location: { origin: 'https://voice.test' }, + dispatchEvent: vi.fn(), +}; + +const crossOriginWindow = { + location: { origin: 'tauri://localhost' }, + __TAURI_INTERNALS__: {}, + dispatchEvent: vi.fn(), +}; + +describe('short-lived admin session client', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + sessionStorage.clear(); + }); + + it('selects cookie transport only for an exact same-origin HTTP API', () => { + expect(isSameOriginApi('https://voice.test', sameOriginWindow)).toBe(true); + expect(isSameOriginApi('https://voice.test:444', sameOriginWindow)).toBe(false); + expect(isSameOriginApi('http://voice.test', sameOriginWindow)).toBe(false); + expect(isSameOriginApi('https://voice.test.evil.test', sameOriginWindow)).toBe(false); + expect(isSameOriginApi('http://127.0.0.1:3900', crossOriginWindow)).toBe(false); + }); + + it('exchanges a same-origin master for an HttpOnly cookie without persisting any token', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const fetchImpl = vi.fn().mockResolvedValue(response(null, 204)); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://voice.test/', + fetchImpl, + windowLike: sameOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toEqual({ transport: 'cookie' }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://voice.test/api/auth/session', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + cache: 'no-store', + referrerPolicy: 'no-referrer', + headers: expect.objectContaining({ Authorization: `Bearer ${MASTER}` }), + body: JSON.stringify({ transport: 'cookie' }), + }), + ); + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull(); + expect(sessionStorage.length).toBe(0); + }); + + it('stores only a backend-bound short-lived bearer session for cross-origin clients', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 })); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900/', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 }); + + const persisted = sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY) ?? ''; + expect(persisted).toContain(SESSION); + expect(persisted).toContain('https://gpu.test:3900'); + expect(persisted).not.toContain(MASTER); + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull(); + expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }); + }); + + it('uses relative lifetime when remote and browser clocks are not synchronized', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + response({ + token: SESSION, + expires_at: 1, + expires_in: 3600, + }), + ); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toEqual({ transport: 'bearer', expiresAt: NOW_SECONDS + 3600 }); + + expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toEqual({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }); + }); + + it('retains the legacy master while the exchange is pending and removes it on success', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + let resolveFetch: (value: Response) => void = () => {}; + const fetchImpl = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const pending = exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }); + + // Not yet: only a session that actually exists may consume the stored key. + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER); + resolveFetch(response({ token: SESSION, expires_at: NOW_SECONDS + 3600 })); + await pending; + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBeNull(); + }); + + it('never retries a failed exchange and exposes no master or response body in its error', async () => { + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const reflected = `invalid credential: ${MASTER}`; + const fetchImpl = vi.fn().mockResolvedValue(response({ detail: reflected }, 401)); + + const error = await exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }).catch((value) => value); + + expect(error).toBeInstanceOf(AuthSessionError); + expect(error.status).toBe(401); + expect(String(error)).not.toContain(MASTER); + expect(String(error)).not.toContain(reflected); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(sessionStorage.length).toBe(0); + // A failed exchange leaves the durable key for the next launch's retry. + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER); + }); + + it('bounds a hung exchange and retains the durable master for the next migration attempt', async () => { + vi.useFakeTimers(); + localStorage.setItem(LEGACY_API_KEY_STORAGE_KEY, MASTER); + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ); + + const pending = exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl: fetchImpl as typeof fetch, + windowLike: crossOriginWindow, + timeoutMs: 25, + }); + const observed = pending.catch((error) => error); + await vi.advanceTimersByTimeAsync(25); + + expect(await observed).toBeInstanceOf(AuthSessionError); + expect(fetchImpl).toHaveBeenCalledOnce(); + // Unreachable/hung backend: the stored copy is the user's only copy. + expect(localStorage.getItem(LEGACY_API_KEY_STORAGE_KEY)).toBe(MASTER); + expect(sessionStorage.length).toBe(0); + vi.useRealTimers(); + }); + + it.each([ + [{ token: MASTER, expires_at: NOW_SECONDS + 3600 }, 'master-shaped token'], + [{ token: SESSION, expires_at: NOW_SECONDS - 1 }, 'expired session'], + [{ token: SESSION, expires_at: NOW_SECONDS + 40_000 }, 'implausible expiry'], + [{ token: SESSION }, 'missing expiry'], + [null, 'missing body'], + ])('rejects and does not persist a malformed bearer response: %s (%s)', async (body, _label) => { + const fetchImpl = vi.fn().mockResolvedValue(response(body)); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + expect(sessionStorage.length).toBe(0); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, 40_000, '3600'])( + 'rejects an invalid relative session lifetime: %s', + async (expiresIn) => { + const fetchImpl = vi.fn().mockResolvedValue( + response({ + token: SESSION, + expires_at: NOW_SECONDS + 3600, + expires_in: expiresIn, + }), + ); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + expect(sessionStorage.length).toBe(0); + }, + ); + + it('rejects oversized bearer responses before parsing them', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response('x'.repeat(20_000), { + status: 201, + headers: { 'content-length': '20000' }, + }), + ); + + await expect( + exchangeApiKey(MASTER, { + apiBase: 'https://gpu.test:3900', + fetchImpl, + windowLike: crossOriginWindow, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + expect(sessionStorage.length).toBe(0); + }); + + it('drops malformed, expired, or wrong-backend session storage', () => { + sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, '{bad json'); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ token: SESSION, expiresAt: NOW_SECONDS - 1, apiBase: 'https://gpu.test' }), + ); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 10, + apiBase: 'https://other.test', + }), + ); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 40_000, + apiBase: 'https://gpu.test', + }), + ); + expect(getAdminSession('https://gpu.test', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); + }); + + it('mints a path-bound WebSocket ticket with the session only in an HTTP header', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 })); + + await expect( + requestWebSocketTicket('/ws/transcribe?model=live', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toBe(TICKET); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gpu.test:3900/api/auth/ws-ticket', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }), + body: JSON.stringify({ path: '/ws/transcribe' }), + }), + ); + expect(JSON.stringify(fetchImpl.mock.calls[0][0])).not.toContain(SESSION); + }); + + it('accepts a ticket lifetime independent of server wall-clock skew', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_at: 1, expires_in: 30 })); + + await expect( + requestWebSocketTicket('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toBe(TICKET); + }); + + it('places only the one-use ticket in a bearer-authenticated WebSocket URL', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 })); + + const url = await authenticatedWsUrl('/ws/transcribe?model=live&api_key=legacy', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + + expect(url).toBe(`wss://gpu.test:3900/ws/transcribe?model=live&ws_ticket=${TICKET}`); + expect(url).not.toContain(SESSION); + expect(url).not.toContain(MASTER); + expect(url).not.toContain('api_key'); + }); + + it('preserves a reverse-proxy base path while binding the ticket to the logical WS route', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test/studio', + }), + ); + const fetchImpl = vi + .fn() + .mockResolvedValue(response({ ticket: TICKET, expires_in: 30, expires_at: 1 })); + + await expect( + authenticatedWsUrl('/ws/events?view=active', { + apiBase: 'https://gpu.test/studio', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }), + ).resolves.toBe(`wss://gpu.test/studio/ws/events?view=active&ws_ticket=${TICKET}`); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gpu.test/studio/api/auth/ws-ticket', + expect.objectContaining({ body: JSON.stringify({ path: '/ws/events' }) }), + ); + }); + + it.each(['/ws/events/../admin', '//evil.test/ws/events', 'https://gpu.test/ws/events'])( + 'rejects a non-canonical WebSocket target: %s', + async (path) => { + await expect( + authenticatedWsUrl(path, { apiBase: 'https://gpu.test', fetchImpl: vi.fn() }), + ).rejects.toBeInstanceOf(AuthSessionError); + }, + ); + + it('requests a fresh ticket for every WebSocket connection attempt', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const secondTicket = `ovs_ws_ticket_${'C'.repeat(43)}`; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response({ ticket: TICKET, expires_at: NOW_SECONDS + 30 })) + .mockResolvedValueOnce(response({ ticket: secondTicket, expires_at: NOW_SECONDS + 30 })); + + const first = await authenticatedWsUrl('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + const second = await authenticatedWsUrl('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + + expect(first).toContain(TICKET); + expect(second).toContain(secondTicket); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('uses a credential-free WebSocket URL when no bearer session exists', async () => { + const fetchImpl = vi.fn(); + await expect( + authenticatedWsUrl('/ws/events?api_key=must-be-removed', { + apiBase: 'http://127.0.0.1:3900', + fetchImpl, + }), + ).resolves.toBe('ws://127.0.0.1:3900/ws/events'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('clears an invalid session and raises the auth gate when ticket issuance is rejected', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + const windowLike = { ...crossOriginWindow, dispatchEvent: vi.fn() }; + + await expect( + requestWebSocketTicket('/ws/events', { + apiBase: 'https://gpu.test:3900', + fetchImpl: vi.fn().mockResolvedValue(response({ detail: 'expired' }, 401)), + windowLike, + now: () => NOW_SECONDS * 1000, + }), + ).rejects.toBeInstanceOf(AuthSessionError); + + expect(getAdminSession('https://gpu.test:3900', { now: () => NOW_SECONDS * 1000 })).toBeNull(); + expect(windowLike.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ov:auth-required' }), + ); + }); + + it('clears session state idempotently without touching unrelated storage', () => { + sessionStorage.setItem(ADMIN_SESSION_STORAGE_KEY, 'value'); + sessionStorage.setItem('unrelated', 'keep'); + clearAdminSession(); + clearAdminSession(); + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); + expect(sessionStorage.getItem('unrelated')).toBe('keep'); + }); + + it('revokes a bearer session while clearing local state before the request settles', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: SESSION, + expiresAt: NOW_SECONDS + 3600, + apiBase: 'https://gpu.test:3900', + }), + ); + let resolveFetch: (response: Response) => void = () => {}; + const fetchImpl = vi.fn(() => new Promise((resolve) => (resolveFetch = resolve))); + + const pending = revokeAdminSession('https://gpu.test:3900', { + fetchImpl, + now: () => NOW_SECONDS * 1000, + }); + + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gpu.test:3900/api/auth/session', + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ Authorization: `Bearer ${SESSION}` }), + credentials: 'include', + }), + ); + resolveFetch(response(null, 204)); + await expect(pending).resolves.toBe(true); + }); + + it('revokes a same-origin cookie session with the CSRF marker', async () => { + const fetchImpl = vi.fn().mockResolvedValue(response(null, 204)); + + await expect( + revokeAdminSession('https://voice.test', { + fetchImpl, + windowLike: sameOriginWindow, + }), + ).resolves.toBe(true); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://voice.test/api/auth/session', + expect.objectContaining({ + headers: { 'X-VoiceStudio-CSRF': '1' }, + credentials: 'include', + }), + ); + }); +}); diff --git a/frontend/src/api/authSession.ts b/frontend/src/api/authSession.ts new file mode 100644 index 00000000..0f5f4b43 --- /dev/null +++ b/frontend/src/api/authSession.ts @@ -0,0 +1,483 @@ +/** + * Browser-side boundary for the remote administrator credential. + * + * The configured master key is accepted only as an input to `exchangeApiKey`. + * It is never written to storage and never placed in a WebSocket URL. Browser + * clients retain only a backend-bound, short-lived session in sessionStorage; + * same-origin clients use an HttpOnly cookie that JavaScript cannot read. + */ + +export const LEGACY_API_KEY_STORAGE_KEY = 'ov_api_key'; +export const ADMIN_SESSION_STORAGE_KEY = 'ov_admin_session'; +export const CSRF_HEADER_NAME = 'X-VoiceStudio-CSRF'; + +const ADMIN_SESSION_RE = /^ovs_admin_session_[A-Za-z0-9_-]{43}$/; +const WS_TICKET_RE = /^ovs_ws_ticket_[A-Za-z0-9_-]{43}$/; +const MAX_AUTH_RESPONSE_BYTES = 16 * 1024; +const MAX_SESSION_LIFETIME_SECONDS = 9 * 60 * 60; +const MAX_TICKET_LIFETIME_SECONDS = 60; + +type StorageLike = Pick; + +type AuthWindow = { + location?: { origin?: string }; + dispatchEvent?: (event: Event) => boolean; + __TAURI__?: unknown; + __TAURI_INTERNALS__?: unknown; +}; + +type CommonOptions = { + apiBase: string; + fetchImpl?: typeof fetch; + storage?: StorageLike | null; + windowLike?: AuthWindow; + now?: () => number; + timeoutMs?: number; +}; + +export type StoredAdminSession = { + token: string; + expiresAt: number; + apiBase: string; +}; + +export class AuthSessionError extends Error { + status?: number; + + constructor(status?: number) { + super('Remote administrator authentication failed.'); + this.name = 'AuthSessionError'; + this.status = status; + } +} + +function defaultWindow(): AuthWindow | undefined { + return typeof window === 'undefined' ? undefined : window; +} + +function defaultSessionStorage(): StorageLike | null { + try { + return typeof sessionStorage === 'undefined' ? null : sessionStorage; + } catch { + return null; + } +} + +function defaultLocalStorage(): StorageLike | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +function normalizedApiBase(raw: string): string { + const candidate = raw.trim(); + let url: URL; + try { + url = new URL(candidate); + } catch { + throw new AuthSessionError(); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash + ) { + throw new AuthSessionError(); + } + return url.toString().replace(/\/+$/, ''); +} + +export function isSameOriginApi( + apiBase: string, + windowLike: AuthWindow | undefined = defaultWindow(), +): boolean { + try { + const apiOrigin = new URL(normalizedApiBase(apiBase)).origin; + const pageOrigin = windowLike?.location?.origin; + return Boolean(pageOrigin && pageOrigin !== 'null' && apiOrigin === pageOrigin); + } catch { + return false; + } +} + +function removeLegacyMaster(storage: StorageLike | null = defaultLocalStorage()): void { + try { + storage?.removeItem(LEGACY_API_KEY_STORAGE_KEY); + } catch { + // A blocked storage API is already equivalent to the key not persisting. + } +} + +export function clearAdminSession({ + storage = defaultSessionStorage(), +}: { storage?: StorageLike | null } = {}): void { + try { + storage?.removeItem(ADMIN_SESSION_STORAGE_KEY); + } catch { + // Best effort; callers still stop using the in-memory value immediately. + } +} + +export function getAdminSession( + apiBase: string, + { + storage = defaultSessionStorage(), + now = Date.now, + }: { storage?: StorageLike | null; now?: () => number } = {}, +): StoredAdminSession | null { + let normalized: string; + try { + normalized = normalizedApiBase(apiBase); + } catch { + clearAdminSession({ storage }); + return null; + } + + let raw: string | null = null; + try { + raw = storage?.getItem(ADMIN_SESSION_STORAGE_KEY) ?? null; + } catch { + return null; + } + if (!raw || raw.length > 4096) { + if (raw) clearAdminSession({ storage }); + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const nowSeconds = now() / 1000; + if ( + !ADMIN_SESSION_RE.test(String(parsed.token ?? '')) || + typeof parsed.expiresAt !== 'number' || + !Number.isFinite(parsed.expiresAt) || + parsed.expiresAt <= nowSeconds || + parsed.expiresAt > nowSeconds + MAX_SESSION_LIFETIME_SECONDS || + parsed.apiBase !== normalized + ) { + clearAdminSession({ storage }); + return null; + } + return { + token: parsed.token as string, + expiresAt: parsed.expiresAt, + apiBase: normalized, + }; + } catch { + clearAdminSession({ storage }); + return null; + } +} + +async function readBoundedText(response: Response): Promise { + const advertisedBytes = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_AUTH_RESPONSE_BYTES) { + throw new AuthSessionError(response.status); + } + + const reader = response.body?.getReader(); + if (!reader) return ''; + const decoder = new TextDecoder(); + const parts: string[] = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_AUTH_RESPONSE_BYTES) { + await reader.cancel(); + throw new AuthSessionError(response.status); + } + parts.push(decoder.decode(value, { stream: true })); + } + parts.push(decoder.decode()); + return parts.join(''); + } finally { + reader.releaseLock(); + } +} + +async function readBoundedObject(response: Response): Promise> { + const text = await readBoundedText(response); + try { + const value = JSON.parse(text); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(); + return value as Record; + } catch (error) { + if (error instanceof AuthSessionError) throw error; + throw new AuthSessionError(response.status); + } +} + +function plausibleExpiry( + value: unknown, + nowMs: number, + maxLifetimeSeconds: number, +): value is number { + if (typeof value !== 'number' || !Number.isFinite(value)) return false; + const nowSeconds = nowMs / 1000; + return value > nowSeconds && value <= nowSeconds + maxLifetimeSeconds; +} + +function responseExpiry( + payload: Record, + nowMs: number, + maxLifetimeSeconds: number, +): number | null { + const relative = payload.expires_in; + if (relative !== undefined) { + if ( + typeof relative !== 'number' || + !Number.isFinite(relative) || + relative <= 0 || + relative > maxLifetimeSeconds + ) { + return null; + } + return nowMs / 1000 + relative; + } + return plausibleExpiry(payload.expires_at, nowMs, maxLifetimeSeconds) ? payload.expires_at : null; +} + +function dispatchAuthRequired(windowLike: AuthWindow | undefined): void { + try { + windowLike?.dispatchEvent?.( + new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } }), + ); + } catch { + // Non-browser callers can still handle the typed error. + } +} + +export async function exchangeApiKey( + apiKey: string, + { + apiBase, + fetchImpl = fetch, + storage = defaultSessionStorage(), + windowLike = defaultWindow(), + now = Date.now, + legacyStorage = defaultLocalStorage(), + timeoutMs = 10_000, + }: CommonOptions & { legacyStorage?: StorageLike | null }, +): Promise<{ transport: 'cookie' } | { transport: 'bearer'; expiresAt: number }> { + // A stale session must not outlive a new exchange attempt, but the + // historical durable master is deleted only after the backend ACCEPTS the + // exchange. Deleting it up front stranded remote-backend users whose box was + // unreachable at first launch after upgrade: the failed exchange consumed + // their only stored copy of OMNIVOICE_API_KEY. Keeping it on failure lets + // the next launch retry the migration; every success path below removes it, + // so the key never coexists with a live session. + clearAdminSession({ storage }); + + const master = apiKey.trim(); + if (!master || master.length > 8192) throw new AuthSessionError(); + const base = normalizedApiBase(apiBase); + const transport = isSameOriginApi(base, windowLike) ? 'cookie' : 'bearer'; + + let response: Response; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 60_000))); + try { + response = await fetchImpl(`${base}/api/auth/session`, { + method: 'POST', + headers: { + Authorization: `Bearer ${master}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ transport }), + credentials: 'include', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + } catch { + throw new AuthSessionError(); + } finally { + clearTimeout(timer); + } + + if (transport === 'cookie') { + if (response.status !== 204) throw new AuthSessionError(response.status); + removeLegacyMaster(legacyStorage); + return { transport }; + } + if (response.status !== 201) throw new AuthSessionError(response.status); + + const payload = await readBoundedObject(response); + const token = payload.token; + const expiresAt = responseExpiry(payload, now(), MAX_SESSION_LIFETIME_SECONDS); + if (typeof token !== 'string' || !ADMIN_SESSION_RE.test(token) || expiresAt === null) { + throw new AuthSessionError(response.status); + } + + const record: StoredAdminSession = { token, expiresAt, apiBase: base }; + try { + if (!storage) throw new TypeError(); + storage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(record)); + } catch { + clearAdminSession({ storage }); + throw new AuthSessionError(); + } + removeLegacyMaster(legacyStorage); + return { transport, expiresAt }; +} + +/** Best-effort server revocation used when switching away from a backend. + * Local state is cleared before the network await, so a hung or unreachable + * backend cannot prolong the browser's ability to use the session. */ +export async function revokeAdminSession( + apiBase: string, + { + fetchImpl = fetch, + storage = defaultSessionStorage(), + windowLike = defaultWindow(), + now = Date.now, + timeoutMs = 1500, + }: Omit = {}, +): Promise { + let base: string; + try { + base = normalizedApiBase(apiBase); + } catch { + clearAdminSession({ storage }); + return false; + } + const session = getAdminSession(base, { storage, now }); + const sameOrigin = isSameOriginApi(base, windowLike); + clearAdminSession({ storage }); + // Cross-origin cookie auth cannot work (the cookie is SameSite=Strict), and + // without a bearer token there is nothing meaningful to revoke remotely. + if (!session && !sameOrigin) return true; + + const headers: Record = {}; + if (session) headers.Authorization = `Bearer ${session.token}`; + if (sameOrigin) headers[CSRF_HEADER_NAME] = '1'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 10_000))); + try { + const response = await fetchImpl(`${base}/api/auth/session`, { + method: 'DELETE', + headers, + credentials: 'include', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + return response.status === 204; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +const ALLOWED_WS_PATHS = new Set(['/ws/events', '/ws/transcribe']); +const LOGICAL_WS_ORIGIN = 'http://omnivoice.invalid'; + +function websocketTarget(path: string, apiBase: string): { url: URL; logicalPath: string } { + const base = normalizedApiBase(apiBase); + const baseUrl = new URL(base); + let logical: URL; + try { + if (!path.startsWith('/') || path.startsWith('//')) throw new TypeError(); + logical = new URL(path, `${LOGICAL_WS_ORIGIN}/`); + } catch { + throw new AuthSessionError(); + } + if (logical.origin !== LOGICAL_WS_ORIGIN || !ALLOWED_WS_PATHS.has(logical.pathname)) { + throw new AuthSessionError(); + } + + // Resolve relative to `${base}/`, not the origin root. Reverse proxies may + // publish the backend under a path prefix (for example `/studio`). The + // server still receives the logical route after the proxy strips its prefix, + // so ticket binding uses `logical.pathname` below. + const url = new URL(path.slice(1), `${base}/`); + if (url.origin !== baseUrl.origin) throw new AuthSessionError(); + url.username = ''; + url.password = ''; + url.hash = ''; + url.searchParams.delete('api_key'); + url.searchParams.delete('ws_ticket'); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return { url, logicalPath: logical.pathname }; +} + +export async function requestWebSocketTicket( + path: string, + { + apiBase, + fetchImpl = fetch, + storage = defaultSessionStorage(), + windowLike = defaultWindow(), + now = Date.now, + timeoutMs = 5000, + }: CommonOptions, +): Promise { + const base = normalizedApiBase(apiBase); + const { logicalPath } = websocketTarget(path, base); + const session = getAdminSession(base, { storage, now }); + if (!session) throw new AuthSessionError(401); + + let response: Response; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs, 30_000))); + try { + response = await fetchImpl(`${base}/api/auth/ws-ticket`, { + method: 'POST', + headers: { + Authorization: `Bearer ${session.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path: logicalPath }), + credentials: 'include', + cache: 'no-store', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + } catch { + throw new AuthSessionError(); + } finally { + clearTimeout(timer); + } + + if (response.status !== 201) { + if (response.status === 401 || response.status === 403) { + clearAdminSession({ storage }); + dispatchAuthRequired(windowLike); + } + throw new AuthSessionError(response.status); + } + const payload = await readBoundedObject(response); + const expiresAt = responseExpiry(payload, now(), MAX_TICKET_LIFETIME_SECONDS); + if ( + typeof payload.ticket !== 'string' || + !WS_TICKET_RE.test(payload.ticket) || + expiresAt === null + ) { + throw new AuthSessionError(response.status); + } + return payload.ticket; +} + +export async function authenticatedWsUrl(path: string, options: CommonOptions): Promise { + const { url } = websocketTarget(path, options.apiBase); + const session = getAdminSession(options.apiBase, { + storage: options.storage, + now: options.now, + }); + if (!session) return url.toString(); + + const ticket = await requestWebSocketTicket(path, options); + url.searchParams.set('ws_ticket', ticket); + return url.toString(); +} diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 6666ca03..89c42e36 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { _parseDeepLinkCredentials } from './client'; +import { + API, + _bootstrapBrowserCredentials, + _isApiTarget, + _parseDeepLinkCredentials, + wsUrl, +} from './client'; +import { ADMIN_SESSION_STORAGE_KEY, CSRF_HEADER_NAME } from './authSession'; describe('apiFetch PIN header', () => { let realFetch: typeof globalThis.fetch; @@ -21,7 +28,8 @@ describe('apiFetch PIN header', () => { }) as any; const { apiFetch } = await import('./client'); await apiFetch('/system/info'); - expect((seen.headers || {})['X-OmniVoice-Pin']).toBe('424242'); + expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBe('424242'); + expect(seen.credentials).toBe('include'); }); it('omits the header when no pin', async () => { @@ -32,7 +40,27 @@ describe('apiFetch PIN header', () => { }) as any; const { apiFetch } = await import('./client'); await apiFetch('/system/info'); - expect((seen.headers || {})['X-OmniVoice-Pin']).toBeUndefined(); + expect(new Headers(seen.headers).get('X-OmniVoice-Pin')).toBeNull(); + }); + + it('keeps cookie and loopback requests usable when Web Storage is blocked', async () => { + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as any; + + try { + const { apiFetch } = await import('./client'); + await expect(apiFetch('/system/info')).resolves.toMatchObject({ ok: true }); + } finally { + getItem.mockRestore(); + } + + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get('X-OmniVoice-Pin')).toBeNull(); + expect(headers.get('Authorization')).toBeNull(); + expect(fetchMock.mock.calls[0][1]?.credentials).toBe('include'); }); it('turns a thrown fetch into an actionable ApiError (backend unreachable)', async () => { @@ -55,6 +83,85 @@ describe('apiFetch PIN header', () => { }); }); +describe('apiFetch short-lived admin authentication', () => { + let realFetch: typeof globalThis.fetch; + + beforeEach(() => { + realFetch = globalThis.fetch; + sessionStorage.clear(); + localStorage.clear(); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + sessionStorage.clear(); + localStorage.clear(); + }); + + it('attaches only the backend-bound short-lived session, never the persisted master', async () => { + const session = `ovs_admin_session_${'S'.repeat(43)}`; + localStorage.setItem('ov_api_key', 'legacy-master'); + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }), + ); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as any; + + const { apiFetch } = await import('./client'); + await apiFetch('/system/info'); + + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(headers.get('Authorization')).toBe(`Bearer ${session}`); + expect(headers.get('Authorization')).not.toContain('legacy-master'); + }); + + it('builds credential-free legacy WebSocket URLs even if old storage is populated', () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const url = wsUrl('/ws/events?view=active'); + expect(url).toContain('/ws/events?view=active'); + expect(url).not.toContain('api_key'); + expect(url).not.toContain('legacy-master'); + }); + + it('never sends backend credentials to an absolute foreign URL', async () => { + const session = `ovs_admin_session_${'S'.repeat(43)}`; + sessionStorage.setItem('ov_pin', '424242'); + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ token: session, expiresAt: Date.now() / 1000 + 3600, apiBase: API }), + ); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as any; + + const { apiFetch } = await import('./client'); + await apiFetch('https://voice.example.evil.test/public.wav', { + credentials: 'omit', + headers: { 'X-Public-Media': '1' }, + }); + + const [target, init] = fetchMock.mock.calls[0]; + const headers = new Headers(init?.headers); + expect(target).toBe('https://voice.example.evil.test/public.wav'); + expect(headers.get('Authorization')).toBeNull(); + expect(headers.get('X-OmniVoice-Pin')).toBeNull(); + expect(headers.get(CSRF_HEADER_NAME)).toBeNull(); + expect(headers.get('X-Public-Media')).toBe('1'); + expect(init?.credentials).toBe('omit'); + }); + + it('binds credentials to the exact configured API path prefix', () => { + expect(_isApiTarget('https://voice.test/studio/v1/audio', 'https://voice.test/studio')).toBe( + true, + ); + expect(_isApiTarget('https://voice.test/studio-evil/v1', 'https://voice.test/studio')).toBe( + false, + ); + expect(_isApiTarget('https://voice.test/other', 'https://voice.test/studio')).toBe(false); + expect(_isApiTarget('https://voice.test.evil/v1', 'https://voice.test')).toBe(false); + }); +}); + describe('apiFetch 401 routing', () => { // The backend has two 401-returning middlewares distinguished only by their // `detail` body: "API key required" (BearerKeyMiddleware) vs "PIN required" @@ -89,6 +196,14 @@ describe('apiFetch 401 routing', () => { dispatch.mock.calls.map((c) => c[0]).find((e) => (e as Event).type === 'ov:auth-required'); it('dispatches ov:auth-required {mode:"apikey"} on an "API key required" 401', async () => { + sessionStorage.setItem( + ADMIN_SESSION_STORAGE_KEY, + JSON.stringify({ + token: `ovs_admin_session_${'S'.repeat(43)}`, + expiresAt: Date.now() / 1000 + 3600, + apiBase: API, + }), + ); globalThis.fetch = stub401('API key required'); const { apiFetch } = await import('./client'); try { @@ -98,6 +213,7 @@ describe('apiFetch 401 routing', () => { } expect(authEvent()).toBeTruthy(); expect((authEvent() as any).detail.mode).toBe('apikey'); + expect(sessionStorage.getItem(ADMIN_SESSION_STORAGE_KEY)).toBeNull(); }); it('dispatches ov:auth-required {mode:"pin"} on a "PIN required" 401', async () => { @@ -231,3 +347,127 @@ describe('_parseDeepLinkCredentials', () => { expect(r.cleanUrl).toBe('/path?page=2#top'); }); }); + +describe('_bootstrapBrowserCredentials', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('scrubs the fragment before exchanging exactly once, deleting legacy storage on success', async () => { + const order: string[] = []; + localStorage.setItem('ov_api_key', 'older-master'); + const win = { + location: { href: 'https://voice.test/app?pin=1234#api_key=fragment-master&tab=voices' }, + history: { + replaceState: (_data: unknown, _unused: string, url?: string | URL | null) => { + order.push(`scrub:${String(url)}`); + }, + }, + }; + const exchange = vi.fn(async (master) => { + order.push('exchange'); + expect(master).toBe('fragment-master'); + // The durable key survives until the backend accepts the exchange — a + // failure at this point must leave it for the next launch to retry. + expect(localStorage.getItem('ov_api_key')).toBe('older-master'); + }); + + await _bootstrapBrowserCredentials(win, { + apiBase: 'https://voice.test', + exchange: exchange as any, + }); + + expect(sessionStorage.getItem('ov_pin')).toBe('1234'); + expect(order).toEqual(['scrub:/app#tab=voices', 'exchange']); + expect(exchange).toHaveBeenCalledOnce(); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('retains the stored master when the backend is unreachable (no stranding)', async () => { + // The upgrade-day disaster this guards against: a remote-backend user's + // only copy of OMNIVOICE_API_KEY lives in localStorage, and the backend is + // down at first launch. The failed exchange must NOT consume the key. + localStorage.setItem('ov_api_key', 'legacy-master'); + const exchange = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + + await expect( + _bootstrapBrowserCredentials( + { location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } }, + { apiBase: 'https://voice.test', exchange }, + ), + ).rejects.toThrow(); + + expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' }); + expect(localStorage.getItem('ov_api_key')).toBe('legacy-master'); + }); + + it('re-runs the migration on the next launch and consumes the key once it succeeds', async () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const exchange = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 }); + const launch = () => + _bootstrapBrowserCredentials( + { location: { href: 'https://voice.test/' }, history: { replaceState: vi.fn() } }, + { apiBase: 'https://voice.test', exchange }, + ); + + // Launch 1: backend unreachable — key survives. + await expect(launch()).rejects.toThrow(); + expect(localStorage.getItem('ov_api_key')).toBe('legacy-master'); + + // Launch 2: backend back — the retained key is retried and then removed. + await launch(); + expect(exchange).toHaveBeenNthCalledWith(2, 'legacy-master', { + apiBase: 'https://voice.test', + }); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('consumes a legacy stored master without writing it anywhere else', async () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' }); + + await _bootstrapBrowserCredentials( + { + location: { href: 'https://voice.test/' }, + history: { replaceState: vi.fn() }, + }, + { apiBase: 'https://voice.test', exchange }, + ); + + expect(exchange).toHaveBeenCalledWith('legacy-master', { apiBase: 'https://voice.test' }); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + expect(sessionStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('still deletes and exchanges the master when PIN session storage is blocked', async () => { + localStorage.setItem('ov_api_key', 'legacy-master'); + const replaceState = vi.fn(); + const exchange = vi.fn().mockResolvedValue({ transport: 'bearer' }); + + await _bootstrapBrowserCredentials( + { + location: { href: 'https://voice.test/?pin=1234#api_key=fragment-master' }, + history: { replaceState }, + }, + { + apiBase: 'https://voice.test', + sessionStore: { + setItem: vi.fn(() => { + throw new DOMException('blocked'); + }), + }, + exchange, + }, + ); + + expect(replaceState).toHaveBeenCalledWith(null, '', '/'); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + expect(exchange).toHaveBeenCalledWith('fragment-master', { + apiBase: 'https://voice.test', + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e18967f6..37cfd659 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -26,13 +26,23 @@ import { recordBackendContact, unreachableBackendMessage, } from '../utils/backendContact.ts'; +import { + CSRF_HEADER_NAME, + LEGACY_API_KEY_STORAGE_KEY, + clearAdminSession, + exchangeApiKey, + getAdminSession, + isSameOriginApi, +} from './authSession.ts'; const viteEnv = import.meta.env ?? {}; // Remote-backend settings (Wave 2.3): user-configured in Settings → Sharing. // localStorage so the choice survives restarts; read once at module load — // the Settings panel reloads the app on save. export const LS_BACKEND_URL = 'ov_backend_url'; -export const LS_API_KEY = 'ov_api_key'; +// Compatibility name used only to delete data written by older releases. +// New code must never persist the configured master credential. +export const LS_API_KEY = LEGACY_API_KEY_STORAGE_KEY; // Pure + exported for unit testing — takes env + window so tests don't need to // re-import the module or stub import.meta.env. export function _resolveApiBase(env: any, win: any): string { @@ -64,40 +74,25 @@ export function _resolveApiBase(env: any, win: any): string { } export const API = _resolveApiBase(viteEnv, typeof window !== 'undefined' ? window : undefined); -function _apiKey(): string | null { +function sessionPin(): string | null { try { - return typeof localStorage !== 'undefined' ? localStorage.getItem(LS_API_KEY) : null; + return typeof sessionStorage === 'undefined' ? null : sessionStorage.getItem('ov_pin'); } catch { + // Cookie-authenticated and loopback requests must still work when a + // privacy policy blocks Web Storage. return null; } } -/** Persist the durable remote API key (trimmed). localStorage so it survives - * reloads; read back by `_apiKey()` on every request. Returns false (without - * writing) when the value is empty-after-trim or storage is unavailable, so the - * caller can avoid reloading into a loop. */ -export function saveApiKey(v: string): boolean { - const t = v.trim(); - if (!t) return false; - try { - localStorage.setItem(LS_API_KEY, t); - return true; - } catch { - return false; - } -} - /** Build a ws:// or wss:// URL for a backend WebSocket endpoint. * * Scheme derives from the API base itself (NOT window.location — a Tauri - * webview pointing at an https remote must still get wss), and the remote - * API key rides as ?api_key= because browser WebSockets can't set headers. */ + * webview pointing at an https remote must still get wss). Credentials are + * intentionally excluded; authenticated callers obtain a one-use ticket via + * `authenticatedWsUrl` in authSession.ts. */ export function wsUrl(path: string): string { const base = API.replace(/^http/, 'ws').replace(/\/+$/, ''); - const url = `${base}${path.startsWith('/') ? '' : '/'}${path}`; - const key = _apiKey(); - if (!key) return url; - return `${url}${url.includes('?') ? '&' : '?'}api_key=${encodeURIComponent(key)}`; + return `${base}${path.startsWith('/') ? '' : '/'}${path}`; } /** @@ -106,9 +101,9 @@ export function wsUrl(path: string): string { * the effects. * • ?pin= (query) — LAN-share QR. Returned as `pin` (session). * • #api_key= (fragment) — remote-backend deep link. Returned as `apiKey` - * (durable). Read from the FRAGMENT because fragments aren't sent to the - * server, so the durable secret stays out of request logs; the PIN stays in - * the query since the QR flow needs the server to see it. + * for one immediate exchange. Read from the FRAGMENT because fragments + * aren't sent to the server, so the root secret stays out of request logs; + * the PIN stays in the query since the QR flow needs the server to see it. * A stray legacy ?api_key= in the query is scrubbed from `cleanUrl` but NOT * returned — reading it would resend the secret to the server on reload, the * very leak the fragment avoids. `scrubbed` is true when any credential param @@ -143,15 +138,95 @@ export function _parseDeepLinkCredentials(href: string): { }; } -// On load, capture deep-link credentials (?pin= from the QR query, #api_key= -// from a remote-backend fragment) so apiFetch attaches them automatically, then -// scrub them from the address bar (one-shot — see _parseDeepLinkCredentials). +type BootstrapWindow = { + location: { href: string }; + history: { replaceState: (data: unknown, unused: string, url?: string | URL | null) => void }; +}; + +/** One-shot migration seam kept injectable so ordering is regression-tested: + * scrub the URL synchronously, read (never re-write) the durable master, then + * perform the only request that may carry it. The durable copy is deleted only + * after that exchange SUCCEEDS: deleting it first stranded remote-backend + * users whose backend was unreachable at first launch after upgrade — the + * failed exchange destroyed their only copy of the admin key. On failure the + * key stays put so the next launch retries this migration. */ +export async function _bootstrapBrowserCredentials( + win: BootstrapWindow, + { + apiBase = API, + sessionStore, + localStore, + exchange = exchangeApiKey, + }: { + apiBase?: string; + sessionStore?: Pick | null; + localStore?: Pick | null; + exchange?: typeof exchangeApiKey; + } = {}, +): Promise { + if (sessionStore === undefined) { + try { + sessionStore = sessionStorage; + } catch { + sessionStore = null; + } + } + if (localStore === undefined) { + try { + localStore = localStorage; + } catch { + localStore = null; + } + } + const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(win.location.href); + if (scrubbed) { + try { + win.history.replaceState(null, '', cleanUrl); + } catch { + /* keep deleting retained credentials even if history is unavailable */ + } + } + + let master = apiKey; + try { + const legacy = localStore?.getItem(LS_API_KEY) ?? null; + if (!master) master = legacy; + } catch { + /* a fragment exchange can still proceed */ + } + if (pin) { + try { + sessionStore?.setItem('ov_pin', pin); + } catch { + /* blocked PIN storage must not prevent the exchange */ + } + } + if (master) { + // A rejected/unreachable exchange throws past this point, leaving the + // durable key in place for the next launch's retry (the module-load catch + // below still raises the auth gate). Only a session that actually exists + // may consume the stored master. + await exchange(master, { apiBase }); + try { + localStore?.removeItem(LS_API_KEY); + } catch { + /* storage unavailable; exchangeApiKey performed the same best-effort deletion */ + } + } +} + +// On load, capture deep-link credentials, scrub the address bar synchronously, +// and exchange a master key exactly once. Historical durable master storage is +// read before the first await and deleted only once the exchange succeeds, so +// an unreachable backend leaves it for the next launch to retry. apiFetch +// waits for this one-shot migration so no request races ahead with an +// unauthenticated first call. +let authBootstrapPromise: Promise = Promise.resolve(); if (typeof window !== 'undefined') { try { - const { pin, apiKey, cleanUrl, scrubbed } = _parseDeepLinkCredentials(window.location.href); - if (pin) sessionStorage.setItem('ov_pin', pin); - if (apiKey) saveApiKey(apiKey); - if (scrubbed) window.history.replaceState(null, '', cleanUrl); + authBootstrapPromise = _bootstrapBrowserCredentials(window).catch(() => { + window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode: 'apikey' } })); + }); } catch { /* noop */ } @@ -173,6 +248,21 @@ export function apiUrl(path?: string): string { return path.startsWith('http') ? path : `${API}${path.startsWith('/') ? '' : '/'}${path}`; } +/** Whether an already-resolved request URL stays inside the configured API + * origin and path prefix. Absolute URLs remain supported for public media, but + * they must never inherit backend credentials by accident. */ +export function _isApiTarget(target: string, apiBase: string = API): boolean { + try { + const base = new URL(apiBase.replace(/\/+$/, '') + '/'); + const url = new URL(target); + if (url.origin !== base.origin) return false; + const prefix = base.pathname.replace(/\/+$/, ''); + return !prefix || url.pathname === prefix || url.pathname.startsWith(`${prefix}/`); + } catch { + return false; + } +} + // Stamped on EVERY response by the backend's BackendMarkerMiddleware and // exposed cross-origin, so its presence is AUTHORITATIVE: this really is an // VoiceStudio backend answering, whatever the body looks like (#1385). @@ -243,18 +333,25 @@ const RECONCILE_INTERVAL_MS = 1000; export type ApiFetchOptions = RequestInit & { retryTransport?: boolean }; export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promise { - const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null; - const key = _apiKey(); - // Only modify the request when a PIN/API key is set, so the default call - // shape (e.g. FormData posts with no headers / no Content-Type override) - // is preserved exactly. - const extra: Record = {}; - if (pin) extra['X-OmniVoice-Pin'] = pin; - if (key) extra['Authorization'] = `Bearer ${key}`; + await authBootstrapPromise; + const requestUrl = apiUrl(path); + const backendTarget = _isApiTarget(requestUrl); + const pin = backendTarget ? sessionPin() : null; + const session = backendTarget ? getAdminSession(API) : null; const { retryTransport = true, ...requestOpts } = opts; - const finalOpts: RequestInit = Object.keys(extra).length - ? { ...requestOpts, headers: { ...(requestOpts.headers as Record), ...extra } } - : requestOpts; + const headers = new Headers(requestOpts.headers); + if (pin) headers.set('X-OmniVoice-Pin', pin); + if (session) headers.set('Authorization', `Bearer ${session.token}`); + // Same-origin browser clients authenticate through an HttpOnly cookie. The + // marker makes ambient-cookie mutations fail closed under the backend's + // exact-Origin CSRF policy; browser-managed Sec-Fetch-Site supplies the + // additional guard for side-effectful GET routes. + if (backendTarget && isSameOriginApi(API)) headers.set(CSRF_HEADER_NAME, '1'); + const finalOpts: RequestInit = { + ...requestOpts, + headers, + ...(backendTarget ? { credentials: 'include' as RequestCredentials } : {}), + }; const signal = finalOpts.signal as AbortSignal | null | undefined; let lastDetail = ''; // The shell's last word on the backend. When it still says `ready` after we've @@ -270,12 +367,12 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); let res: Response; try { - res = await fetch(apiUrl(path), finalOpts); + res = await fetch(requestUrl, finalOpts); // Any response — success or HTTP error alike — proves the backend // process is alive and answering. Recording it lets a LATER transport // failure say "it was answering Xs ago and stopped" instead of the // one-size "can't reach" (#1164). - recordBackendContact(); + if (backendTarget) recordBackendContact(); } catch (e) { // A thrown fetch (TypeError "Failed to fetch" / "NetworkError") means the // request never reached the backend — it's still starting up, crashed, or @@ -421,8 +518,8 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis // or a reverse proxy with no route for this path. Echoing that page // ("NOT_FOUND bom1::…") sends the user chasing a page that never // existed; name the actual problem instead: where requests are going. - if (res.status === 404 && !backendShaped) { - throw new ApiError(misroutedBackendMessage(apiUrl(path)), { + if (backendTarget && res.status === 404 && !backendShaped) { + throw new ApiError(misroutedBackendMessage(requestUrl), { status: res.status, detail, }); @@ -431,13 +528,14 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis // "API key required" (BearerKeyMiddleware, OMNIVOICE_API_KEY) vs anything // else, i.e. "PIN required" (NetworkAccessMiddleware). Both are 401; the // detail is the only discriminator (only two 401 sites exist backend-side). - if (res.status === 401 && typeof window !== 'undefined') { + if (backendTarget && res.status === 401 && typeof window !== 'undefined') { // readError's declared `string` return isn't guaranteed at runtime — // `j.detail` can be a structured object/array on a future 401. Match only // real strings (avoids both a `.toLowerCase()` crash and `String()` itself // throwing on a malformed object); anything else falls back to PIN. const mode = typeof detail === 'string' && detail.toLowerCase().includes('api key') ? 'apikey' : 'pin'; + if (mode === 'apikey') clearAdminSession(); window.dispatchEvent(new CustomEvent('ov:auth-required', { detail: { mode } })); } // Structured details (e.g. the typed asr_model_missing 409) carry a diff --git a/frontend/src/components/CaptureWidget.jsx b/frontend/src/components/CaptureWidget.jsx index 8b9ce9e0..fb64af24 100644 --- a/frontend/src/components/CaptureWidget.jsx +++ b/frontend/src/components/CaptureWidget.jsx @@ -5,7 +5,8 @@ import { toast } from 'react-hot-toast'; import { useAppStore } from '../store'; import { useTranslation } from 'react-i18next'; -import { wsUrl as buildWsUrl, apiFetch } from '../api/client'; +import { API, apiFetch } from '../api/client'; +import { authenticatedWsUrl } from '../api/authSession'; import { addTranscription } from '../pages/Transcriptions'; import { describeMicError, detectPlatform, micErrorMessage, micHintKey } from '../utils/micError'; import { checkMicrophone, openMicrophoneSettings } from '../utils/permissions'; @@ -898,8 +899,10 @@ export default function CaptureWidget({ onDismiss }) { // Open WebSocket BEFORE starting capture. try { - // Scheme + host + remote api key all derive from the API base - // (Wave 2.3) — window.location lies inside the Tauri webview. + // Scheme + host derive from the API base (window.location lies inside + // the Tauri webview). A remote bearer session is converted to a fresh, + // path-bound WebSocket ticket; neither the master nor session token is + // ever placed in this URL. // • sherpa → ?model=&sr=16000 (raw int16 PCM, live partials) // • AEC → ?aec=1&sr=16000 (tagged raw PCM, NLMS canceller) // • both → ?model=&aec=1&sr=16000 @@ -911,7 +914,8 @@ export default function CaptureWidget({ onDismiss }) { if (pcmFallback) params.push('pcm=1'); if (pcmMode) params.push('sr=16000'); const wsPath = params.length ? `/ws/transcribe?${params.join('&')}` : '/ws/transcribe'; - const ws = new WebSocket(buildWsUrl(wsPath)); + const endpoint = await authenticatedWsUrl(wsPath, { apiBase: API }); + const ws = new WebSocket(endpoint); ws.binaryType = 'arraybuffer'; const failRawPcmSession = () => { if ( @@ -1113,7 +1117,7 @@ export default function CaptureWidget({ onDismiss }) { } }; wsRef.current = ws; - } catch (err) { + } catch { wsRef.current = null; if (pcmMode) { // Raw-PCM has no POST fallback — a socket that can't even be @@ -1121,13 +1125,13 @@ export default function CaptureWidget({ onDismiss }) { // recording into the void. stream.getTracks().forEach((tr) => tr.stop()); streamRef.current = null; - setErrorInfo({ kind: 'server', message: String(err?.message || err) }); + setErrorInfo({ kind: 'server', message: '' }); setState('error'); return; } // Legacy path continues below: the recorder still buffers chunks and // the POST /transcribe fallback delivers the result on stop. - console.warn('ws open failed — will fall back to POST /transcribe:', err); + console.warn('ws open failed — will fall back to POST /transcribe'); } if (pcmMode) { diff --git a/frontend/src/components/CaptureWidget.test.jsx b/frontend/src/components/CaptureWidget.test.jsx index 8c0dc8cd..766021d3 100644 --- a/frontend/src/components/CaptureWidget.test.jsx +++ b/frontend/src/components/CaptureWidget.test.jsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => { return { state, holder, + authenticatedWsUrl: vi.fn(async (path) => `ws://test${path}&ws_ticket=one-use`), invoke: async (cmd, args) => { holder.calls.push([cmd, args]); if (cmd === 'check_accessibility') return holder.a11y; @@ -47,9 +48,11 @@ vi.mock('../store', () => ({ useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }), })); vi.mock('../api/client', () => ({ + API: 'http://test', wsUrl: (p) => `ws://test${p}`, apiFetch: vi.fn(async () => ({ json: async () => ({}) })), })); +vi.mock('../api/authSession', () => ({ authenticatedWsUrl: mocks.authenticatedWsUrl })); vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() })); vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) })); vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } })); @@ -132,6 +135,10 @@ describe('CaptureWidget', () => { mocks.holder.paste = async () => undefined; mocks.holder.calls = []; mocks.holder.onFrame = null; + mocks.authenticatedWsUrl.mockClear(); + mocks.authenticatedWsUrl.mockImplementation( + async (path) => `ws://test${path}${path.includes('?') ? '&' : '?'}ws_ticket=one-use`, + ); mocks.state.dictationMode = 'toggle'; mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3'; FakeWebSocket.instances = []; @@ -186,6 +193,10 @@ describe('CaptureWidget', () => { const ws = await startSession(); expect(ws.url).toContain('/ws/transcribe?pcm=1&sr=16000'); + expect(mocks.authenticatedWsUrl).toHaveBeenCalledWith('/ws/transcribe?pcm=1&sr=16000', { + apiBase: 'http://test', + }); + expect(ws.url).toContain('ws_ticket=one-use'); expect(mocks.holder.onFrame).toBeTypeOf('function'); act(() => mocks.holder.onFrame(new Float32Array([0.25, -0.25]))); diff --git a/frontend/src/components/RemoteAuthGate.jsx b/frontend/src/components/RemoteAuthGate.jsx index f407be38..4493b369 100644 --- a/frontend/src/components/RemoteAuthGate.jsx +++ b/frontend/src/components/RemoteAuthGate.jsx @@ -1,27 +1,29 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { saveApiKey } from '../api/client'; +import { API } from '../api/client'; +import { exchangeApiKey } from '../api/authSession'; // On a remote device the backend can demand EITHER a LAN-share PIN // (NetworkAccessMiddleware → "PIN required") OR an API key (BearerKeyMiddleware // → "API key required") — both 401. client.ts reads the detail, decides which, // and dispatches a single `ov:auth-required` CustomEvent carrying the mode; this // gate listens for it and swaps the app tree for the matching entry form. -// `forceGate` / `forceMode` are test-only. Submitting stores the credential -// (sessionStorage for the session PIN, localStorage for the durable API key) and -// reloads so the gated requests retry with the header attached. If both gates -// are active the reload cycle re-shows this gate in whichever mode the next 401 -// dictates. +// `forceGate` / `forceMode` are test-only. PINs remain tab-scoped; an API master +// is immediately exchanged for a short-lived session and never persisted. export default function RemoteAuthGate({ children, forceGate = false, forceMode = 'pin' }) { const { t } = useTranslation(); const [gated, setGated] = useState(forceGate); const [mode, setMode] = useState(forceMode); const [value, setValue] = useState(''); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); useEffect(() => { const onRequired = (e) => { setMode(e.detail?.mode === 'apikey' ? 'apikey' : 'pin'); setGated(true); + setError(null); + setValue(''); }; window.addEventListener('ov:auth-required', onRequired); return () => window.removeEventListener('ov:auth-required', onRequired); @@ -31,21 +33,36 @@ export default function RemoteAuthGate({ children, forceGate = false, forceMode const i18nKey = mode === 'apikey' ? 'remote_apikey_gate' : 'remote_gate'; - const submit = (e) => { + const submit = async (e) => { e.preventDefault(); + if (pending) return; const v = value.trim(); if (!v) return; - // Persist, then reload so the gated requests retry with the credential - // attached. A failed write (privacy-mode storage, etc.) must NOT reload into - // a loop — leave the form up so the user isn't silently re-prompted forever. - let ok = true; + setError(null); + if (mode === 'pin') { + try { + sessionStorage.setItem('ov_pin', v); + window.location.reload(); + } catch { + setError({ status: undefined }); + } + return; + } + + // Remove the secret from controlled UI state before awaiting the network. + // On success, exchangeApiKey also deletes any durable value left by an + // older release; a failed exchange leaves it for the bootstrap migration + // to retry on the next launch. + setValue(''); + setPending(true); try { - if (mode === 'apikey') ok = saveApiKey(v); - else sessionStorage.setItem('ov_pin', v); - } catch { - ok = false; + await exchangeApiKey(v, { apiBase: API }); + window.location.reload(); + } catch (exchangeError) { + setError({ status: exchangeError?.status }); + } finally { + setPending(false); } - if (ok) window.location.reload(); }; return ( @@ -60,9 +77,20 @@ export default function RemoteAuthGate({ children, forceGate = false, forceMode inputMode={mode === 'apikey' ? undefined : 'numeric'} value={value} onChange={(e) => setValue(e.target.value)} + autoComplete="off" + disabled={pending} autoFocus /> - + {error && ( +

+ {error.status + ? t('settings.remote_backend_error_http', { status: error.status }) + : t('settings.remote_backend_error_network')} +

+ )} +
); diff --git a/frontend/src/components/RemoteAuthGate.test.jsx b/frontend/src/components/RemoteAuthGate.test.jsx index 15b1071b..a5c756d4 100644 --- a/frontend/src/components/RemoteAuthGate.test.jsx +++ b/frontend/src/components/RemoteAuthGate.test.jsx @@ -1,9 +1,18 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { exchangeApiKey } = vi.hoisted(() => ({ exchangeApiKey: vi.fn() })); +vi.mock('../api/authSession', async (importOriginal) => ({ + ...(await importOriginal()), + exchangeApiKey, +})); + import RemoteAuthGate from './RemoteAuthGate'; describe('RemoteAuthGate', () => { beforeEach(() => { + vi.clearAllMocks(); + exchangeApiKey.mockResolvedValue({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 }); sessionStorage.clear(); localStorage.clear(); }); @@ -32,7 +41,7 @@ describe('RemoteAuthGate', () => { expect(sessionStorage.getItem('ov_pin')).toBe('999111'); }); - it('stores the entered API key (apikey mode)', () => { + it('exchanges the entered API key without persisting the master (apikey mode)', async () => { render(
app-content
@@ -40,6 +49,51 @@ describe('RemoteAuthGate', () => { ); fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'secret123' } }); fireEvent.click(screen.getByRole('button', { name: /connect/i })); - expect(localStorage.getItem('ov_api_key')).toBe('secret123'); + + await waitFor(() => expect(exchangeApiKey).toHaveBeenCalledOnce()); + expect(exchangeApiKey).toHaveBeenCalledWith( + 'secret123', + expect.objectContaining({ apiBase: expect.any(String) }), + ); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + expect(sessionStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('does not retain or reflect the master when exchange fails', async () => { + exchangeApiKey.mockRejectedValueOnce(Object.assign(new Error('generic'), { status: 401 })); + render( + +
app-content
+
, + ); + const input = screen.getByLabelText(/api key/i); + fireEvent.change(input, { target: { value: 'do-not-reflect' } }); + fireEvent.click(screen.getByRole('button', { name: /connect/i })); + + await screen.findByRole('alert'); + expect(input).toHaveValue(''); + expect(screen.queryByText(/do-not-reflect/)).not.toBeInTheDocument(); + expect(localStorage.getItem('ov_api_key')).toBeNull(); + }); + + it('coalesces repeated submissions while an exchange is pending', async () => { + let resolveExchange; + exchangeApiKey.mockImplementationOnce( + () => new Promise((resolve) => (resolveExchange = resolve)), + ); + render( + +
app-content
+
, + ); + fireEvent.change(screen.getByLabelText(/api key/i), { target: { value: 'secret123' } }); + const button = screen.getByRole('button', { name: /connect/i }); + fireEvent.click(button); + fireEvent.click(button); + + expect(exchangeApiKey).toHaveBeenCalledOnce(); + expect(button).toBeDisabled(); + resolveExchange({ transport: 'bearer', expiresAt: Date.now() / 1000 + 60 }); + await waitFor(() => expect(button).not.toBeDisabled()); }); }); diff --git a/frontend/src/components/RemoteBackendRecovery.jsx b/frontend/src/components/RemoteBackendRecovery.jsx index 7001af4b..1b80b852 100644 --- a/frontend/src/components/RemoteBackendRecovery.jsx +++ b/frontend/src/components/RemoteBackendRecovery.jsx @@ -31,7 +31,7 @@ export default function RemoteBackendRecovery({ - - {hasSavedRemote && ( )} + {/* The openai-compat family entry and the LLM Providers + panel are one system (the backend resolves through the + active provider); this is the door between the two, so + picking the family and configuring the endpoint stop + being separate discoveries. */} + {activeFamily === 'llm' && b.id === 'openai-compat' && ( + + )} {/* TTS-05: license-acceptance entry point. Surfaced when the backend says the user hasn't accepted the engine's license yet AND we have a dialog diff --git a/frontend/src/components/settings/LLMProvidersPanel.jsx b/frontend/src/components/settings/LLMProvidersPanel.jsx index 5069847e..9a41b351 100644 --- a/frontend/src/components/settings/LLMProvidersPanel.jsx +++ b/frontend/src/components/settings/LLMProvidersPanel.jsx @@ -22,6 +22,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Brain, ExternalLink } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { apiJson, apiFetch, apiPost } from '../../api/client'; +import { useAppStore } from '../../store'; import { SettingsSection, SettingRow, SettingsInput } from './primitives'; import { Button, Badge, Select } from '../../ui'; @@ -228,6 +229,19 @@ export default function LLMProvidersPanel() { title={t('settings.llm_providers')} description={t('settings.llmp_desc')} > + {/* Backlink half of the catalogue↔providers door: the provider picked + here is what the catalogue's LLM family actually calls through. */} +

+ {t('settings.llmp_catalogue_note')}{' '} + +

{ expect(screen.queryByTestId('manual-install-kittentts')).not.toBeInTheDocument(); expect(screen.queryByTestId('install-kittentts')).not.toBeInTheDocument(); }); + + it('opens the LLM Providers panel from the openai-compat row and names the provider', async () => { + const { useAppStore } = await import('../store'); + const response = routingResponse(); + response.llm.backends.push({ + id: 'openai-compat', + display_name: 'OpenAI-compatible', + available: true, + reason: null, + install_hint: null, + last_error: null, + isolation_mode: 'in-process', + gpu_compat: [], + effective_device: 'network', + routing_status: 'n/a', + routing_reason: null, + // The backend's proof that this family entry and the Providers panel + // are one system: the row names the endpoint that actually answers. + hint: 'OrcaRouter · gpt-4o-mini', + }); + const original = useAppStore.getState().openSettingsTab; + const openSettingsTab = vi.fn(); + useAppStore.setState({ openSettingsTab }); + try { + render( + , + ); + await waitFor(() => screen.getByText('OpenAI-compatible')); + expect(screen.getByText('OrcaRouter · gpt-4o-mini')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('configure-llm-providers')); + expect(openSettingsTab).toHaveBeenCalledWith('llm-providers'); + } finally { + useAppStore.setState({ openSettingsTab: original }); + } + }); }); diff --git a/frontend/src/utils/bugReport.test.js b/frontend/src/utils/bugReport.test.js index 49617068..96246b0a 100644 --- a/frontend/src/utils/bugReport.test.js +++ b/frontend/src/utils/bugReport.test.js @@ -78,6 +78,14 @@ describe('scrubText — frontend twin of backend/core/scrub.py', () => { }); describe('buildBugReportUrl — encoded length ceiling', () => { + beforeEach(() => { + // Hermetic like the sibling describe: without this stub the builder + // fetches real backend facts, and a dev machine with a wedged local + // backend (port open, never answering) hangs this test past its timeout. + // The subject here is encoding math, not reachability. + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + }); + it('keeps the ENCODED body under the ceiling even when the raw body is dense', async () => { // A body full of chars that expand under encodeURIComponent (newlines, // spaces, backticks) must still yield a URL comfortably under ~8k. diff --git a/tests/test_llm_openai_available.py b/tests/test_llm_openai_available.py index f74e8edf..37e8db08 100644 --- a/tests/test_llm_openai_available.py +++ b/tests/test_llm_openai_available.py @@ -22,3 +22,28 @@ def test_llm_backend_not_blocked_by_missing_openai_package(): # Without a configured endpoint it's still unavailable — but the reason must # be "configure an endpoint", NOT "openai package missing". assert "package missing" not in msg.lower(), msg + + +def test_provider_hint_names_the_active_provider_only_for_openai_compat(monkeypatch): + """The catalogue's LLM row must say WHICH provider answers (#coherence): + llm_backend and the LLM Providers panel are one system, and the hint is + the row-level proof of that. Other backends carry no hint, and a + provider-registry failure degrades to no hint, never to a crash.""" + from services import llm_backend, llm_providers + + class _P: + display_name = "OrcaRouter" + + monkeypatch.setattr(llm_providers, "active_provider", lambda: _P()) + monkeypatch.setattr(llm_providers, "resolve_model", lambda p: "gpt-4o-mini") + assert llm_backend._provider_hint("openai-compat") == "OrcaRouter · gpt-4o-mini" + assert llm_backend._provider_hint("off") is None + + monkeypatch.setattr(llm_providers, "resolve_model", lambda p: "") + assert llm_backend._provider_hint("openai-compat") == "OrcaRouter" + + def _boom(): + raise RuntimeError("registry unavailable") + + monkeypatch.setattr(llm_providers, "active_provider", _boom) + assert llm_backend._provider_hint("openai-compat") is None From 77ae194f9cc8a603f9e0d57e365ec61b50beb99e Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:05:53 +0000 Subject: [PATCH 10/15] =?UTF-8?q?fix(asr):=20ROCm=20torch=20is=20not=20CUD?= =?UTF-8?q?A=20=E2=80=94=20keep=20CTranslate2=20off=20the=20HIP=20GPU=20(#?= =?UTF-8?q?1539)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An RX 7900 XTX in the :rocm image crashed ASR init with 'CUDA driver version is insufficient for CUDA runtime version' (#1529): ROCm torch answers torch.cuda.is_available() and hands out 'cuda' device strings, but whisperx/faster-whisper run on CTranslate2, whose CUDA runtime is NVIDIA-only. Same class as the Apple/#1127 lesson, on the AMD axis. - _ctranslate2_cuda_ok(): 'cuda' for CTranslate2 only when torch is a real CUDA build (torch.version.hip is the honest tell); ROCm hosts take CPU int8 instead of a native crash. - _auto_detect(): on a ROCm-GPU host prefer pytorch-whisper — a pure transformers pipeline riding torch itself, so it actually uses the HIP GPU while CTranslate2 engines would idle on the CPU. Fail-before/pass-after: 4 new tests fail on the old device pick/order. Fixes #1529 Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + backend/services/asr_backend.py | 67 +++++++++++++++++++---- tests/test_asr_device_aware_autodetect.py | 50 +++++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e97f8b..49f5c631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529) - Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510) - Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526) - The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520! diff --git a/backend/services/asr_backend.py b/backend/services/asr_backend.py index 29846a1a..4e317ddc 100644 --- a/backend/services/asr_backend.py +++ b/backend/services/asr_backend.py @@ -520,12 +520,10 @@ class WhisperXBackend(ASRBackend): def _pick_device() -> tuple[str, str]: # CUDA fp16 when available; otherwise CPU int8 (fastest CPU path, # negligible WER regression vs fp32 for whisper-large-v3). - try: - import torch - if torch.cuda.is_available(): - return "cuda", "float16" - except Exception: - pass + # _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also + # answers True there, and CTranslate2 has no HIP backend (#1529). + if _ctranslate2_cuda_ok(): + return "cuda", "float16" return "cpu", "int8" # Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2 @@ -981,12 +979,10 @@ class FasterWhisperBackend(ASRBackend): # - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible # WER regression vs fp32 for whisper-large-v3) device, compute_type = "cpu", "int8" - try: - import torch - if torch.cuda.is_available(): - device, compute_type = "cuda", "float16" - except Exception: - pass + # _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also + # answers True there, and CTranslate2 has no HIP backend (#1529). + if _ctranslate2_cuda_ok(): + device, compute_type = "cuda", "float16" logger.info( "faster-whisper loading %s on %s (%s)", self._model_name, device, compute_type, @@ -2464,6 +2460,45 @@ def _mps_available() -> bool: return False +def _cuda_reported_available() -> bool: + """``torch.cuda.is_available()`` verbatim — True on real CUDA *and* HIP.""" + try: + import torch + + return bool(torch.cuda.is_available()) + except Exception: # noqa: BLE001 — no torch + return False + + +def _rocm_torch() -> bool: + """True when torch is the ROCm (HIP) build. + + ROCm torch masquerades as CUDA: ``torch.cuda.is_available()`` answers True + and tensors live on ``"cuda"`` devices, but the CUDA *runtime libraries* + other packages ship are still NVIDIA-only. ``torch.version.hip`` is the + one honest tell. + """ + try: + import torch + + return getattr(torch.version, "hip", None) is not None + except Exception: # noqa: BLE001 — no torch + return False + + +def _ctranslate2_cuda_ok() -> bool: + """Whether CTranslate2 (whisperx / faster-whisper) may use ``"cuda"``. + + CTranslate2 has NO HIP backend. On a ROCm host torch says cuda is + available (HIP), the device string is handed to CTranslate2, and its + NVIDIA CUDA runtime dies with "CUDA driver version is insufficient for + CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the + :rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here + (auto-detect prefers pytorch-whisper there, which does use HIP). + """ + return _cuda_reported_available() and not _rocm_torch() + + def _auto_detect() -> str: """Pick the best available ASR engine **for this hardware**. @@ -2495,6 +2530,14 @@ def _auto_detect() -> str: """ if _mps_available() and _probe_available(MLXWhisperBackend): return "mlx-whisper" + # Same class as the Apple case, on the ROCm axis (#1529): whisperx and + # faster-whisper are CTranslate2, which has no HIP backend — on a ROCm + # host they run on the CPU while the GPU sits idle (and before + # _ctranslate2_cuda_ok they died outright trying NVIDIA's runtime). + # pytorch-whisper is a pure transformers pipeline riding torch itself, + # so it genuinely uses the HIP GPU there. + if _rocm_torch() and _cuda_reported_available() and _probe_available(PyTorchWhisperBackend): + return "pytorch-whisper" if _probe_available(WhisperXBackend): return "whisperx" if _probe_available(FasterWhisperBackend): diff --git a/tests/test_asr_device_aware_autodetect.py b/tests/test_asr_device_aware_autodetect.py index 300a4adc..9b110ddb 100644 --- a/tests/test_asr_device_aware_autodetect.py +++ b/tests/test_asr_device_aware_autodetect.py @@ -394,3 +394,53 @@ def test_parakeet_mlx_real_transcribe_smoke(tmp_path): assert w["word"] and w["start"] <= w["end"] for chunk in out["chunks"]: assert chunk["text"] and len(chunk["timestamp"]) == 2 + + +# ── ROCm (#1529): the Apple lesson repeated on the AMD axis ───────────────── +# ROCm torch masquerades as CUDA (torch.cuda.is_available() is True, devices +# are "cuda"), but CTranslate2 has no HIP backend — whisperx handed "cuda" to +# NVIDIA's runtime on an RX 7900 XTX and died with "CUDA driver version is +# insufficient for CUDA runtime version". pytorch-whisper rides torch itself, +# so it is the engine that actually uses the HIP GPU. + + +def test_rocm_host_picks_pytorch_whisper_not_ctranslate2(monkeypatch): + """The #1529 regression: before the fix this returned "whisperx".""" + monkeypatch.setattr(ab, "_mps_available", lambda: False) + monkeypatch.setattr(ab, "_rocm_torch", lambda: True) + monkeypatch.setattr(ab, "_cuda_reported_available", lambda: True) + monkeypatch.setattr( + ab, "_probe_available", _probe({"whisperx", "faster-whisper", "pytorch-whisper"}) + ) + assert ab._auto_detect() == "pytorch-whisper" + + +def test_rocm_host_without_transformers_still_degrades_to_whisperx_on_cpu(monkeypatch): + """pytorch-whisper unavailable → whisperx is still correct, on the CPU.""" + monkeypatch.setattr(ab, "_mps_available", lambda: False) + monkeypatch.setattr(ab, "_rocm_torch", lambda: True) + monkeypatch.setattr(ab, "_cuda_reported_available", lambda: True) + monkeypatch.setattr(ab, "_probe_available", _probe({"whisperx", "faster-whisper"})) + assert ab._auto_detect() == "whisperx" + + +def test_real_cuda_is_untouched_by_the_rocm_branch(monkeypatch): + monkeypatch.setattr(ab, "_mps_available", lambda: False) + monkeypatch.setattr(ab, "_rocm_torch", lambda: False) + monkeypatch.setattr(ab, "_cuda_reported_available", lambda: True) + monkeypatch.setattr( + ab, "_probe_available", _probe({"whisperx", "faster-whisper", "pytorch-whisper"}) + ) + assert ab._auto_detect() == "whisperx" + + +def test_ctranslate2_never_gets_cuda_on_a_rocm_build(monkeypatch): + """The crash itself: whisperx's device pick must refuse HIP-flavoured cuda.""" + monkeypatch.setattr(ab, "_rocm_torch", lambda: True) + monkeypatch.setattr(ab, "_cuda_reported_available", lambda: True) + assert ab._ctranslate2_cuda_ok() is False + assert ab.WhisperXBackend._pick_device() == ("cpu", "int8") + + monkeypatch.setattr(ab, "_rocm_torch", lambda: False) + assert ab._ctranslate2_cuda_ok() is True + assert ab.WhisperXBackend._pick_device() == ("cuda", "float16") From 0ee62b2261cbfc2946c16b299a361a3d41500115 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:48:05 +0000 Subject: [PATCH 11/15] feat(gallery): save gallery voices as profiles, with validated audio references (#1542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gallery): save gallery voices as profiles, with validated audio references Work-in-progress lifted from the concurrent gallery session at the owner's request (its uncommitted working tree, preserved verbatim from base 92b1ee5d; safety snapshot remains at rescue/gallery-wip): - gallery voices can be saved as local profiles: audio is copied into the profile store with content-addressed filenames, existing profiles are detected and refreshed only when the source clip changed - backend/core/audio_validation.py: symlink-rejecting, root-contained resolution for persisted profile WAV references, with tests - archetype/community routers and the Voice Gallery UI updated for the save-as-profile handoff (spec: docs/specs/longform/26-gallery-use-handoff.md) - locale updates for the new gallery strings across all 21 files Co-Authored-By: Claude Fable 5 * chore: drop a stray local screenshot script that rode in with the tree copy * fix(community): explain the tolerated Content-Length parse failure; drop an unused import CodeQL on #1542: the empty except now says why it is safe (the streamed byte counter enforces the same cap regardless), and the test file loses an unused Path import. Co-Authored-By: Claude Fable 5 * fix(gallery): review findings — copy outside the write lock, no stale completions CodeRabbit on #1542, all findings addressed: - the profile-audio copy stages to a .part temp BEFORE BEGIN IMMEDIATE and publishes via atomic os.replace inside it — other backend writers no longer block for the duration of an audio copy; a mid-copy failure leaves no temp droppings and no profile row (both pinned by tests) - VoiceGallery async ops carry per-operation generation tokens: a preview or save-as-profile that resolves after unmount (or after a newer operation) can no longer play audio, redirect into a workspace, or touch state — three fail-before regression tests - VoiceGalleryActions imports the page at test runtime; the e2e locator uses a stable data-testid instead of a translated string; symlink tests skip cleanly where the OS can't create symlinks; the changelog line carries its PR ref Co-Authored-By: Claude Fable 5 * ci: static ffmpeg fallback when the chocolatey feed is down Third feed outage to break a PR run (2026-07-20, 2026-07-28, today — three attempts, three 'installed 0/1'). Chocolatey is a distribution channel, not the dependency: after the retry loop exhausts, fetch the static gyan.dev build from its GitHub release mirror and put it on PATH — same binary, no feed in the path. URL verified live (HTTP 200). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 13 + CHANGELOG.md | 1 + README.md | 2 +- backend/api/routers/archetypes.py | 302 +++++-- backend/api/routers/community.py | 827 +++++++++++++++--- backend/api/routers/gallery.py | 315 +++++-- backend/core/audio_validation.py | 106 +++ backend/tests/conftest.py | 29 + backend/tests/test_archetypes_api.py | 277 +++++- backend/tests/test_audio_validation.py | 44 + backend/tests/test_community.py | 686 ++++++++++++++- backend/tests/test_gallery_profiles.py | 250 ++++++ docs/specs/longform/26-gallery-use-handoff.md | 2 + frontend/e2e/footer-clipping.spec.ts | 4 +- frontend/e2e/gallery.spec.ts | 6 +- frontend/e2e/support-compact.spec.ts | 32 + frontend/src/App.jsx | 3 +- frontend/src/api/community.ts | 6 + frontend/src/api/types.ts | 3 + .../src/components/gallery/ArchetypeCard.jsx | 87 +- .../src/components/gallery/ArchetypesZone.jsx | 12 +- .../gallery/ArchetypesZone.test.jsx | 42 + .../src/components/gallery/CommunityZone.jsx | 63 +- .../components/gallery/CommunityZone.test.jsx | 76 ++ .../src/components/gallery/ImportsZone.jsx | 92 +- .../components/gallery/ImportsZone.test.jsx | 91 ++ frontend/src/hooks/useProfiles.js | 42 +- frontend/src/hooks/useProfiles.test.jsx | 98 +++ frontend/src/i18n/locales/ar.json | 3 + frontend/src/i18n/locales/de.json | 3 + frontend/src/i18n/locales/en.json | 3 + frontend/src/i18n/locales/es.json | 3 + frontend/src/i18n/locales/fr.json | 3 + frontend/src/i18n/locales/hi.json | 3 + frontend/src/i18n/locales/id.json | 3 + frontend/src/i18n/locales/it.json | 3 + frontend/src/i18n/locales/ja.json | 3 + frontend/src/i18n/locales/ko.json | 3 + frontend/src/i18n/locales/nl.json | 3 + frontend/src/i18n/locales/pl.json | 3 + frontend/src/i18n/locales/pt.json | 3 + frontend/src/i18n/locales/ru.json | 3 + frontend/src/i18n/locales/sv.json | 3 + frontend/src/i18n/locales/th.json | 3 + frontend/src/i18n/locales/tr.json | 3 + frontend/src/i18n/locales/uk.json | 3 + frontend/src/i18n/locales/vi.json | 3 + frontend/src/i18n/locales/zh-CN.json | 3 + frontend/src/i18n/locales/zh-TW.json | 3 + frontend/src/pages/ContactPage.jsx | 54 +- frontend/src/pages/SupportPage.jsx | 527 +++++------ frontend/src/pages/VoiceGallery.jsx | 263 ++++-- .../src/test/SupportPageSections.test.jsx | 91 +- .../src/test/VoiceGalleryActions.test.jsx | 278 ++++++ .../test/VoiceGalleryPreviewFallback.test.jsx | 22 +- frontend/src/utils/voiceInstruct.js | 30 + frontend/src/utils/voiceInstruct.test.js | 63 +- tests/fixtures/api_routes.txt | 1 + tests/test_gallery_previews.py | 25 +- 59 files changed, 4104 insertions(+), 824 deletions(-) create mode 100644 backend/core/audio_validation.py create mode 100644 backend/tests/test_audio_validation.py create mode 100644 backend/tests/test_gallery_profiles.py create mode 100644 frontend/e2e/support-compact.spec.ts create mode 100644 frontend/src/components/gallery/CommunityZone.test.jsx create mode 100644 frontend/src/components/gallery/ImportsZone.test.jsx create mode 100644 frontend/src/hooks/useProfiles.test.jsx create mode 100644 frontend/src/test/VoiceGalleryActions.test.jsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20a0fb65..2c5731d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -365,6 +365,19 @@ jobs: echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s" sleep $((i * 30)) done + # Chocolatey is one distribution channel, not the dependency. When + # its feed is down across every retry (2026-08-13: three attempts, + # three 'installed 0/1'), fall back to the static gyan.dev release + # build GitHub mirror — the same binary, no feed in the path. + if ! command -v ffmpeg >/dev/null 2>&1; then + echo "::warning::choco feed down — falling back to static ffmpeg build" + curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \ + https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip + unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg + bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)") + echo "$bindir" >> "$GITHUB_PATH" + export PATH="$bindir:$PATH" + fi ffmpeg -version - name: System deps (Linux) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f5c631..861cb3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Changed +- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542) - Support amount choices now use every theme's shared card, accent and focus tokens. (#1530) - Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522) - Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522) diff --git a/README.md b/README.md index e65cdbbf..198af11a 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Ships two [skills](https://skills.sh): |----------|----------| | **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody | | **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home | -| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace | +| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with working preview/use/designer actions and one-step handoffs to Studio, Stories, and Audiobook (its voices are also selectable in Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace | | **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation | | **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution | | **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment | diff --git a/backend/api/routers/archetypes.py b/backend/api/routers/archetypes.py index ea3cdcf6..32142559 100644 --- a/backend/api/routers/archetypes.py +++ b/backend/api/routers/archetypes.py @@ -26,8 +26,10 @@ Design notes from __future__ import annotations import hashlib +import json import logging import os +import re import time import uuid from pathlib import Path @@ -37,6 +39,7 @@ from fastapi import APIRouter, Body, HTTPException, Query from fastapi.responses import FileResponse from core import archetypes +from core.audio_validation import is_playable_wav, resolve_regular_file from core.config import OUTPUTS_DIR, VOICES_DIR from services import gallery @@ -69,6 +72,153 @@ def _preview_key(a: dict) -> str: ).hexdigest()[:16] +def _design_profile_values(a: dict) -> tuple[str, str]: + """Canonical instruct + complete picker state for a designed archetype.""" + return a["instruct"], json.dumps(a["attrs"], sort_keys=True) + + +def _profile_audio_path(ref_audio_path: object) -> Optional[Path]: + """Resolve only a regular, non-symlinked file inside ``VOICES_DIR``.""" + return resolve_regular_file(VOICES_DIR, ref_audio_path) + + +def _materialized_audio_is_current(row, a: dict) -> bool: + """Whether an existing row still has the sample described by its metadata.""" + expected_filename = _profile_audio_filename(row["id"]) + path = _profile_audio_path(row["ref_audio_path"]) + return bool( + row["ref_audio_path"] == expected_filename + and is_playable_wav(path) + and row["instruct"] == a["instruct"] + and row["language"] == a["language"] + and row["ref_text"] == a["sample_script"] + and row["seed"] == _PREVIEW_SEED + ) + + +def _profile_audio_filename(profile_id: str) -> str: + safe_id = ( + profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "") + else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16] + ) + return f"{safe_id}.wav" + + +def _archetype_personality(a: dict) -> str: + return f"archetype:{a['id']}" + + +def _legacy_archetype_profile(conn, a: dict): + """Adopt only a row that an older archetype materializer could have made.""" + row = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? LIMIT 1", + (a["id"],), + ).fetchone() + if row is None: + return None + expected_audio = _profile_audio_filename(row["id"]) + try: + states_match = ( + not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"] + ) + except (TypeError, ValueError): + states_match = False + if ( + row["ref_audio_path"] == expected_audio + and row["instruct"] == a["instruct"] + and row["language"] == a["language"] + and row["ref_text"] == a["sample_script"] + and row["seed"] == _PREVIEW_SEED + and row["kind"] in (None, "", "clone", "design") + and not row["is_locked"] + and not row["verified_own_voice"] + and states_match + ): + return row + return None + + +def _is_materialized_archetype_row(row, a: dict) -> bool: + """Recognize rows owned by this materializer without trusting identity text alone.""" + try: + states_match = json.loads(row["vd_states"]) == a["attrs"] + except (TypeError, ValueError): + return False + return bool( + row["personality"] == _archetype_personality(a) + and row["kind"] == "design" + and row["seed"] == _PREVIEW_SEED + and row["ref_audio_path"] == _profile_audio_filename(row["id"]) + and row["instruct"] == a["instruct"] + and row["language"] == a["language"] + and row["ref_text"] == a["sample_script"] + and states_match + and not row["is_locked"] + and not row["verified_own_voice"] + ) + + +def _existing_archetype_profile(conn, a: dict): + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id", + (_archetype_personality(a),), + ).fetchall() + owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None) + return owned if owned is not None else _legacy_archetype_profile(conn, a) + + +async def _render_profile_audio( + a: dict, profile_id: str, *, publish: bool = True, +) -> tuple[str, Path]: + """Render one validated sample, optionally staging it for a later CAS.""" + audio_filename = _profile_audio_filename(profile_id) + safe_id = Path(audio_filename).stem + audio_path = Path(VOICES_DIR) / audio_filename + if publish: + await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-") + else: + audio_path.parent.mkdir(parents=True, exist_ok=True) + audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav" + try: + await _render_archetype_wav(a, audio_path) + if not is_playable_wav(audio_path): + raise RuntimeError("the voice engine produced an invalid WAV") + except BaseException: + with __import__("contextlib").suppress(OSError): + audio_path.unlink() + raise + return audio_filename, audio_path + + +async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path: + """Render and validate a WAV before atomically replacing *out_path*.""" + audio_path = Path(out_path) + audio_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav" + try: + await _render_archetype_wav(a, tmp_path) + if not is_playable_wav(tmp_path): + raise RuntimeError("the voice engine produced an invalid WAV") + os.replace(tmp_path, audio_path) + finally: + with __import__("contextlib").suppress(OSError): + tmp_path.unlink() + return audio_path + + +def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None: + """Repair profiles created before archetype `/use` persisted design kind.""" + instruct, vd_states = _design_profile_values(a) + conn.execute( + "UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, " + "ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?", + ( + instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED, + audio_filename, _archetype_personality(a), row["id"], + ), + ) + + # A non-empty script is always required — synthesizing empty text yields # silence. Every archetype carries a use-case script, but guard the render path # too so a malformed archetype can never drive a blank render. @@ -255,7 +405,7 @@ def _preview_source(a: dict) -> tuple[str, str]: "Pre-rendered preview from the voice gallery — a fixed reference " "rendering, not a render from your current engine." ) - if (_PREVIEW_DIR / f"{key}.wav").exists(): + if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"): return "cached", "" if _no_voice_model_downloaded(): return "no_model", ( @@ -388,9 +538,9 @@ async def preview_archetype( ) cache_path = _PREVIEW_DIR / f"{key}.wav" - if not cache_path.exists(): + if not is_playable_wav(cache_path): try: - await _render_archetype_wav(a, cache_path) + await _render_wav_atomic(a, cache_path, prefix=".preview-") except Exception as e: # model missing / OOM / inference failure logger.error("Archetype preview render failed", exc_info=True) # Two different failures, two different answers. Without a model @@ -442,70 +592,122 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)): # Idempotent (dedup): an archetype materializes to exactly ONE voice profile. # Picking the same gallery voice again — from any picker (Gallery grid, # VoiceSelector, …) — must reuse that one row instead of rendering + inserting - # a fresh duplicate every time. The `personality` column already carries the - # source archetype id (stamped by the INSERT below), so it's the natural - # dedup key; the expensive render + INSERT only run on first use. + # a fresh duplicate every time. Use a namespaced personality identity so an + # imported persona cannot collide with and be rewritten by an archetype id. with db_conn() as conn: - existing = conn.execute( - "SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1", - (a["id"],), - ).fetchone() + existing = _existing_archetype_profile(conn, a) + + profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8] + audio_path: Optional[Path] = None + if existing is not None and _materialized_audio_is_current(existing, a): + audio_filename = existing["ref_audio_path"] + else: + try: + audio_filename, audio_path = await _render_profile_audio( + a, profile_id, publish=existing is None, + ) + except Exception as e: + logger.error("Archetype 'use' render failed", exc_info=True) + # Same actionable/diagnostic split as /preview — minus the gallery + # suggestion, which cannot help here. + if _no_voice_model_downloaded(): + detail = ( + "Creating a voice needs the voice model — no voice model is " + "downloaded yet. Model Catalogue → Models → Download." + ) + else: + detail = ( + "Couldn't create a voice from this archetype — the voice engine " + f"reported: {e}" + ) + raise HTTPException(status_code=503, detail=detail) from e + if existing is not None: - return {"profile_id": existing["id"], "name": existing["name"]} - - profile_id = str(uuid.uuid4())[:8] - audio_filename = f"{profile_id}.wav" - audio_path = Path(VOICES_DIR) / audio_filename - - try: - await _render_archetype_wav(a, audio_path) - except Exception as e: - logger.error("Archetype 'use' render failed", exc_info=True) - # Same actionable/diagnostic split as /preview — minus the gallery - # suggestion, which cannot help here. - if _no_voice_model_downloaded(): - detail = ( - "Creating a voice needs the voice model — no voice model is " - "downloaded yet. Model Catalogue → Models → Download." + with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") + current = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (existing["id"],), + ).fetchone() + owned = _existing_archetype_profile(conn, a) + still_owned = current is not None and ( + owned is not None and owned["id"] == current["id"] ) + if still_owned: + if audio_path is not None: + destination = Path(VOICES_DIR) / audio_filename + os.replace(audio_path, destination) + audio_path = None + _heal_materialized_profile(conn, current, a, audio_filename) + existing_result = {"profile_id": current["id"], "name": current["name"]} + else: + existing_result = None + if existing_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]}) + return existing_result + # The row was edited/deleted while rendering. Preserve it and use the + # validated staged sample for a fresh canonical materialization. + profile_id = str(uuid.uuid4())[:8] + audio_filename = _profile_audio_filename(profile_id) + destination = Path(VOICES_DIR) / audio_filename + if audio_path is None: + try: + audio_filename, audio_path = await _render_profile_audio(a, profile_id) + except Exception as e: + raise HTTPException( + status_code=503, detail="Couldn't create a voice from this archetype.", + ) from e else: - detail = ( - "Couldn't create a voice from this archetype — the voice engine " - f"reported: {e}" - ) - raise HTTPException(status_code=503, detail=detail) + os.replace(audio_path, destination) + audio_path = destination + + if audio_path is None: # defensive: a new profile always rendered above + raise RuntimeError("new archetype profile has no rendered audio") profile_name = (name or a["name"]).strip() or a["name"] try: with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") # Re-check under the write connection right before inserting: a # concurrent /use for the same archetype may have inserted while we # were rendering (the pre-render SELECT above raced). Reuse that row # and drop our just-rendered sample instead of creating a duplicate. - # (personality is NOT globally unique — marketplace/persona imports - # reuse the column — so a UNIQUE index isn't an option; this closes - # the realistic window for the single-user desktop app.) - dup = conn.execute( - "SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1", - (a["id"],), - ).fetchone() + # `personality` is not globally UNIQUE, so serialize and re-check. + dup = _existing_archetype_profile(conn, a) if dup is not None: + duplicate_audio = dup["ref_audio_path"] + if not _materialized_audio_is_current(dup, a): + duplicate_audio = _profile_audio_filename(dup["id"]) + _duplicate_path = Path(VOICES_DIR) / duplicate_audio + _duplicate_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(audio_path, _duplicate_path) + audio_path = None + _heal_materialized_profile(conn, dup, a, duplicate_audio) with __import__("contextlib").suppress(OSError): - os.remove(audio_path) - return {"profile_id": dup["id"], "name": dup["name"]} - conn.execute( - "INSERT INTO voice_profiles " - "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ( - profile_id, profile_name, audio_filename, a["sample_script"], - a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(), - ), - ) + if audio_path is not None: + os.remove(audio_path) + duplicate_result = {"profile_id": dup["id"], "name": dup["name"]} + else: + duplicate_result = None + if duplicate_result is None: + instruct, vd_states = _design_profile_values(a) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)", + ( + profile_id, profile_name, audio_filename, a["sample_script"], + instruct, a["language"], _PREVIEW_SEED, + _archetype_personality(a), time.time(), vd_states, + ), + ) except Exception: with __import__("contextlib").suppress(OSError): - os.remove(audio_path) + if audio_path is not None: + os.remove(audio_path) raise + if duplicate_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]}) + return duplicate_result event_bus.emit("profiles", {"action": "created", "id": profile_id}) return {"profile_id": profile_id, "name": profile_name} diff --git a/backend/api/routers/community.py b/backend/api/routers/community.py index 12c433ab..21e1a1c5 100644 --- a/backend/api/routers/community.py +++ b/backend/api/routers/community.py @@ -20,18 +20,26 @@ Design / safety from __future__ import annotations import asyncio +import contextlib +import hashlib import json import logging import os import re +import shutil +import tempfile +import time +import uuid from pathlib import Path from typing import Optional -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse from core import archetypes -from core.config import DATA_DIR +from core.audio_validation import is_playable_wav, resolve_regular_file +from core.config import DATA_DIR, VOICES_DIR logger = logging.getLogger("omnivoice.community") router = APIRouter() @@ -42,9 +50,32 @@ _ALLOWED_AUDIO_HOSTS = { "cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com", } +_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"} _VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID) _USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES} -_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only +_SOURCE_RE = re.compile( + r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$", +) # owner/repo only +_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + +# A gallery open may touch this loader several times (grid, preview, use). Keep +# a successful response for six hours, then revalidate it once. On a network +# failure the readable stale copy remains usable and its check time advances, +# preventing every offline gallery open from waiting through the same timeout. +_MANIFEST_MAX_AGE_S = 6 * 60 * 60 +_MAX_MANIFEST_BYTES = 4 << 20 +_MAX_SAMPLE_SCRIPT_CHARS = 2_000 +_MAX_REF_TEXT_CHARS = 4_000 + +# Community voice submissions are documented as short clean WAV clips. The cap +# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a +# remote manifest from turning Preview into an unbounded disk/memory download. +_MAX_VOICE_AUDIO_BYTES = 32 << 20 + +_ATTR_NAMES = ( + "Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect", +) # ── Config: which content repos to load ─────────────────────────────────────── @@ -52,14 +83,18 @@ def configured_sources() -> list[str]: """Gallery sources, in priority order. Env var > config file > default.""" env = os.environ.get("OMNIVOICE_GALLERY_SOURCES") if env: - return [s.strip() for s in env.split(",") if s.strip()] + sources = [s.strip() for s in env.split(",")] + valid = [s for s in sources if _SOURCE_RE.fullmatch(s)] + return valid or list(_DEFAULT_SOURCES) cfg = Path(DATA_DIR) / "gallery_sources.json" if cfg.exists(): try: data = json.loads(cfg.read_text(encoding="utf-8")) srcs = data.get("sources") if isinstance(srcs, list) and srcs: - return [str(s) for s in srcs] + valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)] + if valid: + return valid except Exception: logger.warning("gallery_sources.json unreadable; using default") return list(_DEFAULT_SOURCES) @@ -81,9 +116,51 @@ def _safe_audio_url(url: str) -> bool: return False +def _safe_manifest_url(url: str) -> bool: + try: + parsed = urlparse(url or "") + return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS + except Exception: + return False + + +def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]: + """Normalize one validator-safe tag per design category. + + Membership in the vocabulary is not enough: ``male, female`` contains two + individually valid tokens but the engine rejects the pair as conflicting. + Build the frontend's full ``vd_states`` shape at this trust boundary too, + so Magic Wand never inherits stale sliders from the previous voice. + """ + attrs = {name: "Auto" for name in _ATTR_NAMES} + normalized: list[str] = [] + seen_categories: set[int] = set() + for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")): + token = raw.strip().lower() + if not token or token not in _VALID_TOKENS: + return None + category = archetypes._VD._instruct_category_index(token) + if category < 0 or category in seen_categories: + return None + seen_categories.add(category) + + # The picker represents the universal gender/age/pitch/style axes in + # English even for Chinese speech; dialect remains Chinese-only. + canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token) + attrs[_ATTR_NAMES[category]] = canonical + normalized.append(canonical) + + if not normalized: + return None + # Accent and Chinese dialect are separate taxonomy buckets but the engine + # deliberately forbids mixing them in a single design. + if 4 in seen_categories and 5 in seen_categories: + return None + return ", ".join(normalized), attrs + + def is_valid_instruct(instruct: str) -> bool: - toks = [t.strip() for t in (instruct or "").split(",") if t.strip()] - return bool(toks) and all(t in _VALID_TOKENS for t in toks) + return normalize_preset_instruct(instruct) is not None def validate_item(raw: dict) -> Optional[dict]: @@ -93,62 +170,203 @@ def validate_item(raw: dict) -> Optional[dict]: it = dict(raw) if it.get("type") not in ("preset", "voice"): return None - if not it.get("id") or not it.get("name"): + if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]): return None + if not isinstance(it.get("name"), str) or not it["name"].strip(): + return None + it["name"] = it["name"].strip()[:80] if it.get("use_case") not in _USE_CASE_IDS: return None - if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")): - return None # would crash synthesis — drop it - if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")): - return None - it.setdefault("facets", {}) + raw_facets = it.get("facets") + if not isinstance(raw_facets, dict): + raw_facets = {} + language = it.get("language") + if not isinstance(language, str) or not language.strip(): + language = raw_facets.get("lang", "English") + it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English" + + facets = dict(raw_facets) + if it["type"] == "preset": + normalized = normalize_preset_instruct(it.get("instruct", "")) + if normalized is None: + return None # unknown/conflicting tokens would crash synthesis + it["instruct"], it["attrs"] = normalized + attrs = it["attrs"] + facets.update({ + "gender": None if attrs["Gender"] == "Auto" else attrs["Gender"], + "age": None if attrs["Age"] == "Auto" else attrs["Age"], + "pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"], + "accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"], + "whisper": attrs["Style"] == "whisper", + "lang": it["language"], + }) + sample_script = it.get("sample_script") + it["sample_script"] = ( + sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS] + if isinstance(sample_script, str) else "" + ) + else: + audio = it.get("audio") + if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")): + return None + expected = audio.get("sha256") + if expected is not None: + expected = str(expected).lower() + if not _SHA256_RE.fullmatch(expected): + return None + audio = {**audio, "sha256": expected} + ref_text = audio.get("ref_text") + audio = { + **audio, + "ref_text": ( + ref_text.strip()[:_MAX_REF_TEXT_CHARS] + if isinstance(ref_text, str) else "" + ), + } + it["audio"] = audio + facets.setdefault("gender", None) + facets.setdefault("age", None) + facets.setdefault("pitch", None) + facets.setdefault("accent", None) + facets.setdefault("whisper", False) + facets.setdefault("lang", it["language"]) + it["facets"] = facets it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles")) - it.setdefault("language", it.get("facets", {}).get("lang", "English")) it["is_community"] = it.get("source") != "starter" + it["preview_url"] = f"/community/items/{it['id']}/preview" return it def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]: items, packs, seen = [], [], set() for src, m in manifests: - if not m: + if not isinstance(m, dict): continue - for raw in (m.get("items") or []): + raw_items = m.get("items") + for raw in raw_items if isinstance(raw_items, list) else []: v = validate_item(raw) if v and v["id"] not in seen: v["_source_repo"] = src seen.add(v["id"]) items.append(v) - for p in (m.get("packs") or []): + raw_packs = m.get("packs") + for p in raw_packs if isinstance(raw_packs, list) else []: if isinstance(p, dict): packs.append({**p, "_source_repo": src}) return items, packs -def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]: - """Return a source's manifest from cache, or fetch + cache it. None if both fail.""" - cache = _cache_path(source) - if not refresh and cache.exists(): - try: - return json.loads(cache.read_text(encoding="utf-8")) - except Exception: - pass +def _read_manifest_cache(cache: Path) -> Optional[dict]: try: - import httpx - with httpx.Client(timeout=15.0, follow_redirects=True) as client: - resp = client.get(_manifest_url(source)) - resp.raise_for_status() - data = resp.json() - cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_text(json.dumps(data), encoding="utf-8") + if cache.stat().st_size > _MAX_MANIFEST_BYTES: + return None + data = json.loads(cache.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError, TypeError): + return None + + +def _write_bytes_atomic(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp) + raise + + +def _fetch_remote_manifest(source: str, *, client=None) -> dict: + """Fetch one bounded manifest, validating every redirect before request.""" + import httpx + + if not _SOURCE_RE.fullmatch(source or ""): + raise ValueError("invalid gallery source") + owned_client = client is None + http = client or httpx.Client(timeout=15.0, follow_redirects=False) + current_url = _manifest_url(source) + payload = bytearray() + try: + fetched = False + for _redirect in range(6): + if not _safe_manifest_url(current_url): + raise ValueError("gallery manifest URL is not from an allowed host") + with http.stream("GET", current_url, follow_redirects=False) as response: + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + next_url = urljoin(current_url, location or "") + if not location or not _safe_manifest_url(next_url): + raise ValueError("gallery manifest redirected to a disallowed host") + current_url = next_url + continue + response.raise_for_status() + length = response.headers.get("content-length") + if length: + try: + declared_length = int(length) + except ValueError: + declared_length = None + if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES: + raise ValueError("gallery manifest exceeded the size limit") + for chunk in response.iter_bytes(): + if not chunk: + continue + if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES: + raise ValueError("gallery manifest exceeded the size limit") + payload.extend(chunk) + fetched = True + break + if not fetched: + raise ValueError("gallery manifest followed too many redirects") + finally: + if owned_client: + http.close() + if not payload: + raise ValueError("gallery manifest was empty") + data = json.loads(payload) + if not isinstance(data, dict): + raise ValueError("gallery manifest is not a JSON object") + return data + + +def _fetch_manifest( + source: str, refresh: bool, *, now: Optional[float] = None, +) -> Optional[dict]: + """Return a fresh manifest, with a throttled stale-cache offline fallback.""" + cache = _cache_path(source) + cached = _read_manifest_cache(cache) + checked_at = time.time() if now is None else float(now) + if not refresh and cached is not None: + try: + if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S: + return cached + except OSError: + pass # treat a stat race as stale and try the source once + try: + data = _fetch_remote_manifest(source) + encoded = json.dumps( + data, ensure_ascii=False, separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > _MAX_MANIFEST_BYTES: + raise ValueError("gallery manifest exceeded the cache size limit") + _write_bytes_atomic(cache, encoded) + # Tests inject their own clock; production's value equals wall time. + os.utime(cache, (checked_at, checked_at)) return data except Exception as e: # offline / 404 / bad json logger.warning("manifest fetch failed for %s: %s", source, e) - if cache.exists(): - try: - return json.loads(cache.read_text(encoding="utf-8")) - except Exception: - pass + if cached is not None: + # This mtime is a last-*check* marker. Advancing it on failure keeps + # an offline app responsive while guaranteeing another check after + # the bounded freshness interval. + with contextlib.suppress(OSError): + os.utime(cache, (checked_at, checked_at)) + return cached return None @@ -214,6 +432,385 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source: return {"url": f"https://github.com/{src}/issues/new?template={template}"} +def _find_item(items: list[dict], item_id: str) -> dict: + if not _ITEM_ID_RE.fullmatch(item_id or ""): + raise HTTPException(status_code=404, detail="Item not found in the gallery.") + item = next((it for it in items if it["id"] == item_id), None) + if item is None: + raise HTTPException(status_code=404, detail="Item not found in the gallery.") + return item + + +def _canonical_archetype(item: dict) -> Optional[dict]: + """The built-in archetype represented exactly by a marketplace preset.""" + if item.get("type") != "preset": + return None + canonical = archetypes.get_archetype(item["id"]) + if canonical is None: + return None + if (canonical.get("instruct") != item.get("instruct") + or canonical.get("language") != item.get("language")): + return None + remote_script = (item.get("sample_script") or "").strip() + if remote_script and remote_script != (canonical.get("sample_script") or "").strip(): + return None + return canonical + + +def _preset_preview_path(item: dict) -> Path: + fingerprint = hashlib.sha256( + json.dumps({ + "instruct": item.get("instruct"), + "language": item.get("language"), + "sample_script": item.get("sample_script"), + }, sort_keys=True).encode("utf-8") + ).hexdigest()[:16] + return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav" + + +def _voice_audio_fingerprint(item: dict) -> str: + audio = item.get("audio") or {} + return hashlib.sha256( + f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8") + ).hexdigest()[:16] + + +def _voice_audio_path(item: dict) -> Path: + return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav" + + +async def _render_preset_atomic(item: dict, out_path: Path) -> Path: + if is_playable_wav(out_path): + return out_path + from api.routers.archetypes import _render_archetype_wav + + out_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav") + os.close(fd) + tmp = Path(tmp_name) + try: + await _render_archetype_wav({ + "instruct": item["instruct"], + "language": item.get("language", "English"), + "sample_script": ( + (item.get("sample_script") or "").strip() + or "Hello — this is a preview of this voice." + ), + }, tmp) + if not is_playable_wav(tmp): + raise RuntimeError("the voice engine produced an invalid preview WAV") + os.replace(tmp, out_path) + return out_path + finally: + with contextlib.suppress(OSError): + tmp.unlink() + + +def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None: + """Stream one allow-listed voice clip into an atomic, size-bounded file.""" + audio = item.get("audio") or {} + url = audio.get("url", "") + if not _safe_audio_url(url): + raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.") + + import httpx + + owned_client = client is None + http = client or httpx.Client(timeout=30.0, follow_redirects=False) + out_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part") + total = 0 + digest = hashlib.sha256() + try: + with os.fdopen(fd, "wb") as handle: + current_url = url + downloaded = False + for _redirect in range(6): + with http.stream("GET", current_url, follow_redirects=False) as response: + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + next_url = urljoin(current_url, location or "") + if not location or not _safe_audio_url(next_url): + raise HTTPException( + status_code=502, + detail="Community voice audio redirected to a disallowed host.", + ) + current_url = next_url + continue + response.raise_for_status() + length = response.headers.get("content-length") + if length: + try: + if int(length) > _MAX_VOICE_AUDIO_BYTES: + raise HTTPException( + status_code=502, + detail="Community voice audio exceeded the download size limit.", + ) + except ValueError: + # A non-numeric Content-Length header is the + # server's problem, not a reason to refuse the + # download — the streamed byte counter below + # still enforces the same cap on what actually + # arrives. + pass + for chunk in response.iter_bytes(): + if not chunk: + continue + total += len(chunk) + if total > _MAX_VOICE_AUDIO_BYTES: + raise HTTPException( + status_code=502, + detail="Community voice audio exceeded the download size limit.", + ) + digest.update(chunk) + handle.write(chunk) + downloaded = True + break + if not downloaded: + raise HTTPException( + status_code=502, + detail="Community voice audio followed too many redirects.", + ) + if total == 0: + raise HTTPException(status_code=502, detail="Community voice audio was empty.") + expected = audio.get("sha256") + if expected and digest.hexdigest() != expected: + raise HTTPException( + status_code=502, + detail="Downloaded voice failed its integrity check.", + ) + handle.flush() + os.fsync(handle.fileno()) + if not is_playable_wav(Path(tmp_name)): + raise HTTPException( + status_code=502, detail="Community voice audio was not a valid WAV.", + ) + os.replace(tmp_name, out_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + finally: + if owned_client: + http.close() + + +def _cached_voice_audio(item: dict) -> Path: + path = _voice_audio_path(item) + if is_playable_wav(path): + return path + with contextlib.suppress(OSError): + path.unlink() + _download_voice_audio(item, path) + return path + + +def _copy_atomic(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part", + ) + try: + with os.fdopen(fd, "wb") as out, source.open("rb") as src: + shutil.copyfileobj(src, out) + out.flush() + os.fsync(out.fileno()) + os.replace(tmp_name, destination) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + + +@router.get("/community/items/{item_id}/preview") +async def community_preview( + item_id: str, + local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"), +): + """Serve every community preview through the authenticated same-origin API.""" + _, items, _, _ = await asyncio.to_thread(_load, False) + item = _find_item(items, item_id) + + canonical = _canonical_archetype(item) + if canonical is not None: + # Reuse the signed-gallery/local-render fallback and cache owned by the + # canonical endpoint rather than synthesizing the same preset twice. + # Delegate in-process: a root-relative HTTP redirect drops supported + # reverse-proxy path prefixes such as ``https://host/api``. + from api.routers.archetypes import preview_archetype + return await preview_archetype(canonical["id"], local=local) + + try: + if item["type"] == "preset": + path = await _render_preset_atomic(item, _preset_preview_path(item)) + else: + path = await asyncio.to_thread(_cached_voice_audio, item) + except HTTPException: + raise + except Exception as exc: + logger.warning("Community preview unavailable (%s)", type(exc).__name__) + raise HTTPException( + status_code=503, detail="This community voice preview is unavailable right now.", + ) from exc + return FileResponse( + path, media_type="audio/wav", + headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"}, + ) + + +def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]: + if item["type"] == "preset": + return "design", item["instruct"], json.dumps(item["attrs"]), 42 + return "clone", "", None, None + + +def _community_profile_audio_filename(profile_id: str, item: dict) -> str: + safe_id = ( + profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "") + else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16] + ) + if item["type"] == "voice": + # The manifest URL/checksum fingerprint makes a changed submission + # invalidate its already-materialized clone without a schema change. + return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav" + return f"{safe_id}.wav" + + +def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]: + return resolve_regular_file(VOICES_DIR, ref_audio_path) + + +def _community_audio_is_current(row, item: dict, ref_text: str) -> bool: + path = _stored_profile_audio(row["ref_audio_path"]) + expected_filename = _community_profile_audio_filename(row["id"], item) + if row["ref_audio_path"] != expected_filename or not is_playable_wav(path): + return False + kind, instruct, _vd_states, seed = _profile_fields(item) + inputs_match = ( + row["instruct"] == instruct + and row["language"] == item.get("language", "Auto") + and row["ref_text"] == ref_text + and row["seed"] == seed + ) + if not inputs_match: + return False + return True + + +async def _materialize_item_audio( + item: dict, profile_id: str, *, publish: bool = True, +) -> tuple[str, Path]: + """Copy the current manifest audio, optionally staging it for a later CAS.""" + audio_filename = _community_profile_audio_filename(profile_id, item) + destination = Path(VOICES_DIR) / audio_filename + audio_path = destination + if not publish: + destination.parent.mkdir(parents=True, exist_ok=True) + audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav" + if item["type"] == "preset": + cached = await _render_preset_atomic(item, _preset_preview_path(item)) + else: + cached = await asyncio.to_thread(_cached_voice_audio, item) + await asyncio.to_thread(_copy_atomic, cached, audio_path) + return audio_filename, audio_path + + +def _community_personality(item: dict) -> str: + source = item.get("_source_repo") + if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source): + source = _DEFAULT_SOURCES[0] + return f"community:{source}:{item['id']}" + + +def _is_materialized_community_row(row, item: dict) -> bool: + if ( + row["personality"] != _community_personality(item) + or row["is_locked"] or row["verified_own_voice"] + ): + return False + if item["type"] == "voice": + safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split( + "-community-", 1, + )[0] + return bool( + row["kind"] == "clone" + and row["seed"] is None + and not row["vd_states"] + and row["instruct"] == "" + and row["language"] == item.get("language", "Auto") + and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "") + and re.fullmatch( + rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav", + row["ref_audio_path"] or "", + ) + ) + try: + states = json.loads(row["vd_states"]) + except (TypeError, ValueError): + return False + return bool( + row["kind"] == "design" + and row["seed"] == 42 + and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item) + and row["instruct"] == item["instruct"] + and row["language"] == item.get("language", "Auto") + and row["ref_text"] == (item.get("sample_script") or "") + and states == item["attrs"] + ) + + +def _existing_community_profile(conn, item: dict, personality: str): + candidates = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id", + (personality,), + ).fetchall() + existing = next( + (row for row in candidates if _is_materialized_community_row(row, item)), None, + ) + if existing is not None: + return existing + # Old builds stored the bare item id. Import formats preserve arbitrary + # personality text too, so adopt only the exact shape the old materializer + # wrote; otherwise a remote item id could rewrite a user's imported voice. + if archetypes.get_archetype(item["id"]) is None: + legacy = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? LIMIT 1", + (item["id"],), + ).fetchone() + if legacy is not None: + kind, instruct, _vd_states, _seed = _profile_fields(item) + ref_text = item.get("sample_script") or (item.get("audio") or {}).get( + "ref_text", "", + ) + if ( + legacy["ref_audio_path"] == f"{legacy['id']}.wav" + and legacy["kind"] == kind + and legacy["instruct"] == instruct + and legacy["language"] == item.get("language", "Auto") + and legacy["ref_text"] == ref_text + and legacy["seed"] is None + and not legacy["vd_states"] + and not legacy["is_locked"] + and not legacy["verified_own_voice"] + ): + return legacy + return None + + +def _heal_existing_profile( + conn, row, item: dict, ref_text: str, personality: str, audio_filename: str, +) -> None: + kind, instruct, vd_states, seed = _profile_fields(item) + conn.execute( + "UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, " + "ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?", + ( + kind, instruct, vd_states, item.get("language", "Auto"), ref_text, + seed, personality, audio_filename, row["id"], + ), + ) + + @router.post("/community/items/{item_id}/use") async def community_use(item_id: str, name: Optional[str] = Query(None)): """Materialize a community item into a reusable voice profile. @@ -223,76 +820,108 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)): ``voice_profiles`` row usable everywhere voices are picked. """ _, items, _, _ = await asyncio.to_thread(_load, False) - item = next((it for it in items if it["id"] == item_id), None) - if item is None: - raise HTTPException(status_code=404, detail="Item not found in the gallery.") + item = _find_item(items, item_id) + + canonical = _canonical_archetype(item) + if canonical is not None: + from api.routers.archetypes import use_archetype + return await use_archetype(canonical["id"], name) - import time - import uuid from core import event_bus from core.db import db_conn - from core.config import VOICES_DIR - profile_id = str(uuid.uuid4())[:8] - audio_filename = f"{profile_id}.wav" - audio_path = Path(VOICES_DIR) / audio_filename - profile_name = (name or item["name"]).strip() or item["name"] - instruct = item.get("instruct", "") if item["type"] == "preset" else "" ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "") + personality = _community_personality(item) + with db_conn() as conn: + existing = _existing_community_profile(conn, item, personality) - try: - if item["type"] == "preset": - from api.routers.archetypes import _render_archetype_wav - pseudo = { - "instruct": instruct, - "language": item.get("language", "English"), - "sample_script": ref_text or "Hello — this is a preview of this voice.", - } - await _render_archetype_wav(pseudo, audio_path) - else: # voice — download the reference clip (off the event loop) - await asyncio.to_thread(_download_voice_audio, item, audio_path) - except HTTPException: - raise - except Exception as e: - logger.error("Community 'use' failed", exc_info=True) - raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}") - - try: - # A community "preset" is a synthetic designed voice (rendered from an - # instruct string) → kind='design'; a "voice" carries a real reference - # clip → kind='clone'. Setting kind makes the persona-gallery - # synthetic-only gating work (§R3) instead of defaulting all imports to - # 'clone'. - kind = "design" if item["type"] == "preset" else "clone" - with db_conn() as conn: - conn.execute( - "INSERT INTO voice_profiles " - "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (profile_id, profile_name, audio_filename, ref_text, instruct, - item.get("language", "Auto"), None, item["id"], time.time(), kind), + profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8] + audio_path: Optional[Path] = None + if existing is not None and _community_audio_is_current(existing, item, ref_text): + audio_filename = existing["ref_audio_path"] + else: + try: + audio_filename, audio_path = await _materialize_item_audio( + item, profile_id, publish=existing is None, ) + except HTTPException: + raise + except Exception as e: + logger.error("Community 'use' failed", exc_info=True) + raise HTTPException( + status_code=503, detail="Couldn't add this voice right now.", + ) from e + + if existing is not None: + with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") + current = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (existing["id"],), + ).fetchone() + owned = _existing_community_profile(conn, item, personality) + still_owned = current is not None and ( + _is_materialized_community_row(current, item) + or (owned is not None and owned["id"] == current["id"]) + ) + if still_owned: + if audio_path is not None: + destination = Path(VOICES_DIR) / audio_filename + os.replace(audio_path, destination) + audio_path = None + _heal_existing_profile( + conn, current, item, ref_text, personality, audio_filename, + ) + existing_result = {"profile_id": current["id"], "name": current["name"]} + else: + existing_result = None + if existing_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]}) + return existing_result + profile_id = str(uuid.uuid4())[:8] + audio_filename = _community_profile_audio_filename(profile_id, item) + destination = Path(VOICES_DIR) / audio_filename + if audio_path is None: + audio_filename, audio_path = await _materialize_item_audio(item, profile_id) + else: + os.replace(audio_path, destination) + audio_path = destination + + if audio_path is None: # defensive: a new profile always materialized above + raise RuntimeError("new community profile has no materialized audio") + profile_name = (name or item["name"]).strip() or item["name"] + kind, instruct, vd_states, seed = _profile_fields(item) + try: + with db_conn() as conn: + conn.execute("BEGIN IMMEDIATE") + duplicate = _existing_community_profile(conn, item, personality) + if duplicate is not None: + duplicate_audio = duplicate["ref_audio_path"] + if not _community_audio_is_current(duplicate, item, ref_text): + duplicate_audio = _community_profile_audio_filename(duplicate["id"], item) + duplicate_path = Path(VOICES_DIR) / duplicate_audio + _copy_atomic(audio_path, duplicate_path) + _heal_existing_profile( + conn, duplicate, item, ref_text, personality, duplicate_audio, + ) + with contextlib.suppress(OSError): + audio_path.unlink() + duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]} + else: + duplicate_result = None + if duplicate_result is None: + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (profile_id, profile_name, audio_filename, ref_text, instruct, + item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states), + ) except Exception: - with __import__("contextlib").suppress(OSError): - os.remove(audio_path) + with contextlib.suppress(OSError): + audio_path.unlink() raise + if duplicate_result is not None: + event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]}) + return duplicate_result event_bus.emit("profiles", {"action": "created", "id": profile_id}) return {"profile_id": profile_id, "name": profile_name} - - -def _download_voice_audio(item: dict, out_path: Path) -> None: - import hashlib - audio = item.get("audio") or {} - url = audio.get("url", "") - if not _safe_audio_url(url): - raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.") - import httpx - with httpx.Client(timeout=30.0, follow_redirects=True) as client: - resp = client.get(url) - resp.raise_for_status() - data = resp.content - expected = audio.get("sha256") - if expected and hashlib.sha256(data).hexdigest() != expected: - raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.") - out_path.parent.mkdir(parents=True, exist_ok=True) - out_path.write_bytes(data) diff --git a/backend/api/routers/gallery.py b/backend/api/routers/gallery.py index 217914a6..fc8d08ff 100644 --- a/backend/api/routers/gallery.py +++ b/backend/api/routers/gallery.py @@ -1,18 +1,24 @@ -import os -import json -import uuid -import time import asyncio +import contextlib +import json import logging -from typing import Optional, List +import os +import re +import shutil +import tempfile +import time +import uuid from pathlib import Path +from typing import List, Optional + from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query -from fastapi.responses import FileResponse, RedirectResponse +from fastapi.responses import FileResponse from pydantic import BaseModel from core.db import db_conn from core.config import VOICES_DIR, OUTPUTS_DIR from core import event_bus +from core.audio_validation import resolve_regular_file from core.file_cleanup import FileCleanupError, unlink_if_present from services.ffmpeg_utils import spawn_subprocess @@ -360,46 +366,223 @@ async def upload_voice_clip( } +def _stage_profile_audio(source: Path, directory: Path) -> Path: + """Copy an imported clip to a hidden temp file inside ``directory``. + + The temp lives in the destination directory itself so a later + ``os.replace`` to the final name is an atomic same-filesystem rename — + cheap enough to run while holding a DB write lock, unlike the copy. + Callers own cleanup of the returned path if they never publish it. + """ + directory.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + dir=str(directory), prefix=".gallery-import-", suffix=".part", + ) + os.close(fd) + try: + shutil.copy2(source, tmp_name) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + return Path(tmp_name) + + +def _copy_profile_audio(source: Path, destination: Path) -> None: + """Copy an imported clip without exposing a partial profile audio file.""" + staged = _stage_profile_audio(source, destination.parent) + try: + os.replace(staged, destination) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(staged) + raise + + +def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str: + """Return the canonical, portable filename for a My Imports profile.""" + safe_id = ( + profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "") + else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16] + ) + suffix = source.suffix.lower() + if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix): + suffix = ".wav" + return f"{safe_id}_gallery{suffix}" + + +def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool: + """Recognize only rows created by this materializer, not identity collisions.""" + return bool( + row["personality"] == f"gallery:{voice['id']}" + and row["ref_audio_path"] == audio_filename + and row["ref_text"] == "" + and row["instruct"] == "" + and row["language"] == "Auto" + and row["seed"] is None + and row["kind"] == "clone" + and not row["vd_states"] + and row["description"] == (voice.get("description") or "") + and not row["is_locked"] + and not row["verified_own_voice"] + and not row["locked_audio_path"] + ) + + +def _existing_gallery_profile(conn, voice: dict, source: Path): + personality = f"gallery:{voice['id']}" + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id", + (personality,), + ).fetchall() + for row in rows: + expected = _gallery_profile_audio_filename(row["id"], source) + if _is_materialized_gallery_profile(row, voice, expected): + return row + return None + + +def _gallery_profile_audio_is_current(row, source: Path) -> bool: + """Detect missing/replaced copies without re-hashing unchanged imports.""" + destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"]) + if destination is None: + return False + try: + source_stat = source.stat() + destination_stat = destination.stat() + # copy2 preserves mtime; size + nanosecond mtime catches ordinary edits + # and partial writes while keeping repeated Use clicks inexpensive. + return ( + source_stat.st_size == destination_stat.st_size + and source_stat.st_mtime_ns == destination_stat.st_mtime_ns + ) + except OSError: + return False + + +def _materialize_gallery_profile( + voice_id: str, requested_name: Optional[str] = None, +) -> dict: + """Idempotently materialize/heal one My Imports clip as a clone profile.""" + personality = f"gallery:{voice_id}" + copied_path: Optional[Path] = None + created = False + staged_path: Optional[Path] = None + staged_source: Optional[Path] = None + try: + # Stage the (potentially large) audio copy BEFORE taking SQLite's + # write lock: copying inside BEGIN IMMEDIATE would stall every other + # backend writer for the whole copy. The staged temp lives in + # VOICES_DIR itself, so publishing it inside the transaction is an + # atomic same-filesystem os.replace. This pre-read is advisory only — + # the locked transaction below re-reads and re-decides everything. + copy_needed = False + with db_conn() as conn: + pre_row = conn.execute( + "SELECT * FROM voice_gallery WHERE id = ?", (voice_id,), + ).fetchone() + if pre_row is not None: + pre_source = Path(pre_row["audio_path"]) + if pre_source.is_file(): + pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source) + copy_needed = pre_existing is None or not _gallery_profile_audio_is_current( + pre_existing, pre_source, + ) + if copy_needed: + staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR)) + staged_source = pre_source + + with db_conn() as conn: + # The identity is not globally UNIQUE because personality is shared + # with other import mechanisms. Serialize this check+insert in + # SQLite so simultaneous Use clicks cannot both create a row. + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT * FROM voice_gallery WHERE id = ?", (voice_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Voice not found") + + voice = dict(row) + source = Path(voice["audio_path"]) + if not source.is_file(): + raise HTTPException(status_code=404, detail="Audio file not found on disk") + + def _install_audio(destination: Path) -> None: + """Publish the staged copy under the lock via atomic rename.""" + nonlocal staged_path + if staged_path is not None and staged_source == source: + os.replace(staged_path, destination) + staged_path = None + else: + # Rare race: the gallery row changed between the advisory + # pre-read and taking the lock, so any staged bytes may be + # from the wrong source. Fall back to the blocking copy + # rather than publish stale audio. + _copy_profile_audio(source, destination) + + existing = _existing_gallery_profile(conn, voice, source) + if existing is not None: + ref_filename = _gallery_profile_audio_filename(existing["id"], source) + if not _gallery_profile_audio_is_current(existing, source): + ref_path = Path(VOICES_DIR) / ref_filename + _install_audio(ref_path) + copied_path = ref_path + conn.execute( + "UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', " + "language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, " + "personality=? WHERE id=?", + ( + ref_filename, voice["description"] or "", personality, + existing["id"], + ), + ) + result = {"profile_id": existing["id"], "name": existing["name"]} + else: + profile_id = str(uuid.uuid4())[:8] + profile_name = (requested_name or voice["name"]).strip() or voice["name"] + ref_filename = _gallery_profile_audio_filename(profile_id, source) + copied_path = Path(VOICES_DIR) / ref_filename + _install_audio(copied_path) + conn.execute( + """INSERT INTO voice_profiles + (id, name, ref_audio_path, ref_text, instruct, language, seed, + personality, is_locked, locked_audio_path, description, kind, + vd_states, created_at) + VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""", + ( + profile_id, profile_name, ref_filename, personality, + voice["description"] or "", time.time(), + ), + ) + created = True + result = {"profile_id": profile_id, "name": profile_name} + except BaseException: + if copied_path is not None: + with contextlib.suppress(OSError): + copied_path.unlink() + raise + finally: + # Staged but never published (failure, or a concurrent request healed + # the profile first) — never leave .part droppings in VOICES_DIR. + if staged_path is not None: + with contextlib.suppress(OSError): + os.unlink(staged_path) + + event_bus.emit( + "profiles", {"action": "created" if created else "updated", "id": result["profile_id"]}, + ) + return result + + @router.post("/gallery/voices/{voice_id}/save-as-profile") async def save_voice_as_profile( voice_id: str, profile_name: str = Query(..., description="Name for the voice profile"), ): """Save a gallery voice as a voice profile for cloning.""" - with db_conn() as conn: - row = conn.execute( - "SELECT * FROM voice_gallery WHERE id = ?", (voice_id,) - ).fetchone() - - if not row: - raise HTTPException(status_code=404, detail="Voice not found") - - profile_id = str(uuid.uuid4())[:8] - import shutil - - ext = os.path.splitext(row["audio_path"])[1] - new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}") - shutil.copy(row["audio_path"], new_audio_path) - - conn.execute( - """ - INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - profile_id, - profile_name, - f"{profile_id}{ext}", - row["description"] or "", - row["character"] or "", - "Auto", - None, - time.time(), - ), - ) - event_bus.emit("profiles", {"action": "created", "id": profile_id}) - - return {"profile_id": profile_id, "name": profile_name} + result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name) + return {"profile_id": result["profile_id"], "name": result["name"]} @router.get("/gallery/voices/{voice_id}/preview") @@ -415,22 +598,10 @@ def preview_voice(voice_id: str): audio_path = row["audio_path"] - # Debug logging - is_absolute = os.path.isabs(audio_path) - path_exists = os.path.exists(audio_path) if audio_path else False - - # If absolute path, serve directly or redirect - if is_absolute and path_exists: - # Get just the relative path from outputs dir - outputs_path = str(OUTPUTS_DIR) - if audio_path.startswith(outputs_path): - # Remove outputs_dir prefix to get relative path within outputs - rel_path = os.path.relpath(audio_path, outputs_path) - # The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav - # rel_path becomes: voice_gallery/file.wav - # We want to serve from /audio/ so: /audio/voice_gallery/file.wav - return RedirectResponse(f"/audio/{rel_path}") - return FileResponse(audio_path, media_type="audio/wav") + if os.path.isabs(audio_path) and os.path.exists(audio_path): + # Serve the file from this API route so deployments mounted below a + # path prefix do not lose that prefix while following a redirect. + return FileResponse(audio_path) raise HTTPException( status_code=404, @@ -503,33 +674,5 @@ def batch_delete_voices(body: dict): @router.post("/gallery/voices/{voice_id}/to-profile") def voice_to_profile(voice_id: str): """Create a voice profile from a gallery clip.""" - with db_conn() as conn: - row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone() - if not row: - raise HTTPException(status_code=404, detail="Voice not found") - - voice = dict(row) - audio_path = voice["audio_path"] - if not os.path.exists(audio_path): - raise HTTPException(status_code=404, detail="Audio file not found on disk") - - import shutil - import uuid - - profile_id = str(uuid.uuid4())[:8] - # Copy audio to voices dir - dest_filename = f"{profile_id}_gallery.wav" - dest_path = os.path.join(VOICES_DIR, dest_filename) - shutil.copy2(audio_path, dest_path) - - import time - now = time.time() - conn.execute( - """INSERT INTO voice_profiles - (id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now), - ) - event_bus.emit("profiles", {"action": "created", "id": profile_id}) - - return {"success": True, "profile_id": profile_id, "name": voice["name"]} + result = _materialize_gallery_profile(voice_id) + return {"success": True, "profile_id": result["profile_id"], "name": result["name"]} diff --git a/backend/core/audio_validation.py b/backend/core/audio_validation.py new file mode 100644 index 00000000..66a1746b --- /dev/null +++ b/backend/core/audio_validation.py @@ -0,0 +1,106 @@ +"""Lightweight validation for persisted profile WAV references. + +This module deliberately uses only the standard library. Gallery routers import +it during startup, so pulling in torch/torchaudio merely to validate a cached +file would make every Gallery open pay the model stack's import cost. +""" +from __future__ import annotations + +import os +import wave +from pathlib import Path +from typing import Optional + +from core.path_security import UnsafePath, resolve_within, safe_filename + +_READ_CHUNK_BYTES = 1 << 20 +_MAX_CHANNELS = 64 +_MAX_SAMPLE_RATE = 768_000 +_MAX_SAMPLE_WIDTH = 8 + + +def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]: + """Resolve a portable bare filename inside *root*, rejecting symlinks.""" + try: + name = safe_filename(value) + unresolved = Path(root).resolve(strict=False) / name + if unresolved.is_symlink(): + return None + return resolve_within(root, name) + except (OSError, UnsafePath): + return None + + +def is_playable_wav(path: Optional[Path]) -> bool: + """Return true only for a regular, decodable WAV with audio frames.""" + if path is None: + return False + try: + if not path.is_file() or path.is_symlink(): + return False + file_size = path.stat().st_size + with wave.open(str(path), "rb") as wav: + channels = wav.getnchannels() + sample_rate = wav.getframerate() + sample_width = wav.getsampwidth() + frame_count = wav.getnframes() + if ( + not 0 < channels <= _MAX_CHANNELS + or not 0 < sample_rate <= _MAX_SAMPLE_RATE + or not 0 < sample_width <= _MAX_SAMPLE_WIDTH + or frame_count <= 0 + ): + return False + # ``wave.getnframes`` trusts the header. Read through the declared + # payload so an interrupted write with a complete header but a + # truncated data chunk cannot masquerade as playable audio. + frame_size = channels * sample_width + expected_bytes = frame_count * frame_size + # A PCM payload cannot be larger than the containing file. Check + # before calling ``readframes`` so hostile header values cannot + # turn a tiny file into a multi-gigabyte allocation request. + if expected_bytes > file_size: + return False + read_bytes = 0 + chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size)) + while read_bytes < expected_bytes: + chunk = wav.readframes(chunk_frames) + if not chunk or len(chunk) % frame_size: + return False + read_bytes += len(chunk) + return read_bytes == expected_bytes + except (MemoryError, OSError, EOFError, OverflowError, wave.Error): + # Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE + # files. SoundFile is already a runtime dependency and recognizes those + # containers; import it only on the uncommon fallback path. + try: + import soundfile as sf + + with sf.SoundFile(str(path)) as audio: + if ( + audio.format != "WAV" + or not 0 < audio.channels <= _MAX_CHANNELS + or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE + or len(audio) <= 0 + ): + return False + remaining = len(audio) + # Decode through the declared payload in byte-bounded chunks; + # ``sf.info`` alone also trusts a truncated file's header. + chunk_frames = max( + 1, _READ_CHUNK_BYTES // (audio.channels * 4), + ) + while remaining: + frames = audio.read( + min(remaining, chunk_frames), dtype="float32", always_2d=True, + ) + count = len(frames) + if count <= 0: + return False + remaining -= count + return True + except Exception: + return False + + +__all__ = ["is_playable_wav", "resolve_regular_file"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ac99bf4e..45f75272 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -49,9 +49,38 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"): os.environ["OMNIVOICE_MODEL"] = "test" +import functools +import shutil + import pytest +@functools.lru_cache(maxsize=1) +def supports_symlinks() -> bool: + """True when this process may create symlinks. On Windows, + ``os.symlink`` raises OSError without Developer Mode or admin rights, so + symlink-dependent assertions must be skipped there rather than fail.""" + probe_dir = tempfile.mkdtemp(prefix="omnivoice-symlink-probe-") + try: + target = os.path.join(probe_dir, "target") + with open(target, "w", encoding="utf-8"): + pass + try: + os.symlink(target, os.path.join(probe_dir, "link")) + except (OSError, NotImplementedError): + return False + return True + finally: + shutil.rmtree(probe_dir, ignore_errors=True) + + +@pytest.fixture(scope="session") +def symlinks_supported() -> bool: + """Bool fixture over :func:`supports_symlinks` for guarding the + symlink-only assertions of a test while its other assertions still run.""" + return supports_symlinks() + + @pytest.fixture def asr_model_installed(monkeypatch, request): """Neutralize the no-ASR-installed preflight (asr_model_missing_error → diff --git a/backend/tests/test_archetypes_api.py b/backend/tests/test_archetypes_api.py index b14b9622..fe91389b 100644 --- a/backend/tests/test_archetypes_api.py +++ b/backend/tests/test_archetypes_api.py @@ -9,7 +9,10 @@ generation.py's proven ``_run_inference`` rather than re-implementing it. """ from __future__ import annotations +import io +import json from pathlib import Path +import wave import pytest @@ -23,6 +26,23 @@ from core import archetypes # noqa: E402 from api.routers import archetypes as arch_router # noqa: E402 +def _wav_bytes() -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(24_000) + wav.writeframes(b"\x00\x01" * 64) + return buf.getvalue() + + +def _write_wav(path: Path) -> bytes: + data = _wav_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return data + + @pytest.fixture(scope="module") def client(): app = FastAPI() @@ -133,8 +153,7 @@ def test_preview_serves_cached_wav_without_model(client): key = arch_router._preview_key(sample) cache_dir = Path(arch_router._PREVIEW_DIR) cache_dir.mkdir(parents=True, exist_ok=True) - dummy = b"RIFF\x24\x00\x00\x00WAVEfmt cached-archetype-preview" - (cache_dir / f"{key}.wav").write_bytes(dummy) + dummy = _write_wav(cache_dir / f"{key}.wav") r = client.get(f"/archetypes/{sample['id']}/preview") assert r.status_code == 200 @@ -143,7 +162,7 @@ def test_preview_serves_cached_wav_without_model(client): # ── Materialize-on-use idempotency (dedup, no re-render) ─────────────────────── -def test_use_is_idempotent_dedup(client, monkeypatch): +def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_supported): """The 2nd `/use` of the same archetype reuses its one materialized profile and does NOT render again — the guarantee that materialize-on-select in any voice picker can't spawn duplicate rows on repeated picks. @@ -151,6 +170,7 @@ def test_use_is_idempotent_dedup(client, monkeypatch): The render boundary (``_render_archetype_wav``) is mocked so no model/GPU is needed: it just drops a stub WAV where the row expects one. """ + from core import event_bus from core.db import init_db init_db() # ensure the voice_profiles table exists in the hermetic tmp DB @@ -159,10 +179,13 @@ def test_use_is_idempotent_dedup(client, monkeypatch): async def _fake_render(a, out_path): render_calls["n"] += 1 - Path(out_path).parent.mkdir(parents=True, exist_ok=True) - Path(out_path).write_bytes(b"RIFF\x24\x00\x00\x00WAVEfmt stub") + _write_wav(Path(out_path)) monkeypatch.setattr(arch_router, "_render_archetype_wav", _fake_render) + emitted = [] + monkeypatch.setattr( + event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)), + ) sample = archetypes.list_archetypes(featured=True)[0] @@ -181,6 +204,248 @@ def test_use_is_idempotent_dedup(client, monkeypatch): from core.db import db_conn with db_conn() as conn: rows = conn.execute( - "SELECT id FROM voice_profiles WHERE personality = ?", (sample["id"],) + "SELECT * FROM voice_profiles WHERE personality = ?", + (arch_router._archetype_personality(sample),), ).fetchall() assert len(rows) == 1 + assert rows[0]["kind"] == "design" + assert json.loads(rows[0]["vd_states"]) == sample["attrs"] + + with db_conn() as conn: + row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone() + assert row["kind"] == "design" + assert row["instruct"] == sample["instruct"] + assert json.loads(row["vd_states"]) == sample["attrs"] + + # A missing sample or synthesis-input drift must be repaired before the + # existing profile is returned; Preview and Use must describe one voice. + audio_path = arch_router._profile_audio_path(row["ref_audio_path"]) + assert audio_path is not None + audio_path.unlink() + repaired = client.post(f"/archetypes/{sample['id']}/use") + assert repaired.status_code == 200 and repaired.json()["profile_id"] == pid + assert render_calls["n"] == 2 + assert audio_path.read_bytes().startswith(b"RIFF") + + with db_conn() as conn: + conn.execute("UPDATE voice_profiles SET instruct='male' WHERE id=?", (pid,)) + refreshed = client.post(f"/archetypes/{sample['id']}/use") + assert refreshed.status_code == 200 + assert refreshed.json()["profile_id"] != pid + assert render_calls["n"] == 3 + with db_conn() as conn: + edited = conn.execute("SELECT instruct FROM voice_profiles WHERE id=?", (pid,)).fetchone() + assert edited["instruct"] == "male" + + # Continue corruption checks against the new canonical materialization. + pid = refreshed.json()["profile_id"] + with db_conn() as conn: + row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone() + audio_path = arch_router._profile_audio_path(row["ref_audio_path"]) + assert audio_path is not None + + audio_path.write_bytes(b"not a WAV") + repaired_corrupt = client.post(f"/archetypes/{sample['id']}/use") + assert repaired_corrupt.status_code == 200 + assert render_calls["n"] == 4 + + if symlinks_supported: # Windows needs Developer Mode to create symlinks + outside = tmp_path / "outside.wav" + outside_bytes = _write_wav(outside) + audio_path.unlink() + audio_path.symlink_to(outside) + repaired_symlink = client.post(f"/archetypes/{sample['id']}/use") + assert repaired_symlink.status_code == 200 + assert render_calls["n"] == 5 + assert not audio_path.is_symlink() + assert outside.read_bytes() == outside_bytes + + # A valid header with a missing payload is not playable and must self-heal. + renders_before = render_calls["n"] + truncated = _wav_bytes()[:44] + audio_path.write_bytes(truncated) + repaired_truncated = client.post(f"/archetypes/{sample['id']}/use") + assert repaired_truncated.status_code == 200 + assert render_calls["n"] == renders_before + 1 + assert audio_path.read_bytes() != truncated + + +def test_archetype_staged_repair_preserves_concurrently_edited_profile( + client, monkeypatch, +): + """A repair may publish only if the row still belongs to the archetype.""" + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + sample = archetypes.list_archetypes(featured=True)[3] + personality = arch_router._archetype_personality(sample) + edited_personality = f"user-edited:{sample['id']}" + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)", + (sample["id"], personality, edited_personality), + ) + + original_id = {"value": None} + mutation_seen = {"value": False} + + async def racing_render(_item, path): + destination = Path(path) + if destination.name.endswith(".staged.wav"): + assert original_id["value"] is not None + with db_conn() as conn: + conn.execute( + "UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?", + (edited_personality, original_id["value"]), + ) + mutation_seen["value"] = True + _write_wav(destination) + + monkeypatch.setattr(arch_router, "_render_archetype_wav", racing_render) + first = client.post(f"/archetypes/{sample['id']}/use") + assert first.status_code == 200 + original_id["value"] = first.json()["profile_id"] + + with db_conn() as conn: + original = conn.execute( + "SELECT ref_audio_path FROM voice_profiles WHERE id=?", + (original_id["value"],), + ).fetchone() + original_audio = arch_router._profile_audio_path(original["ref_audio_path"]) + assert original_audio is not None + corrupt_bytes = b"corrupt user-owned sample" + original_audio.write_bytes(corrupt_bytes) + + repaired = client.post(f"/archetypes/{sample['id']}/use") + assert repaired.status_code == 200 + repaired_id = repaired.json()["profile_id"] + assert mutation_seen["value"] + assert repaired_id != original_id["value"] + + with db_conn() as conn: + edited = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],), + ).fetchone() + canonical = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (repaired_id,), + ).fetchone() + canonical_count = conn.execute( + "SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,), + ).fetchone()[0] + assert edited["name"] == "User edit" + assert edited["personality"] == edited_personality + assert edited["instruct"] == sample["instruct"] + assert original_audio.read_bytes() == corrupt_bytes + assert canonical["personality"] == personality + assert canonical["ref_audio_path"] == arch_router._profile_audio_filename(repaired_id) + assert canonical_count == 1 + assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes() + assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav")) + + +def test_archetype_use_adopts_only_a_compatible_legacy_row(client, monkeypatch): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + sample = archetypes.list_archetypes(featured=True)[1] + legacy_id = "legacyarch" + legacy_audio = Path(VOICES_DIR) / f"{legacy_id}.wav" + _write_wav(legacy_audio) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (sample["id"], arch_router._archetype_personality(sample)), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, created_at) VALUES (?, 'Legacy archetype', ?, ?, ?, ?, 42, ?, " + "'clone', NULL, 1)", + ( + legacy_id, legacy_audio.name, sample["sample_script"], sample["instruct"], + sample["language"], sample["id"], + ), + ) + + async def unexpected_render(*_args): + raise AssertionError("a valid legacy archetype sample must be reused") + + monkeypatch.setattr(arch_router, "_render_archetype_wav", unexpected_render) + response = client.post(f"/archetypes/{sample['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] == legacy_id + with db_conn() as conn: + row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (legacy_id,)).fetchone() + assert row["personality"] == arch_router._archetype_personality(sample) + assert row["kind"] == "design" + assert json.loads(row["vd_states"]) == sample["attrs"] + + +def test_archetype_use_does_not_rewrite_an_imported_personality_collision( + client, monkeypatch, +): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + sample = archetypes.list_archetypes(featured=True)[2] + imported_id = "importedarch" + imported_ns_id = "importedarchns" + imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav" + imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav" + original_audio = _write_wav(imported_audio) + original_ns_audio = _write_wav(imported_ns_audio) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (sample["id"], arch_router._archetype_personality(sample)), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, " + "'clone', 1, 1, 1)", + (imported_id, imported_audio.name, sample["id"]), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, " + "'design', NULL, 0, 0, 2)", + ( + imported_ns_id, imported_ns_audio.name, sample["sample_script"], + sample["instruct"], sample["language"], + arch_router._archetype_personality(sample), + ), + ) + + async def render(_item, path): + _write_wav(Path(path)) + + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + response = client.post(f"/archetypes/{sample['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] != imported_id + with db_conn() as conn: + imported = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_id,), + ).fetchone() + imported_ns = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,), + ).fetchone() + created = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert imported["personality"] == sample["id"] + assert imported["instruct"] == "male" + assert imported["ref_text"] == "user transcript" + assert imported_audio.read_bytes() == original_audio + assert imported_ns["instruct"] == sample["instruct"] + assert imported_ns["ref_text"] == sample["sample_script"] + assert imported_ns["vd_states"] is None + assert imported_ns_audio.read_bytes() == original_ns_audio + assert created["personality"] == arch_router._archetype_personality(sample) diff --git a/backend/tests/test_audio_validation.py b/backend/tests/test_audio_validation.py new file mode 100644 index 00000000..5948d494 --- /dev/null +++ b/backend/tests/test_audio_validation.py @@ -0,0 +1,44 @@ +"""Regression tests for the lightweight persisted-WAV trust boundary.""" +from __future__ import annotations + +import struct + +from core.audio_validation import is_playable_wav, resolve_regular_file + + +def test_oversized_declared_wav_payload_is_not_treated_as_playable(tmp_path): + """A hostile frame count must be bounded and backed by real payload bytes.""" + path = tmp_path / "oversized.wav" + declared_size = 0xFFFF_FFF0 + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", + 0xFFFF_FFFF, + b"WAVE", + b"fmt ", + 16, + 1, + 1, + 24_000, + 48_000, + 2, + 16, + b"data", + declared_size, + ) + path.write_bytes(header + b"\x00\x01") + + assert not is_playable_wav(path) + + +def test_profile_wav_resolution_rejects_escape_and_symlink(tmp_path, symlinks_supported): + root = tmp_path / "voices" + root.mkdir() + outside = tmp_path / "outside.wav" + outside.write_bytes(b"outside") + + assert resolve_regular_file(root, "../outside.wav") is None + assert resolve_regular_file(root, str(outside)) is None + if symlinks_supported: # Windows needs Developer Mode to create symlinks + (root / "linked.wav").symlink_to(outside) + assert resolve_regular_file(root, "linked.wav") is None diff --git a/backend/tests/test_community.py b/backend/tests/test_community.py index 42396e99..b578e4e8 100644 --- a/backend/tests/test_community.py +++ b/backend/tests/test_community.py @@ -1,25 +1,43 @@ """Tests for the community gallery (marketplace) loader. -Covers the no-network surface: strict item validation (invalid presets and -unsafe audio URLs are dropped so they can never crash synthesis or fetch from -an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and -the prefilled submit URL. The render/download paths need the model/network and -are exercised at runtime. +Covers strict item validation, manifest/cache boundaries, same-origin preview, +and idempotent profile materialization without a model or network dependency. """ from __future__ import annotations +import io import json +import os +from pathlib import Path +import wave import pytest # conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a # throwaway tmpdir before this module imports the REAL core.config (the old # sys.modules stub leaked at collection time and broke mixed runs). -from fastapi import FastAPI # noqa: E402 +from fastapi import FastAPI, HTTPException, Response # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from api.routers import community # noqa: E402 + +def _wav_bytes() -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(24_000) + wav.writeframes(b"\x00\x01" * 64) + return buf.getvalue() + + +def _write_wav(path: Path) -> bytes: + data = _wav_bytes() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return data + _FIXTURE = { "schema_version": 1, "items": [ @@ -75,12 +93,51 @@ def test_unknown_use_case_dropped(): assert community.validate_item(_FIXTURE["items"][4]) is None +def test_malformed_manifest_entries_do_not_break_other_sources(): + valid = _FIXTURE["items"][0] + items, packs = community._merge([ + ("bad/repo", {"items": 42, "packs": "not-a-list"}), + ("good/repo", {"items": [None, "not-an-item", valid], "packs": [None]}), + ]) + + assert [item["id"] for item in items] == [valid["id"]] + assert packs == [] + + def test_is_valid_instruct(): assert community.is_valid_instruct("male, elderly, very low pitch") assert not community.is_valid_instruct("male, sultry") + assert not community.is_valid_instruct("male, female") + assert not community.is_valid_instruct("british accent, 四川话") assert not community.is_valid_instruct("") +def test_preset_attrs_are_normalized_and_complete(): + item = community.validate_item(_FIXTURE["items"][0]) + assert item["instruct"] == "female, middle-aged, low pitch" + assert item["attrs"] == { + "Gender": "female", "Age": "middle-aged", "Pitch": "low pitch", + "Style": "Auto", "EnglishAccent": "Auto", "ChineseDialect": "Auto", + } + assert item["preview_url"] == "/community/items/p1/preview" + + +def test_remote_transcript_fields_are_bounded(): + preset = community.validate_item({ + **_FIXTURE["items"][0], + "sample_script": " x " * (community._MAX_SAMPLE_SCRIPT_CHARS + 10), + }) + voice = community.validate_item({ + **_FIXTURE["items"][3], + "audio": { + **_FIXTURE["items"][3]["audio"], + "ref_text": " y " * (community._MAX_REF_TEXT_CHARS + 10), + }, + }) + assert len(preset["sample_script"]) == community._MAX_SAMPLE_SCRIPT_CHARS + assert len(voice["audio"]["ref_text"]) == community._MAX_REF_TEXT_CHARS + + # ── merge keeps only valid items ────────────────────────────────────────────── def test_merge_drops_invalid_and_dedups(): items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)]) @@ -116,3 +173,620 @@ def test_submit_url(client): voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"] assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset assert "voice-submission.yml" in voice + + +# ── bounded cache freshness + stale offline fallback ───────────────────────── +def test_stale_manifest_refreshes_then_stays_fresh(tmp_path, monkeypatch): + monkeypatch.setattr(community, "_CACHE_DIR", tmp_path) + source = "debpalash/omnivoice-gallery" + cache = community._cache_path(source) + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps(_FIXTURE), encoding="utf-8") + os.utime(cache, (100.0, 100.0)) + + fresh = {**_FIXTURE, "updated_at": "new"} + calls = [] + monkeypatch.setattr( + community, "_fetch_remote_manifest", + lambda src: calls.append(src) or fresh, + ) + now = 100.0 + community._MANIFEST_MAX_AGE_S + 1 + assert community._fetch_manifest(source, False, now=now)["updated_at"] == "new" + assert community._fetch_manifest(source, False, now=now + 1)["updated_at"] == "new" + assert calls == [source] + + +def test_stale_manifest_falls_back_and_throttles_offline_retry(tmp_path, monkeypatch): + monkeypatch.setattr(community, "_CACHE_DIR", tmp_path) + source = "debpalash/omnivoice-gallery" + cache = community._cache_path(source) + cache.parent.mkdir(parents=True) + cache.write_text(json.dumps(_FIXTURE), encoding="utf-8") + os.utime(cache, (100.0, 100.0)) + + calls = [] + def offline(src): + calls.append(src) + raise OSError("offline") + monkeypatch.setattr(community, "_fetch_remote_manifest", offline) + now = 100.0 + community._MANIFEST_MAX_AGE_S + 1 + assert community._fetch_manifest(source, False, now=now) == _FIXTURE + assert community._fetch_manifest(source, False, now=now + 1) == _FIXTURE + assert calls == [source] + + +def test_manifest_fetch_is_bounded(monkeypatch): + monkeypatch.setattr(community, "_MAX_MANIFEST_BYTES", 8) + + class Response: + status_code = 200 + headers = {} + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield b'{"items":[]}' + class Client: + def stream(self, method, url, **kwargs): + assert method == "GET" + assert url.startswith("https://cdn.jsdelivr.net/") + assert kwargs == {"follow_redirects": False} + return Response() + + with pytest.raises(ValueError, match="size limit"): + community._fetch_remote_manifest("test/source", client=Client()) + + +def test_manifest_fetch_rejects_redirect_before_external_request(): + requested = [] + + class Response: + status_code = 302 + headers = {"location": "https://evil.example/manifest.json"} + def __enter__(self): return self + def __exit__(self, *_args): return False + class Client: + def stream(self, _method, url, **_kwargs): + requested.append(url) + return Response() + + with pytest.raises(ValueError, match="disallowed host"): + community._fetch_remote_manifest("test/source", client=Client()) + assert requested == [community._manifest_url("test/source")] + + +# ── Preview proxy ───────────────────────────────────────────────────────────── +def test_canonical_preset_preview_delegates_same_origin(client, monkeypatch): + from core import archetypes + from api.routers import archetypes as arch_router + + canonical = archetypes.list_archetypes(featured=True)[0] + item = community.validate_item({ + **canonical, "type": "preset", "source": "starter", + }) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + delegated = [] + + async def preview(archetype_id, local=False): + delegated.append((archetype_id, local)) + return Response(_wav_bytes(), media_type="audio/wav") + + monkeypatch.setattr(arch_router, "preview_archetype", preview) + response = client.get(f"/community/items/{item['id']}/preview") + local = client.get(f"/community/items/{item['id']}/preview?local=true") + + assert response.status_code == local.status_code == 200 + assert "location" not in response.headers + assert delegated == [(item["id"], False), (item["id"], True)] + + +def test_noncanonical_preset_preview_renders_once(client, tmp_path, monkeypatch): + item = community.validate_item(_FIXTURE["items"][0]) + monkeypatch.setattr(community, "_CACHE_DIR", tmp_path) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + from api.routers import archetypes as arch_router + calls = [] + async def render(_item, path): + calls.append(path) + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + + first = client.get("/community/items/p1/preview") + second = client.get("/community/items/p1/preview") + assert first.status_code == second.status_code == 200 + assert first.content == _wav_bytes() + assert first.headers["x-omnivoice-preview-source"] == "community" + assert len(calls) == 1 + + community._preset_preview_path(item).write_bytes(b"not audio") + repaired = client.get("/community/items/p1/preview") + assert repaired.status_code == 200 + assert repaired.content == _wav_bytes() + assert len(calls) == 2 + + +def test_recorded_preview_is_served_from_same_origin(client, tmp_path, monkeypatch): + item = community.validate_item(_FIXTURE["items"][3]) + clip = tmp_path / "voice.wav" + expected = _write_wav(clip) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + monkeypatch.setattr(community, "_cached_voice_audio", lambda _item: clip) + response = client.get("/community/items/v1/preview") + assert response.status_code == 200 + assert response.content == expected + + +def test_recorded_download_cap_is_atomic(tmp_path, monkeypatch): + item = community.validate_item(_FIXTURE["items"][3]) + destination = tmp_path / "voice.wav" + destination.write_bytes(b"existing-good-audio") + monkeypatch.setattr(community, "_MAX_VOICE_AUDIO_BYTES", 8) + + class Response: + status_code = 200 + headers = {} + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield b"123456789" + class Client: + def stream(self, method, url, **kwargs): + assert method == "GET" and url.startswith("https://github.com/") + assert kwargs == {"follow_redirects": False} + return Response() + + with pytest.raises(HTTPException) as exc: + community._download_voice_audio(item, destination, client=Client()) + assert getattr(exc.value, "status_code", None) == 502 + assert destination.read_bytes() == b"existing-good-audio" + assert not list(tmp_path.glob(".*.part")) + + +def test_recorded_download_rejects_redirect_before_external_request(tmp_path): + item = community.validate_item(_FIXTURE["items"][3]) + requested = [] + + class Response: + status_code = 302 + headers = {"location": "https://evil.example/private.wav"} + def __enter__(self): return self + def __exit__(self, *_args): return False + class Client: + def stream(self, _method, url, **_kwargs): + requested.append(url) + return Response() + + with pytest.raises(HTTPException) as exc: + community._download_voice_audio(item, tmp_path / "voice.wav", client=Client()) + assert getattr(exc.value, "status_code", None) == 502 + assert requested == [item["audio"]["url"]] + + +def test_recorded_download_follows_allowlisted_redirect(tmp_path): + item = community.validate_item(_FIXTURE["items"][3]) + destination = tmp_path / "voice.wav" + requested = [] + expected = _wav_bytes() + + class Response: + def __init__(self, status, headers, body=b""): + self.status_code, self.headers, self.body = status, headers, body + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield self.body + class Client: + def stream(self, _method, url, **_kwargs): + requested.append(url) + if len(requested) == 1: + return Response(302, {"location": "https://objects.githubusercontent.com/v1.wav"}) + return Response(200, {}, expected) + + community._download_voice_audio(item, destination, client=Client()) + assert destination.read_bytes() == expected + assert requested == [item["audio"]["url"], "https://objects.githubusercontent.com/v1.wav"] + + +def test_recorded_download_rejects_non_audio_bytes(tmp_path): + item = community.validate_item(_FIXTURE["items"][3]) + destination = tmp_path / "voice.wav" + + class Response: + status_code = 200 + headers = {} + def __enter__(self): return self + def __exit__(self, *_args): return False + def raise_for_status(self): return None + def iter_bytes(self): yield b"this is not audio" + class Client: + def stream(self, _method, _url, **_kwargs): return Response() + + with pytest.raises(HTTPException, match="valid WAV"): + community._download_voice_audio(item, destination, client=Client()) + assert not destination.exists() + assert not list(tmp_path.glob(".*.part")) + + +# ── Materialization ─────────────────────────────────────────────────────────── +def test_community_use_is_idempotent_design_profile( + client, tmp_path, monkeypatch, symlinks_supported, +): + from core import event_bus + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + calls = [] + emitted = [] + async def render(_item, path): + calls.append(path) + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + monkeypatch.setattr( + event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)), + ) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + + first = client.post("/community/items/p1/use") + second = client.post("/community/items/p1/use") + assert first.status_code == second.status_code == 200 + assert second.json()["profile_id"] == first.json()["profile_id"] + assert len(calls) == 1 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "design" + assert row["personality"] == personality + assert json.loads(row["vd_states"])["Gender"] == "female" + assert row["instruct"] == item["instruct"] + assert emitted[-1] == ( + "profiles", {"action": "updated", "id": first.json()["profile_id"]}, + ) + + profile_audio = community._stored_profile_audio(row["ref_audio_path"]) + assert profile_audio is not None + profile_audio.unlink() + repaired = client.post("/community/items/p1/use") + assert repaired.status_code == 200 + assert repaired.json()["profile_id"] == first.json()["profile_id"] + assert profile_audio.read_bytes() == _wav_bytes() + # The current preset preview cache repairs the profile without another + # model render. + assert len(calls) == 1 + + profile_audio.write_bytes(b"not a WAV") + repaired_corrupt = client.post("/community/items/p1/use") + assert repaired_corrupt.status_code == 200 + assert profile_audio.read_bytes() == _wav_bytes() + + if symlinks_supported: # Windows needs Developer Mode to create symlinks + outside = tmp_path / "outside.wav" + outside_bytes = _write_wav(outside) + profile_audio.unlink() + profile_audio.symlink_to(outside) + repaired_symlink = client.post("/community/items/p1/use") + assert repaired_symlink.status_code == 200 + assert not profile_audio.is_symlink() + assert outside.read_bytes() == outside_bytes + + +def test_community_staged_repair_preserves_concurrently_edited_profile( + client, monkeypatch, +): + """A staged community repair must not reclaim a row edited mid-copy.""" + from core.config import VOICES_DIR + from core.db import db_conn, init_db + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + edited_personality = f"user-edited:{personality}" + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)", + (item["id"], personality, edited_personality), + ) + _write_wav(community._preset_preview_path(item)) + + original_id = {"value": None} + mutation_seen = {"value": False} + real_copy_atomic = community._copy_atomic + + def racing_copy(source, destination): + destination = Path(destination) + if destination.name.endswith(".staged.wav"): + assert original_id["value"] is not None + with db_conn() as conn: + conn.execute( + "UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?", + (edited_personality, original_id["value"]), + ) + mutation_seen["value"] = True + real_copy_atomic(Path(source), destination) + + monkeypatch.setattr(community, "_copy_atomic", racing_copy) + first = client.post(f"/community/items/{item['id']}/use") + assert first.status_code == 200 + original_id["value"] = first.json()["profile_id"] + + with db_conn() as conn: + original = conn.execute( + "SELECT ref_audio_path FROM voice_profiles WHERE id=?", + (original_id["value"],), + ).fetchone() + original_audio = community._stored_profile_audio(original["ref_audio_path"]) + assert original_audio is not None + corrupt_bytes = b"corrupt user-owned sample" + original_audio.write_bytes(corrupt_bytes) + + repaired = client.post(f"/community/items/{item['id']}/use") + assert repaired.status_code == 200 + repaired_id = repaired.json()["profile_id"] + assert mutation_seen["value"] + assert repaired_id != original_id["value"] + + with db_conn() as conn: + edited = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],), + ).fetchone() + canonical = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (repaired_id,), + ).fetchone() + canonical_count = conn.execute( + "SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,), + ).fetchone()[0] + assert edited["name"] == "User edit" + assert edited["personality"] == edited_personality + assert edited["instruct"] == item["instruct"] + assert original_audio.read_bytes() == corrupt_bytes + assert canonical["personality"] == personality + assert canonical["ref_audio_path"] == community._community_profile_audio_filename( + repaired_id, item, + ) + assert canonical_count == 1 + assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes() + assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav")) + + +def test_recorded_community_use_is_idempotent_clone_profile(client, tmp_path, monkeypatch): + from core.db import db_conn, init_db + + init_db() + item = community.validate_item(_FIXTURE["items"][3]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + clip = tmp_path / "recorded.wav" + _write_wav(clip) + cache_calls = [] + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + monkeypatch.setattr( + community, "_cached_voice_audio", lambda _item: cache_calls.append(_item["id"]) or clip, + ) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + + first = client.post("/community/items/v1/use") + second = client.post("/community/items/v1/use") + assert first.status_code == second.status_code == 200 + assert second.json()["profile_id"] == first.json()["profile_id"] + assert cache_calls == ["v1"] + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "clone" + assert row["personality"] == personality + assert row["vd_states"] is None and row["instruct"] == "" + assert row["ref_text"] == "" + + old_audio_filename = row["ref_audio_path"] + item["audio"]["url"] = "https://raw.githubusercontent.com/test/source/main/v2.wav" + refreshed = client.post("/community/items/v1/use") + assert refreshed.status_code == 200 + assert refreshed.json()["profile_id"] == first.json()["profile_id"] + assert cache_calls == ["v1", "v1"] + with db_conn() as conn: + refreshed_row = conn.execute( + "SELECT ref_audio_path FROM voice_profiles WHERE id=?", + (first.json()["profile_id"],), + ).fetchone() + assert refreshed_row["ref_audio_path"] != old_audio_filename + + +def test_noncanonical_builtin_id_cannot_heal_archetype_profile(client, monkeypatch): + from core import archetypes + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + canonical = archetypes.list_archetypes(featured=True)[0] + changed_instruct = "female" if canonical["instruct"] != "female" else "male" + item = community.validate_item({ + **canonical, + "type": "preset", + "source": "community", + "instruct": changed_instruct, + }) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + builtin_profile_id = f"b{os.urandom(4).hex()[:7]}" + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (canonical["id"], personality), + ) + conn.execute( + "INSERT INTO voice_profiles (id, name, personality, instruct, kind, created_at) " + "VALUES (?, 'Built-in profile', ?, 'sentinel', 'design', 1)", + (builtin_profile_id, canonical["id"]), + ) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + async def render(_item, path): + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + + response = client.post(f"/community/items/{canonical['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] != builtin_profile_id + with db_conn() as conn: + builtin = conn.execute( + "SELECT instruct FROM voice_profiles WHERE id=?", (builtin_profile_id,), + ).fetchone() + community_row = conn.execute( + "SELECT personality FROM voice_profiles WHERE id=?", + (response.json()["profile_id"],), + ).fetchone() + conn.execute( + "DELETE FROM voice_profiles WHERE id IN (?, ?)", + (builtin_profile_id, response.json()["profile_id"]), + ) + assert builtin["instruct"] == "sentinel" + assert community_row["personality"] == personality + + +def test_community_use_does_not_rewrite_an_imported_bare_id_collision( + client, monkeypatch, +): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + imported_id = "importedcomm" + imported_ns_id = "importedcommns" + imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav" + imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav" + original_audio = _write_wav(imported_audio) + original_ns_audio = _write_wav(imported_ns_audio) + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, " + "'clone', 1, 1, 1)", + (imported_id, imported_audio.name, item["id"]), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, is_locked, verified_own_voice, created_at) VALUES " + "(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, " + "'design', NULL, 0, 0, 2)", + ( + imported_ns_id, imported_ns_audio.name, item["sample_script"], + item["instruct"], item["language"], personality, + ), + ) + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + + async def render(_item, path): + _write_wav(Path(path)) + + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + response = client.post(f"/community/items/{item['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] != imported_id + with db_conn() as conn: + imported = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_id,), + ).fetchone() + imported_ns = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,), + ).fetchone() + created = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert imported["personality"] == item["id"] + assert imported["instruct"] == "male" + assert imported["ref_text"] == "user transcript" + assert imported_audio.read_bytes() == original_audio + assert imported_ns["instruct"] == item["instruct"] + assert imported_ns["ref_text"] == item["sample_script"] + assert imported_ns["vd_states"] is None + assert imported_ns_audio.read_bytes() == original_ns_audio + assert created["personality"] == personality + + +def test_noncolliding_legacy_community_profile_is_adopted(client, monkeypatch): + from core.config import VOICES_DIR + from core.db import db_conn, init_db + from api.routers import archetypes as arch_router + + init_db() + item = community.validate_item(_FIXTURE["items"][0]) + item["_source_repo"] = "test/source" + personality = community._community_personality(item) + legacy_id = f"l{os.urandom(4).hex()[:7]}" + with db_conn() as conn: + conn.execute( + "DELETE FROM voice_profiles WHERE personality IN (?, ?)", + (item["id"], personality), + ) + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "kind, vd_states, created_at) VALUES " + "(?, 'Legacy community profile', ?, '', ?, ?, NULL, ?, 'design', NULL, 1)", + (legacy_id, f"{legacy_id}.wav", item["instruct"], item["language"], item["id"]), + ) + _write_wav(Path(VOICES_DIR) / f"{legacy_id}.wav") + monkeypatch.setattr( + community, "_load", lambda _refresh: (["test/source"], [item], [], False), + ) + community._preset_preview_path(item).unlink(missing_ok=True) + rendered = [] + async def render(_item, path): + rendered.append(path) + _write_wav(Path(path)) + monkeypatch.setattr(arch_router, "_render_archetype_wav", render) + + response = client.post(f"/community/items/{item['id']}/use") + assert response.status_code == 200 + assert response.json()["profile_id"] == legacy_id + assert len(rendered) == 1 + with db_conn() as conn: + adopted = conn.execute( + "SELECT personality, kind, ref_audio_path FROM voice_profiles WHERE id=?", + (legacy_id,), + ).fetchone() + assert adopted["personality"] == personality + assert adopted["kind"] == "design" + adopted_audio = community._stored_profile_audio(adopted["ref_audio_path"]) + assert adopted_audio is not None and adopted_audio.is_file() diff --git a/backend/tests/test_gallery_profiles.py b/backend/tests/test_gallery_profiles.py new file mode 100644 index 00000000..942d5608 --- /dev/null +++ b/backend/tests/test_gallery_profiles.py @@ -0,0 +1,250 @@ +"""Gallery-import profile materialization contracts.""" +from __future__ import annotations + +import shutil +import sqlite3 +import time +import uuid +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routers import gallery +from core.db import db_conn, init_db + + +@pytest.fixture(scope="module") +def client(): + init_db() + gallery._init_gallery_db() + app = FastAPI() + app.include_router(gallery.router) + return TestClient(app) + + +def _gallery_voice( + suffix: str = ".wav", content: bytes = b"RIFF imported voice", +) -> tuple[str, Path]: + voice_id = f"g{uuid.uuid4().hex[:7]}" + path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + with db_conn() as conn: + conn.execute( + """INSERT INTO voice_gallery + (id, name, character, category, source_type, source_url, audio_path, + duration, description, tags, created_at) + VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""", + ( + voice_id, "Imported narrator", "Video title is not an instruct", + "https://example.invalid/source", str(path), + "Source URL/notes are not a spoken transcript", time.time(), + ), + ) + return voice_id, path + + +def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client): + voice_id, _ = _gallery_voice() + response = client.post( + f"/gallery/voices/{voice_id}/save-as-profile", + params={"profile_name": "Reusable import"}, + ) + assert response.status_code == 200 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "clone" + assert row["personality"] == f"gallery:{voice_id}" + assert row["ref_text"] == "" + assert row["instruct"] == "" + assert row["description"] == "Source URL/notes are not a spoken transcript" + + +def test_to_profile_uses_live_schema_and_clone_metadata(client): + voice_id, _ = _gallery_voice() + response = client.post(f"/gallery/voices/{voice_id}/to-profile") + assert response.status_code == 200 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert row["kind"] == "clone" + assert row["personality"] == f"gallery:{voice_id}" + assert row["ref_text"] == row["instruct"] == "" + assert row["description"] == "Source URL/notes are not a spoken transcript" + + +def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch): + emitted = [] + monkeypatch.setattr( + gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)), + ) + voice_id, _ = _gallery_voice() + first = client.post( + f"/gallery/voices/{voice_id}/save-as-profile", + params={"profile_name": "One reusable profile"}, + ) + repeated = client.post( + f"/gallery/voices/{voice_id}/save-as-profile", + params={"profile_name": "Ignored duplicate name"}, + ) + alternate = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert first.status_code == repeated.status_code == alternate.status_code == 200 + assert { + first.json()["profile_id"], + repeated.json()["profile_id"], + alternate.json()["profile_id"], + } == {first.json()["profile_id"]} + with db_conn() as conn: + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality=?", + (f"gallery:{voice_id}",), + ).fetchall() + assert len(rows) == 1 + assert rows[0]["name"] == "One reusable profile" + assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None + assert emitted[-1] == ( + "profiles", {"action": "updated", "id": first.json()["profile_id"]}, + ) + + +def test_gallery_profile_repairs_a_missing_copy_without_duplication(client): + voice_id, source = _gallery_voice() + first = client.post(f"/gallery/voices/{voice_id}/to-profile") + assert first.status_code == 200 + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],), + ).fetchone() + copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"] + copied.unlink() + + repaired = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert repaired.status_code == 200 + assert repaired.json()["profile_id"] == first.json()["profile_id"] + assert copied.read_bytes() == source.read_bytes() + + +def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client): + voice_id, source = _gallery_voice() + collision_id = f"c{uuid.uuid4().hex[:7]}" + personality = f"gallery:{voice_id}" + collision_name = gallery._gallery_profile_audio_filename(collision_id, source) + collision_audio = Path(gallery.VOICES_DIR) / collision_name + collision_audio.parent.mkdir(parents=True, exist_ok=True) + collision_audio.write_bytes(b"user-owned audio") + with db_conn() as conn: + conn.execute( + "INSERT INTO voice_profiles " + "(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, " + "description, kind, vd_states, is_locked, verified_own_voice, created_at) " + "VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, " + "'user-owned metadata', 'clone', NULL, 0, 0, ?)", + (collision_id, collision_name, personality, time.time()), + ) + + response = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert response.status_code == 200 + assert response.json()["profile_id"] != collision_id + with db_conn() as conn: + collision = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (collision_id,), + ).fetchone() + created = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],), + ).fetchone() + assert collision["description"] == "user-owned metadata" + assert collision_audio.read_bytes() == b"user-owned audio" + assert created["personality"] == personality + + +def _part_files() -> set[Path]: + return set(Path(gallery.VOICES_DIR).glob("*.part")) | set( + Path(gallery.VOICES_DIR).glob(".*.part") + ) + + +def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch): + """The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction. + + While the copy runs, another backend writer takes (and releases) SQLite's + write lock. If materialization copied inside its own write transaction, + this concurrent writer would hit `database is locked` and the test fails. + """ + from core.config import DB_PATH + + voice_id, _ = _gallery_voice() + real_copy2 = shutil.copy2 + concurrent_writes = [] + + def copy_and_probe(src, dst, **kwargs): + probe = sqlite3.connect(DB_PATH, timeout=0.5) + try: + probe.execute("BEGIN IMMEDIATE") + probe.execute( + "UPDATE voice_gallery SET category = category WHERE id = ?", + (voice_id,), + ) + probe.commit() + concurrent_writes.append(True) + finally: + probe.close() + return real_copy2(src, dst, **kwargs) + + monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe) + + response = client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert response.status_code == 200 + assert concurrent_writes == [True] + assert _part_files() == set() + + +def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch): + """A copy that dies mid-write must not leave .part files or a DB row.""" + voice_id, _ = _gallery_voice() + + def exploding_copy(src, dst, **kwargs): + Path(dst).write_bytes(b"partial bytes") + raise OSError("disk full mid-copy") + + monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy) + + with pytest.raises(OSError, match="disk full mid-copy"): + client.post(f"/gallery/voices/{voice_id}/to-profile") + + assert _part_files() == set() + with db_conn() as conn: + rows = conn.execute( + "SELECT * FROM voice_profiles WHERE personality = ?", + (f"gallery:{voice_id}",), + ).fetchall() + assert rows == [] + + +def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client): + voice_id, source = _gallery_voice() + + response = client.get( + f"/gallery/voices/{voice_id}/preview", follow_redirects=False, + ) + + assert response.status_code == 200 + assert "location" not in response.headers + assert response.content == source.read_bytes() + + +def test_gallery_preview_preserves_non_wav_content_type(client): + voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice") + + response = client.get(f"/gallery/voices/{voice_id}/preview") + + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/mpeg" diff --git a/docs/specs/longform/26-gallery-use-handoff.md b/docs/specs/longform/26-gallery-use-handoff.md index d5bf43aa..40dc0a66 100644 --- a/docs/specs/longform/26-gallery-use-handoff.md +++ b/docs/specs/longform/26-gallery-use-handoff.md @@ -1,5 +1,7 @@ # Spec — TASK #26: Gallery "Use in Stories" / "Set as Audiobook default" + create-voice handoff +> **Implemented (2026-08-13).** Gallery and Community persona cards now materialize once and hand the returned profile directly to Studio, the current Stories cast, or the current Audiobook default. The implementation uses the unified `longformSlice` that superseded the store additions proposed below; the remainder of this document preserves the original design record. + ## TL;DR Today the Gallery's "Use voice" action materializes an archetype/community voice into a profile and hard-codes a handoff into the **Studio** synthesis view (`frontend/src/pages/VoiceGallery.jsx:199-210` for archetypes; `frontend/src/App.jsx:254-268` for the studio-side pickup). There is no path from the Gallery into the **Stories** cast or the **Audiobook** default narrator. This task adds two quick-actions to gallery + community cards — "Use in Stories" and "Set as Audiobook default" — that (a) materialize the voice into a real profile (same backend call as today) and (b) land it in the right destination: appended to the Stories cast as a new character, or set as the persisted Audiobook default voice. The Audiobook default currently has no store binding at all, so this task also promotes it from local `useState` to a persisted store field. diff --git a/frontend/e2e/footer-clipping.spec.ts b/frontend/e2e/footer-clipping.spec.ts index 9a1d6548..f533d61b 100644 --- a/frontend/e2e/footer-clipping.spec.ts +++ b/frontend/e2e/footer-clipping.spec.ts @@ -27,7 +27,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => { test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => { await gotoMode(page, 'gallery'); - const cards = page.locator('.archetype-card'); + const cards = page.getByTestId('gallery-persona-card'); await expect(cards.first()).toBeVisible({ timeout: 20_000 }); const top = await footerTop(page); @@ -45,7 +45,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => { page, }) => { await gotoMode(page, 'gallery'); - const cards = page.locator('.archetype-card'); + const cards = page.getByTestId('gallery-persona-card'); await expect(cards.first()).toBeVisible({ timeout: 20_000 }); // Expand the logs panel (chevron toggle in the collapsed bar). diff --git a/frontend/e2e/gallery.spec.ts b/frontend/e2e/gallery.spec.ts index 2c5d2948..db3d9648 100644 --- a/frontend/e2e/gallery.spec.ts +++ b/frontend/e2e/gallery.spec.ts @@ -9,7 +9,9 @@ test.describe('VoiceStudio Gallery', () => { test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => { await gotoMode(page, 'gallery'); - const select = page.locator('select.facet-select').first(); + // Scope by testid, not the translated 'Archetypes' label — the accessible + // name follows the app locale and breaks under non-English navigators. + const select = page.getByTestId('archetypes-zone').getByRole('combobox').first(); await expect(select).toBeVisible(); // Regression guard for the undefined-var fallback: the fixed style resolves // --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface @@ -25,7 +27,7 @@ test.describe('VoiceStudio Gallery', () => { await gotoMode(page, 'gallery'); // Cards load from the backend; wait for the first one. - const designerBtn = page.locator('.archetype-card .designer-btn').first(); + const designerBtn = page.getByRole('button', { name: /Open in Designer/i }).first(); await expect(designerBtn).toBeVisible({ timeout: 20_000 }); await designerBtn.click(); diff --git a/frontend/e2e/support-compact.spec.ts b/frontend/e2e/support-compact.spec.ts new file mode 100644 index 00000000..c789d8a1 --- /dev/null +++ b/frontend/e2e/support-compact.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { gotoMode } from './_helpers'; + +const MIN_WINDOW = { width: 900, height: 600 }; + +test.describe('Support page stays on one screen @ 900x600', () => { + test.use({ viewport: MIN_WINDOW }); + + test('support, commercial licence and contact panels do not overflow', async ({ page }) => { + await gotoMode(page, 'donate'); + await expect(page.getByRole('heading', { name: 'Support VoiceStudio' })).toBeVisible({ + timeout: 20_000, + }); + + for (const tabName of ['Support', 'Commercial License', 'Contact']) { + const tab = page.getByRole('tab', { name: tabName }); + if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click({ force: true }); + + await expect(tab).toHaveAttribute('aria-selected', 'true'); + const panel = page.getByRole('tabpanel'); + await expect(panel).toBeVisible(); + const { clientHeight, scrollHeight } = await panel.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect( + scrollHeight, + `${tabName} panel should not need vertical scrolling`, + ).toBeLessThanOrEqual(clientHeight + 1); + } + }); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1ceead80..f767fee4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -397,6 +397,7 @@ function App() { handleLockProfile, handleUnlockProfile, } = useProfiles({ loadHistory, loadProfiles }); + const clearSelectedProfile = useCallback(() => setSelectedProfile(null), [setSelectedProfile]); const { refAudio, @@ -1513,7 +1514,7 @@ function App() { ) : mode === 'gallery' ? ( }> - + ) : mode === 'transcriptions' ? ( diff --git a/frontend/src/api/community.ts b/frontend/src/api/community.ts index b2dfac21..b29a710e 100644 --- a/frontend/src/api/community.ts +++ b/frontend/src/api/community.ts @@ -19,7 +19,9 @@ interface CommunityItem { author?: string; license?: string; source?: string; + _source_repo?: string; is_community?: boolean; + attrs?: Record; } export interface CommunityPage { @@ -59,3 +61,7 @@ export const addCommunityItem = ( const q = name ? `?name=${encodeURIComponent(name)}` : ''; return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' }); }; + +/** Same-origin preview path for both designed presets and recorded voices. */ +export const communityPreviewUrl = (id: string, local = false): string => + `/community/items/${encodeURIComponent(id)}/preview${local ? '?local=true' : ''}`; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e7dd7dfe..8238e706 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -177,9 +177,12 @@ export interface Profile { id: string; name: string; kind: ProfileKind; + language?: string; language_code?: string; ref_audio?: string; ref_text?: string; + instruct?: string; + vd_states?: string | null; description?: string; created_at?: string; is_locked?: boolean; diff --git a/frontend/src/components/gallery/ArchetypeCard.jsx b/frontend/src/components/gallery/ArchetypeCard.jsx index 26eb38c9..7634e32a 100644 --- a/frontend/src/components/gallery/ArchetypeCard.jsx +++ b/frontend/src/components/gallery/ArchetypeCard.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Play, Loader, Star, Wand2, UserPlus } from 'lucide-react'; +import { BookOpen, Ellipsis, Headphones, Loader, Play, Star, UserPlus, Wand2 } from 'lucide-react'; +import { Menu } from '../../ui'; import { ArchetypeAvatar, AccentFlag, @@ -19,6 +20,12 @@ export default function ArchetypeCard({ onUse, onDesign, onToggleFavorite, + onUseInStories, + onUseAsAudiobookDefault, + favoriteId = a.id, + previewLocked = false, + isMaterializing = false, + materializationLocked = false, }) { const color = USE_CASE_COLOR[a.use_case] || '#83a598'; const sub = [a.facets.gender, a.facets.age, a.facets.pitch] @@ -44,7 +51,11 @@ export default function ArchetypeCard({ : ''; return ( -
+
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
@@ -65,7 +76,7 @@ export default function ArchetypeCard({ ? 'text-[#fabd2f]' : 'text-[var(--color-fg-subtle)] opacity-70 group-hover:opacity-100 hover:text-[#fabd2f]' }`} - onClick={() => onToggleFavorite(a.id)} + onClick={() => onToggleFavorite(favoriteId)} title={t('gallery.favorite', { defaultValue: 'Favorite' })} aria-label={t('gallery.favorite', { defaultValue: 'Favorite' })} aria-pressed={isFavorite} @@ -98,8 +109,10 @@ export default function ArchetypeCard({
- + {onDesign ? ( + + ) : null} + {onUseInStories || onUseAsAudiobookDefault ? ( + onUseInStories(a), + } + : null, + onUseAsAudiobookDefault + ? { + id: 'audiobook', + icon: Headphones, + label: t('gallery.set_audiobook_default', { + defaultValue: 'Set as Audiobook default', + }), + onSelect: () => onUseAsAudiobookDefault(a), + } + : null, + ].filter(Boolean)} + > + + + ) : null}
); diff --git a/frontend/src/components/gallery/ArchetypesZone.jsx b/frontend/src/components/gallery/ArchetypesZone.jsx index 6408f75e..648f434d 100644 --- a/frontend/src/components/gallery/ArchetypesZone.jsx +++ b/frontend/src/components/gallery/ArchetypesZone.jsx @@ -59,6 +59,9 @@ export default function ArchetypesZone({ onPreview, onUse, onDesign, + onUseInStories, + onUseAsAudiobookDefault, + materializingId, }) { const [favOnly, setFavOnly] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false); @@ -111,10 +114,15 @@ export default function ArchetypesZone({ isFavorite: favSet.has(a.id), isPlaying: playingId === a.id, isLoadingPreview: loadingPreviewId === a.id, + previewLocked: Boolean(loadingPreviewId), onPreview, onUse, onDesign, + onUseInStories, + onUseAsAudiobookDefault, onToggleFavorite: toggleFavorite, + isMaterializing: materializingId === a.id, + materializationLocked: Boolean(materializingId), }); const facetToggle = @@ -125,7 +133,9 @@ export default function ArchetypesZone({ : 'flex flex-col gap-[6px]'; return ( -
+ // data-testid: stable e2e hook — locale-independent, unlike the translated + // aria-labels/headings inside (see e2e/gallery.spec.ts). +