From f94c49cf465b7403f22894f2db1432dee66fd64e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 1 Jun 2026 09:17:08 -0700 Subject: [PATCH] Use deep links for macOS codex app paths (#25485) ## Why `codex app [PATH]` is the documented CLI entry point for opening Codex Desktop on a workspace. Recent desktop builds can focus the app while failing to honor paths passed as macOS document-open arguments via `open -a Codex.app `, which broke `codex app .` for users. See #25333; related report: #25166. The desktop app still supports the explicit `codex://threads/new?path=...` route, so the CLI should use that app-owned launch surface instead of depending on folder-open event delivery. ## What Changed - Build a `codex://threads/new?path=` URL in the macOS app launcher. - Pass that URL to `open -a ` instead of passing the workspace path as a document argument. - Add coverage that workspace paths needing escaping round-trip through URL query encoding. ## Verification - `just test -p codex-cli codex_new_thread_url_encodes_workspace_path` --- codex-rs/cli/src/desktop_app/mac.rs | 38 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/codex-rs/cli/src/desktop_app/mac.rs b/codex-rs/cli/src/desktop_app/mac.rs index 10a47806b1..85928b551f 100644 --- a/codex-rs/cli/src/desktop_app/mac.rs +++ b/codex-rs/cli/src/desktop_app/mac.rs @@ -82,10 +82,11 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> "Opening workspace {workspace}...", workspace = workspace.display() ); + let url = codex_new_thread_url(workspace); let status = Command::new("open") .arg("-a") .arg(app_path) - .arg(workspace) + .arg(&url) .status() .await .context("failed to invoke `open`")?; @@ -95,12 +96,20 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> } anyhow::bail!( - "`open -a {app_path} {workspace}` exited with {status}", + "`open -a {app_path} {url}` exited with {status}", app_path = app_path.display(), - workspace = workspace.display() + url = url ); } +fn codex_new_thread_url(workspace: &Path) -> String { + let workspace = workspace.as_os_str().to_string_lossy(); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("path", workspace.as_ref()); + let query = serializer.finish(); + format!("codex://threads/new?{query}") +} + async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyhow::Result { let temp_dir = Builder::new() .prefix("codex-app-installer-") @@ -293,8 +302,10 @@ fn parse_hdiutil_attach_mount_point(output: &str) -> Option { #[cfg(test)] mod tests { + use super::codex_new_thread_url; use super::parse_hdiutil_attach_mount_point; use pretty_assertions::assert_eq; + use std::path::Path; #[test] fn parses_mount_point_from_tab_separated_hdiutil_output() { @@ -313,4 +324,25 @@ mod tests { Some("/Volumes/Codex Installer") ); } + + #[test] + fn codex_new_thread_url_encodes_workspace_path() { + let url = url::Url::parse(&codex_new_thread_url(Path::new("/tmp/codex workspace/#1"))) + .expect("deep link should parse"); + + assert_eq!( + ( + url.scheme().to_string(), + url.host_str().map(str::to_string), + url.path().to_string(), + url.query_pairs().into_owned().collect::>(), + ), + ( + "codex".to_string(), + Some("threads".to_string()), + "/new".to_string(), + vec![("path".to_string(), "/tmp/codex workspace/#1".to_string())], + ) + ); + } }