mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 20:40:12 +00:00
fix(persistence): preserve user data across updates
This commit is contained in:
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+82
-17
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¤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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[] };
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user