//! System-tray (StatusNotifierItem) integration for the GUI. //! //! The tray runs on its **own dedicated thread** with its own current-thread //! tokio runtime, fully decoupled from the winit event loop (which owns the //! main thread) and from the process-wide `#[tokio::main]` runtime. It talks to //! the egui app purely over winit's event channel and a status channel: //! //! * tray → app: a [`super::UserEvent::Tray`] carrying a [`TrayAction`] //! (Show / Quit), pushed through the [`winit::event_loop::EventLoopProxy`]. //! Using the proxy (not egui's repaint) is essential: a tray click must //! wake the winit loop even when the window has been **dropped** (hidden to //! tray), so the loop can recreate it. //! * app → tray: [`TrayStatus`] (idle / hosting / viewing), pushed on change. //! //! Why a separate thread instead of `Handle::current().spawn`: updating the //! tray from the egui thread would need `block_on`, which panics when called //! from inside the running runtime. Keeping ksni's async wholly on its own //! runtime sidesteps that and keeps the frame loop non-blocking. use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use ksni::TrayMethods; use winit::event_loop::EventLoopProxy; use super::UserEvent; /// What the user picked from the tray icon or its menu (tray thread → app), /// delivered as a [`UserEvent::Tray`]. pub enum TrayAction { /// Left-click, or the "Show window" item: bring the window back. Show, /// The "Quit" item: really exit (the close button only hides to tray). Quit, } /// What the tray icon's tooltip/menu reflect (app → tray thread). #[derive(Clone, Copy, PartialEq, Eq)] pub enum TrayStatus { Idle, Hosting { active: u32, max: u32 }, Viewing, } fn status_text(status: TrayStatus) -> String { match status { TrayStatus::Idle => "Idle".to_string(), TrayStatus::Hosting { active, max } => { format!("Hosting — {active} of {max} viewer(s) connected") } TrayStatus::Viewing => "Viewing a stream".to_string(), } } /// Handle held by the egui app for the lifetime of the window. Dropping it /// closes the app→tray channel, which ends the tray thread and removes the icon. pub struct TrayHandle { status_tx: tokio::sync::mpsc::UnboundedSender, /// Set true once the tray actually registered with a StatusNotifier host. /// The app must not divert the window's close to a tray that never appeared. registered: Arc, /// Last status pushed, so we don't spam D-Bus with no-op updates. last_sent: Option, } impl TrayHandle { /// Whether a system tray is actually showing our icon. Until this is true, /// hiding the window would strand it with no way back. pub fn registered(&self) -> bool { self.registered.load(Ordering::Acquire) } /// Push a status change to the tray, deduped against the last one sent. pub fn set_status(&mut self, status: TrayStatus) { if self.last_sent != Some(status) { let _ = self.status_tx.send(status); self.last_sent = Some(status); } } } struct PixelPassTray { status: TrayStatus, /// ARGB pixmap, so the icon shows even where the themed "pixelpass" name /// can't be resolved (e.g. running the dev binary before `make install`). icon: Vec, /// Wakes the winit loop and delivers the action — works even when the /// window has been dropped to the tray (no egui frame is running then). proxy: EventLoopProxy, /// Shared with [`TrayHandle`]; kept in sync with the watcher's presence via /// the `watcher_online`/`watcher_offline` callbacks so the app never diverts /// a close to a tray that has since disappeared. registered: Arc, } impl PixelPassTray { fn notify(&self, action: TrayAction) { let _ = self.proxy.send_event(UserEvent::Tray(action)); } } impl ksni::Tray for PixelPassTray { fn id(&self) -> String { "pixelpass".to_string() } fn title(&self) -> String { "PixelPass".to_string() } // Themed icon (matches the installed hicolor/scalable/apps/pixelpass.svg); // icon_pixmap below is the always-works fallback. fn icon_name(&self) -> String { "pixelpass".to_string() } fn icon_pixmap(&self) -> Vec { self.icon.clone() } fn status(&self) -> ksni::Status { ksni::Status::Active } fn tool_tip(&self) -> ksni::ToolTip { ksni::ToolTip { title: "PixelPass".to_string(), description: status_text(self.status), icon_name: "pixelpass".to_string(), icon_pixmap: Vec::new(), } } fn activate(&mut self, _x: i32, _y: i32) { self.notify(TrayAction::Show); } /// The StatusNotifierWatcher came back (e.g. the panel restarted). Mark the /// tray live again so close-to-tray can resume hiding the window. fn watcher_online(&self) { self.registered.store(true, Ordering::Release); } /// The watcher went away (panel restart, tray plugin disabled, …). Clear the /// flag so a subsequent close quits normally instead of destroying the window /// into a tray that no longer exists, and force the window back now in case /// it was already hidden (otherwise it'd be stranded with no way to restore). /// Returning `true` keeps the service alive so it re-registers if the watcher /// returns. fn watcher_offline(&self, reason: ksni::OfflineReason) -> bool { tracing::warn!("tray: StatusNotifierWatcher offline ({reason:?}); restoring window"); self.registered.store(false, Ordering::Release); self.notify(TrayAction::Show); true } fn menu(&self) -> Vec> { use ksni::menu::{MenuItem, StandardItem}; vec![ // Non-clickable status line. StandardItem { label: status_text(self.status), enabled: false, ..Default::default() } .into(), MenuItem::Separator, StandardItem { label: "Show window".to_string(), activate: Box::new(|t: &mut Self| t.notify(TrayAction::Show)), ..Default::default() } .into(), StandardItem { label: "Quit PixelPass".to_string(), icon_name: "application-exit".to_string(), activate: Box::new(|t: &mut Self| t.notify(TrayAction::Quit)), ..Default::default() } .into(), ] } } /// Decode the embedded PNG (RGBA) and convert to the ARGB pixmap ksni wants. /// Reuses eframe's PNG decoder so we don't take a direct `image` dependency. fn load_icon() -> Option> { let icon = eframe::icon_data::from_png_bytes(include_bytes!("../../assets/pixelpass-256.png")).ok()?; let mut data = icon.rgba; // RGBA8, row-major for px in data.chunks_exact_mut(4) { px.rotate_right(1); // [r,g,b,a] -> [a,r,g,b], network byte order } Some(vec![ksni::Icon { width: icon.width as i32, height: icon.height as i32, data, }]) } /// Start the tray on its own thread. Returns a handle for the app to drive it, /// or `None` if the icon couldn't be decoded or the thread couldn't spawn (in /// which case the GUI simply runs without a tray — close behaves as before). pub fn start(proxy: EventLoopProxy) -> Option { let icon = load_icon()?; let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::(); let registered = Arc::new(AtomicBool::new(false)); let registered_thread = registered.clone(); std::thread::Builder::new() .name("pixelpass-tray".to_string()) .spawn(move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() { Ok(rt) => rt, Err(e) => { tracing::warn!("tray: could not build runtime: {e}"); return; } }; rt.block_on(async move { let tray = PixelPassTray { status: TrayStatus::Idle, icon, proxy, registered: registered_thread.clone(), }; let handle = match tray.spawn().await { Ok(handle) => handle, Err(e) => { // No StatusNotifier host (no system tray) — degrade // gracefully: the window keeps its normal close. tracing::warn!("tray: not available, running without it: {e}"); return; } }; registered_thread.store(true, Ordering::Release); // Apply status changes until the app drops its sender (on quit), // which ends this loop, the runtime, the thread, and the icon. while let Some(status) = status_rx.recv().await { let _ = handle .update(move |t: &mut PixelPassTray| t.status = status) .await; } }); }) .ok()?; Some(TrayHandle { status_tx, registered, last_sent: None, }) }