From 9710089f3dea4dad3e68ae6878e1ccf1f545b95b Mon Sep 17 00:00:00 2001 From: anlvdt Date: Sat, 13 Jun 2026 09:39:45 +0700 Subject: [PATCH] feat: add macOS system tray (menu bar) support Extend the existing Windows-only tray icon support to macOS, enabling the 'Send to Tray' workflow on macOS systems. Changes: - Add tray-icon, winit, and image dependencies for macOS target - Widen cfg gates from #[cfg(windows)] to #[cfg(any(windows, target_os = "macos"))] for tray module, SendToTray action, and auto-start usage window helpers - Keep Windows-specific console show/hide functions gated to Windows only - Add macOS icon candidate paths (Codex.app bundle locations) - All 91 existing tests pass with no warnings --- Cargo.toml | 3 +++ src/app.rs | 4 ++-- src/app/auto_start.rs | 8 ++++---- src/app/tui.rs | 6 +++--- src/cli.rs | 10 +++++++--- src/lib.rs | 2 +- src/tray.rs | 14 ++++++++++++++ 7 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d9898ed..ff221c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,9 @@ winresource = "0.1.31" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3.6.3", features = ["apple-native"] } +image = { version = "0.25.10", default-features = false, features = ["png", "ico"] } +tray-icon = { version = "0.22.1", default-features = false } +winit = { version = "0.30.13", default-features = false } [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3.6.3", features = ["sync-secret-service", "vendored"] } diff --git a/src/app.rs b/src/app.rs index f080861..b442b56 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ mod tui; use uuid::Uuid; pub use auto_start::spawn_auto_start_usage_windows_worker; -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] pub(crate) use auto_start::{ run_auto_start_usage_windows_check_now, subscribe_auto_start_usage_windows_checks, }; @@ -32,7 +32,7 @@ pub enum InteractiveMode { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum InteractiveExit { Quit, - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] SendToTray, } diff --git a/src/app/auto_start.rs b/src/app/auto_start.rs index 637f54a..7c488c0 100644 --- a/src/app/auto_start.rs +++ b/src/app/auto_start.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::Path; use std::process::{Command, ExitStatus, Stdio}; use std::sync::mpsc::Sender; -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] use std::sync::mpsc::{self, Receiver}; use std::sync::{Mutex, OnceLock}; use std::thread; @@ -151,7 +151,7 @@ where static AUTO_START_RUN_LOCK: OnceLock> = OnceLock::new(); static AUTO_START_CHECK_LISTENERS: OnceLock>>> = OnceLock::new(); -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] pub(crate) fn subscribe_auto_start_usage_windows_checks() -> Receiver<()> { let (sender, receiver) = mpsc::channel(); let mut listeners = AUTO_START_CHECK_LISTENERS @@ -190,7 +190,7 @@ fn notify_auto_start_usage_windows_checked() { listeners.retain(|listener| listener.send(()).is_ok()); } -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] pub(crate) fn run_auto_start_usage_windows_check_now(env: AppEnv) -> Result<()> { run_auto_start_usage_windows_for_env(env) } @@ -460,7 +460,7 @@ mod tests { assert!(!usage_window_needs_ping(now + Duration::minutes(1), now)); } - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] #[test] fn auto_start_check_notification_reaches_listener() { let receiver = subscribe_auto_start_usage_windows_checks(); diff --git a/src/app/tui.rs b/src/app/tui.rs index 8b874b7..1f9922a 100644 --- a/src/app/tui.rs +++ b/src/app/tui.rs @@ -223,7 +223,7 @@ where } } } - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] InteractiveAction::SendToTray => return Ok(InteractiveExit::SendToTray), InteractiveAction::Quit => break, } @@ -240,7 +240,7 @@ pub(crate) enum InteractiveAction { DeletePrompt, ShowStatus, SetAutoStartUsageWindows(bool), - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] SendToTray, Quit, } @@ -476,7 +476,7 @@ pub(crate) fn build_menu( label: "Show status".to_owned(), action: InteractiveAction::ShowStatus, }); - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] actions.push(InteractiveItem { label: "Send to Tray".to_owned(), action: InteractiveAction::SendToTray, diff --git a/src/cli.rs b/src/cli.rs index 2f640b5..c07b7bc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -218,22 +218,26 @@ where S: crate::secrets::SecretStore, { crate::app::spawn_auto_start_usage_windows_worker(app.env().clone()); - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] { loop { match app.interactive(InteractiveMode::Persistent, false)? { InteractiveExit::Quit => return Ok(()), InteractiveExit::SendToTray => { + #[cfg(windows)] crate::tray::hide_console_window(); match crate::tray::run(app)? { - crate::tray::TrayExit::ShowTui => crate::tray::show_console_window(), + crate::tray::TrayExit::ShowTui => { + #[cfg(windows)] + crate::tray::show_console_window(); + } crate::tray::TrayExit::Quit => return Ok(()), } } } } } - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "macos")))] { match app.interactive(InteractiveMode::Persistent, false)? { InteractiveExit::Quit => Ok(()), diff --git a/src/lib.rs b/src/lib.rs index fba8f6b..50559c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,6 @@ pub mod repository; pub mod secrets; pub mod settings; mod time_display; -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] pub mod tray; pub mod usage; diff --git a/src/tray.rs b/src/tray.rs index 45cc42e..4e5f1ed 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -445,14 +445,17 @@ fn load_codex_icon() -> Icon { .unwrap_or_else(fallback_icon) } +#[cfg(windows)] pub(crate) fn hide_console_window() { release_console(); } +#[cfg(windows)] pub(crate) fn show_console_window() { allocate_console(); } +#[cfg(windows)] fn release_console() { use windows_sys::Win32::System::Console::FreeConsole; @@ -461,6 +464,7 @@ fn release_console() { } } +#[cfg(windows)] fn allocate_console() { use windows_sys::Win32::System::Console::{AllocConsole, GetConsoleWindow}; use windows_sys::Win32::UI::WindowsAndMessaging::{SW_RESTORE, ShowWindow}; @@ -487,6 +491,7 @@ fn candidate_icon_paths() -> Vec { paths.push(dir.join("icon.ico")); paths.push(dir.join("icon.png")); } + #[cfg(windows)] if let Some(program_files) = std::env::var_os("ProgramFiles") { let windows_apps = PathBuf::from(program_files).join("WindowsApps"); if let Ok(entries) = std::fs::read_dir(windows_apps) { @@ -499,6 +504,15 @@ fn candidate_icon_paths() -> Vec { } } } + #[cfg(target_os = "macos")] + { + let codex_app = PathBuf::from("/Applications/Codex.app"); + paths.push(codex_app.join("Contents/Resources/icon.png")); + paths.push(codex_app.join("Contents/Resources/icon.icns")); + paths.push(codex_app.join("Contents/Resources/AppIcon.icns")); + // Also check the bundled asset + paths.push(codex_app.join("Contents/Frameworks/Codex Framework.framework/Resources/icon.png")); + } paths }