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
+86 -27
+86 -27
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,61 +299,117 @@ 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() {
match msg {
TrayUpdate::SetTrack {
title,
artist,
album,
} => {
log::debug!(
"[tray] tooltip update -> {} / {} / {}",
title,
artist,
album
);
handle.update(move |tray| {
tray.now_playing = Some(NowPlaying {
title,
artist,
album,
});
});
}
TrayUpdate::ClearTrack => {
log::debug!("[tray] tooltip cleared");
handle.update(|tray| {
tray.now_playing = None;
tray.is_playing = false;
});
}
TrayUpdate::SetPlaying(is_playing) => {
handle.update(move |tray| {
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 with<F: FnOnce(&mut QbzTray)>(&self, f: F) { fn send(&self, msg: TrayUpdate) {
let guard = match self.inner.lock() { let guard = match self.sender.lock() {
Ok(g) => g, Ok(g) => g,
Err(_) => return, Err(_) => return,
}; };
if let Some(handle) = guard.as_ref() { if let Some(tx) = guard.as_ref() {
handle.update(f); let _ = tx.send(msg);
} }
} }
pub fn set_track(&self, title: String, artist: String, album: String) { pub fn set_track(&self, title: String, artist: String, album: String) {
self.with(move |tray| { self.send(TrayUpdate::SetTrack {
tray.now_playing = Some(NowPlaying { title,
title, artist,
artist, album,
album,
});
}); });
} }
pub fn clear_track(&self) { pub fn clear_track(&self) {
self.with(|tray| { self.send(TrayUpdate::ClearTrack);
tray.now_playing = None;
tray.is_playing = false;
});
} }
pub fn set_playing(&self, is_playing: bool) { pub fn set_playing(&self, is_playing: bool) {
self.with(move |tray| { self.send(TrayUpdate::SetPlaying(is_playing));
tray.is_playing = is_playing;
});
} }
} }