From 714522d577b95b64497a44bf396b71263957eb77 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:35:06 +0200 Subject: [PATCH] Quit from the tray even when the window is minimized and gets no frames (#77) --- apps/artcraft-launcher/src/main.rs | 57 +++++++++++++++++++++++++++--- apps/artcraft-launcher/src/tray.rs | 6 +++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs index 3d915f0..6014fc7 100644 --- a/apps/artcraft-launcher/src/main.rs +++ b/apps/artcraft-launcher/src/main.rs @@ -12,6 +12,7 @@ mod tray; use std::path::PathBuf; use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, channel, sync_channel}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use std::time::Duration; @@ -45,7 +46,7 @@ fn main() -> ExitCode { Instance::Primary(rx) => rx, }; let (tray_loop, woken) = sync_channel(1); - let waker = Arc::new(Waker { window: Mutex::new(None), tray_loop }); + let waker = Arc::new(Waker { window: Mutex::new(None), tray_loop, quit_pending: AtomicBool::new(false) }); { let waker = waker.clone(); let _ = wake.set(Box::new(move || waker.wake())); @@ -173,8 +174,13 @@ fn wgpu_options() -> eframe::WgpuConfiguration { struct Waker { window: Mutex>, tray_loop: SyncSender<()>, + /// Quit was picked in the tray and no tick has taken it up yet. + quit_pending: AtomicBool, } +/// How long a tray Quit may wait for the window to take it up before the launcher exits anyway. +const QUIT_GRACE: Duration = Duration::from_secs(2); + impl Waker { fn wake(&self) { if let Some(ctx) = self.window.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { @@ -183,6 +189,30 @@ impl Waker { let _ = self.tray_loop.try_send(()); } + /// Quit was picked in the tray. A minimized window may get no frames at all (Wayland sends none), + /// so the quit would wait until the window is restored: if no tick has taken it up in time, exit. + fn quit_from_tray(self: &Arc) { + self.quit_pending.store(true, Ordering::SeqCst); + let waker = self.clone(); + std::thread::spawn(move || { + std::thread::sleep(QUIT_GRACE); + if waker.quit_unseen() { + log::warn!("the window did not answer a quit from the tray: exiting"); + std::process::exit(0); + } + }); + } + + /// A tick took up the quit. + fn quit_seen(&self) { + self.quit_pending.store(false, Ordering::SeqCst); + } + + /// Whether a quit from the tray is still waiting (and no longer is). + fn quit_unseen(&self) -> bool { + self.quit_pending.swap(false, Ordering::SeqCst) + } + fn set_window(&self, ctx: Option) { *self.window.lock().unwrap_or_else(PoisonError::into_inner) = ctx; } @@ -317,6 +347,7 @@ struct Launcher { closed_to_tray: bool, /// eframe's settings file, which the launcher writes itself while there is no window. settings_path: Option, + waker: Option>, } impl Launcher { @@ -336,6 +367,7 @@ impl Launcher { started: false, closed_to_tray: false, settings_path, + waker: None, } } @@ -346,8 +378,9 @@ impl Launcher { if let Some(settings) = settings { self.ui.settings = settings; } - let waker = waker.clone(); - match tray::Tray::new(move || waker.wake()) { + self.waker = Some(waker.clone()); + let (wake, on_quit) = (waker.clone(), waker.clone()); + match tray::Tray::new(move || wake.wake(), move || on_quit.quit_from_tray()) { Ok(t) => self.tray = Some(t), Err(e) => log::warn!("no tray icon: {e}"), } @@ -391,7 +424,12 @@ impl Launcher { log::warn!("couldn't open {url}: {e}"); } } - tray::TrayAction::Quit => requests.push(Request::Quit), + tray::TrayAction::Quit => { + if let Some(waker) = &self.waker { + waker.quit_seen(); + } + requests.push(Request::Quit); + } } } if let Some(t) = self.tray.as_mut() { @@ -768,6 +806,17 @@ mod tests { assert!(!dir.exists()); } + #[test] + fn a_tray_quit_nobody_took_up_ends_the_launcher() { + let (tray_loop, _woken) = sync_channel(1); + let waker = Waker { window: Mutex::new(None), tray_loop, quit_pending: AtomicBool::new(true) }; + assert!(waker.quit_unseen()); + assert!(!waker.quit_unseen()); + waker.quit_pending.store(true, Ordering::SeqCst); + waker.quit_seen(); + assert!(!waker.quit_unseen()); + } + #[test] fn quitting_overrides_a_close_to_the_tray() { let (mut launcher, _show, _) = launcher(); diff --git a/apps/artcraft-launcher/src/tray.rs b/apps/artcraft-launcher/src/tray.rs index 084b468..f7dced9 100644 --- a/apps/artcraft-launcher/src/tray.rs +++ b/apps/artcraft-launcher/src/tray.rs @@ -42,7 +42,8 @@ fn load_icon() -> Result { impl Tray { /// Creates the tray icon. `wake` repaints the UI so it sees the action, even when hidden. - pub fn new(wake: impl Fn() + Send + Sync + Clone + 'static) -> Result { + /// `on_quit` runs when Quit is picked, before the UI can see it: the UI may never get to. + pub fn new(wake: impl Fn() + Send + Sync + Clone + 'static, on_quit: impl Fn() + Send + Sync + 'static) -> Result { let show = MenuItem::new(i18n::t("Show ArtCraft Launcher"), true, None); let check = MenuItem::new(i18n::t("Check for Updates"), true, None); use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL}; @@ -80,6 +81,9 @@ impl Tray { } else { return; }; + if matches!(action, TrayAction::Quit) { + on_quit(); + } let _ = tx.send(action); wake(); }));