From b260d57dc4e5dbadf17f6aa8cdb358cf12dd181c Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 26 May 2026 05:54:06 -0400 Subject: [PATCH] feat(gui): system tray with opt-in close-to-tray setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a StatusNotifierItem tray (ksni — pure-Rust over the zbus stack notify-rust already pulls; only new crate is the pastey macro helper). The icon reflects host/viewer status via its tooltip and offers Show / Quit; it runs on its own thread, channel-wired to the egui app. Add a Settings screen with a persisted toggle 'keep running in the tray when I close the window' (config.toml [gui] close_to_tray), defaulting OFF so the close button quits as users expect. When ON, closing hides to the tray on X11 / minimizes on Wayland (which has no protocol to hide a toplevel) and keeps any live stream running. If no tray is present the close behaves normally, so the window can never be stranded. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 21 ++++ Cargo.toml | 5 +- src/common/config.rs | 17 +++- src/gui/mod.rs | 148 ++++++++++++++++++++++++++- src/gui/tray.rs | 234 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 src/gui/tray.rs diff --git a/Cargo.lock b/Cargo.lock index 1c53131..b82f0c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2902,6 +2902,19 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "ksni" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc" +dependencies = [ + "futures-util", + "pastey", + "serde", + "tokio", + "zbus 5.15.0", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -4021,6 +4034,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -4149,6 +4168,7 @@ dependencies = [ "eframe", "iroh", "iroh-tickets", + "ksni", "nix 0.30.1", "notify-rust", "pipewire", @@ -7181,6 +7201,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/Cargo.toml b/Cargo.toml index e935274..60948b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,9 @@ eframe = { version = "0.34.2", default-features = false, features = ["glow", "de # Desktop notifications on viewer join/leave. Default features give the # pure-Rust zbus backend (no system libdbus, no image crate). notify-rust = { version = "4", optional = true } +# System-tray icon (StatusNotifierItem over D-Bus). Pure-Rust, riding the same +# zbus stack notify-rust already pulls — no GTK, no libappindicator/C libdbus. +ksni = { version = "0.3", optional = true } [profile.release] lto = "thin" @@ -46,4 +49,4 @@ strip = "symbols" [features] # Opt-in graphical front-end (pixelpass --gui). Default-off so the headless # build never pulls the GUI toolkit tree. -gui = ["dep:eframe", "dep:notify-rust"] +gui = ["dep:eframe", "dep:notify-rust", "dep:ksni"] diff --git a/src/common/config.rs b/src/common/config.rs index c3bd55c..d4df681 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1,8 +1,7 @@ //! Persistent user-level config at `~/.config/pixelpass/config.toml`. //! -//! Right now this only tracks the bandwidth pre-flight result. Future -//! preferences (default player, default bitrate, etc.) can hang off the -//! same file under their own `[section]`. +//! It tracks the bandwidth pre-flight result and the GUI's preferences. +//! Further settings can hang off the same file under their own `[section]`. use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; @@ -16,6 +15,18 @@ use std::path::PathBuf; pub struct Config { #[serde(default)] pub bandwidth: BandwidthEntry, + #[serde(default)] + pub gui: GuiSettings, +} + +/// Preferences for the `pixelpass --gui` front-end. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GuiSettings { + /// When true, the window's close button hides the app to the system tray + /// (keeping any live stream running) instead of quitting. Defaults to + /// false — closing quits, which is what people expect. + #[serde(default)] + pub close_to_tray: bool, } /// Result of the first-run upstream measurement. diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 02715b5..a2674b3 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -10,10 +10,12 @@ //! the GUI can be closed or crash without taking a live stream down. mod child; +mod tray; use eframe::egui; use self::child::{ChildEvent, ChildProc}; +use self::tray::{TrayAction, TrayHandle, TrayStatus}; /// Launch the GUI event loop. Blocks until the window is closed. Runs on the /// main thread (a winit requirement), which is where `main` calls it from. @@ -43,7 +45,7 @@ pub fn run() -> anyhow::Result<()> { eframe::run_native( "PixelPass", options, - Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))), + Box::new(|cc| Ok(Box::new(PixelPassApp::new(cc.egui_ctx.clone())))), ) .map_err(|e| anyhow::anyhow!("GUI failed to start: {e}")) } @@ -109,6 +111,17 @@ fn notify(summary: &'static str, body: String) { }); } +/// Persist just the close-to-tray preference, preserving the rest of the +/// on-disk config (e.g. the bandwidth section the headless child may have +/// written). Best-effort: a write failure is logged, not surfaced. +fn persist_close_to_tray(value: bool) { + let mut cfg = crate::common::config::load().unwrap_or_default(); + cfg.gui.close_to_tray = value; + if let Err(e) = crate::common::config::save(&cfg) { + tracing::warn!("failed to save settings: {e}"); + } +} + /// Which screen the single window is currently showing. #[derive(Default, PartialEq)] enum Screen { @@ -116,6 +129,7 @@ enum Screen { Menu, Host, Viewer, + Settings, } /// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality` @@ -237,24 +251,113 @@ struct PixelPassApp { screen: Screen, host: HostState, viewer: ViewerState, + /// System-tray handle; `None` if the tray couldn't start, in which case the + /// window behaves normally (no minimize-to-tray). + tray: Option, + /// winit can't truly hide a Wayland toplevel, so close-to-tray iconifies + /// there and fully hides on X11. + is_wayland: bool, + /// Set by the tray's "Quit" item so the next close really exits — the + /// window's own close button only hides to the tray. + really_quit: bool, + /// Persisted preference: when true, the close button hides to the tray + /// instead of quitting. Loaded at startup, written on toggle in Settings. + close_to_tray: bool, } impl eframe::App for PixelPassApp { // eframe 0.34 hands us the central-panel `ui` directly. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + // Tray clicks + close-to-tray come first, before any drawing. + let ctx = ui.ctx().clone(); + self.handle_tray(&ctx); // Drain any pending child events before drawing this frame. self.pump_host_events(); self.pump_viewer_events(); + // Reflect the resulting host/viewer state into the tray icon. + self.sync_tray_status(); match self.screen { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), Screen::Viewer => self.viewer(ui), + Screen::Settings => self.settings(ui), } } } impl PixelPassApp { + /// Build the app and start the tray with the live egui context (needed so + /// tray clicks can wake a hidden/minimized window). + fn new(ctx: egui::Context) -> Self { + let close_to_tray = crate::common::config::load() + .map(|c| c.gui.close_to_tray) + .unwrap_or(false); + Self { + tray: tray::start(ctx), + is_wayland: std::env::var_os("WAYLAND_DISPLAY").is_some(), + close_to_tray, + ..Default::default() + } + } + + /// Drain tray actions and divert the window's close button to the tray + /// (keeping any live host stream running) instead of quitting. + fn handle_tray(&mut self, ctx: &egui::Context) { + let actions: Vec = match &self.tray { + Some(t) => std::iter::from_fn(|| t.actions.try_recv().ok()).collect(), + None => Vec::new(), + }; + for action in actions { + match action { + TrayAction::Show => { + // Restore from whichever hide path this backend used, then + // raise. The no-op one of these two is harmless. + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + TrayAction::Quit => { + self.really_quit = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + + // Only divert the close when a tray is actually showing our icon — + // otherwise hiding would strand the window with no way to get it back. + let tray_live = self.tray.as_ref().is_some_and(TrayHandle::registered); + if tray_live + && self.close_to_tray + && !self.really_quit + && ctx.input(|i| i.viewport().close_requested()) + { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if self.is_wayland { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } else { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } + } + } + + /// Mirror current activity into the tray icon's tooltip/menu. + fn sync_tray_status(&mut self) { + let status = if self.host.proc.is_some() { + TrayStatus::Hosting { + active: self.host.active, + max: self.host.max, + } + } else if self.viewer.proc.is_some() { + TrayStatus::Viewing + } else { + TrayStatus::Idle + }; + if let Some(tray) = &mut self.tray { + tray.set_status(status); + } + } + fn menu(&mut self, ui: &mut egui::Ui) { ui.vertical_centered(|ui| { ui.add_space(24.0); @@ -283,9 +386,52 @@ impl PixelPassApp { self.screen = Screen::Viewer; self.prefill_viewer_ticket(); } + ui.add_space(20.0); + if ui.button("⚙ Settings").clicked() { + self.screen = Screen::Settings; + } }); } + fn settings(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + if ui.button("← Menu").clicked() { + self.screen = Screen::Menu; + } + ui.heading("Settings"); + }); + ui.separator(); + ui.add_space(4.0); + + let resp = ui.checkbox( + &mut self.close_to_tray, + "Keep running in the tray when I close the window", + ); + if resp.changed() { + persist_close_to_tray(self.close_to_tray); + } + + ui.add_space(4.0); + ui.label( + egui::RichText::new( + "Off: closing the window quits PixelPass.\n\ + On: closing hides it to the system tray and any active stream \ + keeps running — reopen it from the tray icon.", + ) + .small() + .weak(), + ); + + // The option does nothing without a tray to hide into; say so plainly. + if !self.tray.as_ref().is_some_and(TrayHandle::registered) { + ui.add_space(8.0); + ui.colored_label( + egui::Color32::from_rgb(220, 160, 60), + "⚠ No system tray detected — this option has no effect right now.", + ); + } + } + // ── Host screen ────────────────────────────────────────────────────── fn host(&mut self, ui: &mut egui::Ui) { diff --git a/src/gui/tray.rs b/src/gui/tray.rs new file mode 100644 index 0000000..11f33c6 --- /dev/null +++ b/src/gui/tray.rs @@ -0,0 +1,234 @@ +//! 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 channels: +//! +//! * tray → app: [`TrayAction`] (Show / Quit), polled each frame. +//! * 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 std::sync::mpsc::{Receiver, Sender}; + +use eframe::egui; +use ksni::TrayMethods; + +/// What the user picked from the tray icon or its menu (tray thread → app). +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 { + /// Menu/icon actions to drain each frame. + pub actions: Receiver, + 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, + actions: Sender, + /// Repaint the (possibly hidden/minimized) window so it wakes to act on a + /// tray click — otherwise an idle, hidden window never processes the action. + ctx: egui::Context, +} + +impl PixelPassTray { + fn notify(&self, action: TrayAction) { + let _ = self.actions.send(action); + self.ctx.request_repaint(); + } +} + +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); + } + + 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(ctx: egui::Context) -> Option { + let icon = load_icon()?; + let (action_tx, action_rx) = std::sync::mpsc::channel(); + 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, + actions: action_tx, + ctx, + }; + 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 { + actions: action_rx, + status_tx, + registered, + last_sent: None, + }) +}