Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2fdacd33 | ||
|
|
8750b91522 | ||
|
|
7705343386 | ||
|
|
94093605eb | ||
|
|
7e03b84d3b | ||
|
|
b0c692d26c | ||
|
|
662339b50a | ||
|
|
0421be966e |
@@ -718,6 +718,34 @@ jobs:
|
||||
files: ${{ steps.checksums.outputs.checksums_file }}
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
# ── Uninstall scripts as release assets (#1089) ───────────────────────────
|
||||
# The in-app uninstaller (Settings → Storage → Remove all data) is the primary
|
||||
# path, but a user who wants to clean up WITHOUT launching the app — or after
|
||||
# already deleting it — has no repo to run scripts/uninstall.sh from. Ship the
|
||||
# two scripts alongside the installers so they're one download away.
|
||||
#
|
||||
# MUST use `gh release upload` (attach to the EXISTING release), NOT
|
||||
# softprops/action-gh-release: a second softprops publish races tauri-action's
|
||||
# per-matrix draft and splits the platform installers across two releases for
|
||||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||||
# idempotent. This can never create a second release.
|
||||
uninstall-scripts:
|
||||
needs: [build]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Attach uninstall scripts to the existing release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
scripts/uninstall.sh scripts/uninstall.ps1 \
|
||||
--clobber --repo "${{ github.repository }}"
|
||||
|
||||
# ── Auto-generated preview release notes ──────────────────────────────────
|
||||
# tauri-action publishes the rolling `preview` release with the plain
|
||||
# changelog-fallback body ("Auto-generated release for main…"). Replace it
|
||||
|
||||
@@ -6,6 +6,40 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [0.3.21] — 2026-07-12
|
||||
|
||||
The memory release. The reason the app kept saying "Can't reach the local backend" on 16 GB machines was never really the network — the backend was quietly running out of memory and getting killed. This release fixes that at the source: the models it holds now get out of each other's way. Plus the uninstaller and factory reset grew into a proper Settings → Storage pair.
|
||||
|
||||
### Added
|
||||
|
||||
- **Factory reset grew up: Settings → Storage → "Reset & remove".** It used to do exactly one thing — clear your UI preferences — while the only other option was deleting everything and starting over. Between "forget my theme" and "wipe the machine" sat every reset people actually needed. Now there are four one-click tiers — **UI preferences**, **all settings**, **downloaded assets & models**, and **everything OmniVoice did** — plus a per-item checklist if you want to drop just the model weights, just a wedged sidecar engine, or just the history. Every option shows its **real size on disk before you commit**, and the number on the button is exactly what gets freed. Deleting voices, projects or audio asks you to type `DELETE`; nothing irreversible happens on a single click. "Everything" deliberately stops short of the Python environment, so you land on a working first-run screen rather than a rebuild — the app stops its engine, deletes, and starts it again for you. On macOS and Linux the model cache is the **shared** Hugging Face cache, so it's its own checkbox and says so; on Windows and portable installs it's OmniVoice's own, and the app doesn't pretend otherwise.
|
||||
|
||||
- **The Storage panels got a design.** "Remove all data" and "Reset & remove" listed folders as a flat run of text, so a 7.5 GB model cache and a 391-byte config file carried exactly the same visual weight — the one thing you actually wanted to see (where the space went) was the one thing you couldn't. Every row now has an icon, a dimmed path, and a **proportional bar showing its share of what will be freed**, so the big one looks big. The shared Hugging Face cache is promoted out of the confirm dialog into its own "Optional" row with a checkbox, so ticking it moves the running total **in front of you** instead of springing a different number on you at the point of no return, and the dialog now lists exactly what is about to go.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Switching TTS engines no longer stacks their models in memory.** Using a second engine in a session (or a per-request engine override) loaded its model *on top of* the first one's, because the OmniVoice core model and the other engines live in two separate caches that never coordinated — measured on a 16 GB M2, an `omnivoice` → `mlx-audio` switch left the machine holding both (footprint 3.9 GB → 4.3 GB, the ~2.8 GB core never freed). That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. Now only one TTS engine's model stays resident: resolving an engine hands back every *other* resident engine first (the same `omnivoice → mlx-audio` switch now drops to ~1.5 GB). Steady-state single-engine use is unaffected; an A/B switch pays a re-load on the way back (~8 s for the OmniVoice core, ~1–2 s for the lighter engines). Opt out with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0` if you have RAM to keep several warm. Two underlying leaks are fixed as part of this: every in-process TTS engine's `unload()` now actually frees its model and empties the device cache (previously all but OmniVoice were silent no-ops), and `faster-whisper`'s `unload()` cleared the wrong attribute so its model was never released.
|
||||
|
||||
- **The backend no longer sits on ~2 GB of idle dictation model — the real reason it was being killed on 16 GB Macs.** Four reports of *"Can't reach the local OmniVoice backend"* (#1076, #1092, #1093, #1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it showed the generate was never the problem — it costs about 116 MB. The problem was the **baseline**: the backend sat at **~6.2 GB even while idle**. The TTS model has always been unloaded after an idle timeout, but the speech-recognition model used for dictation never was — so once you dictated a single time, ~2 GB stayed resident for as long as the app ran. On a 16 GB Mac, that plus the app, macOS, and your other programs is enough for the system to run out of memory and kill the backend, which surfaced as the "can't reach the backend" error. Dictation's model now gets the same idle release the TTS model already had, handing that memory back. The only cost is a ~1.4-second re-warm on your next dictation after a long pause, and a live dictation session is pinned so nothing is ever unloaded mid-sentence.
|
||||
|
||||
- **Folder sizes under 1 KB displayed as "0 KB".** The uninstall panel's `391 B` config folder rendered as `0 KB` — which reads as "nothing here" for a folder that very much exists. The Storage panels now share one byte formatter that can say `391 B`.
|
||||
|
||||
- **Some styling silently did nothing.** A handful of components referenced CSS custom properties that were never defined (`--chrome-fg-subtle`, `--chrome-bg-raised`, `--color-warning`). An undefined `var()` makes the whole declaration invalid, so the browser drops it and the element quietly inherits — the dimmed folder paths in the Storage panels weren't dimmed at all. Fixed in those panels, and a new guard (`frontend/src/test/cssTokens.test.js`) fails on any bare `var(--token)` in JSX that isn't defined in a stylesheet or documented as runtime-injected, so a typo can't ship as invisible styling again.
|
||||
|
||||
## [0.3.20] — 2026-07-12
|
||||
|
||||
The follow-through release. v0.3.19 promised that "Can't reach the local OmniVoice backend" would stop firing while the backend was merely restarting — and then a user hit it anyway, on 0.3.19, because the fix had a race in it. That's closed properly here. Uninstalling also stopped being a thing only maintainers could do: it's now a button in the app, where the person who asked for it can actually reach it.
|
||||
|
||||
### Added
|
||||
|
||||
- **Uninstall is now in the app: Settings → Storage → "Remove all data".** The v0.3.19 uninstaller was a *script* — which never reached the people who needed it, since anyone who installed the .dmg / .msi / AppImage has no repo to run it from (exactly the case in #1089). The app now lists every folder this install owns with its real size, deletes them behind a typed confirmation, and quits. The **downloaded model weights are a separate, opt-in checkbox**, because that's the standard Hugging Face cache shared with other AI tools on your machine — removing it can delete models OmniVoice never downloaded. Custom and portable install locations are honored, and nothing outside OmniVoice's own folders can be touched. The scripts now also ship as **release assets**, so you can clean up without launching the app at all. (#1089)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **"Can't reach the local OmniVoice backend" could still fire on 0.3.19 — the fix had a hole.** The app asks the desktop shell whether a start/restart is in progress before showing that error, but the shell learns of a dead backend from a **2-second poll**: when the backend dies mid-generation, the supervisor needs a moment to notice it, record the crash, and flip its state to "restarting". The app was asking **once**, ~3 seconds in — often still hearing "everything's fine" — and dead-ending on the generic toast anyway. A failed connection *contradicts* "everything's fine", so that answer is now treated as stale rather than authoritative: the app keeps retrying briefly, letting the shell catch up, which turns the failure into the "backend is restarting — hang tight" banner (and gives the crash report time to be written, so you get the real cause instead of a guess). A shell that has genuinely given up, or no shell at all, still errors immediately. (#1101)
|
||||
|
||||
- **The uninstaller was leaving the backend's log folder behind on Linux and Windows.** It cleaned the app-data, config, and Python-env folders but missed where the backend actually writes `backend.log` / `backend_err.log` — `~/.local/state/OmniVoice` on Linux and `%LOCALAPPDATA%\OmniVoice\Logs` on Windows. Both the scripts and the documented path lists now cover them. (#1089)
|
||||
|
||||
## [0.3.19] — 2026-07-12
|
||||
|
||||
The honesty release. Every error in here was already *technically* true and practically useless — so this round went after the lies the app tells when something goes wrong. "Can't reach the local OmniVoice backend" no longer fires while the backend is simply still starting; a dead Hugging Face mirror no longer strands the setup wizard with advice it can't follow; and a dub that dies mid-transcription now names the actual cause instead of guessing at it. Alongside that: generated speech starts playing on the *first* chunk instead of the last, and there's finally a real uninstaller.
|
||||
|
||||
@@ -158,13 +158,20 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
# legacy Whisper/WebM path, byte-for-byte unchanged.
|
||||
spec = _select_sherpa_spec(websocket)
|
||||
if spec is not None:
|
||||
from services.asr_backend import SherpaDictationBackend
|
||||
from services.asr_backend import SherpaDictationBackend, capture_lease
|
||||
ok, _reason = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
if spec.streaming:
|
||||
await _run_sherpa_streaming(websocket, spec)
|
||||
else:
|
||||
await _run_sherpa_offline(websocket, spec)
|
||||
# A live session holds the shared capture backend for its whole
|
||||
# lifetime without ever re-resolving it, so the idle reaper
|
||||
# (#1101 class) must not unload the model out from under it — even
|
||||
# if the user leaves the mic open, silent, past the idle timeout.
|
||||
# The lease pins it for exactly this window and restarts the idle
|
||||
# clock on the way out.
|
||||
with capture_lease():
|
||||
if spec.streaming:
|
||||
await _run_sherpa_streaming(websocket, spec)
|
||||
else:
|
||||
await _run_sherpa_offline(websocket, spec)
|
||||
return
|
||||
# sherpa not installed → fall through to the legacy path so the user
|
||||
# still gets dictation (just not live partials).
|
||||
|
||||
@@ -783,6 +783,15 @@ async def generate_speech(
|
||||
),
|
||||
)
|
||||
|
||||
# Single-active-engine memory discipline: hand back any OTHER resident TTS
|
||||
# engine's model before loading this one, so switching engines (or a
|
||||
# per-request engine= override, which bypasses /engines/select entirely)
|
||||
# doesn't stack two multi-GB models in memory — the accumulation behind the
|
||||
# 16 GB-Mac OOM deaths. No-op when nothing else is resident, so steady-state
|
||||
# single-engine use pays nothing. Opt out: OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
|
||||
from services.engine_memory import evict_other_tts_engines
|
||||
await evict_other_tts_engines(engine_id)
|
||||
|
||||
_model = None
|
||||
_backend = None
|
||||
if backend_cls is OmniVoiceBackend:
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.3.19"
|
||||
_FALLBACK_VERSION = "0.3.21"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -27,7 +27,9 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import contextlib
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
@@ -990,7 +992,12 @@ class FasterWhisperBackend(ASRBackend):
|
||||
return out
|
||||
|
||||
def unload(self) -> None:
|
||||
self._asr = None
|
||||
# #memory: this cleared self._asr — an attribute FasterWhisperBackend
|
||||
# never assigns — so the actual model in self._model was never freed and
|
||||
# a warm faster-whisper stayed resident for the life of the process.
|
||||
# Clear the real handle so the model is released.
|
||||
self._model = None
|
||||
self._asr = None # harmless if a subclass ever used it; keeps idempotence
|
||||
import gc
|
||||
gc.collect()
|
||||
try:
|
||||
@@ -2238,6 +2245,74 @@ _capture_backend_key: str | None = None
|
||||
# check-then-build must be atomic to avoid two threads each building a model.
|
||||
_capture_backend_lock = threading.Lock()
|
||||
|
||||
# ── Idle release of the warm capture/dictation ASR (#1101 class) ────────────
|
||||
#
|
||||
# The TTS model has always been idle-unloaded (model_manager.idle_worker), but
|
||||
# the capture ASR singleton above was not: once you dictated even once, its
|
||||
# model stayed resident for the life of the process. Measured on a 16 GB M2:
|
||||
# the backend sits at ~6.2 GB idle — TTS 3.8 GB plus ~2 GB of warm ASR — while
|
||||
# an actual generate costs only ~116 MB on top. That baseline, not any spike, is
|
||||
# what pushes a 16 GB machine into memory pressure until the OS kills the
|
||||
# backend mid-generate — the death behind #1076/#1092/#1093/#1101. Freeing
|
||||
# 3.8 GB of TTS while silently holding 2 GB of ASR forever was the asymmetry.
|
||||
#
|
||||
# Reclaiming it costs a model re-warm on the next dictation (~1.4 s for
|
||||
# mlx-whisper turbo) and only after a full idle timeout — the same bargain the
|
||||
# TTS model already makes.
|
||||
_capture_last_used: float = 0.0
|
||||
# Live dictation streams hold the singleton for the WHOLE session while calling
|
||||
# nothing that would refresh `_capture_last_used`, so a long session could have
|
||||
# its model unloaded mid-sentence. A lease pins it for exactly that window.
|
||||
_capture_leases: int = 0
|
||||
|
||||
|
||||
def _touch_capture() -> None:
|
||||
"""Mark the capture backend as used now (resets its idle clock)."""
|
||||
global _capture_last_used
|
||||
_capture_last_used = time.monotonic()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def capture_lease():
|
||||
"""Pin the warm capture backend for the duration of a live session, so the
|
||||
idle reaper can never unload the model out from under an open dictation
|
||||
stream. Releasing the lease restarts the idle clock."""
|
||||
global _capture_leases
|
||||
with _capture_backend_lock:
|
||||
_capture_leases += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _capture_backend_lock:
|
||||
_capture_leases = max(0, _capture_leases - 1)
|
||||
_touch_capture()
|
||||
|
||||
|
||||
def release_idle_capture_backend(idle_s: float, *, now: float | None = None) -> bool:
|
||||
"""Unload the warm capture/dictation ASR once it has gone unused for
|
||||
``idle_s`` seconds. Returns True when a model was actually released.
|
||||
|
||||
No-ops while a live session holds a lease, when nothing is loaded, or when
|
||||
the model was used recently. Never raises — a failed unload must not take
|
||||
the idle worker down with it."""
|
||||
global _capture_backend, _capture_backend_key
|
||||
now = time.monotonic() if now is None else now
|
||||
with _capture_backend_lock:
|
||||
if _capture_backend is None or _capture_leases > 0:
|
||||
return False
|
||||
if now - _capture_last_used < idle_s:
|
||||
return False
|
||||
backend, _capture_backend, _capture_backend_key = _capture_backend, None, None
|
||||
try:
|
||||
backend.unload()
|
||||
except Exception: # noqa: BLE001 — a stuck unload must not kill idle_worker
|
||||
logger.warning("capture ASR unload failed", exc_info=True)
|
||||
logger.info(
|
||||
"Idle timeout reached. Unloading capture ASR (%s) to free memory.",
|
||||
type(backend).__name__,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
|
||||
"""Return a shared, warm-cached :class:`SherpaDictationBackend` for
|
||||
@@ -2252,6 +2327,7 @@ def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
|
||||
:func:`get_capture_asr_backend`. Thread-safe: the recognizer is shared;
|
||||
each session creates its own decode stream (see capture_ws)."""
|
||||
global _capture_backend, _capture_backend_key
|
||||
_touch_capture() # any handout resets the idle clock
|
||||
with _capture_backend_lock:
|
||||
if (isinstance(_capture_backend, SherpaDictationBackend)
|
||||
and _capture_backend_key == model_id):
|
||||
@@ -2299,6 +2375,7 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
"""
|
||||
global _capture_backend, _capture_backend_key
|
||||
|
||||
_touch_capture() # any handout resets the idle clock (#1101 class)
|
||||
# Atomic resolve+build so the preload thread and a WS session (which may
|
||||
# call get_sherpa_dictation_backend concurrently) can't both build a model.
|
||||
with _capture_backend_lock:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Single-active-TTS-engine memory discipline.
|
||||
|
||||
Only one TTS engine's model stays resident at a time. When the generate path
|
||||
resolves an engine, every *other* resident engine is unloaded first — so the
|
||||
previous engine's model is handed back instead of stacking in memory until GC.
|
||||
|
||||
Why this matters (measured on a 16 GB M2): a generate on ``omnivoice`` leaves
|
||||
its ~2.8 GB core model resident; a subsequent generate on ``mlx-audio`` loaded
|
||||
that engine's model **on top** (footprint 3.9 GB → 4.3 GB, both resident),
|
||||
because the two live in different caches with no coordination — the core in
|
||||
``model_manager.model``, the rest in ``engines._ENGINE_INSTANCES`` (which was
|
||||
never unloaded). That accumulation is the baseline that pushes a 16 GB machine
|
||||
into the memory pressure behind the "Can't reach the local backend" OOM deaths.
|
||||
|
||||
Default on. Opt out with ``OMNIVOICE_SINGLE_ENGINE_RESIDENT=0`` on machines with
|
||||
RAM to spare (keeping several engines warm avoids the reload latency on an A/B
|
||||
switch — ~8 s for the OmniVoice core, ~1–2 s for the lighter engines).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("omnivoice.engine_memory")
|
||||
|
||||
_OFF = {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def single_engine_resident() -> bool:
|
||||
"""Whether the one-engine-at-a-time policy is active (default True)."""
|
||||
return (os.environ.get("OMNIVOICE_SINGLE_ENGINE_RESIDENT", "1").strip().lower()
|
||||
not in _OFF)
|
||||
|
||||
|
||||
def _evict_instance_cache(keep_cls) -> list[str]:
|
||||
"""Unload + drop every cached engine instance except ``keep_cls``.
|
||||
|
||||
Operates on the per-request instance cache the generate path shares with the
|
||||
engine health route (``engines._ENGINE_INSTANCES``). Each engine's
|
||||
``unload()`` frees its heavy model (the ABC default clears ``_MODEL_ATTRS``
|
||||
and empties the device cache; subprocess engines reap their sidecar). Never
|
||||
raises — a stuck unload must not block the generation that triggered it."""
|
||||
evicted: list[str] = []
|
||||
try:
|
||||
from api.routers.engines import _ENGINE_INSTANCES
|
||||
except Exception: # pragma: no cover — router import should always succeed
|
||||
return evicted
|
||||
for cls, inst in list(_ENGINE_INSTANCES.items()):
|
||||
if cls is keep_cls:
|
||||
continue
|
||||
try:
|
||||
inst.unload()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("evict: %s.unload() failed", getattr(cls, "id", cls.__name__),
|
||||
exc_info=True)
|
||||
_ENGINE_INSTANCES.pop(cls, None)
|
||||
evicted.append(getattr(cls, "id", cls.__name__))
|
||||
return evicted
|
||||
|
||||
|
||||
async def evict_other_tts_engines(keep_id: str) -> list[str]:
|
||||
"""Unload every resident TTS engine except ``keep_id`` and return their ids.
|
||||
|
||||
Spans both stores a TTS model can live in: the OmniVoice core singleton
|
||||
(``model_manager.model``, freed under its async lock when we're switching
|
||||
*away* from it) and the generic engine instance cache. A no-op when the
|
||||
policy is off or nothing else is resident, so steady-state single-engine use
|
||||
pays nothing — only an actual switch evicts. Never raises."""
|
||||
if not single_engine_resident():
|
||||
return []
|
||||
|
||||
evicted: list[str] = []
|
||||
|
||||
# The OmniVoice core singleton — only when the incoming engine isn't it.
|
||||
if keep_id != "omnivoice":
|
||||
try:
|
||||
import services.model_manager as mm
|
||||
|
||||
async with mm._model_lock:
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
mm.free_vram()
|
||||
evicted.append("omnivoice")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("evict: OmniVoice core unload failed", exc_info=True)
|
||||
|
||||
# Every other in-process / sidecar engine instance.
|
||||
keep_cls = None
|
||||
try:
|
||||
from services.tts_backend import get_backend_class
|
||||
|
||||
keep_cls = get_backend_class(keep_id)
|
||||
except Exception: # noqa: BLE001 — unknown id → evict all cached instances
|
||||
keep_cls = None
|
||||
evicted.extend(_evict_instance_cache(keep_cls))
|
||||
|
||||
if evicted:
|
||||
logger.info("single-engine eviction: freed %s (keeping %s)", evicted, keep_id)
|
||||
return evicted
|
||||
@@ -1273,11 +1273,27 @@ async def idle_worker():
|
||||
torch = _lazy_torch()
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
idle_timeout = _resolve_idle_timeout()
|
||||
async with _model_lock:
|
||||
if model is not None and time.time() - _last_used > _resolve_idle_timeout():
|
||||
if model is not None and time.time() - _last_used > idle_timeout:
|
||||
logger.info("Idle timeout reached. Unloading OmniVoice model to free VRAM.")
|
||||
model = None
|
||||
free_vram()
|
||||
# The capture/dictation ASR was never idle-released — so once a user
|
||||
# dictated, its model stayed resident for the life of the process while
|
||||
# the TTS model dutifully freed its 3.8 GB. On a 16 GB Mac that left the
|
||||
# backend sitting at ~6.2 GB idle, which is what tipped it into the
|
||||
# memory pressure that gets it killed mid-generate (#1076/#1092/#1093/
|
||||
# #1101). Give it the same bargain the TTS model already makes. Held
|
||||
# off while a live dictation stream has a lease, so nothing is unloaded
|
||||
# mid-sentence.
|
||||
try:
|
||||
from services.asr_backend import release_idle_capture_backend
|
||||
|
||||
if release_idle_capture_backend(idle_timeout):
|
||||
free_vram()
|
||||
except Exception: # noqa: BLE001 — the reaper must never kill idle_worker
|
||||
logger.warning("idle capture-ASR release failed", exc_info=True)
|
||||
|
||||
def free_vram():
|
||||
"""Release cached GPU memory on any accelerator (CUDA, MPS, XPU)."""
|
||||
|
||||
@@ -249,13 +249,37 @@ class TTSBackend(ABC):
|
||||
# `torch.cuda.empty_cache()` / `torch.mps.empty_cache()`).
|
||||
# • Safe to call before the first generate(): a backend that never
|
||||
# loaded has nothing to release.
|
||||
def unload(self) -> None:
|
||||
"""Release any GPU memory and file handles held by this backend.
|
||||
# Attribute(s) that hold this backend's heavy model, cleared by the default
|
||||
# unload(). Every in-process engine loads its weights lazily into one of
|
||||
# these in `_ensure_loaded()`; the next generate() re-runs that loader. An
|
||||
# engine that holds its model elsewhere (or nowhere — e.g. an external HTTP
|
||||
# server) overrides `unload()` or leaves these unset. OmniVoice overrides
|
||||
# entirely (it drives the shared model_manager singleton).
|
||||
_MODEL_ATTRS: tuple[str, ...] = ("_model", "_tts")
|
||||
|
||||
Called by the registry on engine switch and on app shutdown. Default
|
||||
is a no-op so engines that haven't migrated keep working; per-engine
|
||||
overrides arrive in Phase 2 (see ROADMAP.md). Must be idempotent.
|
||||
def unload(self) -> None:
|
||||
"""Release the heavy model this backend holds, and free device caches.
|
||||
|
||||
Called by the registry on engine switch, by the single-active-engine
|
||||
eviction (services.engine_memory), and on app shutdown. Clears each of
|
||||
``_MODEL_ATTRS`` that is set on this instance, then empties the device
|
||||
cache — so switching engines actually hands the memory back instead of
|
||||
leaving the old model resident until GC (the 16 GB-Mac OOM class). The
|
||||
next generate() lazily reloads. Idempotent and safe before first load:
|
||||
a backend that never loaded has every attr already None/absent.
|
||||
"""
|
||||
freed = False
|
||||
for attr in self._MODEL_ATTRS:
|
||||
if getattr(self, attr, None) is not None:
|
||||
setattr(self, attr, None)
|
||||
freed = True
|
||||
if freed:
|
||||
try:
|
||||
from services.model_manager import free_vram
|
||||
|
||||
free_vram()
|
||||
except Exception: # noqa: BLE001 — unload must never raise (idempotent contract)
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,52 @@ and ships a script that finds and removes them for you (with a dry-run first).
|
||||
> cache** (the Hugging Face weights — several GB) and the **managed Python
|
||||
> environment** (`project/.venv` — a few GB). Everything else is small.
|
||||
|
||||
## The one-command uninstaller (recommended)
|
||||
## In the app (easiest — no repo needed)
|
||||
|
||||
**Settings → Storage → Remove all data.** It lists every folder this install
|
||||
owns with its real size, lets you opt in (separately) to the shared Hugging Face
|
||||
model cache, asks you to type `DELETE`, then removes everything and quits.
|
||||
|
||||
This is the right path if you installed the **.dmg / .msi / AppImage** — you
|
||||
don't have the repo, so the script below isn't available to you.
|
||||
|
||||
> **You may not need to uninstall.** Right above it, **Reset & remove** does the
|
||||
> same job at any scale you like — and leaves you with a working app instead of
|
||||
> no app. See [Resetting](#resetting-instead-of-uninstalling) below.
|
||||
|
||||
## Resetting instead of uninstalling
|
||||
|
||||
**Settings → Storage → Reset & remove** puts part — or all — of OmniVoice back to
|
||||
how it shipped, without removing the app. Every option shows its real size before
|
||||
you commit, and the app restarts itself when it's done.
|
||||
|
||||
| Option | What it removes | What it keeps |
|
||||
| --- | --- | --- |
|
||||
| **UI preferences only** | Theme, layout, language, dub settings | Everything on disk |
|
||||
| **All settings** | The above, plus saved settings on disk (engine choices, voice defaults) | Voices, projects, audio, models |
|
||||
| **Downloaded assets & models** | Model weights, sidecar engines, audio tools, caches | Everything you made |
|
||||
| **Everything OmniVoice did** | All of the above, plus voices, projects, generated audio, history, logs | The app itself, and the Python environment it runs on |
|
||||
|
||||
"Choose exactly what to remove" opens the same list as individual checkboxes, so
|
||||
you can drop just the model weights, just a wedged sidecar engine, or just the
|
||||
history — whatever is actually wrong.
|
||||
|
||||
Two things it deliberately does **not** touch:
|
||||
|
||||
- **Your storage locations.** If you pointed OmniVoice at a custom data or model
|
||||
directory, a settings reset keeps that pointer. Clearing it would strand
|
||||
gigabytes of already-downloaded weights at a path the app no longer looks in.
|
||||
- **The managed Python environment.** "Everything OmniVoice did" still leaves you
|
||||
with a working app that restarts on the first-run screen. If you want the
|
||||
interpreter gone too, that's **Remove all data** — the section above.
|
||||
|
||||
The shared Hugging Face model cache is called out separately wherever it applies:
|
||||
on macOS and Linux it's the standard cache other AI tools use too, so removing it
|
||||
may delete models OmniVoice never downloaded. (On Windows, and in portable
|
||||
installs, the cache is OmniVoice's own — there's nothing to share, and the app
|
||||
says so.)
|
||||
|
||||
## The one-command uninstaller (from a clone)
|
||||
|
||||
From a clone or the source tarball:
|
||||
|
||||
@@ -60,7 +105,8 @@ Four kinds of data, in up to four locations:
|
||||
|
||||
```
|
||||
~/.omnivoice/ ← app data (voices, projects, omnivoice.db, outputs, omnivoice.log)
|
||||
~/.local/share/com.debpalash.omnivoice-studio/ ← config.json, logs, AND the managed Python env (project/.venv)
|
||||
~/.local/share/com.debpalash.omnivoice-studio/ ← config.json, shell logs, AND the managed Python env (project/.venv)
|
||||
~/.local/state/OmniVoice/ ← backend logs (backend.log, backend_err.log)
|
||||
~/.cache/huggingface/ ← model weights (shared HF cache — see caveat)
|
||||
```
|
||||
|
||||
@@ -68,7 +114,8 @@ Four kinds of data, in up to four locations:
|
||||
|
||||
```
|
||||
%APPDATA%\OmniVoice\ ← app data (voices, projects, omnivoice.db, outputs, omnivoice.log)
|
||||
%LOCALAPPDATA%\com.debpalash.omnivoice-studio\ ← config.json, logs, AND the managed Python env (project\.venv)
|
||||
%LOCALAPPDATA%\com.debpalash.omnivoice-studio\ ← config.json, shell logs, AND the managed Python env (project\.venv)
|
||||
%LOCALAPPDATA%\OmniVoice\Logs\ ← backend logs (backend.log, backend_err.log)
|
||||
%LOCALAPPDATA%\OmniVoice\hf_cache\ ← model weights (OmniVoice uses a short path here to dodge MAX_PATH)
|
||||
```
|
||||
|
||||
@@ -98,7 +145,7 @@ model paths don't hit the 260-character `MAX_PATH` limit.
|
||||
|
||||
## Remove the app itself
|
||||
|
||||
The script above clears the **data**; removing the installed **app** is the
|
||||
The steps above clear the **data**; removing the installed **app** is the
|
||||
normal per-platform step:
|
||||
|
||||
- **macOS:** drag **OmniVoice Studio.app** from `/Applications` to the Trash.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.19",
|
||||
"version": "0.3.21",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+2
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.19"
|
||||
version = "0.3.21"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
@@ -2965,6 +2965,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"walkdir",
|
||||
"webkit2gtk",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.19"
|
||||
version = "0.3.21"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -84,3 +84,8 @@ libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# Scoped-reset tests build real directory trees to prove the delete guard only
|
||||
# ever removes paths inside a validated OmniVoice root.
|
||||
tempfile = "3"
|
||||
|
||||
@@ -132,13 +132,26 @@ pub fn get_bootstrap_logs(state: tauri::State<'_, BootstrapState>) -> Vec<LogPay
|
||||
|
||||
#[tauri::command]
|
||||
pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapState>) {
|
||||
if let Ok(mut guard) = state.stage.lock() {
|
||||
respawn_backend(app, state.stage.clone(), state.logs.clone());
|
||||
}
|
||||
|
||||
/// Take the port back and bring a healthy backend up on it, from scratch if
|
||||
/// need be. Shared by the Retry button and by a scoped reset (`reset.rs`), which
|
||||
/// deletes data out from under a stopped backend and needs the *same* recovery
|
||||
/// afterwards — a fresh process that re-runs `ensure_dirs()` and alembic, so a
|
||||
/// wiped database comes back empty rather than missing.
|
||||
pub fn respawn_backend(
|
||||
app: tauri::AppHandle,
|
||||
stage: Arc<Mutex<BootstrapStage>>,
|
||||
logs: Arc<Mutex<Vec<LogPayload>>>,
|
||||
) {
|
||||
if let Ok(mut guard) = stage.lock() {
|
||||
*guard = BootstrapStage::Checking;
|
||||
}
|
||||
if let Ok(mut logs) = state.logs.lock() {
|
||||
if let Ok(mut logs) = logs.lock() {
|
||||
logs.clear();
|
||||
}
|
||||
let stage_handle = state.stage.clone();
|
||||
let stage_handle = stage;
|
||||
std::thread::spawn(move || {
|
||||
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
|
||||
if skip_spawn {
|
||||
|
||||
@@ -14,6 +14,8 @@ pub mod tools;
|
||||
pub mod backend;
|
||||
pub mod commands;
|
||||
pub mod crash;
|
||||
pub mod reset;
|
||||
pub mod uninstall;
|
||||
pub mod updater_channel;
|
||||
|
||||
use std::process::Child;
|
||||
@@ -400,6 +402,10 @@ pub fn run() {
|
||||
commands::clear_webview_cache_and_relaunch,
|
||||
crash::get_last_backend_crash,
|
||||
crash::acknowledge_backend_crash,
|
||||
uninstall::uninstall_scan,
|
||||
uninstall::uninstall_purge,
|
||||
reset::reset_scan,
|
||||
reset::reset_purge,
|
||||
])
|
||||
.setup(move |app| {
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Scoped reset — Settings → Storage → "Reset & remove".
|
||||
//!
|
||||
//! Factory reset used to mean one thing: clear `localStorage`. That is the
|
||||
//! *smallest* useful reset and it was the only one, so a user whose install had
|
||||
//! gone wrong in any deeper way (a half-downloaded model, a wedged sidecar
|
||||
//! engine, settings they could no longer find) had exactly two options — live
|
||||
//! with it, or delete everything and start over. This module fills the gap with
|
||||
//! a scope registry: every distinct thing OmniVoice writes to disk, sized, and
|
||||
//! individually removable.
|
||||
//!
|
||||
//! **Why the shell and not the backend.** Two reasons the backend cannot do
|
||||
//! this to itself:
|
||||
//! 1. A loaded model memory-maps its weights straight out of the HF cache. On
|
||||
//! Windows those files are locked while mapped, so "delete the models" from
|
||||
//! inside the process that mapped them simply fails.
|
||||
//! 2. `ensure_dirs()` runs at *import* time (backend/core/config.py). Delete
|
||||
//! `voices/` or `outputs/` under a live backend and nothing recreates them;
|
||||
//! every subsequent write lands in a missing directory.
|
||||
//! Plus the in-memory strays: `_dub_jobs`, batch `_jobs`, the media-tools
|
||||
//! version cache, the 5-minute storage-report cache — all of them would keep
|
||||
//! pointing at paths that no longer exist.
|
||||
//!
|
||||
//! So the shell stops the backend, deletes, and starts it again. That restart is
|
||||
//! also what *repairs* the wipe: the fresh process re-runs `ensure_dirs()` and
|
||||
//! alembic, so a removed database comes back empty rather than missing. This is
|
||||
//! the same reason `uninstall.rs` lives here — but uninstall quits afterwards,
|
||||
//! and a reset must leave the user with a working app.
|
||||
//!
|
||||
//! Safety: every target is resolved from the same single source of truth the
|
||||
//! rest of the app uses (`setup::{resolved,default}_{data,models}_dir`,
|
||||
//! `backend::backend_log_path`), and nothing is removed unless it sits inside a
|
||||
//! *validated* root — one that either carries an OmniVoice-owned path component
|
||||
//! or holds an actual OmniVoice signature file. A custom data dir on an external
|
||||
//! volume passes on the signature; a mis-set `data_dir: "/"` passes on neither.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::bootstrap::BootstrapState;
|
||||
use crate::{backend_port, AppFlags};
|
||||
|
||||
/// Every scope the UI can offer. Two of them (`ui_prefs`, `history`) own no
|
||||
/// files — they are listed here so the frontend has one registry to render, but
|
||||
/// they are cleared frontend-side (localStorage / the history DELETE endpoints)
|
||||
/// and never reach `reset_purge`.
|
||||
pub const FRONTEND_SCOPES: [&str; 2] = ["ui_prefs", "history"];
|
||||
|
||||
/// Scopes that delete files, in the order they are removed.
|
||||
pub const DISK_SCOPES: [&str; 7] = [
|
||||
"settings", "content", "engines", "tools", "models", "caches", "logs",
|
||||
];
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct ResetScope {
|
||||
/// Stable id the UI keys off.
|
||||
pub key: String,
|
||||
/// Concrete paths this scope would remove (empty for frontend-only scopes).
|
||||
pub paths: Vec<String>,
|
||||
pub size_bytes: u64,
|
||||
pub exists: bool,
|
||||
/// True only for the Hugging Face cache when it lives OUTSIDE our own tree —
|
||||
/// i.e. the standard `~/.cache/huggingface` other ML tools share. On Windows
|
||||
/// (and in a portable install) the cache is app-private, so this is false and
|
||||
/// the UI shows no scary caveat it doesn't need to.
|
||||
pub shared: bool,
|
||||
/// Frontend-only scopes need no backend bounce; disk scopes always do.
|
||||
pub needs_restart: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug, Default)]
|
||||
pub struct ResetReport {
|
||||
pub removed: Vec<String>,
|
||||
/// Paths that existed but could not be removed (locked, permissions).
|
||||
pub failed: Vec<String>,
|
||||
/// Paths the safety guard rejected — a bug or a corrupt config, never routine.
|
||||
pub refused: Vec<String>,
|
||||
pub freed_bytes: u64,
|
||||
/// True when the backend was stopped and re-launched.
|
||||
pub restarted: bool,
|
||||
}
|
||||
|
||||
/// The four directories every scope is carved out of. Kept as a plain struct so
|
||||
/// target resolution is pure and unit-testable without an `AppHandle`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Roots {
|
||||
pub data: PathBuf,
|
||||
pub models: PathBuf,
|
||||
/// The backend's own log dir — outside DATA_DIR on every platform.
|
||||
pub logs: Option<PathBuf>,
|
||||
pub temp: PathBuf,
|
||||
}
|
||||
|
||||
/// Recursive size. Symlinks are never followed: the HF cache is a forest of
|
||||
/// symlinks into `blobs/`, and following them would count the same bytes twice
|
||||
/// (and could wander clean out of the tree).
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
if !path.exists() {
|
||||
return 0;
|
||||
}
|
||||
if path.is_file() {
|
||||
return fs::symlink_metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
}
|
||||
walkdir::WalkDir::new(path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Children of `dir` whose file name starts with `prefix`. This is how the
|
||||
/// SQLite trio (`omnivoice.db`, `-wal`, `-shm`) and the rolling logs
|
||||
/// (`omnivoice.log`, `.log.1`, …) are caught without a glob crate — and why the
|
||||
/// expansion is re-run at purge time rather than trusting the scan: WAL siblings
|
||||
/// come and go while the backend is still alive.
|
||||
fn prefixed_children(dir: &Path, prefix: &str) -> Vec<PathBuf> {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut out: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|n| n.starts_with(prefix))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// True when the model cache sits inside our own tree, so wiping it cannot touch
|
||||
/// another tool's downloads: Windows redirects it to
|
||||
/// `%LOCALAPPDATA%\OmniVoice\hf_cache` (MAX_PATH), and a portable install keeps
|
||||
/// it under `<portable>/data/models`. Anywhere else it is the shared HF cache.
|
||||
pub fn models_are_shared(models: &Path, data: &Path) -> bool {
|
||||
if models.starts_with(data) {
|
||||
return false;
|
||||
}
|
||||
let owned = ["omnivoice", ".omnivoice"];
|
||||
let app_private = models
|
||||
.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.any(|c| owned.iter().any(|o| c.eq_ignore_ascii_case(o)));
|
||||
!app_private
|
||||
}
|
||||
|
||||
/// Does this directory actually look like OmniVoice's? Used to clear a *custom*
|
||||
/// data or model dir — one the user pointed us at, whose path carries no
|
||||
/// OmniVoice-ish name — without also clearing whatever else a mis-configured
|
||||
/// path might point at. Presence of our own files is the proof of ownership.
|
||||
fn has_app_signature(dir: &Path) -> bool {
|
||||
for marker in ["omnivoice.db", "prefs.json", "voices", "outputs", "engines"] {
|
||||
if dir.join(marker).exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A Hugging Face cache root: `hub/` or a `models--org--name` snapshot dir.
|
||||
if dir.join("hub").is_dir() {
|
||||
return true;
|
||||
}
|
||||
fs::read_dir(dir)
|
||||
.map(|entries| {
|
||||
entries.flatten().any(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|n| n.starts_with("models--"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A root may only be deleted out of if it is absolute, is not `/` or `$HOME`,
|
||||
/// is not a bare top-level directory, and is recognizably ours — by name, or by
|
||||
/// the files it contains. This is the backstop between a corrupt `config.json`
|
||||
/// and `remove_dir_all`.
|
||||
pub fn is_valid_root(root: &Path, home: Option<&Path>) -> bool {
|
||||
if !root.is_absolute() || root.parent().is_none() {
|
||||
return false;
|
||||
}
|
||||
if home == Some(root) {
|
||||
return false;
|
||||
}
|
||||
// "/Users" or "C:\" — never a data dir, always a catastrophe.
|
||||
if root.components().count() < 3 {
|
||||
return false;
|
||||
}
|
||||
crate::uninstall::is_recognizably_ours(root, home) || has_app_signature(root)
|
||||
}
|
||||
|
||||
/// Files and directories a scope owns. Pure: `Roots` in, paths out. Paths that
|
||||
/// do not exist are included — the caller filters — so the scan can report an
|
||||
/// empty scope rather than silently omitting it.
|
||||
pub fn scope_targets(key: &str, roots: &Roots) -> Vec<PathBuf> {
|
||||
let data = &roots.data;
|
||||
match key {
|
||||
// prefs.json only. The user's *storage locations* (config.json, the
|
||||
// ~/.config/omnivoice/env file) are deliberately NOT reset: they are
|
||||
// install-shape choices, not preferences, and clearing the model-cache
|
||||
// pointer would strand gigabytes of already-downloaded weights at a path
|
||||
// the app no longer looks in. Same principle as PRESERVED_KEYS on the
|
||||
// frontend (the remote-backend URL survives a preference reset).
|
||||
"settings" => vec![data.join("prefs.json")],
|
||||
|
||||
// Everything the user made. The database goes with it — history, voice
|
||||
// profiles, projects, glossary and pronunciation entries all live in it,
|
||||
// and half-deleting it (rows without files) is how you get a library full
|
||||
// of broken entries. A fresh backend recreates the schema via alembic.
|
||||
"content" => {
|
||||
let mut v = vec![
|
||||
data.join("voices"),
|
||||
data.join("outputs"),
|
||||
data.join("dub_jobs"),
|
||||
data.join("batch"),
|
||||
data.join("preview"),
|
||||
];
|
||||
v.extend(prefixed_children(data, "omnivoice.db"));
|
||||
v
|
||||
}
|
||||
|
||||
// Sidecar engine installs (IndexTTS-2 & friends): a git checkout, a venv
|
||||
// and multi-GB weights each, under DATA_DIR/engines/<id>.
|
||||
"engines" => vec![data.join("engines")],
|
||||
|
||||
// Checksum-pinned ffmpeg/ffprobe/yt-dlp binaries the app fetched itself.
|
||||
"tools" => vec![data.join("media_tools")],
|
||||
|
||||
"models" => vec![roots.models.clone()],
|
||||
|
||||
"caches" => {
|
||||
let mut v = vec![data.join("gallery_cache"), data.join("gallery_sources.json")];
|
||||
// Scratch dirs the app leaves in the OS temp dir. The `omnivoice`
|
||||
// name prefix IS the guard here — these live outside every root.
|
||||
v.extend(prefixed_children(&roots.temp, "omnivoice"));
|
||||
v
|
||||
}
|
||||
|
||||
"logs" => {
|
||||
let mut v = vec![
|
||||
data.join("crash_log.txt"),
|
||||
data.join("error_journal.jsonl"),
|
||||
];
|
||||
v.extend(prefixed_children(data, "omnivoice.log"));
|
||||
if let Some(logs) = &roots.logs {
|
||||
v.push(logs.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this target safe to remove? It must sit inside a validated root — or, for
|
||||
/// the OS temp scratch dirs which live outside every root, be a direct child of
|
||||
/// the temp dir carrying our name prefix.
|
||||
fn target_allowed(path: &Path, roots: &Roots, home: Option<&Path>) -> bool {
|
||||
if path.parent() == Some(roots.temp.as_path()) {
|
||||
return path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with("omnivoice"))
|
||||
.unwrap_or(false);
|
||||
}
|
||||
for root in [Some(&roots.data), Some(&roots.models), roots.logs.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if path.starts_with(root) && is_valid_root(root, home) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn roots_for(app: &tauri::AppHandle) -> Roots {
|
||||
Roots {
|
||||
data: crate::setup::resolved_data_dir(app).unwrap_or_else(crate::setup::default_data_dir),
|
||||
models: crate::setup::resolved_models_dir(app)
|
||||
.unwrap_or_else(crate::setup::default_models_dir),
|
||||
logs: crate::backend::backend_log_path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf()),
|
||||
temp: std::env::temp_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every scope with its real size — what the confirmation UI renders. Sizes are
|
||||
/// what make this honest: "Reset everything" next to a number the user can check
|
||||
/// against the disk beats a wall of adjectives.
|
||||
#[tauri::command]
|
||||
pub async fn reset_scan(app: tauri::AppHandle) -> Vec<ResetScope> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let roots = roots_for(&app);
|
||||
let shared = models_are_shared(&roots.models, &roots.data);
|
||||
let mut out = Vec::new();
|
||||
|
||||
for key in FRONTEND_SCOPES {
|
||||
out.push(ResetScope {
|
||||
key: key.to_string(),
|
||||
paths: vec![],
|
||||
size_bytes: 0,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: false,
|
||||
});
|
||||
}
|
||||
|
||||
for key in DISK_SCOPES {
|
||||
let targets = scope_targets(key, &roots);
|
||||
let present: Vec<&PathBuf> = targets.iter().filter(|p| p.exists()).collect();
|
||||
out.push(ResetScope {
|
||||
key: key.to_string(),
|
||||
size_bytes: present.iter().map(|p| dir_size(p)).sum(),
|
||||
exists: !present.is_empty(),
|
||||
paths: present.iter().map(|p| p.to_string_lossy().to_string()).collect(),
|
||||
shared: key == "models" && shared,
|
||||
needs_restart: true,
|
||||
});
|
||||
}
|
||||
out
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Delete the selected scopes, then bring the backend back.
|
||||
///
|
||||
/// Unknown or frontend-only scope names are ignored rather than erroring: the
|
||||
/// frontend sends one list for the whole reset, and `ui_prefs` / `history` are
|
||||
/// its own to handle.
|
||||
#[tauri::command]
|
||||
pub async fn reset_purge(app: tauri::AppHandle, scopes: Vec<String>) -> Result<ResetReport, String> {
|
||||
let wanted: Vec<String> = scopes
|
||||
.into_iter()
|
||||
.filter(|s| DISK_SCOPES.contains(&s.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut report = ResetReport::default();
|
||||
if wanted.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
// Stop the backend first. `set_backend_kill_intended` tells the #941/#567
|
||||
// supervisor this death is deliberate, so it neither writes a crash marker
|
||||
// nor races us by respawning a backend into the directories we are deleting.
|
||||
// Note we do NOT set `flags.quitting` — that is the uninstall path, and it
|
||||
// would stop us from starting the backend again at the end.
|
||||
crate::bootstrap::set_backend_kill_intended(true);
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
|
||||
let purge_app = app.clone();
|
||||
let mut report = tauri::async_runtime::spawn_blocking(move || {
|
||||
// Give the process a moment to actually exit and drop its file handles;
|
||||
// on Windows a mapped weights file stays locked until it does.
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
|
||||
let roots = roots_for(&purge_app);
|
||||
let home = dirs_next::home_dir();
|
||||
|
||||
for key in DISK_SCOPES.iter().filter(|k| wanted.iter().any(|w| w == *k)) {
|
||||
for path in scope_targets(key, &roots) {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
if !target_allowed(&path, &roots, home.as_deref()) {
|
||||
log::warn!("reset: refusing to delete unrecognized path {}", path.display());
|
||||
report.refused.push(path.to_string_lossy().to_string());
|
||||
continue;
|
||||
}
|
||||
let size = dir_size(&path);
|
||||
let outcome = if path.is_dir() {
|
||||
fs::remove_dir_all(&path)
|
||||
} else {
|
||||
fs::remove_file(&path)
|
||||
};
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
log::info!("reset[{key}]: removed {}", path.display());
|
||||
report.freed_bytes += size;
|
||||
report.removed.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("reset[{key}]: failed to remove {}: {e}", path.display());
|
||||
report.failed.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("reset failed: {e}"))?;
|
||||
|
||||
// Back up. The fresh backend re-runs ensure_dirs() and alembic, so a deleted
|
||||
// database returns empty instead of missing. If the app is on its way out
|
||||
// anyway, don't fight the shutdown.
|
||||
let flags = app.state::<AppFlags>();
|
||||
if !flags.quitting.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let state = app.state::<BootstrapState>();
|
||||
crate::bootstrap::respawn_backend(app.clone(), state.stage.clone(), state.logs.clone());
|
||||
report.restarted = true;
|
||||
} else {
|
||||
crate::bootstrap::set_backend_kill_intended(false);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn roots(tmp: &Path) -> Roots {
|
||||
Roots {
|
||||
data: tmp.join("OmniVoice"),
|
||||
models: tmp.join(".cache/huggingface"),
|
||||
logs: Some(tmp.join("Logs/OmniVoice")),
|
||||
temp: tmp.join("tmp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_scope_takes_the_database_with_the_media() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r = roots(dir.path());
|
||||
fs::create_dir_all(&r.data).unwrap();
|
||||
for f in ["omnivoice.db", "omnivoice.db-wal", "omnivoice.db-shm"] {
|
||||
fs::write(r.data.join(f), b"x").unwrap();
|
||||
}
|
||||
let targets = scope_targets("content", &r);
|
||||
// Rows and files go together, or the library fills with broken entries.
|
||||
for expect in ["voices", "outputs", "dub_jobs", "omnivoice.db", "omnivoice.db-wal"] {
|
||||
assert!(
|
||||
targets.iter().any(|p| p.ends_with(expect)),
|
||||
"content scope must cover {expect}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_scope_spares_the_storage_locations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r = roots(dir.path());
|
||||
let targets = scope_targets("settings", &r);
|
||||
assert_eq!(targets, vec![r.data.join("prefs.json")]);
|
||||
// Resetting preferences must never move the model cache: config.json and
|
||||
// the user env file are install shape, not preference.
|
||||
assert!(!targets.iter().any(|p| p.ends_with("config.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_scope_reaches_the_backend_log_dir_outside_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r = roots(dir.path());
|
||||
let targets = scope_targets("logs", &r);
|
||||
assert!(targets.iter().any(|p| Some(p.as_path()) == r.logs.as_deref()));
|
||||
assert!(targets.iter().any(|p| p.ends_with("crash_log.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_scope_is_inert() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(scope_targets("rm -rf /", &roots(dir.path())).is_empty());
|
||||
assert!(scope_targets("ui_prefs", &roots(dir.path())).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hf_cache_is_shared_only_when_it_sits_outside_our_tree() {
|
||||
// macOS / Linux: the standard cache, shared with every other HF tool.
|
||||
assert!(models_are_shared(
|
||||
Path::new("/Users/me/.cache/huggingface"),
|
||||
Path::new("/Users/me/Library/Application Support/OmniVoice")
|
||||
));
|
||||
// Windows: redirected into our own dir to dodge MAX_PATH → app-private.
|
||||
assert!(!models_are_shared(
|
||||
Path::new("C:/Users/me/AppData/Local/OmniVoice/hf_cache"),
|
||||
Path::new("C:/Users/me/AppData/Roaming/OmniVoice")
|
||||
));
|
||||
// Portable: models live under the portable data dir → app-private.
|
||||
assert!(!models_are_shared(
|
||||
Path::new("/Volumes/USB/OmniVoiceStudio/data/models"),
|
||||
Path::new("/Volumes/USB/OmniVoiceStudio/data")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_data_dir_qualifies_on_its_contents_not_its_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let custom = dir.path().join("my stuff");
|
||||
fs::create_dir_all(&custom).unwrap();
|
||||
// Nothing OmniVoice-ish in the name and no signature yet → refuse.
|
||||
assert!(!is_valid_root(&custom, None));
|
||||
// The app's own database is proof enough that this dir is ours.
|
||||
fs::write(custom.join("omnivoice.db"), b"x").unwrap();
|
||||
assert!(is_valid_root(&custom, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_root_never_home_never_a_top_level_dir() {
|
||||
let home = PathBuf::from("/Users/someone");
|
||||
assert!(!is_valid_root(Path::new("/"), Some(&home)));
|
||||
assert!(!is_valid_root(&home, Some(&home)));
|
||||
assert!(!is_valid_root(Path::new("/Users"), Some(&home)));
|
||||
assert!(!is_valid_root(Path::new("relative/omnivoice"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_outside_every_root_are_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r = roots(dir.path());
|
||||
fs::create_dir_all(&r.data).unwrap();
|
||||
fs::write(r.data.join("omnivoice.db"), b"x").unwrap();
|
||||
|
||||
assert!(target_allowed(&r.data.join("voices"), &r, None));
|
||||
// A path that is not under data, models, or logs — the guard's whole job.
|
||||
assert!(!target_allowed(Path::new("/etc/passwd"), &r, None));
|
||||
assert!(!target_allowed(&dir.path().join("Documents"), &r, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_scratch_is_guarded_by_its_name_prefix() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let r = roots(dir.path());
|
||||
fs::create_dir_all(&r.temp).unwrap();
|
||||
assert!(target_allowed(&r.temp.join("omnivoice_dub_42"), &r, None));
|
||||
// Somebody else's scratch dir in the same temp root.
|
||||
assert!(!target_allowed(&r.temp.join("com.apple.something"), &r, None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! In-app uninstall — "remove all OmniVoice data" (#1089).
|
||||
//!
|
||||
//! Why this lives in the Rust shell and not the backend: the biggest thing to
|
||||
//! remove is the **managed Python environment**, and the backend is *running
|
||||
//! from it*. A process cannot delete its own interpreter out from under itself
|
||||
//! (and on Windows the files are locked while it lives). The shell owns the
|
||||
//! backend's lifetime, so it can stop it, delete everything, and exit.
|
||||
//!
|
||||
//! Paths come from the same single source of truth the rest of the app uses —
|
||||
//! `setup::{resolved_data_dir, default_data_dir, env_root, resolved_models_dir,
|
||||
//! default_models_dir}` and `backend::backend_log_path()` — so a custom or
|
||||
//! portable install is cleaned correctly instead of the defaults being assumed.
|
||||
//!
|
||||
//! Safety: nothing is deleted that doesn't pass `is_recognizably_ours()` (an
|
||||
//! absolute path, not `/` or `$HOME`, carrying an OmniVoice-owned component).
|
||||
//! The shared Hugging Face cache is reported separately and is **opt-in** — it
|
||||
//! is the standard HF cache other ML tools share, so sweeping it up silently
|
||||
//! would delete models this app never downloaded.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{backend_port, AppFlags};
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct UninstallTarget {
|
||||
/// Stable id the UI keys off: "data" | "env" | "logs" | "models".
|
||||
pub key: String,
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub exists: bool,
|
||||
/// True for the shared Hugging Face cache — opt-in, never removed by default.
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct UninstallReport {
|
||||
pub removed: Vec<String>,
|
||||
pub failed: Vec<String>,
|
||||
pub freed_bytes: u64,
|
||||
}
|
||||
|
||||
/// Recursive size of a directory. Symlinks are NOT followed: the HF cache is a
|
||||
/// forest of symlinks into `blobs/`, and following them would count the same
|
||||
/// bytes many times over (and could wander outside the tree entirely).
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
if !path.exists() {
|
||||
return 0;
|
||||
}
|
||||
walkdir::WalkDir::new(path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The backend's own log directory (`backend.log` / `backend_err.log`).
|
||||
/// `backend_log_path()` returns the FILE; we remove the directory it lives in,
|
||||
/// which is OmniVoice-owned on every platform:
|
||||
/// macOS ~/Library/Logs/OmniVoice
|
||||
/// Windows %LOCALAPPDATA%\OmniVoice\Logs
|
||||
/// Linux ~/.local/state/OmniVoice
|
||||
fn backend_log_dir() -> Option<PathBuf> {
|
||||
crate::backend::backend_log_path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// A last-resort guard before any `remove_dir_all`. A path only qualifies if it
|
||||
/// is absolute, has a parent (never `/`), is not the home directory itself, and
|
||||
/// carries a component this app actually owns. Pure — unit-tested below.
|
||||
pub fn is_recognizably_ours(path: &Path, home: Option<&Path>) -> bool {
|
||||
if !path.is_absolute() || path.parent().is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(home) = home {
|
||||
if path == home {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const OWNED: [&str; 5] = [
|
||||
"OmniVoice",
|
||||
"omnivoice",
|
||||
".omnivoice",
|
||||
"com.debpalash.omnivoice-studio",
|
||||
"huggingface",
|
||||
];
|
||||
path.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.any(|c| OWNED.iter().any(|o| c.eq_ignore_ascii_case(o)))
|
||||
}
|
||||
|
||||
fn target(key: &str, path: PathBuf, shared: bool) -> UninstallTarget {
|
||||
let exists = path.exists();
|
||||
UninstallTarget {
|
||||
key: key.to_string(),
|
||||
size_bytes: if exists { dir_size(&path) } else { 0 },
|
||||
path: path.to_string_lossy().to_string(),
|
||||
exists,
|
||||
shared,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every folder this install owns, with sizes — what the confirmation UI shows.
|
||||
/// Honors custom + portable locations via the shared resolvers.
|
||||
#[tauri::command]
|
||||
pub fn uninstall_scan(app: tauri::AppHandle) -> Vec<UninstallTarget> {
|
||||
let data = crate::setup::resolved_data_dir(&app).unwrap_or_else(crate::setup::default_data_dir);
|
||||
let env = crate::setup::env_root(&app);
|
||||
let models =
|
||||
crate::setup::resolved_models_dir(&app).unwrap_or_else(crate::setup::default_models_dir);
|
||||
|
||||
let mut out = vec![
|
||||
// Voices, projects, DB, generated audio, the backend's rolling log.
|
||||
target("data", data, false),
|
||||
// config.json + the managed Python env (project/.venv) — the multi-GB one.
|
||||
target("env", env, false),
|
||||
];
|
||||
if let Some(logs) = backend_log_dir() {
|
||||
out.push(target("logs", logs, false));
|
||||
}
|
||||
// Shared with every other huggingface_hub tool on this machine → opt-in.
|
||||
out.push(target("models", models, true));
|
||||
out
|
||||
}
|
||||
|
||||
/// Stop the backend and delete the scanned folders. `include_models` opts into
|
||||
/// the shared Hugging Face cache. Returns what was removed; the caller quits the
|
||||
/// app afterwards (the Python env it runs on is gone, so there is nothing to
|
||||
/// return to).
|
||||
#[tauri::command]
|
||||
pub fn uninstall_purge(
|
||||
app: tauri::AppHandle,
|
||||
include_models: bool,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<UninstallReport, String> {
|
||||
// Mark the app as quitting BEFORE the backend dies, so the #567 supervisor
|
||||
// treats the death as intentional and doesn't respawn a backend into the
|
||||
// very directories we are about to delete.
|
||||
flags
|
||||
.quitting
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
crate::bootstrap::set_backend_kill_intended(true);
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
|
||||
let home = dirs_next::home_dir();
|
||||
let mut report = UninstallReport {
|
||||
removed: vec![],
|
||||
failed: vec![],
|
||||
freed_bytes: 0,
|
||||
};
|
||||
|
||||
for t in uninstall_scan(app.clone()) {
|
||||
if !t.exists {
|
||||
continue;
|
||||
}
|
||||
if t.shared && !include_models {
|
||||
continue; // the shared HF cache stays unless explicitly opted in
|
||||
}
|
||||
let path = PathBuf::from(&t.path);
|
||||
if !is_recognizably_ours(&path, home.as_deref()) {
|
||||
log::warn!("uninstall: refusing to delete unrecognized path {}", t.path);
|
||||
report.failed.push(t.path);
|
||||
continue;
|
||||
}
|
||||
match fs::remove_dir_all(&path) {
|
||||
Ok(()) => {
|
||||
log::info!("uninstall: removed {}", t.path);
|
||||
report.freed_bytes += t.size_bytes;
|
||||
report.removed.push(t.path);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("uninstall: failed to remove {}: {}", t.path, e);
|
||||
report.failed.push(t.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refuses_root_home_and_foreign_paths() {
|
||||
let home = PathBuf::from("/Users/someone");
|
||||
// Never the filesystem root or the home dir itself.
|
||||
assert!(!is_recognizably_ours(Path::new("/"), Some(&home)));
|
||||
assert!(!is_recognizably_ours(&home, Some(&home)));
|
||||
// Never a path we don't own, even under home.
|
||||
assert!(!is_recognizably_ours(
|
||||
Path::new("/Users/someone/Documents"),
|
||||
Some(&home)
|
||||
));
|
||||
// Never a relative path.
|
||||
assert!(!is_recognizably_ours(Path::new("relative/omnivoice"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_real_targets_on_every_platform() {
|
||||
let home = PathBuf::from("/Users/someone");
|
||||
for p in [
|
||||
"/Users/someone/Library/Application Support/OmniVoice",
|
||||
"/Users/someone/Library/Application Support/com.debpalash.omnivoice-studio",
|
||||
"/Users/someone/Library/Logs/OmniVoice",
|
||||
"/Users/someone/.omnivoice",
|
||||
"/Users/someone/.local/state/OmniVoice",
|
||||
"/Users/someone/.local/share/com.debpalash.omnivoice-studio",
|
||||
"/Users/someone/.cache/huggingface",
|
||||
"C:\\Users\\someone\\AppData\\Roaming\\OmniVoice",
|
||||
] {
|
||||
let path = PathBuf::from(p);
|
||||
// Windows-style paths aren't absolute on unix; only assert the ones that are.
|
||||
if path.is_absolute() {
|
||||
assert!(
|
||||
is_recognizably_ours(&path, Some(&home)),
|
||||
"should accept {p}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,24 @@ const TRANSPORT_RETRY_BACKOFF_MS = [400, 900, 1600];
|
||||
const RESTART_WAIT_INTERVAL_MS = 1500;
|
||||
const STARTUP_GRACE_MS = 120_000;
|
||||
|
||||
// #1101: the shell's stage is a 2-second POLL, not a live probe. When the
|
||||
// backend dies mid-generate, `supervise_backend` needs up to ~2 s to notice the
|
||||
// exit, record the crash marker, and flip the stage to "starting" — so a single
|
||||
// check at the end of the ~2.9 s cascade very often still sees `ready` and we
|
||||
// dead-ended on the generic "Can't reach the backend" anyway. That was the hole
|
||||
// in the #1094 fix, reported against 0.3.19.
|
||||
//
|
||||
// A transport failure CONTRADICTS `ready`: if the shell believed the backend
|
||||
// were reachable, the fetch would have succeeded. So `ready` is treated as a
|
||||
// STALE belief, not an authority — we keep retrying across this reconciliation
|
||||
// window, re-asking each time, which lets a death the supervisor hasn't noticed
|
||||
// yet turn into "starting" (→ the long wait + banner) and gives the crash marker
|
||||
// time to be written so the error can tell the honest story instead of guessing.
|
||||
// Only `failed` (the shell gave up) or `unknown` (no shell — browser/Docker)
|
||||
// still errors immediately.
|
||||
const RECONCILE_MS = 12_000;
|
||||
const RECONCILE_INTERVAL_MS = 1000;
|
||||
|
||||
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
|
||||
const key = _apiKey();
|
||||
@@ -170,10 +188,9 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
// The short cascade is exhausted, but the desktop shell may KNOW the
|
||||
// backend is mid-start/restart (a real one takes 10–20+ s — torch
|
||||
// import — not 2.9 s). Keep waiting exactly as long as the shell says
|
||||
// "starting", bounded by STARTUP_GRACE_MS; 'failed'/'unknown' (no
|
||||
// shell, or the shell gave up) falls through to the error below so a
|
||||
// truly dead backend still surfaces promptly.
|
||||
if (Date.now() - startedAt < STARTUP_GRACE_MS) {
|
||||
// "starting", bounded by STARTUP_GRACE_MS.
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (elapsed < STARTUP_GRACE_MS) {
|
||||
let stage = 'unknown';
|
||||
try {
|
||||
stage = await backendLifecycleStage();
|
||||
@@ -184,6 +201,16 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
await new Promise((r) => setTimeout(r, RESTART_WAIT_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
// `ready` while the transport is failing is a contradiction — the
|
||||
// shell's 2 s poll simply hasn't caught up with a backend that just
|
||||
// died (#1101). Don't believe it yet: keep retrying briefly so the
|
||||
// supervisor can notice, flip to "starting", and write the crash
|
||||
// marker. 'failed'/'unknown' fall through and error now, so a shell
|
||||
// that gave up — or no shell at all — still surfaces promptly.
|
||||
if (stage === 'ready' && elapsed < RECONCILE_MS) {
|
||||
await new Promise((r) => setTimeout(r, RECONCILE_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// #941: if the desktop shell recorded an unacknowledged backend crash,
|
||||
// tell the honest story instead of the vague "can't reach" — and let
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function HistoryRetentionPanel() {
|
||||
control={
|
||||
<div className="flex items-center gap-[var(--space-3)]">
|
||||
<input
|
||||
className="box-border w-[110px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-input-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
className="box-border w-[110px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
|
||||
@@ -475,7 +475,7 @@ export default function ModelStoreTab({ info, modelBadge }) {
|
||||
<div className="inline-flex items-center gap-[var(--space-2)]">
|
||||
<input
|
||||
type="password"
|
||||
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-input-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
className="min-w-0 rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-2)] py-[2px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
placeholder="hf_xxxxxxxxxxxx"
|
||||
value={hfToken}
|
||||
onChange={(e) => setHfToken(e.target.value)}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Settings → Storage → "Reset & remove".
|
||||
*
|
||||
* Factory reset used to do exactly one thing — clear localStorage — while the
|
||||
* only other option was deleting everything and starting over. Between "forget
|
||||
* my theme" and "wipe the machine" sat every reset a user actually needs: drop a
|
||||
* corrupt model download, remove a wedged sidecar engine, put the settings back
|
||||
* without losing a single voice. This panel is that middle ground: four presets
|
||||
* for the common cases, and a per-scope checklist for everything else.
|
||||
*
|
||||
* Three rules it keeps:
|
||||
* - **The number is the truth.** Every scope shows its real on-disk size, and
|
||||
* the confirm button shows the sum of what is actually ticked. A reset that
|
||||
* says "14.2 GB" frees 14.2 GB.
|
||||
* - **The shared model cache is never swept up silently.** On macOS and Linux
|
||||
* the Hugging Face cache is shared with every other ML tool on the machine,
|
||||
* so it is its own checkbox and says so. (On Windows and in portable installs
|
||||
* the cache is app-private — the shell computes that, and the caveat is not
|
||||
* shown when it does not apply.)
|
||||
* - **Nothing irreversible happens on one click.** Removing voices, projects or
|
||||
* models needs the word typed.
|
||||
*
|
||||
* Disk scopes are executed by the Rust shell (`reset.rs`), which stops the
|
||||
* backend, deletes, and starts it again — a running backend cannot delete the
|
||||
* weights it has mapped into memory, nor recreate the directories it lost. The
|
||||
* two scopes that own no files are handled here: UI preferences (localStorage)
|
||||
* and history (the DELETE endpoints, which take rows and audio together).
|
||||
*
|
||||
* Outside the Tauri shell (browser / Docker) there is no local install to clear,
|
||||
* so only the preferences tier is offered.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Palette,
|
||||
SlidersHorizontal,
|
||||
History,
|
||||
Folder,
|
||||
Boxes,
|
||||
Wrench,
|
||||
Database,
|
||||
Archive,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Button, Dialog } from '../../ui';
|
||||
import { SettingsSection } from './primitives';
|
||||
import { fmtBytes } from './bytes';
|
||||
import StorageTargetRow from './StorageTargetRow';
|
||||
import { clearLocalPreferences } from '../../utils/prefKeys';
|
||||
import { clearHistory } from '../../api/generate';
|
||||
import { clearDubHistory } from '../../api/dub';
|
||||
|
||||
const inTauri = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
/** Scopes that own no files — cleared in the browser, never sent to the shell. */
|
||||
export const FRONTEND_SCOPES = ['ui_prefs', 'history'];
|
||||
|
||||
/** Deleting these cannot be undone, so they gate the typed confirmation. */
|
||||
export const IRREVERSIBLE_SCOPES = ['content'];
|
||||
|
||||
/** One glyph per scope, so a nine-row list can be scanned instead of read. */
|
||||
const SCOPE_ICONS = {
|
||||
ui_prefs: Palette,
|
||||
settings: SlidersHorizontal,
|
||||
history: History,
|
||||
content: Folder,
|
||||
engines: Boxes,
|
||||
tools: Wrench,
|
||||
models: Database,
|
||||
caches: Archive,
|
||||
logs: ScrollText,
|
||||
};
|
||||
|
||||
/** Render order. Cheapest and safest first, so the list reads as an escalation. */
|
||||
export const SCOPE_ORDER = [
|
||||
'ui_prefs',
|
||||
'settings',
|
||||
'history',
|
||||
'content',
|
||||
'engines',
|
||||
'tools',
|
||||
'models',
|
||||
'caches',
|
||||
'logs',
|
||||
];
|
||||
|
||||
/**
|
||||
* The four one-click tiers. `everything` deliberately stops short of the managed
|
||||
* Python environment: that is the interpreter the app runs on, and rebuilding it
|
||||
* is a multi-GB download. A reset should hand back a working app on the far
|
||||
* side — "Remove all data" below is the door out of that.
|
||||
*/
|
||||
export const PRESETS = {
|
||||
ui: ['ui_prefs'],
|
||||
settings: ['ui_prefs', 'settings'],
|
||||
assets: ['models', 'engines', 'tools', 'caches'],
|
||||
everything: ['ui_prefs', 'settings', 'content', 'engines', 'tools', 'models', 'caches', 'logs'],
|
||||
};
|
||||
|
||||
/** Bytes the reset will actually free — the sum of exactly what is ticked. */
|
||||
export function selectedBytes(scopes, selected) {
|
||||
return (scopes || [])
|
||||
.filter((s) => selected.includes(s.key) && s.exists)
|
||||
.reduce((sum, s) => sum + (s.size_bytes || 0), 0);
|
||||
}
|
||||
|
||||
/** Typing the word is required the moment something unrecoverable is in scope. */
|
||||
export function needsTypedConfirm(selected) {
|
||||
return selected.some((k) => IRREVERSIBLE_SCOPES.includes(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a selection into the work each half of the app is responsible for.
|
||||
* `content` wipes the whole database, so an explicit `history` tick alongside it
|
||||
* would be a redundant round-trip against rows that are about to be deleted.
|
||||
*/
|
||||
export function plan(selected) {
|
||||
const disk = selected.filter((k) => !FRONTEND_SCOPES.includes(k));
|
||||
return {
|
||||
disk,
|
||||
prefs: selected.includes('ui_prefs'),
|
||||
history: selected.includes('history') && !selected.includes('content'),
|
||||
restart: disk.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The preset whose scope set matches the current ticks exactly, if any. */
|
||||
export function matchingPreset(selected) {
|
||||
const same = (a, b) =>
|
||||
a.length === b.length && [...a].sort().every((v, i) => [...b].sort()[i] === v);
|
||||
return Object.keys(PRESETS).find((p) => same(PRESETS[p], selected)) || null;
|
||||
}
|
||||
|
||||
// `_forceAdvanced` starts the "choose exactly what to remove" list expanded —
|
||||
// used only by the visual-regression harness so a snapshot shows the full row
|
||||
// treatment. It has no effect on the real toggle.
|
||||
export default function ResetPanel({ _forceAdvanced = false } = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [scopes, setScopes] = useState(null);
|
||||
const [selected, setSelected] = useState(PRESETS.ui);
|
||||
const [advanced, setAdvanced] = useState(_forceAdvanced);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const CONFIRM_WORD = t('settings.reset_confirm_word', { defaultValue: 'DELETE' });
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
if (!inTauri()) return;
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
setScopes(await invoke('reset_scan'));
|
||||
} catch (e) {
|
||||
console.warn('[ResetPanel] scan failed', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scan();
|
||||
}, [scan]);
|
||||
|
||||
const byKey = useMemo(() => Object.fromEntries((scopes || []).map((s) => [s.key, s])), [scopes]);
|
||||
const willFree = selectedBytes(scopes, selected);
|
||||
const sharedModels = byKey.models?.shared && selected.includes('models');
|
||||
const typedOk = !needsTypedConfirm(selected) || typed.trim().toUpperCase() === CONFIRM_WORD;
|
||||
|
||||
const toggle = (key) =>
|
||||
setSelected((cur) => (cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key]));
|
||||
|
||||
const run = async () => {
|
||||
const steps = plan(selected);
|
||||
setBusy(true);
|
||||
try {
|
||||
// History first: its endpoints take the DB rows and their audio together,
|
||||
// and they need a backend that is still alive to do it.
|
||||
if (steps.history) {
|
||||
await Promise.all([clearHistory(), clearDubHistory()]);
|
||||
}
|
||||
if (steps.disk.length) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const report = await invoke('reset_purge', { scopes: steps.disk });
|
||||
if (report?.refused?.length || report?.failed?.length) {
|
||||
toast.error(
|
||||
t('settings.reset_partial', {
|
||||
defaultValue: 'Some items could not be removed: {{paths}}',
|
||||
paths: [...(report.refused || []), ...(report.failed || [])].join(', '),
|
||||
}),
|
||||
{ duration: 10000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Preferences last: the reload below is what makes them take effect, and if
|
||||
// anything above threw we would rather not have wiped them for nothing.
|
||||
if (steps.prefs) clearLocalPreferences();
|
||||
|
||||
toast.success(
|
||||
steps.restart
|
||||
? t('settings.reset_done_restart', {
|
||||
defaultValue: 'Reset complete — restarting OmniVoice…',
|
||||
})
|
||||
: t('settings.reset_done', { defaultValue: 'Reset complete — reloading…' }),
|
||||
);
|
||||
setOpen(false);
|
||||
// The backend is coming back up behind us; the bootstrap splash and the
|
||||
// reconnecting banner (#1094) own that wait, so all this has to do is get
|
||||
// the UI back to a clean slate.
|
||||
setTimeout(() => window.location.reload(), 400);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
toast.error(
|
||||
t('settings.reset_failed', {
|
||||
defaultValue: 'Reset failed: {{message}}',
|
||||
message: e?.message || String(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const LABELS = {
|
||||
ui_prefs: t('settings.reset_scope_ui_prefs', {
|
||||
defaultValue: 'UI preferences — theme, language, layout, dub settings',
|
||||
}),
|
||||
settings: t('settings.reset_scope_settings', {
|
||||
defaultValue: 'App settings — engine choices, voice defaults, saved options',
|
||||
}),
|
||||
history: t('settings.reset_scope_history', {
|
||||
defaultValue: 'Generation & dub history, with their audio',
|
||||
}),
|
||||
content: t('settings.reset_scope_content', {
|
||||
defaultValue: 'Voices, projects, generated audio, and the app database',
|
||||
}),
|
||||
engines: t('settings.reset_scope_engines', {
|
||||
defaultValue: 'Installed sidecar engines (IndexTTS-2 and friends)',
|
||||
}),
|
||||
tools: t('settings.reset_scope_tools', {
|
||||
defaultValue: 'Downloaded audio tools (ffmpeg, ffprobe, yt-dlp)',
|
||||
}),
|
||||
models: t('settings.reset_scope_models', {
|
||||
defaultValue: 'Downloaded model weights',
|
||||
}),
|
||||
caches: t('settings.reset_scope_caches', {
|
||||
defaultValue: 'Caches and temporary files',
|
||||
}),
|
||||
logs: t('settings.reset_scope_logs', { defaultValue: 'Logs and crash reports' }),
|
||||
};
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
id: 'ui',
|
||||
label: t('settings.reset_tier_ui', { defaultValue: 'UI preferences only' }),
|
||||
hint: t('settings.reset_tier_ui_hint', {
|
||||
defaultValue: 'Theme, layout and dub knobs go back to defaults. Nothing on disk changes.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: t('settings.reset_tier_settings', { defaultValue: 'All settings' }),
|
||||
hint: t('settings.reset_tier_settings_hint', {
|
||||
defaultValue:
|
||||
'Every preference, in the app and on disk. Your voices, projects and models are untouched.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
label: t('settings.reset_tier_assets', { defaultValue: 'Downloaded assets & models' }),
|
||||
hint: t('settings.reset_tier_assets_hint', {
|
||||
defaultValue:
|
||||
'Model weights, sidecar engines, audio tools and caches. Everything you made stays. They re-download when next needed.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'everything',
|
||||
label: t('settings.reset_tier_everything', { defaultValue: 'Everything OmniVoice did' }),
|
||||
hint: t('settings.reset_tier_everything_hint', {
|
||||
defaultValue:
|
||||
'Back to a fresh install: settings, voices, projects, audio, models, engines, logs. The app restarts on the first-run screen.',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// No shell → no local install to clear. Preferences are all we own here.
|
||||
const shellless = !inTauri();
|
||||
const activePreset = matchingPreset(selected);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
icon={RotateCcw}
|
||||
title={t('settings.reset', { defaultValue: 'Reset & remove' })}
|
||||
description={t('settings.reset_desc', {
|
||||
defaultValue: 'Put part — or all — of OmniVoice back to how it shipped.',
|
||||
})}
|
||||
>
|
||||
{shellless ? (
|
||||
<p className="m-0 mb-[var(--space-4)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.reset_body_web', {
|
||||
defaultValue:
|
||||
'Clears locally-saved preferences (theme, language, dub settings) and reloads. Your voices, projects and generated audio are not affected.',
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-[var(--space-4)] flex flex-col gap-[var(--space-2)]">
|
||||
{TIERS.map((tier) => {
|
||||
const size = selectedBytes(scopes, PRESETS[tier.id]);
|
||||
const on = activePreset === tier.id;
|
||||
return (
|
||||
<label
|
||||
key={tier.id}
|
||||
className={`flex cursor-pointer items-start gap-[var(--space-3)] rounded-[var(--radius-md)] p-[var(--space-3)] ${
|
||||
on ? 'bg-[var(--chrome-accent-bg)]' : 'bg-[var(--chrome-hover-bg)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="reset-tier"
|
||||
checked={on}
|
||||
onChange={() => setSelected(PRESETS[tier.id])}
|
||||
data-testid={`reset-tier-${tier.id}`}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline justify-between gap-[var(--space-3)]">
|
||||
<span className="[font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]">
|
||||
{tier.label}
|
||||
</span>
|
||||
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
|
||||
{tier.id === 'ui' ? '—' : fmtBytes(size)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.5] text-[var(--chrome-fg-muted)]">
|
||||
{tier.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvanced((v) => !v)}
|
||||
data-testid="reset-advanced-toggle"
|
||||
className="mb-[var(--space-3)] flex items-center gap-[var(--space-1)] border-0 bg-transparent p-0 [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
className={advanced ? 'rotate-90 transition-transform' : 'transition-transform'}
|
||||
/>
|
||||
{t('settings.reset_advanced', { defaultValue: 'Choose exactly what to remove' })}
|
||||
</button>
|
||||
|
||||
{advanced && (
|
||||
<div
|
||||
className="mb-[var(--space-4)] flex flex-col gap-[var(--space-2)]"
|
||||
data-testid="reset-advanced"
|
||||
>
|
||||
{SCOPE_ORDER.map((key) => {
|
||||
const s = byKey[key];
|
||||
const fileScope = !FRONTEND_SCOPES.includes(key);
|
||||
return (
|
||||
<StorageTargetRow
|
||||
key={key}
|
||||
icon={SCOPE_ICONS[key]}
|
||||
label={LABELS[key]}
|
||||
hint={
|
||||
s?.shared
|
||||
? t('settings.reset_models_shared', {
|
||||
defaultValue:
|
||||
'Shared Hugging Face cache — may hold models other AI tools downloaded.',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
size={fileScope ? (s?.size_bytes ?? 0) : undefined}
|
||||
share={willFree > 0 && fileScope ? (s?.size_bytes ?? 0) / willFree : 0}
|
||||
checked={selected.includes(key)}
|
||||
onToggle={() => toggle(key)}
|
||||
disabled={Boolean(s && !s.exists && fileScope)}
|
||||
warn={Boolean(s?.shared)}
|
||||
testId={`reset-scope-${key}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
size="md"
|
||||
leading={<RotateCcw size={13} />}
|
||||
disabled={selected.length === 0}
|
||||
onClick={() => {
|
||||
setTyped('');
|
||||
setOpen(true);
|
||||
}}
|
||||
data-testid="factory-reset-open"
|
||||
>
|
||||
{t('settings.reset', { defaultValue: 'Reset & remove' })}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => !busy && setOpen(false)}
|
||||
title={t('settings.reset_confirm_title', { defaultValue: 'Reset OmniVoice?' })}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={busy}
|
||||
disabled={busy || !typedOk}
|
||||
onClick={run}
|
||||
data-testid="factory-reset-confirm"
|
||||
>
|
||||
{plan(selected).restart
|
||||
? t('settings.reset_confirm_restart', {
|
||||
defaultValue: 'Remove {{size}} and restart',
|
||||
size: fmtBytes(willFree),
|
||||
})
|
||||
: t('settings.reset_confirm', { defaultValue: 'Reset and reload' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--space-4)]">
|
||||
<ul className="m-0 list-none p-0" data-testid="reset-summary">
|
||||
{selected.map((key) => (
|
||||
<li
|
||||
key={key}
|
||||
className="flex items-baseline justify-between gap-[var(--space-3)] py-[var(--space-1)] [font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]"
|
||||
>
|
||||
<span>{LABELS[key]}</span>
|
||||
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
|
||||
{FRONTEND_SCOPES.includes(key) ? '—' : fmtBytes(byKey[key]?.size_bytes ?? 0)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{sharedModels && (
|
||||
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className="mt-1 shrink-0 text-[var(--chrome-severity-warn)]"
|
||||
/>
|
||||
<span data-testid="reset-shared-warning">
|
||||
{t('settings.reset_models_shared_warning', {
|
||||
defaultValue:
|
||||
'The model cache is the standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Everything OmniVoice needs will download again on next use.',
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{needsTypedConfirm(selected) && (
|
||||
<>
|
||||
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg)]">
|
||||
<AlertTriangle size={16} className="mt-1 shrink-0 text-[var(--color-danger)]" />
|
||||
<span>
|
||||
{t('settings.reset_irreversible', {
|
||||
defaultValue:
|
||||
'Your voice profiles, projects and generated audio will be permanently deleted. This cannot be undone.',
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<label className="flex flex-col gap-[var(--space-2)]">
|
||||
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.reset_type_to_confirm', {
|
||||
defaultValue: 'Type {{word}} to confirm:',
|
||||
word: CONFIRM_WORD,
|
||||
})}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
data-testid="reset-type-confirm"
|
||||
className="rounded-[var(--radius-md)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] [font-family:var(--font-mono)] text-[length:var(--text-md)] text-[var(--chrome-fg)] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan(selected).restart && (
|
||||
<p className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.reset_restart_note', {
|
||||
defaultValue:
|
||||
'OmniVoice will restart its engine to finish. This takes a few seconds.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import ResetPanel, {
|
||||
PRESETS,
|
||||
plan,
|
||||
selectedBytes,
|
||||
needsTypedConfirm,
|
||||
matchingPreset,
|
||||
} from './ResetPanel';
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a) => invoke(...a) }));
|
||||
|
||||
const clearHistory = vi.fn(async () => ({ ok: true }));
|
||||
const clearDubHistory = vi.fn(async () => ({ ok: true }));
|
||||
vi.mock('../../api/generate', () => ({ clearHistory: (...a) => clearHistory(...a) }));
|
||||
vi.mock('../../api/dub', () => ({ clearDubHistory: (...a) => clearDubHistory(...a) }));
|
||||
|
||||
/** What `reset_scan` returns from the shell: every scope, with its real size. */
|
||||
const SCOPES = [
|
||||
{ key: 'ui_prefs', paths: [], size_bytes: 0, exists: true, shared: false, needs_restart: false },
|
||||
{ key: 'history', paths: [], size_bytes: 0, exists: true, shared: false, needs_restart: false },
|
||||
{
|
||||
key: 'settings',
|
||||
paths: ['/d/prefs.json'],
|
||||
size_bytes: 4_096,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
paths: ['/d/voices'],
|
||||
size_bytes: 5 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'engines',
|
||||
paths: ['/d/engines'],
|
||||
size_bytes: 2 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
paths: ['/d/media_tools'],
|
||||
size_bytes: 100 * 1024 ** 2,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'models',
|
||||
paths: ['/hf'],
|
||||
size_bytes: 14 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: true,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'caches',
|
||||
paths: ['/d/gallery_cache'],
|
||||
size_bytes: 10 * 1024 ** 2,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
paths: ['/d/omnivoice.log'],
|
||||
size_bytes: 1024,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
];
|
||||
|
||||
const byKey = Object.fromEntries(SCOPES.map((s) => [s.key, s]));
|
||||
|
||||
describe('reset planning', () => {
|
||||
it('never sends a frontend-only scope to the shell', () => {
|
||||
// ui_prefs is localStorage and history is a DB endpoint — the Rust purge has
|
||||
// no idea what either means, and passing them would be a silent no-op at best.
|
||||
const steps = plan(['ui_prefs', 'history', 'models']);
|
||||
expect(steps.disk).toEqual(['models']);
|
||||
expect(steps.prefs).toBe(true);
|
||||
expect(steps.history).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the history endpoints when the whole database is going anyway', () => {
|
||||
// `content` deletes omnivoice.db outright, so clearing history rows first is a
|
||||
// pointless round-trip against records that are about to cease to exist.
|
||||
const steps = plan(['history', 'content']);
|
||||
expect(steps.history).toBe(false);
|
||||
expect(steps.disk).toEqual(['content']);
|
||||
});
|
||||
|
||||
it('only needs a backend restart when something on disk is being deleted', () => {
|
||||
expect(plan(['ui_prefs']).restart).toBe(false);
|
||||
expect(plan(['history']).restart).toBe(false);
|
||||
expect(plan(['ui_prefs', 'settings']).restart).toBe(true);
|
||||
});
|
||||
|
||||
it('demands the typed word for anything unrecoverable, and not otherwise', () => {
|
||||
expect(needsTypedConfirm(['content'])).toBe(true);
|
||||
expect(needsTypedConfirm(PRESETS.everything)).toBe(true);
|
||||
// Models are a big download, but they are only a download — they come back.
|
||||
expect(needsTypedConfirm(PRESETS.assets)).toBe(false);
|
||||
expect(needsTypedConfirm(PRESETS.settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts exactly what is ticked, so the button number is the truth', () => {
|
||||
expect(selectedBytes(SCOPES, ['settings'])).toBe(4_096);
|
||||
expect(selectedBytes(SCOPES, PRESETS.assets)).toBe(
|
||||
byKey.models.size_bytes +
|
||||
byKey.engines.size_bytes +
|
||||
byKey.tools.size_bytes +
|
||||
byKey.caches.size_bytes,
|
||||
);
|
||||
// Scopes that own no files contribute nothing.
|
||||
expect(selectedBytes(SCOPES, ['ui_prefs', 'history'])).toBe(0);
|
||||
expect(selectedBytes(SCOPES, [])).toBe(0);
|
||||
expect(selectedBytes(undefined, ['models'])).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the user something that still runs: no preset removes the Python env', () => {
|
||||
// The owner's call — "Everything" means a fresh install, not a reinstall. The
|
||||
// interpreter the backend runs on is the uninstaller's business, not reset's.
|
||||
for (const scopes of Object.values(PRESETS)) {
|
||||
expect(scopes).not.toContain('env');
|
||||
}
|
||||
expect(PRESETS.assets).not.toContain('content');
|
||||
});
|
||||
|
||||
it('recognises the tier a selection corresponds to', () => {
|
||||
expect(matchingPreset(['ui_prefs'])).toBe('ui');
|
||||
expect(matchingPreset([...PRESETS.everything].reverse())).toBe('everything');
|
||||
expect(matchingPreset(['models'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResetPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
invoke.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'reset_scan') return SCOPES;
|
||||
if (cmd === 'reset_purge')
|
||||
return { removed: [], failed: [], refused: [], freed_bytes: 0, restarted: true };
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
const openDialog = async () => {
|
||||
render(<ResetPanel />);
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith('reset_scan'));
|
||||
fireEvent.click(screen.getByTestId('factory-reset-open'));
|
||||
await screen.findByTestId('factory-reset-confirm');
|
||||
};
|
||||
|
||||
it('defaults to the least destructive tier', async () => {
|
||||
render(<ResetPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('reset-tier-ui')).toBeChecked());
|
||||
expect(screen.getByTestId('reset-tier-everything')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('will not delete voices and projects on a single click', async () => {
|
||||
render(<ResetPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('reset-tier-everything')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('reset-tier-everything'));
|
||||
fireEvent.click(screen.getByTestId('factory-reset-open'));
|
||||
|
||||
const confirm = await screen.findByTestId('factory-reset-confirm');
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('reset-type-confirm'), { target: { value: 'delete' } });
|
||||
await waitFor(() => expect(confirm).toBeEnabled());
|
||||
});
|
||||
|
||||
it('sends only disk scopes to the shell and clears preferences here', async () => {
|
||||
localStorage.setItem('omnivoice.app', '{"state":{}}');
|
||||
localStorage.setItem('omni_transcriptions', '[{"text":"note"}]');
|
||||
|
||||
render(<ResetPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('reset-tier-settings')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('reset-tier-settings')); // ui_prefs + settings
|
||||
fireEvent.click(screen.getByTestId('factory-reset-open'));
|
||||
fireEvent.click(await screen.findByTestId('factory-reset-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('reset_purge', { scopes: ['settings'] }),
|
||||
);
|
||||
// The zustand blob goes; dictation history — user data, not a preference — stays.
|
||||
expect(localStorage.getItem('omnivoice.app')).toBeNull();
|
||||
expect(localStorage.getItem('omni_transcriptions')).toBe('[{"text":"note"}]');
|
||||
});
|
||||
|
||||
it('clears history through the API without bouncing the backend', async () => {
|
||||
await openDialog();
|
||||
fireEvent.click(screen.getByTestId('reset-advanced-toggle'));
|
||||
fireEvent.click(await screen.findByTestId('reset-scope-history'));
|
||||
fireEvent.click(screen.getByTestId('reset-scope-ui_prefs')); // untick, leaving history alone
|
||||
fireEvent.click(screen.getByTestId('factory-reset-confirm'));
|
||||
|
||||
await waitFor(() => expect(clearHistory).toHaveBeenCalled());
|
||||
expect(clearDubHistory).toHaveBeenCalled();
|
||||
// Nothing on disk was touched, so there is nothing to restart for.
|
||||
expect(invoke).not.toHaveBeenCalledWith('reset_purge', expect.anything());
|
||||
});
|
||||
|
||||
it('warns before sweeping up a model cache other tools share', async () => {
|
||||
render(<ResetPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('reset-tier-assets')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('reset-tier-assets'));
|
||||
fireEvent.click(screen.getByTestId('factory-reset-open'));
|
||||
expect(await screen.findByTestId('reset-shared-warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays silent about sharing when the cache is app-private (Windows, portable)', async () => {
|
||||
invoke.mockImplementation(async (cmd) =>
|
||||
cmd === 'reset_scan'
|
||||
? SCOPES.map((s) => (s.key === 'models' ? { ...s, shared: false } : s))
|
||||
: { removed: [], failed: [], refused: [], freed_bytes: 0, restarted: true },
|
||||
);
|
||||
render(<ResetPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('reset-tier-assets')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('reset-tier-assets'));
|
||||
fireEvent.click(screen.getByTestId('factory-reset-open'));
|
||||
await screen.findByTestId('factory-reset-confirm');
|
||||
expect(screen.queryByTestId('reset-shared-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('outside the desktop shell, offers preferences only', async () => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
render(<ResetPanel />);
|
||||
expect(screen.queryByTestId('reset-tier-everything')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('factory-reset-open')).toBeInTheDocument();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -110,7 +110,7 @@ export default function StoragePanel() {
|
||||
control={
|
||||
<div className="flex w-full flex-wrap items-center gap-[var(--space-3)]">
|
||||
<input
|
||||
className="box-border min-w-0 max-w-[520px] flex-[1_1_280px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-input-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
className="box-border min-w-0 max-w-[520px] flex-[1_1_280px] rounded-[var(--chrome-radius-pill)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] font-[family-name:var(--chrome-font-mono)] text-[length:var(--text-base)] text-[var(--chrome-fg)] placeholder:text-[var(--chrome-fg-dim)] focus-visible:border-[var(--chrome-accent)] focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none"
|
||||
type="text"
|
||||
value={input}
|
||||
placeholder={def || '~/.cache/huggingface'}
|
||||
|
||||
@@ -2,52 +2,33 @@
|
||||
* Settings → Storage (System group).
|
||||
*
|
||||
* Shows where OmniVoice keeps its data and outputs (read-only, from systemInfo,
|
||||
* each with an Open-folder affordance via the /export/reveal endpoint) and a
|
||||
* "Factory reset" action that clears every locally-persisted UI preference —
|
||||
* the full registry in utils/prefKeys.js, not just the zustand blob — behind a
|
||||
* confirm Dialog, then reloads.
|
||||
* each with an Open-folder affordance via the /export/reveal endpoint), then the
|
||||
* two destructive affordances, in escalating order:
|
||||
*
|
||||
* NOTE: the models *cache* directory lives in the Models category (StoragePanel)
|
||||
* — this category is about the app's own data/outputs paths and a clean-slate
|
||||
* reset of UI prefs. Factory reset only touches localStorage prefs; it never
|
||||
* deletes the user's voices, projects, or outputs on disk, and never wipes the
|
||||
* remote-backend connection or dictation history (see prefKeys.PRESERVED_KEYS).
|
||||
* ResetPanel — scoped reset. Anything from "forget my theme" to "back to a
|
||||
* fresh install", per-scope, with real sizes. Leaves a working
|
||||
* app behind: the shell restarts the backend afterwards.
|
||||
* UninstallPanel — the door out (#1089). Deletes everything including the
|
||||
* managed Python environment, then quits.
|
||||
*
|
||||
* NOTE: the models *cache* directory lives in the Models category (StoragePanel).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { FolderOpen, HardDrive, RotateCcw } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { FolderOpen, HardDrive } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSystemInfo } from '../../api/hooks';
|
||||
import { exportReveal } from '../../api/exports';
|
||||
import { clearLocalPreferences } from '../../utils/prefKeys';
|
||||
import { Button, Dialog } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { SettingsSection } from './primitives';
|
||||
import Row from './Row';
|
||||
import HistoryRetentionPanel from './HistoryRetentionPanel';
|
||||
import ResetPanel from './ResetPanel';
|
||||
import UninstallPanel from './UninstallPanel';
|
||||
|
||||
export default function StorageTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: info } = useSystemInfo();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const factoryReset = () => {
|
||||
try {
|
||||
clearLocalPreferences();
|
||||
toast.success(
|
||||
t('settings.factory_reset_done', { defaultValue: 'Preferences cleared — reloading…' }),
|
||||
);
|
||||
setConfirmOpen(false);
|
||||
// Reload so the store rehydrates from defaults across the whole app.
|
||||
setTimeout(() => window.location.reload(), 350);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t('settings.factory_reset_failed', {
|
||||
defaultValue: 'Reset failed: {{message}}',
|
||||
message: e?.message || e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = async (path) => {
|
||||
try {
|
||||
@@ -103,59 +84,11 @@ export default function StorageTab() {
|
||||
|
||||
<HistoryRetentionPanel />
|
||||
|
||||
<SettingsSection
|
||||
icon={RotateCcw}
|
||||
title={t('settings.factory_reset', { defaultValue: 'Factory reset' })}
|
||||
description={t('settings.factory_reset_desc', {
|
||||
defaultValue:
|
||||
'Reset all in-app preferences to their defaults. Your files stay untouched.',
|
||||
})}
|
||||
>
|
||||
<p className="m-0 mb-[var(--space-4)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.factory_reset_body', {
|
||||
defaultValue:
|
||||
'Clears locally-saved settings (theme, language, dub knobs, gallery favorites, and other UI preferences). It does NOT delete your voices, projects, or generated audio on disk.',
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="md"
|
||||
leading={<RotateCcw size={13} />}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
data-testid="factory-reset-open"
|
||||
>
|
||||
{t('settings.factory_reset', { defaultValue: 'Factory reset' })}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
{/* Scoped reset: preferences → settings → assets → everything. */}
|
||||
<ResetPanel />
|
||||
|
||||
<Dialog
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title={t('settings.factory_reset_confirm_title', { defaultValue: 'Reset preferences?' })}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmOpen(false)}>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={factoryReset}
|
||||
data-testid="factory-reset-confirm"
|
||||
>
|
||||
{t('settings.factory_reset_confirm', { defaultValue: 'Reset and reload' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="m-0 [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg)]">
|
||||
{t('settings.factory_reset_confirm_body', {
|
||||
defaultValue:
|
||||
'This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. Continue?',
|
||||
})}
|
||||
</p>
|
||||
</Dialog>
|
||||
{/* The door out (#1089): everything, including the Python env, then quit. */}
|
||||
<UninstallPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* One line item in a destructive Storage panel — used by both "Reset & remove"
|
||||
* and "Remove all data", so the two read as one system rather than two lists
|
||||
* that happen to sit next to each other.
|
||||
*
|
||||
* The design problem it solves: a flat list of sizes is unreadable. A 7.5 GB
|
||||
* model cache and a 391 B config file rendered at the same visual weight, so the
|
||||
* one number that actually matters — *where the space went* — was the one thing
|
||||
* you couldn't see. Each row now carries a **proportional bar**: its share of the
|
||||
* total being removed. The big one looks big.
|
||||
*
|
||||
* Rows are either informational (uninstall lists what it will delete) or
|
||||
* selectable (reset lets you pick). Pass `onToggle` to get a checkbox; leave it
|
||||
* off for a plain row.
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fmtBytes } from './bytes';
|
||||
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {Function} props.icon lucide icon component
|
||||
* @param {string} props.label what this is, in the user's words
|
||||
* @param {string} [props.hint] a second line — a caveat, not a repeat of the label
|
||||
* @param {string} [props.path] the folder on disk (dimmed; truncates, full text on hover)
|
||||
* @param {number} [props.size] bytes; omit for scopes that own no files
|
||||
* @param {number} [props.share] 0–1, this row's fraction of the total. Drives the bar.
|
||||
* @param {boolean} [props.checked]
|
||||
* @param {Function} [props.onToggle] present → the row is selectable
|
||||
* @param {boolean} [props.disabled] nothing here to remove
|
||||
* @param {boolean} [props.warn] tint the bar as a caution (the shared model cache)
|
||||
*/
|
||||
export default function StorageTargetRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
hint,
|
||||
path,
|
||||
size,
|
||||
share = 0,
|
||||
checked = false,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
warn = false,
|
||||
testId,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const selectable = typeof onToggle === 'function';
|
||||
// An unticked row still shows its size, but claims none of the bar: the bars
|
||||
// must add up to what the button says it will free, or they are lying.
|
||||
const filled = selectable && !checked ? 0 : Math.max(0, Math.min(1, share));
|
||||
const Wrapper = selectable ? 'label' : 'div';
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
// A selectable row's handle is its checkbox; an informational row has none,
|
||||
// so the id lands on the row itself. Either way `testId` addresses the thing
|
||||
// a test would actually interact with.
|
||||
data-testid={selectable ? undefined : testId}
|
||||
className={`flex items-start gap-[var(--space-3)] rounded-[var(--radius-md)] p-[var(--space-3)] ${
|
||||
selectable && !disabled ? 'cursor-pointer' : ''
|
||||
} ${checked ? 'bg-[var(--chrome-accent-bg)]' : 'bg-[var(--chrome-hover-bg)]'} ${
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{selectable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
className="mt-[3px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`mt-[1px] flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)] ${
|
||||
checked ? 'text-[var(--chrome-accent)]' : 'text-[var(--chrome-fg-muted)]'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Icon && <Icon size={15} />}
|
||||
</span>
|
||||
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-[var(--space-1)]">
|
||||
<span className="flex items-baseline justify-between gap-[var(--space-3)]">
|
||||
<span className="[font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
|
||||
{Number.isFinite(size) ? fmtBytes(size) : '—'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Share of the total. Hidden when there is nothing to show, so rows that
|
||||
own no files (UI preferences) don't render an eternally empty track. */}
|
||||
{Number.isFinite(size) && size > 0 && (
|
||||
<span
|
||||
className="block h-[3px] w-full overflow-hidden rounded-[var(--radius-pill)] bg-[var(--chrome-hover-bg)]"
|
||||
role="presentation"
|
||||
data-testid={testId ? `${testId}-bar` : undefined}
|
||||
>
|
||||
<span
|
||||
className={`block h-full rounded-[var(--radius-pill)] ${
|
||||
warn ? 'bg-[var(--chrome-severity-warn)]' : 'bg-[var(--chrome-accent)]'
|
||||
}`}
|
||||
style={{ width: `${Math.round(filled * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hint && (
|
||||
<span className="[font-family:var(--font-sans)] text-[length:var(--text-xs)] leading-[1.5] text-[var(--chrome-fg-muted)]">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{path && (
|
||||
<span
|
||||
title={path}
|
||||
className="block truncate [font-family:var(--font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)]"
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{disabled && (
|
||||
<span className="[font-family:var(--font-sans)] text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)]">
|
||||
{t('settings.storage_target_empty', { defaultValue: 'Nothing to remove' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ function ProportionBar({ value, max }) {
|
||||
const pct = max > 0 ? Math.max(value > 0 ? 1.5 : 0, (value / max) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
className="h-[4px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-input-bg)]"
|
||||
className="h-[4px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
@@ -290,7 +290,7 @@ export default function StorageUsagePanel() {
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="mb-[var(--space-3)] h-[36px] animate-pulse rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-input-bg)]"
|
||||
className="mb-[var(--space-3)] h-[36px] animate-pulse rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -313,7 +313,7 @@ export default function StorageUsagePanel() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[6px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-input-bg)]">
|
||||
<div className="h-[6px] w-full overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)]">
|
||||
<div
|
||||
className="h-full rounded-[var(--chrome-radius-pill)]"
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Settings → Storage → "Remove all data" (#1089).
|
||||
*
|
||||
* The in-app half of the uninstaller. A user who installed the .dmg / .msi /
|
||||
* AppImage has no repo, so `scripts/uninstall.sh` never reaches them — this is
|
||||
* the affordance they actually have. It asks the desktop shell for every folder
|
||||
* this install owns (honoring custom + portable locations), shows each with its
|
||||
* real size, and deletes them behind a typed confirmation.
|
||||
*
|
||||
* Two deliberate choices:
|
||||
* - The **shared Hugging Face cache is opt-in**, on its own checkbox with the
|
||||
* caveat spelled out: it's the standard HF cache other ML tools use, so
|
||||
* removing it can delete models OmniVoice never downloaded.
|
||||
* - The confirmation requires **typing the word**, not just a click. This
|
||||
* deletes voice profiles and projects that cannot be recovered.
|
||||
*
|
||||
* After the purge the app quits: the Python environment it runs on is gone, so
|
||||
* there is nothing to return to. Removing the app *binary* is a per-platform
|
||||
* step we link out to (docs/install/uninstall.md).
|
||||
*
|
||||
* Outside the Tauri shell (browser/Docker) there is no local install to remove,
|
||||
* so this renders nothing.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, AlertTriangle, Folder, Package, ScrollText, Database } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Button, Dialog } from '../../ui';
|
||||
import { SettingsSection } from './primitives';
|
||||
import StorageTargetRow from './StorageTargetRow';
|
||||
import { fmtBytes } from './bytes';
|
||||
|
||||
const inTauri = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
|
||||
// Re-exported: this was the panel's own helper before the Storage panels shared
|
||||
// one formatter, and it is imported by name in the tests.
|
||||
export { fmtBytes };
|
||||
|
||||
/** Bytes the purge will actually free, given the opt-in on the shared cache.
|
||||
* Pure + exported: the number shown on the button must match what gets deleted. */
|
||||
export function freedBytes(targets, includeModels) {
|
||||
return (targets || [])
|
||||
.filter((t) => t.exists && (!t.shared || includeModels))
|
||||
.reduce((sum, t) => sum + (t.size_bytes || 0), 0);
|
||||
}
|
||||
|
||||
const ICONS = { data: Folder, env: Package, logs: ScrollText, models: Database };
|
||||
|
||||
export default function UninstallPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [targets, setTargets] = useState(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includeModels, setIncludeModels] = useState(false);
|
||||
const [typed, setTyped] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const CONFIRM_WORD = t('settings.uninstall_confirm_word', { defaultValue: 'DELETE' });
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
if (!inTauri()) return;
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
setTargets(await invoke('uninstall_scan'));
|
||||
} catch (e) {
|
||||
console.warn('[UninstallPanel] scan failed', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scan();
|
||||
}, [scan]);
|
||||
|
||||
const purge = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const report = await invoke('uninstall_purge', { includeModels });
|
||||
if (report?.failed?.length) {
|
||||
toast.error(
|
||||
t('settings.uninstall_partial', {
|
||||
defaultValue: 'Some folders could not be removed: {{paths}}',
|
||||
paths: report.failed.join(', '),
|
||||
}),
|
||||
{ duration: 10000 },
|
||||
);
|
||||
}
|
||||
// The Python env we run on is gone — quit rather than pretend to carry on.
|
||||
await invoke('quit_app').catch(() => {});
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
toast.error(
|
||||
t('settings.uninstall_failed', {
|
||||
defaultValue: 'Could not remove the data: {{message}}',
|
||||
message: e?.message || String(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!inTauri()) return null;
|
||||
|
||||
const present = (targets || []).filter((x) => x.exists);
|
||||
const models = present.find((x) => x.shared);
|
||||
const owned = present.filter((x) => !x.shared);
|
||||
const willFree = freedBytes(targets, includeModels);
|
||||
const LABELS = {
|
||||
data: t('settings.uninstall_target_data', {
|
||||
defaultValue: 'Voices, projects, generated audio, history',
|
||||
}),
|
||||
env: t('settings.uninstall_target_env', {
|
||||
defaultValue: 'Settings + the managed Python environment',
|
||||
}),
|
||||
logs: t('settings.uninstall_target_logs', { defaultValue: 'Logs' }),
|
||||
models: t('settings.uninstall_target_models', {
|
||||
defaultValue: 'Downloaded model weights (shared Hugging Face cache)',
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
icon={Trash2}
|
||||
title={t('settings.uninstall', { defaultValue: 'Remove all data' })}
|
||||
description={t('settings.uninstall_desc', {
|
||||
defaultValue: 'Delete everything OmniVoice has written to this machine, then quit.',
|
||||
})}
|
||||
>
|
||||
<p className="m-0 mb-[var(--space-4)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.uninstall_body', {
|
||||
defaultValue:
|
||||
'OmniVoice is fully local, so uninstalling is just deleting the folders it wrote. This removes your voice profiles, projects, and generated audio permanently — there is no undo. Removing the app itself is a separate step.',
|
||||
})}
|
||||
</p>
|
||||
{owned.length > 0 && (
|
||||
<div className="mb-[var(--space-4)] flex flex-col gap-[var(--space-2)]">
|
||||
{owned.map((tg) => (
|
||||
<StorageTargetRow
|
||||
key={tg.key}
|
||||
icon={ICONS[tg.key]}
|
||||
label={LABELS[tg.key] || tg.key}
|
||||
path={tg.path}
|
||||
size={tg.size_bytes}
|
||||
share={willFree > 0 ? tg.size_bytes / willFree : 0}
|
||||
testId={`uninstall-target-${tg.key}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The shared cache is a different KIND of thing, so it gets its own
|
||||
group and its own checkbox — here, in the list, not buried in the
|
||||
confirm dialog, so the total on the button moves when you tick it. */}
|
||||
{models && (
|
||||
<>
|
||||
<span className="mt-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-xs)] uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-dim)]">
|
||||
{t('settings.uninstall_optional_group', { defaultValue: 'Optional' })}
|
||||
</span>
|
||||
<StorageTargetRow
|
||||
icon={ICONS.models}
|
||||
label={LABELS.models}
|
||||
hint={t('settings.uninstall_models_caveat', {
|
||||
defaultValue:
|
||||
'The standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Anything OmniVoice needs downloads again.',
|
||||
})}
|
||||
path={models.path}
|
||||
size={models.size_bytes}
|
||||
share={willFree > 0 ? models.size_bytes / willFree : 0}
|
||||
checked={includeModels}
|
||||
onToggle={(e) => setIncludeModels(e.target.checked)}
|
||||
warn
|
||||
testId="uninstall-include-models"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="m-0 mt-[var(--space-2)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.uninstall_total', {
|
||||
defaultValue: '{{count}} locations · {{size}} will be freed',
|
||||
count: present.filter((x) => !x.shared || includeModels).length,
|
||||
size: fmtBytes(willFree),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="md"
|
||||
leading={<Trash2 size={13} />}
|
||||
onClick={() => {
|
||||
setTyped('');
|
||||
setOpen(true);
|
||||
}}
|
||||
data-testid="uninstall-open"
|
||||
>
|
||||
{t('settings.uninstall', { defaultValue: 'Remove all data' })}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => !busy && setOpen(false)}
|
||||
title={t('settings.uninstall_confirm_title', {
|
||||
defaultValue: 'Remove all OmniVoice data?',
|
||||
})}
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setOpen(false)}>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={busy}
|
||||
disabled={busy || typed.trim().toUpperCase() !== CONFIRM_WORD}
|
||||
onClick={purge}
|
||||
data-testid="uninstall-confirm"
|
||||
>
|
||||
{t('settings.uninstall_confirm', {
|
||||
defaultValue: 'Delete {{size}} and quit',
|
||||
size: fmtBytes(willFree),
|
||||
})}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-[var(--space-4)]">
|
||||
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-md)] leading-[1.6] text-[var(--chrome-fg)]">
|
||||
<AlertTriangle size={16} className="mt-1 shrink-0 text-[var(--color-danger)]" />
|
||||
<span>
|
||||
{t('settings.uninstall_confirm_body', {
|
||||
defaultValue:
|
||||
'Your voice profiles, projects, and generated audio will be permanently deleted. This cannot be undone.',
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* What is actually going, at the moment of no return. The opt-in for
|
||||
the shared cache lives in the list behind this dialog — asking twice
|
||||
invites the user to skim, and this is the screen to read. */}
|
||||
<ul className="m-0 list-none p-0" data-testid="uninstall-summary">
|
||||
{present
|
||||
.filter((x) => !x.shared || includeModels)
|
||||
.map((tg) => (
|
||||
<li
|
||||
key={tg.key}
|
||||
className="flex items-baseline justify-between gap-[var(--space-3)] py-[var(--space-1)] [font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]"
|
||||
>
|
||||
<span>{LABELS[tg.key] || tg.key}</span>
|
||||
<span className="shrink-0 [font-family:var(--font-mono)] text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg-muted)]">
|
||||
{fmtBytes(tg.size_bytes)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{models && includeModels && (
|
||||
<p className="m-0 flex items-start gap-[var(--space-3)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className="mt-1 shrink-0 text-[var(--chrome-severity-warn)]"
|
||||
/>
|
||||
<span data-testid="uninstall-models-warning">
|
||||
{t('settings.uninstall_models_warning', {
|
||||
defaultValue:
|
||||
'This includes the shared Hugging Face cache ({{size}}) — models other AI tools downloaded may go with it.',
|
||||
size: fmtBytes(models.size_bytes),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-[var(--space-2)]">
|
||||
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
|
||||
{t('settings.uninstall_type_to_confirm', {
|
||||
defaultValue: 'Type {{word}} to confirm:',
|
||||
word: CONFIRM_WORD,
|
||||
})}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
data-testid="uninstall-type-confirm"
|
||||
className="rounded-[var(--radius-md)] [border:1px_solid_var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-[var(--space-3)] py-[var(--space-2)] [font-family:var(--font-mono)] text-[length:var(--text-md)] text-[var(--chrome-fg)] focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import UninstallPanel from './UninstallPanel';
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: (...a) => invoke(...a) }));
|
||||
|
||||
const TARGETS = [
|
||||
{
|
||||
key: 'data',
|
||||
path: '/u/Library/Application Support/OmniVoice',
|
||||
size_bytes: 720 * 1024,
|
||||
exists: true,
|
||||
shared: false,
|
||||
},
|
||||
{
|
||||
key: 'env',
|
||||
path: '/u/Library/Application Support/com.debpalash.omnivoice-studio',
|
||||
size_bytes: 391,
|
||||
exists: true,
|
||||
shared: false,
|
||||
},
|
||||
{ key: 'logs', path: '/u/Library/Logs/OmniVoice', size_bytes: 4096, exists: true, shared: false },
|
||||
{
|
||||
key: 'models',
|
||||
path: '/u/.cache/huggingface',
|
||||
size_bytes: 7.5 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('UninstallPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.__TAURI_INTERNALS__ = {};
|
||||
invoke.mockImplementation(async (cmd) => (cmd === 'uninstall_scan' ? TARGETS : null));
|
||||
});
|
||||
|
||||
const ready = async () => {
|
||||
render(<UninstallPanel />);
|
||||
await waitFor(() => expect(screen.getByTestId('uninstall-target-data')).toBeInTheDocument());
|
||||
};
|
||||
|
||||
it('renders a 391-byte folder as bytes, not as "0 KB"', async () => {
|
||||
// The old formatter floored at KB, so the config folder read as empty.
|
||||
await ready();
|
||||
expect(screen.getByText('391 B')).toBeInTheDocument();
|
||||
expect(screen.getByText('720 KB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the shared model cache out of the total until it is ticked', async () => {
|
||||
await ready();
|
||||
// 720 KB + 391 B + 4 KB — the 7.5 GB cache is opt-in and must not be counted.
|
||||
expect(screen.getByText(/3 locations/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/724 KB will be freed/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('uninstall-include-models'));
|
||||
|
||||
// Ticking it moves the total *in the panel* — the number is live, not a
|
||||
// surprise sprung on the user in the confirm dialog.
|
||||
await waitFor(() => expect(screen.getByText(/4 locations/)).toBeInTheDocument());
|
||||
expect(screen.getByText(/7\.5 GB will be freed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives each folder a bar sized to its share of what will be freed', async () => {
|
||||
await ready();
|
||||
const bar = (key) => screen.getByTestId(`uninstall-target-${key}-bar`).firstChild;
|
||||
// data is ~99% of the 724 KB being freed; env (391 B) is a sliver.
|
||||
expect(parseInt(bar('data').style.width, 10)).toBeGreaterThan(90);
|
||||
expect(parseInt(bar('env').style.width, 10)).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('the confirm dialog lists exactly what is going, and warns only when the shared cache is in', async () => {
|
||||
await ready();
|
||||
fireEvent.click(screen.getByTestId('uninstall-open'));
|
||||
|
||||
const summary = await screen.findByTestId('uninstall-summary');
|
||||
expect(summary.children).toHaveLength(3); // the cache is not ticked
|
||||
expect(screen.queryByTestId('uninstall-models-warning')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('uninstall-confirm')); // still gated on the typed word
|
||||
expect(invoke).not.toHaveBeenCalledWith('uninstall_purge', expect.anything());
|
||||
});
|
||||
|
||||
it('warns in the dialog once the shared cache is included', async () => {
|
||||
await ready();
|
||||
fireEvent.click(screen.getByTestId('uninstall-include-models'));
|
||||
fireEvent.click(screen.getByTestId('uninstall-open'));
|
||||
|
||||
expect(await screen.findByTestId('uninstall-models-warning')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('uninstall-summary').children).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('will not purge until DELETE is typed', async () => {
|
||||
await ready();
|
||||
fireEvent.click(screen.getByTestId('uninstall-open'));
|
||||
const confirm = await screen.findByTestId('uninstall-confirm');
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('uninstall-type-confirm'), { target: { value: 'DELETE' } });
|
||||
await waitFor(() => expect(confirm).toBeEnabled());
|
||||
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('uninstall_purge', { includeModels: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Byte formatting for the Storage panels.
|
||||
*
|
||||
* Deliberately not `models/format.fmtBytes`: that one floors at kilobytes
|
||||
* (`Math.round(n / 1024)` + " KB"), so a 391-byte config file renders as
|
||||
* "0 KB" — which reads as "nothing here" for a folder that very much exists.
|
||||
* These panels list real folders and must be able to say "391 B".
|
||||
*/
|
||||
|
||||
/** "1.4 GB" / "820 MB" / "12 KB" / "391 B". Pure. */
|
||||
export function fmtBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let n = bytes;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
||||
}
|
||||
@@ -189,8 +189,15 @@ export const GROUPS = [
|
||||
'storage',
|
||||
'data directory',
|
||||
'outputs directory',
|
||||
// "factory reset" is what users search for even though the feature is
|
||||
// now the broader "Reset & remove" — keep the old name findable.
|
||||
'factory reset',
|
||||
'reset',
|
||||
'wipe',
|
||||
'delete models',
|
||||
'uninstall',
|
||||
'remove all data',
|
||||
'start over',
|
||||
'disk usage',
|
||||
'free space',
|
||||
'disk space',
|
||||
@@ -199,7 +206,7 @@ export const GROUPS = [
|
||||
'temp files',
|
||||
'clear logs',
|
||||
],
|
||||
keywordKeys: ['settings.storage_usage', 'settings.factory_reset'],
|
||||
keywordKeys: ['settings.storage_usage', 'settings.reset', 'settings.uninstall'],
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "عرض مقاييس النظام المباشرة في الرأس",
|
||||
"header_live_stats_desc": "يضيف شاشة RAM / CPU / VRAM مباشرة إلى الشريط العلوي (يتم إيقاف تشغيله افتراضيًا).",
|
||||
"storage_desc": "حيث يحتفظ OmniVoice ببياناتك ومخرجاتك.",
|
||||
"factory_reset": "إعادة ضبط المصنع",
|
||||
"factory_reset_desc": "إعادة تعيين جميع التفضيلات داخل التطبيق إلى إعداداتها الافتراضية. ملفاتك تبقى دون تغيير.",
|
||||
"factory_reset_body": "مسح الإعدادات المحفوظة محليًا (السمة، واللغة، ومقابض الدبلجة، ومفضلات المعرض، وتفضيلات واجهة المستخدم الأخرى). لا يحذف أصواتك أو مشاريعك أو الصوت الذي تم إنشاؤه على القرص.",
|
||||
"factory_reset_confirm_title": "هل تريد إعادة ضبط التفضيلات؟",
|
||||
"factory_reset_confirm": "إعادة تعيين وإعادة تحميل",
|
||||
"factory_reset_confirm_body": "يؤدي هذا إلى مسح جميع تفضيلات واجهة المستخدم المحفوظة وإعادة تحميل التطبيق. لن تتأثر أصواتك ومشاريعك ومخرجاتك الموجودة على القرص. يكمل؟",
|
||||
"factory_reset_done": "تم مسح التفضيلات — جارٍ إعادة التحميل...",
|
||||
"factory_reset_failed": "فشلت إعادة التعيين",
|
||||
"history_retention": "سجل التوليد",
|
||||
"history_retention_desc": "عدد اللقطات المحتفظ بها قبل تنظيف الأقدم.",
|
||||
"history_retention_help": "بعد كل عملية توليد، تُحذف أقدم اللقطات غير المميزة بنجمة التي تتجاوز هذا الحد مع ملفاتها الصوتية. اللقطات المميزة بنجمة تبقى دائمًا. 0 = الاحتفاظ بالكل.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "موقع الكتروني",
|
||||
"website_desc": "المزيد عن المشروع والصانع."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Live-Systemmetriken in der Kopfzeile anzeigen",
|
||||
"header_live_stats_desc": "Fügt der oberen Leiste einen Live-RAM-/CPU-/VRAM-Monitor hinzu (standardmäßig deaktiviert).",
|
||||
"storage_desc": "Wo OmniVoice Ihre Daten und Ausgaben speichert.",
|
||||
"factory_reset": "Werksreset",
|
||||
"factory_reset_desc": "Setzen Sie alle In-App-Einstellungen auf ihre Standardeinstellungen zurück. Ihre Dateien bleiben unberührt.",
|
||||
"factory_reset_body": "Löscht lokal gespeicherte Einstellungen (Thema, Sprache, Dub-Regler, Galerie-Favoriten und andere UI-Einstellungen). Es löscht NICHT Ihre Stimmen, Projekte oder generierten Audiodaten auf der Festplatte.",
|
||||
"factory_reset_confirm_title": "Einstellungen zurücksetzen?",
|
||||
"factory_reset_confirm": "Zurücksetzen und neu laden",
|
||||
"factory_reset_confirm_body": "Dadurch werden alle gespeicherten Benutzeroberflächeneinstellungen gelöscht und die App neu geladen. Ihre Stimmen, Projekte und Ausgaben auf der Festplatte sind nicht betroffen. Weitermachen?",
|
||||
"factory_reset_done": "Einstellungen gelöscht – wird neu geladen…",
|
||||
"factory_reset_failed": "Zurücksetzen fehlgeschlagen",
|
||||
"history_retention": "Generierungsverlauf",
|
||||
"history_retention_desc": "Wie viele Takes behalten werden, bevor die ältesten aufgeräumt werden.",
|
||||
"history_retention_help": "Nach jeder Generierung werden die ältesten nicht markierten Takes über diesem Limit samt Audiodateien entfernt. Markierte Takes bleiben immer erhalten. 0 = alles behalten.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Website",
|
||||
"website_desc": "Mehr über das Projekt und den Macher."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,14 +577,59 @@
|
||||
"header_live_stats": "Show live system metrics in header",
|
||||
"header_live_stats_desc": "Adds a live RAM / CPU / VRAM monitor to the top bar (off by default).",
|
||||
"storage_desc": "Where OmniVoice keeps your data and outputs.",
|
||||
"factory_reset": "Factory reset",
|
||||
"factory_reset_desc": "Reset all in-app preferences to their defaults. Your files stay untouched.",
|
||||
"factory_reset_body": "Clears locally-saved settings (theme, language, dub knobs, gallery favorites, and other UI preferences). It does NOT delete your voices, projects, or generated audio on disk.",
|
||||
"factory_reset_confirm_title": "Reset preferences?",
|
||||
"factory_reset_confirm": "Reset and reload",
|
||||
"factory_reset_confirm_body": "This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. Continue?",
|
||||
"factory_reset_done": "Preferences cleared — reloading…",
|
||||
"factory_reset_failed": "Reset failed: {{message}}",
|
||||
"uninstall": "Remove all data",
|
||||
"uninstall_desc": "Delete everything OmniVoice has written to this machine, then quit.",
|
||||
"uninstall_body": "OmniVoice is fully local, so uninstalling is just deleting the folders it wrote. This removes your voice profiles, projects, and generated audio permanently — there is no undo. Removing the app itself is a separate step.",
|
||||
"uninstall_target_data": "Voices, projects, generated audio, history",
|
||||
"uninstall_target_env": "Settings + the managed Python environment",
|
||||
"uninstall_target_logs": "Logs",
|
||||
"uninstall_target_models": "Downloaded model weights (shared Hugging Face cache)",
|
||||
"uninstall_confirm_title": "Remove all OmniVoice data?",
|
||||
"uninstall_confirm_body": "Your voice profiles, projects, and generated audio will be permanently deleted. This cannot be undone.",
|
||||
"uninstall_type_to_confirm": "Type {{word}} to confirm:",
|
||||
"uninstall_confirm_word": "DELETE",
|
||||
"uninstall_confirm": "Delete {{size}} and quit",
|
||||
"uninstall_partial": "Some folders could not be removed: {{paths}}",
|
||||
"uninstall_failed": "Could not remove the data: {{message}}",
|
||||
"uninstall_optional_group": "Optional",
|
||||
"uninstall_models_caveat": "The standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Anything OmniVoice needs downloads again.",
|
||||
"uninstall_models_warning": "This includes the shared Hugging Face cache ({{size}}) — models other AI tools downloaded may go with it.",
|
||||
"uninstall_total": "{{count}} locations · {{size}} will be freed",
|
||||
"storage_target_empty": "Nothing to remove",
|
||||
"reset": "Reset & remove",
|
||||
"reset_desc": "Put part — or all — of OmniVoice back to how it shipped.",
|
||||
"reset_body_web": "Clears locally-saved preferences (theme, language, dub settings) and reloads. Your voices, projects and generated audio are not affected.",
|
||||
"reset_tier_ui": "UI preferences only",
|
||||
"reset_tier_ui_hint": "Theme, layout and dub knobs go back to defaults. Nothing on disk changes.",
|
||||
"reset_tier_settings": "All settings",
|
||||
"reset_tier_settings_hint": "Every preference, in the app and on disk. Your voices, projects and models are untouched.",
|
||||
"reset_tier_assets": "Downloaded assets & models",
|
||||
"reset_tier_assets_hint": "Model weights, sidecar engines, audio tools and caches. Everything you made stays. They re-download when next needed.",
|
||||
"reset_tier_everything": "Everything OmniVoice did",
|
||||
"reset_tier_everything_hint": "Back to a fresh install: settings, voices, projects, audio, models, engines, logs. The app restarts on the first-run screen.",
|
||||
"reset_advanced": "Choose exactly what to remove",
|
||||
"reset_scope_ui_prefs": "UI preferences — theme, language, layout, dub settings",
|
||||
"reset_scope_settings": "App settings — engine choices, voice defaults, saved options",
|
||||
"reset_scope_history": "Generation & dub history, with their audio",
|
||||
"reset_scope_content": "Voices, projects, generated audio, and the app database",
|
||||
"reset_scope_engines": "Installed sidecar engines (IndexTTS-2 and friends)",
|
||||
"reset_scope_tools": "Downloaded audio tools (ffmpeg, ffprobe, yt-dlp)",
|
||||
"reset_scope_models": "Downloaded model weights",
|
||||
"reset_scope_caches": "Caches and temporary files",
|
||||
"reset_scope_logs": "Logs and crash reports",
|
||||
"reset_models_shared": "Shared Hugging Face cache — may hold models other AI tools downloaded.",
|
||||
"reset_models_shared_warning": "The model cache is the standard Hugging Face cache, shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. Everything OmniVoice needs will download again on next use.",
|
||||
"reset_confirm_title": "Reset OmniVoice?",
|
||||
"reset_confirm": "Reset and reload",
|
||||
"reset_confirm_restart": "Remove {{size}} and restart",
|
||||
"reset_confirm_word": "DELETE",
|
||||
"reset_type_to_confirm": "Type {{word}} to confirm:",
|
||||
"reset_irreversible": "Your voice profiles, projects and generated audio will be permanently deleted. This cannot be undone.",
|
||||
"reset_restart_note": "OmniVoice will restart its engine to finish. This takes a few seconds.",
|
||||
"reset_done": "Reset complete — reloading…",
|
||||
"reset_done_restart": "Reset complete — restarting OmniVoice…",
|
||||
"reset_partial": "Some items could not be removed: {{paths}}",
|
||||
"reset_failed": "Reset failed: {{message}}",
|
||||
"storage_usage": "Disk usage",
|
||||
"storage_usage_desc": "What OmniVoice stores on this machine, and how much space is left.",
|
||||
"storage_refresh": "Refresh",
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Mostrar métricas del sistema en vivo en el encabezado",
|
||||
"header_live_stats_desc": "Agrega un monitor de RAM/CPU/VRAM en vivo a la barra superior (desactivado de forma predeterminada).",
|
||||
"storage_desc": "Donde OmniVoice guarda sus datos y resultados.",
|
||||
"factory_reset": "Restablecimiento de fábrica",
|
||||
"factory_reset_desc": "Restablezca todas las preferencias de la aplicación a sus valores predeterminados. Tus archivos permanecen intactos.",
|
||||
"factory_reset_body": "Borra las configuraciones guardadas localmente (tema, idioma, botones de doblaje, favoritos de la galería y otras preferencias de la interfaz de usuario). NO elimina sus voces, proyectos o audio generado en el disco.",
|
||||
"factory_reset_confirm_title": "¿Restablecer preferencias?",
|
||||
"factory_reset_confirm": "Reiniciar y recargar",
|
||||
"factory_reset_confirm_body": "Esto borra todas las preferencias de UI guardadas y recarga la aplicación. Sus voces, proyectos y salidas en disco no se ven afectados. ¿Continuar?",
|
||||
"factory_reset_done": "Preferencias borradas: recargando...",
|
||||
"factory_reset_failed": "Error al restablecer",
|
||||
"history_retention": "Historial de generación",
|
||||
"history_retention_desc": "Cuántas tomas conservar antes de limpiar las más antiguas.",
|
||||
"history_retention_help": "Tras cada generación, las tomas más antiguas sin estrella que superen este límite se eliminan junto con sus archivos de audio. Las tomas destacadas siempre se conservan. 0 = conservar todo.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Sitio web",
|
||||
"website_desc": "Más sobre el proyecto y el creador."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Afficher les métriques du système en direct dans l'en-tête",
|
||||
"header_live_stats_desc": "Ajoute un moniteur RAM / CPU / VRAM en direct à la barre supérieure (désactivé par défaut).",
|
||||
"storage_desc": "Où OmniVoice conserve vos données et sorties.",
|
||||
"factory_reset": "Réinitialisation d'usine",
|
||||
"factory_reset_desc": "Réinitialisez toutes les préférences de l'application à leurs valeurs par défaut. Vos fichiers restent intacts.",
|
||||
"factory_reset_body": "Efface les paramètres enregistrés localement (thème, langue, boutons de doublage, favoris de la galerie et autres préférences de l'interface utilisateur). Il ne supprime PAS vos voix, projets ou audio générés sur le disque.",
|
||||
"factory_reset_confirm_title": "Réinitialiser les préférences ?",
|
||||
"factory_reset_confirm": "Réinitialiser et recharger",
|
||||
"factory_reset_confirm_body": "Cela efface toutes les préférences d'interface utilisateur enregistrées et recharge l'application. Vos voix, projets et sorties sur disque ne sont pas affectés. Continuer?",
|
||||
"factory_reset_done": "Préférences effacées — rechargement…",
|
||||
"factory_reset_failed": "Échec de la réinitialisation",
|
||||
"history_retention": "Historique de génération",
|
||||
"history_retention_desc": "Nombre de prises à conserver avant de nettoyer les plus anciennes.",
|
||||
"history_retention_help": "Après chaque génération, les prises les plus anciennes sans étoile au-delà de cette limite sont supprimées avec leurs fichiers audio. Les prises étoilées sont toujours conservées. 0 = tout conserver.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Site Web",
|
||||
"website_desc": "En savoir plus sur le projet et le créateur."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "हेडर में लाइव सिस्टम मेट्रिक्स दिखाएं",
|
||||
"header_live_stats_desc": "शीर्ष बार में एक लाइव रैम/सीपीयू/वीआरएएम मॉनिटर जोड़ता है (डिफ़ॉल्ट रूप से बंद)।",
|
||||
"storage_desc": "जहां ओमनीवॉइस आपका डेटा और आउटपुट रखता है।",
|
||||
"factory_reset": "फ़ैक्टरी रीसेट",
|
||||
"factory_reset_desc": "सभी इन-ऐप प्राथमिकताओं को उनके डिफ़ॉल्ट पर रीसेट करें। आपकी फ़ाइलें अछूती रहती हैं.",
|
||||
"factory_reset_body": "स्थानीय रूप से सहेजी गई सेटिंग्स (थीम, भाषा, डब नॉब्स, गैलरी पसंदीदा और अन्य यूआई प्राथमिकताएं) साफ़ करता है। यह आपकी आवाज़ों, प्रोजेक्टों या डिस्क पर उत्पन्न ऑडियो को नहीं हटाता है।",
|
||||
"factory_reset_confirm_title": "प्राथमिकताएँ रीसेट करें?",
|
||||
"factory_reset_confirm": "रीसेट करें और पुनः लोड करें",
|
||||
"factory_reset_confirm_body": "इससे सभी सहेजी गई यूआई प्राथमिकताएँ साफ़ हो जाती हैं और ऐप पुनः लोड हो जाता है। डिस्क पर आपकी आवाज़ें, प्रोजेक्ट और आउटपुट प्रभावित नहीं होंगे। जारी रखना?",
|
||||
"factory_reset_done": "प्राथमिकताएँ साफ़ की गईं - पुनः लोड हो रहा है...",
|
||||
"factory_reset_failed": "रीसेट विफल रहा",
|
||||
"history_retention": "जनरेशन इतिहास",
|
||||
"history_retention_desc": "सबसे पुराने टेक साफ़ करने से पहले कितने टेक रखें।",
|
||||
"history_retention_help": "हर जनरेशन के बाद, इस सीमा से अधिक सबसे पुराने बिना-स्टार टेक उनकी ऑडियो फ़ाइलों सहित हटा दिए जाते हैं। स्टार किए गए टेक हमेशा रखे जाते हैं। 0 = सब रखें।",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "वेबसाइट",
|
||||
"website_desc": "परियोजना और निर्माता के बारे में अधिक जानकारी."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Tampilkan metrik sistem langsung di header",
|
||||
"header_live_stats_desc": "Menambahkan monitor RAM/CPU/VRAM langsung ke bilah atas (dinonaktifkan secara default).",
|
||||
"storage_desc": "Tempat OmniVoice menyimpan data dan keluaran Anda.",
|
||||
"factory_reset": "Reset pabrik",
|
||||
"factory_reset_desc": "Reset semua preferensi dalam aplikasi ke defaultnya. File Anda tetap tidak tersentuh.",
|
||||
"factory_reset_body": "Menghapus pengaturan yang disimpan secara lokal (tema, bahasa, kenop sulih suara, favorit galeri, dan preferensi UI lainnya). Itu TIDAK menghapus suara, proyek, atau audio yang dihasilkan pada disk.",
|
||||
"factory_reset_confirm_title": "Setel ulang preferensi?",
|
||||
"factory_reset_confirm": "Setel ulang dan muat ulang",
|
||||
"factory_reset_confirm_body": "Ini menghapus semua preferensi UI yang tersimpan dan memuat ulang aplikasi. Suara, proyek, dan keluaran Anda pada disk tidak terpengaruh. Melanjutkan?",
|
||||
"factory_reset_done": "Preferensi dihapus — memuat ulang…",
|
||||
"factory_reset_failed": "Penyetelan ulang gagal",
|
||||
"history_retention": "Riwayat pembuatan",
|
||||
"history_retention_desc": "Berapa banyak take yang disimpan sebelum yang tertua dibersihkan.",
|
||||
"history_retention_help": "Setelah setiap pembuatan, take tertua tanpa bintang yang melebihi batas ini dihapus beserta berkas audionya. Take berbintang selalu disimpan. 0 = simpan semua.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Situs web",
|
||||
"website_desc": "Lebih lanjut tentang proyek dan pembuatnya."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Mostra le metriche del sistema in tempo reale nell'intestazione",
|
||||
"header_live_stats_desc": "Aggiunge un monitor RAM/CPU/VRAM live alla barra superiore (disattivato per impostazione predefinita).",
|
||||
"storage_desc": "Dove OmniVoice conserva i tuoi dati e i tuoi output.",
|
||||
"factory_reset": "Ripristino delle impostazioni di fabbrica",
|
||||
"factory_reset_desc": "Ripristina tutte le preferenze in-app ai valori predefiniti. I tuoi file rimangono intatti.",
|
||||
"factory_reset_body": "Cancella le impostazioni salvate localmente (tema, lingua, manopole dub, preferiti della galleria e altre preferenze dell'interfaccia utente). NON elimina le tue voci, i tuoi progetti o l'audio generato sul disco.",
|
||||
"factory_reset_confirm_title": "Reimpostare le preferenze?",
|
||||
"factory_reset_confirm": "Reimposta e ricarica",
|
||||
"factory_reset_confirm_body": "Ciò cancella tutte le preferenze dell'interfaccia utente salvate e ricarica l'app. Le tue voci, progetti e output su disco non sono interessati. Continuare?",
|
||||
"factory_reset_done": "Preferenze cancellate: ricaricamento in corso...",
|
||||
"factory_reset_failed": "Reimpostazione non riuscita",
|
||||
"history_retention": "Cronologia generazioni",
|
||||
"history_retention_desc": "Quanti take conservare prima di ripulire i più vecchi.",
|
||||
"history_retention_help": "Dopo ogni generazione, i take più vecchi senza stella oltre questo limite vengono rimossi insieme ai file audio. I take con stella vengono sempre conservati. 0 = conserva tutto.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Sito web",
|
||||
"website_desc": "Maggiori informazioni sul progetto e sul produttore."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "ライブシステムメトリクスをヘッダーに表示",
|
||||
"header_live_stats_desc": "ライブ RAM / CPU / VRAM モニターをトップバーに追加します (デフォルトではオフ)。",
|
||||
"storage_desc": "OmniVoice がデータと出力を保管する場所。",
|
||||
"factory_reset": "工場出荷時設定にリセット",
|
||||
"factory_reset_desc": "すべてのアプリ内設定をデフォルトにリセットします。ファイルはそのまま残ります。",
|
||||
"factory_reset_body": "ローカルに保存された設定 (テーマ、言語、ダブノブ、ギャラリーのお気に入り、その他の UI 設定) をクリアします。ディスク上の音声、プロジェクト、生成されたオーディオは削除されません。",
|
||||
"factory_reset_confirm_title": "設定をリセットしますか?",
|
||||
"factory_reset_confirm": "リセットしてリロードする",
|
||||
"factory_reset_confirm_body": "これにより、保存されている UI 設定がすべてクリアされ、アプリがリロードされます。ディスク上の音声、プロジェクト、出力は影響を受けません。続く?",
|
||||
"factory_reset_done": "設定がクリアされました — 再読み込み中…",
|
||||
"factory_reset_failed": "リセットに失敗しました",
|
||||
"history_retention": "生成履歴",
|
||||
"history_retention_desc": "古いテイクを整理するまでに保持するテイク数。",
|
||||
"history_retention_help": "生成のたびに、この上限を超えた古いスターなしテイクは音声ファイルごと削除されます。スター付きテイクは常に保持されます。0 = すべて保持。",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "ウェブサイト",
|
||||
"website_desc": "プロジェクトとメーカーについて詳しく説明します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "헤더에 실시간 시스템 측정항목 표시",
|
||||
"header_live_stats_desc": "상단 표시줄에 라이브 RAM/CPU/VRAM 모니터를 추가합니다(기본적으로 꺼져 있음).",
|
||||
"storage_desc": "OmniVoice가 데이터와 출력을 보관하는 곳입니다.",
|
||||
"factory_reset": "공장 초기화",
|
||||
"factory_reset_desc": "모든 인앱 환경설정을 기본값으로 재설정합니다. 귀하의 파일은 그대로 유지됩니다.",
|
||||
"factory_reset_body": "로컬에 저장된 설정(테마, 언어, 더빙 노브, 갤러리 즐겨찾기 및 기타 UI 기본 설정)을 지웁니다. 디스크에 있는 음성, 프로젝트 또는 생성된 오디오는 삭제되지 않습니다.",
|
||||
"factory_reset_confirm_title": "환경설정을 재설정하시겠습니까?",
|
||||
"factory_reset_confirm": "재설정 및 새로고침",
|
||||
"factory_reset_confirm_body": "This clears all saved UI preferences and reloads the app. Your voices, projects, and outputs on disk are not affected. 계속하다?",
|
||||
"factory_reset_done": "환경설정이 삭제되었습니다 — 새로고침 중…",
|
||||
"factory_reset_failed": "재설정 실패",
|
||||
"history_retention": "생성 기록",
|
||||
"history_retention_desc": "오래된 테이크를 정리하기 전에 유지할 테이크 수입니다.",
|
||||
"history_retention_help": "생성할 때마다 이 한도를 초과한 오래된 별표 없는 테이크가 오디오 파일과 함께 삭제됩니다. 별표된 테이크는 항상 유지됩니다. 0 = 모두 유지.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "웹사이트",
|
||||
"website_desc": "프로젝트와 제작자에 대해 자세히 알아보세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Toon live systeemstatistieken in de koptekst",
|
||||
"header_live_stats_desc": "Voegt een live RAM/CPU/VRAM-monitor toe aan de bovenste balk (standaard uitgeschakeld).",
|
||||
"storage_desc": "Waar OmniVoice uw gegevens en output bewaart.",
|
||||
"factory_reset": "Fabrieksreset",
|
||||
"factory_reset_desc": "Zet alle in-app-voorkeuren terug naar hun standaardwaarden. Uw bestanden blijven onaangeroerd.",
|
||||
"factory_reset_body": "Wist lokaal opgeslagen instellingen (thema, taal, kopieerknoppen, galerijfavorieten en andere UI-voorkeuren). Het verwijdert NIET uw stemmen, projecten of gegenereerde audio op schijf.",
|
||||
"factory_reset_confirm_title": "Voorkeuren opnieuw instellen?",
|
||||
"factory_reset_confirm": "Resetten en opnieuw laden",
|
||||
"factory_reset_confirm_body": "Hiermee worden alle opgeslagen UI-voorkeuren gewist en wordt de app opnieuw geladen. Uw stemmen, projecten en outputs op schijf worden niet beïnvloed. Doorgaan?",
|
||||
"factory_reset_done": "Voorkeuren gewist — herladen…",
|
||||
"factory_reset_failed": "Resetten mislukt",
|
||||
"history_retention": "Generatiegeschiedenis",
|
||||
"history_retention_desc": "Hoeveel takes bewaard blijven voordat de oudste worden opgeruimd.",
|
||||
"history_retention_help": "Na elke generatie worden de oudste takes zonder ster boven deze limiet verwijderd, samen met hun audiobestanden. Takes met ster blijven altijd bewaard. 0 = alles bewaren.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Website",
|
||||
"website_desc": "Meer over het project en de maker."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Pokaż aktualne dane systemowe w nagłówku",
|
||||
"header_live_stats_desc": "Dodaje bieżący monitor RAM/CPU/VRAM do górnego paska (domyślnie wyłączony).",
|
||||
"storage_desc": "Miejsce, w którym OmniVoice przechowuje Twoje dane i wyniki.",
|
||||
"factory_reset": "Reset do ustawień fabrycznych",
|
||||
"factory_reset_desc": "Zresetuj wszystkie preferencje w aplikacji do wartości domyślnych. Twoje pliki pozostają nietknięte.",
|
||||
"factory_reset_body": "Czyści ustawienia zapisane lokalnie (motyw, język, pokrętła dub, ulubione galerie i inne preferencje interfejsu użytkownika). NIE usuwa Twoich głosów, projektów ani wygenerowanego dźwięku na dysku.",
|
||||
"factory_reset_confirm_title": "Zresetować preferencje?",
|
||||
"factory_reset_confirm": "Zresetuj i załaduj ponownie",
|
||||
"factory_reset_confirm_body": "Spowoduje to usunięcie wszystkich zapisanych preferencji interfejsu użytkownika i ponowne załadowanie aplikacji. Nie ma to wpływu na Twoje głosy, projekty i wyjścia na dysku. Kontynuować?",
|
||||
"factory_reset_done": "Preferencje wyczyszczone — ładuję ponownie…",
|
||||
"factory_reset_failed": "Resetowanie nie powiodło się",
|
||||
"history_retention": "Historia generowania",
|
||||
"history_retention_desc": "Ile nagrań zachować, zanim najstarsze zostaną wyczyszczone.",
|
||||
"history_retention_help": "Po każdym generowaniu najstarsze nagrania bez gwiazdki powyżej tego limitu są usuwane wraz z plikami audio. Nagrania z gwiazdką są zawsze zachowywane. 0 = zachowaj wszystko.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Strona internetowa",
|
||||
"website_desc": "Więcej o projekcie i twórcy."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Mostrar métricas do sistema ao vivo no cabeçalho",
|
||||
"header_live_stats_desc": "Adiciona um monitor RAM/CPU/VRAM ativo à barra superior (desativado por padrão).",
|
||||
"storage_desc": "Onde OmniVoice mantém seus dados e resultados.",
|
||||
"factory_reset": "Redefinição de fábrica",
|
||||
"factory_reset_desc": "Redefina todas as preferências do aplicativo para os padrões. Seus arquivos permanecem intactos.",
|
||||
"factory_reset_body": "Limpa configurações salvas localmente (tema, idioma, botões de dublagem, favoritos da galeria e outras preferências da interface do usuário). NÃO exclui suas vozes, projetos ou áudio gerado no disco.",
|
||||
"factory_reset_confirm_title": "Redefinir preferências?",
|
||||
"factory_reset_confirm": "Redefinir e recarregar",
|
||||
"factory_reset_confirm_body": "Isso limpa todas as preferências de UI salvas e recarrega o aplicativo. Suas vozes, projetos e saídas em disco não são afetados. Continuar?",
|
||||
"factory_reset_done": "Preferências limpas — recarregando…",
|
||||
"factory_reset_failed": "Falha na redefinição",
|
||||
"history_retention": "Histórico de geração",
|
||||
"history_retention_desc": "Quantas takes manter antes de limpar as mais antigas.",
|
||||
"history_retention_help": "Após cada geração, as takes mais antigas sem estrela acima deste limite são removidas junto com seus arquivos de áudio. Takes com estrela são sempre mantidas. 0 = manter tudo.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Site",
|
||||
"website_desc": "Mais sobre o projeto e o criador."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Показывать метрики системы в реальном времени в заголовке",
|
||||
"header_live_stats_desc": "Добавляет монитор оперативной памяти/ЦП/ВОЗУ на верхнюю панель (по умолчанию отключен).",
|
||||
"storage_desc": "Где OmniVoice хранит ваши данные и выходные данные.",
|
||||
"factory_reset": "Сброс к заводским настройкам",
|
||||
"factory_reset_desc": "Сбросьте все настройки приложения до значений по умолчанию. Ваши файлы остаются нетронутыми.",
|
||||
"factory_reset_body": "Очищает локально сохраненные настройки (тема, язык, кнопки дублирования, избранное галереи и другие настройки пользовательского интерфейса). Он НЕ удаляет ваши голоса, проекты или созданный звук на диске.",
|
||||
"factory_reset_confirm_title": "Сбросить настройки?",
|
||||
"factory_reset_confirm": "Сброс и перезагрузка",
|
||||
"factory_reset_confirm_body": "Это очистит все сохраненные настройки пользовательского интерфейса и перезагрузит приложение. Ваши голоса, проекты и материалы на диске не будут затронуты. Продолжать?",
|
||||
"factory_reset_done": "Настройки очищены — перезагрузка…",
|
||||
"factory_reset_failed": "Сбросить не удалось",
|
||||
"history_retention": "История генераций",
|
||||
"history_retention_desc": "Сколько дублей хранить, прежде чем удалять самые старые.",
|
||||
"history_retention_help": "После каждой генерации самые старые неотмеченные дубли сверх этого лимита удаляются вместе с аудиофайлами. Отмеченные дубли сохраняются всегда. 0 = хранить всё.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Веб-сайт",
|
||||
"website_desc": "Подробнее о проекте и создателе."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Visa livesystemstatistik i rubriken",
|
||||
"header_live_stats_desc": "Lägger till en live RAM/CPU/VRAM-monitor till den övre raden (av som standard).",
|
||||
"storage_desc": "Där OmniVoice förvarar dina data och utdata.",
|
||||
"factory_reset": "Fabriksåterställning",
|
||||
"factory_reset_desc": "Återställ alla inställningar i appen till standardinställningarna. Dina filer förblir orörda.",
|
||||
"factory_reset_body": "Rensar lokalt sparade inställningar (tema, språk, dubbningsknappar, gallerifavoriter och andra användargränssnittsinställningar). Det tar INTE bort dina röster, projekt eller genererat ljud på disken.",
|
||||
"factory_reset_confirm_title": "Återställa inställningarna?",
|
||||
"factory_reset_confirm": "Återställ och ladda om",
|
||||
"factory_reset_confirm_body": "Detta rensar alla sparade användargränssnittsinställningar och laddar om appen. Dina röster, projekt och utdata på disken påverkas inte. Fortsätta?",
|
||||
"factory_reset_done": "Inställningar rensade – laddar om...",
|
||||
"factory_reset_failed": "Återställningen misslyckades",
|
||||
"history_retention": "Genereringshistorik",
|
||||
"history_retention_desc": "Hur många tagningar som behålls innan de äldsta rensas.",
|
||||
"history_retention_help": "Efter varje generering tas de äldsta ostjärnmärkta tagningarna över gränsen bort tillsammans med sina ljudfiler. Stjärnmärkta tagningar behålls alltid. 0 = behåll allt.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Webbplats",
|
||||
"website_desc": "Mer om projektet och skaparen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "แสดงตัวชี้วัดระบบสดในส่วนหัว",
|
||||
"header_live_stats_desc": "เพิ่มจอภาพ RAM / CPU / VRAM แบบสดไปที่แถบด้านบน (ปิดโดยค่าเริ่มต้น)",
|
||||
"storage_desc": "โดยที่ OmniVoice จะเก็บข้อมูลและเอาต์พุตของคุณ",
|
||||
"factory_reset": "รีเซ็ตเป็นค่าจากโรงงาน",
|
||||
"factory_reset_desc": "รีเซ็ตการตั้งค่าในแอปทั้งหมดเป็นค่าเริ่มต้น ไฟล์ของคุณยังคงไม่มีใครแตะต้อง",
|
||||
"factory_reset_body": "ล้างการตั้งค่าที่บันทึกไว้ในเครื่อง (ธีม ภาษา ปุ่มพากย์ แกลเลอรีรายการโปรด และการตั้งค่า UI อื่นๆ) มันไม่ได้ลบเสียงของคุณ โปรเจ็กต์ หรือเสียงที่สร้างขึ้นบนดิสก์",
|
||||
"factory_reset_confirm_title": "รีเซ็ตการตั้งค่าใช่ไหม",
|
||||
"factory_reset_confirm": "รีเซ็ตและโหลดซ้ำ",
|
||||
"factory_reset_confirm_body": "วิธีนี้จะล้างการตั้งค่า UI ที่บันทึกไว้ทั้งหมดและโหลดแอปซ้ำ เสียง โปรเจ็กต์ และเอาต์พุตของคุณบนดิสก์จะไม่ได้รับผลกระทบ ดำเนินการต่อ?",
|
||||
"factory_reset_done": "ล้างค่ากำหนดแล้ว — กำลังโหลดซ้ำ...",
|
||||
"factory_reset_failed": "การรีเซ็ตล้มเหลว",
|
||||
"history_retention": "ประวัติการสร้าง",
|
||||
"history_retention_desc": "จำนวนเทคที่เก็บไว้ก่อนล้างเทคที่เก่าที่สุด",
|
||||
"history_retention_help": "หลังการสร้างแต่ละครั้ง เทคเก่าที่ไม่ติดดาวซึ่งเกินขีดจำกัดนี้จะถูกลบพร้อมไฟล์เสียง เทคที่ติดดาวจะถูกเก็บไว้เสมอ 0 = เก็บทั้งหมด",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "เว็บไซต์",
|
||||
"website_desc": "ข้อมูลเพิ่มเติมเกี่ยวกับโครงการและผู้สร้าง"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Başlıkta canlı sistem ölçümlerini göster",
|
||||
"header_live_stats_desc": "Üst çubuğa canlı bir RAM / CPU / VRAM monitörü ekler (varsayılan olarak kapalıdır).",
|
||||
"storage_desc": "OmniVoice'un verilerinizi ve çıktılarınızı sakladığı yer.",
|
||||
"factory_reset": "Fabrika ayarlarına sıfırlama",
|
||||
"factory_reset_desc": "Tüm uygulama içi tercihleri varsayılan değerlerine sıfırlayın. Dosyalarınız dokunulmadan kalır.",
|
||||
"factory_reset_body": "Yerel olarak kaydedilen ayarları (tema, dil, dub düğmeleri, galeri favorileri ve diğer kullanıcı arayüzü tercihleri) temizler. Seslerinizi, projelerinizi veya diskte oluşturulan sesleri SİLMEZ.",
|
||||
"factory_reset_confirm_title": "Tercihler sıfırlansın mı?",
|
||||
"factory_reset_confirm": "Sıfırla ve yeniden yükle",
|
||||
"factory_reset_confirm_body": "Bu, kayıtlı tüm kullanıcı arayüzü tercihlerini temizler ve uygulamayı yeniden yükler. Diskteki sesleriniz, projeleriniz ve çıktılarınız etkilenmez. Devam etmek?",
|
||||
"factory_reset_done": "Tercihler temizlendi — yeniden yükleniyor…",
|
||||
"factory_reset_failed": "Sıfırlama başarısız oldu",
|
||||
"history_retention": "Üretim geçmişi",
|
||||
"history_retention_desc": "En eskiler temizlenmeden önce kaç kaydın saklanacağı.",
|
||||
"history_retention_help": "Her üretimden sonra bu sınırı aşan en eski yıldızsız kayıtlar ses dosyalarıyla birlikte silinir. Yıldızlı kayıtlar her zaman saklanır. 0 = tümünü sakla.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Web sitesi",
|
||||
"website_desc": "Proje ve yapımcı hakkında daha fazla bilgi."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Показати живі системні показники в заголовку",
|
||||
"header_live_stats_desc": "Додає живий монітор RAM/CPU/VRAM на верхню панель (за умовчанням вимкнено).",
|
||||
"storage_desc": "Де OmniVoice зберігає ваші дані та результати.",
|
||||
"factory_reset": "Скидання до заводських налаштувань",
|
||||
"factory_reset_desc": "Скинути всі параметри програми до значень за умовчанням. Ваші файли залишаються недоторканими.",
|
||||
"factory_reset_body": "Очищає локально збережені налаштування (тему, мову, ручки дубляжу, вибране з галереї та інші параметри інтерфейсу). Він НЕ видаляє ваші голоси, проекти чи згенероване аудіо на диску.",
|
||||
"factory_reset_confirm_title": "Скинути налаштування?",
|
||||
"factory_reset_confirm": "Скинути та перезавантажити",
|
||||
"factory_reset_confirm_body": "Це видаляє всі збережені параметри інтерфейсу користувача та перезавантажує програму. Ваші голоси, проекти та результати на диску не впливають. Продовжити?",
|
||||
"factory_reset_done": "Налаштування очищено — перезавантаження…",
|
||||
"factory_reset_failed": "Помилка скидання",
|
||||
"history_retention": "Історія генерацій",
|
||||
"history_retention_desc": "Скільки дублів зберігати, перш ніж очищати найстаріші.",
|
||||
"history_retention_help": "Після кожної генерації найстаріші дублі без зірки понад цей ліміт видаляються разом з аудіофайлами. Дублі із зіркою зберігаються завжди. 0 = зберігати все.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Веб-сайт",
|
||||
"website_desc": "Детальніше про проект та виробника."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "Hiển thị số liệu hệ thống trực tiếp trong tiêu đề",
|
||||
"header_live_stats_desc": "Thêm màn hình RAM/CPU/VRAM trực tiếp vào thanh trên cùng (tắt theo mặc định).",
|
||||
"storage_desc": "Nơi OmniVoice lưu giữ dữ liệu và kết quả đầu ra của bạn.",
|
||||
"factory_reset": "Khôi phục cài đặt gốc",
|
||||
"factory_reset_desc": "Đặt lại tất cả các tùy chọn trong ứng dụng về mặc định. Các tập tin của bạn không bị ảnh hưởng.",
|
||||
"factory_reset_body": "Xóa cài đặt được lưu cục bộ (chủ đề, ngôn ngữ, nút lồng tiếng, mục yêu thích trong thư viện và các tùy chọn giao diện người dùng khác). Nó KHÔNG xóa giọng nói, dự án hoặc âm thanh được tạo trên đĩa của bạn.",
|
||||
"factory_reset_confirm_title": "Đặt lại tùy chọn?",
|
||||
"factory_reset_confirm": "Đặt lại và tải lại",
|
||||
"factory_reset_confirm_body": "Thao tác này sẽ xóa tất cả tùy chọn giao diện người dùng đã lưu và tải lại ứng dụng. Giọng nói, dự án và đầu ra trên đĩa của bạn không bị ảnh hưởng. Tiếp tục?",
|
||||
"factory_reset_done": "Đã xóa tùy chọn - đang tải lại…",
|
||||
"factory_reset_failed": "Đặt lại không thành công",
|
||||
"history_retention": "Lịch sử tạo",
|
||||
"history_retention_desc": "Số bản thu được giữ trước khi dọn các bản cũ nhất.",
|
||||
"history_retention_help": "Sau mỗi lần tạo, các bản thu cũ nhất chưa gắn sao vượt quá giới hạn này sẽ bị xóa cùng tệp âm thanh. Bản thu đã gắn sao luôn được giữ lại. 0 = giữ tất cả.",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "Trang web",
|
||||
"website_desc": "Thông tin thêm về dự án và nhà sản xuất."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,14 +380,6 @@
|
||||
"header_live_stats": "在标题中显示实时系统指标",
|
||||
"header_live_stats_desc": "在顶部栏添加实时 RAM/CPU/VRAM 监视器(默认关闭)。",
|
||||
"storage_desc": "OmniVoice 保存您的数据和输出的地方。",
|
||||
"factory_reset": "恢复出厂设置",
|
||||
"factory_reset_desc": "将所有应用内首选项重置为默认值。您的文件保持不变。",
|
||||
"factory_reset_body": "清除本地保存的设置(主题、语言、配音旋钮、图库收藏夹和其他 UI 首选项)。它不会删除磁盘上的声音、项目或生成的音频。",
|
||||
"factory_reset_confirm_title": "重置偏好设置?",
|
||||
"factory_reset_confirm": "重置并重新加载",
|
||||
"factory_reset_confirm_body": "这将清除所有保存的 UI 首选项并重新加载应用程序。您在磁盘上的声音、项目和输出不受影响。继续?",
|
||||
"factory_reset_done": "偏好设置已清除 - 正在重新加载...",
|
||||
"factory_reset_failed": "重置失败",
|
||||
"history_retention": "生成历史",
|
||||
"history_retention_desc": "在清理最旧条目之前保留的生成数量。",
|
||||
"history_retention_help": "每次生成后,超过此上限的最旧未加星条目将连同音频文件一起删除。已加星的条目始终保留。0 = 全部保留。",
|
||||
@@ -2154,4 +2146,4 @@
|
||||
"website": "网站",
|
||||
"website_desc": "有关该项目和制造商的更多信息。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +159,6 @@
|
||||
"header_live_stats": "在標題中顯示即時系統指標",
|
||||
"header_live_stats_desc": "在頂部欄位新增即時 RAM/CPU/VRAM 監視器(預設為關閉)。",
|
||||
"storage_desc": "OmniVoice 保存您的資料和輸出的地方。",
|
||||
"factory_reset": "恢復出廠設定",
|
||||
"factory_reset_desc": "將所有應用程式內首選項重設為預設值。您的文件保持不變。",
|
||||
"factory_reset_body": "清除本機儲存的設定(主題、語言、配音旋鈕、圖庫收藏夾和其他 UI 首選項)。它不會刪除磁碟上的聲音、項目或產生的音訊。",
|
||||
"factory_reset_confirm_title": "重置偏好設定?",
|
||||
"factory_reset_confirm": "重置並重新加載",
|
||||
"factory_reset_confirm_body": "這將清除所有已儲存的 UI 首選項並重新載入應用程式。您在磁碟上的聲音、專案和輸出不受影響。繼續?",
|
||||
"factory_reset_done": "偏好設定已清除 - 正在重新載入...",
|
||||
"factory_reset_failed": "重置失敗",
|
||||
"history_retention": "生成歷史",
|
||||
"history_retention_desc": "在清理最舊項目之前保留的生成數量。",
|
||||
"history_retention_help": "每次生成後,超過此上限的最舊未加星項目將連同音訊檔案一併刪除。已加星的項目永遠保留。0 = 全部保留。",
|
||||
@@ -2147,4 +2139,4 @@
|
||||
"website": "網站",
|
||||
"website_desc": "有關該項目和製造商的更多資訊。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,47 @@ describe('apiFetch — lifecycle-aware restart wait', () => {
|
||||
await assertion;
|
||||
});
|
||||
|
||||
// #1101 — the hole in the original fix, reported against 0.3.19. The shell's
|
||||
// stage is a 2 s POLL: when the backend dies mid-generate the supervisor needs
|
||||
// a moment to notice, flip to "starting" and write the crash marker. Asking
|
||||
// once at the end of the cascade still saw `ready`, so the request dead-ended
|
||||
// on the generic toast anyway. A transport failure contradicts `ready`, so we
|
||||
// must keep retrying long enough for the shell to catch up.
|
||||
it('does NOT believe a stale "ready" — it waits for the shell to notice the death', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
// The supervisor hasn't ticked yet: still 'ready' for the first few polls,
|
||||
// then it notices the death and flips to 'starting'.
|
||||
stageMock
|
||||
.mockResolvedValueOnce('ready')
|
||||
.mockResolvedValueOnce('ready')
|
||||
.mockResolvedValue('starting');
|
||||
|
||||
const p = apiFetch('/generate');
|
||||
// Never settles into the generic error — it keeps waiting.
|
||||
const settled = vi.fn();
|
||||
p.then(settled, settled);
|
||||
await vi.advanceTimersByTimeAsync(CASCADE_MS + 1000 + 1000 + 1500);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
// Once the backend comes back, the request succeeds — the toast never fired.
|
||||
fetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
await vi.advanceTimersByTimeAsync(1500 * 2);
|
||||
await expect(p).resolves.toMatchObject({ status: 200 });
|
||||
});
|
||||
|
||||
it('still gives up when a "ready" backend stays unreachable past the reconcile window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||
stageMock.mockResolvedValue('ready'); // shell insists it's fine; it never recovers
|
||||
|
||||
const p = apiFetch('/model/status');
|
||||
const assertion = expect(p).rejects.toMatchObject({ status: 0 });
|
||||
await vi.advanceTimersByTimeAsync(CASCADE_MS + 12_000 + 2000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it('keeps the old prompt failure outside the Tauri shell (stage unknown)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Guard: every `var(--token)` referenced from JSX must actually be defined.
|
||||
*
|
||||
* An undefined custom property fails *silently* and invisibly. `text-[var(--nope)]`
|
||||
* compiles to a declaration with an invalid value, the browser drops it, and the
|
||||
* element quietly inherits — so the styling you wrote simply doesn't happen and
|
||||
* nothing anywhere says so. This is exactly how the Storage panels shipped paths
|
||||
* that were meant to be dim and rendered at full body weight
|
||||
* (`--chrome-fg-subtle` — never defined; the real token is `--chrome-fg-dim`).
|
||||
*
|
||||
* Tokens legitimately injected at RUNTIME (by Radix, or by an inline `style` that
|
||||
* sets the property on an ancestor) can't be found in CSS and are allowlisted
|
||||
* below with the reason.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
// vitest runs with the frontend package as cwd.
|
||||
const SRC = resolve(process.cwd(), 'src');
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FILES = walk(SRC);
|
||||
const read = (p) => readFileSync(p, 'utf8');
|
||||
|
||||
/** Custom properties defined anywhere in our stylesheets. */
|
||||
const DEFINED = new Set(
|
||||
FILES.filter((f) => f.endsWith('.css')).flatMap((f) =>
|
||||
[...read(f).matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)].map((m) => m[1]),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Set at runtime, so they never appear in a stylesheet. Each needs a reason —
|
||||
* "it's failing the test" is not one. If you're tempted to add a `--chrome-*` or
|
||||
* `--color-*` token here, you almost certainly mistyped an existing one instead.
|
||||
*/
|
||||
const RUNTIME_INJECTED = new Map([
|
||||
['--radix-select-trigger-height', 'Radix sets this on the select content element'],
|
||||
['--radix-select-trigger-width', 'Radix sets this on the select content element'],
|
||||
['--audio-dock-height', 'set inline by the audio dock as it mounts/resizes'],
|
||||
['--logs-footer-height', 'set inline by LogsFooter as the user drags it'],
|
||||
['--card-accent', 'per-card hue, set inline from the item being rendered'],
|
||||
['--card-hue', 'per-card hue, set inline from the item being rendered'],
|
||||
['--goal-accent', 'per-goal color, set inline by GoalBar'],
|
||||
['--rail-accent', 'per-item color, set inline by NavRail'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Only BARE `var(--token)` is checked. `var(--token, #1d1d22)` supplies a
|
||||
* fallback, so an undefined token still renders the fallback — that is valid CSS
|
||||
* and several components rely on it deliberately. It is the bare form that fails
|
||||
* silently, and only the bare form this guard forbids.
|
||||
*/
|
||||
const BARE_VAR = /var\(\s*(--[a-zA-Z0-9_-]+)\s*\)/g;
|
||||
|
||||
describe('CSS custom properties referenced from JSX', () => {
|
||||
it('are all actually defined somewhere (or explicitly runtime-injected)', () => {
|
||||
const offenders = [];
|
||||
for (const file of FILES.filter((f) => /\.(jsx|tsx)$/.test(f))) {
|
||||
for (const [, token] of read(file).matchAll(BARE_VAR)) {
|
||||
if (DEFINED.has(token) || RUNTIME_INJECTED.has(token)) continue;
|
||||
offenders.push(`${relative(SRC, file)} → var(${token})`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
[...new Set(offenders)],
|
||||
'Undefined CSS custom property with no fallback — the declaration is invalid\n' +
|
||||
'and silently does nothing (the element just inherits). Fix the token name,\n' +
|
||||
'give it a fallback, or add it to RUNTIME_INJECTED with the reason.',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('sanity: the scan actually found our design tokens', () => {
|
||||
// If a refactor moves the stylesheets, this guard must fail loudly rather
|
||||
// than pass vacuously by finding nothing to check.
|
||||
expect(DEFINED.size).toBeGreaterThan(50);
|
||||
expect(DEFINED.has('--chrome-fg-dim')).toBe(true);
|
||||
});
|
||||
|
||||
it('catches the exact bug that motivated it', () => {
|
||||
// --chrome-fg-subtle was used in two Storage panels and defined nowhere, so
|
||||
// the folder paths meant to recede rendered at full body weight. There is no
|
||||
// grandfather list here: every bare var() in the app resolves today, and this
|
||||
// asserts the guard would still notice if one stopped.
|
||||
expect(DEFINED.has('--chrome-fg-subtle')).toBe(false);
|
||||
expect(DEFINED.has('--chrome-input-bg')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fmtBytes, freedBytes } from '../components/settings/UninstallPanel.jsx';
|
||||
|
||||
// #1089: the number on the confirm button must equal what actually gets deleted.
|
||||
// The shared Hugging Face cache is OPT-IN — it's the standard HF cache other ML
|
||||
// tools share, so it must never be counted (or removed) unless explicitly ticked.
|
||||
|
||||
const TARGETS = [
|
||||
{ key: 'data', size_bytes: 100, exists: true, shared: false },
|
||||
{ key: 'env', size_bytes: 1000, exists: true, shared: false },
|
||||
{ key: 'logs', size_bytes: 10, exists: true, shared: false },
|
||||
{ key: 'models', size_bytes: 50_000, exists: true, shared: true },
|
||||
];
|
||||
|
||||
describe('freedBytes — the shared model cache is opt-in', () => {
|
||||
it('excludes the shared cache by default', () => {
|
||||
expect(freedBytes(TARGETS, false)).toBe(1110);
|
||||
});
|
||||
|
||||
it('includes the shared cache only when opted in', () => {
|
||||
expect(freedBytes(TARGETS, true)).toBe(51_110);
|
||||
});
|
||||
|
||||
it('ignores folders that do not exist', () => {
|
||||
const some = [
|
||||
{ key: 'data', size_bytes: 100, exists: false, shared: false },
|
||||
{ key: 'env', size_bytes: 7, exists: true, shared: false },
|
||||
];
|
||||
expect(freedBytes(some, true)).toBe(7);
|
||||
});
|
||||
|
||||
it('is safe on empty/undefined input', () => {
|
||||
expect(freedBytes([], false)).toBe(0);
|
||||
expect(freedBytes(undefined, true)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fmtBytes', () => {
|
||||
it('scales units and keeps sizes readable', () => {
|
||||
expect(fmtBytes(0)).toBe('0 B');
|
||||
expect(fmtBytes(512)).toBe('512 B');
|
||||
expect(fmtBytes(1024)).toBe('1.0 KB');
|
||||
expect(fmtBytes(1536)).toBe('1.5 KB');
|
||||
expect(fmtBytes(5 * 1024 ** 3)).toBe('5.0 GB');
|
||||
expect(fmtBytes(20 * 1024 ** 3)).toBe('20 GB');
|
||||
});
|
||||
|
||||
it('never renders a negative or bogus size', () => {
|
||||
expect(fmtBytes(-5)).toBe('0 B');
|
||||
expect(fmtBytes(NaN)).toBe('0 B');
|
||||
expect(fmtBytes(undefined)).toBe('0 B');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -26,6 +26,8 @@ export const COMPONENTS = [
|
||||
'AppearancePanel',
|
||||
'GeneralTab',
|
||||
'StoragePanel',
|
||||
'ResetPanel',
|
||||
'UninstallPanel',
|
||||
] as const;
|
||||
|
||||
export const THEMES = ['default', 'midnight', 'catppuccin'] as const;
|
||||
|
||||
@@ -84,6 +84,23 @@ export function installFetchStub(handler) {
|
||||
};
|
||||
}
|
||||
|
||||
// Stub the Tauri IPC bridge for specs that render desktop-shell panels (the
|
||||
// Storage destructive panels invoke `reset_scan` / `uninstall_scan` on mount).
|
||||
// `@tauri-apps/api/core` reads `window.__TAURI_INTERNALS__.invoke`, so seeding
|
||||
// that is enough for a dynamic `import('@tauri-apps/api/core')` to resolve to
|
||||
// our handler — and the panels' own `inTauri()` check (which looks for the same
|
||||
// global) then believes it is in the shell, so it renders instead of bailing.
|
||||
export function installTauriStub(handler) {
|
||||
const real = window.__TAURI_INTERNALS__;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
...real,
|
||||
invoke: (cmd, args) => Promise.resolve(handler(cmd, args)),
|
||||
};
|
||||
return () => {
|
||||
window.__TAURI_INTERNALS__ = real;
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare all infrastructure a provider-spec asked for, BEFORE first render,
|
||||
// and return a function that wraps the spec's element in the live providers.
|
||||
// Idempotent per page load (the harness renders once).
|
||||
@@ -110,6 +127,9 @@ export function applyProviders(providers, ctx) {
|
||||
// 4. Direct api/* fetches — stub the global fetch.
|
||||
if (resolved.fetch) installFetchStub(resolved.fetch);
|
||||
|
||||
// 5. Tauri IPC — stub window.__TAURI_INTERNALS__.invoke.
|
||||
if (resolved.invoke) installTauriStub(resolved.invoke);
|
||||
|
||||
return function Wrap({ children }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -42,8 +42,98 @@ import './harness.css';
|
||||
import AppearancePanel from '../../components/settings/AppearancePanel.jsx';
|
||||
import GeneralTab from '../../components/settings/GeneralTab.jsx';
|
||||
import StoragePanel from '../../components/settings/StoragePanel.jsx';
|
||||
import ResetPanel from '../../components/settings/ResetPanel.jsx';
|
||||
import UninstallPanel from '../../components/settings/UninstallPanel.jsx';
|
||||
import { queryKeys } from '../../api/hooks.ts';
|
||||
|
||||
// Representative scan payloads for the two desktop-shell Storage panels, so the
|
||||
// harness renders their loaded state (sizes, bars, the shared-cache row) with no
|
||||
// backend. Sizes span B → GB on purpose: it's the spread the redesign is FOR.
|
||||
const RESET_SCAN = [
|
||||
{ key: 'ui_prefs', paths: [], size_bytes: 0, exists: true, shared: false, needs_restart: false },
|
||||
{ key: 'history', paths: [], size_bytes: 0, exists: true, shared: false, needs_restart: false },
|
||||
{
|
||||
key: 'settings',
|
||||
paths: ['~/…/OmniVoice/prefs.json'],
|
||||
size_bytes: 4096,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
paths: ['~/…/OmniVoice/voices'],
|
||||
size_bytes: 5.4 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'engines',
|
||||
paths: ['~/…/OmniVoice/engines'],
|
||||
size_bytes: 2.3 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
paths: ['~/…/OmniVoice/media_tools'],
|
||||
size_bytes: 96 * 1024 ** 2,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'models',
|
||||
paths: ['~/.cache/huggingface'],
|
||||
size_bytes: 14.2 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: true,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'caches',
|
||||
paths: ['~/…/OmniVoice/gallery_cache'],
|
||||
size_bytes: 11 * 1024 ** 2,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
paths: ['~/…/OmniVoice/omnivoice.log'],
|
||||
size_bytes: 820,
|
||||
exists: true,
|
||||
shared: false,
|
||||
needs_restart: true,
|
||||
},
|
||||
];
|
||||
const UNINSTALL_SCAN = [
|
||||
{
|
||||
key: 'data',
|
||||
path: '~/Library/Application Support/OmniVoice',
|
||||
size_bytes: 720 * 1024,
|
||||
exists: true,
|
||||
shared: false,
|
||||
},
|
||||
{
|
||||
key: 'env',
|
||||
path: '~/Library/Application Support/com.debpalash.omnivoice-studio',
|
||||
size_bytes: 391,
|
||||
exists: true,
|
||||
shared: false,
|
||||
},
|
||||
{ key: 'logs', path: '~/Library/Logs/OmniVoice', size_bytes: 4096, exists: true, shared: false },
|
||||
{
|
||||
key: 'models',
|
||||
path: '~/.cache/huggingface',
|
||||
size_bytes: 7.5 * 1024 ** 3,
|
||||
exists: true,
|
||||
shared: true,
|
||||
},
|
||||
];
|
||||
|
||||
function Spec({ label, children }) {
|
||||
return (
|
||||
<div className="visual-spec">
|
||||
@@ -442,4 +532,24 @@ export const SPECS = {
|
||||
},
|
||||
render: () => <StoragePanel />,
|
||||
},
|
||||
|
||||
// The scoped-reset panel, advanced list expanded so the full row treatment —
|
||||
// icon, size, proportional bar, dimmed path, the shared-cache caution — is on
|
||||
// screen at once.
|
||||
ResetPanel: {
|
||||
width: 640,
|
||||
providers: {
|
||||
invoke: (cmd) => (cmd === 'reset_scan' ? RESET_SCAN : null),
|
||||
},
|
||||
render: () => <ResetPanel _forceAdvanced />,
|
||||
},
|
||||
|
||||
// The uninstaller list, with the shared HF cache in its own "Optional" group.
|
||||
UninstallPanel: {
|
||||
width: 640,
|
||||
providers: {
|
||||
invoke: (cmd) => (cmd === 'uninstall_scan' ? UNINSTALL_SCAN : null),
|
||||
},
|
||||
render: () => <UninstallPanel />,
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnivoice"
|
||||
version = "0.3.19"
|
||||
version = "0.3.21"
|
||||
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
|
||||
readme = "README.md"
|
||||
# Free and open-source under the GNU Affero General Public License v3 (see
|
||||
|
||||
@@ -56,8 +56,13 @@ function Get-FolderSize($path) {
|
||||
} catch { return '?' }
|
||||
}
|
||||
|
||||
# The BACKEND writes its own logs here (backend_log_path() in
|
||||
# src-tauri/src/backend.rs) — a sibling of hf_cache under %LOCALAPPDATA%\OmniVoice,
|
||||
# so it is covered by neither the app-data nor the config dir.
|
||||
$logsDefault = Join-Path (Join-Path $localApp 'OmniVoice') 'Logs'
|
||||
|
||||
$appTargets = @()
|
||||
foreach ($p in @($dataDir, $configDefault)) {
|
||||
foreach ($p in @($dataDir, $configDefault, $logsDefault)) {
|
||||
if (Test-Path -LiteralPath $p) { $appTargets += $p }
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ case "$OS" in
|
||||
Linux)
|
||||
data_default="$HOME/.omnivoice"
|
||||
config_default="${XDG_DATA_HOME:-$HOME/.local/share}/$IDENTIFIER"
|
||||
# The BACKEND writes its own logs outside the app-data dir — see
|
||||
# backend_log_path() in src-tauri/src/backend.rs. Missing this left a stray
|
||||
# log dir behind on every Linux uninstall.
|
||||
logs_extra=("${XDG_STATE_HOME:-$HOME/.local/state}/OmniVoice")
|
||||
models_default="$HOME/.cache/huggingface"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""The warm capture/dictation ASR must be idle-released, like the TTS model.
|
||||
|
||||
Root cause behind the "Can't reach the local OmniVoice backend" deaths on 16 GB
|
||||
Macs (#1076/#1092/#1093/#1101): the TTS model has always been unloaded after an
|
||||
idle timeout (``model_manager.idle_worker``), but the capture-ASR singleton was
|
||||
not — once a user dictated even once, its model stayed resident for the life of
|
||||
the process.
|
||||
|
||||
Measured on a 16 GB M2: the backend sat at ~6.2 GB **idle** (TTS 3.8 GB + ~2 GB
|
||||
of warm ASR) while an actual generate cost only ~116 MB on top. That baseline —
|
||||
not any spike during generation — is what pushes the machine into memory
|
||||
pressure until the OS kills the backend mid-generate. Freeing 3.8 GB of TTS
|
||||
while silently holding 2 GB of ASR forever was the asymmetry.
|
||||
|
||||
Fail-before: ``release_idle_capture_backend`` did not exist and ``idle_worker``
|
||||
never touched the ASR singleton.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
import pytest
|
||||
|
||||
from services import asr_backend as ab
|
||||
|
||||
|
||||
class _FakeBackend:
|
||||
"""Stands in for a warm mlx-whisper / sherpa recognizer."""
|
||||
|
||||
def __init__(self):
|
||||
self.unloaded = False
|
||||
|
||||
def unload(self):
|
||||
self.unloaded = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_singleton(monkeypatch):
|
||||
"""Isolate the module-level capture singleton for every test."""
|
||||
monkeypatch.setattr(ab, "_capture_backend", None, raising=False)
|
||||
monkeypatch.setattr(ab, "_capture_backend_key", None, raising=False)
|
||||
monkeypatch.setattr(ab, "_capture_leases", 0, raising=False)
|
||||
monkeypatch.setattr(ab, "_capture_last_used", 0.0, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def _install(backend, *, last_used=0.0):
|
||||
ab._capture_backend = backend
|
||||
ab._capture_backend_key = "fake"
|
||||
ab._capture_last_used = last_used
|
||||
|
||||
|
||||
def test_releases_the_model_once_it_has_gone_idle():
|
||||
fake = _FakeBackend()
|
||||
_install(fake, last_used=0.0)
|
||||
|
||||
# 900 s (the default idle timeout) later, with nothing holding it.
|
||||
assert ab.release_idle_capture_backend(900.0, now=1000.0) is True
|
||||
assert fake.unloaded is True
|
||||
# The singleton is dropped, so the next dictation rebuilds a fresh one.
|
||||
assert ab._capture_backend is None
|
||||
assert ab._capture_backend_key is None
|
||||
|
||||
|
||||
def test_keeps_the_model_while_it_is_still_in_use():
|
||||
fake = _FakeBackend()
|
||||
_install(fake, last_used=990.0) # used 10 s ago
|
||||
|
||||
assert ab.release_idle_capture_backend(900.0, now=1000.0) is False
|
||||
assert fake.unloaded is False
|
||||
assert ab._capture_backend is fake
|
||||
|
||||
|
||||
def test_never_unloads_underneath_a_live_dictation_session():
|
||||
"""A live stream holds the backend for its whole life without re-resolving
|
||||
it — so an open-but-silent session must NOT have its model pulled away."""
|
||||
fake = _FakeBackend()
|
||||
_install(fake, last_used=0.0) # long idle: would otherwise be reaped
|
||||
|
||||
with ab.capture_lease():
|
||||
assert ab.release_idle_capture_backend(900.0, now=1_000_000.0) is False
|
||||
assert fake.unloaded is False
|
||||
assert ab._capture_backend is fake
|
||||
|
||||
# Leaving the session restarts the idle clock (it is NOT instantly reapable).
|
||||
assert ab.release_idle_capture_backend(900.0) is False
|
||||
assert fake.unloaded is False
|
||||
|
||||
|
||||
def test_lease_is_released_even_if_the_session_raises():
|
||||
fake = _FakeBackend()
|
||||
_install(fake, last_used=0.0)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with ab.capture_lease():
|
||||
raise RuntimeError("client disconnected mid-stream")
|
||||
|
||||
assert ab._capture_leases == 0 # not leaked → the reaper isn't wedged forever
|
||||
|
||||
|
||||
def test_nested_leases_refcount_correctly():
|
||||
fake = _FakeBackend()
|
||||
_install(fake, last_used=0.0)
|
||||
|
||||
with ab.capture_lease():
|
||||
with ab.capture_lease():
|
||||
assert ab.release_idle_capture_backend(900.0, now=1_000_000.0) is False
|
||||
# Inner released, outer still holds it.
|
||||
assert ab._capture_leases == 1
|
||||
assert ab.release_idle_capture_backend(900.0, now=1_000_000.0) is False
|
||||
assert ab._capture_leases == 0
|
||||
|
||||
|
||||
def test_no_op_when_nothing_is_loaded():
|
||||
assert ab.release_idle_capture_backend(900.0, now=1_000_000.0) is False
|
||||
|
||||
|
||||
def test_a_failing_unload_still_drops_the_reference():
|
||||
"""A stuck unload must not wedge the reaper or keep the model pinned —
|
||||
idle_worker calls this on a loop and must never die."""
|
||||
|
||||
class _Boom(_FakeBackend):
|
||||
def unload(self):
|
||||
raise RuntimeError("metal context already torn down")
|
||||
|
||||
_install(_Boom(), last_used=0.0)
|
||||
assert ab.release_idle_capture_backend(900.0, now=1000.0) is True
|
||||
assert ab._capture_backend is None
|
||||
|
||||
|
||||
def test_getting_the_backend_resets_the_idle_clock(monkeypatch):
|
||||
"""Any handout counts as use — otherwise a freshly-warmed model built at
|
||||
T=0 would be reaped on the very next idle tick."""
|
||||
fake = _FakeBackend()
|
||||
_install(fake, last_used=0.0)
|
||||
monkeypatch.setattr(ab, "dictation_model_id", lambda: None)
|
||||
monkeypatch.setattr(ab, "_pick_capture_whisper_backend", lambda: fake, raising=False)
|
||||
|
||||
before = ab._capture_last_used
|
||||
ab._touch_capture()
|
||||
assert ab._capture_last_used > before
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Single-active-TTS-engine memory discipline (the 16 GB-Mac OOM class).
|
||||
|
||||
Measured before this: a generate on ``omnivoice`` (~2.8 GB core) followed by a
|
||||
generate on ``mlx-audio`` left BOTH resident (footprint 3.9 → 4.3 GB), because
|
||||
the OmniVoice core lives in ``model_manager.model`` and the other engines in
|
||||
``engines._ENGINE_INSTANCES`` — two caches with no coordination, and the latter
|
||||
was never unloaded. That accumulation is the baseline that OOM-kills a 16 GB Mac.
|
||||
|
||||
These tests pin the fix: resolving an engine evicts every OTHER resident engine
|
||||
first, across both stores, and the default ``unload()`` actually frees the held
|
||||
model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
import pytest
|
||||
|
||||
from services import engine_memory as em
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""A backend holding a heavy model in `_model`, using the ABC unload()."""
|
||||
|
||||
def __init__(self, eid):
|
||||
self.id = eid
|
||||
self._model = object() # stand-in for multi-GB weights
|
||||
self.unloaded = 0
|
||||
|
||||
# Reuse the real ABC default unload via delegation so we test THAT logic.
|
||||
def unload(self):
|
||||
from services.tts_backend import TTSBackend
|
||||
|
||||
self.unloaded += 1
|
||||
TTSBackend.unload(self)
|
||||
|
||||
|
||||
# ── ABC default unload actually frees the model ─────────────────────────────
|
||||
|
||||
|
||||
def _concrete(tb):
|
||||
"""A minimal concrete TTSBackend subclass (satisfies the ABC) that inherits
|
||||
the real default unload() under test."""
|
||||
|
||||
class _Base(tb.TTSBackend):
|
||||
id = "fake"
|
||||
sample_rate = 24000
|
||||
supported_languages = ("en",)
|
||||
|
||||
@classmethod
|
||||
def is_available(cls):
|
||||
return True, "ready"
|
||||
|
||||
def generate(self, *a, **k): # never called in these tests
|
||||
raise NotImplementedError
|
||||
|
||||
return _Base
|
||||
|
||||
|
||||
def test_default_unload_clears_model_attrs_and_frees_vram(monkeypatch):
|
||||
from services import tts_backend as tb
|
||||
|
||||
freed = {"n": 0}
|
||||
monkeypatch.setattr("services.model_manager.free_vram", lambda: freed.__setitem__("n", freed["n"] + 1))
|
||||
|
||||
class Eng(_concrete(tb)):
|
||||
def __init__(self):
|
||||
self._model = object()
|
||||
|
||||
e = Eng()
|
||||
e.unload()
|
||||
assert e._model is None
|
||||
assert freed["n"] == 1
|
||||
# Idempotent + safe when nothing is loaded: a second call frees nothing more.
|
||||
e.unload()
|
||||
assert freed["n"] == 1
|
||||
|
||||
|
||||
def test_default_unload_handles_the_tts_attr_and_missing_attrs(monkeypatch):
|
||||
from services import tts_backend as tb
|
||||
|
||||
monkeypatch.setattr("services.model_manager.free_vram", lambda: None)
|
||||
|
||||
class Sherpa(_concrete(tb)):
|
||||
def __init__(self):
|
||||
self._tts = object() # sherpa holds its model here, not _model
|
||||
|
||||
s = Sherpa()
|
||||
s.unload()
|
||||
assert s._tts is None
|
||||
|
||||
class External(_concrete(tb)):
|
||||
def __init__(self):
|
||||
pass # no model attrs at all (e.g. an HTTP-server engine)
|
||||
|
||||
External().unload() # must not raise
|
||||
|
||||
|
||||
# ── evict_other_tts_engines ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_core_model(monkeypatch):
|
||||
"""Pin the OmniVoice core singleton to None so these tests are order-
|
||||
independent: evict_other_tts_engines() also frees model_manager.model when
|
||||
switching away from omnivoice, and an earlier full-suite test can leave a
|
||||
model loaded there. Tests that exercise the core eviction set it explicitly."""
|
||||
import services.model_manager as mm
|
||||
|
||||
monkeypatch.setattr(mm, "model", None, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance_cache(monkeypatch):
|
||||
"""A stand-in for engines._ENGINE_INSTANCES keyed by class."""
|
||||
import api.routers.engines as eng
|
||||
|
||||
cache: dict = {}
|
||||
monkeypatch.setattr(eng, "_ENGINE_INSTANCES", cache, raising=False)
|
||||
return cache
|
||||
|
||||
|
||||
async def _evict(keep):
|
||||
return await em.evict_other_tts_engines(keep)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evicts_other_engine_instances_but_keeps_the_active_one(instance_cache, monkeypatch):
|
||||
class KittenTTSBackend:
|
||||
id = "kittentts"
|
||||
|
||||
class MLXAudioBackend:
|
||||
id = "mlx-audio"
|
||||
|
||||
keep = MLXAudioBackend()
|
||||
drop = KittenTTSBackend()
|
||||
drop.unloaded = 0
|
||||
drop.unload = lambda: setattr(drop, "unloaded", drop.unloaded + 1)
|
||||
instance_cache[MLXAudioBackend] = keep
|
||||
instance_cache[KittenTTSBackend] = drop
|
||||
|
||||
monkeypatch.setattr(em, "get_backend_class", None, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"services.tts_backend.get_backend_class",
|
||||
lambda i: MLXAudioBackend if i == "mlx-audio" else KittenTTSBackend,
|
||||
)
|
||||
|
||||
evicted = await _evict("mlx-audio")
|
||||
|
||||
assert evicted == ["kittentts"]
|
||||
assert drop.unloaded == 1
|
||||
assert KittenTTSBackend not in instance_cache # dropped
|
||||
assert instance_cache[MLXAudioBackend] is keep # kept
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evicts_the_omnivoice_core_when_switching_away_from_it(instance_cache, monkeypatch):
|
||||
import services.model_manager as mm
|
||||
|
||||
monkeypatch.setattr(mm, "model", object(), raising=False)
|
||||
freed = {"n": 0}
|
||||
monkeypatch.setattr(mm, "free_vram", lambda: freed.__setitem__("n", freed["n"] + 1))
|
||||
monkeypatch.setattr("services.tts_backend.get_backend_class", lambda i: type("X", (), {"id": i}))
|
||||
|
||||
evicted = await _evict("mlx-audio")
|
||||
|
||||
assert "omnivoice" in evicted
|
||||
assert mm.model is None
|
||||
assert freed["n"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keeps_the_omnivoice_core_when_it_IS_the_active_engine(instance_cache, monkeypatch):
|
||||
import services.model_manager as mm
|
||||
|
||||
sentinel = object()
|
||||
monkeypatch.setattr(mm, "model", sentinel, raising=False)
|
||||
monkeypatch.setattr(mm, "free_vram", lambda: None)
|
||||
monkeypatch.setattr("services.tts_backend.get_backend_class", lambda i: type("X", (), {"id": i}))
|
||||
|
||||
evicted = await _evict("omnivoice")
|
||||
|
||||
assert "omnivoice" not in evicted
|
||||
assert mm.model is sentinel # the active engine's model is NOT evicted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_policy_can_be_disabled(instance_cache, monkeypatch):
|
||||
import services.model_manager as mm
|
||||
|
||||
monkeypatch.setenv("OMNIVOICE_SINGLE_ENGINE_RESIDENT", "0")
|
||||
monkeypatch.setattr(mm, "model", object(), raising=False)
|
||||
|
||||
class Other:
|
||||
id = "kittentts"
|
||||
|
||||
other = Other()
|
||||
other.unload = lambda: pytest.fail("must not unload when policy is off")
|
||||
instance_cache[Other] = other
|
||||
|
||||
assert await _evict("mlx-audio") == []
|
||||
assert mm.model is not None # untouched
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_unload_does_not_abort_the_eviction(instance_cache, monkeypatch):
|
||||
class A:
|
||||
id = "a"
|
||||
|
||||
class B:
|
||||
id = "b"
|
||||
|
||||
a, b = A(), B()
|
||||
a.unload = lambda: (_ for _ in ()).throw(RuntimeError("stuck"))
|
||||
b.unloaded = 0
|
||||
b.unload = lambda: setattr(b, "unloaded", b.unloaded + 1)
|
||||
instance_cache[A] = a
|
||||
instance_cache[B] = b
|
||||
monkeypatch.setattr("services.tts_backend.get_backend_class",
|
||||
lambda i: type("keep", (), {"id": i}))
|
||||
|
||||
evicted = await _evict("other") # keep nothing in the cache
|
||||
|
||||
# Both attempted; the raising one didn't stop the other from being freed.
|
||||
assert set(evicted) == {"a", "b"}
|
||||
assert b.unloaded == 1
|
||||
assert not instance_cache # both dropped despite the failure
|
||||
Reference in New Issue
Block a user