fix(persistence): preserve user data across updates

This commit is contained in:
NimBold
2026-06-20 19:08:48 +03:30
parent 487e1fea43
commit 79b579790d
16 changed files with 1288 additions and 260 deletions
-10
View File
@@ -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",
-1
View File
@@ -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",
+82 -17
View File
@@ -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"
+1 -1
View File
@@ -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"
+979
View File
@@ -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<Connection>,
}
impl DbState {
pub fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, 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<String>,
downloads: Vec<String>,
queues: Vec<String>,
ownership: Vec<(String, String)>,
pairing_token: Option<String>,
}
pub fn init(app_handle: &tauri::AppHandle) -> Result<DbState, String> {
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<DbState, String> {
init_at_path_internal(app_data_dir, false)
}
fn init_at_path_internal(app_data_dir: &Path, migrate_keychain: bool) -> Result<DbState, String> {
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<LegacyData, String> {
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<LegacyData, String> {
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<bool, String> {
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>), 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>), 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>), 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<Vec<String>, 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<Vec<String>, 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::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to read legacy data: {error}"))
}
fn backup_file(path: &Path, reason: &str) -> Result<PathBuf, String> {
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<PathBuf, String> {
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<bool, String> {
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<bool, String> {
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<bool, String> {
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<Option<String>, 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<Vec<String>, 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<Value> = 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::<Result<Vec<_>, _>>()?;
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<Vec<String>, 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<Value> =
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::<Result<Vec<_>, _>>()?;
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<Vec<(String, String)>, 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::<Result<Vec<_>, _>>()
.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<bool, String> {
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<bool, String> {
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<String, String> {
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(&current).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(&current).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());
}
}
+23 -51
View File
@@ -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::<crate::db::DbState>();
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::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::remove_ownership(&connection, id)
}
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
@@ -96,43 +85,26 @@ pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>
}
fn load_records(app_handle: &tauri::AppHandle) -> Result<Vec<DownloadOwnershipRecord>, 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::<Vec<DownloadOwnershipRecord>>(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<DownloadOwnershipRecord>,
) -> 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::<crate::db::DbState>();
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<Vec<PathBuf>, String> {
let store = app_handle
.store(STORE_NAME)
.map_err(|error| format!("Failed to load download queue: {error}"))?;
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
let downloads = crate::db::load_downloads(&connection)?
.into_iter()
.map(|value| serde_json::from_str::<crate::ipc::DownloadItem>(&value))
.collect::<Result<Vec<_>, _>>()
.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::<Vec<crate::ipc::DownloadItem>>(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 {
+84 -10
View File
@@ -2756,22 +2756,90 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
#[tauri::command]
fn set_keychain_password(id: String, password: String) -> 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<String, String> {
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<PairingTokenHydration, String> {
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<Option<String>, String> {
let connection = state.lock()?;
crate::db::load_settings(&connection)
}
#[tauri::command]
fn db_get_all_downloads(
state: tauri::State<'_, crate::db::DbState>,
) -> Result<Vec<String>, 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<Vec<String>, 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;
+12 -31
View File
@@ -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<PersistedSettings, 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::<crate::db::DbState>();
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<PersistedSettings, String> {
@@ -37,28 +31,15 @@ pub fn update_settings_state(
app_handle: &AppHandle,
update: impl FnOnce(&mut Map<String, Value>),
) -> 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::<crate::db::DbState>();
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<Value, String> {
+51 -7
View File
@@ -18,6 +18,7 @@ import DiagnosticsView from "./components/DiagnosticsView";
function App() {
const [filter, setFilter] = useState<SidebarFilter>('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<string | null>(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<HTMLDivElement>) => {
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() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
{pairingTokenChanged && (
<div
className="app-toast fixed bottom-5 right-5 z-[80] max-w-[420px] p-4 text-[12px]"
role="status"
>
<p className="font-medium">
Browser extension disconnected because its pairing token changed.
</p>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
className="app-button px-3 py-1.5"
onClick={() => {
const token = useSettingsStore.getState().extensionPairingToken;
if (token) {
void navigator.clipboard.writeText(token);
}
acknowledgePairingTokenChange();
}}
>
Copy new token
</button>
<button
type="button"
className="app-button px-3 py-1.5"
onClick={() => {
const settings = useSettingsStore.getState();
settings.setActiveSettingsTab('integrations');
settings.setActiveView('settings');
acknowledgePairingTokenChange();
}}
>
Open Integrations
</button>
</div>
</div>
)}
</div>
);
}
+3
View File
@@ -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, };
+6 -6
View File
@@ -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[] };
-75
View File
@@ -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<string | null> => {
if (name === 'firelink-engine-settings') {
try {
const data = await tauriStore.get<string>('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<void> => {
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<void> => {
// 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<SettingsState>()(
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,
}),
}
)
);
-10
View File
@@ -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(() => ({
+19 -8
View File
@@ -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<DownloadState>((set, get) => ({
},
initDB: async () => {
try {
const queues = await tauriStore.get<Queue[]>('queues') || [];
const downloads = await tauriStore.get<DownloadItem[]>('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<DownloadState>((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;
}
}
});
+27 -32
View File
@@ -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<string | null> => {
if (name === 'firelink-settings') {
try {
const data = await tauriStore.get<string>('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<void> => {
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<void> => {
@@ -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<void>;
hydratePairingToken: () => Promise<boolean>;
}
const defaultDirectories = {
@@ -240,7 +238,7 @@ export const useSettingsStore = create<SettingsState>()(
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<SettingsState>()(
});
},
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<SettingsState>;
return {
...persisted,
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
} as SettingsState;
},
partialize: (state): PersistedSettings => ({
theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath,
+1 -1
View File
@@ -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;