From c36d68f2cb034f58bdd40cdf7d2de3cd958579d3 Mon Sep 17 00:00:00 2001 From: C3B6 Date: Wed, 27 May 2026 16:07:18 +0800 Subject: [PATCH] Fix macOS app entrypoints and startup status prompt --- apps/codex-plus-launcher/src/main.rs | 62 +++++++++-- .../src-tauri/src/commands.rs | 41 +++++++ apps/codex-plus-manager/src-tauri/src/lib.rs | 3 + apps/codex-plus-manager/src/App.tsx | 7 ++ crates/codex-plus-core/src/install/macos.rs | 27 ++++- crates/codex-plus-core/src/install/mod.rs | 54 +++++++-- crates/codex-plus-core/tests/installers.rs | 104 +++++++++++++++++- 7 files changed, 274 insertions(+), 24 deletions(-) diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index c5ceda66..dd53475e 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -35,6 +35,10 @@ impl Default for LauncherHooks { #[tokio::main] async fn main() -> Result<()> { + run_launcher().await +} + +async fn run_launcher() -> Result<()> { let Some(_guard) = acquire_single_instance_guard()? else { return Ok(()); }; @@ -43,7 +47,13 @@ async fn main() -> Result<()> { let _ = notify_manager_when_update_available().await; }); let hooks = LauncherHooks::default(); - let handle = launch_and_inject_with_hooks(options, &hooks).await?; + let handle = match launch_and_inject_with_hooks(options, &hooks).await { + Ok(handle) => handle, + Err(error) => { + let _ = open_manager_with_status_prompt(); + return Err(error); + } + }; handle.wait_for_codex_exit().await?; Ok(()) } @@ -84,9 +94,23 @@ async fn notify_manager_when_update_available() -> anyhow::Result { } fn open_manager_with_update_prompt() -> anyhow::Result<()> { + open_manager_with_args(["--show-update"]) +} + +fn open_manager_with_status_prompt() -> anyhow::Result<()> { + open_manager_with_args(["--show-status"]) +} + +fn open_manager_with_args(args: I) -> anyhow::Result<()> +where + I: IntoIterator, + S: AsRef, +{ let manager_path = manager_exe_path(); let mut command = std::process::Command::new(&manager_path); - command.arg("--show-update"); + for arg in args { + command.arg(arg); + } #[cfg(windows)] { command.creation_flags(codex_plus_core::windows_create_no_window()); @@ -558,14 +582,7 @@ fn open_url(url: &str) -> anyhow::Result<()> { } fn manager_exe_path() -> PathBuf { - let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); - let dir = exe.parent().unwrap_or_else(|| Path::new(".")); - let suffix = if cfg!(windows) { ".exe" } else { "" }; - dir.join(format!( - "{}{}", - codex_plus_core::install::MANAGER_BINARY, - suffix - )) + codex_plus_core::install::companion_binary_path(codex_plus_core::install::MANAGER_BINARY) } fn default_user_script_manager() -> UserScriptManager { @@ -640,6 +657,31 @@ mod tests { .is_some_and(|name| name.contains(codex_plus_core::install::MANAGER_BINARY)) ); } + + #[test] + fn installed_launcher_resolves_manager_app_executable() { + let launcher_exe = Path::new("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus"); + let manager_path = codex_plus_core::install::option_or_current_exe_from_exe( + launcher_exe, + &None, + codex_plus_core::install::MANAGER_BINARY, + Some(Path::new("/Applications")), + ); + + assert_eq!( + manager_path, + PathBuf::from("/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager") + ); + } + + #[test] + fn launcher_failure_prompt_opens_manager_status_view() { + let source = include_str!("main.rs"); + + assert!(source.contains("open_manager_with_status_prompt")); + assert!(source.contains("\"--show-status\"")); + assert!(source.contains("launch_and_inject_with_hooks(options, &hooks).await")); + } } fn builtin_user_scripts_dir() -> PathBuf { diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index fd6bfeb9..f888fe7c 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -220,6 +220,7 @@ pub struct ScriptMarketPayload { #[serde(rename_all = "camelCase")] pub struct StartupPayload { pub show_update: bool, + pub show_status: bool, } #[tauri::command] @@ -238,6 +239,7 @@ pub fn startup_options() -> CommandResult { "启动参数已读取。", StartupPayload { show_update: startup_should_show_update(), + show_status: startup_should_show_status(), }, ) } @@ -249,6 +251,13 @@ pub fn startup_should_show_update() -> bool { ) } +pub fn startup_should_show_status() -> bool { + should_show_status( + std::env::args(), + std::env::var("CODEX_PLUS_SHOW_STATUS").ok().as_deref(), + ) +} + fn should_show_update(args: I, env_value: Option<&str>) -> bool where I: IntoIterator, @@ -257,6 +266,14 @@ where args.into_iter().any(|arg| arg.as_ref() == "--show-update") || env_value == Some("1") } +fn should_show_status(args: I, env_value: Option<&str>) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().any(|arg| arg.as_ref() == "--show-status") || env_value == Some("1") +} + #[tauri::command] pub async fn load_overview() -> CommandResult { let payload = tauri::async_runtime::spawn_blocking(load_overview_payload).await; @@ -2163,6 +2180,22 @@ mod tests { assert!(result.payload.show_update); } + #[test] + fn startup_options_honors_show_status_environment() { + unsafe { + std::env::set_var("CODEX_PLUS_SHOW_STATUS", "1"); + } + + let result = startup_options(); + + unsafe { + std::env::remove_var("CODEX_PLUS_SHOW_STATUS"); + } + + assert_eq!(result.status, "ok"); + assert!(result.payload.show_status); + } + #[test] fn startup_options_honors_show_update_argument() { assert!(should_show_update( @@ -2171,6 +2204,14 @@ mod tests { )); } + #[test] + fn startup_options_honors_show_status_argument() { + assert!(should_show_status( + ["codex-plus-plus-manager.exe", "--show-status"], + None + )); + } + #[test] fn overview_contains_expected_operational_fields() { let result = tauri::async_runtime::block_on(load_overview()); diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index f118a799..8620c9e2 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -13,11 +13,14 @@ pub fn run() { return; }; let show_update = commands::startup_should_show_update(); + let show_status = commands::startup_should_show_status(); let run_result = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .setup(move |app| { let url = if show_update { "index.html?showUpdate=1" + } else if show_status { + "index.html?showStatus=1" } else { "index.html" }; diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 475f7df1..10ebf145 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -353,6 +353,7 @@ function syncMarketInstalledState(current: ScriptMarketResult | null, userScript type StartupResult = CommandResult<{ showUpdate: boolean; + showStatus: boolean; }>; type Route = "overview" | "relay" | "context" | "enhance" | "userScripts" | "providerSync" | "recommendations" | "maintenance" | "about" | "settings"; @@ -1088,6 +1089,9 @@ export function App() { if (startup?.showUpdate) { setRoute("about"); void checkUpdate(false); + } else if (startup?.showStatus) { + setRoute("overview"); + void checkUpdate(true); } else { void checkUpdate(true); } @@ -4386,5 +4390,8 @@ function loadInitialRoute(): Route { if (params.get("showUpdate") === "1" || window.location.hash === "#about") { return "about"; } + if (params.get("showStatus") === "1" || window.location.hash === "#overview") { + return "overview"; + } return "overview"; } diff --git a/crates/codex-plus-core/src/install/macos.rs b/crates/codex-plus-core/src/install/macos.rs index bbf8db2d..cc122742 100644 --- a/crates/codex-plus-core/src/install/macos.rs +++ b/crates/codex-plus-core/src/install/macos.rs @@ -7,7 +7,7 @@ use std::path::Path; use super::{ InstallOptions, MANAGER_BINARY, MANAGER_NAME, MacosAppBundle, SILENT_BINARY, SILENT_NAME, - install_root_or_default, option_or_current_exe, + install_root_or_default, option_or_current_exe_from_exe, }; pub fn build_app_bundle(options: &InstallOptions, manager: bool) -> MacosAppBundle { @@ -23,19 +23,23 @@ pub fn build_app_bundle(options: &InstallOptions, manager: bool) -> MacosAppBund } else { SILENT_BINARY }; - let target = option_or_current_exe( + let current_exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let target = option_or_current_exe_from_exe( + ¤t_exe, if manager { &options.manager_path } else { &options.launcher_path }, binary, + Some(&install_root), ); let identifier_suffix = if manager { ".manager" } else { "" }; MacosAppBundle { app_path: install_root.join(format!("{display_name}.app")), info_plist: info_plist(display_name, executable_name, identifier_suffix), launch_script: format!("#!/bin/sh\nexec \"{}\"\n", target.to_string_lossy()), + target_path: target, } } @@ -77,7 +81,9 @@ fn write_bundle(bundle: &MacosAppBundle) -> anyhow::Result<()> { 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)?; + if executable != bundle.target_path { + fs::write(&executable, &bundle.launch_script)?; + } let mut permissions = fs::metadata(&executable)?.permissions(); permissions.set_mode(0o755); fs::set_permissions(executable, permissions)?; @@ -85,6 +91,21 @@ fn write_bundle(bundle: &MacosAppBundle) -> anyhow::Result<()> { Ok(()) } +#[cfg(target_os = "macos")] +pub fn launch_script_writes_executable(bundle: &MacosAppBundle) -> bool { + let executable = bundle + .app_path + .join("Contents") + .join("MacOS") + .join(executable_name_from_plist(&bundle.info_plist)); + executable != bundle.target_path +} + +#[cfg(not(target_os = "macos"))] +pub fn launch_script_writes_executable(_bundle: &MacosAppBundle) -> bool { + true +} + #[cfg(target_os = "macos")] fn copy_icon(resources: &Path) -> anyhow::Result<()> { let source = std::env::current_exe() diff --git a/crates/codex-plus-core/src/install/mod.rs b/crates/codex-plus-core/src/install/mod.rs index 2a201a19..edc689a0 100644 --- a/crates/codex-plus-core/src/install/mod.rs +++ b/crates/codex-plus-core/src/install/mod.rs @@ -48,6 +48,7 @@ pub struct MacosAppBundle { pub app_path: PathBuf, pub info_plist: String, pub launch_script: String, + pub target_path: PathBuf, } impl ShortcutState { @@ -111,6 +112,10 @@ pub fn build_macos_app_bundle(options: &InstallOptions, manager: bool) -> MacosA macos::build_app_bundle(options, manager) } +pub fn macos_launch_script_writes_executable(bundle: &MacosAppBundle) -> bool { + macos::launch_script_writes_executable(bundle) +} + pub fn remove_owned_data() -> std::io::Result<()> { let dir = crate::paths::default_app_state_dir(); if dir.exists() { @@ -217,11 +222,20 @@ fn entrypoint_candidates(root: &Option, manager: bool) -> Vec } pub fn option_or_current_exe(value: &Option, binary: &str) -> PathBuf { + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); + option_or_current_exe_from_exe(&exe, value, binary, None) +} + +pub fn option_or_current_exe_from_exe( + exe: &Path, + value: &Option, + binary: &str, + install_root: Option<&Path>, +) -> PathBuf { if let Some(value) = value { return value.clone(); } - let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); - companion_binary_path_from_exe(&exe, binary) + companion_binary_path_from_exe_with_install_root(exe, binary, install_root) } pub fn companion_binary_path(binary: &str) -> PathBuf { @@ -230,12 +244,20 @@ pub fn companion_binary_path(binary: &str) -> PathBuf { } pub fn companion_binary_path_from_exe(exe: &Path, binary: &str) -> PathBuf { + companion_binary_path_from_exe_with_install_root(exe, binary, None) +} + +fn companion_binary_path_from_exe_with_install_root( + exe: &Path, + binary: &str, + install_root: Option<&Path>, +) -> PathBuf { let dir = exe.parent().unwrap_or_else(|| Path::new(".")); let suffix = if cfg!(windows) { ".exe" } else { "" }; + if let Some(app_binary) = macos_installed_app_binary_from_exe(exe, binary, install_root) { + return app_binary; + } if binary == SILENT_BINARY { - if let Some(sibling_app_binary) = macos_silent_app_binary_from_exe(exe) { - return sibling_app_binary; - } let same_bundle = dir.join(binary); if same_bundle.exists() { return same_bundle; @@ -244,14 +266,26 @@ pub fn companion_binary_path_from_exe(exe: &Path, binary: &str) -> PathBuf { dir.join(format!("{binary}{suffix}")) } -fn macos_silent_app_binary_from_exe(exe: &Path) -> Option { - macos_applications_dir_from_exe(exe).map(|applications_dir| { +fn macos_installed_app_binary_from_exe( + exe: &Path, + binary: &str, + install_root: Option<&Path>, +) -> Option { + let applications_dir = install_root + .map(Path::to_path_buf) + .or_else(|| macos_applications_dir_from_exe(exe))?; + let (app_name, executable_name) = match binary { + SILENT_BINARY => (SILENT_NAME, "CodexPlusPlus"), + MANAGER_BINARY => (MANAGER_NAME, "CodexPlusPlusManager"), + _ => return None, + }; + Some( applications_dir - .join(format!("{SILENT_NAME}.app")) + .join(format!("{app_name}.app")) .join("Contents") .join("MacOS") - .join("CodexPlusPlus") - }) + .join(executable_name), + ) } fn macos_applications_dir_from_exe(exe: &Path) -> Option { diff --git a/crates/codex-plus-core/tests/installers.rs b/crates/codex-plus-core/tests/installers.rs index c26f7004..075d5f9a 100644 --- a/crates/codex-plus-core/tests/installers.rs +++ b/crates/codex-plus-core/tests/installers.rs @@ -1,6 +1,7 @@ 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, + install_entrypoints, macos_launch_script_writes_executable, option_or_current_exe_from_exe, shortcut_names, }; @@ -94,6 +95,107 @@ fn companion_binary_path_resolves_macos_silent_app_next_to_manager_app() { ); } +#[test] +fn macos_manager_app_resolves_installed_silent_launcher_app() { + let manager_exe = std::path::Path::new( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager", + ); + let install_root = std::path::Path::new("/Applications"); + let target = + option_or_current_exe_from_exe(manager_exe, &None, SILENT_BINARY, Some(install_root)); + + assert_eq!( + target, + std::path::PathBuf::from("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus") + ); +} + +#[test] +fn macos_silent_app_resolves_installed_manager_app() { + let launcher_exe = + std::path::Path::new("/Applications/Codex++.app/Contents/MacOS/CodexPlusPlus"); + let install_root = std::path::Path::new("/Applications"); + let target = + option_or_current_exe_from_exe(launcher_exe, &None, MANAGER_BINARY, Some(install_root)); + + assert_eq!( + target, + std::path::PathBuf::from( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" + ) + ); +} + +#[test] +fn macos_manager_app_resolves_its_own_executable_for_manager_entry() { + let manager_exe = std::path::Path::new( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager", + ); + let install_root = std::path::Path::new("/Applications"); + let target = + option_or_current_exe_from_exe(manager_exe, &None, MANAGER_BINARY, Some(install_root)); + + assert_eq!( + target, + std::path::PathBuf::from( + "/Applications/Codex++ 管理工具.app/Contents/MacOS/CodexPlusPlusManager" + ) + ); +} + +#[test] +fn macos_installed_app_bundle_does_not_overwrite_its_own_executable_with_script() { + 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!(!macos_launch_script_writes_executable(&silent)); + assert!(!macos_launch_script_writes_executable(&manager)); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_install_preserves_existing_installed_app_executables() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let launcher = root + .join("Codex++.app") + .join("Contents") + .join("MacOS") + .join("CodexPlusPlus"); + let manager = root + .join("Codex++ 管理工具.app") + .join("Contents") + .join("MacOS") + .join("CodexPlusPlusManager"); + std::fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + std::fs::create_dir_all(manager.parent().unwrap()).unwrap(); + std::fs::write(&launcher, "launcher-binary").unwrap(); + std::fs::write(&manager, "manager-binary").unwrap(); + + let result = install_entrypoints(&InstallOptions { + install_root: Some(root.into()), + launcher_path: Some(launcher.clone()), + manager_path: Some(manager.clone()), + remove_owned_data: false, + }); + + assert_eq!(result.status, "ok"); + assert_eq!( + std::fs::read_to_string(launcher).unwrap(), + "launcher-binary" + ); + assert_eq!(std::fs::read_to_string(manager).unwrap(), "manager-binary"); +} + #[test] fn windows_default_install_root_uses_known_folder_before_userprofile_desktop() { let strategy = default_install_root_strategy();