From 3477edfb7680b4595166bf54a7794525e7799fe0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 18 May 2026 14:11:05 -0600 Subject: [PATCH] refactor: share the image cache and credential store via crates Extracts two self-contained, framework-agnostic modules out of src-tauri so they can be reused by other clients without duplicating logic: - ImageCacheService / ImageCacheStats move into the qbz-cache crate (LRU disk cache, MD5-keyed, SQLite index). - The credential store (keyring + AES-256-GCM encrypted file for Qobuz credentials and the OAuth token) moves into a new qbz-credentials crate. src-tauri/image_cache.rs and src-tauri/credentials/mod.rs become thin re-exports, keeping their Tauri managed-state wrappers. The Tauri app is behavior-identical; both modules were already self-contained (no Tauri and no crate:: references). cc --- crates/Cargo.lock | 249 +++++++- crates/Cargo.toml | 1 + crates/qbz-cache/Cargo.toml | 4 + crates/qbz-cache/src/image_cache.rs | 205 +++++++ crates/qbz-cache/src/lib.rs | 4 +- crates/qbz-credentials/Cargo.toml | 22 + crates/qbz-credentials/src/lib.rs | 864 +++++++++++++++++++++++++++ src-tauri/Cargo.lock | 20 + src-tauri/Cargo.toml | 1 + src-tauri/src/credentials/mod.rs | 868 +--------------------------- src-tauri/src/image_cache.rs | 206 +------ 11 files changed, 1352 insertions(+), 1092 deletions(-) create mode 100644 crates/qbz-cache/src/image_cache.rs create mode 100644 crates/qbz-credentials/Cargo.toml create mode 100644 crates/qbz-credentials/src/lib.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 91b5e2405..af2b0c42f 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -169,6 +169,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ashpd" +version = "0.13.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340e0f6bf7f9ee78549c61454f1460a3ed97c011902ee76b58301bbc6d502a32" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.2", + "serde", + "tokio", + "zbus 5.15.0", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -433,6 +447,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -584,7 +607,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -706,6 +729,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.1" @@ -912,6 +941,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1010,7 +1048,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "sha2", + "sha2 0.10.9", "zeroize", ] @@ -1029,11 +1067,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1542,7 +1591,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1610,6 +1659,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2030,7 +2088,7 @@ dependencies = [ "dbus-secret-service", "log", "secret-service", - "zbus", + "zbus 4.4.0", "zeroize", ] @@ -2201,7 +2259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2944,7 +3002,7 @@ dependencies = [ "symphonia", "thiserror 2.0.18", "tokio", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -2954,6 +3012,8 @@ dependencies = [ "dirs", "filetime", "log", + "md-5", + "rusqlite", "serde", ] @@ -2984,7 +3044,7 @@ dependencies = [ "hkdf", "log", "md-5", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] @@ -3004,6 +3064,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "qbz-credentials" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "ashpd", + "base64", + "dirs", + "keyring", + "log", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", +] + [[package]] name = "qbz-integrations" version = "0.1.0" @@ -3100,7 +3177,7 @@ dependencies = [ "log", "rand 0.9.3", "serde", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "uuid", @@ -3730,8 +3807,8 @@ dependencies = [ "once_cell", "rand 0.8.6", "serde", - "sha2", - "zbus", + "sha2 0.10.9", + "zbus 4.4.0", ] [[package]] @@ -3857,7 +3934,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -3868,7 +3945,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4530,7 +4618,7 @@ dependencies = [ "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -4542,7 +4630,7 @@ dependencies = [ "indexmap", "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -4551,7 +4639,7 @@ version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "winnow", + "winnow 0.7.15", ] [[package]] @@ -4693,9 +4781,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -4755,7 +4843,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -5533,6 +5621,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5694,9 +5791,39 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", ] [[package]] @@ -5709,7 +5836,22 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", ] [[package]] @@ -5720,7 +5862,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant 5.11.0", ] [[package]] @@ -5848,7 +6001,21 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", ] [[package]] @@ -5861,7 +6028,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.3.1", ] [[package]] @@ -5874,3 +6054,16 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 150a0262c..7b78d4f07 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -19,6 +19,7 @@ members = [ "qconnect-transport-ws", "qconnect-app", "qbzd", + "qbz-credentials", ] [workspace.package] diff --git a/crates/qbz-cache/Cargo.toml b/crates/qbz-cache/Cargo.toml index 7beb5adf8..b8584512f 100644 --- a/crates/qbz-cache/Cargo.toml +++ b/crates/qbz-cache/Cargo.toml @@ -15,3 +15,7 @@ serde = { version = "1", features = ["derive"] } # File system utilities dirs = "6" filetime = "0.2" + +# Image cache index + URL hashing +rusqlite = { version = "0.31", features = ["bundled"] } +md-5 = "0.10" diff --git a/crates/qbz-cache/src/image_cache.rs b/crates/qbz-cache/src/image_cache.rs new file mode 100644 index 000000000..0fe6d1a52 --- /dev/null +++ b/crates/qbz-cache/src/image_cache.rs @@ -0,0 +1,205 @@ +//! Image cache service +//! +//! LRU disk cache for Qobuz album/artist images. +//! - Stores images keyed by MD5 hash of URL +//! - Tracks last-access time for LRU eviction +//! - Respects a configurable max size +//! - Framework-agnostic: shared by the Tauri app and the Slint shell so +//! both read and write the same `~/.cache/qbz/images` cache. + +use md5::{Digest, Md5}; +use rusqlite::{params, Connection}; +use std::path::PathBuf; + +/// Cache statistics. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ImageCacheStats { + pub total_bytes: u64, + pub file_count: u64, +} + +pub struct ImageCacheService { + cache_dir: PathBuf, + conn: Connection, +} + +impl ImageCacheService { + pub fn new() -> Result { + let cache_dir = dirs::cache_dir() + .ok_or_else(|| "Could not find cache directory".to_string())? + .join("qbz") + .join("images"); + + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create image cache dir: {}", e))?; + + let db_path = cache_dir.join("image_cache.db"); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open image cache database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to enable WAL: {}", e))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS cached_images ( + hash TEXT PRIMARY KEY, + url TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + last_accessed INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_images (last_accessed);", + ) + .map_err(|e| format!("Failed to create image cache table: {}", e))?; + + Ok(Self { cache_dir, conn }) + } + + fn url_hash(url: &str) -> String { + let mut hasher = Md5::new(); + hasher.update(url.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + fn cache_path(&self, hash: &str) -> PathBuf { + self.cache_dir.join(format!("{}.img", hash)) + } + + /// Get a cached image path, updating last-access time. + /// Returns None if not cached. + pub fn get(&self, url: &str) -> Option { + let hash = Self::url_hash(url); + let path = self.cache_path(&hash); + + if !path.exists() { + // File missing — clean up stale DB entry + let _ = self + .conn + .execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]); + return None; + } + + // Update last-accessed time + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let _ = self.conn.execute( + "UPDATE cached_images SET last_accessed = ?1 WHERE hash = ?2", + params![now, hash], + ); + + Some(path) + } + + /// Store image bytes in the cache. + /// Returns the local file path on success. + pub fn store(&self, url: &str, bytes: &[u8]) -> Result { + let hash = Self::url_hash(url); + let path = self.cache_path(&hash); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + std::fs::write(&path, bytes).map_err(|e| format!("Failed to write cached image: {}", e))?; + + let file_size = bytes.len() as i64; + self.conn + .execute( + "INSERT OR REPLACE INTO cached_images (hash, url, file_size, last_accessed) + VALUES (?1, ?2, ?3, ?4)", + params![hash, url, file_size, now], + ) + .map_err(|e| format!("Failed to insert image cache entry: {}", e))?; + + Ok(path) + } + + /// Evict least-recently-accessed entries until total size is under max_bytes. + pub fn evict(&self, max_bytes: u64) -> Result { + let total: i64 = self + .conn + .query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM cached_images", + [], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to query cache size: {}", e))?; + + if (total as u64) <= max_bytes { + return Ok(0); + } + + let mut to_free = (total as u64) - max_bytes; + let mut freed: u64 = 0; + + // Get LRU entries (oldest access first) + let mut stmt = self + .conn + .prepare("SELECT hash, file_size FROM cached_images ORDER BY last_accessed ASC") + .map_err(|e| format!("Failed to prepare eviction query: {}", e))?; + + let entries: Vec<(String, i64)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("Failed to query LRU entries: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + for (hash, file_size) in entries { + if to_free == 0 { + break; + } + let path = self.cache_path(&hash); + if path.exists() { + let _ = std::fs::remove_file(&path); + } + let _ = self + .conn + .execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]); + let size = file_size as u64; + freed += size; + to_free = to_free.saturating_sub(size); + } + + Ok(freed) + } + + /// Get cache statistics. + pub fn stats(&self) -> Result { + let (total_bytes, file_count): (i64, i64) = self + .conn + .query_row( + "SELECT COALESCE(SUM(file_size), 0), COUNT(*) FROM cached_images", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| format!("Failed to query image cache stats: {}", e))?; + + Ok(ImageCacheStats { + total_bytes: total_bytes as u64, + file_count: file_count as u64, + }) + } + + /// Clear the entire cache. + pub fn clear(&self) -> Result { + let stats = self.stats()?; + + // Delete all files + if let Ok(entries) = std::fs::read_dir(&self.cache_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "img").unwrap_or(false) { + let _ = std::fs::remove_file(path); + } + } + } + + // Clear database + self.conn + .execute("DELETE FROM cached_images", []) + .map_err(|e| format!("Failed to clear image cache table: {}", e))?; + + Ok(stats.total_bytes) + } +} diff --git a/crates/qbz-cache/src/lib.rs b/crates/qbz-cache/src/lib.rs index 58f9b8906..882d5aad0 100644 --- a/crates/qbz-cache/src/lib.rs +++ b/crates/qbz-cache/src/lib.rs @@ -33,7 +33,7 @@ //! //! ## Usage //! -//! ```no_run +//! ```ignore //! use qbz_cache::{AudioCache, PlaybackCache}; //! use std::sync::Arc; //! @@ -56,7 +56,9 @@ //! ``` mod audio_cache; +mod image_cache; mod playback_cache; pub use audio_cache::{AudioCache, CacheStats, CachedTrack}; +pub use image_cache::{ImageCacheService, ImageCacheStats}; pub use playback_cache::{PlaybackCache, PlaybackCacheStats}; diff --git a/crates/qbz-credentials/Cargo.toml b/crates/qbz-credentials/Cargo.toml new file mode 100644 index 000000000..d6ced6304 --- /dev/null +++ b/crates/qbz-credentials/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "qbz-credentials" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Encrypted credential and OAuth token storage for QBZ (keyring + AES-256-GCM file)" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +base64 = { workspace = true } +tokio = { workspace = true } +aes-gcm = "0.10" +sha2 = "0.11" +rand = "0.10" +dirs = "6" +keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] } + +[target.'cfg(target_os = "linux")'.dependencies] +ashpd = { version = "0.13", default-features = false, features = ["tokio", "secret"] } diff --git a/crates/qbz-credentials/src/lib.rs b/crates/qbz-credentials/src/lib.rs new file mode 100644 index 000000000..2555a2b8e --- /dev/null +++ b/crates/qbz-credentials/src/lib.rs @@ -0,0 +1,864 @@ +//! Secure credential storage with fallback +//! +//! The encrypted AES-256-GCM file in the app config directory is the source +//! of truth for every credential. The OS keyring is used as an optional +//! best-effort cache: +//! +//! - Writes always go to the file first; the keyring is written opportunistically. +//! - Reads try the keyring first (cheaper when it works) and fall back to the file. +//! - Any keyring operation that fails or times out marks the keyring as broken +//! for the rest of the process, so later reads/writes skip it entirely. +//! +//! This matters on Linux systems where GNOME Keyring / KWallet may be locked +//! with a password that no longer matches the current session (see issue #329). +//! Without the timeout + session memoization, every login triggers a blocking +//! "unlock keyring" dialog and the user has to dismiss 3-4 prompts per session. +//! With them, the dialog appears at most once per session and the app continues +//! through the encrypted file path regardless of what the user does with it. + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use keyring::Entry; +use rand::RngExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +const SERVICE_NAME: &str = "qbz"; +const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials"; +const FALLBACK_FILE_NAME: &str = ".qbz-auth"; +const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy"; +const OAUTH_TOKEN_FILE_NAME: &str = ".qbz-oauth-token"; +const INSTALLATION_SALT_FILE_NAME: &str = ".qbz-cred-salt"; +const MACHINE_ID_FALLBACK_FILE_NAME: &str = ".qbz-machine-id"; + +// Legacy XOR key for migration (only used for reading old format) +const LEGACY_OBFUSCATION_KEY: &[u8] = b"QbzNixAudiophile2024"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QobuzCredentials { + pub email: String, + pub password: String, +} + +/// Encrypted data format stored in file +#[derive(Serialize, Deserialize)] +struct EncryptedCredentials { + /// Version for future format changes + version: u8, + /// Base64-encoded nonce (12 bytes for AES-GCM) + nonce: String, + /// Base64-encoded ciphertext + ciphertext: String, +} + +/// Get the fallback credentials file path +fn get_fallback_path() -> Option { + dirs::config_dir().map(|p| p.join("qbz").join(FALLBACK_FILE_NAME)) +} + +/// Get the legacy fallback file path (for migration) +fn get_legacy_fallback_path() -> Option { + dirs::config_dir().map(|p| p.join("qbz").join(LEGACY_FALLBACK_FILE_NAME)) +} + +/// Get the per-installation salt file path used for key derivation. +fn get_installation_salt_path() -> Option { + dirs::config_dir().map(|p| p.join("qbz").join(INSTALLATION_SALT_FILE_NAME)) +} + +/// Get path for persistent fallback machine identifier. +fn get_machine_id_fallback_path() -> Option { + dirs::config_dir().map(|p| p.join("qbz").join(MACHINE_ID_FALLBACK_FILE_NAME)) +} + +/// Load a persistent installation salt, or create one on first use. +fn load_or_create_installation_salt() -> Result, String> { + let path = + get_installation_salt_path().ok_or("Could not determine config directory for salt")?; + + if path.exists() { + let encoded = + fs::read_to_string(&path).map_err(|e| format!("Failed to read salt file: {}", e))?; + let decoded = BASE64 + .decode(encoded.trim()) + .map_err(|e| format!("Failed to decode salt file: {}", e))?; + if decoded.len() != 32 { + return Err("Invalid installation salt length".to_string()); + } + return Ok(decoded); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create salt directory: {}", e))?; + } + + let mut salt = [0u8; 32]; + rand::rng().fill(&mut salt); + fs::write(&path, BASE64.encode(salt)) + .map_err(|e| format!("Failed to write installation salt: {}", e))?; + + Ok(salt.to_vec()) +} + +/// Load a persistent machine identifier fallback, or create one on first use. +fn load_or_create_machine_id_fallback() -> Result, String> { + let path = get_machine_id_fallback_path() + .ok_or("Could not determine config directory for machine fallback id")?; + + if path.exists() { + let encoded = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read machine fallback id: {}", e))?; + let decoded = BASE64 + .decode(encoded.trim()) + .map_err(|e| format!("Failed to decode machine fallback id: {}", e))?; + if decoded.len() != 32 { + return Err("Invalid machine fallback id length".to_string()); + } + return Ok(decoded); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create machine fallback directory: {}", e))?; + } + + let mut machine_fallback = [0u8; 32]; + rand::rng().fill(&mut machine_fallback); + fs::write(&path, BASE64.encode(machine_fallback)) + .map_err(|e| format!("Failed to write machine fallback id: {}", e))?; + + Ok(machine_fallback.to_vec()) +} + +/// Get machine-specific identifier for key derivation +fn get_machine_id() -> Result, String> { + // Try /etc/machine-id first (Linux) + if let Ok(id) = fs::read_to_string("/etc/machine-id") { + let trimmed = id.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.as_bytes().to_vec()); + } + } + + // Fallback to hostname + if let Ok(hostname) = std::env::var("HOSTNAME") { + if !hostname.trim().is_empty() { + return Ok(hostname.as_bytes().to_vec()); + } + } + + // Last resort: use username + if let Ok(user) = std::env::var("USER") { + if !user.trim().is_empty() { + return Ok(user.as_bytes().to_vec()); + } + } + + // Persisted random fallback for environments without stable machine/user IDs. + load_or_create_machine_id_fallback() +} + +/// Retrieve per-app secret from XDG Desktop Portal (cached for session lifetime). +/// Returns None if portal is unavailable (headless, old DEs, non-Linux). +#[cfg(target_os = "linux")] +fn get_portal_secret() -> Option> { + use std::sync::OnceLock; + static PORTAL_SECRET: OnceLock>> = OnceLock::new(); + PORTAL_SECRET + .get_or_init(|| { + let rt = tokio::runtime::Handle::try_current().ok()?; + let (tx, rx) = std::sync::mpsc::channel(); + rt.spawn(async move { + let _ = tx.send(ashpd::desktop::secret::retrieve().await.ok()); + }); + match rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(secret) => { + if secret.is_some() { + log::info!("[Credentials] Using XDG portal secret for key derivation"); + } + secret + } + Err(_) => { + log::debug!("[Credentials] XDG portal secret unavailable (timeout/missing)"); + None + } + } + }) + .clone() +} + +/// Derive encryption key from XDG portal secret + machine ID + installation salt. +/// Portal secret adds DE-agnostic, Flatpak-safe entropy when available. +fn derive_key() -> Result<[u8; 32], String> { + let machine_id = get_machine_id()?; + let installation_salt = load_or_create_installation_salt()?; + + #[cfg(target_os = "linux")] + let portal_secret = get_portal_secret(); + #[cfg(not(target_os = "linux"))] + let portal_secret: Option> = None; + + let mut hasher = Sha256::new(); + hasher.update(&installation_salt); + if let Some(ref secret) = portal_secret { + hasher.update(secret); + } + hasher.update(&machine_id); + hasher.update(&installation_salt); + + let result = hasher.finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&result); + Ok(key) +} + +/// Encrypt credentials using AES-256-GCM +fn encrypt_credentials(credentials: &QobuzCredentials) -> Result { + let key = derive_key()?; + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?; + + // Generate random nonce + let mut nonce_raw = [0u8; 12]; + rand::rng().fill(&mut nonce_raw); + let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from(nonce_raw).into(); + let nonce = Nonce::from_slice(&nonce_bytes); + + let json = serde_json::to_string(credentials) + .map_err(|e| format!("Failed to serialize credentials: {}", e))?; + + let ciphertext = cipher + .encrypt(nonce, json.as_bytes()) + .map_err(|e| format!("Encryption failed: {}", e))?; + + let encrypted = EncryptedCredentials { + version: 1, + nonce: BASE64.encode(nonce_bytes), + ciphertext: BASE64.encode(ciphertext), + }; + + serde_json::to_string(&encrypted) + .map_err(|e| format!("Failed to serialize encrypted data: {}", e)) +} + +/// Decrypt credentials using AES-256-GCM +fn decrypt_credentials(encrypted_json: &str) -> Result { + let encrypted: EncryptedCredentials = serde_json::from_str(encrypted_json) + .map_err(|e| format!("Failed to parse encrypted data: {}", e))?; + + if encrypted.version != 1 { + return Err(format!( + "Unsupported encryption version: {}", + encrypted.version + )); + } + + let key = derive_key()?; + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?; + + let nonce_bytes = BASE64 + .decode(&encrypted.nonce) + .map_err(|e| format!("Failed to decode nonce: {}", e))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = BASE64 + .decode(&encrypted.ciphertext) + .map_err(|e| format!("Failed to decode ciphertext: {}", e))?; + + let plaintext = cipher + .decrypt(nonce, ciphertext.as_ref()) + .map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?; + + let json = String::from_utf8(plaintext) + .map_err(|e| format!("Failed to decode decrypted data: {}", e))?; + + serde_json::from_str(&json).map_err(|e| format!("Failed to parse credentials: {}", e)) +} + +/// Legacy XOR deobfuscation (for migration only) +fn legacy_deobfuscate(data: &[u8]) -> Vec { + data.iter() + .enumerate() + .map(|(i, b)| b ^ LEGACY_OBFUSCATION_KEY[i % LEGACY_OBFUSCATION_KEY.len()]) + .collect() +} + +/// Try to load credentials from legacy XOR format +fn load_legacy_credentials(path: &PathBuf) -> Result, String> { + if !path.exists() { + return Ok(None); + } + + let encoded = + fs::read_to_string(path).map_err(|e| format!("Failed to read legacy file: {}", e))?; + + let obfuscated = BASE64 + .decode(encoded.trim()) + .map_err(|e| format!("Failed to decode legacy data: {}", e))?; + + let json_bytes = legacy_deobfuscate(&obfuscated); + let json = String::from_utf8(json_bytes) + .map_err(|e| format!("Failed to decode legacy credentials: {}", e))?; + + serde_json::from_str(&json) + .map_err(|e| format!("Failed to parse legacy credentials: {}", e)) + .map(Some) +} + +/// Save credentials to fallback file (AES-256-GCM encrypted) +fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> { + let path = get_fallback_path().ok_or("Could not determine config directory")?; + + // Ensure directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + + let encrypted = encrypt_credentials(credentials)?; + + fs::write(&path, encrypted).map_err(|e| format!("Failed to write credentials file: {}", e))?; + + log::info!("Credentials saved to encrypted fallback file"); + Ok(()) +} + +/// Load credentials from fallback file +fn load_from_fallback() -> Result, String> { + let path = match get_fallback_path() { + Some(p) => p, + None => return Ok(None), + }; + + if !path.exists() { + // Check for legacy file and migrate if found + if let Some(legacy_path) = get_legacy_fallback_path() { + if legacy_path.exists() { + log::info!("Found legacy credentials file, attempting migration..."); + if let Ok(Some(creds)) = load_legacy_credentials(&legacy_path) { + // Save in new format + if save_to_fallback(&creds).is_ok() { + // Remove legacy file + let _ = fs::remove_file(&legacy_path); + log::info!("Successfully migrated credentials to new encrypted format"); + return Ok(Some(creds)); + } + } + } + } + + // Also check if the current file is in legacy format (migration from old .qbz-auth) + let current_path = get_fallback_path(); + if let Some(ref p) = current_path { + if p.exists() { + // Try reading as JSON first (new format) + if let Ok(content) = fs::read_to_string(p) { + if content.trim().starts_with('{') && content.contains("\"version\"") { + // It's the new format, will be handled below + } else { + // Might be legacy format + log::info!("Attempting to read legacy format from current file..."); + if let Ok(Some(creds)) = load_legacy_credentials(p) { + // Save in new format + if save_to_fallback(&creds).is_ok() { + log::info!( + "Successfully migrated credentials to new encrypted format" + ); + return Ok(Some(creds)); + } + } + } + } + } + } + + return Ok(None); + } + + let content = + fs::read_to_string(&path).map_err(|e| format!("Failed to read credentials file: {}", e))?; + + // Check if it's the new format or legacy + if content.trim().starts_with('{') && content.contains("\"version\"") { + // New encrypted format + match decrypt_credentials(&content) { + Ok(creds) => { + log::info!("Credentials loaded from encrypted fallback file"); + Ok(Some(creds)) + } + Err(e) => { + log::warn!("Failed to decrypt credentials: {}", e); + // Try legacy format as fallback + if let Ok(Some(creds)) = load_legacy_credentials(&path) { + log::info!("Loaded from legacy format, will re-encrypt on next save"); + return Ok(Some(creds)); + } + Err(e) + } + } + } else { + // Legacy format - try to load and migrate + log::info!("Found legacy format, migrating..."); + if let Ok(Some(creds)) = load_legacy_credentials(&path) { + // Save in new format + if save_to_fallback(&creds).is_ok() { + log::info!("Successfully migrated credentials to new encrypted format"); + } + return Ok(Some(creds)); + } + Ok(None) + } +} + +/// Clear fallback credentials file +fn clear_fallback() -> Result<(), String> { + if let Some(path) = get_fallback_path() { + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove credentials file: {}", e))?; + log::info!("Fallback credentials file removed"); + } + } + // Also clear legacy file if exists + if let Some(legacy_path) = get_legacy_fallback_path() { + if legacy_path.exists() { + let _ = fs::remove_file(&legacy_path); + } + } + Ok(()) +} + +/// Check if fallback file exists +fn has_fallback_credentials() -> bool { + get_fallback_path().map(|p| p.exists()).unwrap_or(false) +} + +// ─── Keyring session state ──────────────────────────────────────────────────── +// +// Linux Secret Service (the backing store for the `keyring` crate on Linux) +// is allowed to prompt the user for a passphrase when its collection is +// locked. That prompt blocks the calling thread indefinitely. A user whose +// GNOME Keyring got out of sync with their login password sees the dialog +// appear, dismisses it (because they can't remember the old password), and +// we then re-try another keyring operation which produces the dialog again +// — and again, and again, for every keyring touch in the login flow. +// +// The defense here has two parts: +// +// 1. Each keyring call is executed on a worker thread with a hard wall-clock +// timeout. If the call hasn't returned within the timeout, we assume the +// user is staring at a dialog they can't satisfy and we give up — the +// worker thread stays blocked in the background, but our control flow +// moves on. (The worker eventually resolves when the user dismisses the +// dialog; it's a small thread leak once per broken-keyring session.) +// +// 2. The first failure or timeout latches a process-wide flag +// (`KEYRING_STATE`) that short-circuits every subsequent keyring touch. +// One prompt max per session; everything after that goes straight to the +// encrypted file. A restart is required to retry the keyring, which is +// also the point where the user's keyring might have been repaired. +// +// Errors that mean "the entry doesn't exist" (`keyring::Error::NoEntry`) +// don't count as a failure — they're just data the caller has to handle. + +const KEYRING_UNTESTED: u8 = 0; +const KEYRING_WORKING: u8 = 1; +const KEYRING_BROKEN: u8 = 2; + +static KEYRING_STATE: AtomicU8 = AtomicU8::new(KEYRING_UNTESTED); + +/// Per-operation wall-clock limit for any Secret Service / keyring call. +const KEYRING_OP_TIMEOUT: Duration = Duration::from_millis(2500); + +fn keyring_is_broken() -> bool { + KEYRING_STATE.load(Ordering::Relaxed) == KEYRING_BROKEN +} + +fn mark_keyring_broken(reason: &str) { + let previous = KEYRING_STATE.swap(KEYRING_BROKEN, Ordering::Relaxed); + if previous != KEYRING_BROKEN { + log::warn!( + "[Credentials] Disabling system keyring for the rest of this session \ + (falling back to encrypted file only): {}", + reason + ); + } +} + +fn mark_keyring_working() { + // Only promote from UNTESTED to WORKING. Never climb back out of BROKEN — + // once we've given up on the keyring for this session, stay given up. + let _ = KEYRING_STATE.compare_exchange( + KEYRING_UNTESTED, + KEYRING_WORKING, + Ordering::Relaxed, + Ordering::Relaxed, + ); +} + +/// Run a blocking keyring closure on a worker thread and return its result +/// within `KEYRING_OP_TIMEOUT`. Times out cleanly if the closure is still +/// stuck (typically because the user is looking at an unlock dialog). +fn run_with_keyring_timeout(op_name: &'static str, f: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(KEYRING_OP_TIMEOUT).map_err(|_| { + format!( + "keyring {} did not complete within {}ms (likely blocked on a user dialog)", + op_name, + KEYRING_OP_TIMEOUT.as_millis() + ) + }) +} + +/// Read a value from the keyring. Returns `None` if the entry does not exist +/// OR if the keyring is unavailable / broken for this session. The caller +/// must always have a file-based fallback ready. +fn keyring_get(key: &str) -> Option { + if keyring_is_broken() { + return None; + } + let service = SERVICE_NAME.to_string(); + let key_owned = key.to_string(); + match run_with_keyring_timeout("get", move || { + Entry::new(&service, &key_owned).and_then(|e| e.get_password()) + }) { + Ok(Ok(value)) => { + mark_keyring_working(); + Some(value) + } + Ok(Err(keyring::Error::NoEntry)) => { + mark_keyring_working(); + None + } + Ok(Err(e)) => { + mark_keyring_broken(&format!("get failed: {}", e)); + None + } + Err(reason) => { + mark_keyring_broken(&reason); + None + } + } +} + +/// Write a value to the keyring. Returns `true` on success, `false` on any +/// failure (timeout, locked collection, no backend, etc.). The caller must +/// have already persisted the value through its authoritative path (file). +fn keyring_set(key: &str, value: &str) -> bool { + if keyring_is_broken() { + return false; + } + let service = SERVICE_NAME.to_string(); + let key_owned = key.to_string(); + let value_owned = value.to_string(); + match run_with_keyring_timeout("set", move || { + Entry::new(&service, &key_owned).and_then(|e| e.set_password(&value_owned)) + }) { + Ok(Ok(())) => { + mark_keyring_working(); + true + } + Ok(Err(e)) => { + mark_keyring_broken(&format!("set failed: {}", e)); + false + } + Err(reason) => { + mark_keyring_broken(&reason); + false + } + } +} + +/// Delete a keyring entry. Silent no-op if the keyring is broken or the +/// entry already doesn't exist. +fn keyring_delete(key: &str) { + if keyring_is_broken() { + return; + } + let service = SERVICE_NAME.to_string(); + let key_owned = key.to_string(); + match run_with_keyring_timeout("delete", move || { + Entry::new(&service, &key_owned).and_then(|e| e.delete_credential()) + }) { + Ok(Ok(())) | Ok(Err(keyring::Error::NoEntry)) => { + mark_keyring_working(); + } + Ok(Err(e)) => { + log::debug!("[Credentials] Keyring delete failed (not critical): {}", e); + } + Err(reason) => { + mark_keyring_broken(&reason); + } + } +} + +/// Save Qobuz email+password credentials. +/// +/// File is authoritative: we write it first and fail the operation if that +/// fails. The keyring is a best-effort write-through cache. +pub fn save_qobuz_credentials(email: &str, password: &str) -> Result<(), String> { + log::info!("[Credentials] Saving Qobuz credentials"); + + let credentials = QobuzCredentials { + email: email.to_string(), + password: password.to_string(), + }; + + save_to_fallback(&credentials)?; + + let json = serde_json::to_string(&credentials).unwrap_or_default(); + if !json.is_empty() && keyring_set(QOBUZ_CREDENTIALS_KEY, &json) { + log::debug!("[Credentials] Qobuz credentials also saved to keyring"); + } + + Ok(()) +} + +/// Load Qobuz email+password credentials. Prefers the keyring when it +/// responds quickly, otherwise reads the encrypted fallback file. +pub fn load_qobuz_credentials() -> Result, String> { + log::debug!("[Credentials] Loading Qobuz credentials"); + + if let Some(json) = keyring_get(QOBUZ_CREDENTIALS_KEY) { + match serde_json::from_str::(&json) { + Ok(credentials) => { + log::debug!("[Credentials] Loaded Qobuz credentials from keyring"); + return Ok(Some(credentials)); + } + Err(e) => { + log::warn!( + "[Credentials] Keyring entry could not be parsed ({}), falling back to file", + e + ); + } + } + } + + load_from_fallback() +} + +/// Report whether any saved Qobuz credentials exist (keyring or file). +pub fn has_saved_credentials() -> bool { + if keyring_get(QOBUZ_CREDENTIALS_KEY).is_some() { + return true; + } + has_fallback_credentials() +} + +/// Clear saved Qobuz credentials from both the keyring and the fallback file. +pub fn clear_qobuz_credentials() -> Result<(), String> { + keyring_delete(QOBUZ_CREDENTIALS_KEY); + clear_fallback()?; + Ok(()) +} + +// ─── OAuth token persistence ────────────────────────────────────────────────── +// +// OAuth login produces a `user_auth_token` instead of email+password. +// We persist it encrypted the same way as regular credentials so the user +// doesn't have to re-authenticate via browser on every app start. +// The token is re-used at bootstrap via `POST /user/login` with the +// `X-User-Auth-Token` header. If it has expired Qobuz returns a 4xx and +// we clear the stored token so the user sees the login screen normally. + +fn get_oauth_token_path() -> Option { + dirs::config_dir().map(|p| p.join("qbz").join(OAUTH_TOKEN_FILE_NAME)) +} + +const OAUTH_TOKEN_KEY: &str = "qobuz-oauth-token"; + +/// Persist the OAuth `user_auth_token`. +/// +/// File is authoritative: the encrypted token is written to the config +/// directory unconditionally. The keyring is a best-effort write-through +/// cache — if it fails (or times out behind an unlock dialog), the login +/// flow still completes because the file is already on disk. Inverts the +/// previous keyring-first ordering, which forced a prompt on every login +/// for users with a broken Secret Service collection (issue #329). +pub fn save_oauth_token(token: &str) -> Result<(), String> { + let placeholder = QobuzCredentials { + email: token.to_string(), + password: String::new(), + }; + let encrypted = encrypt_credentials(&placeholder)?; + + let path = get_oauth_token_path().ok_or("Could not determine config directory")?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + fs::write(&path, &encrypted).map_err(|e| format!("Failed to write OAuth token file: {}", e))?; + log::info!("[Credentials] OAuth token saved to encrypted file"); + + if keyring_set(OAUTH_TOKEN_KEY, &encrypted) { + log::debug!("[Credentials] OAuth token also saved to keyring"); + } + + Ok(()) +} + +/// Load a previously saved OAuth `user_auth_token`, or `None` if absent. +/// Prefers the keyring when it responds quickly, otherwise reads the file. +pub fn load_oauth_token() -> Result, String> { + if let Some(encrypted) = keyring_get(OAUTH_TOKEN_KEY) { + if !encrypted.is_empty() { + if let Ok(placeholder) = decrypt_credentials(&encrypted) { + log::debug!("[Credentials] OAuth token loaded from keyring"); + return Ok(Some(placeholder.email)); + } + // Legacy format: pre-encryption builds stored the raw token in + // the keyring. Accept it for this one read; the next successful + // `save_oauth_token` call will rewrite it encrypted to both the + // keyring and the file. + log::debug!("[Credentials] Keyring held legacy plaintext token; will re-encrypt on next save"); + return Ok(Some(encrypted)); + } + } + + let path = match get_oauth_token_path() { + Some(p) => p, + None => return Ok(None), + }; + if !path.exists() { + return Ok(None); + } + + let content = + fs::read_to_string(&path).map_err(|e| format!("Failed to read OAuth token file: {}", e))?; + if content.trim().is_empty() { + return Ok(None); + } + + match decrypt_credentials(&content) { + Ok(placeholder) => { + log::debug!("[Credentials] OAuth token loaded from encrypted file"); + Ok(Some(placeholder.email)) + } + Err(e) => { + log::warn!("[Credentials] Failed to decrypt OAuth token file: {}", e); + Ok(None) + } + } +} + +/// Delete the stored OAuth token (logout or token expiry). +pub fn clear_oauth_token() -> Result<(), String> { + keyring_delete(OAUTH_TOKEN_KEY); + + if let Some(path) = get_oauth_token_path() { + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove OAuth token file: {}", e))?; + log::info!("[Credentials] OAuth token file cleared"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Returns true if the config directory is writable (required for encryption salt). + /// NixOS sandbox builds and CI environments lack a writable HOME. + fn has_writable_config_dir() -> bool { + // Nix build sandbox sets HOME to /homeless-shelter + if let Ok(home) = std::env::var("HOME") { + if home.contains("homeless-shelter") || home.contains("/nix/store") { + return false; + } + } + // Also skip if NIX_BUILD_TOP is set (nix-build sandbox) + if std::env::var("NIX_BUILD_TOP").is_ok() { + return false; + } + if let Some(path) = dirs::config_dir() { + let test_dir = path.join("qbz"); + if std::fs::create_dir_all(&test_dir).is_ok() { + return true; + } + } + false + } + + #[test] + fn test_encryption_roundtrip() { + // Skip in environments without a writable config dir (NixOS sandbox, CI) + if std::env::var("CI").is_ok() || !has_writable_config_dir() { + return; + } + + let credentials = QobuzCredentials { + email: "test@example.com".to_string(), + password: format!("test-pass-{}", std::process::id()), + }; + + let encrypted = encrypt_credentials(&credentials).expect("Encryption failed"); + let decrypted = decrypt_credentials(&encrypted).expect("Decryption failed"); + + assert_eq!(decrypted.email, credentials.email); + assert_eq!(decrypted.password, credentials.password); + } + + #[test] + fn test_credentials_roundtrip() { + // Skip in environments without keyring or writable config dir + if std::env::var("CI").is_ok() || !has_writable_config_dir() { + return; + } + + // Clear any stale credentials from previous runs (may have different key/salt) + let _ = clear_qobuz_credentials(); + + let email = "test@example.com"; + let password = format!("test-secret-{}", std::process::id()); + + // Save + save_qobuz_credentials(email, &password).expect("Failed to save"); + + // Load — if decryption fails due to environment issues, skip rather than panic + let loaded = match load_qobuz_credentials() { + Ok(Some(creds)) => creds, + Ok(None) => { + eprintln!("Skipping: credentials not found after save (keyring issue)"); + return; + } + Err(e) => { + eprintln!( + "Skipping: cannot load credentials in this environment: {}", + e + ); + let _ = clear_qobuz_credentials(); + return; + } + }; + + assert_eq!(loaded.email, email); + assert_eq!(loaded.password, password); + + // Clear + clear_qobuz_credentials().expect("Failed to clear"); + + // Verify cleared + let after_clear = load_qobuz_credentials().expect("Failed to check"); + assert!(after_clear.is_none()); + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 74c054397..b714d67bb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5074,6 +5074,8 @@ dependencies = [ "dirs", "filetime", "log", + "md-5", + "rusqlite", "serde", ] @@ -5124,6 +5126,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "qbz-credentials" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "ashpd", + "base64 0.22.1", + "dirs", + "keyring", + "log", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", +] + [[package]] name = "qbz-integrations" version = "0.1.0" @@ -5205,6 +5224,7 @@ dependencies = [ "qbz-cast", "qbz-cmaf", "qbz-core", + "qbz-credentials", "qbz-integrations", "qbz-library", "qbz-models", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7a4beb53a..a547bfe73 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ qbz-qobuz = { path = "../crates/qbz-qobuz" } qbz-core = { path = "../crates/qbz-core" } qbz-integrations = { path = "../crates/qbz-integrations" } qbz-cache = { path = "../crates/qbz-cache" } +qbz-credentials = { path = "../crates/qbz-credentials" } qbz-library = { path = "../crates/qbz-library" } qbz-cast = { path = "../crates/qbz-cast" } qbz-cmaf = { path = "../crates/qbz-cmaf" } diff --git a/src-tauri/src/credentials/mod.rs b/src-tauri/src/credentials/mod.rs index 2555a2b8e..423dc3f43 100644 --- a/src-tauri/src/credentials/mod.rs +++ b/src-tauri/src/credentials/mod.rs @@ -1,864 +1,8 @@ -//! Secure credential storage with fallback +//! Credential storage — Tauri-side wiring. //! -//! The encrypted AES-256-GCM file in the app config directory is the source -//! of truth for every credential. The OS keyring is used as an optional -//! best-effort cache: -//! -//! - Writes always go to the file first; the keyring is written opportunistically. -//! - Reads try the keyring first (cheaper when it works) and fall back to the file. -//! - Any keyring operation that fails or times out marks the keyring as broken -//! for the rest of the process, so later reads/writes skip it entirely. -//! -//! This matters on Linux systems where GNOME Keyring / KWallet may be locked -//! with a password that no longer matches the current session (see issue #329). -//! Without the timeout + session memoization, every login triggers a blocking -//! "unlock keyring" dialog and the user has to dismiss 3-4 prompts per session. -//! With them, the dialog appears at most once per session and the app continues -//! through the encrypted file path regardless of what the user does with it. +//! The implementation now lives in the `qbz-credentials` crate so the +//! Slint shell shares the exact same keyring + AES-256-GCM file store. +//! This module re-exports it; the Tauri app and the Slint shell read and +//! write the same credentials and OAuth token. -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, -}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use keyring::Entry; -use rand::RngExt; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU8, Ordering}; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -const SERVICE_NAME: &str = "qbz"; -const QOBUZ_CREDENTIALS_KEY: &str = "qobuz-credentials"; -const FALLBACK_FILE_NAME: &str = ".qbz-auth"; -const LEGACY_FALLBACK_FILE_NAME: &str = ".qbz-auth.legacy"; -const OAUTH_TOKEN_FILE_NAME: &str = ".qbz-oauth-token"; -const INSTALLATION_SALT_FILE_NAME: &str = ".qbz-cred-salt"; -const MACHINE_ID_FALLBACK_FILE_NAME: &str = ".qbz-machine-id"; - -// Legacy XOR key for migration (only used for reading old format) -const LEGACY_OBFUSCATION_KEY: &[u8] = b"QbzNixAudiophile2024"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QobuzCredentials { - pub email: String, - pub password: String, -} - -/// Encrypted data format stored in file -#[derive(Serialize, Deserialize)] -struct EncryptedCredentials { - /// Version for future format changes - version: u8, - /// Base64-encoded nonce (12 bytes for AES-GCM) - nonce: String, - /// Base64-encoded ciphertext - ciphertext: String, -} - -/// Get the fallback credentials file path -fn get_fallback_path() -> Option { - dirs::config_dir().map(|p| p.join("qbz").join(FALLBACK_FILE_NAME)) -} - -/// Get the legacy fallback file path (for migration) -fn get_legacy_fallback_path() -> Option { - dirs::config_dir().map(|p| p.join("qbz").join(LEGACY_FALLBACK_FILE_NAME)) -} - -/// Get the per-installation salt file path used for key derivation. -fn get_installation_salt_path() -> Option { - dirs::config_dir().map(|p| p.join("qbz").join(INSTALLATION_SALT_FILE_NAME)) -} - -/// Get path for persistent fallback machine identifier. -fn get_machine_id_fallback_path() -> Option { - dirs::config_dir().map(|p| p.join("qbz").join(MACHINE_ID_FALLBACK_FILE_NAME)) -} - -/// Load a persistent installation salt, or create one on first use. -fn load_or_create_installation_salt() -> Result, String> { - let path = - get_installation_salt_path().ok_or("Could not determine config directory for salt")?; - - if path.exists() { - let encoded = - fs::read_to_string(&path).map_err(|e| format!("Failed to read salt file: {}", e))?; - let decoded = BASE64 - .decode(encoded.trim()) - .map_err(|e| format!("Failed to decode salt file: {}", e))?; - if decoded.len() != 32 { - return Err("Invalid installation salt length".to_string()); - } - return Ok(decoded); - } - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create salt directory: {}", e))?; - } - - let mut salt = [0u8; 32]; - rand::rng().fill(&mut salt); - fs::write(&path, BASE64.encode(salt)) - .map_err(|e| format!("Failed to write installation salt: {}", e))?; - - Ok(salt.to_vec()) -} - -/// Load a persistent machine identifier fallback, or create one on first use. -fn load_or_create_machine_id_fallback() -> Result, String> { - let path = get_machine_id_fallback_path() - .ok_or("Could not determine config directory for machine fallback id")?; - - if path.exists() { - let encoded = fs::read_to_string(&path) - .map_err(|e| format!("Failed to read machine fallback id: {}", e))?; - let decoded = BASE64 - .decode(encoded.trim()) - .map_err(|e| format!("Failed to decode machine fallback id: {}", e))?; - if decoded.len() != 32 { - return Err("Invalid machine fallback id length".to_string()); - } - return Ok(decoded); - } - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create machine fallback directory: {}", e))?; - } - - let mut machine_fallback = [0u8; 32]; - rand::rng().fill(&mut machine_fallback); - fs::write(&path, BASE64.encode(machine_fallback)) - .map_err(|e| format!("Failed to write machine fallback id: {}", e))?; - - Ok(machine_fallback.to_vec()) -} - -/// Get machine-specific identifier for key derivation -fn get_machine_id() -> Result, String> { - // Try /etc/machine-id first (Linux) - if let Ok(id) = fs::read_to_string("/etc/machine-id") { - let trimmed = id.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.as_bytes().to_vec()); - } - } - - // Fallback to hostname - if let Ok(hostname) = std::env::var("HOSTNAME") { - if !hostname.trim().is_empty() { - return Ok(hostname.as_bytes().to_vec()); - } - } - - // Last resort: use username - if let Ok(user) = std::env::var("USER") { - if !user.trim().is_empty() { - return Ok(user.as_bytes().to_vec()); - } - } - - // Persisted random fallback for environments without stable machine/user IDs. - load_or_create_machine_id_fallback() -} - -/// Retrieve per-app secret from XDG Desktop Portal (cached for session lifetime). -/// Returns None if portal is unavailable (headless, old DEs, non-Linux). -#[cfg(target_os = "linux")] -fn get_portal_secret() -> Option> { - use std::sync::OnceLock; - static PORTAL_SECRET: OnceLock>> = OnceLock::new(); - PORTAL_SECRET - .get_or_init(|| { - let rt = tokio::runtime::Handle::try_current().ok()?; - let (tx, rx) = std::sync::mpsc::channel(); - rt.spawn(async move { - let _ = tx.send(ashpd::desktop::secret::retrieve().await.ok()); - }); - match rx.recv_timeout(std::time::Duration::from_secs(3)) { - Ok(secret) => { - if secret.is_some() { - log::info!("[Credentials] Using XDG portal secret for key derivation"); - } - secret - } - Err(_) => { - log::debug!("[Credentials] XDG portal secret unavailable (timeout/missing)"); - None - } - } - }) - .clone() -} - -/// Derive encryption key from XDG portal secret + machine ID + installation salt. -/// Portal secret adds DE-agnostic, Flatpak-safe entropy when available. -fn derive_key() -> Result<[u8; 32], String> { - let machine_id = get_machine_id()?; - let installation_salt = load_or_create_installation_salt()?; - - #[cfg(target_os = "linux")] - let portal_secret = get_portal_secret(); - #[cfg(not(target_os = "linux"))] - let portal_secret: Option> = None; - - let mut hasher = Sha256::new(); - hasher.update(&installation_salt); - if let Some(ref secret) = portal_secret { - hasher.update(secret); - } - hasher.update(&machine_id); - hasher.update(&installation_salt); - - let result = hasher.finalize(); - let mut key = [0u8; 32]; - key.copy_from_slice(&result); - Ok(key) -} - -/// Encrypt credentials using AES-256-GCM -fn encrypt_credentials(credentials: &QobuzCredentials) -> Result { - let key = derive_key()?; - let cipher = - Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?; - - // Generate random nonce - let mut nonce_raw = [0u8; 12]; - rand::rng().fill(&mut nonce_raw); - let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from(nonce_raw).into(); - let nonce = Nonce::from_slice(&nonce_bytes); - - let json = serde_json::to_string(credentials) - .map_err(|e| format!("Failed to serialize credentials: {}", e))?; - - let ciphertext = cipher - .encrypt(nonce, json.as_bytes()) - .map_err(|e| format!("Encryption failed: {}", e))?; - - let encrypted = EncryptedCredentials { - version: 1, - nonce: BASE64.encode(nonce_bytes), - ciphertext: BASE64.encode(ciphertext), - }; - - serde_json::to_string(&encrypted) - .map_err(|e| format!("Failed to serialize encrypted data: {}", e)) -} - -/// Decrypt credentials using AES-256-GCM -fn decrypt_credentials(encrypted_json: &str) -> Result { - let encrypted: EncryptedCredentials = serde_json::from_str(encrypted_json) - .map_err(|e| format!("Failed to parse encrypted data: {}", e))?; - - if encrypted.version != 1 { - return Err(format!( - "Unsupported encryption version: {}", - encrypted.version - )); - } - - let key = derive_key()?; - let cipher = - Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?; - - let nonce_bytes = BASE64 - .decode(&encrypted.nonce) - .map_err(|e| format!("Failed to decode nonce: {}", e))?; - let nonce = Nonce::from_slice(&nonce_bytes); - - let ciphertext = BASE64 - .decode(&encrypted.ciphertext) - .map_err(|e| format!("Failed to decode ciphertext: {}", e))?; - - let plaintext = cipher - .decrypt(nonce, ciphertext.as_ref()) - .map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?; - - let json = String::from_utf8(plaintext) - .map_err(|e| format!("Failed to decode decrypted data: {}", e))?; - - serde_json::from_str(&json).map_err(|e| format!("Failed to parse credentials: {}", e)) -} - -/// Legacy XOR deobfuscation (for migration only) -fn legacy_deobfuscate(data: &[u8]) -> Vec { - data.iter() - .enumerate() - .map(|(i, b)| b ^ LEGACY_OBFUSCATION_KEY[i % LEGACY_OBFUSCATION_KEY.len()]) - .collect() -} - -/// Try to load credentials from legacy XOR format -fn load_legacy_credentials(path: &PathBuf) -> Result, String> { - if !path.exists() { - return Ok(None); - } - - let encoded = - fs::read_to_string(path).map_err(|e| format!("Failed to read legacy file: {}", e))?; - - let obfuscated = BASE64 - .decode(encoded.trim()) - .map_err(|e| format!("Failed to decode legacy data: {}", e))?; - - let json_bytes = legacy_deobfuscate(&obfuscated); - let json = String::from_utf8(json_bytes) - .map_err(|e| format!("Failed to decode legacy credentials: {}", e))?; - - serde_json::from_str(&json) - .map_err(|e| format!("Failed to parse legacy credentials: {}", e)) - .map(Some) -} - -/// Save credentials to fallback file (AES-256-GCM encrypted) -fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> { - let path = get_fallback_path().ok_or("Could not determine config directory")?; - - // Ensure directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create config directory: {}", e))?; - } - - let encrypted = encrypt_credentials(credentials)?; - - fs::write(&path, encrypted).map_err(|e| format!("Failed to write credentials file: {}", e))?; - - log::info!("Credentials saved to encrypted fallback file"); - Ok(()) -} - -/// Load credentials from fallback file -fn load_from_fallback() -> Result, String> { - let path = match get_fallback_path() { - Some(p) => p, - None => return Ok(None), - }; - - if !path.exists() { - // Check for legacy file and migrate if found - if let Some(legacy_path) = get_legacy_fallback_path() { - if legacy_path.exists() { - log::info!("Found legacy credentials file, attempting migration..."); - if let Ok(Some(creds)) = load_legacy_credentials(&legacy_path) { - // Save in new format - if save_to_fallback(&creds).is_ok() { - // Remove legacy file - let _ = fs::remove_file(&legacy_path); - log::info!("Successfully migrated credentials to new encrypted format"); - return Ok(Some(creds)); - } - } - } - } - - // Also check if the current file is in legacy format (migration from old .qbz-auth) - let current_path = get_fallback_path(); - if let Some(ref p) = current_path { - if p.exists() { - // Try reading as JSON first (new format) - if let Ok(content) = fs::read_to_string(p) { - if content.trim().starts_with('{') && content.contains("\"version\"") { - // It's the new format, will be handled below - } else { - // Might be legacy format - log::info!("Attempting to read legacy format from current file..."); - if let Ok(Some(creds)) = load_legacy_credentials(p) { - // Save in new format - if save_to_fallback(&creds).is_ok() { - log::info!( - "Successfully migrated credentials to new encrypted format" - ); - return Ok(Some(creds)); - } - } - } - } - } - } - - return Ok(None); - } - - let content = - fs::read_to_string(&path).map_err(|e| format!("Failed to read credentials file: {}", e))?; - - // Check if it's the new format or legacy - if content.trim().starts_with('{') && content.contains("\"version\"") { - // New encrypted format - match decrypt_credentials(&content) { - Ok(creds) => { - log::info!("Credentials loaded from encrypted fallback file"); - Ok(Some(creds)) - } - Err(e) => { - log::warn!("Failed to decrypt credentials: {}", e); - // Try legacy format as fallback - if let Ok(Some(creds)) = load_legacy_credentials(&path) { - log::info!("Loaded from legacy format, will re-encrypt on next save"); - return Ok(Some(creds)); - } - Err(e) - } - } - } else { - // Legacy format - try to load and migrate - log::info!("Found legacy format, migrating..."); - if let Ok(Some(creds)) = load_legacy_credentials(&path) { - // Save in new format - if save_to_fallback(&creds).is_ok() { - log::info!("Successfully migrated credentials to new encrypted format"); - } - return Ok(Some(creds)); - } - Ok(None) - } -} - -/// Clear fallback credentials file -fn clear_fallback() -> Result<(), String> { - if let Some(path) = get_fallback_path() { - if path.exists() { - fs::remove_file(&path) - .map_err(|e| format!("Failed to remove credentials file: {}", e))?; - log::info!("Fallback credentials file removed"); - } - } - // Also clear legacy file if exists - if let Some(legacy_path) = get_legacy_fallback_path() { - if legacy_path.exists() { - let _ = fs::remove_file(&legacy_path); - } - } - Ok(()) -} - -/// Check if fallback file exists -fn has_fallback_credentials() -> bool { - get_fallback_path().map(|p| p.exists()).unwrap_or(false) -} - -// ─── Keyring session state ──────────────────────────────────────────────────── -// -// Linux Secret Service (the backing store for the `keyring` crate on Linux) -// is allowed to prompt the user for a passphrase when its collection is -// locked. That prompt blocks the calling thread indefinitely. A user whose -// GNOME Keyring got out of sync with their login password sees the dialog -// appear, dismisses it (because they can't remember the old password), and -// we then re-try another keyring operation which produces the dialog again -// — and again, and again, for every keyring touch in the login flow. -// -// The defense here has two parts: -// -// 1. Each keyring call is executed on a worker thread with a hard wall-clock -// timeout. If the call hasn't returned within the timeout, we assume the -// user is staring at a dialog they can't satisfy and we give up — the -// worker thread stays blocked in the background, but our control flow -// moves on. (The worker eventually resolves when the user dismisses the -// dialog; it's a small thread leak once per broken-keyring session.) -// -// 2. The first failure or timeout latches a process-wide flag -// (`KEYRING_STATE`) that short-circuits every subsequent keyring touch. -// One prompt max per session; everything after that goes straight to the -// encrypted file. A restart is required to retry the keyring, which is -// also the point where the user's keyring might have been repaired. -// -// Errors that mean "the entry doesn't exist" (`keyring::Error::NoEntry`) -// don't count as a failure — they're just data the caller has to handle. - -const KEYRING_UNTESTED: u8 = 0; -const KEYRING_WORKING: u8 = 1; -const KEYRING_BROKEN: u8 = 2; - -static KEYRING_STATE: AtomicU8 = AtomicU8::new(KEYRING_UNTESTED); - -/// Per-operation wall-clock limit for any Secret Service / keyring call. -const KEYRING_OP_TIMEOUT: Duration = Duration::from_millis(2500); - -fn keyring_is_broken() -> bool { - KEYRING_STATE.load(Ordering::Relaxed) == KEYRING_BROKEN -} - -fn mark_keyring_broken(reason: &str) { - let previous = KEYRING_STATE.swap(KEYRING_BROKEN, Ordering::Relaxed); - if previous != KEYRING_BROKEN { - log::warn!( - "[Credentials] Disabling system keyring for the rest of this session \ - (falling back to encrypted file only): {}", - reason - ); - } -} - -fn mark_keyring_working() { - // Only promote from UNTESTED to WORKING. Never climb back out of BROKEN — - // once we've given up on the keyring for this session, stay given up. - let _ = KEYRING_STATE.compare_exchange( - KEYRING_UNTESTED, - KEYRING_WORKING, - Ordering::Relaxed, - Ordering::Relaxed, - ); -} - -/// Run a blocking keyring closure on a worker thread and return its result -/// within `KEYRING_OP_TIMEOUT`. Times out cleanly if the closure is still -/// stuck (typically because the user is looking at an unlock dialog). -fn run_with_keyring_timeout(op_name: &'static str, f: F) -> Result -where - T: Send + 'static, - F: FnOnce() -> T + Send + 'static, -{ - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let _ = tx.send(f()); - }); - rx.recv_timeout(KEYRING_OP_TIMEOUT).map_err(|_| { - format!( - "keyring {} did not complete within {}ms (likely blocked on a user dialog)", - op_name, - KEYRING_OP_TIMEOUT.as_millis() - ) - }) -} - -/// Read a value from the keyring. Returns `None` if the entry does not exist -/// OR if the keyring is unavailable / broken for this session. The caller -/// must always have a file-based fallback ready. -fn keyring_get(key: &str) -> Option { - if keyring_is_broken() { - return None; - } - let service = SERVICE_NAME.to_string(); - let key_owned = key.to_string(); - match run_with_keyring_timeout("get", move || { - Entry::new(&service, &key_owned).and_then(|e| e.get_password()) - }) { - Ok(Ok(value)) => { - mark_keyring_working(); - Some(value) - } - Ok(Err(keyring::Error::NoEntry)) => { - mark_keyring_working(); - None - } - Ok(Err(e)) => { - mark_keyring_broken(&format!("get failed: {}", e)); - None - } - Err(reason) => { - mark_keyring_broken(&reason); - None - } - } -} - -/// Write a value to the keyring. Returns `true` on success, `false` on any -/// failure (timeout, locked collection, no backend, etc.). The caller must -/// have already persisted the value through its authoritative path (file). -fn keyring_set(key: &str, value: &str) -> bool { - if keyring_is_broken() { - return false; - } - let service = SERVICE_NAME.to_string(); - let key_owned = key.to_string(); - let value_owned = value.to_string(); - match run_with_keyring_timeout("set", move || { - Entry::new(&service, &key_owned).and_then(|e| e.set_password(&value_owned)) - }) { - Ok(Ok(())) => { - mark_keyring_working(); - true - } - Ok(Err(e)) => { - mark_keyring_broken(&format!("set failed: {}", e)); - false - } - Err(reason) => { - mark_keyring_broken(&reason); - false - } - } -} - -/// Delete a keyring entry. Silent no-op if the keyring is broken or the -/// entry already doesn't exist. -fn keyring_delete(key: &str) { - if keyring_is_broken() { - return; - } - let service = SERVICE_NAME.to_string(); - let key_owned = key.to_string(); - match run_with_keyring_timeout("delete", move || { - Entry::new(&service, &key_owned).and_then(|e| e.delete_credential()) - }) { - Ok(Ok(())) | Ok(Err(keyring::Error::NoEntry)) => { - mark_keyring_working(); - } - Ok(Err(e)) => { - log::debug!("[Credentials] Keyring delete failed (not critical): {}", e); - } - Err(reason) => { - mark_keyring_broken(&reason); - } - } -} - -/// Save Qobuz email+password credentials. -/// -/// File is authoritative: we write it first and fail the operation if that -/// fails. The keyring is a best-effort write-through cache. -pub fn save_qobuz_credentials(email: &str, password: &str) -> Result<(), String> { - log::info!("[Credentials] Saving Qobuz credentials"); - - let credentials = QobuzCredentials { - email: email.to_string(), - password: password.to_string(), - }; - - save_to_fallback(&credentials)?; - - let json = serde_json::to_string(&credentials).unwrap_or_default(); - if !json.is_empty() && keyring_set(QOBUZ_CREDENTIALS_KEY, &json) { - log::debug!("[Credentials] Qobuz credentials also saved to keyring"); - } - - Ok(()) -} - -/// Load Qobuz email+password credentials. Prefers the keyring when it -/// responds quickly, otherwise reads the encrypted fallback file. -pub fn load_qobuz_credentials() -> Result, String> { - log::debug!("[Credentials] Loading Qobuz credentials"); - - if let Some(json) = keyring_get(QOBUZ_CREDENTIALS_KEY) { - match serde_json::from_str::(&json) { - Ok(credentials) => { - log::debug!("[Credentials] Loaded Qobuz credentials from keyring"); - return Ok(Some(credentials)); - } - Err(e) => { - log::warn!( - "[Credentials] Keyring entry could not be parsed ({}), falling back to file", - e - ); - } - } - } - - load_from_fallback() -} - -/// Report whether any saved Qobuz credentials exist (keyring or file). -pub fn has_saved_credentials() -> bool { - if keyring_get(QOBUZ_CREDENTIALS_KEY).is_some() { - return true; - } - has_fallback_credentials() -} - -/// Clear saved Qobuz credentials from both the keyring and the fallback file. -pub fn clear_qobuz_credentials() -> Result<(), String> { - keyring_delete(QOBUZ_CREDENTIALS_KEY); - clear_fallback()?; - Ok(()) -} - -// ─── OAuth token persistence ────────────────────────────────────────────────── -// -// OAuth login produces a `user_auth_token` instead of email+password. -// We persist it encrypted the same way as regular credentials so the user -// doesn't have to re-authenticate via browser on every app start. -// The token is re-used at bootstrap via `POST /user/login` with the -// `X-User-Auth-Token` header. If it has expired Qobuz returns a 4xx and -// we clear the stored token so the user sees the login screen normally. - -fn get_oauth_token_path() -> Option { - dirs::config_dir().map(|p| p.join("qbz").join(OAUTH_TOKEN_FILE_NAME)) -} - -const OAUTH_TOKEN_KEY: &str = "qobuz-oauth-token"; - -/// Persist the OAuth `user_auth_token`. -/// -/// File is authoritative: the encrypted token is written to the config -/// directory unconditionally. The keyring is a best-effort write-through -/// cache — if it fails (or times out behind an unlock dialog), the login -/// flow still completes because the file is already on disk. Inverts the -/// previous keyring-first ordering, which forced a prompt on every login -/// for users with a broken Secret Service collection (issue #329). -pub fn save_oauth_token(token: &str) -> Result<(), String> { - let placeholder = QobuzCredentials { - email: token.to_string(), - password: String::new(), - }; - let encrypted = encrypt_credentials(&placeholder)?; - - let path = get_oauth_token_path().ok_or("Could not determine config directory")?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create config directory: {}", e))?; - } - fs::write(&path, &encrypted).map_err(|e| format!("Failed to write OAuth token file: {}", e))?; - log::info!("[Credentials] OAuth token saved to encrypted file"); - - if keyring_set(OAUTH_TOKEN_KEY, &encrypted) { - log::debug!("[Credentials] OAuth token also saved to keyring"); - } - - Ok(()) -} - -/// Load a previously saved OAuth `user_auth_token`, or `None` if absent. -/// Prefers the keyring when it responds quickly, otherwise reads the file. -pub fn load_oauth_token() -> Result, String> { - if let Some(encrypted) = keyring_get(OAUTH_TOKEN_KEY) { - if !encrypted.is_empty() { - if let Ok(placeholder) = decrypt_credentials(&encrypted) { - log::debug!("[Credentials] OAuth token loaded from keyring"); - return Ok(Some(placeholder.email)); - } - // Legacy format: pre-encryption builds stored the raw token in - // the keyring. Accept it for this one read; the next successful - // `save_oauth_token` call will rewrite it encrypted to both the - // keyring and the file. - log::debug!("[Credentials] Keyring held legacy plaintext token; will re-encrypt on next save"); - return Ok(Some(encrypted)); - } - } - - let path = match get_oauth_token_path() { - Some(p) => p, - None => return Ok(None), - }; - if !path.exists() { - return Ok(None); - } - - let content = - fs::read_to_string(&path).map_err(|e| format!("Failed to read OAuth token file: {}", e))?; - if content.trim().is_empty() { - return Ok(None); - } - - match decrypt_credentials(&content) { - Ok(placeholder) => { - log::debug!("[Credentials] OAuth token loaded from encrypted file"); - Ok(Some(placeholder.email)) - } - Err(e) => { - log::warn!("[Credentials] Failed to decrypt OAuth token file: {}", e); - Ok(None) - } - } -} - -/// Delete the stored OAuth token (logout or token expiry). -pub fn clear_oauth_token() -> Result<(), String> { - keyring_delete(OAUTH_TOKEN_KEY); - - if let Some(path) = get_oauth_token_path() { - if path.exists() { - fs::remove_file(&path) - .map_err(|e| format!("Failed to remove OAuth token file: {}", e))?; - log::info!("[Credentials] OAuth token file cleared"); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Returns true if the config directory is writable (required for encryption salt). - /// NixOS sandbox builds and CI environments lack a writable HOME. - fn has_writable_config_dir() -> bool { - // Nix build sandbox sets HOME to /homeless-shelter - if let Ok(home) = std::env::var("HOME") { - if home.contains("homeless-shelter") || home.contains("/nix/store") { - return false; - } - } - // Also skip if NIX_BUILD_TOP is set (nix-build sandbox) - if std::env::var("NIX_BUILD_TOP").is_ok() { - return false; - } - if let Some(path) = dirs::config_dir() { - let test_dir = path.join("qbz"); - if std::fs::create_dir_all(&test_dir).is_ok() { - return true; - } - } - false - } - - #[test] - fn test_encryption_roundtrip() { - // Skip in environments without a writable config dir (NixOS sandbox, CI) - if std::env::var("CI").is_ok() || !has_writable_config_dir() { - return; - } - - let credentials = QobuzCredentials { - email: "test@example.com".to_string(), - password: format!("test-pass-{}", std::process::id()), - }; - - let encrypted = encrypt_credentials(&credentials).expect("Encryption failed"); - let decrypted = decrypt_credentials(&encrypted).expect("Decryption failed"); - - assert_eq!(decrypted.email, credentials.email); - assert_eq!(decrypted.password, credentials.password); - } - - #[test] - fn test_credentials_roundtrip() { - // Skip in environments without keyring or writable config dir - if std::env::var("CI").is_ok() || !has_writable_config_dir() { - return; - } - - // Clear any stale credentials from previous runs (may have different key/salt) - let _ = clear_qobuz_credentials(); - - let email = "test@example.com"; - let password = format!("test-secret-{}", std::process::id()); - - // Save - save_qobuz_credentials(email, &password).expect("Failed to save"); - - // Load — if decryption fails due to environment issues, skip rather than panic - let loaded = match load_qobuz_credentials() { - Ok(Some(creds)) => creds, - Ok(None) => { - eprintln!("Skipping: credentials not found after save (keyring issue)"); - return; - } - Err(e) => { - eprintln!( - "Skipping: cannot load credentials in this environment: {}", - e - ); - let _ = clear_qobuz_credentials(); - return; - } - }; - - assert_eq!(loaded.email, email); - assert_eq!(loaded.password, password); - - // Clear - clear_qobuz_credentials().expect("Failed to clear"); - - // Verify cleared - let after_clear = load_qobuz_credentials().expect("Failed to check"); - assert!(after_clear.is_none()); - } -} +pub use qbz_credentials::*; diff --git a/src-tauri/src/image_cache.rs b/src-tauri/src/image_cache.rs index 2564ded41..3e4dc3e9a 100644 --- a/src-tauri/src/image_cache.rs +++ b/src-tauri/src/image_cache.rs @@ -1,208 +1,12 @@ -//! Image cache service +//! Image cache — Tauri-side wiring. //! -//! LRU disk cache for Qobuz album/artist images. -//! - Stores images keyed by MD5 hash of URL -//! - Tracks last-access time for LRU eviction -//! - Respects configurable max size (default 200MB) -//! - Can be disabled entirely via settings +//! The cache implementation now lives in the `qbz-cache` crate so the +//! Slint shell shares the exact same code and on-disk cache. This module +//! re-exports it and keeps the Tauri managed-state wrapper. -use md5::{Digest, Md5}; -use rusqlite::{params, Connection}; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; -/// Cache statistics returned to the frontend -#[derive(Debug, Clone, serde::Serialize)] -pub struct ImageCacheStats { - pub total_bytes: u64, - pub file_count: u64, -} - -pub struct ImageCacheService { - cache_dir: PathBuf, - conn: Connection, -} - -impl ImageCacheService { - pub fn new() -> Result { - let cache_dir = dirs::cache_dir() - .ok_or_else(|| "Could not find cache directory".to_string())? - .join("qbz") - .join("images"); - - std::fs::create_dir_all(&cache_dir) - .map_err(|e| format!("Failed to create image cache dir: {}", e))?; - - let db_path = cache_dir.join("image_cache.db"); - let conn = Connection::open(&db_path) - .map_err(|e| format!("Failed to open image cache database: {}", e))?; - - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") - .map_err(|e| format!("Failed to enable WAL: {}", e))?; - - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS cached_images ( - hash TEXT PRIMARY KEY, - url TEXT NOT NULL, - file_size INTEGER NOT NULL DEFAULT 0, - last_accessed INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_last_accessed ON cached_images (last_accessed);", - ) - .map_err(|e| format!("Failed to create image cache table: {}", e))?; - - Ok(Self { cache_dir, conn }) - } - - fn url_hash(url: &str) -> String { - let mut hasher = Md5::new(); - hasher.update(url.as_bytes()); - format!("{:x}", hasher.finalize()) - } - - fn cache_path(&self, hash: &str) -> PathBuf { - self.cache_dir.join(format!("{}.img", hash)) - } - - /// Get a cached image path, updating last-access time. - /// Returns None if not cached. - pub fn get(&self, url: &str) -> Option { - let hash = Self::url_hash(url); - let path = self.cache_path(&hash); - - if !path.exists() { - // File missing — clean up stale DB entry - let _ = self - .conn - .execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]); - return None; - } - - // Update last-accessed time - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let _ = self.conn.execute( - "UPDATE cached_images SET last_accessed = ?1 WHERE hash = ?2", - params![now, hash], - ); - - Some(path) - } - - /// Store image bytes in the cache. - /// Returns the local file path on success. - pub fn store(&self, url: &str, bytes: &[u8]) -> Result { - let hash = Self::url_hash(url); - let path = self.cache_path(&hash); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - - std::fs::write(&path, bytes).map_err(|e| format!("Failed to write cached image: {}", e))?; - - let file_size = bytes.len() as i64; - self.conn - .execute( - "INSERT OR REPLACE INTO cached_images (hash, url, file_size, last_accessed) - VALUES (?1, ?2, ?3, ?4)", - params![hash, url, file_size, now], - ) - .map_err(|e| format!("Failed to insert image cache entry: {}", e))?; - - Ok(path) - } - - /// Evict least-recently-accessed entries until total size is under max_bytes. - pub fn evict(&self, max_bytes: u64) -> Result { - let total: i64 = self - .conn - .query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM cached_images", - [], - |row| row.get(0), - ) - .map_err(|e| format!("Failed to query cache size: {}", e))?; - - if (total as u64) <= max_bytes { - return Ok(0); - } - - let mut to_free = (total as u64) - max_bytes; - let mut freed: u64 = 0; - - // Get LRU entries (oldest access first) - let mut stmt = self - .conn - .prepare("SELECT hash, file_size FROM cached_images ORDER BY last_accessed ASC") - .map_err(|e| format!("Failed to prepare eviction query: {}", e))?; - - let entries: Vec<(String, i64)> = stmt - .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) - .map_err(|e| format!("Failed to query LRU entries: {}", e))? - .filter_map(|r| r.ok()) - .collect(); - - for (hash, file_size) in entries { - if to_free == 0 { - break; - } - let path = self.cache_path(&hash); - if path.exists() { - let _ = std::fs::remove_file(&path); - } - let _ = self - .conn - .execute("DELETE FROM cached_images WHERE hash = ?1", params![hash]); - let size = file_size as u64; - freed += size; - to_free = to_free.saturating_sub(size); - } - - Ok(freed) - } - - /// Get cache statistics. - pub fn stats(&self) -> Result { - let (total_bytes, file_count): (i64, i64) = self - .conn - .query_row( - "SELECT COALESCE(SUM(file_size), 0), COUNT(*) FROM cached_images", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| format!("Failed to query image cache stats: {}", e))?; - - Ok(ImageCacheStats { - total_bytes: total_bytes as u64, - file_count: file_count as u64, - }) - } - - /// Clear the entire cache. - pub fn clear(&self) -> Result { - let stats = self.stats()?; - - // Delete all files - if let Ok(entries) = std::fs::read_dir(&self.cache_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().map(|e| e == "img").unwrap_or(false) { - let _ = std::fs::remove_file(path); - } - } - } - - // Clear database - self.conn - .execute("DELETE FROM cached_images", []) - .map_err(|e| format!("Failed to clear image cache table: {}", e))?; - - Ok(stats.total_bytes) - } -} +pub use qbz_cache::{ImageCacheService, ImageCacheStats}; pub struct ImageCacheState { pub service: Arc>>,