mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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,
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`")?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user