feat(tray): persisted icon theme override with live update on Linux

Auto-detected dark vs light tray glyph fails on GNOME, whose top bar
is permanently dark even when the system color scheme is light, so
the dark glyph disappears against it. Add a persisted user override
("auto" / "light" / "dark") that the SNI tray respects at startup
and on the fly.

Backend pieces:
- TraySettings gains tray_icon_theme: String with "auto" default and
  a normalize_tray_icon_theme() helper that clamps unknown values
  back to "auto". SQLite migration ALTERs the table to add the
  column when an older DB is opened.
- tray_linux_ksni::decode_tray_icons takes an optional override and
  resolve_dark_tray() consults it before falling back to the
  existing GNOME/GTK/KDE auto-detection. init() forwards it.
- LinuxTrayHandle gains set_icon_theme(theme); a new
  TrayUpdate::SetIconTheme variant flows through the dedicated
  worker thread so re-decoding plus handle.update() happen outside
  the tokio runtime (same panic-avoidance pattern used for
  tooltip updates).
- v2_set_tray_icon_theme persists the value, mirrors it to the
  global startup store, and pushes it to the live tray handle so
  the icon flips without restart.

cc
This commit is contained in:
vicrodh committed 2026-04-28 19:02:46 -06:00
1 parent bf81814a65
commit ee713e3f0c
5 files changed
+158 -12

No files matched your search

+32
View File
@@ -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::<crate::tray_linux_ksni::LinuxTrayHandle>()
{
tray.set_icon_theme(normalized);
}
}
Ok(())
}
#[tauri::command]
pub fn v2_set_autoplay_mode(
mode: AutoplayMode,
+60 -3
View File
@@ -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<TraySettings, String> {
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
+5 -1
View File
@@ -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,
+8 -3
View File
@@ -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<dyn std::error::Error>> {
/// 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<dyn std::error::Error>> {
#[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(());
}
+53 -5
View File
@@ -104,11 +104,24 @@ fn decode_pixmap(bytes: &[u8]) -> Result<Icon, String> {
})
}
/// 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<Vec<Icon>, String> {
let sources: [&[u8]; 4] = if prefer_dark_tray() {
fn decode_tray_icons(theme_override: Option<&str>) -> Result<Vec<Icon>, 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<LinuxTrayHandle, Box<dyn std::error::Error>> {
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<LinuxTrayHandle, Box<dyn std::error::Error>> {
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,