Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)

- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
2026-10-04 18:13:57 +05:30
parent 1623b879e2
commit 92f952b136
16 changed files with 2513 additions and 7 deletions
+20
View File
@@ -56,6 +56,15 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
return Err(e.into());
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => {
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
}
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
}
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
Ok(conn) => conn,
Err(e) => {
@@ -71,6 +80,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
eprintln!("Could not derive the logo images: {e}");
}
commands::backup::spawn_auto_backup(dirs);
Ok(AppState {
db: Mutex::new(conn),
data_dir,
@@ -80,6 +90,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
commands::backup::wait_if_relaunched();
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
// combinations. Respect an explicit user setting, otherwise turn it off.
#[cfg(target_os = "linux")]
@@ -136,6 +149,13 @@ pub fn run() {
commands::payments::record_payment,
commands::payments::list_payments,
commands::payments::delete_payment,
commands::backup::create_backup,
commands::backup::restore_backup,
commands::backup::cancel_pending_restore,
commands::backup::get_backup_status,
commands::backup::set_auto_backup,
commands::backup::restart_app,
commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,