From 79b579790dba466e155261195b316a3a32ff4d32 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 20 Jun 2026 19:08:48 +0330 Subject: [PATCH] fix(persistence): preserve user data across updates --- package-lock.json | 10 - package.json | 1 - src-tauri/Cargo.lock | 99 ++- src-tauri/Cargo.toml | 2 +- src-tauri/src/db.rs | 979 ++++++++++++++++++++++++++ src-tauri/src/download_ownership.rs | 74 +- src-tauri/src/lib.rs | 94 ++- src-tauri/src/settings.rs | 43 +- src/App.tsx | 58 +- src/bindings/PairingTokenHydration.ts | 3 + src/ipc.ts | 12 +- src/store/settingsStore.ts | 75 -- src/store/useDownloadStore.test.ts | 10 - src/store/useDownloadStore.ts | 27 +- src/store/useSettingsStore.ts | 59 +- src/utils/downloads.ts | 2 +- 16 files changed, 1288 insertions(+), 260 deletions(-) create mode 100644 src-tauri/src/db.rs create mode 100644 src/bindings/PairingTokenHydration.ts delete mode 100644 src/store/settingsStore.ts diff --git a/package-lock.json b/package-lock.json index 07d1c86..116d177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-store": "^2.4.3", "lucide-react": "^1.17.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -960,15 +959,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-store": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz", - "integrity": "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", diff --git a/package.json b/package.json index 2eb2807..f0a1fa9 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-store": "^2.4.3", "lucide-react": "^1.17.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 93fb1d4..22b07bf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1305,6 +1305,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1361,6 +1373,7 @@ dependencies = [ "objc", "regex 1.12.4", "reqwest 0.12.28", + "rusqlite", "semver", "serde", "serde_json", @@ -1377,7 +1390,6 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", - "tauri-plugin-store", "tempfile", "thiserror 2.0.18", "tokio", @@ -1909,11 +1921,32 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -2534,6 +2567,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3842,6 +3886,31 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -4414,6 +4483,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4936,22 +5017,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "tauri-plugin-store" -version = "2.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b" -dependencies = [ - "dunce", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "tauri-runtime" version = "2.11.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e082f9..bad2265 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,7 +53,7 @@ tokio-tungstenite = "0.29.0" futures-util = { version = "0.3.32", features = ["sink"] } chrono = "0.4.38" url = "2" -tauri-plugin-store = "2.4.3" +rusqlite = { version = "0.40.0", features = ["bundled"] } log = "0.4.32" tauri-plugin-log = "2" trash = "5" diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..140fbac --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,979 @@ +use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::Manager; + +const DATABASE_NAME: &str = "firelink.sqlite"; +const LEGACY_STORE_NAME: &str = "store.bin"; +const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; +const CURRENT_SCHEMA_VERSION: i64 = 1; +const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; +pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; +const KEYCHAIN_SERVICE: &str = "com.firelink.app"; + +pub struct DbState { + conn: Mutex, +} + +impl DbState { + pub fn lock(&self) -> Result, String> { + self.conn + .lock() + .map_err(|_| "persistence database lock is unavailable".to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PairingTokenSource { + Keychain, + LegacySettings, + Generated, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PairingTokenDecision { + token: String, + source: PairingTokenSource, + changed: bool, +} + +#[derive(Default)] +struct LegacyData { + settings: Option, + downloads: Vec, + queues: Vec, + ownership: Vec<(String, String)>, + pairing_token: Option, +} + +pub fn init(app_handle: &tauri::AppHandle) -> Result { + let app_data_dir = app_handle + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data directory: {error}"))?; + init_at_path_internal(&app_data_dir, true) +} + +#[cfg(test)] +fn init_at_path(app_data_dir: &Path) -> Result { + init_at_path_internal(app_data_dir, false) +} + +fn init_at_path_internal(app_data_dir: &Path, migrate_keychain: bool) -> Result { + fs::create_dir_all(app_data_dir) + .map_err(|error| format!("failed to create app data directory: {error}"))?; + let database_path = app_data_dir.join(DATABASE_NAME); + let existed = database_path.exists(); + let mut connection = Connection::open(&database_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + let version = connection + .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .map_err(|error| format!("failed to read database schema version: {error}"))?; + if existed && version < CURRENT_SCHEMA_VERSION { + backup_database(&connection, &database_path, &format!("schema-v{version}"))?; + } + migrate_schema(&mut connection, version)?; + let current_token_pending = + sanitize_current_settings_and_restore_token(&connection, migrate_keychain)?; + import_legacy_data( + &mut connection, + app_data_dir, + migrate_keychain && !current_token_pending, + )?; + + Ok(DbState { + conn: Mutex::new(connection), + }) +} + +fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), String> { + if from_version > CURRENT_SCHEMA_VERSION { + return Err(format!( + "database schema version {from_version} is newer than supported version {CURRENT_SCHEMA_VERSION}" + )); + } + + let transaction = connection + .transaction() + .map_err(|error| format!("failed to begin database migration: {error}"))?; + + if from_version < 1 { + transaction + .execute_batch( + " + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS queues ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS download_ownership ( + id TEXT PRIMARY KEY, + primary_path TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS migration_events ( + key TEXT PRIMARY KEY, + consumed INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + ", + ) + .map_err(|error| format!("failed to create persistence tables: {error}"))?; + + if table_exists(&transaction, "downloads")? { + let queue_id_not_null = column_is_not_null(&transaction, "downloads", "queue_id")?; + if queue_id_not_null { + transaction + .execute_batch( + " + ALTER TABLE downloads RENAME TO downloads_v0; + CREATE TABLE downloads ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + queue_id TEXT, + data TEXT NOT NULL + ); + INSERT INTO downloads (id, status, queue_id, data) + SELECT id, status, queue_id, data FROM downloads_v0; + DROP TABLE downloads_v0; + ", + ) + .map_err(|error| format!("failed to migrate downloads table: {error}"))?; + } + } else { + transaction + .execute_batch( + " + CREATE TABLE downloads ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + queue_id TEXT, + data TEXT NOT NULL + ); + ", + ) + .map_err(|error| format!("failed to create downloads table: {error}"))?; + } + } + + transaction + .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) + .map_err(|error| format!("failed to update database schema version: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit database migration: {error}")) +} + +fn import_legacy_data( + connection: &mut Connection, + app_data_dir: &Path, + migrate_keychain: bool, +) -> Result<(), String> { + let legacy_app_dir = app_data_dir + .parent() + .map(|parent| parent.join(LEGACY_BUNDLE_IDENTIFIER)); + let candidates = [ + Some(app_data_dir.join(LEGACY_STORE_NAME)), + legacy_app_dir.as_ref().map(|dir| dir.join(DATABASE_NAME)), + legacy_app_dir + .as_ref() + .map(|dir| dir.join(LEGACY_STORE_NAME)), + ]; + + for candidate in candidates.into_iter().flatten() { + if !candidate.exists() { + continue; + } + let marker = format!("legacy-import:{}", candidate.to_string_lossy()); + if metadata_exists(connection, &marker)? { + continue; + } + backup_file(&candidate, "legacy-import")?; + let mut legacy = if candidate + .file_name() + .is_some_and(|name| name == DATABASE_NAME) + { + read_legacy_database(&candidate)? + } else { + read_legacy_store(&candidate)? + }; + let mut migration_complete = true; + if migrate_keychain + && get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() + && legacy.pairing_token.is_some() + { + if let Some(token) = legacy.pairing_token.as_deref() { + if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, token) { + log::warn!( + "Legacy pairing token could not be migrated yet; settings import will retry: {}", + error + ); + legacy.settings = None; + migration_complete = false; + } + } + } + merge_legacy_data(connection, legacy)?; + if migration_complete { + connection + .execute( + "INSERT INTO metadata (key, value) VALUES (?1, 'complete') + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![marker], + ) + .map_err(|error| format!("failed to record legacy import: {error}"))?; + } + } + Ok(()) +} + +fn merge_legacy_data(connection: &mut Connection, legacy: LegacyData) -> Result<(), String> { + let transaction = connection + .transaction() + .map_err(|error| format!("failed to begin legacy import: {error}"))?; + + let has_settings = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM settings WHERE id = 1)", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| format!("failed to inspect persisted settings: {error}"))?; + if !has_settings { + if let Some(settings) = legacy.settings { + save_settings_tx(&transaction, &settings)?; + } + } + + let download_count: i64 = transaction + .query_row("SELECT COUNT(*) FROM downloads", [], |row| row.get(0)) + .map_err(|error| format!("failed to inspect persisted downloads: {error}"))?; + if download_count == 0 { + replace_downloads_tx(&transaction, &legacy.downloads)?; + } + + let queue_count: i64 = transaction + .query_row("SELECT COUNT(*) FROM queues", [], |row| row.get(0)) + .map_err(|error| format!("failed to inspect persisted queues: {error}"))?; + if queue_count == 0 { + replace_queues_tx(&transaction, &legacy.queues)?; + } + + for (id, primary_path) in legacy.ownership { + transaction + .execute( + "INSERT OR IGNORE INTO download_ownership (id, primary_path) VALUES (?1, ?2)", + params![id, primary_path], + ) + .map_err(|error| format!("failed to import download ownership: {error}"))?; + } + + transaction + .commit() + .map_err(|error| format!("failed to commit legacy import: {error}"))?; + + Ok(()) +} + +fn read_legacy_store(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read legacy store '{}': {error}", path.display()))?; + let document: Value = serde_json::from_str(&text).map_err(|error| { + format!( + "failed to decode legacy store '{}': {error}", + path.display() + ) + })?; + + let mut data = LegacyData::default(); + if let Some(settings) = document.get("settings") { + let (sanitized, token) = sanitize_settings_value(settings)?; + data.settings = Some(sanitized); + data.pairing_token = token; + } + data.downloads = json_array_as_strings(document.get("download_queue"))?; + data.queues = json_array_as_strings(document.get("queues"))?; + data.ownership = document + .get("download_ownership") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|record| { + Some(( + record.get("id")?.as_str()?.to_string(), + record.get("primaryPath")?.as_str()?.to_string(), + )) + }) + .collect(); + Ok(data) +} + +fn read_legacy_database(path: &Path) -> Result { + let connection = Connection::open(path).map_err(|error| { + format!( + "failed to open legacy database '{}': {error}", + path.display() + ) + })?; + let mut data = LegacyData::default(); + + if table_exists(&connection, "settings")? { + if let Some(settings) = connection + .query_row("SELECT data FROM settings WHERE id = 1", [], |row| { + row.get::<_, String>(0) + }) + .optional() + .map_err(|error| format!("failed to read legacy settings: {error}"))? + { + let (sanitized, token) = sanitize_settings_text(&settings)?; + data.settings = Some(sanitized); + data.pairing_token = token; + } + } + if table_exists(&connection, "downloads")? { + data.downloads = query_string_column(&connection, "SELECT data FROM downloads")?; + } + if table_exists(&connection, "queues")? { + data.queues = query_string_column(&connection, "SELECT data FROM queues")?; + } + Ok(data) +} + +fn sanitize_current_settings_and_restore_token( + connection: &Connection, + migrate_keychain: bool, +) -> Result { + let Some(settings) = load_settings(connection)? else { + return Ok(false); + }; + let (sanitized, legacy_token) = sanitize_settings_text(&settings)?; + if sanitized == settings { + return Ok(false); + } + if migrate_keychain { + if get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() { + if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) { + if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token) { + log::warn!( + "Persisted pairing token could not be migrated yet; original settings retained: {}", + error + ); + return Ok(true); + } + } + } + } + save_settings(connection, &sanitized)?; + Ok(false) +} + +fn sanitize_settings_value(value: &Value) -> Result<(String, Option), String> { + match value { + Value::String(text) => sanitize_settings_text(text), + _ => sanitize_settings_document(value.clone()), + } +} + +fn sanitize_settings_text(text: &str) -> Result<(String, Option), String> { + let document: Value = serde_json::from_str(text) + .map_err(|error| format!("failed to decode persisted settings: {error}"))?; + sanitize_settings_document(document) +} + +fn sanitize_settings_document(mut document: Value) -> Result<(String, Option), String> { + let state_value = if document.get("state").is_some() { + document + .get_mut("state") + .ok_or_else(|| "persisted settings state is missing".to_string())? + } else { + &mut document + }; + let state = state_value + .as_object_mut() + .ok_or_else(|| "persisted settings state must be an object".to_string())?; + let token = state + .remove("extensionPairingToken") + .and_then(|value| value.as_str().map(str::to_string)); + let serialized = serde_json::to_string(&document) + .map_err(|error| format!("failed to encode persisted settings: {error}"))?; + Ok((serialized, token)) +} + +fn json_array_as_strings(value: Option<&Value>) -> Result, String> { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| { + serde_json::to_string(item) + .map_err(|error| format!("failed to encode legacy item: {error}")) + }) + .collect() + }) + .unwrap_or_else(|| Ok(Vec::new())) +} + +fn query_string_column(connection: &Connection, query: &str) -> Result, String> { + let mut statement = connection + .prepare(query) + .map_err(|error| format!("failed to prepare legacy query: {error}"))?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| format!("failed to query legacy data: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("failed to read legacy data: {error}")) +} + +fn backup_file(path: &Path, reason: &str) -> Result { + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("invalid persistence file path '{}'", path.display()))?; + let backup_prefix = format!("{file_name}.backup-{reason}-"); + if let Some(existing) = path.parent().and_then(|parent| { + fs::read_dir(parent).ok()?.flatten().find_map(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(&backup_prefix) + .then_some(entry.path()) + }) + }) { + return Ok(existing); + } + let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); + if backup_path.exists() { + return Ok(backup_path); + } + fs::copy(path, &backup_path).map_err(|error| { + format!( + "failed to back up persistence file '{}' to '{}': {error}", + path.display(), + backup_path.display() + ) + })?; + Ok(backup_path) +} + +fn backup_database(connection: &Connection, path: &Path, reason: &str) -> Result { + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("invalid database path '{}'", path.display()))?; + let backup_path = path.with_file_name(format!("{file_name}.backup-{reason}-{timestamp}")); + connection + .execute("VACUUM INTO ?1", params![backup_path.to_string_lossy()]) + .map_err(|error| { + format!( + "failed to back up database '{}' to '{}': {error}", + path.display(), + backup_path.display() + ) + })?; + Ok(backup_path) +} + +fn table_exists(connection: &Connection, table: &str) -> Result { + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + params![table], + |row| row.get(0), + ) + .map_err(|error| format!("failed to inspect database table '{table}': {error}")) +} + +fn metadata_exists(connection: &Connection, key: &str) -> Result { + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM metadata WHERE key = ?1)", + params![key], + |row| row.get(0), + ) + .map_err(|error| format!("failed to inspect migration metadata: {error}")) +} + +fn column_is_not_null(connection: &Connection, table: &str, column: &str) -> Result { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|error| format!("failed to inspect table '{table}': {error}"))?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, bool>(3)?)) + }) + .map_err(|error| format!("failed to inspect table '{table}': {error}"))?; + for row in rows { + let (name, not_null) = + row.map_err(|error| format!("failed to inspect table '{table}': {error}"))?; + if name == column { + return Ok(not_null); + } + } + Ok(false) +} + +pub fn load_settings(connection: &Connection) -> Result, String> { + connection + .query_row("SELECT data FROM settings WHERE id = 1", [], |row| { + row.get(0) + }) + .optional() + .map_err(|error| format!("failed to load settings: {error}")) +} + +pub fn save_settings(connection: &Connection, data: &str) -> Result<(), String> { + connection + .execute( + "INSERT INTO settings (id, data) VALUES (1, ?1) + ON CONFLICT(id) DO UPDATE SET data = excluded.data", + params![data], + ) + .map_err(|error| format!("failed to save settings: {error}"))?; + Ok(()) +} + +fn save_settings_tx(transaction: &Transaction<'_>, data: &str) -> Result<(), String> { + transaction + .execute( + "INSERT INTO settings (id, data) VALUES (1, ?1) + ON CONFLICT(id) DO UPDATE SET data = excluded.data", + params![data], + ) + .map_err(|error| format!("failed to import settings: {error}"))?; + Ok(()) +} + +pub fn load_downloads(connection: &Connection) -> Result, String> { + query_string_column(connection, "SELECT data FROM downloads ORDER BY rowid") +} + +pub fn replace_downloads(connection: &mut Connection, data: &str) -> Result<(), String> { + let values: Vec = serde_json::from_str(data) + .map_err(|error| format!("failed to decode downloads: {error}"))?; + let strings = values + .iter() + .map(|value| { + serde_json::to_string(value) + .map_err(|error| format!("failed to encode download: {error}")) + }) + .collect::, _>>()?; + let transaction = connection + .transaction() + .map_err(|error| format!("failed to begin download save: {error}"))?; + replace_downloads_tx(&transaction, &strings)?; + transaction + .commit() + .map_err(|error| format!("failed to commit download save: {error}")) +} + +fn replace_downloads_tx(transaction: &Transaction<'_>, downloads: &[String]) -> Result<(), String> { + transaction + .execute("DELETE FROM downloads", []) + .map_err(|error| format!("failed to clear downloads: {error}"))?; + for data in downloads { + let value: Value = serde_json::from_str(data) + .map_err(|error| format!("failed to decode download: {error}"))?; + let id = required_string(&value, "id")?; + let status = required_string(&value, "status")?; + let queue_id = value.get("queueId").and_then(Value::as_str); + transaction + .execute( + "INSERT INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)", + params![id, status, queue_id, data], + ) + .map_err(|error| format!("failed to save download '{id}': {error}"))?; + } + Ok(()) +} + +pub fn load_queues(connection: &Connection) -> Result, String> { + query_string_column(connection, "SELECT data FROM queues ORDER BY rowid") +} + +pub fn replace_queues(connection: &mut Connection, data: &str) -> Result<(), String> { + let values: Vec = + serde_json::from_str(data).map_err(|error| format!("failed to decode queues: {error}"))?; + let strings = values + .iter() + .map(|value| { + serde_json::to_string(value).map_err(|error| format!("failed to encode queue: {error}")) + }) + .collect::, _>>()?; + let transaction = connection + .transaction() + .map_err(|error| format!("failed to begin queue save: {error}"))?; + replace_queues_tx(&transaction, &strings)?; + transaction + .commit() + .map_err(|error| format!("failed to commit queue save: {error}")) +} + +fn replace_queues_tx(transaction: &Transaction<'_>, queues: &[String]) -> Result<(), String> { + transaction + .execute("DELETE FROM queues", []) + .map_err(|error| format!("failed to clear queues: {error}"))?; + for data in queues { + let value: Value = serde_json::from_str(data) + .map_err(|error| format!("failed to decode queue: {error}"))?; + let id = required_string(&value, "id")?; + transaction + .execute( + "INSERT INTO queues (id, data) VALUES (?1, ?2)", + params![id, data], + ) + .map_err(|error| format!("failed to save queue '{id}': {error}"))?; + } + Ok(()) +} + +fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("persisted item is missing '{key}'")) +} + +pub fn load_ownership(connection: &Connection) -> Result, String> { + let mut statement = connection + .prepare("SELECT id, primary_path FROM download_ownership") + .map_err(|error| format!("failed to prepare ownership query: {error}"))?; + let rows = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|error| format!("failed to query ownership data: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("failed to read ownership data: {error}")) +} + +pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> { + connection + .execute( + "INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2) + ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path", + params![id, path], + ) + .map_err(|error| format!("failed to save ownership data: {error}"))?; + Ok(()) +} + +pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> { + connection + .execute("DELETE FROM download_ownership WHERE id = ?1", params![id]) + .map_err(|error| format!("failed to delete ownership data: {error}"))?; + Ok(()) +} + +pub fn has_user_data(connection: &Connection) -> Result { + connection + .query_row( + "SELECT + EXISTS(SELECT 1 FROM settings WHERE id = 1) + OR EXISTS(SELECT 1 FROM downloads) + OR EXISTS(SELECT 1 FROM queues)", + [], + |row| row.get(0), + ) + .map_err(|error| format!("failed to inspect existing user data: {error}")) +} + +pub fn record_notice(connection: &Connection, key: &str) -> Result<(), String> { + connection + .execute( + "INSERT INTO migration_events (key, consumed) VALUES (?1, 0) + ON CONFLICT(key) DO NOTHING", + params![key], + ) + .map_err(|error| format!("failed to record migration notice: {error}"))?; + Ok(()) +} + +pub fn has_pending_notice(connection: &Connection, key: &str) -> Result { + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM migration_events WHERE key = ?1 AND consumed = 0)", + params![key], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| format!("failed to read migration notice: {error}")) +} + +pub fn consume_notice(connection: &Connection, key: &str) -> Result<(), String> { + connection + .execute( + "UPDATE migration_events SET consumed = 1 WHERE key = ?1", + params![key], + ) + .map_err(|error| format!("failed to consume migration notice: {error}"))?; + Ok(()) +} + +pub fn hydrate_pairing_token(connection: &mut Connection) -> Result<(String, bool), String> { + let existing = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).ok(); + let generated = generate_pairing_token(); + let decision = decide_pairing_token( + existing.as_deref(), + None, + has_user_data(connection)?, + &generated, + ); + if decision.source != PairingTokenSource::Keychain { + set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &decision.token)?; + } + if decision.changed { + record_notice(connection, TOKEN_CHANGED_NOTICE)?; + } + let changed = has_pending_notice(connection, TOKEN_CHANGED_NOTICE)?; + Ok((decision.token, changed)) +} + +pub fn acknowledge_pairing_token_notice(connection: &Connection) -> Result<(), String> { + consume_notice(connection, TOKEN_CHANGED_NOTICE) +} + +fn decide_pairing_token( + keychain_token: Option<&str>, + legacy_token: Option<&str>, + has_existing_user_data: bool, + generated_token: &str, +) -> PairingTokenDecision { + if let Some(token) = keychain_token.filter(|token| !token.trim().is_empty()) { + return PairingTokenDecision { + token: token.to_string(), + source: PairingTokenSource::Keychain, + changed: false, + }; + } + if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) { + return PairingTokenDecision { + token: token.to_string(), + source: PairingTokenSource::LegacySettings, + changed: false, + }; + } + PairingTokenDecision { + token: generated_token.to_string(), + source: PairingTokenSource::Generated, + changed: has_existing_user_data, + } +} + +fn generate_pairing_token() -> String { + format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?; + entry + .set_password(password) + .map_err(|error| error.to_string()) +} + +pub fn get_keychain_password(id: &str) -> Result { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?; + entry.get_password().map_err(|error| error.to_string()) +} + +pub fn delete_keychain_password(id: &str) -> Result<(), String> { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?; + let _ = entry.delete_credential(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + #[test] + fn migrates_v0_database_and_creates_backup() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join(DATABASE_NAME); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + " + CREATE TABLE downloads ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + queue_id TEXT NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); + INSERT INTO downloads VALUES ( + 'one', 'queued', 'main', + '{\"id\":\"one\",\"status\":\"queued\",\"queueId\":\"main\"}' + ); + ", + ) + .unwrap(); + drop(connection); + + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert!(!column_is_not_null(&connection, "downloads", "queue_id").unwrap()); + assert_eq!(load_downloads(&connection).unwrap().len(), 1); + assert!(fs::read_dir(temp.path()).unwrap().flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("firelink.sqlite.backup-schema-v0-") + })); + } + + #[test] + fn imports_legacy_bundle_store_and_sanitizes_token() { + let root = TempDir::new().unwrap(); + let current = root.path().join("com.nimbold.firelink"); + let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); + fs::create_dir_all(&legacy).unwrap(); + let store = json!({ + "settings": json!({ + "state": { + "theme": "dark", + "extensionPairingToken": "legacy-secret" + }, + "version": 0 + }).to_string(), + "download_queue": [{ + "id": "download-1", + "status": "ready", + "url": "https://example.com/file", + "fileName": "file", + "category": "Other", + "dateAdded": "" + }], + "queues": [{ + "id": "main", + "name": "Main Queue", + "isMain": true + }] + }); + fs::write( + legacy.join(LEGACY_STORE_NAME), + serde_json::to_vec(&store).unwrap(), + ) + .unwrap(); + + let state = init_at_path(¤t).unwrap(); + let connection = state.lock().unwrap(); + assert_eq!(load_downloads(&connection).unwrap().len(), 1); + assert_eq!(load_queues(&connection).unwrap().len(), 1); + let settings = load_settings(&connection).unwrap().unwrap(); + assert!(settings.contains("\"theme\":\"dark\"")); + assert!(!settings.contains("legacy-secret")); + assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("store.bin.backup-legacy-import-") + })); + } + + #[test] + fn imports_legacy_bundle_sqlite_database() { + let root = TempDir::new().unwrap(); + let current = root.path().join("com.nimbold.firelink"); + let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); + fs::create_dir_all(&legacy).unwrap(); + let legacy_path = legacy.join(DATABASE_NAME); + let connection = Connection::open(&legacy_path).unwrap(); + connection + .execute_batch( + " + CREATE TABLE downloads ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + queue_id TEXT NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE settings (id INTEGER PRIMARY KEY, data TEXT NOT NULL); + CREATE TABLE queues (id TEXT PRIMARY KEY, data TEXT NOT NULL); + INSERT INTO downloads VALUES ( + 'legacy-download', 'queued', 'legacy-main', + '{\"id\":\"legacy-download\",\"status\":\"queued\",\"queueId\":\"legacy-main\"}' + ); + INSERT INTO queues VALUES ( + 'legacy-main', + '{\"id\":\"legacy-main\",\"name\":\"Legacy Main\",\"isMain\":true}' + ); + INSERT INTO settings VALUES ( + 1, + '{\"state\":{\"theme\":\"nord\"},\"version\":0}' + ); + ", + ) + .unwrap(); + drop(connection); + + let state = init_at_path(¤t).unwrap(); + let connection = state.lock().unwrap(); + assert_eq!(load_downloads(&connection).unwrap().len(), 1); + assert_eq!(load_queues(&connection).unwrap().len(), 1); + assert!(load_settings(&connection) + .unwrap() + .unwrap() + .contains("\"nord\"")); + assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("firelink.sqlite.backup-legacy-import-") + })); + } + + #[test] + fn token_decision_preserves_keychain_and_legacy_values() { + let keychain = decide_pairing_token(Some("keychain"), Some("legacy"), true, "generated"); + assert_eq!(keychain.token, "keychain"); + assert_eq!(keychain.source, PairingTokenSource::Keychain); + assert!(!keychain.changed); + + let legacy = decide_pairing_token(None, Some("legacy"), true, "generated"); + assert_eq!(legacy.token, "legacy"); + assert_eq!(legacy.source, PairingTokenSource::LegacySettings); + assert!(!legacy.changed); + + let recovery = decide_pairing_token(None, None, true, "generated"); + assert_eq!(recovery.token, "generated"); + assert_eq!(recovery.source, PairingTokenSource::Generated); + assert!(recovery.changed); + + let fresh = decide_pairing_token(None, None, false, "generated"); + assert!(!fresh.changed); + } + + #[test] + fn migration_notice_is_persistent_until_acknowledged() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + + record_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap(); + assert!(has_pending_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap()); + assert!(has_pending_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap()); + + acknowledge_pairing_token_notice(&connection).unwrap(); + assert!(!has_pending_notice(&connection, TOKEN_CHANGED_NOTICE).unwrap()); + } +} diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index b688d33..03825a4 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -1,9 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri_plugin_store::StoreExt; - -const STORE_NAME: &str = "store.bin"; -const OWNERSHIP_KEY: &str = "download_ownership"; +use tauri::Manager; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -59,23 +56,15 @@ pub fn set_primary_path( return Err("Download ownership path may not be a symlink".to_string()); } - let mut records = load_records(app_handle)?; - records.retain(|record| record.id != id); - records.push(DownloadOwnershipRecord { - id: id.to_string(), - primary_path: path.to_string_lossy().to_string(), - }); - save_records(app_handle, records) + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::set_ownership(&connection, id, &path.to_string_lossy()) } pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { - let mut records = load_records(app_handle)?; - let before = records.len(); - records.retain(|record| record.id != id); - if records.len() == before { - return Ok(()); - } - save_records(app_handle, records) + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::remove_ownership(&connection, id) } pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result, String> { @@ -96,43 +85,26 @@ pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result } fn load_records(app_handle: &tauri::AppHandle) -> Result, String> { - let store = app_handle - .store(STORE_NAME) - .map_err(|error| format!("Failed to load download ownership store: {error}"))?; - store - .get(OWNERSHIP_KEY) - .map(|value| serde_json::from_value::>(value.clone())) - .transpose() - .map_err(|error| format!("Invalid download ownership data: {error}")) - .map(|records| records.unwrap_or_default()) -} - -fn save_records( - app_handle: &tauri::AppHandle, - records: Vec, -) -> Result<(), String> { - let store = app_handle - .store(STORE_NAME) - .map_err(|error| format!("Failed to load download ownership store: {error}"))?; - let value = serde_json::to_value(records) - .map_err(|error| format!("Failed to encode download ownership data: {error}"))?; - store.set(OWNERSHIP_KEY, value); - store - .save() - .map_err(|error| format!("Failed to save download ownership data: {error}")) + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::load_ownership(&connection).map(|records| { + records + .into_iter() + .map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path }) + .collect() + }) } fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result, String> { - let store = app_handle - .store(STORE_NAME) - .map_err(|error| format!("Failed to load download queue: {error}"))?; + let database = app_handle.state::(); + let connection = database.lock()?; + let downloads = crate::db::load_downloads(&connection)? + .into_iter() + .map(|value| serde_json::from_str::(&value)) + .collect::, _>>() + .map_err(|error| format!("Invalid download queue ownership data: {error}"))?; + drop(connection); let settings = crate::settings::load_settings(app_handle).ok(); - let downloads = store - .get("download_queue") - .map(|value| serde_json::from_value::>(value.clone())) - .transpose() - .map_err(|error| format!("Invalid download queue ownership data: {error}"))? - .unwrap_or_default(); let mut paths = Vec::new(); for download in downloads { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c78b730..f759c5c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2756,22 +2756,90 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result Result<(), String> { - let entry = keyring::Entry::new("com.firelink.app", &id).map_err(|e| e.to_string())?; - entry.set_password(&password).map_err(|e| e.to_string())?; - Ok(()) + crate::db::set_keychain_password(&id, &password) } #[tauri::command] fn get_keychain_password(id: String) -> Result { - let entry = keyring::Entry::new("com.firelink.app", &id).map_err(|e| e.to_string())?; - entry.get_password().map_err(|e| e.to_string()) + crate::db::get_keychain_password(&id) } #[tauri::command] fn delete_keychain_password(id: String) -> Result<(), String> { - let entry = keyring::Entry::new("com.firelink.app", &id).map_err(|e| e.to_string())?; - let _ = entry.delete_credential(); // Ignore error if it doesn't exist - Ok(()) + crate::db::delete_keychain_password(&id) +} + +#[derive(Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +struct PairingTokenHydration { + token: String, + token_changed: bool, +} + +#[tauri::command] +fn hydrate_extension_pairing_token( + state: tauri::State<'_, crate::db::DbState>, +) -> Result { + let mut connection = state.lock()?; + let (token, token_changed) = crate::db::hydrate_pairing_token(&mut connection)?; + Ok(PairingTokenHydration { token, token_changed }) +} + +#[tauri::command] +fn acknowledge_pairing_token_change( + state: tauri::State<'_, crate::db::DbState>, +) -> Result<(), String> { + let connection = state.lock()?; + crate::db::acknowledge_pairing_token_notice(&connection) +} + +#[tauri::command] +fn db_save_settings(state: tauri::State<'_, crate::db::DbState>, data: String) -> Result<(), String> { + let connection = state.lock()?; + crate::db::save_settings(&connection, &data) +} + +#[tauri::command] +fn db_load_settings( + state: tauri::State<'_, crate::db::DbState>, +) -> Result, String> { + let connection = state.lock()?; + crate::db::load_settings(&connection) +} + +#[tauri::command] +fn db_get_all_downloads( + state: tauri::State<'_, crate::db::DbState>, +) -> Result, String> { + let connection = state.lock()?; + crate::db::load_downloads(&connection) +} + +#[tauri::command] +fn db_replace_downloads( + state: tauri::State<'_, crate::db::DbState>, + data: String, +) -> Result<(), String> { + let mut connection = state.lock()?; + crate::db::replace_downloads(&mut connection, &data) +} + +#[tauri::command] +fn db_get_all_queues( + state: tauri::State<'_, crate::db::DbState>, +) -> Result, String> { + let connection = state.lock()?; + crate::db::load_queues(&connection) +} + +#[tauri::command] +fn db_replace_queues( + state: tauri::State<'_, crate::db::DbState>, + data: String, +) -> Result<(), String> { + let mut connection = state.lock()?; + crate::db::replace_queues(&mut connection, &data) } #[tauri::command] @@ -3233,7 +3301,10 @@ pub fn run() { .build(app) .unwrap(); - + let database = crate::db::init(app.handle()) + .map_err(|error| format!("failed to initialize persistence: {error}"))?; + app.manage(database); + let max_concurrent = { crate::settings::load_settings(app.handle()) .map(|settings| settings.max_concurrent_downloads) @@ -3534,7 +3605,6 @@ pub fn run() { }) .build(), ) - .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) @@ -3554,6 +3624,7 @@ pub fn run() { update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, + hydrate_extension_pairing_token, acknowledge_pairing_token_change, check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, detach_download_for_reconfigure, @@ -3561,6 +3632,8 @@ pub fn run() { commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains, parity::create_category_directories, + db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads, + db_get_all_queues, db_replace_queues, export_logs ]) .build(tauri::generate_context!()) @@ -3573,4 +3646,5 @@ pub fn run() { }); } mod extension_server; +mod db; mod scheduler; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 1dc5d1b..12a2e25 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -4,20 +4,14 @@ use crate::ipc::{ }; use serde_json::{Map, Value}; use std::collections::HashMap; -use tauri::AppHandle; -use tauri_plugin_store::StoreExt; - -const SETTINGS_STORE: &str = "store.bin"; -const SETTINGS_KEY: &str = "settings"; +use tauri::{AppHandle, Manager}; pub fn load_settings(app_handle: &AppHandle) -> Result { - let store = app_handle - .store(SETTINGS_STORE) - .map_err(|error| format!("failed to open settings store: {error}"))?; - let stored = store - .get(SETTINGS_KEY) + let database = app_handle.state::(); + let connection = database.lock()?; + let stored = crate::db::load_settings(&connection)? .ok_or_else(|| "settings are not persisted yet".to_string())?; - decode_stored_settings(&stored) + decode_stored_settings(&Value::String(stored)) } pub fn decode_stored_settings(stored: &Value) -> Result { @@ -37,28 +31,15 @@ pub fn update_settings_state( app_handle: &AppHandle, update: impl FnOnce(&mut Map), ) -> Result<(), String> { - let store = app_handle - .store(SETTINGS_STORE) - .map_err(|error| format!("failed to open settings store: {error}"))?; - let stored = store - .get(SETTINGS_KEY) + let database = app_handle.state::(); + let connection = database.lock()?; + let stored = crate::db::load_settings(&connection)? .ok_or_else(|| "settings are not persisted yet".to_string())?; - let was_string = stored.is_string(); - let mut document = decode_document(&stored)?; + let mut document = decode_document(&Value::String(stored))?; update(settings_state_mut(&mut document)?); - - let stored = if was_string { - Value::String( - serde_json::to_string(&document) - .map_err(|error| format!("failed to encode settings: {error}"))?, - ) - } else { - document - }; - store.set(SETTINGS_KEY, stored); - store - .save() - .map_err(|error| format!("failed to save settings: {error}")) + let stored = serde_json::to_string(&document) + .map_err(|error| format!("failed to encode settings: {error}"))?; + crate::db::save_settings(&connection, &stored) } fn decode_document(stored: &Value) -> Result { diff --git a/src/App.tsx b/src/App.tsx index e12a865..316db91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import DiagnosticsView from "./components/DiagnosticsView"; function App() { const [filter, setFilter] = useState('all'); + const [pairingTokenChanged, setPairingTokenChanged] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220; @@ -39,6 +40,13 @@ function App() { const previousSpeedLimit = useRef(null); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); + const acknowledgePairingTokenChange = () => { + setPairingTokenChanged(false); + invoke('acknowledge_pairing_token_change').catch(error => { + console.error('Failed to acknowledge pairing token migration notice:', error); + }); + }; + const startSidebarResize = (event: React.PointerEvent) => { event.preventDefault(); const startX = event.clientX; @@ -67,13 +75,11 @@ function App() { useEffect(() => { useDownloadStore.getState().initDB(); - // Hydrate the browser-extension pairing token from the OS keychain before - // the reactive push to the backend. If no token exists (fresh install or - // upgrade from a plaintext-persisting version) a new one is minted and - // stored, effectively rotating it away from any leaked plaintext. - useSettingsStore.getState().hydratePairingToken().catch(error => { - console.error('Failed to hydrate extension pairing token:', error); - }); + useSettingsStore.getState().hydratePairingToken() + .then(setPairingTokenChanged) + .catch(error => { + console.error('Failed to hydrate extension pairing token:', error); + }); }, []); useEffect(() => { @@ -93,6 +99,7 @@ function App() { }, [showMenuBarIcon]); useEffect(() => { + if (!extensionPairingToken) return; invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => { console.error('Failed to configure browser extension pairing token:', error); }); @@ -296,6 +303,43 @@ function App() { + {pairingTokenChanged && ( +
+

+ Browser extension disconnected because its pairing token changed. +

+
+ + +
+
+ )} ); } diff --git a/src/bindings/PairingTokenHydration.ts b/src/bindings/PairingTokenHydration.ts new file mode 100644 index 0000000..8e47a8a --- /dev/null +++ b/src/bindings/PairingTokenHydration.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PairingTokenHydration = { token: string, tokenChanged: boolean, }; diff --git a/src/ipc.ts b/src/ipc.ts index 1718bee..ddafec1 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -4,7 +4,6 @@ import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn import type { DownloadCategory } from './bindings/DownloadCategory'; import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent'; import type { DownloadStateEvent } from './bindings/DownloadStateEvent'; -import type { DownloadStatus } from './bindings/DownloadStatus'; import type { ExtensionDownload } from './bindings/ExtensionDownload'; import type { MediaCookieSource } from './bindings/MediaCookieSource'; import type { MediaMetadata } from './bindings/MediaMetadata'; @@ -13,6 +12,7 @@ import type { EngineStatusItem } from './bindings/EngineStatusItem'; import type { EngineStatusResult } from './bindings/EngineStatusResult'; import type { PostQueueAction } from './bindings/PostQueueAction'; import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome'; +import type { PairingTokenHydration } from './bindings/PairingTokenHydration'; type StartDownloadArgs = { id: string; @@ -88,6 +88,8 @@ type CommandMap = { delete_file: { args: { path: string }; result: void }; toggle_tray_icon: { args: { show: boolean }; result: void }; set_extension_pairing_token: { args: { token: string }; result: void }; + hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration }; + acknowledge_pairing_token_change: { args: undefined; result: void }; set_extension_frontend_ready: { args: { ready: boolean }; result: void }; get_system_proxy: { args: undefined; result: string | null }; get_file_category: { args: { filename: string }; result: DownloadCategory }; @@ -96,11 +98,9 @@ type CommandMap = { db_save_settings: { args: { data: string }; result: void }; db_load_settings: { args: undefined; result: string | null }; db_get_all_downloads: { args: undefined; result: string[] }; - db_save_download: { - args: { id: string; status: DownloadStatus; queueId: string; data: string }; - result: void; - }; - db_delete_download: { args: { id: string }; result: void }; + db_replace_downloads: { args: { data: string }; result: void }; + db_get_all_queues: { args: undefined; result: string[] }; + db_replace_queues: { args: { data: string }; result: void }; create_category_directories: { args: { paths: string[] }; result: void }; export_logs: { args: { destPath: string }; result: string }; get_pending_order: { args: undefined; result: string[] }; diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts deleted file mode 100644 index e4eea57..0000000 --- a/src/store/settingsStore.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; -import { info } from '@tauri-apps/plugin-log'; - -import { tauriStore } from './useDownloadStore'; - -const tauriStorage: StateStorage = { - getItem: async (name: string): Promise => { - if (name === 'firelink-engine-settings') { - try { - const data = await tauriStore.get('engine_settings'); - return data || null; - } catch (e) { - console.error("Failed to load engine settings from DB", e); - return null; - } - } - return null; - }, - setItem: async (name: string, value: string): Promise => { - if (name === 'firelink-engine-settings') { - try { - await tauriStore.set('engine_settings', value); - await tauriStore.save(); - } catch (e) { - console.error("Failed to save engine settings to DB", e); - } - } - }, - removeItem: async (_name: string): Promise => { - // no-op for now - }, -}; - -export interface SettingsState { - defaultDownloadPath: string; - globalSpeedLimit: number; - concurrentDownloads: number; - - setDefaultDownloadPath: (path: string) => void; - setGlobalSpeedLimit: (limit: number) => void; - setConcurrentDownloads: (count: number) => void; -} - -export const useSettingsStore = create()( - persist( - (set) => ({ - defaultDownloadPath: '~/Downloads', - globalSpeedLimit: 0, - concurrentDownloads: 3, - - setDefaultDownloadPath: (path) => { - info(`Settings updated: defaultDownloadPath = ${path}`); - set({ defaultDownloadPath: path }); - }, - setGlobalSpeedLimit: (limit) => { - info(`Settings updated: globalSpeedLimit = ${limit}`); - set({ globalSpeedLimit: limit }); - }, - setConcurrentDownloads: (count) => { - info(`Settings updated: concurrentDownloads = ${count}`); - set({ concurrentDownloads: count }); - }, - }), - { - name: 'firelink-engine-settings', - storage: createJSONStorage(() => tauriStorage), - partialize: (state) => ({ - defaultDownloadPath: state.defaultDownloadPath, - globalSpeedLimit: state.globalSpeedLimit, - concurrentDownloads: state.concurrentDownloads, - }), - } - ) -); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 61b9428..7d080fa 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -12,16 +12,6 @@ vi.mock('@tauri-apps/plugin-log', () => ({ error: vi.fn(), })); -vi.mock('@tauri-apps/plugin-store', () => { - return { - LazyStore: class { - get = vi.fn().mockResolvedValue([]); - set = vi.fn(); - save = vi.fn(); - } - }; -}); - vi.mock('./useSettingsStore', () => ({ useSettingsStore: { getState: vi.fn(() => ({ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 03b05c2..719e5f0 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1,10 +1,8 @@ import { create } from 'zustand'; -import { LazyStore } from '@tauri-apps/plugin-store'; import { info } from '@tauri-apps/plugin-log'; import { homeDir } from '@tauri-apps/api/path'; import { invokeCommand as invoke } from '../ipc'; -export const tauriStore = new LazyStore('store.bin'); import type { DownloadItem } from '../bindings/DownloadItem'; import type { DownloadStatus } from '../bindings/DownloadStatus'; import type { ExtensionDownload } from '../bindings/ExtensionDownload'; @@ -606,8 +604,10 @@ export const useDownloadStore = create((set, get) => ({ }, initDB: async () => { try { - const queues = await tauriStore.get('queues') || []; - const downloads = await tauriStore.get('download_queue') || []; + const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue); + const downloads = (await invoke('db_get_all_downloads')).map( + value => JSON.parse(value) as DownloadItem + ); set(state => ({ queues: queues.length > 0 ? queues : state.queues, @@ -678,11 +678,18 @@ export const useDownloadStore = create((set, get) => ({ })); let lastSavedDownloads = ''; +let downloadsSave = Promise.resolve(); +let queuesSave = Promise.resolve(); useDownloadStore.subscribe(async (state, prevState) => { if (state.queues !== prevState.queues) { - await tauriStore.set('queues', state.queues); - await tauriStore.save(); + const data = JSON.stringify(state.queues); + queuesSave = queuesSave + .then(() => invoke('db_replace_queues', { data })) + .catch(error => { + console.error('Failed to persist queues:', error); + }); + await queuesSave; } if (state.downloads !== prevState.downloads) { @@ -694,8 +701,12 @@ useDownloadStore.subscribe(async (state, prevState) => { const currentSerialized = JSON.stringify(staticDownloads); if (currentSerialized !== lastSavedDownloads) { lastSavedDownloads = currentSerialized; - await tauriStore.set('download_queue', staticDownloads); - await tauriStore.save(); + downloadsSave = downloadsSave + .then(() => invoke('db_replace_downloads', { data: currentSerialized })) + .catch(error => { + console.error('Failed to persist downloads:', error); + }); + await downloadsSave; } } }); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 540e5ba..7eec0a8 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -14,14 +14,13 @@ import type { SettingsTab } from '../bindings/SettingsTab'; import type { SiteLogin } from '../bindings/SiteLogin'; import type { Theme } from '../bindings/Theme'; -import { tauriStore } from './useDownloadStore'; +let settingsSave = Promise.resolve(); const tauriStorage: StateStorage = { getItem: async (name: string): Promise => { if (name === 'firelink-settings') { try { - const data = await tauriStore.get('settings'); - return data || null; + return await invoke('db_load_settings'); } catch (e) { console.error("Failed to load settings from DB", e); return null; @@ -31,12 +30,12 @@ const tauriStorage: StateStorage = { }, setItem: async (name: string, value: string): Promise => { if (name === 'firelink-settings') { - try { - await tauriStore.set('settings', value); - await tauriStore.save(); - } catch (e) { - console.error("Failed to save settings to DB", e); - } + settingsSave = settingsSave + .then(() => invoke('db_save_settings', { data: value })) + .catch(e => { + console.error("Failed to save settings to DB", e); + }); + await settingsSave; } }, removeItem: async (_name: string): Promise => { @@ -47,9 +46,8 @@ const tauriStorage: StateStorage = { /** * Keychain identifier for the browser-extension pairing token. The token is an * HMAC shared secret and is therefore persisted via the OS keychain rather - * than the plaintext `store.bin` settings document. A fresh token is minted - * when no prior entry exists (also covering upgrades from versions that - * stored the token as plaintext, effectively rotating it on upgrade). + * than the user-data database. Legacy plaintext values are migrated into the + * Keychain before being removed from persisted settings. */ const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token'; @@ -135,7 +133,7 @@ export interface SettingsState { removeSiteLogin: (id: string) => void; regeneratePairingToken: () => void; setAutoCheckUpdates: (autoCheckUpdates: boolean) => void; - hydratePairingToken: () => Promise; + hydratePairingToken: () => Promise; } const defaultDirectories = { @@ -240,7 +238,7 @@ export const useSettingsStore = create()( mediaCookieSource: 'none', downloadDirectories: { ...defaultDirectories }, siteLogins: [], - extensionPairingToken: generateSecureToken(), + extensionPairingToken: '', autoCheckUpdates: true, setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); }, @@ -305,30 +303,27 @@ export const useSettingsStore = create()( }); }, hydratePairingToken: async () => { - const existing = useSettingsStore.getState().extensionPairingToken; - try { - const stored = await invoke('get_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID }); - if (stored) { - set({ extensionPairingToken: stored }); - return; - } - } catch { - // No prior token in the keychain (fresh install or upgrade from a - // version that stored plaintext). Fall through to mint + store. - } - const token = existing || generateSecureToken(); - set({ extensionPairingToken: token }); - try { - await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token }); - } catch (e) { - console.error('Failed to persist extension pairing token to keychain:', e); - } + const result = await invoke('hydrate_extension_pairing_token'); + set({ extensionPairingToken: result.token }); + return result.tokenChanged; }, setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }), }), { name: 'firelink-settings', storage: createJSONStorage(() => tauriStorage), + version: 1, + migrate: (persistedState) => { + if (!persistedState || typeof persistedState !== 'object') { + return persistedState as SettingsState; + } + const persisted = persistedState as Partial; + return { + ...persisted, + downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories), + siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : [] + } as SettingsState; + }, partialize: (state): PersistedSettings => ({ theme: state.theme, defaultDownloadPath: state.defaultDownloadPath, diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 5fc995a..3831917 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -98,7 +98,7 @@ export const isMediaUrl = (rawUrl: string): boolean => { * Fields that may carry secrets and therefore must never reach the persisted * `download_queue` document. These are supplied in-memory for the active * session (see `enqueue_download` payloads) but are stripped at the - * persistence boundary so `store.bin` contains no plaintext credentials. + * persistence boundary so the user-data database contains no plaintext credentials. */ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;