Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c692d26c | ||
|
|
662339b50a | ||
|
|
0421be966e |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.3.19",
|
||||
"version": "0.3.20",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.19"
|
||||
version = "0.3.20"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! In-app uninstall — "remove all OmniVoice data" (#1089).
|
||||
//!
|
||||
//! Why this lives in the Rust shell and not the backend: the biggest thing to
|
||||
//! remove is the **managed Python environment**, and the backend is *running
|
||||
//! from it*. A process cannot delete its own interpreter out from under itself
|
||||
//! (and on Windows the files are locked while it lives). The shell owns the
|
||||
//! backend's lifetime, so it can stop it, delete everything, and exit.
|
||||
//!
|
||||
//! Paths come from the same single source of truth the rest of the app uses —
|
||||
//! `setup::{resolved_data_dir, default_data_dir, env_root, resolved_models_dir,
|
||||
//! default_models_dir}` and `backend::backend_log_path()` — so a custom or
|
||||
//! portable install is cleaned correctly instead of the defaults being assumed.
|
||||
//!
|
||||
//! Safety: nothing is deleted that doesn't pass `is_recognizably_ours()` (an
|
||||
//! absolute path, not `/` or `$HOME`, carrying an OmniVoice-owned component).
|
||||
//! The shared Hugging Face cache is reported separately and is **opt-in** — it
|
||||
//! is the standard HF cache other ML tools share, so sweeping it up silently
|
||||
//! would delete models this app never downloaded.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{backend_port, AppFlags};
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct UninstallTarget {
|
||||
/// Stable id the UI keys off: "data" | "env" | "logs" | "models".
|
||||
pub key: String,
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub exists: bool,
|
||||
/// True for the shared Hugging Face cache — opt-in, never removed by default.
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct UninstallReport {
|
||||
pub removed: Vec<String>,
|
||||
pub failed: Vec<String>,
|
||||
pub freed_bytes: u64,
|
||||
}
|
||||
|
||||
/// Recursive size of a directory. Symlinks are NOT followed: the HF cache is a
|
||||
/// forest of symlinks into `blobs/`, and following them would count the same
|
||||
/// bytes many times over (and could wander outside the tree entirely).
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
if !path.exists() {
|
||||
return 0;
|
||||
}
|
||||
walkdir::WalkDir::new(path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The backend's own log directory (`backend.log` / `backend_err.log`).
|
||||
/// `backend_log_path()` returns the FILE; we remove the directory it lives in,
|
||||
/// which is OmniVoice-owned on every platform:
|
||||
/// macOS ~/Library/Logs/OmniVoice
|
||||
/// Windows %LOCALAPPDATA%\OmniVoice\Logs
|
||||
/// Linux ~/.local/state/OmniVoice
|
||||
fn backend_log_dir() -> Option<PathBuf> {
|
||||
crate::backend::backend_log_path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// A last-resort guard before any `remove_dir_all`. A path only qualifies if it
|
||||
/// is absolute, has a parent (never `/`), is not the home directory itself, and
|
||||
/// carries a component this app actually owns. Pure — unit-tested below.
|
||||
pub fn is_recognizably_ours(path: &Path, home: Option<&Path>) -> bool {
|
||||
if !path.is_absolute() || path.parent().is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(home) = home {
|
||||
if path == home {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const OWNED: [&str; 5] = [
|
||||
"OmniVoice",
|
||||
"omnivoice",
|
||||
".omnivoice",
|
||||
"com.debpalash.omnivoice-studio",
|
||||
"huggingface",
|
||||
];
|
||||
path.components()
|
||||
.filter_map(|c| c.as_os_str().to_str())
|
||||
.any(|c| OWNED.iter().any(|o| c.eq_ignore_ascii_case(o)))
|
||||
}
|
||||
|
||||
fn target(key: &str, path: PathBuf, shared: bool) -> UninstallTarget {
|
||||
let exists = path.exists();
|
||||
UninstallTarget {
|
||||
key: key.to_string(),
|
||||
size_bytes: if exists { dir_size(&path) } else { 0 },
|
||||
path: path.to_string_lossy().to_string(),
|
||||
exists,
|
||||
shared,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every folder this install owns, with sizes — what the confirmation UI shows.
|
||||
/// Honors custom + portable locations via the shared resolvers.
|
||||
#[tauri::command]
|
||||
pub fn uninstall_scan(app: tauri::AppHandle) -> Vec<UninstallTarget> {
|
||||
let data = crate::setup::resolved_data_dir(&app).unwrap_or_else(crate::setup::default_data_dir);
|
||||
let env = crate::setup::env_root(&app);
|
||||
let models =
|
||||
crate::setup::resolved_models_dir(&app).unwrap_or_else(crate::setup::default_models_dir);
|
||||
|
||||
let mut out = vec![
|
||||
// Voices, projects, DB, generated audio, the backend's rolling log.
|
||||
target("data", data, false),
|
||||
// config.json + the managed Python env (project/.venv) — the multi-GB one.
|
||||
target("env", env, false),
|
||||
];
|
||||
if let Some(logs) = backend_log_dir() {
|
||||
out.push(target("logs", logs, false));
|
||||
}
|
||||
// Shared with every other huggingface_hub tool on this machine → opt-in.
|
||||
out.push(target("models", models, true));
|
||||
out
|
||||
}
|
||||
|
||||
/// Stop the backend and delete the scanned folders. `include_models` opts into
|
||||
/// the shared Hugging Face cache. Returns what was removed; the caller quits the
|
||||
/// app afterwards (the Python env it runs on is gone, so there is nothing to
|
||||
/// return to).
|
||||
#[tauri::command]
|
||||
pub fn uninstall_purge(
|
||||
app: tauri::AppHandle,
|
||||
include_models: bool,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<UninstallReport, String> {
|
||||
// Mark the app as quitting BEFORE the backend dies, so the #567 supervisor
|
||||
// treats the death as intentional and doesn't respawn a backend into the
|
||||
// very directories we are about to delete.
|
||||
flags
|
||||
.quitting
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
crate::bootstrap::set_backend_kill_intended(true);
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
|
||||
let home = dirs_next::home_dir();
|
||||
let mut report = UninstallReport {
|
||||
removed: vec![],
|
||||
failed: vec![],
|
||||
freed_bytes: 0,
|
||||
};
|
||||
|
||||
for t in uninstall_scan(app.clone()) {
|
||||
if !t.exists {
|
||||
continue;
|
||||
}
|
||||
if t.shared && !include_models {
|
||||
continue; // the shared HF cache stays unless explicitly opted in
|
||||
}
|
||||
let path = PathBuf::from(&t.path);
|
||||
if !is_recognizably_ours(&path, home.as_deref()) {
|
||||
log::warn!("uninstall: refusing to delete unrecognized path {}", t.path);
|
||||
report.failed.push(t.path);
|
||||
continue;
|
||||
}
|
||||
match fs::remove_dir_all(&path) {
|
||||
Ok(()) => {
|
||||
log::info!("uninstall: removed {}", t.path);
|
||||
report.freed_bytes += t.size_bytes;
|
||||
report.removed.push(t.path);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("uninstall: failed to remove {}: {}", t.path, e);
|
||||
report.failed.push(t.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refuses_root_home_and_foreign_paths() {
|
||||
let home = PathBuf::from("/Users/someone");
|
||||
// Never the filesystem root or the home dir itself.
|
||||
assert!(!is_recognizably_ours(Path::new("/"), Some(&home)));
|
||||
assert!(!is_recognizably_ours(&home, Some(&home)));
|
||||
// Never a path we don't own, even under home.
|
||||
assert!(!is_recognizably_ours(
|
||||
Path::new("/Users/someone/Documents"),
|
||||
Some(&home)
|
||||
));
|
||||
// Never a relative path.
|
||||
assert!(!is_recognizably_ours(Path::new("relative/omnivoice"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_real_targets_on_every_platform() {
|
||||
let home = PathBuf::from("/Users/someone");
|
||||
for p in [
|
||||
"/Users/someone/Library/Application Support/OmniVoice",
|
||||
"/Users/someone/Library/Application Support/com.debpalash.omnivoice-studio",
|
||||
"/Users/someone/Library/Logs/OmniVoice",
|
||||
"/Users/someone/.omnivoice",
|
||||
"/Users/someone/.local/state/OmniVoice",
|
||||
"/Users/someone/.local/share/com.debpalash.omnivoice-studio",
|
||||
"/Users/someone/.cache/huggingface",
|
||||
"C:\\Users\\someone\\AppData\\Roaming\\OmniVoice",
|
||||
] {
|
||||
let path = PathBuf::from(p);
|
||||
// Windows-style paths aren't absolute on unix; only assert the ones that are.
|
||||
if path.is_absolute() {
|
||||
assert!(
|
||||
is_recognizably_ours(&path, Some(&home)),
|
||||
"should accept {p}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,24 @@ const TRANSPORT_RETRY_BACKOFF_MS = [400, 900, 1600];
|
||||
const RESTART_WAIT_INTERVAL_MS = 1500;
|
||||
const STARTUP_GRACE_MS = 120_000;
|
||||
|
||||
// #1101: the shell's stage is a 2-second POLL, not a live probe. When the
|
||||
// backend dies mid-generate, `supervise_backend` needs up to ~2 s to notice the
|
||||
// exit, record the crash marker, and flip the stage to "starting" — so a single
|
||||
// check at the end of the ~2.9 s cascade very often still sees `ready` and we
|
||||
// dead-ended on the generic "Can't reach the backend" anyway. That was the hole
|
||||
// in the #1094 fix, reported against 0.3.19.
|
||||
//
|
||||
// A transport failure CONTRADICTS `ready`: if the shell believed the backend
|
||||
// were reachable, the fetch would have succeeded. So `ready` is treated as a
|
||||
// STALE belief, not an authority — we keep retrying across this reconciliation
|
||||
// window, re-asking each time, which lets a death the supervisor hasn't noticed
|
||||
// yet turn into "starting" (→ the long wait + banner) and gives the crash marker
|
||||
// time to be written so the error can tell the honest story instead of guessing.
|
||||
// Only `failed` (the shell gave up) or `unknown` (no shell — browser/Docker)
|
||||
// still errors immediately.
|
||||
const RECONCILE_MS = 12_000;
|
||||
const RECONCILE_INTERVAL_MS = 1000;
|
||||
|
||||
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const pin = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ov_pin') : null;
|
||||
const key = _apiKey();
|
||||
@@ -170,10 +188,9 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
// The short cascade is exhausted, but the desktop shell may KNOW the
|
||||
// backend is mid-start/restart (a real one takes 10–20+ s — torch
|
||||
// import — not 2.9 s). Keep waiting exactly as long as the shell says
|
||||
// "starting", bounded by STARTUP_GRACE_MS; 'failed'/'unknown' (no
|
||||
// shell, or the shell gave up) falls through to the error below so a
|
||||
// truly dead backend still surfaces promptly.
|
||||
if (Date.now() - startedAt < STARTUP_GRACE_MS) {
|
||||
// "starting", bounded by STARTUP_GRACE_MS.
|
||||
const elapsed = Date.now() - startedAt;
|
||||
if (elapsed < STARTUP_GRACE_MS) {
|
||||
let stage = 'unknown';
|
||||
try {
|
||||
stage = await backendLifecycleStage();
|
||||
@@ -184,6 +201,16 @@ export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Re
|
||||
await new Promise((r) => setTimeout(r, RESTART_WAIT_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
// `ready` while the transport is failing is a contradiction — the
|
||||
// shell's 2 s poll simply hasn't caught up with a backend that just
|
||||
// died (#1101). Don't believe it yet: keep retrying briefly so the
|
||||
// supervisor can notice, flip to "starting", and write the crash
|
||||
// marker. 'failed'/'unknown' fall through and error now, so a shell
|
||||
// that gave up — or no shell at all — still surfaces promptly.
|
||||
if (stage === 'ready' && elapsed < RECONCILE_MS) {
|
||||
await new Promise((r) => setTimeout(r, RECONCILE_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// #941: if the desktop shell recorded an unacknowledged backend crash,
|
||||
// tell the honest story instead of the vague "can't reach" — and let
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -64,6 +64,47 @@ describe('apiFetch — lifecycle-aware restart wait', () => {
|
||||
await assertion;
|
||||
});
|
||||
|
||||
// #1101 — the hole in the original fix, reported against 0.3.19. The shell's
|
||||
// stage is a 2 s POLL: when the backend dies mid-generate the supervisor needs
|
||||
// a moment to notice, flip to "starting" and write the crash marker. Asking
|
||||
// once at the end of the cascade still saw `ready`, so the request dead-ended
|
||||
// on the generic toast anyway. A transport failure contradicts `ready`, so we
|
||||
// must keep retrying long enough for the shell to catch up.
|
||||
it('does NOT believe a stale "ready" — it waits for the shell to notice the death', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
// The supervisor hasn't ticked yet: still 'ready' for the first few polls,
|
||||
// then it notices the death and flips to 'starting'.
|
||||
stageMock
|
||||
.mockResolvedValueOnce('ready')
|
||||
.mockResolvedValueOnce('ready')
|
||||
.mockResolvedValue('starting');
|
||||
|
||||
const p = apiFetch('/generate');
|
||||
// Never settles into the generic error — it keeps waiting.
|
||||
const settled = vi.fn();
|
||||
p.then(settled, settled);
|
||||
await vi.advanceTimersByTimeAsync(CASCADE_MS + 1000 + 1000 + 1500);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
// Once the backend comes back, the request succeeds — the toast never fired.
|
||||
fetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
await vi.advanceTimersByTimeAsync(1500 * 2);
|
||||
await expect(p).resolves.toMatchObject({ status: 200 });
|
||||
});
|
||||
|
||||
it('still gives up when a "ready" backend stays unreachable past the reconcile window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||
stageMock.mockResolvedValue('ready'); // shell insists it's fine; it never recovers
|
||||
|
||||
const p = apiFetch('/model/status');
|
||||
const assertion = expect(p).rejects.toMatchObject({ status: 0 });
|
||||
await vi.advanceTimersByTimeAsync(CASCADE_MS + 12_000 + 2000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it('keeps the old prompt failure outside the Tauri shell (stage unknown)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
|
||||
@@ -0,0 +1,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
@@ -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
|
||||
|
||||
@@ -56,8 +56,13 @@ function Get-FolderSize($path) {
|
||||
} catch { return '?' }
|
||||
}
|
||||
|
||||
# The BACKEND writes its own logs here (backend_log_path() in
|
||||
# src-tauri/src/backend.rs) — a sibling of hf_cache under %LOCALAPPDATA%\OmniVoice,
|
||||
# so it is covered by neither the app-data nor the config dir.
|
||||
$logsDefault = Join-Path (Join-Path $localApp 'OmniVoice') 'Logs'
|
||||
|
||||
$appTargets = @()
|
||||
foreach ($p in @($dataDir, $configDefault)) {
|
||||
foreach ($p in @($dataDir, $configDefault, $logsDefault)) {
|
||||
if (Test-Path -LiteralPath $p) { $appTargets += $p }
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ case "$OS" in
|
||||
Linux)
|
||||
data_default="$HOME/.omnivoice"
|
||||
config_default="${XDG_DATA_HOME:-$HOME/.local/share}/$IDENTIFIER"
|
||||
# The BACKEND writes its own logs outside the app-data dir — see
|
||||
# backend_log_path() in src-tauri/src/backend.rs. Missing this left a stray
|
||||
# log dir behind on every Linux uninstall.
|
||||
logs_extra=("${XDG_STATE_HOME:-$HOME/.local/state}/OmniVoice")
|
||||
models_default="$HOME/.cache/huggingface"
|
||||
;;
|
||||
*)
|
||||
|
||||
Reference in New Issue
Block a user