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.
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"app:dev": "tauri dev",
|
"app:dev": "tauri dev",
|
||||||
"app:build": "tauri build --features custom-protocol",
|
"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": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ mkdir -p "$REPO"
|
|||||||
-l "proprietary" \
|
-l "proprietary" \
|
||||||
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
-H "https://git.bongbetic.com/xavierk/Voiced" \
|
||||||
-m "Xavier Karma <xavier@bongbetic.com>" \
|
-m "Xavier Karma <xavier@bongbetic.com>" \
|
||||||
-D "libwebkit2gtk41>=2.40_1" \
|
-D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \
|
||||||
-t "office finance" \
|
-t "office finance" \
|
||||||
--compression zstd \
|
--compression zstd \
|
||||||
"$STAGE"
|
"$STAGE"
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ version=0.1.0
|
|||||||
revision=1
|
revision=1
|
||||||
archs="x86_64*"
|
archs="x86_64*"
|
||||||
hostmakedepends="rust nodejs pkg-config"
|
hostmakedepends="rust nodejs pkg-config"
|
||||||
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel openssl-devel"
|
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
|
||||||
depends="libwebkit2gtk41"
|
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
|
||||||
short_desc="Offline invoicing app by Bongbetic"
|
short_desc="Offline invoicing app by Bongbetic"
|
||||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||||
license="custom:proprietary"
|
license="custom:proprietary"
|
||||||
|
|||||||
Generated
+46
-1
@@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
@@ -2887,6 +2887,16 @@ dependencies = [
|
|||||||
"sqlite-wasm-rs",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3658,6 +3668,37 @@ dependencies = [
|
|||||||
"zbus",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -4196,12 +4237,16 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"rusqlite_migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-window-state",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
+10
-1
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
authors = ["Bongbetic"]
|
authors = ["Bongbetic"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77"
|
rust-version = "1.95"
|
||||||
|
|
||||||
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
|
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
|
||||||
[lib]
|
[lib]
|
||||||
@@ -21,11 +21,20 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
rusqlite_migration = "2.6"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
base64 = "0.22"
|
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]
|
[features]
|
||||||
# Production builds embed the frontend and serve it over the custom asset
|
# Production builds embed the frontend and serve it over the custom asset
|
||||||
# protocol. Without this, the app expects a dev server on localhost:1420.
|
# protocol. Without this, the app expects a dev server on localhost:1420.
|
||||||
|
|||||||
@@ -6,6 +6,25 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"dialog: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/**" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use tauri::State;
|
|||||||
|
|
||||||
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
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,
|
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<Settings> {
|
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
@@ -25,6 +26,10 @@ fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|||||||
currency: row.get(13)?,
|
currency: row.get(13)?,
|
||||||
onboarded: row.get::<_, i64>(14)? != 0,
|
onboarded: row.get::<_, i64>(14)? != 0,
|
||||||
theme: row.get(15)?,
|
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<AppState>, settings: Settings) -> Result<Setti
|
|||||||
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
||||||
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||||
onboarded = ?15, theme = ?16
|
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
||||||
|
signatory_name = ?19, signatory_designation = ?20
|
||||||
WHERE id = 1"#,
|
WHERE id = 1"#,
|
||||||
params![
|
params![
|
||||||
settings.vendor_name,
|
settings.vendor_name,
|
||||||
@@ -67,6 +73,10 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
settings.currency,
|
settings.currency,
|
||||||
if settings.onboarded { 1 } else { 0 },
|
if settings.onboarded { 1 } else { 0 },
|
||||||
settings.theme,
|
settings.theme,
|
||||||
|
settings.gst_registration,
|
||||||
|
settings.default_hsn_sac,
|
||||||
|
settings.signatory_name,
|
||||||
|
settings.signatory_designation,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
+428
-7
@@ -1,7 +1,29 @@
|
|||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use std::path::Path;
|
use rusqlite_migration::{Migrations, M};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// The full schema. Every statement is idempotent so it doubles as the migration step.
|
/// Number of pre-migration backups kept in the backup directory.
|
||||||
|
const MAX_BACKUPS: usize = 10;
|
||||||
|
|
||||||
|
/// Prefix shared by every pre-migration backup file name.
|
||||||
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
|
const LATEST_VERSION: i64 = 2;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Sqlite(#[from] rusqlite::Error),
|
||||||
|
#[error("database migration failed: {0}")]
|
||||||
|
Migration(#[from] rusqlite_migration::Error),
|
||||||
|
#[error("file system error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version 1: the original schema. Every statement is idempotent, so databases
|
||||||
|
/// created before migrations existed (user_version 0, all tables present) adopt it safely.
|
||||||
const SCHEMA: &str = r#"
|
const SCHEMA: &str = r#"
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
@@ -99,17 +121,129 @@ 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);
|
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
|
/// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts.
|
||||||
let conn = Connection::open(path)?;
|
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<Connection, DbError> {
|
||||||
|
let mut conn = Connection::open(path)?;
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
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)?;
|
seed(&conn)?;
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||||
|
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 `<backup_dir>/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<PathBuf, DbError> {
|
||||||
|
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.
|
/// 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<()> {
|
fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
let settings_count: i64 =
|
let settings_count: i64 =
|
||||||
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
|
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,
|
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
|
||||||
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme)
|
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![
|
params![
|
||||||
"Arun P",
|
"Arun P",
|
||||||
"13-B type House, Robertson Road, Frazer Town, Bangalore - 560005",
|
"13-B type House, Robertson Road, Frazer Town, Bangalore - 560005",
|
||||||
@@ -173,3 +307,290 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
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<PathBuf> {
|
||||||
|
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, "Arun P");
|
||||||
|
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, 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<String> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+71
-15
@@ -3,32 +3,84 @@ mod db;
|
|||||||
mod models;
|
mod models;
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Mutex<Connection>,
|
pub db: Mutex<Connection>,
|
||||||
pub data_dir: PathBuf,
|
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<AppState, Box<dyn std::error::Error>> {
|
||||||
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let data_dir = app
|
let state = init_state(app)?;
|
||||||
.path()
|
app.manage(state);
|
||||||
.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,
|
|
||||||
});
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -54,6 +106,10 @@ pub fn run() {
|
|||||||
commands::assets::remove_asset,
|
commands::assets::remove_asset,
|
||||||
commands::assets::save_binary_file,
|
commands::assets::save_binary_file,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!());
|
||||||
.expect("error while running the Voiced application");
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("error while running the Voiced application: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ pub struct Settings {
|
|||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub onboarded: bool,
|
pub onboarded: bool,
|
||||||
pub theme: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"title": "Voiced",
|
"title": "Voiced",
|
||||||
"width": 1360,
|
"width": 1360,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
"minWidth": 1024,
|
"minWidth": 960,
|
||||||
"minHeight": 720,
|
"minHeight": 600,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"center": true
|
"center": true
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
],
|
],
|
||||||
"windows": {
|
"windows": {
|
||||||
"webviewInstallMode": {
|
"webviewInstallMode": {
|
||||||
"type": "downloadBootstrapper"
|
"type": "embedBootstrapper"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installMode": "currentUser"
|
"installMode": "currentUser"
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface Settings {
|
|||||||
currency: string;
|
currency: string;
|
||||||
onboarded: boolean;
|
onboarded: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
gstRegistration: string;
|
||||||
|
defaultHsnSac: string;
|
||||||
|
signatoryName: string;
|
||||||
|
signatoryDesignation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankAccount {
|
export interface BankAccount {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const vendor: Settings = {
|
|||||||
currency: "INR",
|
currency: "INR",
|
||||||
onboarded: true,
|
onboarded: true,
|
||||||
theme: "g10",
|
theme: "g10",
|
||||||
|
gstRegistration: "unregistered",
|
||||||
|
defaultHsnSac: "",
|
||||||
|
signatoryName: "",
|
||||||
|
signatoryDesignation: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bank: BankAccount = {
|
const bank: BankAccount = {
|
||||||
|
|||||||
Reference in New Issue
Block a user