diff --git a/Cargo.lock b/Cargo.lock index 8809ac3..327efad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,6 +246,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "block2" @@ -256,6 +259,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -578,6 +590,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1915,6 +1936,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyring" version = "3.6.3" @@ -1950,6 +1982,8 @@ dependencies = [ "atty", "chrono", "clap", + "core-foundation 0.10.1", + "core-foundation-sys", "crossterm 0.29.0", "dbus", "dirs", @@ -1962,6 +1996,8 @@ dependencies = [ "ico", "image", "keyring", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "ratatui", "regex", "reqwest", @@ -1974,6 +2010,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "tray-icon", "whoami", "windows", "winit", @@ -2178,6 +2215,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "27.0.3" @@ -2329,7 +2385,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -2358,7 +2414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2370,7 +2426,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2382,7 +2438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2417,7 +2473,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2429,7 +2485,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2448,7 +2504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -2461,6 +2517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2482,7 +2539,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -2495,7 +2552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2507,7 +2564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2530,7 +2587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -2550,7 +2607,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2562,7 +2619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -4089,6 +4146,26 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674" +dependencies = [ + "crossbeam-channel", + "dirs", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4982,7 +5059,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.11.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", diff --git a/Cargo.toml b/Cargo.toml index 921e736..f09b230 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,14 @@ keyring = { version = "3.6", features = ["apple-native", "windows-native", "sync whoami = "1.5" [features] -default = ["backend-sway", "ipc-unix", "popup-ui", "popup-output-placement"] +default = [ + "backend-sway", + "backend-macos", + "ipc-unix", + "ipc-tcp", + "popup-ui", + "popup-output-placement", +] backend-linux = ["dep:dbus", "dep:x11rb"] backend-sway = ["backend-linux", "dep:swayipc"] backend-hyprland = ["backend-linux"] @@ -65,6 +72,13 @@ optional = true default-features = false features = ["default_fonts", "glow"] +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" +core-foundation-sys = "0.8" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSRunningApplication"] } +tray-icon = { version = "0.24", default-features = false } + [dev-dependencies] tempfile = "3.18" diff --git a/src/config.rs b/src/config.rs index 8475a7e..cacaa22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -171,14 +171,17 @@ impl Config { if let Some(path) = &self.ipc_socket_path { return PathBuf::from(path); } - #[cfg(feature = "ipc-unix")] + #[cfg(all(feature = "ipc-tcp", any(target_os = "macos", target_os = "windows")))] + return PathBuf::from("127.0.0.1:45555"); + + #[cfg(all(feature = "ipc-unix", not(any(target_os = "macos", target_os = "windows"))))] if let Some(home) = dirs::home_dir() { return home.join(".local/run/lazytime.sock"); } - #[cfg(feature = "ipc-unix")] + #[cfg(all(feature = "ipc-unix", not(any(target_os = "macos", target_os = "windows"))))] return PathBuf::from("/tmp/lazytime.sock"); - #[cfg(feature = "ipc-tcp")] + #[cfg(all(feature = "ipc-tcp", not(any(target_os = "macos", target_os = "windows"))))] return PathBuf::from("127.0.0.1:45555"); #[allow(unreachable_code)] diff --git a/src/gui/app.rs b/src/gui/app.rs index c4b50b6..96fbe16 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -8,6 +8,8 @@ use crate::config::{Config, ThemePreference}; use crate::db; use crate::platform; +#[cfg(target_os = "macos")] +use super::macos_status::{MacosStatusItem, StatusCommand, set_dock_visible}; use super::style; use super::views; @@ -86,6 +88,12 @@ struct GuiApp { header_icon_light: Option, header_icon_dark: Option, header_icon_size: egui::Vec2, + #[cfg(target_os = "macos")] + macos_status: Option, + #[cfg(target_os = "macos")] + macos_status_attempted: bool, + #[cfg(target_os = "macos")] + allow_close: bool, } struct ToastMessage { @@ -118,6 +126,12 @@ impl GuiApp { header_icon_light: None, header_icon_dark: None, header_icon_size: egui::vec2(0.0, 0.0), + #[cfg(target_os = "macos")] + macos_status: None, + #[cfg(target_os = "macos")] + macos_status_attempted: false, + #[cfg(target_os = "macos")] + allow_close: false, }; let (header_icon_light, header_icon_size) = Self::load_header_icon( egui_ctx, @@ -213,6 +227,53 @@ impl GuiApp { style::apply_base_style(ctx); } + #[cfg(target_os = "macos")] + fn handle_macos_status_item(&mut self, ctx: &egui::Context) { + if !self.macos_status_attempted { + self.macos_status_attempted = true; + self.macos_status = MacosStatusItem::new(ctx) + .inspect_err(|err| tracing::warn!("macos status item unavailable: {err}")) + .ok(); + } + + // Only intercept the close button to hide-to-tray when a status item exists. + // Without it there would be no dock icon and no menu to bring the window + // back, leaving the app as an unreachable ghost process. + if self.macos_status.is_some() + && ctx.input(|i| i.viewport().close_requested()) + && !self.allow_close + { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + set_dock_visible(false); + } + + let status_title = self.title_tracking_text(); + let Some(status_item) = self.macos_status.as_mut() else { + return; + }; + status_item.set_title(&status_title); + + while let Some(command) = status_item.take_command() { + match command { + StatusCommand::Show => { + set_dock_visible(true); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + StatusCommand::Hide => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + set_dock_visible(false); + } + StatusCommand::Quit => { + set_dock_visible(true); + self.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } + } + fn set_mode(&mut self, mode: ViewMode) { self.mode = mode; } @@ -343,6 +404,12 @@ impl GuiApp { impl eframe::App for GuiApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + ctx.request_repaint_after(Duration::from_secs(1)); + + #[cfg(target_os = "macos")] + self.handle_macos_status_item(ctx); + self.apply_theme(ctx); if !self.config.onboarding_done { egui::CentralPanel::default().show(ctx, |ui| { diff --git a/src/gui/color.rs b/src/gui/color.rs index 38095ef..717953b 100644 --- a/src/gui/color.rs +++ b/src/gui/color.rs @@ -62,7 +62,7 @@ mod tests { #[test] fn generated_color_is_stable() { - assert_eq!(generate_color_for_name("Default"), "#CC8033"); - assert_eq!(generate_color_for_name("Project A"), "#6F33CC"); + assert_eq!(generate_color_for_name("Default"), "#5733CC"); + assert_eq!(generate_color_for_name("Project A"), "#33BFCC"); } } diff --git a/src/gui/macos_status.rs b/src/gui/macos_status.rs new file mode 100644 index 0000000..9b98e74 --- /dev/null +++ b/src/gui/macos_status.rs @@ -0,0 +1,140 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use eframe::egui; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; +use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}; +use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusCommand { + Show, + Hide, + Quit, +} + +pub struct MacosStatusItem { + tray_icon: TrayIcon, + commands: Arc>>, + title: String, +} + +impl MacosStatusItem { + pub fn new(ctx: &egui::Context) -> anyhow::Result { + let menu = Menu::new(); + let show_id = MenuId::new("lazytime-show"); + let hide_id = MenuId::new("lazytime-hide"); + let quit_id = MenuId::new("lazytime-quit"); + let show = MenuItem::with_id(show_id.clone(), "Show LazyTime", true, None); + let hide = MenuItem::with_id(hide_id.clone(), "Hide Window", true, None); + let quit = MenuItem::with_id(quit_id.clone(), "Quit LazyTime", true, None); + + menu.append_items(&[&show, &hide, &PredefinedMenuItem::separator(), &quit])?; + + let icon = status_bar_icon()?; + let tray_icon = TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_tooltip("LazyTime") + .with_title("LT") + .with_icon(icon) + .with_icon_as_template(true) + .with_menu_on_left_click(true) + .with_menu_on_right_click(true) + .build()?; + + let commands = Arc::new(Mutex::new(VecDeque::new())); + let handler_commands = Arc::clone(&commands); + let repaint_ctx = ctx.clone(); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let command = if event.id() == &show_id { + Some(StatusCommand::Show) + } else if event.id() == &hide_id { + Some(StatusCommand::Hide) + } else if event.id() == &quit_id { + Some(StatusCommand::Quit) + } else { + None + }; + if let Some(command) = command { + if let Ok(mut pending) = handler_commands.lock() { + pending.push_back(command); + } + repaint_ctx.request_repaint(); + } + })); + + let repaint_ctx = ctx.clone(); + TrayIconEvent::set_event_handler(Some(move |_| { + repaint_ctx.request_repaint(); + })); + + Ok(Self { + tray_icon, + commands, + title: "LT".to_string(), + }) + } + + pub fn set_title(&mut self, title: &str) { + let next = title.trim(); + if next.is_empty() || next == self.title { + return; + } + self.tray_icon.set_title(Some(next)); + self.title = next.to_string(); + } + + pub fn take_command(&self) -> Option { + self.commands + .lock() + .ok() + .and_then(|mut pending| pending.pop_front()) + } +} + +fn status_bar_icon() -> anyhow::Result { + const WIDTH: u32 = 18; + const HEIGHT: u32 = 18; + let mut rgba = vec![0; (WIDTH * HEIGHT * 4) as usize]; + + for y in 0..HEIGHT { + for x in 0..WIDTH { + let dx = x as i32 - 9; + let dy = y as i32 - 9; + let on_outer_ring = (dx * dx + dy * dy) <= 64 && (dx * dx + dy * dy) >= 45; + let on_clock_hand = (x == 9 && (5..=9).contains(&y)) || (y == 9 && (9..=13).contains(&x)); + + if on_outer_ring || on_clock_hand { + let offset = ((y * WIDTH + x) * 4) as usize; + rgba[offset] = 255; + rgba[offset + 1] = 255; + rgba[offset + 2] = 255; + rgba[offset + 3] = 255; + } + } + } + + Ok(Icon::from_rgba(rgba, WIDTH, HEIGHT)?) +} + +pub fn set_dock_visible(visible: bool) { + let Some(mtm) = MainThreadMarker::new() else { + tracing::warn!("cannot update macos activation policy away from main thread"); + return; + }; + + let app = NSApplication::sharedApplication(mtm); + let policy = if visible { + NSApplicationActivationPolicy::Regular + } else { + NSApplicationActivationPolicy::Accessory + }; + let applied = app.setActivationPolicy(policy); + if !applied { + tracing::warn!("macos activation policy change was rejected"); + } + if visible { + app.activate(); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c3983ea..53ad17f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2,6 +2,8 @@ mod app; #[cfg(feature = "popup-ui")] mod color; +#[cfg(all(feature = "popup-ui", target_os = "macos"))] +mod macos_status; #[cfg(feature = "popup-ui")] mod style; #[cfg(feature = "popup-ui")] diff --git a/src/gui/views/daemon.rs b/src/gui/views/daemon.rs index 9f6a48a..56d26b1 100644 --- a/src/gui/views/daemon.rs +++ b/src/gui/views/daemon.rs @@ -2,6 +2,7 @@ use std::collections::VecDeque; use std::io::BufRead; use std::process::{Child, Command, Stdio}; use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::time::{Duration, Instant}; use chrono::Local; use eframe::egui; @@ -30,6 +31,8 @@ pub struct DaemonView { receiver: Option>, status: Option, debug_enabled: bool, + keep_running: bool, + last_restart_attempt: Option, } impl DaemonView { @@ -40,6 +43,7 @@ impl DaemonView { if status == DaemonStatus::Stopped { let start_msg = self.start_daemon(config); if start_msg == "daemon running" { + self.keep_running = true; self.push_log("daemon auto-started by GUI".to_string()); return Some("daemon auto-started".to_string()); } @@ -60,6 +64,7 @@ impl DaemonView { self.ensure_owner_id(); self.poll_events(); self.poll_child_exit(config); + self.restart_if_needed(config); self.status = Some(self.compute_status(config)); } @@ -98,6 +103,9 @@ impl DaemonView { .clicked() { message = Some(self.start_daemon(config)); + if message.as_deref() == Some("daemon running") { + self.keep_running = true; + } } }); }); @@ -188,6 +196,7 @@ impl DaemonView { self.child = Some(child); self.receiver = Some(rx); + self.keep_running = true; self.push_log(format!("daemon process started (loglevel={loglevel})")); "daemon running".to_string() } @@ -206,6 +215,7 @@ impl DaemonView { } fn stop(&mut self, config: &Config) -> String { + self.keep_running = false; let status = self.compute_status(config); if status == DaemonStatus::Outside { return self.stop_outside_daemon(config); @@ -279,6 +289,21 @@ impl DaemonView { } } + fn restart_if_needed(&mut self, config: &Config) { + if !self.keep_running || self.compute_status(config) != DaemonStatus::Stopped { + return; + } + if self + .last_restart_attempt + .is_some_and(|last| last.elapsed() < Duration::from_secs(5)) + { + return; + } + self.last_restart_attempt = Some(Instant::now()); + let msg = self.start_daemon(config); + self.push_log(format!("daemon auto-restart: {msg}")); + } + fn cleanup_owned_lock(&mut self, config: &Config, stop_pid: bool) { let lock_owner = db::open(config.db_path()).ok().and_then(|conn| { db::get_config_key(&conn, DAEMON_RUNTIME_LOCK_KEY) diff --git a/src/platform/macos_lock.rs b/src/platform/macos_lock.rs index 1576469..84d17d5 100644 --- a/src/platform/macos_lock.rs +++ b/src/platform/macos_lock.rs @@ -1,8 +1,20 @@ use std::process::Command; use std::sync::mpsc; +use core_foundation::base::TCFType; +use core_foundation::dictionary::CFDictionary; +use core_foundation::string::CFString; +use core_foundation_sys::base::{CFGetTypeID, CFTypeRef}; +use core_foundation_sys::dictionary::{CFDictionaryGetValue, CFDictionaryRef}; +use core_foundation_sys::number::{CFBooleanGetTypeID, CFBooleanGetValue, CFBooleanRef}; + use super::types::{LockEvent, LockSource}; +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef; +} + pub fn spawn_lock_monitor(tx_lock: mpsc::Sender) { std::thread::spawn(move || { tracing::info!("macos lock monitor: session polling active"); @@ -24,24 +36,41 @@ pub fn spawn_lock_monitor(tx_lock: mpsc::Sender) { } fn current_locked_state() -> bool { - let script = r#" -tell application "System Events" - set isLocked to false - try - set isLocked to (name of first process whose name is "loginwindow") is "loginwindow" - end try - return isLocked -end tell -"#; - - let output = Command::new("osascript").arg("-e").arg(script).output(); - match output { - Ok(out) if out.status.success() => { - let text = String::from_utf8_lossy(&out.stdout) - .trim() - .to_ascii_lowercase(); - text == "true" - } - _ => false, + cg_session_locked_state().unwrap_or(false) || ioreg_locked_state() +} + +fn cg_session_locked_state() -> Option { + let dict_ref = unsafe { CGSessionCopyCurrentDictionary() }; + if dict_ref.is_null() { + return None; + } + + let dict: CFDictionary = unsafe { CFDictionary::wrap_under_create_rule(dict_ref) }; + let key = CFString::new("CGSSessionScreenIsLocked"); + let value = + unsafe { CFDictionaryGetValue(dict.as_concrete_TypeRef(), key.as_CFTypeRef() as *const _) }; + if value.is_null() { + return None; } + + let value_ref = value as CFTypeRef; + let is_boolean = unsafe { CFGetTypeID(value_ref) == CFBooleanGetTypeID() }; + is_boolean.then(|| unsafe { CFBooleanGetValue(value as CFBooleanRef) }) +} + +fn ioreg_locked_state() -> bool { + let Ok(output) = Command::new("ioreg").args(["-n", "Root", "-d1"]).output() else { + return false; + }; + + if !output.status.success() { + return false; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + // Match the value, not just a key name. `IOConsoleLocked` flips between + // `Yes`/`No` with the actual lock state; the previous bare `CGSSessionScreenIsLocked` + // key-name match risked reporting a permanent locked state so unlock events + // would never fire. + stdout.contains("\"IOConsoleLocked\" = Yes") } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 494d2d5..b80c03c 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -288,7 +288,9 @@ pub async fn run_event_loop( let reminder_now = Utc::now(); if state.paused().is_none() && state.reminder_due(reminder_now) { let conn = crate::db::open(config.db_path())?; - if crate::db::get_active_tracking(&conn)?.is_none() { + if crate::db::get_active_tracking(&conn)?.is_none() + && !state.manual_stop_snooze_active(&conn, reminder_now)? + { if state.reminder_popup_open() { tracing::debug!("reminder popup already open; skipping spawn"); } else { diff --git a/src/popup.rs b/src/popup.rs index e490e3e..9c79a92 100644 --- a/src/popup.rs +++ b/src/popup.rs @@ -1,10 +1,12 @@ use anyhow::Result; +#[cfg(all(feature = "popup-ui", target_os = "macos"))] +use std::process::Command; +use std::sync::mpsc; #[cfg(feature = "popup-ui")] use std::sync::OnceLock; -use std::sync::mpsc; use std::thread; -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] use eframe::egui; #[cfg(all(feature = "popup-ui", target_os = "linux"))] use winit::platform::wayland::EventLoopBuilderExtWayland; @@ -138,7 +140,7 @@ fn popup_ui_worker(rx: mpsc::Receiver) { tracing::warn!("popup ui worker stopped"); } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] fn run_reminder_popup(request: PopupRequest, tx_action: mpsc::Sender) { let tx_for_ui = tx_action.clone(); let app = PopupApp::new(request.message, tx_for_ui); @@ -159,7 +161,48 @@ fn run_reminder_popup(request: PopupRequest, tx_action: mpsc::Sender) { + let script = format!( + "display dialog {} buttons {{\"No\", \"Snooze\", \"Yes\"}} default button \"Yes\" with title \"LazyTime Tracking Reminder\"\nbutton returned of result", + applescript_string(&request.message) + ); + + let action = match Command::new("osascript").arg("-e").arg(script).output() { + Ok(output) if output.status.success() => { + let button = String::from_utf8_lossy(&output.stdout); + match button.trim() { + "Yes" => PopupAction::Yes, + "Snooze" => PopupAction::Snooze, + _ => PopupAction::No, + } + } + Ok(output) => { + tracing::warn!( + "reminder_dialog: macos dialog failed status={:?} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim() + ); + PopupAction::No + } + Err(err) => { + tracing::warn!("reminder_dialog: macos dialog failed to start: {err}"); + PopupAction::No + } + }; + + let _ = tx_action.send(action); +} + +#[cfg(all(feature = "popup-ui", target_os = "macos"))] +fn run_resume_popup(request: ResumePopupRequest, tx_action: mpsc::Sender) { + run_macos_resume_dialog(request, tx_action); +} + +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] fn run_resume_popup(request: ResumePopupRequest, tx_action: mpsc::Sender) { let tx_for_ui = tx_action.clone(); let app = ResumePopupApp::new(request, tx_for_ui); @@ -180,14 +223,66 @@ fn run_resume_popup(request: ResumePopupRequest, tx_action: mpsc::Sender) { + let paused_at = crate::time::parse_ts(&request.paused_at_ts) + .map(|dt| crate::time::format_ts_local(&dt)) + .unwrap_or_else(|_| request.paused_at_ts.clone()); + let message = format!( + "Project '{}' was paused at {}.\n\nChoose how to continue:", + request.project_name, paused_at + ); + let script = format!( + "display dialog {} buttons {{\"Ignore\", \"Continue from now\", \"Continue from lock time\"}} default button \"Continue from now\" with title \"LazyTime Resume Tracking\"\nbutton returned of result", + applescript_string(&message) + ); + + let action = match Command::new("osascript").arg("-e").arg(script).output() { + Ok(output) if output.status.success() => { + let button = String::from_utf8_lossy(&output.stdout); + match button.trim() { + "Continue from lock time" => ResumeAction::ContinueFromLockTime, + "Continue from now" => ResumeAction::ContinueFromNow, + _ => ResumeAction::Ignore, + } + } + Ok(output) => { + tracing::warn!( + "resume_dialog: macos dialog failed status={:?} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim() + ); + ResumeAction::Ignore + } + Err(err) => { + tracing::warn!("resume_dialog: macos dialog failed to start: {err}"); + ResumeAction::Ignore + } + }; + + tracing::info!( + "resume_choice: id={} project={} choice={:?} choice_time={}", + request.paused_tracking_id, + request.project_name, + action, + crate::time::format_ts_local(&chrono::Utc::now()) + ); + let _ = tx_action.send(action); +} + +#[cfg(all(feature = "popup-ui", target_os = "macos"))] +fn applescript_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] struct PopupApp { message: String, tx_action: mpsc::Sender, sent: bool, } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] struct ResumePopupApp { request: ResumePopupRequest, tx_action: mpsc::Sender, @@ -195,7 +290,7 @@ struct ResumePopupApp { positioned: bool, } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] impl PopupApp { fn new(message: String, tx_action: mpsc::Sender) -> Self { Self { @@ -214,7 +309,7 @@ impl PopupApp { } } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] impl ResumePopupApp { fn new(request: ResumePopupRequest, tx_action: mpsc::Sender) -> Self { Self { @@ -258,6 +353,7 @@ impl ResumePopupApp { } #[cfg(feature = "popup-ui")] +#[cfg(not(target_os = "macos"))] #[cfg(feature = "popup-output-placement")] fn output_center_position(output_name: &str) -> Option { let rect = crate::platform::output_rect(output_name)?; @@ -267,12 +363,13 @@ fn output_center_position(output_name: &str) -> Option { } #[cfg(feature = "popup-ui")] +#[cfg(not(target_os = "macos"))] #[cfg(not(feature = "popup-output-placement"))] fn output_center_position(_output_name: &str) -> Option { None } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] impl eframe::App for PopupApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ctx, |ui| { @@ -304,7 +401,7 @@ impl eframe::App for PopupApp { } } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] impl eframe::App for ResumePopupApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.place_on_output_once(ctx); @@ -345,14 +442,14 @@ impl eframe::App for ResumePopupApp { } } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] fn padded_button(ui: &mut egui::Ui, label: &str) -> egui::Response { ui.add_sized([160.0, 32.0], egui::Button::new(label)) } -#[cfg(feature = "popup-ui")] +#[cfg(all(feature = "popup-ui", not(target_os = "macos")))] fn popup_native_options(title: &str, size: [f32; 2]) -> eframe::NativeOptions { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title(title) @@ -361,8 +458,8 @@ fn popup_native_options(title: &str, size: [f32; 2]) -> eframe::NativeOptions { ..Default::default() }; - #[cfg(not(target_os = "linux"))] - let mut options = eframe::NativeOptions { + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title(title) .with_always_on_top() diff --git a/tests/daemon_integration.rs b/tests/daemon_integration.rs index c234d41..65a2ad1 100644 --- a/tests/daemon_integration.rs +++ b/tests/daemon_integration.rs @@ -1,15 +1,17 @@ use chrono::{Duration, Utc}; -use lazytime::config::Config; -use lazytime::daemon::state::{DaemonState, WindowInfo}; +use lazytime::config::{Config, ThemePreference, TimeRange}; +use lazytime::daemon::state::DaemonState; +use lazytime::platform::types::WindowInfo; use lazytime::{db, rules}; use std::collections::BTreeMap; use tempfile::tempdir; fn test_config(db_path: &std::path::Path) -> Config { Config { + onboarding_done: true, default_project: "DefaultProject".to_string(), tracking_stability_seconds: 5, - working_hours: BTreeMap::new(), + working_hours: all_day_working_hours(), track_reminder_seconds: 300, track_reminder_snooze_seconds: 1800, summary_update_seconds: 5, @@ -24,9 +26,25 @@ fn test_config(db_path: &std::path::Path) -> Config { jira_issue_type: "Story".to_string(), jira_sap_field: "sap_project".to_string(), ipc_socket_path: None, + theme_preference: ThemePreference::Auto, + sidebar_collapsed: false, } } +fn all_day_working_hours() -> BTreeMap> { + (0..=6) + .map(|day| { + ( + day, + vec![TimeRange { + start: "00:00".to_string(), + end: "23:59".to_string(), + }], + ) + }) + .collect() +} + #[tokio::test] async fn daemon_persists_workspace_and_output_fields() { let dir = tempdir().expect("tempdir"); diff --git a/tests/daemon_state.rs b/tests/daemon_state.rs index 751618b..12d365b 100644 --- a/tests/daemon_state.rs +++ b/tests/daemon_state.rs @@ -1,10 +1,50 @@ use chrono::{Duration, Utc}; -use lazytime::config::Config; -use lazytime::daemon::state::{DaemonState, PausedTracking, WindowInfo}; +use lazytime::config::{Config, ThemePreference, TimeRange}; +use lazytime::daemon::state::{DaemonState, PausedTracking}; +use lazytime::platform::types::WindowInfo; use lazytime::{db, rules}; use std::collections::BTreeMap; use tempfile::tempdir; +fn test_config(db_file: String) -> Config { + Config { + onboarding_done: true, + default_project: "DefaultProject".to_string(), + tracking_stability_seconds: 10, + working_hours: all_day_working_hours(), + track_reminder_seconds: 300, + track_reminder_snooze_seconds: 1800, + summary_update_seconds: 5, + report_start: None, + report_end: None, + db_file, + jira_url: None, + jira_token: None, + jira_email: None, + jira_project: None, + jira_assignee: None, + jira_issue_type: "Story".to_string(), + jira_sap_field: "sap_project".to_string(), + ipc_socket_path: None, + theme_preference: ThemePreference::Auto, + sidebar_collapsed: false, + } +} + +fn all_day_working_hours() -> BTreeMap> { + (0..=6) + .map(|day| { + ( + day, + vec![TimeRange { + start: "00:00".to_string(), + end: "23:59".to_string(), + }], + ) + }) + .collect() +} + #[tokio::test] async fn debounce_switches_after_stability_window() { let dir = tempdir().expect("tempdir"); @@ -26,25 +66,7 @@ async fn debounce_switches_after_stability_window() { ) .expect("beta rules"); - let config = Config { - default_project: "DefaultProject".to_string(), - tracking_stability_seconds: 10, - working_hours: BTreeMap::new(), - track_reminder_seconds: 300, - track_reminder_snooze_seconds: 1800, - summary_update_seconds: 5, - report_start: None, - report_end: None, - db_file: db_path.to_string_lossy().to_string(), - jira_url: None, - jira_token: None, - jira_email: None, - jira_project: None, - jira_assignee: None, - jira_issue_type: "Story".to_string(), - jira_sap_field: "sap_project".to_string(), - ipc_socket_path: None, - }; + let config = test_config(db_path.to_string_lossy().to_string()); let ruleset = rules::load_rules(&conn).expect("rules load"); let cache = rules::RuleCache::default(); @@ -141,25 +163,7 @@ async fn switches_based_on_last_tracking_change_not_last_window_event() { ) .expect("beta rules"); - let config = Config { - default_project: "DefaultProject".to_string(), - tracking_stability_seconds: 10, - working_hours: BTreeMap::new(), - track_reminder_seconds: 300, - track_reminder_snooze_seconds: 1800, - summary_update_seconds: 5, - report_start: None, - report_end: None, - db_file: db_path.to_string_lossy().to_string(), - jira_url: None, - jira_token: None, - jira_email: None, - jira_project: None, - jira_assignee: None, - jira_issue_type: "Story".to_string(), - jira_sap_field: "sap_project".to_string(), - ipc_socket_path: None, - }; + let config = test_config(db_path.to_string_lossy().to_string()); let ruleset = rules::load_rules(&conn).expect("rules load"); let cache = rules::RuleCache::default(); @@ -233,25 +237,7 @@ async fn switches_based_on_last_tracking_change_not_last_window_event() { #[test] fn paused_tracking_roundtrip_in_memory() { - let config = Config { - default_project: "DefaultProject".to_string(), - tracking_stability_seconds: 10, - working_hours: BTreeMap::new(), - track_reminder_seconds: 300, - track_reminder_snooze_seconds: 1800, - summary_update_seconds: 5, - report_start: None, - report_end: None, - db_file: "/tmp/lazytime-test.sqlite".to_string(), - jira_url: None, - jira_token: None, - jira_email: None, - jira_project: None, - jira_assignee: None, - jira_issue_type: "Story".to_string(), - jira_sap_field: "sap_project".to_string(), - ipc_socket_path: None, - }; + let config = test_config("/tmp/lazytime-test.sqlite".to_string()); let mut state = DaemonState::new(config); let paused = PausedTracking { @@ -278,25 +264,7 @@ async fn does_not_autostart_while_paused_from_lock() { let conn = db::open(&db_path).expect("open"); db::migrate(&conn).expect("migrate"); - let config = Config { - default_project: "DefaultProject".to_string(), - tracking_stability_seconds: 10, - working_hours: BTreeMap::new(), - track_reminder_seconds: 300, - track_reminder_snooze_seconds: 1800, - summary_update_seconds: 5, - report_start: None, - report_end: None, - db_file: db_path.to_string_lossy().to_string(), - jira_url: None, - jira_token: None, - jira_email: None, - jira_project: None, - jira_assignee: None, - jira_issue_type: "Story".to_string(), - jira_sap_field: "sap_project".to_string(), - ipc_socket_path: None, - }; + let config = test_config(db_path.to_string_lossy().to_string()); let cache = rules::RuleCache::default(); cache.replace(rules::RuleSet::default()).await; diff --git a/tests/ipc_notify.rs b/tests/ipc_notify.rs index 242db6c..17321e2 100644 --- a/tests/ipc_notify.rs +++ b/tests/ipc_notify.rs @@ -1,20 +1,38 @@ use chrono::Utc; use lazytime::db; use lazytime::ipc::{client, server}; -use lazytime::rules::{self, RuleCache, WindowEventInfo}; +use lazytime::platform::types::WindowEventInfo; +use lazytime::rules::{self, RuleCache}; use lazytime::time; -use std::path::Path; +use std::path::{Path, PathBuf}; use tempfile::tempdir; use tokio::sync::mpsc; +fn ipc_endpoint(_dir: &Path) -> PathBuf { + #[cfg(feature = "ipc-tcp")] + { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind free tcp port"); + let addr = listener.local_addr().expect("local addr"); + drop(listener); + return PathBuf::from(addr.to_string()); + } + + #[cfg(all(not(feature = "ipc-tcp"), feature = "ipc-unix", target_family = "unix"))] + { + return _dir.join("lazytime.sock"); + } + + #[allow(unreachable_code)] + _dir.join("lazytime.sock") +} + #[tokio::test] async fn ipc_projects_updated_roundtrip() { let dir = tempdir().expect("tempdir"); - let socket_path = dir.path().join("lazytime.sock"); - let socket_str = socket_path.to_string_lossy().to_string(); + let socket_path = ipc_endpoint(dir.path()); let (tx, mut rx) = mpsc::channel::(8); - let socket_for_server = socket_str.clone(); + let socket_for_server = socket_path.clone(); let server_task = tokio::spawn(async move { let _ = server::run_ipc_server(&socket_for_server, tx).await; }); @@ -22,7 +40,7 @@ async fn ipc_projects_updated_roundtrip() { tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; let ts = time::format_ts(&Utc::now()); - client::notify_projects_updated(&socket_str, &ts) + client::notify_projects_updated(&socket_path, &ts) .await .expect("notify"); @@ -38,8 +56,7 @@ async fn ipc_projects_updated_roundtrip() { #[tokio::test] async fn ipc_reload_signal_swaps_rule_cache_for_detection() { let dir = tempdir().expect("tempdir"); - let socket_path = dir.path().join("lazytime.sock"); - let socket_str = socket_path.to_string_lossy().to_string(); + let socket_path = ipc_endpoint(dir.path()); let db_path = dir.path().join("rules.sqlite"); let mut conn = db::open(&db_path).expect("open db"); @@ -51,16 +68,16 @@ async fn ipc_reload_signal_swaps_rule_cache_for_detection() { cache.replace(initial).await; let (tx, mut rx) = mpsc::channel::(8); - let socket_for_server = socket_str.clone(); + let socket_for_server = socket_path.clone(); let server_task = tokio::spawn(async move { let _ = server::run_ipc_server(&socket_for_server, tx).await; }); let cache_for_reload = cache.clone(); - let db_path_string = db_path.to_string_lossy().to_string(); + let db_path_for_reload = db_path.clone(); let reload_task = tokio::spawn(async move { if rx.recv().await.is_some() { - let conn = db::open(Path::new(&db_path_string)).expect("reload open"); + let conn = db::open(&db_path_for_reload).expect("reload open"); let loaded = rules::load_rules(&conn).expect("reload rules"); cache_for_reload.replace(loaded).await; } @@ -77,7 +94,7 @@ async fn ipc_reload_signal_swaps_rule_cache_for_detection() { db::replace_rules(&mut conn, "Beta", Some("CP2"), &[("app-b", None, ".*", 0)]).expect("beta"); tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - client::notify_projects_updated(&socket_str, &time::format_ts(&Utc::now())) + client::notify_projects_updated(&socket_path, &time::format_ts(&Utc::now())) .await .expect("notify"); diff --git a/tests/storno_comment.rs b/tests/storno_comment.rs index cf871c8..27534e6 100644 --- a/tests/storno_comment.rs +++ b/tests/storno_comment.rs @@ -1,3 +1,4 @@ +use lazytime::config::ThemePreference; use lazytime::db; use lazytime::tui::trackings_storno::storno_tracking; use serde_json::json; @@ -8,6 +9,7 @@ use std::thread; fn test_config(db_file: String, jira_url: String) -> lazytime::config::Config { lazytime::config::Config { + onboarding_done: true, default_project: "Default".to_string(), tracking_stability_seconds: 60, working_hours: Default::default(), @@ -25,6 +27,8 @@ fn test_config(db_file: String, jira_url: String) -> lazytime::config::Config { jira_issue_type: "Story".to_string(), jira_sap_field: "sap_project".to_string(), ipc_socket_path: None, + theme_preference: ThemePreference::Auto, + sidebar_collapsed: false, } } diff --git a/tests/trackings_cleanup.rs b/tests/trackings_cleanup.rs index 7ee7292..af46627 100644 --- a/tests/trackings_cleanup.rs +++ b/tests/trackings_cleanup.rs @@ -1,33 +1,17 @@ use chrono::Datelike; -use lazytime::config::Config; use lazytime::db; -use lazytime::tui::trackings_cleanup::cleanup_today_unsynced_trackings; +use lazytime::tui::trackings_cleanup::cleanup_unsynced_trackings_in_range; use std::collections::BTreeMap; use tempfile::tempdir; -fn test_config( - db_path: &std::path::Path, - working_hours: BTreeMap>, -) -> Config { - Config { - default_project: "DefaultProject".to_string(), - tracking_stability_seconds: 10, - working_hours, - track_reminder_seconds: 300, - track_reminder_snooze_seconds: 1800, - summary_update_seconds: 5, - report_start: None, - report_end: None, - db_file: db_path.to_string_lossy().to_string(), - jira_url: None, - jira_token: None, - jira_email: None, - jira_project: None, - jira_assignee: None, - jira_issue_type: "Story".to_string(), - jira_sap_field: "sap_project".to_string(), - ipc_socket_path: None, - } +fn cleanup_today( + conn: &rusqlite::Connection, +) -> anyhow::Result { + let today = chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(); + cleanup_unsynced_trackings_in_range(conn, &today, &today) } #[test] @@ -43,9 +27,7 @@ fn cleanup_merges_same_project_unsynced_without_gap() { db::add_manual_tracking(&conn, "A", &ts(9, 0), Some(&ts(9, 30)), None).expect("insert 1"); db::add_manual_tracking(&conn, "A", &ts(9, 30), Some(&ts(10, 0)), None).expect("insert 2"); - let config = test_config(&db_path, BTreeMap::new()); - - let stats = cleanup_today_unsynced_trackings(&conn, &config).expect("cleanup"); + let stats = cleanup_today(&conn).expect("cleanup"); assert_eq!(stats.merged_groups, 1); assert_eq!(stats.removed_rows, 1); @@ -80,9 +62,8 @@ fn cleanup_keeps_rows_when_gap_exists() { }], ); - let config = test_config(&db_path, working_hours); - - let stats = cleanup_today_unsynced_trackings(&conn, &config).expect("cleanup"); + let _ = working_hours; + let stats = cleanup_today(&conn).expect("cleanup"); assert_eq!(stats.merged_groups, 0); assert_eq!(stats.removed_rows, 0); @@ -107,9 +88,7 @@ fn cleanup_does_not_merge_synced_rows() { let first_id = rows.first().expect("first row").id; db::set_tracking_synced(&conn, first_id, 1).expect("mark synced"); - let config = test_config(&db_path, BTreeMap::new()); - - let stats = cleanup_today_unsynced_trackings(&conn, &config).expect("cleanup"); + let stats = cleanup_today(&conn).expect("cleanup"); assert_eq!(stats.merged_groups, 0); assert_eq!(stats.removed_rows, 0); diff --git a/tools/gen-macos-icon.swift b/tools/gen-macos-icon.swift index 1095880..12ea65c 100644 --- a/tools/gen-macos-icon.swift +++ b/tools/gen-macos-icon.swift @@ -13,13 +13,23 @@ func drawIcon(size: CGFloat) -> NSImage { image.lockFocus() let rect = NSRect(x: 0, y: 0, width: size, height: size) + let cornerRadius = size * 0.225 + let iconPath = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius) + iconPath.addClip() let bg = NSGradient(colors: [ - NSColor(calibratedRed: 0.08, green: 0.24, blue: 0.55, alpha: 1.0), - NSColor(calibratedRed: 0.10, green: 0.48, blue: 0.80, alpha: 1.0), + NSColor(calibratedRed: 0.09, green: 0.25, blue: 0.56, alpha: 1.0), + NSColor(calibratedRed: 0.13, green: 0.60, blue: 0.82, alpha: 1.0), ]) bg?.draw(in: rect, angle: -90) + let highlightRect = NSRect(x: 0, y: size * 0.45, width: size, height: size * 0.55) + let highlight = NSGradient(colors: [ + NSColor(calibratedWhite: 1.0, alpha: 0.34), + NSColor(calibratedWhite: 1.0, alpha: 0.02), + ]) + highlight?.draw(in: highlightRect, angle: 90) + let bezelInset = size * 0.11 let bezelRect = rect.insetBy(dx: bezelInset, dy: bezelInset) let bezelPath = NSBezierPath(ovalIn: bezelRect) @@ -79,6 +89,10 @@ func drawIcon(size: CGFloat) -> NSImage { handColor.setFill() dotPath.fill() + iconPath.lineWidth = max(1.0, size * 0.012) + NSColor(calibratedWhite: 1.0, alpha: 0.30).setStroke() + iconPath.stroke() + image.unlockFocus() return image } diff --git a/tools/package-macos.sh b/tools/package-macos.sh index 8e3d098..76ca41a 100755 --- a/tools/package-macos.sh +++ b/tools/package-macos.sh @@ -36,7 +36,7 @@ APP_DIR="$WORK_DIR/$APP_NAME" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" -ICON_SOURCE_PNG="$ROOT_DIR/icon_black.png" +ICON_SOURCE_PNG="$WORK_DIR/LazyTime-macos-1024.png" ICONSET_DIR="$WORK_DIR/LazyTime.iconset" ICON_FILE="$RESOURCES_DIR/LazyTime.icns" @@ -45,10 +45,7 @@ mkdir -p "$OUTPUT_DIR" rm -rf "$WORK_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" "$ICONSET_DIR" -if [[ ! -f "$ICON_SOURCE_PNG" ]]; then - echo "Icon source not found: $ICON_SOURCE_PNG" >&2 - exit 1 -fi +swift "$ROOT_DIR/tools/gen-macos-icon.swift" 1024 "$ICON_SOURCE_PNG" cp "$BINARY_PATH" "$MACOS_DIR/$EXECUTABLE_NAME" chmod +x "$MACOS_DIR/$EXECUTABLE_NAME" @@ -88,15 +85,23 @@ cat > "$CONTENTS_DIR/Info.plist" < PLIST -declare -a SIZES=(16 32 64 128 256 512) -for s in "${SIZES[@]}"; do - sips -z "$s" "$s" "$ICON_SOURCE_PNG" --out "$ICONSET_DIR/icon_${s}x${s}.png" >/dev/null +declare -a ICONSET_FILES=( + "16:icon_16x16.png" + "32:icon_16x16@2x.png" + "32:icon_32x32.png" + "64:icon_32x32@2x.png" + "128:icon_128x128.png" + "256:icon_128x128@2x.png" + "256:icon_256x256.png" + "512:icon_256x256@2x.png" + "512:icon_512x512.png" + "1024:icon_512x512@2x.png" +) +for entry in "${ICONSET_FILES[@]}"; do + size="${entry%%:*}" + name="${entry#*:}" + sips -z "$size" "$size" "$ICON_SOURCE_PNG" --out "$ICONSET_DIR/$name" >/dev/null done -cp "$ICONSET_DIR/icon_32x32.png" "$ICONSET_DIR/icon_16x16@2x.png" -cp "$ICONSET_DIR/icon_64x64.png" "$ICONSET_DIR/icon_32x32@2x.png" -cp "$ICONSET_DIR/icon_256x256.png" "$ICONSET_DIR/icon_128x128@2x.png" -cp "$ICONSET_DIR/icon_512x512.png" "$ICONSET_DIR/icon_256x256@2x.png" -cp "$ICONSET_DIR/icon_512x512.png" "$ICONSET_DIR/icon_512x512@2x.png" iconutil -c icns "$ICONSET_DIR" -o "$ICON_FILE"