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();