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]
