Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 43 additions & 19 deletions crates/codex-plus-core/src/install/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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(())
}
Expand All @@ -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("<key>CFBundleExecutable</key>")
.nth(1)
.and_then(|tail| tail.split("<string>").nth(1))
.and_then(|tail| tail.split("</string>").next())
.unwrap_or("CodexPlusPlus")
.to_string()
fn app_resources_dir(exe: &Path) -> Option<std::path::PathBuf> {
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 {
Expand All @@ -130,7 +154,7 @@ fn info_plist(display_name: &str, executable_name: &str, identifier_suffix: &str
<key>CFBundleExecutable</key>
<string>{executable_name}</string>
<key>CFBundleIconFile</key>
<string>codex-plus-plus.png</string>
<string>{ICON_NAME}</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
Expand Down
22 changes: 19 additions & 3 deletions crates/codex-plus-core/src/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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<PathBuf> {
macos_app_binary_from_exe(exe, MANAGER_NAME, "CodexPlusPlusManager")
}

fn macos_silent_app_binary_from_exe(exe: &Path) -> Option<PathBuf> {
macos_app_binary_from_exe(exe, SILENT_NAME, "CodexPlusPlus")
}

fn macos_app_binary_from_exe(exe: &Path, app_name: &str, executable_name: &str) -> Option<PathBuf> {
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)
})
}

Expand Down
79 changes: 76 additions & 3 deletions crates/codex-plus-core/tests/installers.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -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("<string>Codex++</string>"));
assert!(
silent
.info_plist
.contains("<string>codex-plus-plus.icns</string>")
);
assert!(
manager
.info_plist
.contains("<string>Codex++ 管理工具</string>")
);
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]
Expand Down Expand Up @@ -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();
Expand Down