diff --git a/src-tauri/src/commands_v2/settings.rs b/src-tauri/src/commands_v2/settings.rs index e006a9729..bb1d65ecd 100644 --- a/src-tauri/src/commands_v2/settings.rs +++ b/src-tauri/src/commands_v2/settings.rs @@ -72,6 +72,38 @@ pub fn v2_set_close_to_tray( state.set_close_to_tray(value) } +/// Update the tray icon variant ("auto" / "light" / "dark"). Persists +/// the setting and pushes the change to the live SNI tray on Linux so +/// it takes effect immediately — no restart required. +#[tauri::command] +pub fn v2_set_tray_icon_theme( + value: String, + state: State<'_, TraySettingsState>, + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] + app_handle: tauri::AppHandle, +) -> Result<(), String> { + let normalized = crate::config::tray_settings::normalize_tray_icon_theme(&value); + state.set_tray_icon_theme(&normalized)?; + + // Mirror to global startup store so the next cold start picks up + // the right variant before the user-scoped DB is reattached, same + // pattern v2_set_enable_tray uses. + if let Ok(global_store) = crate::config::tray_settings::TraySettingsStore::new() { + let _ = global_store.set_tray_icon_theme(&normalized); + } + + #[cfg(target_os = "linux")] + { + use tauri::Manager; + if let Some(tray) = + app_handle.try_state::() + { + tray.set_icon_theme(normalized); + } + } + Ok(()) +} + #[tauri::command] pub fn v2_set_autoplay_mode( mode: AutoplayMode, diff --git a/src-tauri/src/config/tray_settings.rs b/src-tauri/src/config/tray_settings.rs index 2749e1ef9..20189c427 100644 --- a/src-tauri/src/config/tray_settings.rs +++ b/src-tauri/src/config/tray_settings.rs @@ -19,6 +19,25 @@ pub struct TraySettings { 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" (default; follows system color + /// scheme), "light" (white glyph for dark panels — useful on GNOME + /// where the top bar is dark even with a light system theme), or + /// "dark" (black glyph for light panels). + #[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 +/// "auto"/"light"/"dark" falls back to "auto". +pub fn normalize_tray_icon_theme(input: &str) -> String { + match input { + "light" | "dark" | "auto" => input.to_string(), + _ => "auto".to_string(), + } } impl Default for TraySettings { @@ -27,6 +46,7 @@ impl Default for TraySettings { enable_tray: true, minimize_to_tray: false, close_to_tray: false, + tray_icon_theme: default_tray_icon_theme(), } } } @@ -58,10 +78,25 @@ impl TraySettingsStore { ) .map_err(|e| format!("Failed to create tray settings table: {}", e))?; + // Migration: add tray_icon_theme column if it doesn't exist (older DBs). + 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))?; + } + // Insert default row if it doesn't exist conn.execute( - "INSERT OR IGNORE INTO tray_settings (id, enable_tray, minimize_to_tray, close_to_tray) - VALUES (1, 1, 0, 0)", + "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))?; @@ -85,16 +120,18 @@ impl TraySettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT enable_tray, minimize_to_tray, close_to_tray FROM tray_settings WHERE id = 1", + "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), }) }, ) @@ -130,6 +167,17 @@ impl TraySettingsStore { .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(()) + } } /// Global state wrapper for thread-safe access @@ -205,6 +253,15 @@ impl TraySettingsState { 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) + } } // Tauri commands diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e347a1e1..b6d42289b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -766,6 +766,7 @@ pub fn run() { // Clone settings for use in closures let enable_tray = tray_settings.enable_tray; + let tray_icon_theme = tray_settings.tray_icon_theme.clone(); // Initialize per-user data paths (no user active yet until login) let user_data_paths = user_data::UserDataPaths::new(); @@ -937,7 +938,9 @@ pub fn run() { // Initialize system tray icon (only if enabled) if enable_tray { - if let Err(e) = tray::init_tray(app.handle()) { + if let Err(e) = + tray::init_tray(app.handle(), Some(tray_icon_theme.as_str())) + { log::error!("Failed to initialize tray icon: {}", e); } } else { @@ -1409,6 +1412,7 @@ pub fn run() { commands_v2::v2_set_enable_tray, commands_v2::v2_set_minimize_to_tray, commands_v2::v2_set_close_to_tray, + commands_v2::v2_set_tray_icon_theme, commands_v2::v2_get_tray_settings, commands_v2::v2_set_autoplay_mode, commands_v2::v2_set_show_context_icon, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 1ce258d95..2736d9aed 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -99,11 +99,16 @@ fn load_tray_icon() -> Image<'static> { /// /// On Linux this also installs the live `LinuxTrayHandle` into Tauri state /// so the rest of the backend can push live tooltip updates as the player -/// state changes. -pub fn init_tray(app: &AppHandle) -> Result<(), Box> { +/// state changes. `theme_override` is the persisted user preference for +/// the icon variant ("auto"/"light"/"dark"); ignored on macOS. +pub fn init_tray( + app: &AppHandle, + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] + theme_override: Option<&str>, +) -> Result<(), Box> { #[cfg(target_os = "linux")] { - let handle = tray_linux_ksni::init(app)?; + let handle = tray_linux_ksni::init(app, theme_override)?; app.manage(handle); return Ok(()); } diff --git a/src-tauri/src/tray_linux_ksni.rs b/src-tauri/src/tray_linux_ksni.rs index 06414e268..cced48ba1 100644 --- a/src-tauri/src/tray_linux_ksni.rs +++ b/src-tauri/src/tray_linux_ksni.rs @@ -104,11 +104,24 @@ fn decode_pixmap(bytes: &[u8]) -> Result { }) } +/// Resolve which icon variant to load. `theme_override` accepts "auto", +/// "light", "dark"; anything else falls through to system detection. The +/// override exists for desktops like GNOME where the top bar is dark even +/// when the system theme reports light, leaving auto-detected dark +/// glyphs invisible. +fn resolve_dark_tray(theme_override: Option<&str>) -> bool { + match theme_override { + Some("light") => false, + Some("dark") => true, + _ => prefer_dark_tray(), + } +} + /// Decode all monochromatic pixmap sizes for the active theme. Panels pick the /// best size from the list; supplying 22/32/44/64 covers standard SNI bar /// heights and HiDPI variants. -fn decode_tray_icons() -> Result, String> { - let sources: [&[u8]; 4] = if prefer_dark_tray() { +fn decode_tray_icons(theme_override: Option<&str>) -> Result, String> { + let sources: [&[u8]; 4] = if resolve_dark_tray(theme_override) { [TRAY_ICON_DARK_22, TRAY_ICON_DARK_32, TRAY_ICON_DARK_44, TRAY_ICON_DARK_64] } else { [TRAY_ICON_LIGHT_22, TRAY_ICON_LIGHT_32, TRAY_ICON_LIGHT_44, TRAY_ICON_LIGHT_64] @@ -320,6 +333,10 @@ enum TrayUpdate { }, ClearTrack, SetPlaying(bool), + /// Re-decode pixmaps for the requested theme override and push them + /// to the live ksni service. Triggers a `NewIcon` SNI signal so + /// panels re-fetch the icon without restart. + SetIconTheme(String), } /// Cross-thread handle to the live ksni tray. Cloneable; mutators just @@ -376,6 +393,23 @@ impl LinuxTrayHandle { tray.is_playing = is_playing; }); } + TrayUpdate::SetIconTheme(theme) => { + log::info!("[tray] icon theme override -> {}", theme); + match decode_tray_icons(Some(&theme)) { + Ok(new_icons) => { + handle.update(move |tray| { + tray.icons = new_icons; + }); + } + Err(e) => { + log::error!( + "[tray] failed to decode icons for theme '{}': {}", + theme, + e + ); + } + } + } } } log::debug!("[tray] updater thread exiting"); @@ -411,15 +445,29 @@ impl LinuxTrayHandle { pub fn set_playing(&self, is_playing: bool) { self.send(TrayUpdate::SetPlaying(is_playing)); } + + pub fn set_icon_theme(&self, theme: String) { + self.send(TrayUpdate::SetIconTheme(theme)); + } } /// Initialize the Linux ksni tray service. Spawns a background thread that /// owns the SNI service and returns a cloneable handle so live tooltip /// updates can be pushed in from the rest of the backend. -pub fn init(app: &AppHandle) -> Result> { - log::info!("Initializing ksni tray (Linux, SNI primary-activate enabled)"); +/// +/// `theme_override` is the persisted user preference: "auto" (system +/// detection), "light", or "dark". GNOME users often pick "light" because +/// its top bar is permanently dark even when the system theme isn't. +pub fn init( + app: &AppHandle, + theme_override: Option<&str>, +) -> Result> { + log::info!( + "Initializing ksni tray (Linux, SNI primary-activate enabled, theme={:?})", + theme_override + ); - let icons = decode_tray_icons()?; + let icons = decode_tray_icons(theme_override)?; let tray = QbzTray { app: app.clone(), icons,