From abe7d63794b48ae098f562662d618694ebd06af3 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 27 May 2026 02:17:10 -0700 Subject: [PATCH] Add system tray with minimize-to-tray (Tartube parity 1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses ksni (StatusNotifierItem via zbus) so we keep our pure-Rust / no-GTK stack — zbus is already pulled in by notify-rust and rfd. Behavior: - Tray icon shows the bundled icon.png; left-click brings the window back to focus - Right-click menu: Show / Hide / Quit - Clicking the window's X button minimizes to tray instead of exiting - Ctrl+Q (or the tray's Quit item) actually exits Failure modes are silent and non-blocking: no DBus → app behaves as if tray flag was off. No StatusNotifier host (e.g. GNOME without the AppIndicator extension) → icon invisible but app still runs normally, and the X-button close cancellation only fires when the tray spawn returned a handle. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 20 ++++++ Cargo.toml | 1 + src/app.rs | 55 +++++++++++++++- src/main.rs | 13 +++- src/tray.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 src/tray.rs diff --git a/Cargo.lock b/Cargo.lock index 368671a..59f98aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2040,6 +2040,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 = "leb128fmt" version = "0.1.0" @@ -2731,6 +2744,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 = "percent-encoding" version = "2.3.2" @@ -5104,6 +5123,7 @@ dependencies = [ "axum", "eframe", "image", + "ksni", "notify-rust", "r2d2", "r2d2_sqlite", diff --git a/Cargo.toml b/Cargo.toml index d936adf..bc10d4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ rand = "0.8" # File-picker dialog for the desktop GUI. xdg-portal backend keeps it pure-Rust # (zbus, no GTK/libdbus build dep), consistent with notify-rust above. rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } +ksni = { version = "0.3.4", features = ["tokio"] } [profile.release] opt-level = 2 diff --git a/src/app.rs b/src/app.rs index 80abd5c..5fee580 100644 --- a/src/app.rs +++ b/src/app.rs @@ -189,6 +189,14 @@ pub struct App { /// "text" buffers so the user can type partial values (e.g. an empty /// limit-rate field) without us forcing zeroes back. channel_options_form: ChannelOptionsForm, + /// System-tray event receiver. `None` when the tray failed to start + /// (no DBus, hostile sandbox, etc.). When present, [`Self::update`] + /// drains it each frame and dispatches viewport commands. + tray: Option, + /// `true` once the user has explicitly chosen Quit (from the tray + /// menu or another exit path). The viewport `CloseRequested` handler + /// honors this — without it, the close button minimizes to tray. + quitting: bool, } /// Scratch struct for the per-channel options dialog. Distinct from @@ -209,7 +217,7 @@ struct ChannelOptionsForm { } impl App { - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>, tray: Option) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let config_path = cwd.join("config.toml"); @@ -356,6 +364,8 @@ impl App { folder_create_buffer: String::new(), show_move_to_folder: false, move_to_folder_target: None, + tray, + quitting: false, } } @@ -3040,6 +3050,49 @@ impl App { impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // ── System-tray event drain ───────────────────────────────────── + // Tray menu activations arrive on a background-thread channel. + // Drain them all each frame and translate into viewport commands. + // The user pressing the X button on the window will request a + // close; if a tray is available we intercept it and hide instead + // (minimize-to-tray). The Quit menu item sets `quitting=true` + // first so the close request is honored on the next pass. + // Ctrl+Q always quits, regardless of tray. Without this the user + // has no keyboard escape from minimize-to-tray when the tray icon + // happens to be invisible (e.g. GNOME without the AppIndicator + // extension). Checked early so it wins over close-cancellation. + if ctx.input(|i| i.modifiers.ctrl && i.key_pressed(egui::Key::Q)) { + self.quitting = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + if let Some(handle) = &self.tray { + while let Ok(evt) = handle.events.try_recv() { + match evt { + crate::tray::TrayEvent::Show => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + crate::tray::TrayEvent::Hide => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } + crate::tray::TrayEvent::Quit => { + self.quitting = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + // Minimize-to-tray: if the user clicked the close button but + // hasn't explicitly chosen Quit, cancel the close and hide + // the window instead. Without a tray we fall through to the + // normal close flow so the app actually exits. + let close_requested = ctx.input(|i| i.viewport().close_requested()); + if close_requested && !self.quitting { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } + } + self.downloader.poll(); // Snapshot the previous-frame running state BEFORE check_notifications // overwrites prev_job_states with the current frame's. Otherwise diff --git a/src/main.rs b/src/main.rs index 31ea612..e89794d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ mod platform; mod plex; mod stats; mod theme; +mod tray; mod web; mod ytdlp_bin; @@ -46,6 +47,16 @@ fn main() -> eframe::Result<()> { web::run(cfg); } + // Start the optional system tray. Returns None if the icon decode + // fails (shouldn't happen — it's embedded) or the thread won't spawn. + // A Some result does NOT guarantee a visible tray icon — that depends + // on whether a StatusNotifier host is registered (KDE: yes; GNOME: + // requires AppIndicator extension; others: varies). When no host + // exists, ksni quietly does nothing and the app behaves as if the + // tray flag were off. + const TRAY_ICON_PNG: &[u8] = include_bytes!("../icon.png"); + let tray = tray::start(TRAY_ICON_PNG); + let native_options = eframe::NativeOptions { viewport: eframe::egui::ViewportBuilder::default() .with_inner_size([1280.0, 820.0]) @@ -56,6 +67,6 @@ fn main() -> eframe::Result<()> { eframe::run_native( "yt-offline", native_options, - Box::new(|cc| Ok(Box::new(app::App::new(cc)))), + Box::new(|cc| Ok(Box::new(app::App::new(cc, tray)))), ) } diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..2633423 --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,177 @@ +//! Linux system-tray integration via StatusNotifierItem (SNI) using `ksni`. +//! +//! Why ksni rather than the cross-platform `tray-icon` crate: ksni is pure +//! zbus (no GTK dep), which matches the rest of the stack (rfd uses +//! xdg-portal, notify-rust uses zbus). On KDE the tray "just works"; on +//! GNOME it requires the AppIndicator extension; on other DEs it varies. +//! When no SNI host is registered, ksni silently does nothing — the app +//! still runs, just without a tray icon. That's the right failure mode +//! for an optional convenience feature. +//! +//! # Threading +//! +//! Ksni needs a tokio runtime. The desktop app doesn't have one (only the +//! web server starts one on demand), so we spin a dedicated current-thread +//! runtime on a background OS thread purely for the tray. Events from the +//! tray menu come back to the egui app via a [`mpsc`] channel which the +//! main loop drains each frame. +//! +//! Egui itself is *not* told about the tray; only [`App`](crate::app::App) +//! polls the [`TrayHandle`] and dispatches `ViewportCommand`s. That keeps +//! the tray entirely optional — if `TrayController::start` fails the app +//! continues normally. + +use std::sync::mpsc::{self, Receiver, Sender}; + +/// Menu events emitted by the tray. The main app drains these in +/// [`eframe::App::update`] and translates them into viewport commands or +/// an exit flag. +#[derive(Clone, Copy, Debug)] +pub enum TrayEvent { + /// Show + focus the main window. Bound to the left-click handler so a + /// single click on the icon brings the app back from "hidden to tray". + Show, + /// Hide the main window. Useful for minimize-to-tray on close. + Hide, + /// Quit the application. Wired to the menu's "Exit" item; the main + /// loop responds by sending `ViewportCommand::Close` to actually exit. + Quit, +} + +/// Owned handle returned by [`start`]. Dropping it does *not* tear down +/// the tray — it lives on the background runtime until the process exits. +/// We just hold the receiver end of the menu-event channel. +pub struct TrayHandle { + pub events: Receiver, +} + +/// Internal tray model implementing `ksni::Tray`. Each method is called +/// by ksni when the SNI host needs a property; menu activations call the +/// boxed closures we store in `MenuItem::activate`, which forward into +/// the channel back to the main thread. +struct TrayModel { + tx: Sender, + /// PNG bytes for the icon, decoded once at construction. ksni asks + /// for raw ARGB so we keep both forms. + icon_argb: Vec, + icon_w: i32, + icon_h: i32, +} + +impl ksni::Tray for TrayModel { + fn id(&self) -> String { + "yt-offline".into() + } + fn title(&self) -> String { + "yt-offline".into() + } + fn tool_tip(&self) -> ksni::ToolTip { + ksni::ToolTip { + title: "yt-offline".into(), + description: "Self-hosted archive for YouTube + friends".into(), + ..Default::default() + } + } + /// Returning a non-empty icon_pixmap takes precedence over icon_name. + /// We embed the icon so the tray works regardless of icon-theme setup. + fn icon_pixmap(&self) -> Vec { + vec![ksni::Icon { + width: self.icon_w, + height: self.icon_h, + data: self.icon_argb.clone(), + }] + } + /// Activate fires on left-click of the tray icon. Surfacing the main + /// window is the most common "what did the user mean" interpretation. + fn activate(&mut self, _x: i32, _y: i32) { + let _ = self.tx.send(TrayEvent::Show); + } + fn menu(&self) -> Vec> { + use ksni::menu::StandardItem; + vec![ + StandardItem { + label: "Show yt-offline".into(), + activate: Box::new(|m: &mut Self| { + let _ = m.tx.send(TrayEvent::Show); + }), + ..Default::default() + } + .into(), + StandardItem { + label: "Hide window".into(), + activate: Box::new(|m: &mut Self| { + let _ = m.tx.send(TrayEvent::Hide); + }), + ..Default::default() + } + .into(), + ksni::MenuItem::Separator, + StandardItem { + label: "Quit".into(), + icon_name: "application-exit".into(), + activate: Box::new(|m: &mut Self| { + let _ = m.tx.send(TrayEvent::Quit); + }), + ..Default::default() + } + .into(), + ] + } +} + +/// Spawn the tray on a dedicated background thread + tokio runtime. +/// +/// `icon_png_bytes` is decoded to ARGB once and embedded as the tray +/// pixmap. Pass the bundled `icon.png` from the binary. +/// +/// Returns `None` if the runtime / channel can't be set up. A `Some(_)` +/// return does *not* guarantee the icon is actually visible — that depends +/// on whether a StatusNotifier host is registered on the user's desktop. +pub fn start(icon_png_bytes: &[u8]) -> Option { + // Decode the PNG eagerly so we fail fast if the embedded asset is bad. + let img = image::load_from_memory(icon_png_bytes).ok()?; + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + // ksni wants ARGB (alpha first), but image gives us RGBA. Swap. + let mut argb = Vec::with_capacity(rgba.len()); + for px in rgba.chunks_exact(4) { + argb.push(px[3]); + argb.push(px[0]); + argb.push(px[1]); + argb.push(px[2]); + } + + let (tx, rx) = mpsc::channel(); + let model = TrayModel { + tx, + icon_argb: argb, + icon_w: w as i32, + icon_h: h as i32, + }; + + std::thread::Builder::new() + .name("yt-offline-tray".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + rt.block_on(async move { + use ksni::TrayMethods; + // spawn() registers the SNI service and returns a handle + // we don't need to keep alive — the service runs as a + // background task on this runtime until the process exits. + if model.spawn().await.is_err() { + // No SNI host or DBus issue. Park the thread so the + // runtime stays alive; that's harmless. + } + std::future::pending::<()>().await; + }); + }) + .ok()?; + + Some(TrayHandle { events: rx }) +}