From d2209d36c6ee7c1e0d69c037b20036ca292944f3 Mon Sep 17 00:00:00 2001 From: weiting Date: Mon, 6 Jul 2026 14:13:37 +0800 Subject: [PATCH 1/3] fix(config): restore macOS autostart launch path Refs #140 --- src/config/autostart.rs | 165 ++++++++++++++++++++++++++++++++++------ 1 file changed, 142 insertions(+), 23 deletions(-) diff --git a/src/config/autostart.rs b/src/config/autostart.rs index 53b9418..472a47d 100644 --- a/src/config/autostart.rs +++ b/src/config/autostart.rs @@ -1,5 +1,5 @@ #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] -use std::env; +use std::{env, path::Path}; #[cfg(target_os = "windows")] use auto_launch::WindowsEnableMode; @@ -25,6 +25,10 @@ pub(crate) enum AutoStartError { /// system-triggered launch from a user-triggered one. pub(crate) struct AutoStartManager { auto_launch: AutoLaunch, + #[cfg(target_os = "macos")] + app_name: String, + #[cfg(target_os = "macos")] + app_path: String, } impl AutoStartManager { @@ -41,30 +45,29 @@ impl AutoStartManager { AutoStartError::Initialization(format!("Failed to build AutoLaunch: {e}")) })?; - Ok(Self { auto_launch }) + Ok(Self { + auto_launch, + #[cfg(target_os = "macos")] + app_name: app_name.to_owned(), + #[cfg(target_os = "macos")] + app_path, + }) } /// Resolve the path that `LaunchAgents` / `.desktop` entries / the - /// Windows registry should point at. On macOS a release build runs - /// inside a `.app` bundle and the auto-launch entry must reference - /// the bundle, not the inner Mach-O — otherwise launchd starts a - /// detached binary without a working environment. On Windows we - /// also rewrite Scoop's versioned install path back to the stable - /// `current` junction — see [`normalise_scoop_path`]. + /// Windows registry should execute. macOS `LaunchAgent` entries place + /// this value directly in `ProgramArguments`, so a bundled app must + /// keep the inner `Contents/MacOS/...` executable path rather than + /// the `.app` directory. On Windows we rewrite Scoop's versioned + /// install path back to the stable `current` junction — see + /// [`normalise_scoop_path`]. fn get_app_path() -> Result { let exe_path = env::current_exe().map_err(|e| AutoStartError::ExecutablePath(e.to_string()))?; + Self::resolve_app_path(&exe_path) + } - #[cfg(target_os = "macos")] - { - let exe_str = exe_path.to_string_lossy(); - if let Some(app_bundle_idx) = exe_str.rfind(".app/") { - // +4 keeps the `.app` suffix in the slice. - let bundle_path = &exe_str[..app_bundle_idx + 4]; - return Ok(bundle_path.to_string()); - } - } - + fn resolve_app_path(exe_path: &Path) -> Result { #[cfg(target_os = "windows")] { let exe_str = exe_path.to_string_lossy(); @@ -102,16 +105,72 @@ impl AutoStartManager { /// some Linux desktops) racy. pub(crate) fn sync_state(&self, enabled: bool) -> Result<(), AutoStartError> { let current_enabled = self.is_enabled().unwrap_or(false); - if current_enabled == enabled { - Ok(()) - } else if enabled { - self.enable() + let entry_matches_app_path = if enabled && current_enabled { + #[cfg(target_os = "macos")] + { + self.autostart_entry_matches_app_path() + } + #[cfg(not(target_os = "macos"))] + { + true + } + } else { + true + }; + + match autostart_sync_action(enabled, current_enabled, entry_matches_app_path) { + AutoStartSyncAction::None => Ok(()), + AutoStartSyncAction::Enable => self.enable(), + AutoStartSyncAction::Disable => self.disable(), + } + } + + #[cfg(target_os = "macos")] + fn autostart_entry_matches_app_path(&self) -> bool { + let Some(home_dir) = dirs::home_dir() else { + return false; + }; + let app_name = &self.app_name; + let plist_path = home_dir + .join("Library") + .join("LaunchAgents") + .join(format!("{app_name}.plist")); + + std::fs::read_to_string(plist_path) + .is_ok_and(|contents| launch_agent_contains_app_path(&contents, &self.app_path)) + } +} + +#[derive(Debug, Eq, PartialEq)] +enum AutoStartSyncAction { + None, + Enable, + Disable, +} + +const fn autostart_sync_action( + enabled: bool, + current_enabled: bool, + entry_matches_app_path: bool, +) -> AutoStartSyncAction { + if enabled { + if current_enabled && entry_matches_app_path { + AutoStartSyncAction::None } else { - self.disable() + AutoStartSyncAction::Enable } + } else if current_enabled { + AutoStartSyncAction::Disable + } else { + AutoStartSyncAction::None } } +#[cfg(any(target_os = "macos", test))] +fn launch_agent_contains_app_path(contents: &str, app_path: &str) -> bool { + contents.contains(&format!("{app_path}")) +} + /// Rewrite a Windows path that points through a Scoop versioned /// directory (`...\scoop\apps\\\...`) so the version /// segment becomes `current`. Returns `None` when `path` is not a @@ -177,6 +236,66 @@ mod tests { assert!(!path_str.is_empty()); } + #[test] + #[expect(clippy::unwrap_used)] + fn test_resolve_app_path_macos_bundle_executable_keeps_executable_path() { + let executable_path = Path::new("/Applications/Ropy.app/Contents/MacOS/ropy"); + + let resolved = AutoStartManager::resolve_app_path(executable_path).unwrap(); + + assert_eq!(resolved, "/Applications/Ropy.app/Contents/MacOS/ropy"); + } + + #[test] + fn test_launch_agent_contains_app_path_matching_executable_returns_true() { + let contents = r" +ProgramArguments +/Applications/Ropy.app/Contents/MacOS/ropy +"; + + assert!(launch_agent_contains_app_path( + contents, + "/Applications/Ropy.app/Contents/MacOS/ropy", + )); + } + + #[test] + fn test_launch_agent_contains_app_path_bundle_directory_returns_false() { + let contents = r" +ProgramArguments +/Applications/Ropy.app +"; + + assert!(!launch_agent_contains_app_path( + contents, + "/Applications/Ropy.app/Contents/MacOS/ropy", + )); + } + + #[test] + fn test_autostart_sync_action_stale_enabled_entry_enables() { + assert_eq!( + autostart_sync_action(true, true, false), + AutoStartSyncAction::Enable, + ); + } + + #[test] + fn test_autostart_sync_action_current_enabled_entry_is_noop() { + assert_eq!( + autostart_sync_action(true, true, true), + AutoStartSyncAction::None, + ); + } + + #[test] + fn test_autostart_sync_action_disabled_preference_disables_enabled_entry() { + assert_eq!( + autostart_sync_action(false, true, true), + AutoStartSyncAction::Disable, + ); + } + #[test] #[serial(autostart)] fn test_sync_state() { From 9851c51da863c6183946df8ab221eaa85dd69944 Mon Sep 17 00:00:00 2001 From: weiting Date: Mon, 6 Jul 2026 14:25:18 +0800 Subject: [PATCH 2/3] fix(config): use macOS login item autostart Refs #140 --- src/config/autostart.rs | 152 +++++++++++++++++++--------------------- 1 file changed, 73 insertions(+), 79 deletions(-) diff --git a/src/config/autostart.rs b/src/config/autostart.rs index 472a47d..a344610 100644 --- a/src/config/autostart.rs +++ b/src/config/autostart.rs @@ -1,6 +1,8 @@ #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))] use std::{env, path::Path}; +#[cfg(target_os = "macos")] +use auto_launch::MacOSLaunchMode; #[cfg(target_os = "windows")] use auto_launch::WindowsEnableMode; use auto_launch::{AutoLaunch, AutoLaunchBuilder}; @@ -26,9 +28,7 @@ pub(crate) enum AutoStartError { pub(crate) struct AutoStartManager { auto_launch: AutoLaunch, #[cfg(target_os = "macos")] - app_name: String, - #[cfg(target_os = "macos")] - app_path: String, + legacy_launch_agent: AutoLaunch, } impl AutoStartManager { @@ -38,6 +38,9 @@ impl AutoStartManager { let mut builder = AutoLaunchBuilder::new(); builder.set_app_name(app_name).set_app_path(&app_path); + #[cfg(target_os = "macos")] + builder.set_macos_launch_mode(MacOSLaunchMode::AppleScript); + #[cfg(target_os = "windows")] builder.set_windows_enable_mode(WindowsEnableMode::CurrentUser); @@ -48,19 +51,15 @@ impl AutoStartManager { Ok(Self { auto_launch, #[cfg(target_os = "macos")] - app_name: app_name.to_owned(), - #[cfg(target_os = "macos")] - app_path, + legacy_launch_agent: Self::build_macos_legacy_launch_agent(app_name, &app_path)?, }) } /// Resolve the path that `LaunchAgents` / `.desktop` entries / the - /// Windows registry should execute. macOS `LaunchAgent` entries place - /// this value directly in `ProgramArguments`, so a bundled app must - /// keep the inner `Contents/MacOS/...` executable path rather than - /// the `.app` directory. On Windows we rewrite Scoop's versioned - /// install path back to the stable `current` junction — see - /// [`normalise_scoop_path`]. + /// Windows registry should execute. macOS uses a Login Item so bundled + /// builds point at the `.app` directory that System Settings displays. + /// On Windows we rewrite Scoop's versioned install path back to the + /// stable `current` junction — see [`normalise_scoop_path`]. fn get_app_path() -> Result { let exe_path = env::current_exe().map_err(|e| AutoStartError::ExecutablePath(e.to_string()))?; @@ -68,6 +67,11 @@ impl AutoStartManager { } fn resolve_app_path(exe_path: &Path) -> Result { + #[cfg(target_os = "macos")] + if let Some(bundle_path) = macos_app_bundle_path(exe_path) { + return Ok(bundle_path); + } + #[cfg(target_os = "windows")] { let exe_str = exe_path.to_string_lossy(); @@ -81,6 +85,21 @@ impl AutoStartManager { }) } + #[cfg(target_os = "macos")] + fn build_macos_legacy_launch_agent( + app_name: &str, + app_path: &str, + ) -> Result { + let mut builder = AutoLaunchBuilder::new(); + builder + .set_app_name(app_name) + .set_app_path(app_path) + .set_macos_launch_mode(MacOSLaunchMode::LaunchAgent); + builder.build().map_err(|e| { + AutoStartError::Initialization(format!("Failed to build legacy AutoLaunch: {e}")) + }) + } + pub(crate) fn enable(&self) -> Result<(), AutoStartError> { self.auto_launch .enable() @@ -101,43 +120,28 @@ impl AutoStartManager { /// Make the system state match the user's preference, but skip the /// platform call when it already matches — repeatedly toggling - /// `LaunchAgents` / registry entries is unnecessary churn and (on + /// Login Items / registry entries is unnecessary churn and (on /// some Linux desktops) racy. pub(crate) fn sync_state(&self, enabled: bool) -> Result<(), AutoStartError> { let current_enabled = self.is_enabled().unwrap_or(false); - let entry_matches_app_path = if enabled && current_enabled { - #[cfg(target_os = "macos")] - { - self.autostart_entry_matches_app_path() - } - #[cfg(not(target_os = "macos"))] - { - true - } - } else { - true - }; - - match autostart_sync_action(enabled, current_enabled, entry_matches_app_path) { + match autostart_sync_action(enabled, current_enabled) { AutoStartSyncAction::None => Ok(()), AutoStartSyncAction::Enable => self.enable(), AutoStartSyncAction::Disable => self.disable(), - } + }?; + self.disable_macos_legacy_launch_agent() } #[cfg(target_os = "macos")] - fn autostart_entry_matches_app_path(&self) -> bool { - let Some(home_dir) = dirs::home_dir() else { - return false; - }; - let app_name = &self.app_name; - let plist_path = home_dir - .join("Library") - .join("LaunchAgents") - .join(format!("{app_name}.plist")); - - std::fs::read_to_string(plist_path) - .is_ok_and(|contents| launch_agent_contains_app_path(&contents, &self.app_path)) + fn disable_macos_legacy_launch_agent(&self) -> Result<(), AutoStartError> { + self.legacy_launch_agent + .disable() + .map_err(|e| AutoStartError::Disable(e.to_string())) + } + + #[cfg(not(target_os = "macos"))] + fn disable_macos_legacy_launch_agent(&self) -> Result<(), AutoStartError> { + Ok(()) } } @@ -148,13 +152,9 @@ enum AutoStartSyncAction { Disable, } -const fn autostart_sync_action( - enabled: bool, - current_enabled: bool, - entry_matches_app_path: bool, -) -> AutoStartSyncAction { +const fn autostart_sync_action(enabled: bool, current_enabled: bool) -> AutoStartSyncAction { if enabled { - if current_enabled && entry_matches_app_path { + if current_enabled { AutoStartSyncAction::None } else { AutoStartSyncAction::Enable @@ -167,8 +167,12 @@ const fn autostart_sync_action( } #[cfg(any(target_os = "macos", test))] -fn launch_agent_contains_app_path(contents: &str, app_path: &str) -> bool { - contents.contains(&format!("{app_path}")) +fn macos_app_bundle_path(exe_path: &Path) -> Option { + let exe_str = exe_path.to_string_lossy(); + exe_str.rfind(".app/").map(|app_bundle_idx| { + // +4 keeps the `.app` suffix in the slice. + exe_str[..app_bundle_idx + 4].to_string() + }) } /// Rewrite a Windows path that points through a Scoop versioned @@ -213,8 +217,8 @@ mod tests { use super::*; - // These tests touch OS-level auto-launch state (macOS LaunchAgents, - // Linux `.desktop` entries, Windows registry). Running them in parallel + // These tests touch OS-level auto-launch state (macOS Login Items / + // legacy LaunchAgents, Linux `.desktop` entries, Windows registry). Running them in parallel // can race on that shared global state, so they are serialised explicitly // while the rest of the suite runs in parallel. @@ -237,61 +241,51 @@ mod tests { } #[test] + #[cfg(target_os = "macos")] #[expect(clippy::unwrap_used)] - fn test_resolve_app_path_macos_bundle_executable_keeps_executable_path() { + fn test_resolve_app_path_macos_bundle_executable_returns_bundle_path() { let executable_path = Path::new("/Applications/Ropy.app/Contents/MacOS/ropy"); let resolved = AutoStartManager::resolve_app_path(executable_path).unwrap(); - assert_eq!(resolved, "/Applications/Ropy.app/Contents/MacOS/ropy"); + assert_eq!(resolved, "/Applications/Ropy.app"); } #[test] - fn test_launch_agent_contains_app_path_matching_executable_returns_true() { - let contents = r" -ProgramArguments -/Applications/Ropy.app/Contents/MacOS/ropy -"; - - assert!(launch_agent_contains_app_path( - contents, - "/Applications/Ropy.app/Contents/MacOS/ropy", - )); + fn test_macos_app_bundle_path_with_inner_executable_returns_bundle_path() { + let executable_path = Path::new("/Applications/Ropy.app/Contents/MacOS/ropy"); + + assert_eq!( + macos_app_bundle_path(executable_path).as_deref(), + Some("/Applications/Ropy.app"), + ); } #[test] - fn test_launch_agent_contains_app_path_bundle_directory_returns_false() { - let contents = r" -ProgramArguments -/Applications/Ropy.app -"; - - assert!(!launch_agent_contains_app_path( - contents, - "/Applications/Ropy.app/Contents/MacOS/ropy", - )); + fn test_macos_app_bundle_path_without_bundle_returns_none() { + assert_eq!( + macos_app_bundle_path(Path::new("/usr/local/bin/ropy")), + None + ); } #[test] - fn test_autostart_sync_action_stale_enabled_entry_enables() { + fn test_autostart_sync_action_enabled_preference_with_disabled_entry_enables() { assert_eq!( - autostart_sync_action(true, true, false), + autostart_sync_action(true, false), AutoStartSyncAction::Enable, ); } #[test] fn test_autostart_sync_action_current_enabled_entry_is_noop() { - assert_eq!( - autostart_sync_action(true, true, true), - AutoStartSyncAction::None, - ); + assert_eq!(autostart_sync_action(true, true), AutoStartSyncAction::None); } #[test] fn test_autostart_sync_action_disabled_preference_disables_enabled_entry() { assert_eq!( - autostart_sync_action(false, true, true), + autostart_sync_action(false, true), AutoStartSyncAction::Disable, ); } From eaabf6477285d9b96d05b9ad8be096993a621e4b Mon Sep 17 00:00:00 2001 From: weiting Date: Mon, 6 Jul 2026 14:32:36 +0800 Subject: [PATCH 3/3] fix(config): avoid non-macOS autostart cleanup lint Refs #140 --- src/config/autostart.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/config/autostart.rs b/src/config/autostart.rs index a344610..2446396 100644 --- a/src/config/autostart.rs +++ b/src/config/autostart.rs @@ -129,7 +129,11 @@ impl AutoStartManager { AutoStartSyncAction::Enable => self.enable(), AutoStartSyncAction::Disable => self.disable(), }?; - self.disable_macos_legacy_launch_agent() + + #[cfg(target_os = "macos")] + self.disable_macos_legacy_launch_agent()?; + + Ok(()) } #[cfg(target_os = "macos")] @@ -138,11 +142,6 @@ impl AutoStartManager { .disable() .map_err(|e| AutoStartError::Disable(e.to_string())) } - - #[cfg(not(target_os = "macos"))] - fn disable_macos_legacy_launch_agent(&self) -> Result<(), AutoStartError> { - Ok(()) - } } #[derive(Debug, Eq, PartialEq)]