From 70c2f6c2d2e2123476e38a8854a371d3b08c858a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 31 May 2026 17:32:50 +0800 Subject: [PATCH] feat(gui): dynamic Dock/menu-bar policy + start-minimized autostart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the macOS activation policy track window presence instead of being statically Accessory: the app shows in the Dock with its menu bar (Cmd-Q, Settings, ...) whenever a window is open, and drops back to a tray-only accessory once the last window closes. `open_main_window` flips to Regular; an `on_window_closed` observer flips to Accessory when `cx.windows()` is empty. Add start-minimized autostart: the LaunchAgent plist now passes `--minimized`, so a login-launched instance comes up in the tray with no window (a brief Dock-icon flash is unavoidable — gpui hardcodes Regular at did_finish_launching, before our run closure). Manual launches — and every non-macOS platform, which has no tray — still open the window. --- crates/openlogi-gui/src/main.rs | 31 +++++++++++++- .../openlogi-gui/src/platform/launch_agent.rs | 7 +++- crates/openlogi-gui/src/platform/tray.rs | 42 ++++++++++++++----- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index cd14789..b1d52c7 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -122,6 +122,13 @@ fn main() -> Result<()> { let (tray_tx, mut tray_rx) = tokio::sync::mpsc::unbounded_channel::(); + // macOS autostart passes `--minimized` (see launch_agent.rs) to come up in + // the tray with no window. No tray elsewhere, so the window always opens. + #[cfg(target_os = "macos")] + let start_minimized = std::env::args().any(|a| a == "--minimized"); + #[cfg(not(target_os = "macos"))] + let start_minimized = false; + // `with_assets` registers the embedded app logo ([`app_assets`]) plus the // lucide SVGs that back `gpui_component::IconName`; without it `img()` / // `Icon` would fail to load. @@ -153,6 +160,17 @@ fn main() -> Result<()> { #[cfg(target_os = "macos")] platform::tray::install(tray_tx); + // Keep the activation policy in step with window presence: when the last + // window closes, drop back to the tray (accessory, no Dock/menu bar); + // `open_main_window` restores Regular whenever a window opens. + #[cfg(target_os = "macos")] + cx.on_window_closed(|cx, _| { + if cx.windows().is_empty() { + platform::tray::hide_from_dock(); + } + }) + .detach(); + cx.spawn(async move |cx| { // Install the hook-shared AppState up front, then open the window at // launch; closing it leaves the app live in the menu bar. @@ -168,7 +186,14 @@ fn main() -> Result<()> { dpi_cycle, )); } - open_main_window(&inventories, cx); + if !start_minimized { + open_main_window(&inventories, cx); + } + #[cfg(target_os = "macos")] + if start_minimized { + // Autostart: live in the menu-bar tray with no window. + platform::tray::hide_from_dock(); + } #[cfg(target_os = "macos")] platform::tray::set_device_status(&tray_status(cx)); }); @@ -303,6 +328,8 @@ fn open_main_window(inventories: &[DeviceInventory], cx: &mut gpui::App) { .is_ok() { cx.activate(true); + #[cfg(target_os = "macos")] + platform::tray::show_in_dock(); return; } } @@ -326,6 +353,8 @@ fn open_main_window(inventories: &[DeviceInventory], cx: &mut gpui::App) { let _ = handle.update(cx, |_, window, _| window.activate_window()); cx.default_global::().main = Some(handle); cx.activate(true); + #[cfg(target_os = "macos")] + platform::tray::show_in_dock(); } Err(e) => warn!(error = %e, "could not open the main window"), } diff --git a/crates/openlogi-gui/src/platform/launch_agent.rs b/crates/openlogi-gui/src/platform/launch_agent.rs index 43c3165..f7b0e62 100644 --- a/crates/openlogi-gui/src/platform/launch_agent.rs +++ b/crates/openlogi-gui/src/platform/launch_agent.rs @@ -88,7 +88,8 @@ fn plist_path() -> io::Result { #[cfg(target_os = "macos")] fn render_plist(exe: &str) -> String { // launchd accepts both XML and binary plists; XML is human-readable - // and small enough that the cost is negligible. + // and small enough that the cost is negligible. The `--minimized` arg makes + // the login-launched instance come up in the menu-bar tray with no window. format!( "\n\ String { {LABEL}\n \ ProgramArguments\n \ \n \ - {exe}\n \ + {exe}\n \ + --minimized\n \ \n \ RunAtLoad\n \ \n \ @@ -120,5 +122,6 @@ mod tests { assert!(body.contains(LABEL)); assert!(body.contains("/Applications/OpenLogi.app/Contents/MacOS/openlogi-gui")); assert!(body.contains("RunAtLoad")); + assert!(body.contains("--minimized")); } } diff --git a/crates/openlogi-gui/src/platform/tray.rs b/crates/openlogi-gui/src/platform/tray.rs index b4bceed..1c7fee0 100644 --- a/crates/openlogi-gui/src/platform/tray.rs +++ b/crates/openlogi-gui/src/platform/tray.rs @@ -11,7 +11,10 @@ //! channel that a dedicated task in `main.rs` drains. #[cfg(target_os = "macos")] -pub use macos::{TrayEvent, install, refresh_labels, request_refresh, set_device_status}; +pub use macos::{ + TrayEvent, hide_from_dock, install, refresh_labels, request_refresh, set_device_status, + show_in_dock, +}; #[cfg(target_os = "macos")] #[expect( @@ -40,6 +43,7 @@ mod macos { } const VARIABLE_LENGTH: f64 = -1.0; + const ACTIVATION_POLICY_REGULAR: i64 = 0; const ACTIVATION_POLICY_ACCESSORY: i64 = 1; const TARGET_CLASS: &str = "OpenLogiMenuTarget"; @@ -59,22 +63,19 @@ mod macos { quit: usize, } - /// Install the status item and switch to accessory activation, dropping the - /// app from the Dock and app switcher. Main thread only. + /// Install the status item. Main thread only. /// - /// The status item, its menu, and the click target are all retained for the - /// app's lifetime (a status item lives as long as the process). The target - /// in particular *must* be retained: `NSMenuItem` does not retain its - /// target, so without this the action callbacks would fire into freed - /// memory. + /// The activation policy (Dock + menu-bar visibility) is *not* set here — + /// [`show_in_dock`] / [`hide_from_dock`] manage it as windows open and + /// close. The status item, its menu, and the click target are all retained + /// for the app's lifetime (a status item lives as long as the process); the + /// target in particular *must* be retained, since `NSMenuItem` keeps only a + /// weak reference to it. pub fn install(tx: mpsc::UnboundedSender) { let _ = MENU_TX.set(tx); ensure_target_class(); unsafe { - let app: id = msg_send![class!(NSApplication), sharedApplication]; - let _: () = msg_send![app, setActivationPolicy: ACTIVATION_POLICY_ACCESSORY]; - let status_bar: id = msg_send![class!(NSStatusBar), systemStatusBar]; let status_item: id = msg_send![status_bar, statusItemWithLength: VARIABLE_LENGTH]; let _: id = msg_send![status_item, retain]; @@ -118,6 +119,25 @@ mod macos { } } + /// Show the app in the Dock + menu bar — called when a window opens, so the + /// app menu (⌘Q, Settings, …) is available while the window is up. + pub fn show_in_dock() { + set_activation_policy(ACTIVATION_POLICY_REGULAR); + } + + /// Drop the app out of the Dock + menu bar, leaving only the status item — + /// called when the last window closes (and on a `--minimized` launch). + pub fn hide_from_dock() { + set_activation_policy(ACTIVATION_POLICY_ACCESSORY); + } + + fn set_activation_policy(policy: i64) { + unsafe { + let app: id = msg_send![class!(NSApplication), sharedApplication]; + let _: () = msg_send![app, setActivationPolicy: policy]; + } + } + /// Update the device line, e.g. `"MX Master 3S · 80%"`. Main thread only. /// A no-op until [`install`] has published the item. pub fn set_device_status(text: &str) {