Compare commits

...

1 Commits

Author SHA1 Message Date
Edward Frazer
5e3834378f feat(cli): add Linux desktop app launcher 2026-05-07 01:42:47 +00:00
4 changed files with 154 additions and 4 deletions

View File

@@ -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
}
}

View File

@@ -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<String>,
) -> 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<PathBuf> {
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<PathBuf> {
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"
));
}
}

View File

@@ -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<String>,
) -> anyhow::Result<()> {
linux::run_linux_app_open_or_install(workspace, download_url_override).await
}

View File

@@ -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(),