From d1082317f53e4b8264b3730ff4aed3119216c6cd Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 15 May 2026 00:04:36 -0600 Subject: [PATCH] refactor(qbz-app): add ui preference stores cx --- crates/qbz-app/src/settings/mod.rs | 2 + crates/qbz-app/src/settings/playback.rs | 451 ++++++++++++++++++++++++ crates/qbz-app/src/settings/tray.rs | 373 ++++++++++++++++++++ 3 files changed, 826 insertions(+) create mode 100644 crates/qbz-app/src/settings/playback.rs create mode 100644 crates/qbz-app/src/settings/tray.rs diff --git a/crates/qbz-app/src/settings/mod.rs b/crates/qbz-app/src/settings/mod.rs index 61d68dee3..e785fce94 100644 --- a/crates/qbz-app/src/settings/mod.rs +++ b/crates/qbz-app/src/settings/mod.rs @@ -1,4 +1,6 @@ pub mod favorites; pub mod legal; pub mod library; +pub mod playback; pub mod subscription; +pub mod tray; diff --git a/crates/qbz-app/src/settings/playback.rs b/crates/qbz-app/src/settings/playback.rs new file mode 100644 index 000000000..85bea68da --- /dev/null +++ b/crates/qbz-app/src/settings/playback.rs @@ -0,0 +1,451 @@ +//! Playback and small playback-adjacent UI preferences. +//! +//! `show_context_icon` is persisted here to preserve the existing settings +//! contract, but it is a portable UI preference, not playback domain logic. + +use log::info; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AutoplayMode { + /// Continue playing within the source (album, playlist, etc.) + #[serde(rename = "continue")] + ContinueWithinSource, + /// Play only the selected track, then stop. + #[serde(rename = "track_only")] + PlayTrackOnly, + /// Create infinite radio when queue ends (based on recent tracks). + #[serde(rename = "infinite")] + InfiniteRadio, +} + +impl Default for AutoplayMode { + fn default() -> Self { + Self::ContinueWithinSource + } +} + +impl AutoplayMode { + fn to_db_value(self) -> &'static str { + match self { + AutoplayMode::ContinueWithinSource => "continue", + AutoplayMode::PlayTrackOnly => "track_only", + AutoplayMode::InfiniteRadio => "infinite", + } + } + + fn from_db_value(value: &str) -> Self { + match value { + "track_only" => AutoplayMode::PlayTrackOnly, + "infinite" => AutoplayMode::InfiniteRadio, + _ => AutoplayMode::ContinueWithinSource, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaybackPreferences { + pub autoplay_mode: AutoplayMode, + /// Portable UI preference for showing the context-stack icon. + /// This is not domain playback behavior. + pub show_context_icon: bool, + pub persist_session: bool, + /// Sub-preference of `persist_session`. When true, restoring a + /// session also seeks to `current_position_secs` of the saved + /// track. When false (default), the saved track is shown paused at + /// 0:00 and the user starts the next listen fresh. + pub resume_playback_position: bool, +} + +impl Default for PlaybackPreferences { + fn default() -> Self { + Self { + autoplay_mode: AutoplayMode::ContinueWithinSource, + show_context_icon: false, + persist_session: false, + resume_playback_position: false, + } + } +} + +pub struct PlaybackPreferencesStore { + conn: Connection, +} + +impl PlaybackPreferencesStore { + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = dir.join(db_name); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open playback preferences database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| { + format!( + "Failed to enable WAL for playback preferences database: {}", + e + ) + })?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS playback_preferences ( + id INTEGER PRIMARY KEY CHECK (id = 1), + autoplay_mode TEXT NOT NULL DEFAULT 'continue' + );", + ) + .map_err(|e| format!("Failed to create playback preferences table: {}", e))?; + + let show_context_icon_exists = + column_exists(&conn, "playback_preferences", "show_context_icon"); + info!( + "[PlaybackPrefs] Column show_context_icon exists: {}", + show_context_icon_exists + ); + if !show_context_icon_exists { + info!("[PlaybackPrefs] Migrating: adding show_context_icon column"); + conn.execute( + "ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0", + [], + ) + .map_err(|e| format!("Failed to add show_context_icon column: {}", e))?; + info!("[PlaybackPrefs] Migration successful"); + } + + if !column_exists(&conn, "playback_preferences", "persist_session") { + info!("[PlaybackPrefs] Migrating: adding persist_session column"); + conn.execute( + "ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0", + [], + ) + .map_err(|e| format!("Failed to add persist_session column: {}", e))?; + info!("[PlaybackPrefs] persist_session migration successful"); + } + + if !column_exists(&conn, "playback_preferences", "resume_playback_position") { + info!("[PlaybackPrefs] Migrating: adding resume_playback_position column"); + conn.execute( + "ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0", + [], + ) + .map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?; + info!("[PlaybackPrefs] resume_playback_position migration successful"); + } + + conn.execute( + "INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position) + VALUES (1, 'continue', 0, 0, 0)", + [], + ) + .map_err(|e| format!("Failed to insert default preferences: {}", e))?; + + Ok(Self { conn }) + } + + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "playback_preferences.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "playback_preferences.db") + } + + pub fn get_preferences(&self) -> Result { + self.conn + .query_row( + "SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1", + [], + |row| { + let autoplay_str: String = row.get(0)?; + let show_icon: i32 = row.get(1)?; + let persist: i32 = row.get(2)?; + let resume_pos: i32 = row.get(3)?; + Ok(PlaybackPreferences { + autoplay_mode: AutoplayMode::from_db_value(&autoplay_str), + show_context_icon: show_icon != 0, + persist_session: persist != 0, + resume_playback_position: resume_pos != 0, + }) + }, + ) + .map_err(|e| format!("Failed to get playback preferences: {}", e)) + } + + pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> { + self.conn + .execute( + "UPDATE playback_preferences SET autoplay_mode = ?1 WHERE id = 1", + params![mode.to_db_value()], + ) + .map_err(|e| format!("Failed to set autoplay mode: {}", e))?; + Ok(()) + } + + pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE playback_preferences SET show_context_icon = ?1 WHERE id = 1", + params![if show { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set show context icon: {}", e))?; + Ok(()) + } + + pub fn set_persist_session(&self, persist: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE playback_preferences SET persist_session = ?1 WHERE id = 1", + params![if persist { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set persist session: {}", e))?; + Ok(()) + } + + pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1", + params![if resume { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set resume playback position: {}", e))?; + Ok(()) + } + + /// Reset all playback preferences to their default values. + pub fn reset_all(&self) -> Result { + let defaults = PlaybackPreferences::default(); + self.conn + .execute( + "UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1", + params![ + defaults.autoplay_mode.to_db_value(), + if defaults.show_context_icon { 1 } else { 0 }, + if defaults.persist_session { 1 } else { 0 }, + if defaults.resume_playback_position { 1 } else { 0 }, + ], + ) + .map_err(|e| format!("Failed to reset playback preferences: {}", e))?; + Ok(defaults) + } +} + +pub struct PlaybackPreferencesState { + pub store: Arc>>, +} + +impl PlaybackPreferencesState { + pub fn new() -> Result { + let store = PlaybackPreferencesStore::new()?; + Ok(Self { + store: Arc::new(Mutex::new(Some(store))), + }) + } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = PlaybackPreferencesStore::new_at(base_dir)?; + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + *guard = None; + Ok(()) + } + + pub fn get_preferences(&self) -> Result { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_preferences() + } + + pub fn set_autoplay_mode(&self, mode: AutoplayMode) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_autoplay_mode(mode) + } + + pub fn set_show_context_icon(&self, show: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_show_context_icon(show) + } + + pub fn set_persist_session(&self, persist: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_persist_session(persist) + } + + pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock playback preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_resume_playback_position(resume) + } +} + +fn column_exists(conn: &Connection, table: &str, column: &str) -> bool { + conn.query_row( + &format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = ?1"), + [column], + |row| { + let count: i32 = row.get(0)?; + Ok(count > 0) + }, + ) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id())) + } + + fn fresh_store(name: &str) -> (std::path::PathBuf, PlaybackPreferencesStore) { + let dir = unique_test_dir(name); + let store = PlaybackPreferencesStore::new_at(&dir).expect("open store in temp dir"); + (dir, store) + } + + #[test] + fn playback_preferences_default_values_are_stable() { + let prefs = PlaybackPreferences::default(); + + assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource); + assert!(!prefs.show_context_icon); + assert!(!prefs.persist_session); + assert!(!prefs.resume_playback_position); + } + + #[test] + fn playback_preferences_store_returns_defaults() { + let (dir, store) = fresh_store("playback-default"); + + let prefs = store.get_preferences().expect("get prefs"); + + assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource); + assert!(!prefs.show_context_icon); + assert!(!prefs.persist_session); + assert!(!prefs.resume_playback_position); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn playback_preferences_persist_all_fields() { + let dir = unique_test_dir("playback-persist"); + { + let store = PlaybackPreferencesStore::new_at(&dir).expect("open store"); + store + .set_autoplay_mode(AutoplayMode::InfiniteRadio) + .expect("set autoplay"); + store.set_show_context_icon(true).expect("set context icon"); + store + .set_persist_session(true) + .expect("set persist session"); + store + .set_resume_playback_position(true) + .expect("set resume position"); + } + + let reopened = PlaybackPreferencesStore::new_at(&dir).expect("reopen store"); + let prefs = reopened.get_preferences().expect("get prefs"); + + assert_eq!(prefs.autoplay_mode, AutoplayMode::InfiniteRadio); + assert!(prefs.show_context_icon); + assert!(prefs.persist_session); + assert!(prefs.resume_playback_position); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn playback_preferences_migrates_legacy_schema() { + let dir = unique_test_dir("playback-migrate"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let db_path = dir.join("playback_preferences.db"); + { + let conn = Connection::open(&db_path).expect("open legacy db"); + conn.execute_batch( + "CREATE TABLE playback_preferences ( + id INTEGER PRIMARY KEY CHECK (id = 1), + autoplay_mode TEXT NOT NULL DEFAULT 'continue' + ); + INSERT INTO playback_preferences (id, autoplay_mode) VALUES (1, 'track_only');", + ) + .expect("create legacy schema"); + } + + let store = PlaybackPreferencesStore::new_at(&dir).expect("migrate store"); + let prefs = store.get_preferences().expect("get prefs"); + + assert_eq!(prefs.autoplay_mode, AutoplayMode::PlayTrackOnly); + assert!(!prefs.show_context_icon); + assert!(!prefs.persist_session); + assert!(!prefs.resume_playback_position); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn playback_preferences_reset_all_preserves_existing_behavior() { + let (dir, store) = fresh_store("playback-reset"); + store + .set_autoplay_mode(AutoplayMode::InfiniteRadio) + .expect("set autoplay"); + store.set_show_context_icon(true).expect("set context icon"); + store.set_persist_session(true).expect("set persist"); + store + .set_resume_playback_position(true) + .expect("set resume position"); + + let defaults = store.reset_all().expect("reset prefs"); + let prefs = store.get_preferences().expect("get prefs"); + + assert_eq!(defaults.autoplay_mode, AutoplayMode::ContinueWithinSource); + assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource); + assert!(!prefs.show_context_icon); + assert!(!prefs.persist_session); + assert!(!prefs.resume_playback_position); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/qbz-app/src/settings/tray.rs b/crates/qbz-app/src/settings/tray.rs new file mode 100644 index 000000000..532eab5c1 --- /dev/null +++ b/crates/qbz-app/src/settings/tray.rs @@ -0,0 +1,373 @@ +//! Portable tray preference storage. +//! +//! This module owns persisted tray preferences only. Runtime tray creation, +//! icon updates, window hiding/showing, and emitted events remain in the +//! Tauri layer. + +use log::info; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraySettings { + /// Show system tray icon (requires restart to take effect). + pub enable_tray: bool, + /// Hide window to tray when clicking minimize. + pub minimize_to_tray: bool, + /// Hide window to tray instead of quitting when clicking close. + pub close_to_tray: bool, + /// Tray icon variant override: + /// - "auto" follows system color scheme, + /// - "mono-light" uses a light glyph for dark panels, + /// - "mono-dark" uses a dark glyph for light panels, + /// - "color" uses the full-color vinyl logo. + #[serde(default = "default_tray_icon_theme")] + pub tray_icon_theme: String, +} + +fn default_tray_icon_theme() -> String { + "auto".to_string() +} + +/// Coerce free-form values to the supported set. Anything outside the +/// supported list falls back to "auto". +/// +/// Legacy 1.2.9-pre values "light"/"dark" had inverted semantics +/// relative to the labels users saw. They are remapped here to the value +/// that matches the user's original intent. +pub fn normalize_tray_icon_theme(input: &str) -> String { + match input { + "mono-light" | "mono-dark" | "color" | "auto" => input.to_string(), + "light" => "mono-light".to_string(), + "dark" => "mono-dark".to_string(), + _ => "auto".to_string(), + } +} + +impl Default for TraySettings { + fn default() -> Self { + Self { + enable_tray: true, + minimize_to_tray: false, + close_to_tray: false, + tray_icon_theme: default_tray_icon_theme(), + } + } +} + +pub struct TraySettingsStore { + conn: Connection, +} + +impl TraySettingsStore { + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = dir.join(db_name); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open tray settings database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to enable WAL for tray settings database: {}", e))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS tray_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enable_tray INTEGER NOT NULL DEFAULT 1, + minimize_to_tray INTEGER NOT NULL DEFAULT 0, + close_to_tray INTEGER NOT NULL DEFAULT 0 + );", + ) + .map_err(|e| format!("Failed to create tray settings table: {}", e))?; + + let has_theme_column: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('tray_settings') WHERE name = 'tray_icon_theme'", + [], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to check tray_icon_theme column: {}", e))?; + if has_theme_column == 0 { + conn.execute_batch( + "ALTER TABLE tray_settings ADD COLUMN tray_icon_theme TEXT NOT NULL DEFAULT 'auto';", + ) + .map_err(|e| format!("Failed to add tray_icon_theme column: {}", e))?; + } + + conn.execute( + "INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme) + VALUES (1, 1, 0, 0, 'auto')", + [], + ) + .map_err(|e| format!("Failed to insert default tray settings: {}", e))?; + + info!("[TraySettings] Database initialized"); + + Ok(Self { conn }) + } + + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "tray_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "tray_settings.db") + } + + pub fn get_settings(&self) -> Result { + self.conn + .query_row( + "SELECT enable_tray, minimize_to_tray, close_to_tray, tray_icon_theme FROM tray_settings WHERE id = 1", + [], + |row| { + let enable_tray: i32 = row.get(0)?; + let minimize_to_tray: i32 = row.get(1)?; + let close_to_tray: i32 = row.get(2)?; + let tray_icon_theme: String = row.get(3)?; + Ok(TraySettings { + enable_tray: enable_tray != 0, + minimize_to_tray: minimize_to_tray != 0, + close_to_tray: close_to_tray != 0, + tray_icon_theme: normalize_tray_icon_theme(&tray_icon_theme), + }) + }, + ) + .map_err(|e| format!("Failed to get tray settings: {}", e)) + } + + pub fn set_enable_tray(&self, value: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE tray_settings SET enable_tray = ?1 WHERE id = 1", + params![if value { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set enable_tray: {}", e))?; + Ok(()) + } + + pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE tray_settings SET minimize_to_tray = ?1 WHERE id = 1", + params![if value { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set minimize_to_tray: {}", e))?; + Ok(()) + } + + pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE tray_settings SET close_to_tray = ?1 WHERE id = 1", + params![if value { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set close_to_tray: {}", e))?; + Ok(()) + } + + pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> { + let normalized = normalize_tray_icon_theme(value); + self.conn + .execute( + "UPDATE tray_settings SET tray_icon_theme = ?1 WHERE id = 1", + params![normalized], + ) + .map_err(|e| format!("Failed to set tray_icon_theme: {}", e))?; + Ok(()) + } +} + +pub struct TraySettingsState { + pub store: Arc>>, +} + +impl TraySettingsState { + pub fn new() -> Result { + let store = TraySettingsStore::new()?; + Ok(Self { + store: Arc::new(Mutex::new(Some(store))), + }) + } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = TraySettingsStore::new_at(base_dir)?; + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + *guard = None; + Ok(()) + } + + pub fn get_settings(&self) -> Result { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_settings() + } + + pub fn set_enable_tray(&self, value: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_enable_tray(value) + } + + pub fn set_minimize_to_tray(&self, value: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_minimize_to_tray(value) + } + + pub fn set_close_to_tray(&self, value: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_close_to_tray(value) + } + + pub fn set_tray_icon_theme(&self, value: &str) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|_| "Failed to lock tray settings store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_tray_icon_theme(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id())) + } + + fn fresh_store(name: &str) -> (std::path::PathBuf, TraySettingsStore) { + let dir = unique_test_dir(name); + let store = TraySettingsStore::new_at(&dir).expect("open store in temp dir"); + (dir, store) + } + + #[test] + fn tray_settings_default_values_are_stable() { + let settings = TraySettings::default(); + + assert!(settings.enable_tray); + assert!(!settings.minimize_to_tray); + assert!(!settings.close_to_tray); + assert_eq!(settings.tray_icon_theme, "auto"); + } + + #[test] + fn tray_settings_store_returns_defaults() { + let (dir, store) = fresh_store("tray-default"); + + let settings = store.get_settings().expect("get settings"); + + assert!(settings.enable_tray); + assert!(!settings.minimize_to_tray); + assert!(!settings.close_to_tray); + assert_eq!(settings.tray_icon_theme, "auto"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn tray_settings_persist_all_fields() { + let dir = unique_test_dir("tray-persist"); + { + let store = TraySettingsStore::new_at(&dir).expect("open store"); + store.set_enable_tray(false).expect("set enable"); + store.set_minimize_to_tray(true).expect("set minimize"); + store.set_close_to_tray(true).expect("set close"); + store + .set_tray_icon_theme("mono-light") + .expect("set icon theme"); + } + + let reopened = TraySettingsStore::new_at(&dir).expect("reopen store"); + let settings = reopened.get_settings().expect("get settings"); + + assert!(!settings.enable_tray); + assert!(settings.minimize_to_tray); + assert!(settings.close_to_tray); + assert_eq!(settings.tray_icon_theme, "mono-light"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn tray_icon_theme_normalizes_supported_legacy_and_unknown_values() { + assert_eq!(normalize_tray_icon_theme("auto"), "auto"); + assert_eq!(normalize_tray_icon_theme("mono-light"), "mono-light"); + assert_eq!(normalize_tray_icon_theme("mono-dark"), "mono-dark"); + assert_eq!(normalize_tray_icon_theme("color"), "color"); + assert_eq!(normalize_tray_icon_theme("light"), "mono-light"); + assert_eq!(normalize_tray_icon_theme("dark"), "mono-dark"); + assert_eq!(normalize_tray_icon_theme("invalid"), "auto"); + } + + #[test] + fn tray_settings_migrates_legacy_schema() { + let dir = unique_test_dir("tray-migrate"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let db_path = dir.join("tray_settings.db"); + { + let conn = Connection::open(&db_path).expect("open legacy db"); + conn.execute_batch( + "CREATE TABLE tray_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enable_tray INTEGER NOT NULL DEFAULT 1, + minimize_to_tray INTEGER NOT NULL DEFAULT 0, + close_to_tray INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray) + VALUES (1, 0, 1, 1);", + ) + .expect("create legacy schema"); + } + + let store = TraySettingsStore::new_at(&dir).expect("migrate store"); + let settings = store.get_settings().expect("get settings"); + + assert!(!settings.enable_tray); + assert!(settings.minimize_to_tray); + assert!(settings.close_to_tray); + assert_eq!(settings.tray_icon_theme, "auto"); + let _ = std::fs::remove_dir_all(dir); + } +}