From d6ec6b2a1784aadf4839c378194b317d436a0a48 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 04:04:34 +0530 Subject: [PATCH] Add versioned DB migrations, startup error dialog and native basics rusqlite_migration with a VACUUM INTO backup before upgrading; M2 adds GST registration, snapshot, cancellation and draft columns and backfills snapshots for existing invoices. Startup failures now show a native dialog instead of exiting silently. Adds single-instance and window-state plugins, minimum window 960x600, embedBootstrapper, DMABUF workaround on Linux and xbps dependency updates. --- package.json | 3 +- packaging/void/build-xbps.sh | 2 +- packaging/void/template | 4 +- src-tauri/Cargo.lock | 47 ++- src-tauri/Cargo.toml | 11 +- src-tauri/capabilities/default.json | 21 +- src-tauri/src/commands/settings.rs | 14 +- src-tauri/src/db.rs | 435 +++++++++++++++++++++++++++- src-tauri/src/lib.rs | 86 +++++- src-tauri/src/models.rs | 4 + src-tauri/tauri.conf.json | 6 +- src/lib/types.ts | 4 + src/pdf/InvoiceDocument.test.tsx | 4 + 13 files changed, 607 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index e047d95..7389800 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "tauri": "tauri", "app:dev": "tauri dev", "app:build": "tauri build --features custom-protocol", - "app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol" + "app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol", + "app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'" }, "dependencies": { "@carbon/icons-react": "^11.89.0", diff --git a/packaging/void/build-xbps.sh b/packaging/void/build-xbps.sh index 0b8942d..a9ea852 100755 --- a/packaging/void/build-xbps.sh +++ b/packaging/void/build-xbps.sh @@ -71,7 +71,7 @@ mkdir -p "$REPO" -l "proprietary" \ -H "https://git.bongbetic.com/xavierk/Voiced" \ -m "Xavier Karma " \ - -D "libwebkit2gtk41>=2.40_1" \ + -D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \ -t "office finance" \ --compression zstd \ "$STAGE" diff --git a/packaging/void/template b/packaging/void/template index 4727fac..4274ae5 100644 --- a/packaging/void/template +++ b/packaging/void/template @@ -20,8 +20,8 @@ version=0.1.0 revision=1 archs="x86_64*" hostmakedepends="rust nodejs pkg-config" -makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel openssl-devel" -depends="libwebkit2gtk41" +makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel" +depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" short_desc="Offline invoicing app by Bongbetic" maintainer="Xavier Karma " license="custom:proprietary" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bfb5392..39287d6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -2887,6 +2887,16 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rusqlite_migration" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03451d4dc523c0a96d9e412c574670b3f5f184c3988b77a435876be25477bbd3" +dependencies = [ + "log", + "rusqlite", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3658,6 +3668,37 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-runtime" version = "2.12.1" @@ -4196,12 +4237,16 @@ dependencies = [ "base64 0.22.1", "chrono", "rusqlite", + "rusqlite_migration", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-window-state", + "tempfile", "thiserror 2.0.21", "uuid", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b4a1043..8ead673 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Voiced — offline invoicing desktop app by Bongbetic" authors = ["Bongbetic"] edition = "2021" -rust-version = "1.77" +rust-version = "1.95" # The `_lib` suffix keeps the library name distinct from the binary name on Windows. [lib] @@ -21,11 +21,20 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.40", features = ["bundled"] } +rusqlite_migration = "2.6" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } thiserror = "2" base64 = "0.22" +# Desktop-only plugins (not available on mobile targets). +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" + +[dev-dependencies] +tempfile = "3" + [features] # Production builds embed the frontend and serve it over the custom asset # protocol. Without this, the app expects a dev server on localhost:1420. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 39b3e68..ecdf9e5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,25 @@ "permissions": [ "core:default", "dialog:default", - "opener:default" + "opener:default", + "window-state:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD/**" }, + { "path": "$DESKTOP/**" }, + { "path": "$HOME/**" } + ] + }, + { + "identifier": "opener:allow-reveal-item-in-dir", + "allow": [ + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD/**" }, + { "path": "$DESKTOP/**" }, + { "path": "$HOME/**" } + ] + } ] } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 775b0c0..e39e3e3 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -5,7 +5,8 @@ use tauri::State; const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate, - default_tax_type, payment_terms_days, currency, onboarded, theme"; + default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name, + signatory_designation"; fn map_settings(row: &Row) -> rusqlite::Result { Ok(Settings { @@ -25,6 +26,10 @@ fn map_settings(row: &Row) -> rusqlite::Result { currency: row.get(13)?, onboarded: row.get::<_, i64>(14)? != 0, theme: row.get(15)?, + gst_registration: row.get(16)?, + default_hsn_sac: row.get(17)?, + signatory_name: row.get(18)?, + signatory_designation: row.get(19)?, }) } @@ -48,7 +53,8 @@ pub fn save_settings(state: State, settings: Settings) -> Result, settings: Settings) -> Result rusqlite::Result { - let conn = Connection::open(path)?; +/// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts. +const M2: &str = r#" +ALTER TABLE app_settings ADD COLUMN gst_registration TEXT NOT NULL DEFAULT 'unregistered'; +ALTER TABLE app_settings ADD COLUMN default_hsn_sac TEXT NOT NULL DEFAULT ''; +ALTER TABLE app_settings ADD COLUMN signatory_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE app_settings ADD COLUMN signatory_designation TEXT NOT NULL DEFAULT ''; +UPDATE app_settings SET gst_registration = 'regular' WHERE vendor_gstin <> ''; +-- An unregistered supplier may not charge GST, so new invoices default to no tax. +UPDATE app_settings SET default_tax_type = 'none' WHERE gst_registration = 'unregistered'; + +ALTER TABLE invoices ADD COLUMN doc_type TEXT NOT NULL DEFAULT 'invoice'; +ALTER TABLE invoices ADD COLUMN reverse_charge INTEGER NOT NULL DEFAULT 0; +ALTER TABLE invoices ADD COLUMN vendor_snapshot TEXT; +ALTER TABLE invoices ADD COLUMN render_prefs TEXT; +ALTER TABLE invoices ADD COLUMN snapshot_origin TEXT NOT NULL DEFAULT 'issued'; +ALTER TABLE invoices ADD COLUMN cancelled_at TEXT; +ALTER TABLE invoices ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT ''; +ALTER TABLE invoices ADD COLUMN archived_pdf_sha256 TEXT; + +UPDATE invoices SET + vendor_snapshot = ( + SELECT json_object( + 'vendorName', vendor_name, + 'vendorAddress', vendor_address, + 'vendorEmail', vendor_email, + 'vendorPhone', vendor_phone, + 'vendorPan', vendor_pan, + 'vendorGstin', vendor_gstin, + 'vendorStateCode', vendor_state_code, + 'logoPath', logo_path, + 'signaturePath', signature_path, + 'gstRegistration', gst_registration, + 'signatoryName', signatory_name, + 'signatoryDesignation', signatory_designation + ) FROM app_settings WHERE id = 1 + ), + snapshot_origin = 'backfilled', + doc_type = CASE WHEN tax_type <> 'none' THEN 'tax_invoice' ELSE 'invoice' END; + +ALTER TABLE invoice_items ADD COLUMN hsn_sac TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS invoice_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); +"#; + +fn migrations() -> Migrations<'static> { + Migrations::new(vec![M::up(SCHEMA), M::up(M2)]) +} + +/// Open (creating if needed) the database at `path` and bring it to the latest schema. +/// Existing databases are backed up into `backup_dir` before any migration runs. +pub fn open(path: &Path, backup_dir: &Path) -> Result { + let mut conn = Connection::open(path)?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; - conn.execute_batch(SCHEMA)?; + + let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + if version < LATEST_VERSION && has_user_tables(&conn)? { + backup_before_migration(&conn, backup_dir, version)?; + } + + migrations().to_latest(&mut conn)?; seed(&conn)?; Ok(conn) } +fn has_user_tables(conn: &Connection) -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')", + [], + |r| r.get(0), + ) +} + +/// Copy the database to `/voiced-pre-v{version}-{YYYYmmdd-HHMMSS}.db` +/// and drop all but the newest `MAX_BACKUPS` backups. +fn backup_before_migration( + conn: &Connection, + backup_dir: &Path, + version: i64, +) -> Result { + fs::create_dir_all(backup_dir)?; + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db")); + let quoted = target.to_string_lossy().replace('\'', "''"); + conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?; + // A failed prune must not block startup; the backup itself already succeeded. + let _ = prune_backups(backup_dir, MAX_BACKUPS); + Ok(target) +} + +/// Keep only the `keep` newest pre-migration backups, ordered by the timestamp +/// embedded in the file name (the schema version prefix is ignored). +fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> { + let mut backups: Vec<(String, PathBuf)> = Vec::new(); + for entry in fs::read_dir(backup_dir)? { + let path = entry?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if let Some(stem) = name + .strip_prefix(BACKUP_PREFIX) + .and_then(|n| n.strip_suffix(".db")) + { + // "{version}-{YYYYmmdd}-{HHMMSS}" -> "{YYYYmmdd}-{HHMMSS}" + let stamp = stem.split_once('-').map_or(stem, |(_, t)| t); + backups.push((stamp.to_string(), path)); + } + } + backups.sort(); + let excess = backups.len().saturating_sub(keep); + for (_, path) in backups.into_iter().take(excess) { + fs::remove_file(path)?; + } + Ok(()) +} + /// Insert the first-run defaults the very first time the app is opened. -/// Vendor details are pre-filled from the owner's existing invoice. +/// Vendor details are pre-filled from the owner's existing invoice. The vendor +/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax). fn seed(conn: &Connection) -> rusqlite::Result<()> { let settings_count: i64 = conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?; @@ -119,7 +253,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> { (id, vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate, default_tax_type, payment_terms_days, currency, onboarded, theme) - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'cgst_sgst', 30, 'INR', 0, 'g10')"#, + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#, params![ "Test Vendor", "1 Test Road, Bangalore - 560001", @@ -173,3 +307,290 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> { pub fn format_number(prefix: &str, padding: i64, number: i64) -> String { format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + /// The schema as it shipped before migrations existed (user_version 0). + const V1_SCHEMA: &str = r#" + +CREATE TABLE IF NOT EXISTS app_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + vendor_name TEXT NOT NULL DEFAULT '', + vendor_address TEXT NOT NULL DEFAULT '', + vendor_email TEXT NOT NULL DEFAULT '', + vendor_phone TEXT NOT NULL DEFAULT '', + vendor_pan TEXT NOT NULL DEFAULT '', + vendor_gstin TEXT NOT NULL DEFAULT '', + vendor_state_code TEXT NOT NULL DEFAULT '29', + logo_path TEXT, + signature_path TEXT, + default_bank_id INTEGER, + default_tax_rate REAL NOT NULL DEFAULT 18, + default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst', + payment_terms_days INTEGER NOT NULL DEFAULT 30, + currency TEXT NOT NULL DEFAULT 'INR', + onboarded INTEGER NOT NULL DEFAULT 0, + theme TEXT NOT NULL DEFAULT 'g10' +); + +CREATE TABLE IF NOT EXISTS bank_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL DEFAULT '', + bank_name TEXT NOT NULL DEFAULT '', + account_name TEXT NOT NULL DEFAULT '', + account_no TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + ifsc TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS invoice_series ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prefix TEXT NOT NULL, + padding INTEGER NOT NULL DEFAULT 3, + next_number INTEGER NOT NULL DEFAULT 1, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + address TEXT NOT NULL DEFAULT '', + gstin TEXT NOT NULL DEFAULT '', + state_code TEXT NOT NULL DEFAULT '', + po_number TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + number TEXT NOT NULL UNIQUE, + series_id INTEGER, + invoice_date TEXT NOT NULL, + due_date TEXT NOT NULL DEFAULT '', + client_id INTEGER, + client_name TEXT NOT NULL DEFAULT '', + client_address TEXT NOT NULL DEFAULT '', + client_gstin TEXT NOT NULL DEFAULT '', + po_number TEXT NOT NULL DEFAULT '', + place_of_supply_state_code TEXT NOT NULL DEFAULT '', + subtotal REAL NOT NULL DEFAULT 0, + discount REAL NOT NULL DEFAULT 0, + tax_type TEXT NOT NULL DEFAULT 'none', + tax_rate REAL NOT NULL DEFAULT 0, + cgst_amount REAL NOT NULL DEFAULT 0, + sgst_amount REAL NOT NULL DEFAULT 0, + igst_amount REAL NOT NULL DEFAULT 0, + total REAL NOT NULL DEFAULT 0, + amount_in_words TEXT NOT NULL DEFAULT '', + bank_account_id INTEGER, + bank_snapshot TEXT NOT NULL DEFAULT '', + signature_path TEXT, + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'issued', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT 'fixed', + rate REAL NOT NULL DEFAULT 0, + unit TEXT NOT NULL DEFAULT 'unit', + quantity REAL NOT NULL DEFAULT 1, + amount REAL NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); +CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); +"#; + + fn user_version(conn: &Connection) -> i64 { + conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap() + } + + fn backups_in(dir: &Path) -> Vec { + match fs::read_dir(dir) { + Ok(rd) => rd.map(|e| e.unwrap().path()).collect(), + Err(_) => Vec::new(), + } + } + + #[test] + fn migrations_are_valid_and_latest_matches() { + migrations().validate().unwrap(); + let mut conn = Connection::open_in_memory().unwrap(); + migrations().to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), LATEST_VERSION); + } + + #[test] + fn fresh_database_is_seeded_without_backup() { + let dir = tempdir().unwrap(); + let backups = dir.path().join("backups"); + let conn = open(&dir.path().join("voiced.db"), &backups).unwrap(); + + assert_eq!(user_version(&conn), LATEST_VERSION); + let (tax_type, gst_reg, name): (String, String, String) = conn + .query_row( + "SELECT default_tax_type, gst_registration, vendor_name FROM app_settings WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(tax_type, "none"); + assert_eq!(gst_reg, "unregistered"); + assert_eq!(name, "Test Vendor"); + assert!(backups_in(&backups).is_empty()); + } + + fn build_v1_database(path: &Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch(V1_SCHEMA).unwrap(); + conn.execute( + "INSERT INTO app_settings (id, vendor_name, vendor_gstin, default_tax_type, onboarded) + VALUES (1, 'Acme Studio', '29ABCDE1234F1Z5', 'cgst_sgst', 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO bank_accounts (label, bank_name, account_no, is_default) + VALUES ('Primary', 'Test Bank', '12345', 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at) + VALUES ('AC/2026-', 3, 2, 1, '2026-01-01T00:00:00Z')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO invoices (number, invoice_date, tax_type, tax_rate, total, created_at, updated_at) + VALUES ('AC/2026-001', '2026-01-02', 'cgst_sgst', 18, 118, 'now', 'now')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, rate, quantity, amount) + VALUES (1, 'Design work', 100, 1, 100)", + [], + ) + .unwrap(); + assert_eq!(user_version(&conn), 0); + } + + #[test] + fn v1_database_upgrades_with_backup_and_backfill() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("voiced.db"); + let backups = dir.path().join("backups"); + build_v1_database(&db_path); + + let conn = open(&db_path, &backups).unwrap(); + assert_eq!(user_version(&conn), LATEST_VERSION); + + // Backup exists and is a valid, un-migrated copy. + let files = backups_in(&backups); + assert_eq!(files.len(), 1); + let name = files[0].file_name().unwrap().to_str().unwrap(); + assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}"); + let backup = Connection::open(&files[0]).unwrap(); + assert_eq!(user_version(&backup), 0); + let n: i64 = backup + .query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + + // Settings backfill. + let (gst_reg, tax_type, vendor): (String, String, String) = conn + .query_row( + "SELECT gst_registration, default_tax_type, vendor_name FROM app_settings WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(gst_reg, "regular"); + assert_eq!(tax_type, "cgst_sgst"); + assert_eq!(vendor, "Acme Studio"); + + // Invoice backfill. + let (origin, snapshot, doc_type, number, total): (String, Option, String, String, f64) = conn + .query_row( + "SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + assert_eq!(origin, "backfilled"); + assert_eq!(doc_type, "tax_invoice"); + assert_eq!(number, "AC/2026-001"); + assert_eq!(total, 118.0); + let json: serde_json::Value = serde_json::from_str(&snapshot.expect("snapshot")).unwrap(); + assert_eq!(json["vendorName"], "Acme Studio"); + assert_eq!(json["vendorGstin"], "29ABCDE1234F1Z5"); + assert_eq!(json["gstRegistration"], "regular"); + + // Old data intact, new columns defaulted. + let (desc, hsn): (String, String) = conn + .query_row("SELECT description, hsn_sac FROM invoice_items", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!(desc, "Design work"); + assert_eq!(hsn, ""); + let (bank, next): (String, i64) = conn + .query_row( + "SELECT (SELECT bank_name FROM bank_accounts), (SELECT next_number FROM invoice_series)", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(bank, "Test Bank"); + assert_eq!(next, 2); + } + + #[test] + fn reopening_up_to_date_database_makes_no_backup() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("voiced.db"); + let backups = dir.path().join("backups"); + build_v1_database(&db_path); + + drop(open(&db_path, &backups).unwrap()); + assert_eq!(backups_in(&backups).len(), 1); + drop(open(&db_path, &backups).unwrap()); + assert_eq!(backups_in(&backups).len(), 1); + } + + #[test] + fn pruning_keeps_the_newest_ten() { + let dir = tempdir().unwrap(); + for i in 0..14 { + // Mixed version prefixes: ordering must follow the timestamp only. + let version = if i % 2 == 0 { 2 } else { 10 }; + let name = format!("{BACKUP_PREFIX}{version}-202601{:02}-120000.db", i + 1); + fs::write(dir.path().join(name), b"x").unwrap(); + } + fs::write(dir.path().join("unrelated.db"), b"x").unwrap(); + + prune_backups(dir.path(), MAX_BACKUPS).unwrap(); + + let mut names: Vec = backups_in(dir.path()) + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(BACKUP_PREFIX)) + .collect(); + names.sort(); + assert_eq!(names.len(), MAX_BACKUPS); + assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104"))); + assert!(dir.path().join("unrelated.db").exists()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 424834b..9330d32 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,32 +3,84 @@ mod db; mod models; use rusqlite::Connection; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::Manager; +use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; pub struct AppState { pub db: Mutex, pub data_dir: PathBuf, } +/// Show a native error dialog. Release builds have no console on Windows, so +/// without this a startup failure would look like the app silently not opening. +fn show_startup_error(app: &tauri::App, error: &dyn std::fmt::Display, data_dir: Option<&Path>) { + let location = data_dir + .map(|d| format!("\n\nData directory:\n{}", d.display())) + .unwrap_or_default(); + eprintln!("Voiced could not start: {error}"); + app.dialog() + .message(format!("{error}{location}")) + .title("Voiced could not start") + .kind(MessageDialogKind::Error) + .blocking_show(); +} + +fn init_state(app: &tauri::App) -> Result> { + let data_dir = match app.path().app_data_dir() { + Ok(dir) => dir, + Err(e) => { + show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None); + return Err(e.into()); + } + }; + if let Err(e) = std::fs::create_dir_all(&data_dir) { + show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir)); + return Err(e.into()); + } + let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) { + Ok(conn) => conn, + Err(e) => { + show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir)); + return Err(e.into()); + } + }; + Ok(AppState { + db: Mutex::new(conn), + data_dir, + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + // 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")] + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + + let builder = tauri::Builder::default(); + + // The single-instance plugin must be registered before every other plugin. + #[cfg(desktop)] + let builder = builder + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_window_state::Builder::default().build()); + + let result = builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .setup(|app| { - let data_dir = app - .path() - .app_data_dir() - .expect("could not resolve the application data directory"); - std::fs::create_dir_all(&data_dir).expect("could not create the application data directory"); - - let conn = db::open(&data_dir.join("voiced.db")).expect("could not open the database"); - app.manage(AppState { - db: Mutex::new(conn), - data_dir, - }); + let state = init_state(app)?; + app.manage(state); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -54,6 +106,10 @@ pub fn run() { commands::assets::remove_asset, commands::assets::save_binary_file, ]) - .run(tauri::generate_context!()) - .expect("error while running the Voiced application"); + .run(tauri::generate_context!()); + + if let Err(e) = result { + eprintln!("error while running the Voiced application: {e}"); + std::process::exit(1); + } } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0b5e23f..4b7f40b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -19,6 +19,10 @@ pub struct Settings { pub currency: String, pub onboarded: bool, pub theme: String, + pub gst_registration: String, + pub default_hsn_sac: String, + pub signatory_name: String, + pub signatory_designation: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 653fbbe..315ee23 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,8 +15,8 @@ "title": "Voiced", "width": 1360, "height": 900, - "minWidth": 1024, - "minHeight": 720, + "minWidth": 960, + "minHeight": 600, "resizable": true, "center": true } @@ -41,7 +41,7 @@ ], "windows": { "webviewInstallMode": { - "type": "downloadBootstrapper" + "type": "embedBootstrapper" }, "nsis": { "installMode": "currentUser" diff --git a/src/lib/types.ts b/src/lib/types.ts index f5d2100..80eb220 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,10 @@ export interface Settings { currency: string; onboarded: boolean; theme: string; + gstRegistration: string; + defaultHsnSac: string; + signatoryName: string; + signatoryDesignation: string; } export interface BankAccount { diff --git a/src/pdf/InvoiceDocument.test.tsx b/src/pdf/InvoiceDocument.test.tsx index 97ec241..8f908ad 100644 --- a/src/pdf/InvoiceDocument.test.tsx +++ b/src/pdf/InvoiceDocument.test.tsx @@ -33,6 +33,10 @@ const vendor: Settings = { currency: "INR", onboarded: true, theme: "g10", + gstRegistration: "unregistered", + defaultHsnSac: "", + signatoryName: "", + signatoryDesignation: "", }; const bank: BankAccount = {