From 21b3f85dca210daa5021f486bdf15d513f93591d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 29 Sep 2026 13:15:12 +0800 Subject: [PATCH] fix(workbuddy): detect macOS executable from Info.plist --- src-tauri/src/agents/workbuddy.rs | 59 +++++++++++++++-- src-tauri/src/agents/workbuddy/tests.rs | 85 +++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/agents/workbuddy.rs b/src-tauri/src/agents/workbuddy.rs index 574cc67..3a2d4a5 100644 --- a/src-tauri/src/agents/workbuddy.rs +++ b/src-tauri/src/agents/workbuddy.rs @@ -60,6 +60,7 @@ fn workbuddy_resources(executable: &Path) -> Option { } pub(crate) fn find_workbuddy_desktop_executable(home: &Path) -> Option { + #[cfg(not(target_os = "macos"))] let mut candidates = Vec::new(); #[cfg(target_os = "windows")] { @@ -85,11 +86,10 @@ pub(crate) fn find_workbuddy_desktop_executable(home: &Path) -> Option } } #[cfg(target_os = "macos")] - for root in [PathBuf::from("/Applications"), home.join("Applications")] { - for (app, executable) in [("WorkBuddy AI", "WorkBuddy AI"), ("WorkBuddy", "WorkBuddy")] { - candidates.push(root.join(format!("{app}.app/Contents/MacOS/{executable}"))); - } - } + let found = find_workbuddy_macos_executable_in_roots( + &[PathBuf::from("/Applications"), home.join("Applications")], + read_workbuddy_bundle_executable, + ); #[cfg(not(any(target_os = "windows", target_os = "macos")))] { candidates.extend([ @@ -98,12 +98,61 @@ pub(crate) fn find_workbuddy_desktop_executable(home: &Path) -> Option PathBuf::from("/opt/WorkBuddyAI/workbuddy"), ]); } + #[cfg(not(target_os = "macos"))] let found = candidates.into_iter().find(|path| path.is_file()); #[cfg(all(target_os = "windows", not(test)))] let found = found.or_else(find_windows_registered_workbuddy_executable); found } +#[cfg(any(target_os = "macos", test))] +fn find_workbuddy_macos_executable_in_roots( + roots: &[PathBuf], + mut read_bundle_executable: impl FnMut(&Path) -> Option, +) -> Option { + for root in roots { + for (app, fallback) in [("WorkBuddy AI", "WorkBuddy AI"), ("WorkBuddy", "WorkBuddy")] { + let application = root.join(format!("{app}.app")); + let macos = application.join("Contents/MacOS"); + let info_plist = application.join("Contents/Info.plist"); + if info_plist.is_file() { + if let Some(name) = read_bundle_executable(&info_plist) { + let name = name.trim(); + let mut components = Path::new(name).components(); + if matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() + { + let executable = macos.join(name); + if executable.is_file() { + return Some(executable); + } + } + } + } + let executable = macos.join(fallback); + if executable.is_file() { + return Some(executable); + } + } + } + None +} + +#[cfg(target_os = "macos")] +fn read_workbuddy_bundle_executable(info_plist: &Path) -> Option { + let mut command = Command::new("/usr/bin/plutil"); + command + .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"]) + .arg(info_plist); + let output = command_output_with_timeout(&mut command, Duration::from_secs(5)).ok()??; + if !output.status.success() { + return None; + } + let name = String::from_utf8(output.stdout).ok()?; + let name = name.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + pub(crate) fn read_workbuddy_app_version(executable: &Path) -> Option { // Use file metadata, never run an Electron application with --version during detection. read_zcode_app_version(executable) diff --git a/src-tauri/src/agents/workbuddy/tests.rs b/src-tauri/src/agents/workbuddy/tests.rs index dbe2ec6..6dd71d5 100644 --- a/src-tauri/src/agents/workbuddy/tests.rs +++ b/src-tauri/src/agents/workbuddy/tests.rs @@ -478,6 +478,91 @@ fn workbuddy_paths_use_environment_then_installed_product_data_folder() { assert_eq!(resolve(Some(&home.0), Some(Path::new("unused"))), home.0); } +#[test] +fn workbuddy_macos_finds_plist_executable_before_legacy_name() { + let home = Home::new(); + for (index, app) in ["WorkBuddy", "WorkBuddy AI"].into_iter().enumerate() { + let root = home.0.join(format!("install-{index}")); + let application = root.join(format!("{app}.app")); + let macos = application.join("Contents/MacOS"); + fs::create_dir_all(&macos).unwrap(); + fs::write(application.join("Contents/Info.plist"), "Electron").unwrap(); + fs::write(macos.join(app), "legacy").unwrap(); + let electron = macos.join("Electron"); + fs::write(&electron, "electron").unwrap(); + + assert_eq!( + find_workbuddy_macos_executable_in_roots(&[root], |info| { + fs::read_to_string(info).ok() + }), + Some(electron) + ); + } +} + +#[test] +fn workbuddy_macos_falls_back_for_missing_or_invalid_plist_executable() { + let home = Home::new(); + let outside = home.0.join("outside"); + fs::write(&outside, "outside").unwrap(); + for (index, app, declared) in [ + (0, "WorkBuddy", None), + (1, "WorkBuddy AI", Some("Electron".to_string())), + (2, "WorkBuddy", Some("../outside".to_string())), + (3, "WorkBuddy AI", Some(".".to_string())), + (4, "WorkBuddy", Some(outside.to_string_lossy().into_owned())), + ] { + let root = home.0.join(format!("install-{index}")); + let application = root.join(format!("{app}.app")); + let macos = application.join("Contents/MacOS"); + fs::create_dir_all(&macos).unwrap(); + if let Some(declared) = declared { + fs::write(application.join("Contents/Info.plist"), declared).unwrap(); + } + fs::write(application.join("Contents/outside"), "outside").unwrap(); + let fallback = macos.join(app); + fs::write(&fallback, "legacy").unwrap(); + + assert_eq!( + find_workbuddy_macos_executable_in_roots(&[root], |info| { + fs::read_to_string(info).ok() + }), + Some(fallback) + ); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn workbuddy_reads_xml_and_binary_bundle_executable() { + let home = Home::new(); + let xml = home.0.join("Info.xml.plist"); + fs::write( + &xml, + r#" + +CFBundleExecutableElectron"#, + ) + .unwrap(); + assert_eq!( + read_workbuddy_bundle_executable(&xml).as_deref(), + Some("Electron") + ); + + let binary = home.0.join("Info.binary.plist"); + let status = Command::new("/usr/bin/plutil") + .args(["-convert", "binary1", "-o"]) + .arg(&binary) + .arg(&xml) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!( + read_workbuddy_bundle_executable(&binary).as_deref(), + Some("Electron") + ); +} + #[cfg(target_os = "windows")] #[test] fn workbuddy_detects_local_install_without_launching_it() {