diff --git a/codex-rs/cli/src/app_cmd.rs b/codex-rs/cli/src/app_cmd.rs index cb761c131..c28182b4c 100644 --- a/codex-rs/cli/src/app_cmd.rs +++ b/codex-rs/cli/src/app_cmd.rs @@ -1,21 +1,25 @@ use clap::Parser; use std::path::PathBuf; -const DEFAULT_CODEX_DMG_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/Codex.dmg"; - #[derive(Debug, Parser)] pub struct AppCommand { /// Workspace path to open in Codex Desktop. #[arg(value_name = "PATH", default_value = ".")] pub path: PathBuf, - /// Override the macOS DMG download URL (advanced). - #[arg(long, default_value = DEFAULT_CODEX_DMG_URL)] - pub download_url: String, + /// Override the app installer download URL (advanced). + #[arg(long = "download-url")] + pub download_url_override: Option, } -#[cfg(target_os = "macos")] pub async fn run_app(cmd: AppCommand) -> anyhow::Result<()> { let workspace = std::fs::canonicalize(&cmd.path).unwrap_or(cmd.path); - crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url).await + #[cfg(target_os = "macos")] + { + crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url_override).await + } + #[cfg(target_os = "windows")] + { + crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url_override).await + } } diff --git a/codex-rs/cli/src/desktop_app/mac.rs b/codex-rs/cli/src/desktop_app/mac.rs index d404f5b7c..10a47806b 100644 --- a/codex-rs/cli/src/desktop_app/mac.rs +++ b/codex-rs/cli/src/desktop_app/mac.rs @@ -1,12 +1,17 @@ use anyhow::Context as _; +use std::ffi::CString; use std::path::Path; use std::path::PathBuf; use tempfile::Builder; use tokio::process::Command; +const CODEX_DMG_URL_ARM64: &str = "https://persistent.oaistatic.com/codex-app-prod/Codex.dmg"; +const CODEX_DMG_URL_X64: &str = + "https://persistent.oaistatic.com/codex-app-prod/Codex-latest-x64.dmg"; + pub async fn run_mac_app_open_or_install( workspace: PathBuf, - download_url: String, + download_url_override: Option, ) -> anyhow::Result<()> { if let Some(app_path) = find_existing_codex_app_path() { eprintln!( @@ -17,6 +22,14 @@ pub async fn run_mac_app_open_or_install( return Ok(()); } eprintln!("Codex Desktop not found; downloading installer..."); + let download_url = download_url_override.unwrap_or_else(|| { + let default_url = if is_apple_silicon_mac() { + CODEX_DMG_URL_ARM64 + } else { + CODEX_DMG_URL_X64 + }; + default_url.to_string() + }); let installed_app = download_and_install_codex_to_user_applications(&download_url) .await .context("failed to download/install Codex Desktop")?; @@ -28,6 +41,28 @@ pub async fn run_mac_app_open_or_install( Ok(()) } +fn is_apple_silicon_mac() -> bool { + fn macos_sysctl_flag(name: &str) -> Option { + let name = CString::new(name).ok()?; + let mut value: libc::c_int = 0; + let mut size = std::mem::size_of_val(&value); + let result = unsafe { + libc::sysctlbyname( + name.as_ptr(), + (&mut value as *mut libc::c_int).cast::(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + (result == 0).then_some(value != 0) + } + + std::env::consts::ARCH == "aarch64" + || macos_sysctl_flag("sysctl.proc_translated").unwrap_or(false) + || macos_sysctl_flag("hw.optional.arm64").unwrap_or(false) +} + fn find_existing_codex_app_path() -> Option { candidate_codex_app_paths() .into_iter() diff --git a/codex-rs/cli/src/desktop_app/mod.rs b/codex-rs/cli/src/desktop_app/mod.rs index 7c42315a8..5a78341c0 100644 --- a/codex-rs/cli/src/desktop_app/mod.rs +++ b/codex-rs/cli/src/desktop_app/mod.rs @@ -1,11 +1,22 @@ #[cfg(target_os = "macos")] mod mac; +#[cfg(target_os = "windows")] +mod windows; /// Run the app install/open logic for the current OS. #[cfg(target_os = "macos")] pub async fn run_app_open_or_install( workspace: std::path::PathBuf, - download_url: String, + download_url_override: Option, ) -> anyhow::Result<()> { - mac::run_mac_app_open_or_install(workspace, download_url).await + mac::run_mac_app_open_or_install(workspace, download_url_override).await +} + +/// Run the app install/open logic for the current OS. +#[cfg(target_os = "windows")] +pub async fn run_app_open_or_install( + workspace: std::path::PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + windows::run_windows_app_open_or_install(workspace, download_url_override).await } diff --git a/codex-rs/cli/src/desktop_app/windows.rs b/codex-rs/cli/src/desktop_app/windows.rs new file mode 100644 index 000000000..932ca00cf --- /dev/null +++ b/codex-rs/cli/src/desktop_app/windows.rs @@ -0,0 +1,132 @@ +use anyhow::Context as _; +use std::path::Path; +use std::path::PathBuf; +use tokio::process::Command; + +const CODEX_WINDOWS_INSTALLER_URL: &str = + "https://get.microsoft.com/installer/download/9PLM9XGG6VKS?cid=website_cta_psi"; +const CODEX_MICROSOFT_STORE_WEB_URL: &str = "https://apps.microsoft.com/detail/9plm9xgg6vks"; + +pub async fn run_windows_app_open_or_install( + workspace: PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + if let Some(app_id) = find_codex_app_id().await? { + eprintln!("Opening Codex Desktop..."); + open_installed_codex_app(&app_id).await?; + eprintln!( + "In Codex Desktop, open workspace {workspace}.", + workspace = display_workspace_path(&workspace) + ); + return Ok(()); + } + + eprintln!("Codex Desktop not found; opening Windows installer..."); + let download_url = download_url_override + .as_deref() + .unwrap_or(CODEX_WINDOWS_INSTALLER_URL); + if open_url(download_url).await.is_err() && download_url_override.is_none() { + open_url(CODEX_MICROSOFT_STORE_WEB_URL).await?; + } + eprintln!( + "After installing Codex Desktop, open workspace {workspace}.", + workspace = display_workspace_path(&workspace) + ); + Ok(()) +} + +async fn find_codex_app_id() -> anyhow::Result> { + let output = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-Command") + .arg("Get-StartApps -Name 'Codex' | Select-Object -First 1 -ExpandProperty AppID") + .output() + .await + .context("failed to invoke `powershell.exe`")?; + + if !output.status.success() { + return Ok(None); + } + + let app_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if app_id.is_empty() { + Ok(None) + } else { + Ok(Some(app_id)) + } +} + +async fn open_installed_codex_app(app_id: &str) -> anyhow::Result<()> { + let target = format!("shell:AppsFolder\\{app_id}"); + open_shell_target(&target).await +} + +async fn open_url(url: &str) -> anyhow::Result<()> { + let status = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-Command") + .arg("& { param($target) Start-Process -FilePath $target }") + .arg(url) + .status() + .await + .with_context(|| format!("failed to open {url}"))?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("failed to open {url} with {status}"); + } +} + +async fn open_shell_target(target: &str) -> anyhow::Result<()> { + // Explorer can successfully hand off shell targets and still return exit code 1. + let _status = Command::new("explorer.exe") + .arg(target) + .status() + .await + .with_context(|| format!("failed to open {target}"))?; + + Ok(()) +} + +fn display_workspace_path(workspace: &Path) -> String { + let path = workspace.display().to_string(); + if let Some(path) = path.strip_prefix(r"\\?\UNC\") { + format!(r"\\{path}") + } else if let Some(path) = path.strip_prefix(r"\\?\") { + path.to_string() + } else { + path + } +} + +#[cfg(test)] +mod tests { + use super::display_workspace_path; + use pretty_assertions::assert_eq; + use std::path::Path; + + #[test] + fn display_workspace_path_removes_windows_extended_prefix() { + assert_eq!( + display_workspace_path(Path::new(r"\\?\C:\Users\fcoury\code\codex")), + r"C:\Users\fcoury\code\codex" + ); + } + + #[test] + fn display_workspace_path_preserves_unc_prefix() { + assert_eq!( + display_workspace_path(Path::new(r"\\?\UNC\server\share\codex")), + r"\\server\share\codex" + ); + } + + #[test] + fn display_workspace_path_leaves_regular_paths_unchanged() { + assert_eq!( + display_workspace_path(Path::new(r"C:\Users\fcoury\code\codex")), + r"C:\Users\fcoury\code\codex" + ); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7ed8262fd..ddee32929 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -34,9 +34,9 @@ use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] mod app_cmd; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; mod marketplace_cmd; mod mcp_cmd; @@ -120,8 +120,8 @@ enum Subcommand { /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), - /// Launch the Codex desktop app (downloads the macOS installer if missing). - #[cfg(target_os = "macos")] + /// Launch the Codex desktop app (opens the app installer if missing). + #[cfg(any(target_os = "macos", target_os = "windows"))] App(app_cmd::AppCommand), /// Generate shell completion scripts. @@ -808,7 +808,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(app_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(),