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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user