Support ChatGPT-branded Desktop app builds (#33901)

## Why

The Desktop app can use Codex or ChatGPT branding while retaining stable platform identities. CLI discovery and TUI handoff should not depend on a single display name or hardcoded executable path.

## What changed

- On macOS, search for both `ChatGPT.app` and `Codex.app`, and accept only bundles with the `com.openai.codex` identifier.
- On Windows, detect installs by their package app ID and resolve the `codex` protocol executable from the AppX manifest before handing off a TUI session.
- Use “Desktop app” consistently in CLI and TUI user-facing text.

## Testing

Add coverage for selecting a ChatGPT-named Codex bundle and rejecting the classic ChatGPT bundle.

GitOrigin-RevId: b84a56eb6e960712152e1a9b023ab530ec9a354a
This commit is contained in:
Eric Traut
2026-07-17 20:46:18 +00:00
committed by copyberry
parent bc5c9161b4
commit 50b8213385
12 changed files with 119 additions and 41 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ use std::path::PathBuf;
#[derive(Debug, Parser)]
pub struct AppCommand {
/// Workspace path to open in Codex Desktop.
/// Workspace path to open in the Desktop app.
#[arg(value_name = "PATH", default_value = ".")]
pub path: PathBuf,
+74 -14
View File
@@ -5,6 +5,7 @@ use std::path::PathBuf;
use tempfile::Builder;
use tokio::process::Command;
const CODEX_BUNDLE_IDENTIFIER: &str = "com.openai.codex";
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";
@@ -13,15 +14,15 @@ pub async fn run_mac_app_open_or_install(
workspace: PathBuf,
download_url_override: Option<String>,
) -> anyhow::Result<()> {
if let Some(app_path) = find_existing_codex_app_path() {
if let Some(app_path) = find_existing_codex_app_path(&codex_app_search_dirs()) {
eprintln!(
"Opening Codex Desktop at {app_path}...",
"Opening Desktop app at {app_path}...",
app_path = app_path.display()
);
open_codex_app(&app_path, &workspace).await?;
return Ok(());
}
eprintln!("Codex Desktop not found; downloading installer...");
eprintln!("Desktop app 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
@@ -32,9 +33,9 @@ pub async fn run_mac_app_open_or_install(
});
let installed_app = download_and_install_codex_to_user_applications(&download_url)
.await
.context("failed to download/install Codex Desktop")?;
.context("failed to download/install Desktop app")?;
eprintln!(
"Launching Codex Desktop from {installed_app}...",
"Launching Desktop app from {installed_app}...",
installed_app = installed_app.display()
);
open_codex_app(&installed_app, &workspace).await?;
@@ -63,20 +64,40 @@ fn is_apple_silicon_mac() -> bool {
|| macos_sysctl_flag("hw.optional.arm64").unwrap_or(false)
}
fn find_existing_codex_app_path() -> Option<PathBuf> {
candidate_codex_app_paths()
.into_iter()
.find(|candidate| candidate.is_dir())
fn find_existing_codex_app_path(applications_dirs: &[PathBuf]) -> Option<PathBuf> {
applications_dirs
.iter()
.flat_map(|dir| ["ChatGPT.app", "Codex.app"].map(|app_name| dir.join(app_name)))
.find(|candidate| is_codex_app_bundle(candidate))
}
fn candidate_codex_app_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/Applications/Codex.app")];
fn codex_app_search_dirs() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/Applications")];
if let Some(home) = std::env::var_os("HOME") {
paths.push(PathBuf::from(home).join("Applications").join("Codex.app"));
paths.push(PathBuf::from(home).join("Applications"));
}
paths
}
fn is_codex_app_bundle(app_path: &Path) -> bool {
if !app_path.is_dir() {
return false;
}
std::process::Command::new("/usr/bin/plutil")
.arg("-extract")
.arg("CFBundleIdentifier")
.arg("raw")
.arg("-o")
.arg("-")
.arg(app_path.join("Contents/Info.plist"))
.output()
.is_ok_and(|output| {
output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim() == CODEX_BUNDLE_IDENTIFIER
})
}
async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> {
eprintln!(
"Opening workspace {workspace}...",
@@ -121,7 +142,7 @@ async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyho
let dmg_path = tmp_root.join("Codex.dmg");
download_dmg(dmg_url, &dmg_path).await?;
eprintln!("Mounting Codex Desktop installer...");
eprintln!("Mounting Desktop app installer...");
let mount_point = mount_dmg(&dmg_path).await?;
eprintln!(
"Installer mounted at {mount_point}.",
@@ -148,7 +169,7 @@ async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyho
async fn install_codex_app_bundle(app_in_volume: &Path) -> anyhow::Result<PathBuf> {
for applications_dir in candidate_applications_dirs()? {
eprintln!(
"Installing Codex Desktop into {applications_dir}...",
"Installing Desktop app into {applications_dir}...",
applications_dir = applications_dir.display()
);
std::fs::create_dir_all(&applications_dir).with_context(|| {
@@ -303,10 +324,49 @@ fn parse_hdiutil_attach_mount_point(output: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::codex_new_thread_url;
use super::find_existing_codex_app_path;
use super::parse_hdiutil_attach_mount_point;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
fn write_app_bundle(app_path: &Path, bundle_identifier: &str) {
let contents_path = app_path.join("Contents");
fs::create_dir_all(&contents_path).expect("create app bundle");
fs::write(
contents_path.join("Info.plist"),
format!(
r#"<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>{bundle_identifier}</string></dict></plist>"#
),
)
.expect("write Info.plist");
}
#[test]
fn finds_chatgpt_app_with_codex_bundle_identifier() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let app_path = temp_dir.path().join("ChatGPT.app");
write_app_bundle(&app_path, "com.openai.codex");
assert_eq!(
find_existing_codex_app_path(&[temp_dir.path().to_path_buf()]),
Some(app_path)
);
}
#[test]
fn ignores_classic_chatgpt_app() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
write_app_bundle(&temp_dir.path().join("ChatGPT.app"), "com.openai.chat");
let codex_app_path = temp_dir.path().join("Codex.app");
write_app_bundle(&codex_app_path, "com.openai.codex");
assert_eq!(
find_existing_codex_app_path(&[temp_dir.path().to_path_buf()]),
Some(codex_app_path)
);
}
#[test]
fn parses_mount_point_from_tab_separated_hdiutil_output() {
let output = "/dev/disk2s1\tApple_HFS\tCodex\t/Volumes/Codex\n";
+7 -4
View File
@@ -14,27 +14,30 @@ pub async fn run_windows_app_open_or_install(
let workspace_path = workspace.display().to_string();
let display_workspace = display_workspace_path(&workspace);
if codex_app_is_installed().await? {
eprintln!("Opening Codex Desktop workspace {display_workspace}...");
eprintln!("Opening workspace {display_workspace} in the Desktop app...");
open_url(&codex_new_thread_url(&workspace_path)).await?;
return Ok(());
}
eprintln!("Codex Desktop not found; opening Windows installer...");
eprintln!("Desktop app 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 {display_workspace}.");
eprintln!("After installing the Desktop app, open workspace {display_workspace}.");
Ok(())
}
async fn codex_app_is_installed() -> anyhow::Result<bool> {
// This package identity is stable across Codex- and ChatGPT-branded builds.
let output = Command::new("powershell.exe")
.arg("-NoProfile")
.arg("-Command")
.arg("Get-StartApps -Name 'Codex' | Select-Object -First 1 -ExpandProperty AppID")
.arg(
"Get-StartApps | Where-Object AppID -Like 'OpenAI.Codex_*!App' | Select-Object -First 1 -ExpandProperty AppID",
)
.output()
.await
.context("failed to invoke `powershell.exe`")?;
+1 -1
View File
@@ -150,7 +150,7 @@ enum Subcommand {
/// [experimental] Manage the app-server daemon with remote control enabled.
RemoteControl(RemoteControlCommand),
/// Launch the Codex desktop app (opens the app installer if missing).
/// Launch the Desktop app (opens the app installer if missing).
#[cfg(any(target_os = "macos", target_os = "windows"))]
App(app_cmd::AppCommand),
+27 -12
View File
@@ -5,7 +5,7 @@
use super::*;
const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in Codex Desktop.";
const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in the Desktop app.";
impl App {
pub(super) fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Box<dyn HistoryCell>) {
@@ -179,7 +179,7 @@ impl App {
fn desktop_thread_open_error_message(err: &str) -> String {
format!(
"Failed to open this session in Codex Desktop: {err}. Install or launch Codex Desktop and try again."
"Failed to open this session in the Desktop app: {err}. Install or launch the Desktop app and try again."
)
}
@@ -205,7 +205,7 @@ fn open_desktop_thread_url(url: &str) -> Result<(), String> {
.arg("-Command")
.arg(&script)
.output()
.map_err(|err| format!("failed to launch Codex Desktop through PowerShell: {err}"))?;
.map_err(|err| format!("failed to launch the Desktop app through PowerShell: {err}"))?;
if output.status.success() {
return Ok(());
@@ -214,7 +214,7 @@ fn open_desktop_thread_url(url: &str) -> Result<(), String> {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!(
"failed to launch Codex Desktop through PowerShell with {}",
"failed to launch the Desktop app through PowerShell with {}",
output.status
))
} else {
@@ -230,21 +230,36 @@ fn windows_desktop_app_launch_script(url: &str) -> String {
$ErrorActionPreference = 'Stop'
$url = {url}
$installLocation = (Get-AppxPackage -Name OpenAI.Codex -ErrorAction SilentlyContinue).InstallLocation
if ([string]::IsNullOrWhiteSpace($installLocation)) {{
Write-Error 'Codex Desktop package is not installed'
$package = Get-AppxPackage -Name OpenAI.Codex -ErrorAction SilentlyContinue
if ($null -eq $package) {{
Write-Error 'Desktop app package is not installed'
exit 1
}}
$appDir = Join-Path $installLocation 'app'
$exe = Join-Path $appDir 'Codex.exe'
$manifest = Get-AppxPackageManifest -Package $package.PackageFullName
$application = $manifest.Package.Applications.Application |
Where-Object {{
@($_.Extensions.Extension) | Where-Object {{
$_.Category -eq 'windows.protocol' -and $_.Protocol.Name -eq 'codex'
}}
}} |
Select-Object -First 1
if ($null -eq $application -or [string]::IsNullOrWhiteSpace($application.Executable)) {{
Write-Error 'Desktop app package does not declare a codex protocol executable'
exit 1
}}
# Launch the package-declared protocol executable rather than an internal Electron shim.
# Windows can deny direct starts of internal executables under WindowsApps.
$exe = Join-Path $package.InstallLocation $application.Executable
$appDir = Split-Path -Parent $exe
$app = Join-Path $appDir 'resources\app.asar'
if (-not (Test-Path $exe)) {{
Write-Error "Codex Desktop executable not found at $exe"
Write-Error "Desktop app executable not found at $exe"
exit 1
}}
if (-not (Test-Path $app)) {{
Write-Error "Codex Desktop app bundle not found at $app"
Write-Error "Desktop app bundle not found at $app"
exit 1
}}
@@ -260,7 +275,7 @@ fn powershell_single_quoted_string(value: &str) -> String {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn open_desktop_thread_url(_url: &str) -> Result<(), String> {
Err("Codex Desktop is only available on macOS and Windows".to_string())
Err("The Desktop app is only available on macOS and Windows".to_string())
}
#[cfg(test)]
@@ -2,4 +2,4 @@
source: tui/src/app/history_ui_tests.rs
expression: render_cell(&cell)
---
■ Failed to open this session in Codex Desktop: launch failed. Install or launch Codex Desktop and try again.
■ Failed to open this session in the Desktop app: launch failed. Install or launch the Desktop app and try again.
@@ -2,4 +2,4 @@
source: tui/src/app/history_ui_tests.rs
expression: render_cell(&cell)
---
• Opened this session in Codex Desktop.
• Opened this session in the Desktop app.
@@ -5,13 +5,13 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 2 },
content: [
" /app continue this session in Codex Desktop ",
" /app continue this session in the Desktop app ",
" /approve approve one retry of a recent auto-review denial ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 50, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 52, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 6, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -20,7 +20,7 @@ expression: commands
/delete - permanently delete this session and exit
/resume - resume a saved chat
/fork - fork the current chat
/app - continue this session in Codex Desktop
/app - continue this session in the Desktop app
/init - create an AGENTS.md file with instructions for Codex
/compact - summarize conversation to prevent hitting the context limit
/agent - switch the active agent thread
+1 -1
View File
@@ -93,7 +93,7 @@ impl SlashCommand {
SlashCommand::Delete => "permanently delete this session and exit",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
SlashCommand::App => "continue this session in Codex Desktop",
SlashCommand::App => "continue this session in the Desktop app",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
+2 -2
View File
@@ -9,10 +9,10 @@ const ANNOUNCEMENT_TIP_URL: &str =
const IS_MACOS: bool = cfg!(target_os = "macos");
const IS_WINDOWS: bool = cfg!(target_os = "windows");
const APP_TOOLTIP: &str = "Try the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const APP_TOOLTIP: &str = "Try the **Desktop app**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const FAST_TOOLTIP: &str =
"*New* Use **/fast** to enable our fastest inference with increased plan usage.";
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Desktop app**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex.";
const FREE_GO_TOOLTIP: &str =
"*New* For a limited time, Codex is included in your plan for free – let’s build together.";
+1 -1
View File
@@ -11,7 +11,7 @@ Use /fork to branch the current chat into a new thread.
Use /side to start a side conversation in a temporary fork without polluting the main thread.
Use /init to create an AGENTS.md with project-specific guidance.
Use /mcp to list configured MCP tools.
Run `codex app` to open Codex Desktop (it installs on macOS if needed).
Run `codex app` to open the Desktop app (it installs on macOS if needed).
Use /personality to customize how Codex communicates.
Use /rename to rename your threads for easier thread resuming.
Use the OpenAI docs MCP for API questions; enable it with `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp`.