diff --git a/codex-rs/cli/src/app_cmd.rs b/codex-rs/cli/src/app_cmd.rs index c28182b4c5e4..10eeb2e4048b 100644 --- a/codex-rs/cli/src/app_cmd.rs +++ b/codex-rs/cli/src/app_cmd.rs @@ -22,4 +22,8 @@ pub async fn run_app(cmd: AppCommand) -> anyhow::Result<()> { { crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url_override).await } + #[cfg(target_os = "linux")] + { + crate::desktop_app::run_app_open_or_install(workspace, cmd.download_url_override).await + } } diff --git a/codex-rs/cli/src/desktop_app/linux.rs b/codex-rs/cli/src/desktop_app/linux.rs new file mode 100644 index 000000000000..9568d18dec83 --- /dev/null +++ b/codex-rs/cli/src/desktop_app/linux.rs @@ -0,0 +1,135 @@ +use anyhow::Context as _; +use std::path::Path; +use std::path::PathBuf; +use tokio::process::Command; + +pub async fn run_linux_app_open_or_install( + workspace: PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + if let Some(desktop_file) = find_existing_codex_desktop_file() { + eprintln!("Opening Codex Desktop..."); + open_codex_app(&desktop_file, &workspace).await?; + return Ok(()); + } + + if let Some(download_url) = download_url_override { + eprintln!("Codex Desktop not found; opening Linux installer..."); + open_url(&download_url).await?; + eprintln!( + "After installing Codex Desktop, rerun `codex app {workspace}`.", + workspace = workspace.display() + ); + return Ok(()); + } + + anyhow::bail!( + "Codex Desktop is not installed. Install the Linux desktop package, then rerun `codex app`." + ); +} + +fn find_existing_codex_desktop_file() -> Option { + candidate_desktop_file_dirs() + .into_iter() + .filter_map(|dir| std::fs::read_dir(dir).ok()) + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| is_codex_desktop_file(path)) +} + +fn candidate_desktop_file_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(xdg_data_home) = std::env::var_os("XDG_DATA_HOME") { + dirs.push(PathBuf::from(xdg_data_home).join("applications")); + } else if let Some(home) = std::env::var_os("HOME") { + dirs.push( + PathBuf::from(home) + .join(".local") + .join("share") + .join("applications"), + ); + } + + let data_dirs = std::env::var_os("XDG_DATA_DIRS") + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string()); + dirs.extend( + data_dirs + .split(':') + .filter(|dir| !dir.is_empty()) + .map(|dir| PathBuf::from(dir).join("applications")), + ); + dirs +} + +fn is_codex_desktop_file(path: &Path) -> bool { + if path.extension().and_then(|ext| ext.to_str()) != Some("desktop") { + return false; + } + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + desktop_file_declares_codex(&contents) +} + +fn desktop_file_declares_codex(contents: &str) -> bool { + contents.lines().any(|line| line.trim() == "Name=Codex") +} + +async fn open_codex_app(desktop_file: &Path, workspace: &Path) -> anyhow::Result<()> { + eprintln!( + "Opening workspace {workspace}...", + workspace = workspace.display() + ); + let status = Command::new("gio") + .arg("launch") + .arg(desktop_file) + .arg(workspace) + .status() + .await + .context("failed to invoke `gio launch`")?; + + if status.success() { + return Ok(()); + } + + anyhow::bail!( + "`gio launch {desktop_file} {workspace}` exited with {status}", + desktop_file = desktop_file.display(), + workspace = workspace.display() + ); +} + +async fn open_url(url: &str) -> anyhow::Result<()> { + let status = Command::new("xdg-open") + .arg(url) + .status() + .await + .with_context(|| format!("failed to open {url}"))?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("failed to open {url} with {status}"); + } +} + +#[cfg(test)] +mod tests { + use super::desktop_file_declares_codex; + + #[test] + fn recognizes_codex_desktop_file_name() { + assert!(desktop_file_declares_codex( + "[Desktop Entry]\nName=Codex\nExec=codex %U\n" + )); + } + + #[test] + fn ignores_other_desktop_file_names() { + assert!(!desktop_file_declares_codex( + "[Desktop Entry]\nName=Codex Nightly\nExec=codex-nightly %U\n" + )); + } +} diff --git a/codex-rs/cli/src/desktop_app/mod.rs b/codex-rs/cli/src/desktop_app/mod.rs index 5a78341c0783..8588d90cb871 100644 --- a/codex-rs/cli/src/desktop_app/mod.rs +++ b/codex-rs/cli/src/desktop_app/mod.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +mod linux; #[cfg(target_os = "macos")] mod mac; #[cfg(target_os = "windows")] @@ -20,3 +22,12 @@ pub async fn run_app_open_or_install( ) -> anyhow::Result<()> { windows::run_windows_app_open_or_install(workspace, download_url_override).await } + +/// Run the app open logic for Linux. +#[cfg(target_os = "linux")] +pub async fn run_app_open_or_install( + workspace: std::path::PathBuf, + download_url_override: Option, +) -> anyhow::Result<()> { + linux::run_linux_app_open_or_install(workspace, download_url_override).await +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7b6e7448d4d8..ab5d8c1bff45 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -39,9 +39,9 @@ use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] mod app_cmd; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] mod desktop_app; mod marketplace_cmd; mod mcp_cmd; @@ -127,7 +127,7 @@ enum Subcommand { AppServer(AppServerCommand), /// Launch the Codex desktop app (opens the app installer if missing). - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] App(app_cmd::AppCommand), /// Generate shell completion scripts. @@ -888,7 +888,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } } - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] Some(Subcommand::App(app_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(),