collective

Browse / github.com/storytold/craft-launcher / patch

Self-update: a launcher that updates in the tray restarts into the tray

Self-update: a launcher that updates in the tray restarts into the tray The second complaint in #337: with automatic updates on, a launcher that updates itself while it sits in the tray restarts into a window, over whatever the user is doing. The same happens to a launcher started hidden at login that then updates itself. ## Change - `Host::window_hidden()` (default `false`, so the web build is untouched): the desktop shell sets it when the window is hidden to the tray or closed to it, cleared when it is shown again, and set while the launcher carries on without a window. - `Command::UpdateLauncher { restart_hidden }`: automatic updates pass `window_hidden()` al

Status
open
Score
0.00
Adopters
0 other operators
Author
XianZheYang (imported from pull request #372, open)
Base
1579aa8c02acc5483f901109292b18c22d750559
Size
+126 −22 in 8 files
Review
pending
Shared
2026-10-28
Id
332a7029ef9a48c16cadb603

How it went

No other operator has reported using it yet.

Diff

raw · apps/artcraft-launcher/src/main.rs crates/core/src/protocol.rs crates/native/src/backend.rs crates/native/src/self_update.rs crates/ui-egui/src/app.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/views.rs crates/ui-egui/tests/ui.rs

diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs--- a/apps/artcraft-launcher/src/main.rs+++ b/apps/artcraft-launcher/src/main.rs@@ -391,6 +391,8 @@ fn claim_notification_identity() -> &'static Result<(), String> { struct DesktopHost {     tray: bool,     quit: bool,+    /// The window is hidden in the tray, closed to it, or was never opened (see `Host::window_hidden`).+    window_hidden: bool, }  impl Host for DesktopHost {@@ -420,6 +422,10 @@ impl Host for DesktopHost {         self.tray     } +    fn window_hidden(&self) -> bool {+        self.window_hidden+    }+     fn has_desktops(&self) -> bool {         cfg!(target_os = "macos")     }@@ -477,7 +483,7 @@ impl Launcher {             system_theme: system_theme::Monitor::default(),             ui: LauncherUi::new(&windowless, backend, Settings::default()),             tray: None,-            host: DesktopHost { tray: false, quit: false },+            host: DesktopHost { tray: false, quit: false, window_hidden: false },             starts,             windowless,             run_again,@@ -578,6 +584,7 @@ impl Launcher {     /// as they change, as no window is there to save them.     fn wait_in_tray(&mut self, woken: &Receiver<()>) -> bool {         log::debug!("no window: carrying on in the tray");+        self.host.window_hidden = true;         // What eframe saved as the window closed, or what was just read in at a start in the tray.         let mut saved = self.ui.settings.clone();         loop {@@ -625,6 +632,7 @@ impl<'a> Shell<'a> {         // Started hidden at login but there's no tray to come back from: show the window.         let visible = !hidden || launcher.tray.is_none();         let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None };+        shell.launcher.host.window_hidden = false;         if !visible {             shell.put_away(&cc.egui_ctx);         } else if hidden {@@ -635,6 +643,7 @@ impl<'a> Shell<'a> {      fn show(&mut self, ctx: &egui::Context) {         self.visible = true;+        self.launcher.host.window_hidden = false;         // Before the window is ordered front, so it comes to this desktop rather than taking the         // user to its own (macOS; the setting doesn't exist elsewhere).         #[cfg(target_os = "macos")]@@ -655,6 +664,7 @@ impl<'a> Shell<'a> {      /// Puts the window away in the tray: hidden where it can hide, closed where it can't.     fn put_away(&mut self, ctx: &egui::Context) {+        self.launcher.host.window_hidden = true;         if self.window_hides {             self.visible = false;             ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));@@ -960,6 +970,7 @@ mod tests {         let mut shell = Shell { launcher: &mut launcher, window_hides: false, visible: true, quitting: false, applied_follow: None };         shell.put_away(&ctx);         assert!(shell.launcher.closed_to_tray);+        assert!(shell.launcher.host.window_hidden());         shell.quit(&ctx);         assert!(!shell.launcher.closed_to_tray);     }@@ -972,6 +983,10 @@ mod tests {         shell.put_away(&ctx);         assert!(!shell.visible);         assert!(!shell.launcher.closed_to_tray);+        // What the UI hears, so a self-update restarts into the tray (#337).+        assert!(shell.launcher.host.window_hidden());+        shell.show(&ctx);+        assert!(!shell.launcher.host.window_hidden());     }      #[test]diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs--- a/crates/core/src/protocol.rs+++ b/crates/core/src/protocol.rs@@ -42,7 +42,11 @@ pub enum Command {         app: String,     },     /// Download the newer ArtCraft Launcher, put it in place and restart into it.-    UpdateLauncher,+    /// `restart_hidden`: the window is away in the tray (or there is none), so the new launcher+    /// starts there too instead of opening a window.+    UpdateLauncher {+        restart_hidden: bool,+    },     /// Fetch the pictures of an app's details page (`core::details`), one [`Event::Screenshot`]     /// each, the lead picture first. Pictures already saved on this machine arrive without     /// touching the network. Asking again while they are on their way does nothing.diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs--- a/crates/native/src/backend.rs+++ b/crates/native/src/backend.rs@@ -390,7 +390,7 @@ impl NativeBackend {     }      /// Downloads the newer launcher, puts it in place and asks the UI to quit so it can start.-    fn update_launcher(&self) {+    fn update_launcher(&self, restart_hidden: bool) {         let id = LAUNCHER_ID.to_string();         let (Some(install), Some(platform)) = (self.self_install.clone(), self.platform) else {             self.shared.emit(Event::JobFinished {@@ -424,7 +424,7 @@ impl NativeBackend {                 claim_restart(&shared.jobs, &shared.restarting, &stop, RESTART_WAIT, &mut waiting)             };             let result = match http.as_ref() {-                Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase, &last),+                Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase, &last, restart_hidden),                 None => Err("secure connections aren't available on this system".into()),             };             shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(LAUNCHER_ID);@@ -938,7 +938,7 @@ impl Backend for NativeBackend {                     }                 }             }-            Command::UpdateLauncher => self.update_launcher(),+            Command::UpdateLauncher { restart_hidden } => self.update_launcher(restart_hidden),             Command::FetchScreenshots { app } => self.fetch_screenshots(&app),             Command::Configure(options) => {                 let changed_pre = {diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs--- a/crates/native/src/self_update.rs+++ b/crates/native/src/self_update.rs@@ -154,6 +154,7 @@ fn detect(_exe: &Path) -> Option<Install> { /// Downloads `release` for `platform`, puts it in place and starts the helper that relaunches /// it once this process has exited. On success the caller must quit promptly. `last` is called /// once the download is ready and returns when the launcher may replace itself and restart.+/// `restart_hidden` starts the new launcher in the tray (`--hidden`), as this one was. #[allow(clippy::too_many_arguments)] pub fn update(     install: &Install,@@ -164,6 +165,7 @@ pub fn update(     stop: &Stop,     phase: &mut dyn FnMut(JobPhase),     last: &dyn Fn() -> Result<(), String>,+    restart_hidden: bool, ) -> Result<(), String> {     let (asset, _) = pick_asset(release, &[LAUNCHER.id], platform, &[install.kind()])         .ok_or_else(|| format!("ArtCraft Launcher {} has no {} for {}", release.version_label(), install.kind().label(), platform.label()))?;@@ -184,20 +186,25 @@ pub fn update(             let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths, &install::macos::Expect::release(release));             let _ = std::fs::remove_file(&file);             let placed = placed?;-            relaunch_after_exit(pid, &[Path::new("/usr/bin/open").as_os_str(), placed.as_os_str()])+            // `open` hands the app what follows `--args`.+            let mut command = vec![Path::new("/usr/bin/open").as_os_str(), placed.as_os_str()];+            if restart_hidden {+                command.extend([std::ffi::OsStr::new("--args"), std::ffi::OsStr::new(crate::login::HIDDEN_FLAG)]);+            }+            relaunch_after_exit(pid, &command)         }         #[cfg(target_os = "linux")]         Install::AppImage(path) => {             replace_appimage(&file, path)?;             let _ = std::fs::remove_file(&file);-            relaunch_after_exit(pid, &[path.as_os_str()])+            relaunch_after_exit(pid, &restart_command(path, restart_hidden))         }         #[cfg(target_os = "linux")]         Install::Package(kind, exe) => {             let result = install::linux::install_package(*kind, &file, stop, phase);             let _ = std::fs::remove_file(&file);             result?;-            relaunch_after_exit(pid, &[exe.as_os_str()])+            relaunch_after_exit(pid, &restart_command(exe, restart_hidden))         }         #[cfg(windows)]         Install::Msi(exe) => windows_helper(@@ -208,6 +215,7 @@ pub fn update(                 ps_quote(&file)             ),             exe,+            restart_hidden,         ),         #[cfg(windows)]         Install::Portable(exe) => {@@ -224,11 +232,12 @@ pub fn update(                     dir = ps_quote(dir),                 ),                 exe,+                restart_hidden,             )         }         #[allow(unreachable_patterns)]         _ => {-            let _ = (pid, &file);+            let _ = (pid, &file, restart_hidden);             Err("this copy of ArtCraft Launcher can't update itself; download the new version instead".into())         }     }@@ -249,6 +258,16 @@ fn replace_appimage(new: &Path, path: &Path) -> Result<(), String> {     }) } +/// The new launcher's command line: the executable, and `--hidden` to keep it in the tray.+#[cfg(target_os = "linux")]+fn restart_command(exe: &Path, restart_hidden: bool) -> Vec<&std::ffi::OsStr> {+    let mut command = vec![exe.as_os_str()];+    if restart_hidden {+        command.push(std::ffi::OsStr::new(crate::login::HIDDEN_FLAG));+    }+    command+}+ /// Starts `sh`, which waits for `pid` to exit and then runs `command`. Detached, so it outlives us. #[cfg(unix)] fn relaunch_after_exit(pid: u32, command: &[&std::ffi::OsStr]) -> Result<(), String> {@@ -268,15 +287,23 @@ fn helper_command(pid: u32, command: &[&std::ffi::OsStr]) -> std::process::Comma     cmd } -/// Starts a hidden PowerShell that waits for `pid` to exit, runs `step`, then starts `exe`.+/// Starts a hidden PowerShell that waits for `pid` to exit, runs `step`, then starts `exe`+/// (with `--hidden` when `restart_hidden`). #[cfg(windows)]-fn windows_helper(pid: u32, step: &str, exe: &Path) -> Result<(), String> {-    let script = format!("Wait-Process -Id {pid} -Timeout 60 -ErrorAction SilentlyContinue; {step}; Start-Process -FilePath {}", ps_quote(exe));+fn windows_helper(pid: u32, step: &str, exe: &Path, restart_hidden: bool) -> Result<(), String> {+    let script = windows_script(pid, step, exe, restart_hidden);     let mut cmd = process::command("powershell.exe");     cmd.args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &script]).env_remove(PRETEND_VAR);     process::spawn_detached(cmd).map_err(|e| format!("couldn't schedule the update ({e})")) } +/// The PowerShell the Windows helper runs: wait for `pid`, do `step`, start `exe` again.+#[cfg_attr(not(windows), allow(dead_code))]+fn windows_script(pid: u32, step: &str, exe: &Path, restart_hidden: bool) -> String {+    let args = if restart_hidden { format!(" -ArgumentList '{}'", crate::login::HIDDEN_FLAG) } else { String::new() };+    format!("Wait-Process -Id {pid} -Timeout 60 -ErrorAction SilentlyContinue; {step}; Start-Process -FilePath {}{args}", ps_quote(exe))+}+ /// A PowerShell single-quoted literal. #[cfg_attr(not(windows), allow(dead_code))] fn ps_quote(path: &Path) -> String {@@ -307,6 +334,22 @@ mod tests {         assert!(!just_updated_to(&paths, &r));     } +    /// A launcher that updated while away in the tray comes back there, not in a window (#337).+    #[test]+    fn a_restart_from_the_tray_starts_the_new_launcher_hidden() {+        let exe = Path::new("C:\\Programs\\artcraft-launcher.exe");+        let shown = windows_script(7, "step", exe, false);+        let hidden = windows_script(7, "step", exe, true);+        assert!(shown.ends_with("Start-Process -FilePath 'C:\\Programs\\artcraft-launcher.exe'"), "{shown}");+        assert!(hidden.ends_with("Start-Process -FilePath 'C:\\Programs\\artcraft-launcher.exe' -ArgumentList '--hidden'"), "{hidden}");+        #[cfg(target_os = "linux")]+        {+            let exe = Path::new("/usr/bin/artcraft-launcher");+            assert_eq!(restart_command(exe, false), [exe.as_os_str()]);+            assert_eq!(restart_command(exe, true), [exe.as_os_str(), std::ffi::OsStr::new("--hidden")]);+        }+    }+     /// The restart helper doesn't pass a pretend version on to the new launcher.     #[cfg(unix)]     #[test]diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -60,6 +60,11 @@ pub trait Host {     }     /// Quit the launcher for real (not just hide to the tray).     fn quit(&mut self) {}+    /// The window is away in the tray, or there is none: a launcher that restarts now should come+    /// back the same way, not open a window over whatever the user is doing (#337).+    fn window_hidden(&self) -> bool {+        false+    } }  pub(crate) struct Toast {@@ -300,7 +305,7 @@ impl LauncherUi {         if changed {             self.announce_updates(host);             if self.settings.auto_update {-                self.trigger_auto_updates();+                self.trigger_auto_updates(host.window_hidden());             }         }         if self.restart_requested {@@ -452,8 +457,9 @@ impl LauncherUi {         }     } -    /// When auto-updates are enabled, automatically downloads and installs pending updates.-    pub(crate) fn trigger_auto_updates(&mut self) {+    /// When auto-updates are enabled, automatically downloads and installs pending updates. A+    /// launcher update restarts the launcher: `restart_hidden` says whether into the tray.+    pub(crate) fn trigger_auto_updates(&mut self, restart_hidden: bool) {         if !self.caps.manage_apps || !self.settings.auto_update {             return;         }@@ -480,7 +486,7 @@ impl LauncherUi {             if !self.auto_update_attempted.contains(&(id.clone(), v.clone())) {                 log::info!("auto-update: queuing launcher self-update to {v}");                 self.auto_update_attempted.insert((id, v));-                self.send(Command::UpdateLauncher);+                self.send(Command::UpdateLauncher { restart_hidden });             }         }     }diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -124,7 +124,7 @@ impl LauncherUi {                         .on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))                         .clicked()                     {-                        self.send(Command::UpdateLauncher);+                        self.send(Command::UpdateLauncher { restart_hidden: false });                         close = true;                     }                 } else {@@ -277,7 +277,8 @@ impl LauncherUi {             && ui.checkbox(&mut self.settings.auto_update, tl!("Automatically download and install updates")).changed()             && self.settings.auto_update         {-            self.trigger_auto_updates();+            // The user is in Settings: the window is up, so a restart brings it back up.+            self.trigger_auto_updates(false);         }         ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));         ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -181,7 +181,7 @@ impl LauncherUi {                                     .on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))                                     .clicked()                                 {-                                    self.send(Command::UpdateLauncher);+                                    self.send(Command::UpdateLauncher { restart_hidden: false });                                 }                             } else {                                 let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone());diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs--- a/crates/ui-egui/tests/ui.rs+++ b/crates/ui-egui/tests/ui.rs@@ -783,7 +783,7 @@ fn the_launcher_updates_itself_and_quits_to_restart() {     h.get_by_label_contains("ArtCraft Launcher 0.2.0 is available");     h.get_by_label("Update and restart").click();     h.run_steps(2);-    assert!(sent.borrow().contains(&Command::UpdateLauncher));+    assert!(sent.borrow().contains(&Command::UpdateLauncher { restart_hidden: false }));     // Downloading: the banner shows progress instead of the button.     shared_events         .borrow_mut()@@ -884,6 +884,41 @@ fn automatic_checks_run_every_five_minutes() {     assert_eq!(CheckEvery::Never.seconds(), None); } +/// A shell whose window is away in the tray.+struct TrayHost;+impl Host for TrayHost {+    fn window_hidden(&self) -> bool {+        true+    }+}++#[test]+fn a_launcher_update_found_while_in_the_tray_restarts_into_the_tray() {+    let mut ev = events();+    ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));+    let sent = Rc::new(RefCell::new(Vec::new()));+    let sent2 = sent.clone();+    let mut backend =+        Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });+    let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(+        move |ui, state: &mut Option<LauncherUi>| {+            if state.is_none()+                && let Some(b) = backend.take()+            {+                *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { auto_update: true, notify: false, ..Settings::default() }));+            }+            if let Some(l) = state.as_mut() {+                l.logic(&ui.ctx().clone(), &mut TrayHost);+                l.ui(ui, &mut TrayHost);+            }+        },+        None,+    );+    h.run_steps(4);+    let commands = sent.borrow().clone();+    assert!(commands.contains(&Command::UpdateLauncher { restart_hidden: true }), "{commands:?}");+}+ #[test] fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {     let mut ev = events();@@ -910,7 +945,7 @@ fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {     let commands = sent.borrow().clone();     assert!(commands.contains(&Command::Install { app: "photocraft".into() }), "outdated photocraft should be queued for install: {commands:?}");     assert!(commands.contains(&Command::Install { app: "effectcraft".into() }), "outdated effectcraft should be queued for install: {commands:?}");-    assert!(commands.contains(&Command::UpdateLauncher), "launcher update should be queued: {commands:?}");+    assert!(commands.contains(&Command::UpdateLauncher { restart_hidden: false }), "launcher update should be queued: {commands:?}"); }  #[test]@@ -938,7 +973,7 @@ fn automatic_updates_are_disabled_by_default() {     h.run_steps(4);     let commands = sent.borrow().clone();     assert!(!commands.contains(&Command::Install { app: "photocraft".into() }), "photocraft should not be auto-installed when auto_update is false");-    assert!(!commands.contains(&Command::UpdateLauncher), "launcher should not be auto-updated when auto_update is false");+    assert!(!commands.iter().any(|c| matches!(c, Command::UpdateLauncher { .. })), "launcher should not be auto-updated when auto_update is false"); }  #[test]