diff --git a/crates/codex-plus-core/src/install/macos.rs b/crates/codex-plus-core/src/install/macos.rs index bbf8db2..b0e4c46 100644 --- a/crates/codex-plus-core/src/install/macos.rs +++ b/crates/codex-plus-core/src/install/macos.rs @@ -10,6 +10,8 @@ use super::{ install_root_or_default, option_or_current_exe, }; +const ICON_NAME: &str = "codex-plus-plus.icns"; + pub fn build_app_bundle(options: &InstallOptions, manager: bool) -> MacosAppBundle { let install_root = install_root_or_default(options); let display_name = if manager { MANAGER_NAME } else { SILENT_NAME }; @@ -32,10 +34,18 @@ pub fn build_app_bundle(options: &InstallOptions, manager: bool) -> MacosAppBund binary, ); let identifier_suffix = if manager { ".manager" } else { "" }; + let app_path = install_root.join(format!("{display_name}.app")); + let executable_path = app_path + .join("Contents") + .join("MacOS") + .join(executable_name); MacosAppBundle { - app_path: install_root.join(format!("{display_name}.app")), + app_path, info_plist: info_plist(display_name, executable_name, identifier_suffix), - launch_script: format!("#!/bin/sh\nexec \"{}\"\n", target.to_string_lossy()), + executable_path: executable_path.clone(), + executable_name: executable_name.to_string(), + target_path: target.clone(), + should_write_wrapper: target != executable_path, } } @@ -76,11 +86,24 @@ fn write_bundle(bundle: &MacosAppBundle) -> anyhow::Result<()> { fs::create_dir_all(&macos)?; fs::create_dir_all(&resources)?; fs::write(contents.join("Info.plist"), &bundle.info_plist)?; - let executable = macos.join(executable_name_from_plist(&bundle.info_plist)); - fs::write(&executable, &bundle.launch_script)?; - let mut permissions = fs::metadata(&executable)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(executable, permissions)?; + let executable = macos.join(&bundle.executable_name); + if bundle.should_write_wrapper { + fs::write( + &executable, + format!( + "#!/bin/sh\nexec \"{}\"\n", + bundle.target_path.to_string_lossy() + ), + )?; + let mut permissions = fs::metadata(&executable)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(executable, permissions)?; + } else if !executable.exists() { + anyhow::bail!( + "macOS app executable is missing and cannot be repaired without a source binary: {}", + executable.display() + ); + } copy_icon(&resources)?; Ok(()) } @@ -89,23 +112,24 @@ fn write_bundle(bundle: &MacosAppBundle) -> anyhow::Result<()> { fn copy_icon(resources: &Path) -> anyhow::Result<()> { let source = std::env::current_exe() .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)) - .map(|path| path.join("codex-plus-plus.png")); + .and_then(|path| app_resources_dir(&path).or_else(|| path.parent().map(Path::to_path_buf))) + .map(|path| path.join(ICON_NAME)); if let Some(source) = source.filter(|path| path.exists()) { - fs::copy(source, resources.join("codex-plus-plus.png"))?; + fs::copy(source, resources.join(ICON_NAME))?; } Ok(()) } #[cfg(target_os = "macos")] -fn executable_name_from_plist(plist: &str) -> String { - plist - .split("CFBundleExecutable") - .nth(1) - .and_then(|tail| tail.split("").nth(1)) - .and_then(|tail| tail.split("").next()) - .unwrap_or("CodexPlusPlus") - .to_string() +fn app_resources_dir(exe: &Path) -> Option { + let mut path = exe; + while let Some(parent) = path.parent() { + if path.extension().and_then(|extension| extension.to_str()) == Some("app") { + return Some(path.join("Contents").join("Resources")); + } + path = parent; + } + None } fn info_plist(display_name: &str, executable_name: &str, identifier_suffix: &str) -> String { @@ -130,7 +154,7 @@ fn info_plist(display_name: &str, executable_name: &str, identifier_suffix: &str CFBundleExecutable {executable_name} CFBundleIconFile - codex-plus-plus.png + {ICON_NAME} LSUIElement LSMinimumSystemVersion diff --git a/crates/codex-plus-core/src/install/mod.rs b/crates/codex-plus-core/src/install/mod.rs index 2a201a1..ab81636 100644 --- a/crates/codex-plus-core/src/install/mod.rs +++ b/crates/codex-plus-core/src/install/mod.rs @@ -47,7 +47,10 @@ pub struct InstallActionResult { pub struct MacosAppBundle { pub app_path: PathBuf, pub info_plist: String, - pub launch_script: String, + pub executable_path: PathBuf, + pub executable_name: String, + pub target_path: PathBuf, + pub should_write_wrapper: bool, } impl ShortcutState { @@ -232,6 +235,11 @@ pub fn companion_binary_path(binary: &str) -> PathBuf { pub fn companion_binary_path_from_exe(exe: &Path, binary: &str) -> PathBuf { let dir = exe.parent().unwrap_or_else(|| Path::new(".")); let suffix = if cfg!(windows) { ".exe" } else { "" }; + if binary == MANAGER_BINARY { + if let Some(manager_app_binary) = macos_manager_app_binary_from_exe(exe) { + return manager_app_binary; + } + } if binary == SILENT_BINARY { if let Some(sibling_app_binary) = macos_silent_app_binary_from_exe(exe) { return sibling_app_binary; @@ -244,13 +252,21 @@ pub fn companion_binary_path_from_exe(exe: &Path, binary: &str) -> PathBuf { dir.join(format!("{binary}{suffix}")) } +fn macos_manager_app_binary_from_exe(exe: &Path) -> Option { + macos_app_binary_from_exe(exe, MANAGER_NAME, "CodexPlusPlusManager") +} + fn macos_silent_app_binary_from_exe(exe: &Path) -> Option { + macos_app_binary_from_exe(exe, SILENT_NAME, "CodexPlusPlus") +} + +fn macos_app_binary_from_exe(exe: &Path, app_name: &str, executable_name: &str) -> Option { macos_applications_dir_from_exe(exe).map(|applications_dir| { applications_dir - .join(format!("{SILENT_NAME}.app")) + .join(format!("{app_name}.app")) .join("Contents") .join("MacOS") - .join("CodexPlusPlus") + .join(executable_name) }) } diff --git a/crates/codex-plus-core/tests/installers.rs b/crates/codex-plus-core/tests/installers.rs index c26f700..e391972 100644 --- a/crates/codex-plus-core/tests/installers.rs +++ b/crates/codex-plus-core/tests/installers.rs @@ -1,5 +1,5 @@ use codex_plus_core::install::{ - InstallOptions, SILENT_BINARY, app_bundle_names, build_macos_app_bundle, + InstallOptions, MANAGER_BINARY, SILENT_BINARY, app_bundle_names, build_macos_app_bundle, build_windows_entrypoint_plan, companion_binary_path_from_exe, default_install_root_strategy, shortcut_names, }; @@ -59,13 +59,64 @@ fn macos_bundle_metadata_contains_silent_and_manager_apps() { assert!(silent.app_path.ends_with("Codex++.app")); assert!(manager.app_path.ends_with("Codex++ 管理工具.app")); assert!(silent.info_plist.contains("Codex++")); + assert!( + silent + .info_plist + .contains("codex-plus-plus.icns") + ); assert!( manager .info_plist .contains("Codex++ 管理工具") ); - assert!(silent.launch_script.contains("codex-plus-plus")); - assert!(manager.launch_script.contains("codex-plus-plus-manager")); + assert_eq!( + silent.executable_path, + std::path::PathBuf::from("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus") + ); + assert_eq!( + manager.executable_path, + std::path::PathBuf::from( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" + ) + ); + assert_eq!( + silent.target_path, + std::path::PathBuf::from("/opt/Codex++/codex-plus-plus") + ); + assert_eq!( + manager.target_path, + std::path::PathBuf::from("/opt/Codex++/codex-plus-plus-manager") + ); + assert!(silent.should_write_wrapper); + assert!(manager.should_write_wrapper); +} + +#[test] +fn macos_bundle_repair_does_not_wrap_itself_when_running_from_installed_apps() { + let options = InstallOptions { + install_root: Some("/Applications".into()), + launcher_path: Some("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus".into()), + manager_path: Some( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager".into(), + ), + remove_owned_data: false, + }; + + let silent = build_macos_app_bundle(&options, false); + let manager = build_macos_app_bundle(&options, true); + + assert!(!silent.should_write_wrapper); + assert!(!manager.should_write_wrapper); + assert_eq!( + silent.target_path, + std::path::PathBuf::from("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus") + ); + assert_eq!( + manager.target_path, + std::path::PathBuf::from( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" + ) + ); } #[test] @@ -94,6 +145,28 @@ fn companion_binary_path_resolves_macos_silent_app_next_to_manager_app() { ); } +#[test] +fn companion_binary_path_resolves_macos_manager_app_without_lowercase_wrapper() { + let manager_exe = std::path::Path::new( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager", + ); + + let companion = companion_binary_path_from_exe(manager_exe, MANAGER_BINARY); + + assert_eq!( + companion, + std::path::PathBuf::from( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" + ) + ); + assert_ne!( + companion, + std::path::PathBuf::from( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/codex-plus-plus-manager" + ) + ); +} + #[test] fn windows_default_install_root_uses_known_folder_before_userprofile_desktop() { let strategy = default_install_root_strategy();