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>
258 lines
9.4 KiB
Rust
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,
|
|
})
|
|
}
|