Add system tray with minimize-to-tray (Tartube parity 1.6)
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 <noreply@anthropic.com>
This commit is contained in:
parent
5fd63de903
commit
abe7d63794
5 changed files with 264 additions and 2 deletions
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
55
src/app.rs
55
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<crate::tray::TrayHandle>,
|
||||
/// `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<crate::tray::TrayHandle>) -> 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
|
||||
|
|
|
|||
13
src/main.rs
13
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)))),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
177
src/tray.rs
Normal file
177
src/tray.rs
Normal file
|
|
@ -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<TrayEvent>,
|
||||
}
|
||||
|
||||
/// 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<TrayEvent>,
|
||||
/// PNG bytes for the icon, decoded once at construction. ksni asks
|
||||
/// for raw ARGB so we keep both forms.
|
||||
icon_argb: Vec<u8>,
|
||||
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<ksni::Icon> {
|
||||
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<ksni::MenuItem<Self>> {
|
||||
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<TrayHandle> {
|
||||
// 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 })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue