feat(tray): use ksni on Linux for working left-click toggle (#310)

Tauri's default tray uses libayatana-appindicator, which by design does
not dispatch primary-click events to its client — libayatana delegates
every left-click to the libdbusmenu-managed menu. The existing
show_menu_on_left_click(false) setting and TrayIconEvent::Click handler
never fire on Linux. Confirmed present in tray-icon 0.21.x and 0.22.0
(the newest release).

Switch the Linux backend to ksni, a pure-Rust StatusNotifierItem
implementation that exposes the full SNI protocol including Activate
(left-click), SecondaryActivate (middle), and ContextMenu. Same SNI
compatibility as the previous backend — works wherever the panel
already handled our tray (KDE, GNOME+AppIndicator, XFCE/Cinnamon/MATE,
wlroots trays). Flatpak just needs the --talk-name=org.kde.
StatusNotifierWatcher permission which is already in our manifest.

Tauri's tray stays on macOS (no Windows client is shipped). The
dispatch lives in tray.rs behind cfg(target_os); Linux delegates to
the new tray_linux_ksni module. Playback-control menu events
(tray:play_pause, tray:next, tray:previous) still emit into the main
webview window, so frontend handlers keep working unchanged.

cc
This commit is contained in:
vicrodh committed 2026-04-13 21:41:20 -06:00
1 parent 247a565acd
commit 10bc0eb660
5 files changed
+260 -12

No files matched your search

+21
View File
@@ -3109,6 +3109,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ksni"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc"
dependencies = [
"futures-util",
"once_cell",
"pastey",
"serde",
"tokio",
"zbus 5.14.0",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -4236,6 +4250,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -4909,6 +4929,7 @@ dependencies = [
"glob",
"image",
"keyring",
"ksni",
"lazy_static",
"lofty",
"log",
+5
View File
@@ -157,6 +157,11 @@ futures-util = "0.3"
tauri-plugin-dialog = { version = "2.7.0" }
ashpd = { version = "0.13", default-features = false, features = ["tokio", "notification", "inhibit", "secret"] }
mupdf = { version = "0.6", features = ["sys-lib-zlib", "sys-lib-harfbuzz", "sys-lib-freetype"] }
# Linux tray via StatusNotifierItem with primary-activate support.
# Tauri's built-in tray uses libayatana-appindicator which does not dispatch
# left-click events to clients (by design — primary click always shows the
# menu). ksni is a pure-Rust SNI impl that exposes Activate. See issue #310.
ksni = { version = "0.3", features = ["blocking"] }
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-dialog = { version = "2.7.0" }
+2
View File
@@ -56,6 +56,8 @@ pub mod session_store;
pub mod share;
pub mod snap;
pub mod tray;
#[cfg(target_os = "linux")]
pub mod tray_linux_ksni;
pub mod updates;
pub mod user_data;
pub mod visualizer;
+39 -12
View File
@@ -1,27 +1,31 @@
//! System tray icon implementation for QBZ
//! System tray icon implementation for QBZ.
//!
//! Provides system tray integration with playback controls and window management.
//! On Linux we use a custom [`ksni`] (StatusNotifierItem) implementation so
//! primary-click actually toggles the window (Tauri's libayatana-appindicator
//! backend cannot dispatch left-click — issue #310). On macOS we keep the
//! Tauri tray. No Windows client is shipped, so the windows cfg is absent.
#[cfg(target_os = "linux")]
use crate::tray_linux_ksni;
#[cfg(not(target_os = "linux"))]
use image::GenericImageView;
use std::path::PathBuf;
#[cfg(not(target_os = "linux"))]
use tauri::{
image::Image,
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Emitter, Manager,
Emitter, Manager,
};
use tauri::AppHandle;
// Embed tray icon at compile time (transparent background)
#[cfg(not(target_os = "linux"))]
const TRAY_ICON_PNG: &[u8] = include_bytes!("../icons/tray.png");
/// Check if running inside Flatpak sandbox
fn is_flatpak() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists()
}
/// Ensure tray icon is available in the user's icon theme directory.
/// This makes the icon discoverable by libayatana-appindicator via
/// StatusNotifierItem name lookup on DEs where pixmap data is not supported.
#[cfg(not(target_os = "linux"))]
fn ensure_tray_icon_in_theme() {
let icon_dirs = [
// Flatpak: /app has icons installed by manifest
@@ -51,7 +55,15 @@ fn ensure_tray_icon_in_theme() {
}
}
/// Check if running inside Flatpak sandbox (macOS has no Flatpak; this is
/// kept for symmetry with the older Linux path)
#[cfg(not(target_os = "linux"))]
fn is_flatpak() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists()
}
/// Get the tray icon - loads from file in Flatpak, embedded data otherwise
#[cfg(not(target_os = "linux"))]
fn load_tray_icon() -> Image<'static> {
// In Flatpak, try to use the installed icon file first
// This works better with StatusNotifierItem/libayatana-appindicator
@@ -77,9 +89,24 @@ fn load_tray_icon() -> Image<'static> {
Image::new_owned(rgba, width, height)
}
/// Initialize the system tray icon with menu
/// Initialize the system tray icon. Dispatches to the platform-specific
/// backend: ksni on Linux (see `tray_linux_ksni`), Tauri's built-in tray on
/// macOS. Falls back to a clean error on unknown targets.
pub fn init_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
log::info!("Initializing system tray icon");
#[cfg(target_os = "linux")]
{
return tray_linux_ksni::init(app);
}
#[cfg(not(target_os = "linux"))]
init_tray_tauri(app)
}
/// Tauri-backed tray implementation used on macOS. Kept as a separate fn so
/// the Linux path doesn't pay to compile the Tauri tray at all.
#[cfg(not(target_os = "linux"))]
fn init_tray_tauri(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
log::info!("Initializing system tray icon (Tauri backend)");
// Create menu items
let play_pause = MenuItem::with_id(app, "play_pause", "Play/Pause", true, None::<&str>)?;
+193
View File
@@ -0,0 +1,193 @@
//! Linux system tray via `ksni` (StatusNotifierItem).
//!
//! Tauri's default tray on Linux is backed by libayatana-appindicator, which
//! by design does not dispatch primary-click events to clients — left-click
//! always shows the menu. See issue #310.
//!
//! `ksni` is a pure-Rust SNI implementation that exposes the full protocol
//! including `Activate` (left-click), `SecondaryActivate` (middle-click) and
//! `ContextMenu` (right-click → menu). Same SNI compatibility as the old
//! tray (both need an SNI-aware panel: KDE native, GNOME w/ extension,
//! XFCE/Cinnamon/MATE/Budgie, wlroots tray widgets).
//!
//! This module runs a ksni `TrayService` in a dedicated background thread
//! (via the blocking API). All callbacks receive `&mut QbzTray` and close
//! over the Tauri `AppHandle` to emit events and manipulate the main window.
use image::GenericImageView;
use ksni::{
blocking::TrayMethods,
menu::StandardItem,
Icon, MenuItem, ToolTip, Tray,
};
use tauri::{AppHandle, Emitter, Manager};
const TRAY_ICON_PNG: &[u8] = include_bytes!("../icons/tray.png");
fn is_flatpak() -> bool {
std::env::var("FLATPAK_ID").is_ok() || std::path::Path::new("/.flatpak-info").exists()
}
/// Convert an embedded RGBA PNG to the ARGB32 big-endian layout ksni expects.
fn decode_tray_icon() -> Result<Icon, String> {
let img = image::load_from_memory(TRAY_ICON_PNG)
.map_err(|e| format!("decode tray icon: {e}"))?;
let (width, height) = img.dimensions();
let mut data = img.into_rgba8().into_vec();
// ksni spec: ARGB32 with A, R, G, B order per pixel. `image` gives us
// RGBA; rotate_right(1) moves the alpha byte from the last slot to the
// first.
for pixel in data.chunks_exact_mut(4) {
pixel.rotate_right(1);
}
Ok(Icon {
width: width as i32,
height: height as i32,
data,
})
}
struct QbzTray {
app: AppHandle,
icon: Icon,
}
impl QbzTray {
fn toggle_window(&self) {
if let Some(window) = self.app.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(false);
let is_minimized = window.is_minimized().unwrap_or(false);
if is_visible && !is_minimized {
log::info!("[tray] hiding window");
let _ = window.hide();
} else {
log::info!("[tray] showing window");
let _ = window.show();
if is_minimized {
let _ = window.unminimize();
}
let _ = window.set_focus();
}
}
}
fn emit_to_main(&self, event: &str) {
if let Some(window) = self.app.get_webview_window("main") {
let _ = window.emit(event, ());
}
}
}
impl Tray for QbzTray {
fn id(&self) -> String {
"com.blitzfc.qbz".into()
}
fn title(&self) -> String {
"QBZ".into()
}
fn icon_name(&self) -> String {
// Fall back to the themed icon name when the panel prefers it over
// pixmap data (some GNOME extensions, certain remote X11 setups).
"com.blitzfc.qbz".into()
}
fn icon_pixmap(&self) -> Vec<Icon> {
vec![self.icon.clone()]
}
fn tool_tip(&self) -> ToolTip {
ToolTip {
title: "QBZ".into(),
description: "Music Player".into(),
icon_name: String::new(),
icon_pixmap: vec![],
}
}
/// Primary click (left) — the headline feature of switching to ksni.
fn activate(&mut self, _x: i32, _y: i32) {
log::info!("[tray] primary activate (left click)");
self.toggle_window();
}
/// Secondary click (middle) — same as left for symmetry; users who were
/// middle-clicking on the old build to open the menu still get a useful
/// behavior.
fn secondary_activate(&mut self, _x: i32, _y: i32) {
log::info!("[tray] secondary activate (middle click)");
self.toggle_window();
}
fn menu(&self) -> Vec<MenuItem<Self>> {
vec![
StandardItem {
label: "Play/Pause".into(),
activate: Box::new(|this: &mut Self| this.emit_to_main("tray:play_pause")),
..Default::default()
}
.into(),
StandardItem {
label: "Next Track".into(),
activate: Box::new(|this: &mut Self| this.emit_to_main("tray:next")),
..Default::default()
}
.into(),
StandardItem {
label: "Previous Track".into(),
activate: Box::new(|this: &mut Self| this.emit_to_main("tray:previous")),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Show/Hide Window".into(),
activate: Box::new(|this: &mut Self| this.toggle_window()),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Quit QBZ".into(),
activate: Box::new(|this: &mut Self| {
log::info!("[tray] quit requested");
this.app.exit(0);
}),
..Default::default()
}
.into(),
]
}
}
/// Initialize the Linux ksni tray service. Spawns a background thread that
/// owns the SNI service; the returned Handle is intentionally leaked because
/// the tray is a singleton that lives for the app's lifetime (dropping the
/// handle would tear down the tray).
pub fn init(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
log::info!("Initializing ksni tray (Linux, SNI primary-activate enabled)");
let icon = decode_tray_icon()?;
let tray = QbzTray {
app: app.clone(),
icon,
};
// Flatpak requires disabling the well-known DBus name because the sandbox
// cannot own arbitrary bus names (Chromium and others hit the same issue).
let handle = if is_flatpak() {
log::info!("[tray] Flatpak detected — spawning ksni without DBus well-known name");
tray.disable_dbus_name(true).spawn()?
} else {
tray.spawn()?
};
// Keep the service alive for the app lifetime. We don't need to update the
// tray dynamically yet; if we do in the future (e.g., change icon when
// playing), we'd store this handle in AppState instead.
std::mem::forget(handle);
log::info!("ksni tray initialized");
Ok(())
}