Files
Psychotoxical-psysonic/src-tauri/src/lib_commands/sync/tray.rs
T
Frank Stellmacher 2f50a1bade fix(tray): stable tray icon id (no KDE duplicate pile-up on toggle) (#991)
* fix(tray): stable tray icon id so KDE toggle stops piling up duplicates

TrayIconBuilder::new() assigns a fresh id on every rebuild. On KDE
(StatusNotifierItem) each new id registers a new item and the stale ones
linger in the hidden-icons list, so toggling the tray icon off/on stacked
up duplicate entries. Build with a constant id so every rebuild reuses the
same item.

* docs: changelog for tray icon duplicate fix (#991)
2026-06-04 22:17:02 +02:00

505 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem};
use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager};
#[cfg(not(target_os = "windows"))]
use tauri::tray::MouseButtonState;
use crate::tray_runtime::{
tray_state_icon, TrayMenuItems, TrayMenuItemsState, TrayMenuLabels, TrayMenuLabelsState,
TrayPlaybackState, TrayState, TrayTooltip,
};
use super::super::ui::{PAUSE_RENDERING_JS, RESUME_RENDERING_JS};
use tauri::image::Image;
/// Debug builds: mirror the default app icon horizontally so the tray differs from release.
fn app_tray_icon(app: &tauri::AppHandle) -> Image<'static> {
let icon = app.default_window_icon().expect("default window icon");
#[cfg(debug_assertions)]
{
flip_image_horizontal(icon)
}
#[cfg(not(debug_assertions))]
{
icon.clone().to_owned()
}
}
#[cfg(debug_assertions)]
fn flip_image_horizontal(icon: &Image<'_>) -> Image<'static> {
let width = icon.width();
let height = icon.height();
let mut rgba = icon.rgba().to_vec();
flip_rgba_horizontal(&mut rgba, width, height);
Image::new_owned(rgba, width, height)
}
#[cfg(debug_assertions)]
fn flip_rgba_horizontal(rgba: &mut [u8], width: u32, height: u32) {
let w = width as usize;
let h = height as usize;
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return;
}
for y in 0..h {
let row = y * w * 4;
for x in 0..w / 2 {
let l = row + x * 4;
let r = row + (w - 1 - x) * 4;
let left = [
rgba[l],
rgba[l + 1],
rgba[l + 2],
rgba[l + 3],
];
let right = [
rgba[r],
rgba[r + 1],
rgba[r + 2],
rgba[r + 3],
];
rgba[l..l + 4].copy_from_slice(&right);
rgba[r..r + 4].copy_from_slice(&left);
}
}
}
/// Stable tray-icon id. Without a fixed id, `TrayIconBuilder::new()` assigns a
/// fresh id on every rebuild; on KDE (StatusNotifierItem) each new id registers
/// a new item, and KDE keeps the stale ones in its "hidden icons" list — so
/// toggling the tray off/on piled up duplicate entries (Asra, 1.47-RC4). A
/// constant id makes every rebuild reuse the same item.
const TRAY_ICON_ID: &str = "psysonic-tray";
pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let labels = app
.try_state::<TrayMenuLabelsState>()
.map(|s| s.lock().unwrap().clone())
.unwrap_or_default();
let play_pause = MenuItemBuilder::with_id("play_pause", &labels.play_pause).build(app)?;
let next = MenuItemBuilder::with_id("next", &labels.next).build(app)?;
let previous = MenuItemBuilder::with_id("previous", &labels.previous).build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", &labels.show_hide).build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", &labels.quit).build(app)?;
let cached_tooltip = app
.try_state::<TrayTooltip>()
.and_then(|s| {
let g = s.lock().ok()?;
if g.is_empty() { None } else { Some(g.clone()) }
})
.unwrap_or_else(|| "Psysonic".to_string());
#[cfg(any(target_os = "windows", target_os = "linux"))]
let playback_state = app
.try_state::<TrayPlaybackState>()
.map(|s| s.0.lock().unwrap().clone())
.unwrap_or_else(|| "stop".to_string());
#[cfg(target_os = "windows")]
let tooltip_with_icon = format!("{} {}", tray_state_icon(&playback_state), cached_tooltip);
// Linux/AppIndicator has no hover tooltip; surface the now-playing track as
// a disabled menu entry at the top instead. The label is updated by
// `set_tray_tooltip` on every track change.
#[cfg(target_os = "linux")]
let (now_playing, sep_now_playing) = {
let icon = tray_state_icon(&playback_state);
let label = if cached_tooltip == "Psysonic" {
format!("{icon} {}", labels.nothing_playing)
} else {
format!("{icon} {cached_tooltip}")
};
let item = MenuItemBuilder::with_id("now_playing", &label)
.enabled(false)
.build(app)?;
(item, PredefinedMenuItem::separator(app)?)
};
#[cfg(target_os = "linux")]
let menu_builder = MenuBuilder::new(app)
.item(&now_playing)
.item(&sep_now_playing);
#[cfg(not(target_os = "linux"))]
let menu_builder = MenuBuilder::new(app);
let menu = menu_builder
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
// Persist handles so set_tray_menu_labels and set_tray_tooltip can update
// them without rebuilding the whole tray icon.
if let Some(state) = app.try_state::<TrayMenuItemsState>() {
*state.lock().unwrap() = Some(TrayMenuItems {
play_pause: play_pause.clone(),
next: next.clone(),
previous: previous.clone(),
show_hide: show_hide.clone(),
quit: quit.clone(),
#[cfg(target_os = "linux")]
now_playing: Some(now_playing.clone()),
#[cfg(not(target_os = "linux"))]
now_playing: None,
});
}
#[cfg(target_os = "windows")]
let tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID)
.icon(app_tray_icon(app))
.menu(&menu)
.tooltip(&tooltip_with_icon)
// tray-icon defaults to opening the context menu on every WM_LBUTTONUP when this is true.
// A left double-click emits Down, Up, DoubleClick, Up — the final Up re-opens the menu right
// after we hide the window from DoubleClick. We only use left double-click for show/hide
// (see on_tray_icon_event); keep the menu on right-click like typical Windows tray apps.
.show_menu_on_left_click(false);
#[cfg(not(target_os = "windows"))]
let tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID)
.icon(app_tray_icon(app))
.menu(&menu)
.tooltip(&cached_tooltip);
tray_builder
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.eval(PAUSE_RENDERING_JS);
let _ = win.hide();
} else {
let _ = win.eval(RESUME_RENDERING_JS);
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { let _ = app.emit("app:force-quit", ()); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Windows fires a Click on *every* half of a double-click, so a
// double-click would toggle the window visibility twice and end up
// back where it started (the bug #cucadmuh reported). Switch to the
// Windows-only DoubleClick event there and ignore single clicks;
// that matches the standard Windows tray convention (Discord, etc).
#[cfg(target_os = "windows")]
let should_toggle = matches!(
event,
TrayIconEvent::DoubleClick { button: MouseButton::Left, .. }
);
#[cfg(not(target_os = "windows"))]
let should_toggle = matches!(
event,
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
);
if should_toggle {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.eval(PAUSE_RENDERING_JS);
let _ = win.hide();
} else {
let _ = win.eval(RESUME_RENDERING_JS);
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)
}
/// Creates the tray icon, or `None` if the OS cannot host one.
///
/// On Linux, `libayatana-appindicator3` / `libappindicator3` may be absent (minimal
/// installs, wrong `LD_LIBRARY_PATH`). The `tray-icon` stack can **panic** on `dlopen`
/// failure instead of returning `Err`, so we catch unwind and keep the app running
/// (e.g. cold start with `--player` still works without tray libraries).
pub(crate) fn try_build_tray_icon(app: &tauri::AppHandle) -> Option<TrayIcon> {
let app = app.clone();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) {
Ok(Ok(tray)) => Some(tray),
Ok(Err(e)) => {
crate::app_eprintln!("[Psysonic] System tray unavailable: {e}");
None
}
Err(_) => {
crate::app_eprintln!(
"[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \
(install the distro package or set LD_LIBRARY_PATH)"
);
None
}
}
}
/// Updates the system-tray icon tooltip with the currently playing track.
///
/// `tooltip` should be a compact "Artist Title" form (no app suffix needed —
/// the tray icon itself identifies the app). An empty string resets to the
/// default `"Psysonic"` tooltip.
///
/// The text is truncated to 127 chars defensively to stay under the historical
/// Windows `NOTIFYICONDATA.szTip` limit (128 bytes including the null terminator).
/// On Linux the visibility depends on the desktop environment / panel —
/// StatusNotifierItem-aware panels (KDE, Cinnamon, GNOME with AppIndicator
/// extension) show it; pure-GNOME without the extension does not.
#[tauri::command]
pub(crate) fn set_tray_tooltip(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
tooltip_cache: tauri::State<TrayTooltip>,
playback_state_cache: tauri::State<TrayPlaybackState>,
tooltip: String,
playback_state: Option<String>,
) -> Result<(), String> {
let has_track_input = !tooltip.is_empty();
let state = playback_state.as_deref().unwrap_or(if has_track_input { "play" } else { "stop" });
let icon = tray_state_icon(state);
let icon_prefix_len = format!("{icon} ").chars().count();
let max_text_chars = 127usize.saturating_sub(icon_prefix_len);
let ellipsis_reserve = 3usize;
let truncated = if tooltip.chars().count() > max_text_chars {
let take = max_text_chars.saturating_sub(ellipsis_reserve);
tooltip.chars().take(take).collect::<String>() + "..."
} else {
tooltip
};
let has_track = !truncated.is_empty();
let effective = if has_track { truncated.clone() } else { "Psysonic".to_string() };
#[cfg(target_os = "windows")]
let effective_with_icon = format!("{icon} {effective}");
*tooltip_cache.lock().unwrap() = truncated.clone();
*playback_state_cache.0.lock().unwrap() = state.to_string();
if let Some(tray) = tray_state.lock().unwrap().as_ref() {
#[cfg(target_os = "windows")]
tray.set_tooltip(Some(&effective_with_icon)).map_err(|e| e.to_string())?;
#[cfg(not(target_os = "windows"))]
tray.set_tooltip(Some(&effective)).map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{
if let Some(state) = app.try_state::<TrayMenuItemsState>() {
if let Some(items) = state.lock().unwrap().as_ref() {
if let Some(np) = items.now_playing.as_ref() {
let label = if has_track {
format!("{icon} {effective}")
} else {
let nothing = app.try_state::<TrayMenuLabelsState>()
.map(|s| s.lock().unwrap().nothing_playing.clone())
.unwrap_or_else(|| "Nothing playing".to_string());
format!("{icon} {nothing}")
};
let _ = np.set_text(&label);
}
}
}
}
#[cfg(not(target_os = "linux"))]
let _ = &app;
Ok(())
}
/// Pushes localized labels into the tray menu. Called from the frontend on
/// startup and whenever the i18n language changes. Updates are applied
/// immediately to live menu items via `set_text` (no tray rebuild required)
/// and cached so the labels survive a tray hide/show cycle.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub(crate) fn set_tray_menu_labels(
app: tauri::AppHandle,
labels_state: tauri::State<TrayMenuLabelsState>,
items_state: tauri::State<TrayMenuItemsState>,
tooltip_cache: tauri::State<TrayTooltip>,
play_pause: String,
next: String,
previous: String,
show_hide: String,
quit: String,
nothing_playing: String,
) -> Result<(), String> {
let new_labels = TrayMenuLabels {
play_pause,
next,
previous,
show_hide,
quit,
nothing_playing,
};
*labels_state.lock().unwrap() = new_labels.clone();
if let Some(items) = items_state.lock().unwrap().as_ref() {
let _ = items.play_pause.set_text(&new_labels.play_pause);
let _ = items.next.set_text(&new_labels.next);
let _ = items.previous.set_text(&new_labels.previous);
let _ = items.show_hide.set_text(&new_labels.show_hide);
let _ = items.quit.set_text(&new_labels.quit);
// Linux now-playing item: only refresh the placeholder. The track
// text itself is owned by `set_tray_tooltip` and shouldn't be
// overwritten by an unrelated language change.
#[cfg(target_os = "linux")]
if let Some(np) = items.now_playing.as_ref() {
let has_track = !tooltip_cache.lock().unwrap().is_empty();
if !has_track {
let state = app
.try_state::<TrayPlaybackState>()
.map(|s| s.0.lock().unwrap().clone())
.unwrap_or_else(|| "stop".to_string());
let label = format!("{} {}", tray_state_icon(&state), new_labels.nothing_playing);
let _ = np.set_text(&label);
}
}
}
let _ = (&app, &tooltip_cache);
Ok(())
}
/// Show (`true`) or fully remove (`false`) the system-tray icon.
///
/// The command is strictly idempotent:
/// - `show=true` when the icon is already present → no-op (prevents duplicate icons).
/// - `show=false` when the icon is already absent → no-op.
///
/// For removal, `set_visible(false)` is called explicitly before the handle is
/// dropped because some platforms (Windows notification area, certain Linux DEs)
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
pub(crate) fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
show: bool,
) -> Result<(), String> {
let mut guard = tray_state.lock().unwrap();
if show {
// Early-return when already shown — never build a second icon.
if guard.is_some() {
return Ok(());
}
let Some(tray) = try_build_tray_icon(&app) else {
return Err(
"Tray icon could not be created (missing system libraries on Linux).".into(),
);
};
*guard = Some(tray);
} else if let Some(tray) = guard.take() {
// Hide synchronously before dropping so the OS processes the removal
// before any subsequent show=true call can create a new icon.
let _ = tray.set_visible(false);
// `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem).
}
Ok(())
}
pub(crate) use crate::audio::stop_audio_engine;
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
/// set by the compositor / DE.
#[cfg(target_os = "linux")]
pub(crate) fn is_tiling_wm() -> bool {
// Direct compositor signatures (most reliable).
let direct = [
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
"SWAYSOCK", // Sway
"I3SOCK", // i3
]
.iter()
.any(|&var| std::env::var_os(var).is_some());
if direct {
return true;
}
// Check XDG_CURRENT_DESKTOP for known tiling WMs.
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = desktop.to_lowercase();
let tiling_wms = [
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
];
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
return true;
}
}
false
}
/// Tauri command: returns true when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set.
/// The frontend uses this to apply a CSS class that swaps out GPU-only effects
/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents.
#[tauri::command]
pub(crate) fn no_compositing_mode() -> bool {
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
.unwrap_or(false)
}
/// Tauri command: `XDG_SESSION_TYPE` from the host environment (e.g. `wayland`, `x11`).
/// Used for Linux-only UI tweaks such as font rasterisation hints; empty string when unset.
#[tauri::command]
pub(crate) fn linux_xdg_session_type() -> String {
#[cfg(target_os = "linux")]
{
std::env::var("XDG_SESSION_TYPE").unwrap_or_default()
}
#[cfg(not(target_os = "linux"))]
{
String::new()
}
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn is_tiling_wm() -> bool {
false
}
/// Tauri command: lets the frontend know whether we're running under a tiling
/// WM so it can decide whether to render the custom TitleBar component.
#[tauri::command]
pub(crate) fn is_tiling_wm_cmd() -> bool {
is_tiling_wm()
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
#[test]
fn flip_rgba_horizontal_mirrors_pixels() {
// 3×1: A B C → C B A
let mut rgba = vec![
1, 0, 0, 255, // A
2, 0, 0, 255, // B
3, 0, 0, 255, // C
];
flip_rgba_horizontal(&mut rgba, 3, 1);
assert_eq!(rgba[0], 3);
assert_eq!(rgba[4], 2);
assert_eq!(rgba[8], 1);
}
}