diff --git a/apps/artcraft-launcher/src/hiding.rs b/apps/artcraft-launcher/src/hiding.rs
new file mode 100644
--- /dev/null
+++ b/apps/artcraft-launcher/src/hiding.rs
@@ -0,0 +1,26 @@
+//! macOS only: closing the window to the menu bar hides the app, as ⌘H does, rather than ordering
+//! the window out.
+//!
+//! macOS brings a hidden app back by itself: clicking its Dock icon, opening it again from Finder
+//! or Spotlight, or picking it with ⌘Tab all unhide it, window and all. A window that was only
+//! ordered out stays gone, as the Dock asks the app to "reopen" instead, and neither winit nor
+//! eframe passes that on (issue #456). Handling that event here would take an Apple Event handler,
+//! which needs `unsafe`.
+
+use objc2::MainThreadMarker;
+use objc2_app_kit::NSApplication;
+
+/// Hides the app and its window. Must run on the main thread, as the UI does; elsewhere it does
+/// nothing.
+pub fn hide() {
+    if let Some(mtm) = MainThreadMarker::new() {
+        NSApplication::sharedApplication(mtm).hide(None);
+    }
+}
+
+/// Brings the app back from [`hide`] and makes it the active app. Main thread only, as [`hide`].
+pub fn unhide() {
+    if let Some(mtm) = MainThreadMarker::new() {
+        NSApplication::sharedApplication(mtm).unhide(None);
+    }
+}
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
@@ -5,6 +5,8 @@
 #![cfg_attr(windows, windows_subsystem = "windows")]
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+#[cfg(target_os = "macos")]
+mod hiding;
 mod logging;
 mod motion;
 mod settings_file;
@@ -658,7 +660,13 @@ 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 };
-        if !visible {
+        if !visible && window_hides {
+            // Started in the tray: the window stays out of sight. On macOS this doesn't hide the
+            // app as `put_away` does, since the app is still being activated as it starts, which
+            // would unhide it, window and all.
+            shell.visible = false;
+            cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
+        } else if !visible {
             shell.put_away(&cc.egui_ctx);
         } else if hidden {
             cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
@@ -675,6 +683,8 @@ impl<'a> Shell<'a> {
             let on = self.launcher.ui.settings.show_on_current_desktop;
             spaces::follow_current_desktop(on);
             self.applied_follow = Some(on);
+            // Back from `put_away`, which hides the whole app here.
+            hiding::unhide();
         }
         if self.window_hides {
             ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
@@ -686,10 +696,14 @@ impl<'a> Shell<'a> {
         }
     }
 
-    /// Puts the window away in the tray: hidden where it can hide, closed where it can't.
+    /// Puts the window away in the tray: hidden where it can hide, closed where it can't. On macOS
+    /// the whole app hides, so its Dock icon brings the window back (`hiding`).
     fn put_away(&mut self, ctx: &egui::Context) {
         if self.window_hides {
             self.visible = false;
+            #[cfg(target_os = "macos")]
+            hiding::hide();
+            #[cfg(not(target_os = "macos"))]
             ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
         } else {
             self.launcher.closed_to_tray = true;
