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:
Luna 2026-05-27 02:17:10 -07:00
parent 5fd63de903
commit abe7d63794
5 changed files with 264 additions and 2 deletions

View file

@ -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