Files
pixelpass/src/gui/tray.rs
T
molluskandClaude Opus 4.8 c30418a0f5 fix(gui/tray): track watcher loss and never strand the window
The `registered` flag was one-way: set true once the tray registered, but
never cleared if the StatusNotifierWatcher later disappeared (panel restart,
tray plugin disabled, tray app killed). After that, a Wayland close-to-tray
would destroy the window into a tray that no longer exists, with no recovery
short of SIGTERM.

Implement ksni's `watcher_online`/`watcher_offline` callbacks to keep the
shared flag in sync. On offline, also force the window back (idempotent
`show()`) so a window that was already hidden when the watcher died isn't
stranded, and return true to keep the service alive for re-registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:33:11 -04:00

258 lines
9.4 KiB
Rust

//! 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<TrayStatus>,
/// 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<AtomicBool>,
/// Last status pushed, so we don't spam D-Bus with no-op updates.
last_sent: Option<TrayStatus>,
}
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<ksni::Icon>,
/// 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<UserEvent>,
/// 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<AtomicBool>,
}
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<ksni::Icon> {
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<ksni::MenuItem<Self>> {
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<Vec<ksni::Icon>> {
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<UserEvent>) -> Option<TrayHandle> {
let icon = load_icon()?;
let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<TrayStatus>();
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,
})
}