fix(tray): apply tooltip updates from a dedicated thread

ksni 0.3.4 with the "blocking" feature wraps every Handle::update in
Runtime::block_on, which panics when called from inside an existing
tokio runtime — exactly where Tauri commands and our async polling
loop run. The panic was being swallowed by the runtime, so set_track
and set_playing looked like no-ops and the SNI tooltip kept rendering
the static "QBZ / Music Player / Nothing playing" fallback.

Move all updates onto a dedicated std::thread that owns the ksni
blocking handle and processes mutations from a std::sync::mpsc
channel, fully outside any tokio context.

cc
This commit is contained in:
vicrodh committed 2026-04-28 15:59:25 -06:00
1 parent 789b0262b6
commit 7714d83212
1 file changed
+90 -31
+90 -31
View File
@@ -19,7 +19,10 @@
//! into the SNI tooltip — replicating the rich tooltip the Plasma media //! into the SNI tooltip — replicating the rich tooltip the Plasma media
//! plasmoid shows on hover. //! plasmoid shows on hover.
use std::sync::{Arc, Mutex}; use std::sync::{
mpsc::{self, Sender},
Arc, Mutex,
};
use image::GenericImageView; use image::GenericImageView;
use ksni::{ use ksni::{
@@ -296,42 +299,64 @@ impl Tray for QbzTray {
} }
} }
/// Cross-thread handle to the live ksni tray. Cloneable; updates funnel /// Updates dispatched from the rest of the backend into the ksni worker.
/// through `Handle::update`, which schedules a property-changed signal so
/// SNI panels redraw the tooltip on the next hover.
/// ///
/// When the tray is disabled or failed to start, the inner option stays /// We cannot call `ksni::blocking::Handle::update` directly from a Tauri
/// `None` and every mutator becomes a no-op. /// command or from the playback poll loop — `ksni` 0.3 with `feature =
/// "blocking"` wraps every update in `Runtime::block_on`, which panics when
/// invoked from within an existing tokio runtime (which is exactly what
/// Tauri commands and `tauri::async_runtime::spawn` tasks run on). The
/// panic is silently swallowed by the runtime, so updates appear to
/// succeed but the tooltip never changes.
///
/// To avoid the nested-runtime issue we serialise updates over a
/// `std::sync::mpsc` channel and apply them from a dedicated `std::thread`
/// that lives entirely outside any tokio context.
enum TrayUpdate {
SetTrack {
title: String,
artist: String,
album: String,
},
ClearTrack,
SetPlaying(bool),
}
/// Cross-thread handle to the live ksni tray. Cloneable; mutators just
/// forward to the worker thread, so they are safe to call from any
/// async context. When the tray is disabled or failed to start, the
/// inner sender stays `None` and every mutator becomes a no-op.
#[derive(Clone)] #[derive(Clone)]
pub struct LinuxTrayHandle { pub struct LinuxTrayHandle {
inner: Arc<Mutex<Option<ksni::blocking::Handle<QbzTray>>>>, sender: Arc<Mutex<Option<Sender<TrayUpdate>>>>,
} }
impl LinuxTrayHandle { impl LinuxTrayHandle {
pub fn empty() -> Self { pub fn empty() -> Self {
Self { Self {
inner: Arc::new(Mutex::new(None)), sender: Arc::new(Mutex::new(None)),
} }
} }
fn install(&self, handle: ksni::blocking::Handle<QbzTray>) { fn install(&self, handle: ksni::blocking::Handle<QbzTray>) {
if let Ok(mut guard) = self.inner.lock() { let (tx, rx) = mpsc::channel::<TrayUpdate>();
*guard = Some(handle); std::thread::Builder::new()
} .name("qbz-tray-updater".into())
} .spawn(move || {
while let Ok(msg) = rx.recv() {
fn with<F: FnOnce(&mut QbzTray)>(&self, f: F) { match msg {
let guard = match self.inner.lock() { TrayUpdate::SetTrack {
Ok(g) => g, title,
Err(_) => return, artist,
}; album,
if let Some(handle) = guard.as_ref() { } => {
handle.update(f); log::debug!(
} "[tray] tooltip update -> {} / {} / {}",
} title,
artist,
pub fn set_track(&self, title: String, artist: String, album: String) { album
self.with(move |tray| { );
handle.update(move |tray| {
tray.now_playing = Some(NowPlaying { tray.now_playing = Some(NowPlaying {
title, title,
artist, artist,
@@ -339,19 +364,53 @@ impl LinuxTrayHandle {
}); });
}); });
} }
TrayUpdate::ClearTrack => {
pub fn clear_track(&self) { log::debug!("[tray] tooltip cleared");
self.with(|tray| { handle.update(|tray| {
tray.now_playing = None; tray.now_playing = None;
tray.is_playing = false; tray.is_playing = false;
}); });
} }
TrayUpdate::SetPlaying(is_playing) => {
pub fn set_playing(&self, is_playing: bool) { handle.update(move |tray| {
self.with(move |tray| {
tray.is_playing = is_playing; tray.is_playing = is_playing;
}); });
} }
}
}
log::debug!("[tray] updater thread exiting");
})
.expect("spawn tray updater thread");
if let Ok(mut guard) = self.sender.lock() {
*guard = Some(tx);
}
}
fn send(&self, msg: TrayUpdate) {
let guard = match self.sender.lock() {
Ok(g) => g,
Err(_) => return,
};
if let Some(tx) = guard.as_ref() {
let _ = tx.send(msg);
}
}
pub fn set_track(&self, title: String, artist: String, album: String) {
self.send(TrayUpdate::SetTrack {
title,
artist,
album,
});
}
pub fn clear_track(&self) {
self.send(TrayUpdate::ClearTrack);
}
pub fn set_playing(&self, is_playing: bool) {
self.send(TrayUpdate::SetPlaying(is_playing));
}
} }
/// Initialize the Linux ksni tray service. Spawns a background thread that /// Initialize the Linux ksni tray service. Spawns a background thread that