Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion crates/openlogi-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ fn main() -> Result<()> {
let (tray_tx, mut tray_rx) =
tokio::sync::mpsc::unbounded_channel::<platform::tray::TrayEvent>();

// 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.
Expand Down Expand Up @@ -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.
Expand All @@ -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));
});
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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::<windows::WindowRegistry>().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"),
}
Expand Down
7 changes: 5 additions & 2 deletions crates/openlogi-gui/src/platform/launch_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ fn plist_path() -> io::Result<PathBuf> {
#[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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
Expand All @@ -99,7 +100,8 @@ fn render_plist(exe: &str) -> String {
<string>{LABEL}</string>\n \
<key>ProgramArguments</key>\n \
<array>\n \
<string>{exe}</string>\n \
<string>{exe}</string>\n \
<string>--minimized</string>\n \
</array>\n \
<key>RunAtLoad</key>\n \
<true/>\n \
Expand All @@ -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"));
}
}
42 changes: 31 additions & 11 deletions crates/openlogi-gui/src/platform/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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";

Expand All @@ -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<TrayEvent>) {
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];
Expand Down Expand Up @@ -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) {
Expand Down
Loading