- New integrations module with an InvoiceSink seam and an ERPNext client built on reqwest with rustls (ring provider only; aws-lc is not in the dependency graph). Token auth, timeouts, retry with backoff on 429, 5xx and timeouts (POSTs only when idempotent), optional extra CA, and a rule that plain http is allowed only for localhost-style hosts. - Error extraction for Frappe v1 and v2 bodies, version and India Compliance discovery, live option loaders, and a connection test that returns warnings instead of failing. - Pure, snapshot-tested mapping from a stored invoice to a Sales Invoice (CGST+SGST, IGST, unregistered, discount, code-less rows, mirrored and series naming) that refuses to produce a payload whose totals differ from Voiced's. - Migration M10 adds erpnext_config (the API secret is never returned to the webview), erpnext_sync and the nullable ERPNext link columns. - Commands to get and save the config, test the connection and load options, with typed wrappers. No UI and no push yet. The API secret is stored in the SQLite file, so backups contain it. Nothing has run against a live ERPNext yet.
194 lines
7.8 KiB
Rust
194 lines
7.8 KiB
Rust
mod commands;
|
|
mod db;
|
|
mod gst;
|
|
mod integrations;
|
|
mod logo;
|
|
mod models;
|
|
mod selftest;
|
|
|
|
use rusqlite::Connection;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
use tauri::Manager;
|
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
|
|
|
pub struct AppState {
|
|
pub db: Mutex<Connection>,
|
|
pub data_dir: PathBuf,
|
|
/// Machine-local, non-roaming data (the PDF archive). On Windows this is
|
|
/// %LOCALAPPDATA%, unlike `data_dir`, which is the roaming location.
|
|
pub local_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 local_data_dir = match app.path().app_local_data_dir() {
|
|
Ok(dir) => dir,
|
|
Err(e) => {
|
|
show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir));
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
if let Err(e) = std::fs::create_dir_all(&local_data_dir) {
|
|
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) => {
|
|
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir));
|
|
return Err(e.into());
|
|
}
|
|
};
|
|
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
|
|
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
|
|
return Err(e.into());
|
|
}
|
|
// Best effort: a logo that cannot be derived keeps working untrimmed, and the next start retries.
|
|
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,
|
|
local_data_dir,
|
|
})
|
|
}
|
|
|
|
#[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")]
|
|
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 state = init_state(app)?;
|
|
app.manage(state);
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
commands::settings::get_settings,
|
|
commands::settings::save_settings,
|
|
commands::settings::list_banks,
|
|
commands::settings::save_bank,
|
|
commands::settings::delete_bank,
|
|
commands::series::get_active_series,
|
|
commands::series::list_series,
|
|
commands::series::start_new_series,
|
|
commands::clients::list_clients,
|
|
commands::clients::save_client,
|
|
commands::clients::delete_client,
|
|
commands::presets::list_item_presets,
|
|
commands::presets::save_item_preset,
|
|
commands::presets::delete_item_preset,
|
|
commands::invoice::peek_next_invoice_number,
|
|
commands::invoice::issue_invoice,
|
|
commands::invoice::cancel_invoice,
|
|
commands::invoice::list_invoices,
|
|
commands::invoice::get_invoice,
|
|
commands::invoice::save_draft,
|
|
commands::invoice::list_drafts,
|
|
commands::invoice::get_draft,
|
|
commands::invoice::delete_draft,
|
|
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,
|
|
commands::assets::remove_asset,
|
|
commands::logo::import_logo,
|
|
commands::logo::save_logo_bytes,
|
|
commands::logo::ensure_logo_derived,
|
|
commands::logo::remove_logo,
|
|
commands::archive::archive_pdf,
|
|
commands::archive::read_archive,
|
|
commands::archive::archive_status,
|
|
commands::fonts::inspect_font,
|
|
commands::fonts::import_font,
|
|
commands::fonts::list_user_fonts,
|
|
commands::fonts::get_user_font_bytes,
|
|
commands::fonts::remove_user_font,
|
|
commands::files::write_export_file,
|
|
commands::files::get_last_export_dir,
|
|
commands::files::reveal_in_folder,
|
|
commands::files::open_file,
|
|
commands::erpnext::erpnext_get_config,
|
|
commands::erpnext::erpnext_save_config,
|
|
commands::erpnext::erpnext_test_connection,
|
|
commands::erpnext::erpnext_load_options,
|
|
selftest::selftest_config,
|
|
selftest::selftest_report,
|
|
])
|
|
.run(tauri::generate_context!());
|
|
|
|
if let Err(e) = result {
|
|
eprintln!("error while running the Voiced application: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|