Add Reset all data to Settings > Data and release 1.2.0
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 40s
CI / lint (pull_request) Successful in 2m50s
CI / e2e (pull_request) Successful in 3m3s
CI / ai-review (pull_request) Failing after 10m2s

Reset all data stages a wipe behind a typed RESET confirmation and applies it
on the next start, before the database is opened. It deletes the database,
assets, PDF archive, imported fonts and every backup, then the setup wizard
shows again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:29:52 +05:30
co-authored by Claude Sonnet 5.5
parent 93a4ece886
commit 8122129a19
15 changed files with 405 additions and 21 deletions
+6 -6
View File
@@ -52,11 +52,11 @@ pub const BACKUP_FORMAT: &str = "voiced.backup.v1";
pub const AUTO_KEEP: usize = 14;
const MANIFEST_NAME: &str = "manifest.json";
const DB_ENTRY: &str = "voiced.db";
const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
const MARKER_NAME: &str = "pending_restore.json";
const LAST_RESTORE_NAME: &str = "last_restore.json";
const STAGING_NAME: &str = "pending-restore";
pub(super) const DB_ENTRY: &str = "voiced.db";
pub(super) const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
pub(super) const MARKER_NAME: &str = "pending_restore.json";
pub(super) const LAST_RESTORE_NAME: &str = "last_restore.json";
pub(super) const STAGING_NAME: &str = "pending-restore";
const AUTO_PREFIX: &str = "voiced-auto-";
const AUTO_SUFFIX: &str = ".zip";
@@ -100,7 +100,7 @@ impl DataDirs {
self.data.join(LAST_RESTORE_NAME)
}
/// Where a root (`assets`, `archive`, `fonts`) lives.
fn root_dir(&self, root: &str) -> PathBuf {
pub(super) fn root_dir(&self, root: &str) -> PathBuf {
match root {
"assets" => self.data.join("assets"),
_ => self.local.join(root),
+1
View File
@@ -10,5 +10,6 @@ pub mod logo;
pub mod payments;
pub mod presets;
pub mod raw;
pub mod reset;
pub mod series;
pub mod settings;
+231
View File
@@ -0,0 +1,231 @@
//! Reset all data: wipe everything the app owns, then start blank (the first-run wizard shows again).
//!
//! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`,
//! so deleting them live is unsafe. Like a restore, a reset has two phases:
//!
//! 1. `reset_all_data` (`stage_wipe_impl`) writes `<data>/pending_wipe.json` and touches nothing else.
//! 2. `apply_pending_wipe` runs in `init_state` BEFORE a staged restore is applied and before the database is
//! opened. It deletes exactly the names in `OWNED_DB_FILES` and `OWNED_DIRS`, never anything else in the
//! data directories (on Linux both roots are the same folder and may hold unrelated files), and removes the
//! marker last, so a failed wipe is retried on the next start. The frontend restarts the app after staging.
//!
//! This also deletes the backups (`<data>/backups`, including pre-restore safety copies and pre-migration
//! copies); that is the point of a reset.
use super::backup::{DataDirs, DB_ENTRY, LAST_RESTORE_NAME, MARKER_NAME, ROOTS, STAGING_NAME};
use super::raw::write_atomic;
use crate::AppState;
use std::fs;
use std::path::Path;
use tauri::State;
const WIPE_MARKER_NAME: &str = "pending_wipe.json";
/// The database and its SQLite sidecars, in the data dir.
const OWNED_DB_FILES: [&str; 4] = [
DB_ENTRY,
"voiced.db-wal",
"voiced.db-shm",
"voiced.db-journal",
];
/// Marker files of the restore mechanism, in the data dir.
const OWNED_MARKERS: [&str; 2] = [MARKER_NAME, LAST_RESTORE_NAME];
#[derive(Debug, PartialEq, Eq)]
pub enum WipeOutcome {
NothingPending,
Wiped,
/// Something could not be deleted; the marker is kept so the next start retries.
Failed {
message: String,
},
}
fn marker(dirs: &DataDirs) -> std::path::PathBuf {
dirs.data.join(WIPE_MARKER_NAME)
}
/// Remove a file or directory tree if it exists. A symlink is unlinked, never followed.
fn remove_if_exists(path: &Path) -> Result<(), String> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("Could not delete {}: {e}", path.display())),
};
let result = if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
result.map_err(|e| format!("Could not delete {}: {e}", path.display()))
}
/// Record that the next start must wipe everything. The live data is not touched.
pub fn stage_wipe_impl(dirs: &DataDirs) -> Result<(), String> {
write_atomic(&marker(dirs), b"{}")
}
/// Call after the data directories exist and before anything else (restore, database) uses them.
pub fn apply_pending_wipe(dirs: &DataDirs) -> WipeOutcome {
if fs::symlink_metadata(marker(dirs)).is_err() {
return WipeOutcome::NothingPending;
}
let mut targets = Vec::new();
// Order matters: the database goes first so a half-finished wipe never leaves data with no settings row.
for name in OWNED_DB_FILES {
targets.push(dirs.data.join(name));
}
targets.push(dirs.backups());
for root in ROOTS {
targets.push(dirs.root_dir(root));
}
for name in OWNED_MARKERS {
targets.push(dirs.data.join(name));
}
targets.push(dirs.data.join(STAGING_NAME));
targets.push(dirs.local.join(STAGING_NAME));
let errors: Vec<String> = targets
.iter()
.filter_map(|p| remove_if_exists(p).err())
.collect();
if !errors.is_empty() {
return WipeOutcome::Failed {
message: errors.join("; "),
};
}
match remove_if_exists(&marker(dirs)) {
Ok(()) => WipeOutcome::Wiped,
Err(message) => WipeOutcome::Failed { message },
}
}
/// Phase one of a reset: stage the wipe. The app must be restarted to carry it out.
#[tauri::command]
pub fn reset_all_data(state: State<AppState>) -> Result<(), String> {
stage_wipe_impl(&DataDirs::new(&state.data_dir, &state.local_data_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn populate(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
fs::write(dirs.data.join(name), b"x").unwrap();
}
fs::create_dir_all(dirs.backups().join("auto")).unwrap();
fs::write(
dirs.backups().join("auto").join("voiced-auto-20260101.zip"),
b"x",
)
.unwrap();
for root in ROOTS {
fs::create_dir_all(dirs.root_dir(root)).unwrap();
fs::write(dirs.root_dir(root).join("f.bin"), b"x").unwrap();
}
for staging in [dirs.data.join(STAGING_NAME), dirs.local.join(STAGING_NAME)] {
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("manifest.json"), b"x").unwrap();
}
fs::write(dirs.data.join("unrelated.txt"), b"keep").unwrap();
fs::create_dir_all(dirs.data.join("other")).unwrap();
fs::write(dirs.data.join("other").join("keep.txt"), b"keep").unwrap();
}
fn assert_wiped(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
assert!(!dirs.data.join(name).exists(), "{name}");
}
assert!(!dirs.backups().exists());
for root in ROOTS {
assert!(!dirs.root_dir(root).exists(), "{root}");
}
assert!(!dirs.data.join(STAGING_NAME).exists());
assert!(!dirs.local.join(STAGING_NAME).exists());
assert!(!marker(dirs).exists());
}
fn assert_unrelated_kept(dirs: &DataDirs) {
assert_eq!(fs::read(dirs.data.join("unrelated.txt")).unwrap(), b"keep");
assert!(dirs.data.join("other").join("keep.txt").exists());
}
#[test]
fn nothing_staged_means_nothing_is_deleted() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
assert!(dirs.db().exists());
assert!(dirs.backups().exists());
}
#[test]
fn staging_alone_does_not_touch_live_data() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert!(marker(&dirs).exists());
assert!(dirs.db().exists());
assert!(dirs.root_dir("archive").join("f.bin").exists());
}
#[test]
fn wipe_removes_owned_data_and_keeps_unrelated_files() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(dirs.data.exists());
}
#[test]
fn wipe_handles_separate_data_and_local_roots() {
let tmp = tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(&local).unwrap();
let dirs = DataDirs::new(&data, &local);
populate(&dirs);
fs::write(local.join("unrelated-local.txt"), b"keep").unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(local.join("unrelated-local.txt").exists());
}
#[test]
fn wipe_is_idempotent_when_things_are_missing() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
}
#[cfg(unix)]
#[test]
fn a_symlinked_root_is_unlinked_not_followed() {
let tmp = tempdir().unwrap();
let outside = tempdir().unwrap();
fs::write(outside.path().join("precious.txt"), b"keep").unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
std::os::unix::fs::symlink(outside.path(), dirs.root_dir("archive")).unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert!(!dirs.root_dir("archive").exists());
assert!(outside.path().join("precious.txt").exists());
}
}
+10 -1
View File
@@ -74,8 +74,16 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
);
return Err(e.into());
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
// A reset staged by the Data tab is carried out first (it also discards any staged restore).
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::reset::apply_pending_wipe(&dirs) {
commands::reset::WipeOutcome::NothingPending => {}
commands::reset::WipeOutcome::Wiped => eprintln!("All data was reset."),
commands::reset::WipeOutcome::Failed { message } => {
eprintln!("Reset failed, it will be retried on the next start: {message}")
}
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => {
@@ -186,6 +194,7 @@ pub fn run() {
commands::backup::get_backup_status,
commands::backup::set_auto_backup,
commands::backup::restart_app,
commands::reset::reset_all_data,
commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,