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:
5 files changed
+260
-12
No files matched your search
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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>)?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user