Compare commits

...
3 Commits
Author SHA1 Message Date
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
19 changed files with 705 additions and 15 deletions
+22
View File
@@ -718,6 +718,28 @@ 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.
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 release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
scripts/uninstall.sh
scripts/uninstall.ps1
fail_on_unmatched_files: true
# ── 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
+14
View File
@@ -6,6 +6,20 @@ 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.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.
+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.20"
def _fallback_version() -> str:
+19 -4
View File
@@ -9,7 +9,20 @@ 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.
> Note: **Factory reset** (right above it) is a different, much smaller action —
> it only clears UI preferences and leaves your voices, projects, and audio
> alone.
## The one-command uninstaller (from a clone)
From a clone or the source tarball:
@@ -60,7 +73,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 +82,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 +113,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.20",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.19"
version = "0.3.20"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.19"
version = "0.3.20"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
+3
View File
@@ -14,6 +14,7 @@ pub mod tools;
pub mod backend;
pub mod commands;
pub mod crash;
pub mod uninstall;
pub mod updater_channel;
use std::process::Child;
@@ -400,6 +401,8 @@ pub fn run() {
commands::clear_webview_cache_and_relaunch,
crash::get_last_backend_crash,
crash::acknowledge_backend_crash,
uninstall::uninstall_scan,
uninstall::uninstall_purge,
])
.setup(move |app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
+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
@@ -24,6 +24,7 @@ import { Button, Dialog } from '../../ui';
import { SettingsSection } from './primitives';
import Row from './Row';
import HistoryRetentionPanel from './HistoryRetentionPanel';
import UninstallPanel from './UninstallPanel';
export default function StorageTab() {
const { t } = useTranslation();
@@ -128,6 +129,9 @@ export default function StorageTab() {
</Button>
</SettingsSection>
{/* The real uninstaller (#1089) — factory reset above only clears UI prefs. */}
<UninstallPanel />
<Dialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
@@ -0,0 +1,257 @@
/**
* 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 } from 'lucide-react';
import toast from 'react-hot-toast';
import { Button, Dialog } from '../../ui';
import { SettingsSection } from './primitives';
const inTauri = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
/** "1.4 GB" / "820 MB" / "12 KB". Pure + exported for tests. */
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]}`;
}
/** 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);
}
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 && (
<ul className="m-0 mb-[var(--space-4)] list-none p-0">
{present.map((tg) => (
<li
key={tg.key}
className="flex items-baseline justify-between gap-[var(--space-4)] rounded-[var(--radius-md)] px-[var(--space-2)] py-[var(--space-2)] odd:bg-[var(--chrome-hover-bg)]"
>
<span className="[font-family:var(--font-sans)] text-[length:var(--text-md)] text-[var(--chrome-fg)]">
{LABELS[tg.key] || tg.key}
{tg.shared && (
<span className="ml-[var(--space-2)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)]">
{t('settings.uninstall_shared_tag', { defaultValue: '— optional' })}
</span>
)}
<span className="block [font-family:var(--font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg-subtle)]">
{tg.path}
</span>
</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>
)}
<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>
{models && (
<label className="flex cursor-pointer items-start gap-[var(--space-3)] rounded-[var(--radius-md)] bg-[var(--chrome-hover-bg)] p-[var(--space-3)]">
<input
type="checkbox"
checked={includeModels}
onChange={(e) => setIncludeModels(e.target.checked)}
data-testid="uninstall-include-models"
className="mt-1"
/>
<span className="[font-family:var(--font-sans)] text-[length:var(--text-sm)] leading-[1.6] text-[var(--chrome-fg-muted)]">
{t('settings.uninstall_models_opt_in', {
defaultValue:
'Also delete the downloaded model weights ({{size}}). This is the standard Hugging Face cache shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. They can always be downloaded again.',
size: fmtBytes(models.size_bytes),
})}
</span>
</label>
)}
<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>
</>
);
}
+16
View File
@@ -577,6 +577,22 @@
"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.",
"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_shared_tag": "— optional",
"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_models_opt_in": "Also delete the downloaded model weights ({{size}}). This is the standard Hugging Face cache shared with other AI tools on this machine — removing it may delete models OmniVoice never downloaded. They can always be downloaded again.",
"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}}",
"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.",
+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'));
+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');
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnivoice"
version = "0.3.19"
version = "0.3.20"
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"
;;
*)
Generated
+1 -1
View File
@@ -3207,7 +3207,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.3.19"
version = "0.3.20"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },