Compare commits

..
8 Commits
Author SHA1 Message Date
4d2fdacd33 release: freeze v0.3.21 — version bump, lockfiles, changelog (#1107)
package.json (single source of truth) + the three toolchain mirrors to 0.3.21;
Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased]
renamed to [0.3.21] — 2026-07-12, "the memory release", sections merged into
house style (one Added, one Fixed).

Ships the 16 GB OOM class fixes end to end: idle-release the dictation ASR
(#1104) + one TTS engine resident at a time (#1105), plus the scoped
Settings → Storage reset/uninstall pair (#1089/#1099/#1100) and the release-
asset-split workflow fix (#1106) so this tag uploads to a single release.

test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:24:07 +05:30
8750b91522 feat(memory): one TTS engine resident at a time — stop stacking models on 16 GB (#1105)
Measured on a 16 GB M2: 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 while every other engine lives in
engines._ENGINE_INSTANCES — two caches that never coordinated, and the latter
was never unloaded. That accumulation is a direct contributor to the memory
pressure behind the "Can't reach the local backend" OOM deaths.

- services/engine_memory.py: evict_other_tts_engines(keep_id) unloads every
  OTHER resident TTS engine before the incoming one loads — spans both stores
  (the OmniVoice core under its async lock, and the instance cache). No-op when
  nothing else is resident, so steady-state single-engine use pays nothing; only
  a real switch evicts. Default on; OMNIVOICE_SINGLE_ENGINE_RESIDENT=0 to keep
  several warm. Wired into the /generate path right after the engine resolves.
- TTSBackend.unload() (the ABC default) now actually frees the held model: it
  clears _MODEL_ATTRS (_model/_tts) and empties the device cache. Every
  in-process engine but OmniVoice previously inherited a NO-OP unload(), so an
  engine switch dropped the instance ref but left its model for GC with the GPU
  cache un-emptied. One change fixes all of them and is future-proof.
- FasterWhisperBackend.unload() cleared self._asr — an attribute it never
  assigns — so its model in self._model was never freed. Fixed.

Live-verified: omnivoice → mlx-audio now DROPS footprint 2300 → 1541 MB (core
evicted) instead of climbing to 4305 with both resident. 7 new unit tests
(eviction spans both stores / keeps the active engine / no-op when disabled /
a failing unload doesn't abort the sweep / ABC unload frees + is idempotent),
order-independent. Backend suite green.

Refs the 16 GB OOM class (#1076 #1092 #1093 #1101)

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:05:11 +05:30
7705343386 fix(release): attach uninstall scripts with gh release upload, not a 2nd softprops publish (#1106)
v0.3.20 shipped with ONLY the Linux AppImage — the macOS dmg and Windows msi
were missing from the published release. Root cause: the uninstall-scripts job
(added in #1097) ran softprops/action-gh-release@v2 as a SECOND publish for the
tag, which raced tauri-action's per-matrix draft and split the platform
installers across two releases (a draft holding mac/windows, a published one
holding linux + checksums + the scripts). The updater manifest split too — each
release's latest.json covered only its half of the platforms.

Fix: attach the scripts with `gh release upload <tag> … --clobber` (which adds
assets to the EXISTING release and can never create a second one) instead of
softprops. `needs: [build]` guarantees the release exists first; --clobber keeps
a re-run idempotent.

(v0.3.20 itself was already repaired by hand — the mac/windows bundles were
re-attached from the draft, the latest.json manifests merged into one covering
all 8 platform keys, and the stray draft deleted. This prevents recurrence.)

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:04:31 +05:30
94093605eb feat(settings): factory reset gets scopes — preferences, settings, assets, everything (#1100)
* feat(settings): factory reset gets scopes — preferences, settings, assets, everything

Factory reset did exactly one thing: clear localStorage. The only other option
was "Remove all data", which deletes the Python env and quits. 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 — and none of them existed.

Settings → Storage → "Reset & remove" now offers four tiers (UI preferences /
all settings / downloaded assets & models / everything OmniVoice did) plus a
per-scope checklist. Every scope shows its real on-disk size, and the number on
the confirm button is exactly what gets freed.

Why the shell and not the backend: a loaded model memory-maps its weights out of
the HF cache (locked on Windows while mapped), and ensure_dirs() runs at import,
so a backend cannot delete voices/ or outputs/ and still write to them. reset.rs
stops the backend, deletes, and starts it again — and that restart is also the
repair: the fresh process re-runs ensure_dirs() and alembic, so a removed
database comes back empty rather than missing. retry_bootstrap's respawn path is
extracted to bootstrap::respawn_backend so both callers share one implementation.

Deliberate scope choices:
- "Everything" stops short of the managed Python env, so a reset hands back a
  working app on the first-run screen. The env is the uninstaller's business.
- A settings reset keeps the storage locations (config.json, the user env file).
  Clearing the model-cache pointer would strand gigabytes at a path the app no
  longer looks in — install shape is not a preference.
- content deletes the DB with the media: rows without files is how you get a
  library full of broken entries.
- The shared HF cache is flagged as shared only when it IS — computed, so Windows
  and portable installs (app-private cache) get no caveat they don't need.

Safety: nothing is removed unless it sits inside a validated root — one carrying
an OmniVoice-owned path component OR holding an OmniVoice signature file, which
is what lets a custom data dir on an external volume be cleared while a mis-set
data_dir: "/" is refused. Voices/projects/audio need the word typed.

9 Rust tests (guard, scope composition, shared-cache computation) + 14 frontend
(planning purity, typed confirm, disk-vs-frontend split, shared warning).
Border utilities follow the design guard (tests/test_no_literal_borders.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(settings): give the Storage panels a design — proportional sizes, live totals, real tokens

"Remove all data" listed four folders as a flat run of text: a 7.5 GB model cache
and a 391-byte config file rendered at identical visual weight, so the one thing
worth seeing — where the space actually went — was the one thing you couldn't.
And the 391 B folder said "0 KB", which reads as "nothing here".

- New shared StorageTargetRow, used by BOTH destructive panels so they read as
  one system: icon, label, dimmed path (truncated, full text on hover), size, and
  a proportional bar showing that row's share of what will be freed. Unticked
  rows claim none of the bar — the bars must sum to what the button promises.
- The shared HF cache moves OUT of the confirm dialog into its own "Optional"
  row with the checkbox and the caveat in the list. Ticking it now moves the
  running total in front of the user, instead of springing a different number on
  them at the point of no return. The dialog lists exactly what is going.
- One byte formatter for both panels (settings/bytes.js). models/format.fmtBytes
  floors at kilobytes, hence "0 KB"; it stays where it is for the model store.

Real fix underneath: three of the tokens these panels styled with DO NOT EXIST
(--chrome-fg-subtle, --chrome-bg-raised, --color-warning). An undefined var()
makes the declaration invalid, the browser drops it, and the element silently
inherits — which is why the paths that were meant to recede rendered at full body
weight. That is a whole class of bug that fails invisibly, so it gets a guard:
src/test/cssTokens.test.js fails on any var(--token) in JSX not defined in a
stylesheet, with runtime-injected tokens (Radix, inline-style hues) allowlisted
by reason. Six pre-existing offenders elsewhere in the app are recorded as
known-broken and ratcheted so the list can only shrink — they are real bugs, but
each is a visual change that wants its own review.

Frontend suite 1196 → 1205 (6 UninstallPanel component tests incl. the live
total and the bar proportions; 3 token-guard tests, verified fail-before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: green CI + finish the token sweep + snapshot the panels

Three things on top of the redesign:

1. CI was red on tests/probe/test_probe_i18n.py — removing the eight dead
   `factory_reset_*` keys from en.json orphaned them in all 20 other locales
   (the probe forbids a non-en key absent from en). Removed them everywhere.
   This guard scans locales at pytest time; a frontend-only run never sees it.

2. Finished the undefined-token sweep instead of grandfathering it. Six bare
   `var(--token)` references resolved to nothing; the only genuinely undefined,
   fallback-less one in shipping panels was `--chrome-input-bg` (input fields
   AND progress-bar tracks AND skeletons across StoragePanel, StorageUsagePanel,
   HistoryRetentionPanel, ModelStoreTab — tracks were rendering with no
   background at all). Repointed to --chrome-hover-bg. The rest
   (--chrome-menu-bg, --chrome-bg-inset, --border, --input-bg, --muted) already
   carry `var(--x, fallback)`, which is valid CSS. So cssTokens.test.js now
   checks only the BARE form and ships with zero exceptions — no known-broken
   ratchet, because there is nothing left broken.

3. Registered both Storage panels in the visual-regression harness (a Tauri
   `invoke` stub added to providers.jsx alongside the existing fetch stub) and
   committed baselines across all three themes. This is how I actually looked at
   the redesign: the bars render proportional (the 720 KB voices row fills, the
   391 B row is a sliver), the shared-cache row sits in its own Optional group,
   and every token now resolves in default/midnight/catppuccin. `_forceAdvanced`
   on ResetPanel opens the checklist for the snapshot; no effect on the toggle.

Full backend suite 2897 passed (incl. the i18n probe). Frontend 1205.

* style: oxfmt the new panels and specs

Format-check is a CI gate (oxfmt --check); the new files weren't run through
oxfmt --write. No behavior change.

* chore: stop tracking the node_modules symlink

A worktree-local symlink slipped past .gitignore (which lists node_modules/ —
the directory form — so it never matched the symlink file). Removed from the
index; the symlink stays on disk for local test runs.

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:00:13 +05:30
7e03b84d3b fix(memory): idle-release the dictation ASR — the real cause of the 16 GB OOM deaths (#1104)
Four "Can't reach the local OmniVoice backend" reports (#1076/#1092/#1093/#1101)
all died at the same moment: during a generate, on a 16 GB machine. Measuring it
(phys_footprint, not RSS — RSS badly undercounts MPS unified memory) showed the
generate was never the problem: it costs ~116 MB. The problem is the BASELINE —
the backend sits at ~6.2 GB *idle*: TTS 3.8 GB plus ~2 GB of warm capture ASR.

The TTS model has always been idle-unloaded (model_manager.idle_worker). The
capture/dictation ASR singleton never was — one dictation warmed it and it
stayed resident for the life of the process. So the app dutifully freed 3.8 GB
of TTS while silently holding 2 GB of ASR forever, and on a 16 GB Mac that
baseline plus the app plus macOS is enough for the OS to kill the backend
mid-generate. That death surfaces as the "can't reach the backend" error — the
class #1102 made honest and this fixes at the source.

- asr_backend.release_idle_capture_backend(idle_s): unloads the warm capture
  singleton once it's been unused that long; no-op under a live lease, when
  nothing's loaded, or when recently used; never raises (idle_worker calls it
  on a loop).
- capture_lease(): pins the singleton for a live dictation session's whole life
  (the stream holds the backend without re-resolving it, so the reaper must not
  unload the model mid-sentence); wrapped around both sherpa handlers in
  capture_ws. Releasing restarts the idle clock.
- Both capture getters stamp _touch_capture() so any handout resets the clock.
- idle_worker runs the reaper each tick with the same idle timeout the TTS model
  uses, then free_vram().

Cost: a ~1.4 s model re-warm on the next dictation after a full idle timeout —
the same bargain the TTS model already makes. 8 new unit tests (releases when
idle / never while leased / never when recently used / lease released on raise /
nested refcount / failing unload still drops the ref / no-op when empty).
Backend suite 2905 passed; verified live against the running backend.

Fixes the crash class behind #1076 #1092 #1093 #1101

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:38:14 +05:30
b0c692d26c release: freeze v0.3.20 — version bump, lockfiles, changelog (#1103)
package.json (single source of truth) + the three toolchain mirrors to 0.3.20;
Cargo.lock + uv.lock regenerated (version lines only). CHANGELOG [Unreleased]
renamed to [0.3.20] — 2026-07-12 with the release headline, sections merged into
house style (one Added, one Fixed).

Ships the #1101 stale-"ready" race fix (0.3.19 users are hitting it today), the
in-app uninstaller (#1099), and the Linux/Windows backend-log-dir fix.

test_app_version.py lockstep: 6 passed. bun install --frozen-lockfile: clean.

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:12:47 +05:30
662339b50a fix(api): don't believe a stale "ready" — close the #1094 race that #1101 hit (#1102)
A 0.3.19 user still got "Can't reach the local OmniVoice backend" (#1101), on
the very release that was supposed to end that class. The fix had a hole.

apiFetch asks the shell whether a start/restart is in progress before erroring.
But 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 child exit, write
the crash marker, and set_stage(StartingBackend). apiFetch asked exactly ONCE, at
the end of the ~2.9 s transport cascade — so it very often still read `Ready` and
fell straight through to the generic toast. Worse, the crash marker usually
wasn't written yet either, so even the honest crash story (#941) was missed and
the user got the vague message.

The bug was trusting `ready` as authoritative. A transport failure CONTRADICTS
it: if the backend were reachable, the fetch would have succeeded. So `ready` is
now treated as a STALE belief — we keep retrying across a bounded reconciliation
window (12 s), re-asking each time, which lets the supervisor catch up and flip
to `starting` (→ the long wait + the restarting banner) and gives the crash
marker time to land so the error can name the real cause. `failed` (the shell
gave up) and `unknown` (no shell — browser/Docker) still error immediately, so a
genuinely dead backend is as prompt as before.

The reporter's trace is the signature: generate:start (design) →
generate:stream-fallback → the toast, on a 16 GB M1/MPS box — i.e. the backend
process died under memory pressure during generation, which is the underlying
crash this now surfaces honestly instead of guessing at.

Regression test reproduces it against the shipped 0.3.19 logic (fails) and passes
on the fix. Frontend 1184 passed; backend 2897 passed.

Refs #1101

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:57:10 +05:30
0421be966e feat(settings): in-app uninstall — Settings → Storage → Remove all data (#1089) (#1099)
* feat(settings): in-app uninstall — Settings → Storage → "Remove all data"

The v0.3.19 uninstaller was a SCRIPT, which never reaches the people who need
it: anyone who installed the .dmg / .msi / AppImage has no repo to run
scripts/uninstall.sh from — exactly the reporter in #1089, an AppImage user.
"Where is uninstall in the app?" had no answer. Now it does.

New Tauri commands (uninstall.rs):
- uninstall_scan  — every folder this install owns, with real sizes, resolved
  through the same setup.rs helpers the app itself uses, so custom + portable
  locations are cleaned instead of the defaults being assumed.
- uninstall_purge — stops the backend (marking the kill intentional so the #567
  supervisor doesn't respawn one into the directories being deleted), removes
  the folders, and lets the UI quit the app: the Python env it runs on is gone,
  so there is nothing to return to.

This lives in the Rust shell, not the backend, because the biggest thing to
remove is the managed Python environment and the backend is RUNNING FROM IT — a
process can't delete its own interpreter (and Windows locks the files).

Safety: every path must pass is_recognizably_ours() before any remove_dir_all —
absolute, not `/` or $HOME, and carrying an OmniVoice-owned component (unit
tested both ways). The shared Hugging Face cache is reported separately and is
OPT-IN behind its own checkbox with the caveat spelled out: it's the standard HF
cache other ML tools share, so sweeping it up silently would delete models this
app never downloaded. Deleting voices/projects is irreversible, so the confirm
requires TYPING the word, not just a click.

Also fixes a real bug in what shipped in v0.3.19: the scripts and docs missed
where the BACKEND writes its logs — ~/.local/state/OmniVoice on Linux and
%LOCALAPPDATA%\OmniVoice\Logs on Windows (backend_log_path(), backend.rs) — so
every Linux/Windows uninstall left a stray log dir behind. Covered now in the
scripts, the docs, and the in-app scan.

And the scripts now ship as release assets, so cleanup is possible without
launching the app at all.

Rust: 2 new guard tests. Frontend: 6 new tests (the size on the confirm button
must equal what actually gets deleted); suite 1182 passed. Docs synced.

Refs #1089

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): drop token border utilities from UninstallPanel (design guard)

tests/test_no_literal_borders.py::test_no_token_border_utilities_in_jsx is a
backend guard that scans JSX — so a frontend-only test run misses it. It forbids
`border-[var(--chrome-border)]` structural utilities: the app-wide border removal
converted every panel/row frame away from them, and they render a stray hairline
the moment the token doesn't resolve transparent.

Row dividers → spacing + an alternating `--chrome-hover-bg` tint; the opt-in
checkbox card → a background tint; the confirm input → the sanctioned arbitrary
`[border:1px_solid_var(--chrome-border)]` property form the other settings inputs
already use (explicitly not flagged by the guard).

Guard green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:35:17 +05:30
69 changed files with 3314 additions and 310 deletions
+28
View File
@@ -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
+34
View File
@@ -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, ~12 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.
+12 -5
View File
@@ -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).
+9
View File
@@ -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:
+1 -1
View File
@@ -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:
+78 -1
View File
@@ -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:
+99
View File
@@ -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, ~12 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
+17 -1
View File
@@ -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)."""
+29 -5
View File
@@ -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
+51 -4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.3.19",
"version": "0.3.21",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+2 -1
View File
@@ -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",
+6 -1
View File
@@ -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"
+16 -3
View File
@@ -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 {
+6
View File
@@ -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())?;
+537
View File
@@ -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));
}
}
+229
View File
@@ -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}"
);
}
}
}
}
+31 -4
View File
@@ -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 1020+ 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'}
+18 -85
View File
@@ -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] 01, 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 }),
);
});
});
+21
View File
@@ -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',
+1 -9
View File
@@ -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": "المزيد عن المشروع والصانع."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+53 -8
View File
@@ -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",
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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": "परियोजना और निर्माता के बारे में अधिक जानकारी."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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": "プロジェクトとメーカーについて詳しく説明します。"
}
}
}
+1 -9
View File
@@ -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": "프로젝트와 제작자에 대해 자세히 알아보세요."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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": "Подробнее о проекте и создателе."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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": "ข้อมูลเพิ่มเติมเกี่ยวกับโครงการและผู้สร้าง"
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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": "Детальніше про проект та виробника."
}
}
}
+1 -9
View File
@@ -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."
}
}
}
+1 -9
View File
@@ -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": "有关该项目和制造商的更多信息。"
}
}
}
+1 -9
View File
@@ -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": "有關該項目和製造商的更多資訊。"
}
}
}
+41
View File
@@ -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'));
+98
View File
@@ -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);
});
});
+53
View File
@@ -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

+2
View File
@@ -26,6 +26,8 @@ export const COMPONENTS = [
'AppearancePanel',
'GeneralTab',
'StoragePanel',
'ResetPanel',
'UninstallPanel',
] as const;
export const THEMES = ['default', 'midnight', 'catppuccin'] as const;
+20
View File
@@ -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}>
+110
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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 }
}
+4
View File
@@ -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"
;;
*)
+144
View File
@@ -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
+230
View File
@@ -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
Generated
+1 -1
View File
@@ -3207,7 +3207,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.3.19"
version = "0.3.21"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },