Release 1.2.0: Reset all data #3

Merged
xavierk merged 7 commits from release/1.2.0 into main 2026-10-06 07:14:35 +00:00
15 changed files with 405 additions and 21 deletions
Showing only changes of commit 8122129a19 - Show all commits
+15 -4
View File
@@ -8,7 +8,7 @@ connection. The only optional network feature is the ERPNext push, which you con
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
Current release: **1.1.0**. Installers and the Void package are attached to the release on the project's Gitea
Current release: **1.2.0**. Installers and the Void package are attached to the release on the project's Gitea
page (see "Installing").
## Features
@@ -38,6 +38,7 @@ page (see "Installing").
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
balance is cash plus TDS against the invoice total.
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
- **Reset all data.** Settings, Data wipes everything and returns to first-run setup after a typed `RESET`.
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
(see `docs/voiced-invoice-v1.md`).
@@ -85,11 +86,11 @@ The release procedure, including the manual first-run checklist, is in `docs/REL
## Installing
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
`v1.1.0`).
`v1.2.0`).
- **Windows 10/11:** run `Voiced_1.1.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
- **Windows 10/11:** run `Voiced_1.2.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
SmartScreen may warn: choose "More info", then "Run anyway".
- **Void Linux:** install `voiced-1.1.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
- **Void Linux:** install `voiced-1.2.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
@@ -126,6 +127,16 @@ Settings, Data tab.
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
backup is lost.
## Reset all data
Settings, Data, Reset all data. This permanently deletes the database (invoices, clients, payments, business details
and settings), `assets/`, the PDF `archive/`, imported `fonts/` and the whole `backups/` folder, including automatic
backups and the `pre-restore-*` safety copies, then starts Voiced as if it were new (the setup wizard shows again).
You must type `RESET` to enable the button. The wipe is staged and applied on the next start, before the database is
opened, so the "Delete everything and restart" button restarts the app for you; if a file cannot be deleted the
wipe is retried on the next start. Backup zips you saved elsewhere are not touched. Take a backup first if you may
want the data back.
## Fonts and licences
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "voiced",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voiced",
"version": "1.1.0",
"version": "1.2.0",
"license": "Apache-2.0",
"dependencies": {
"@carbon/icons-react": "^11.89.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "voiced",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"license": "Apache-2.0",
"description": "Voiced — offline invoicing desktop app by Bongbetic",
"type": "module",
+1 -1
View File
@@ -28,7 +28,7 @@
# pure Rust and need no extra system libraries.
#
pkgname=voiced
version=1.1.0
version=1.2.0
revision=1
archs="x86_64*"
hostmakedepends="rust nodejs pkg-config"
+1 -1
View File
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voiced"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voiced"
version = "1.1.0"
version = "1.2.0"
license = "Apache-2.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
+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,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voiced",
"version": "1.1.0",
"version": "1.2.0",
"identifier": "com.bongbetic.voiced",
"build": {
"beforeDevCommand": "npm run dev",
+73 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { Settings } from "../lib/types";
@@ -11,6 +11,7 @@ import {
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
onRerunSetup: () => void;
}
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
@@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const [resetOpen, setResetOpen] = useState(false);
const [resetTyped, setResetTyped] = useState("");
const [resetting, setResetting] = useState(false);
useReturnFocus(restorePath !== null || resetOpen);
const rerunSetup = async () => {
const ok = await confirm(
@@ -54,6 +58,31 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
}
};
const closeReset = () => {
if (resetting) return;
setResetOpen(false);
setResetTyped("");
};
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
const resetAll = async () => {
setResetting(true);
try {
await api.resetAllData();
} catch (e) {
setResetting(false);
toast.error("Could not reset", String(e));
return;
}
clearVoicedLocalState();
try {
await api.restartApp();
} catch (e) {
setResetting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
}
};
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Reset all data</h2>
<p className="voiced-muted">
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
as if it were new.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
Reset all data…
</Button>
</div>
</Tile>
<Modal
open={resetOpen}
danger
modalHeading="Reset all data?"
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
onRequestClose={closeReset}
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
onSecondarySubmit={closeReset}
>
<div className="voiced-stack">
<p>
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
every backup (automatic, manual copies kept in Voiced&rsquo;s folder, and safety copies) will be permanently deleted. This
cannot be undone.
</p>
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
<TextInput
id="resetConfirm"
labelText={`Type ${RESET_WORD} to confirm`}
value={resetTyped}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
autoComplete="off"
/>
</div>
</Modal>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
+2
View File
@@ -122,6 +122,8 @@ export const api = {
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
resetAllData: () => invoke<void>("reset_all_data"),
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
describe("isResetConfirmed", () => {
it("accepts the word in any case with surrounding spaces", () => {
expect(isResetConfirmed("RESET")).toBe(true);
expect(isResetConfirmed(" reset ")).toBe(true);
});
it("rejects anything else", () => {
expect(isResetConfirmed("")).toBe(false);
expect(isResetConfirmed("rese")).toBe(false);
expect(isResetConfirmed("reset all")).toBe(false);
});
});
describe("clearVoicedLocalState", () => {
// The node test environment has no localStorage, so stub a minimal one.
beforeEach(() => {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
get length() {
return data.size;
},
key: (i: number) => [...data.keys()][i] ?? null,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
});
});
afterEach(() => vi.unstubAllGlobals());
it("removes only the app's own keys", () => {
localStorage.setItem("voiced.theme", "g100");
localStorage.setItem("voiced.settingsTab", "data");
localStorage.setItem("other.key", "keep");
clearVoicedLocalState();
expect(localStorage.getItem("voiced.theme")).toBeNull();
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
expect(localStorage.getItem("other.key")).toBe("keep");
});
});
+18
View File
@@ -0,0 +1,18 @@
/** The word typed to enable "Reset all data". */
export const RESET_WORD = "RESET";
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
export function clearVoicedLocalState(): void {
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key && key.startsWith("voiced.")) keys.push(key);
}
keys.forEach((key) => localStorage.removeItem(key));
} catch {
// Storage can be blocked; there is then nothing to clear.
}
}