From 6426876fa3580a660e310da99b851b124659da25 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 20 May 2026 04:02:21 +0300 Subject: [PATCH 1/5] Follow Linux release assets for updater --- README.md | 4 +- updater/src/app.rs | 48 +++-- updater/src/main.rs | 1 + updater/src/upstream.rs | 461 +++++++++++++++++++++++++++++++++++----- 4 files changed, 444 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 099ff7ae..5ab2e9fd 100644 --- a/README.md +++ b/README.md @@ -280,8 +280,8 @@ CODEX_MULTI_LAUNCH=1 CODEX_MULTI_LAUNCH_PORT_RANGE=5175-5199 ./codex-app/start.s By default, the native package installs a companion `systemd --user` service named `codex-update-manager`. -- It checks upstream `Codex.dmg` on daemon startup, every 6 hours, and in the background on app launch when stale. -- When a new DMG is available, it rebuilds a local native package with `/opt/codex-desktop/update-builder`. +- It checks this repository's GitHub Releases on daemon startup, every 6 hours, and in the background on app launch when stale. +- When a newer release is available, it downloads the matching `.deb`, `.rpm`, or `.pkg.tar.*` asset for the current system. - If Codex Desktop is open, the final install waits until Electron exits. - The updater runs unprivileged and uses `pkexec` only for the final package install. - Codex CLI checks are best-effort and launcher-scoped. Set `CODEX_SYNC_CLI_PREFLIGHT=1` when debugging launch-time CLI preflight. diff --git a/updater/src/app.rs b/updater/src/app.rs index 7bbfbd33..83c82b15 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -1,7 +1,6 @@ //! Application entrypoints and orchestration for the local updater daemon. use crate::{ - builder, cli::{Cli, Commands}, codex_cli, config::{RuntimeConfig, RuntimePaths}, @@ -539,7 +538,24 @@ async fn run_check_cycle( persist_state(paths, state)?; let result: Result<()> = async { - let metadata = upstream::fetch_remote_metadata(&client, &config.dmg_url).await?; + let package_kind = install::PackageKind::detect(); + let metadata = match upstream::fetch_remote_metadata( + &client, + upstream::DEFAULT_RELEASES_API_URL, + package_kind, + ) + .await + { + Ok(metadata) => metadata, + Err(error) if error.downcast_ref::().is_some() => { + state.remote_headers_fingerprint = None; + state.last_successful_check_at = Some(Utc::now()); + set_status(state, paths, UpdateStatus::Idle)?; + info!("Linux release repository has no published releases yet"); + return Ok(()); + } + Err(error) => return Err(error), + }; let previous_headers_fingerprint = state.remote_headers_fingerprint.clone(); state.remote_headers_fingerprint = Some(metadata.headers_fingerprint.clone()); state.last_successful_check_at = Some(Utc::now()); @@ -556,8 +572,15 @@ async fn run_check_cycle( set_status(state, paths, UpdateStatus::DownloadingDmg)?; let downloads_dir = config.workspace_root.join("downloads"); - let downloaded = - upstream::download_dmg(&client, &config.dmg_url, &downloads_dir, Utc::now()).await?; + let downloaded = upstream::download_dmg( + &client, + &metadata.download_url, + &metadata.asset_name, + &downloads_dir, + Utc::now(), + &metadata.candidate_version, + ) + .await?; if state .rollback_blocked_candidate_version @@ -583,9 +606,9 @@ async fn run_check_cycle( && !retrying_failed_update { state.status = UpdateStatus::Idle; - state.artifact_paths.dmg_path = Some(downloaded.path); + state.artifact_paths.package_path = Some(downloaded.path); persist_state(paths, state)?; - info!("downloaded DMG hash matches current cached DMG; no update detected"); + info!("downloaded release package hash matches current cached package; no update detected"); return Ok(()); } @@ -593,7 +616,9 @@ async fn run_check_cycle( state.status = UpdateStatus::UpdateDetected; state.candidate_version = Some(downloaded.candidate_version); state.dmg_sha256 = Some(downloaded.sha256); - state.artifact_paths.dmg_path = Some(downloaded.path.clone()); + state.artifact_paths.dmg_path = None; + state.artifact_paths.workspace_dir = None; + state.artifact_paths.package_path = Some(downloaded.path.clone()); state.notified_events.clear(); state.save(&paths.state_file)?; @@ -603,14 +628,11 @@ async fn run_check_cycle( config.notifications, "update_detected", "New Codex Desktop update detected", - "Preparing a local Linux package from the new upstream DMG.", + "Downloaded the matching package from the Codex Desktop Linux release.", )?; - let candidate_version = state - .candidate_version - .clone() - .expect("candidate version should be set before local build"); - builder::build_update(config, state, paths, &candidate_version, &downloaded.path).await?; + state.status = UpdateStatus::ReadyToInstall; + state.save(&paths.state_file)?; maybe_notify_update_ready(state, paths, config.notifications)?; Ok(()) } diff --git a/updater/src/main.rs b/updater/src/main.rs index 991231ea..eb9042ba 100644 --- a/updater/src/main.rs +++ b/updater/src/main.rs @@ -1,6 +1,7 @@ //! Binary entrypoint for the local Codex Desktop update manager. mod app; +#[cfg(test)] mod builder; mod cli; mod codex_cli; diff --git a/updater/src/upstream.rs b/updater/src/upstream.rs index 2a6687c3..e1aeccd9 100644 --- a/updater/src/upstream.rs +++ b/updater/src/upstream.rs @@ -1,60 +1,134 @@ -//! Upstream DMG metadata and download helpers. +//! Linux release metadata and package download helpers. +use crate::install::PackageKind; use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use futures_util::StreamExt; use reqwest::{header, Client}; +use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; +use std::{ + fmt, + path::{Path, PathBuf}, +}; use tokio::{fs::File, io::AsyncWriteExt}; +pub const DEFAULT_RELEASES_API_URL: &str = + "https://api.github.com/repos/ilysenko/codex-desktop-linux/releases"; + +const USER_AGENT: &str = "codex-update-manager"; +const GITHUB_ACCEPT: &str = "application/vnd.github+json"; +const PACMAN_SUFFIXES: &[&str] = &[ + ".pkg.tar.zst", + ".pkg.tar.xz", + ".pkg.tar.gz", + ".pkg.tar.bz2", + ".pkg.tar.lz", + ".pkg.tar.lz4", + ".pkg.tar.lz5", +]; + #[derive(Debug, Clone, PartialEq, Eq)] -/// Selected HTTP metadata used to detect upstream DMG changes. +/// Selected release metadata used to detect Linux package updates. pub struct RemoteMetadata { pub etag: Option, pub last_modified: Option, pub content_length: Option, pub headers_fingerprint: String, + pub download_url: String, + pub asset_name: String, + pub candidate_version: String, } #[derive(Debug, Clone, PartialEq, Eq)] -/// Result of downloading the current upstream DMG snapshot. +/// Result of downloading the selected Linux release package. pub struct DownloadedDmg { pub path: PathBuf, pub sha256: String, pub candidate_version: String, } -/// Fetches the upstream DMG headers used to detect candidate updates. -pub async fn fetch_remote_metadata(client: &Client, dmg_url: &str) -> Result { +#[derive(Debug)] +/// Returned when the Linux repo has not published any releases yet. +pub struct NoPublishedReleases; + +impl fmt::Display for NoPublishedReleases { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("no published Codex Desktop Linux releases were found") + } +} + +impl std::error::Error for NoPublishedReleases {} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + id: u64, + tag_name: String, + draft: bool, + prerelease: bool, + published_at: Option>, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubAsset { + id: u64, + name: String, + size: Option, + browser_download_url: String, + updated_at: Option>, +} + +/// Fetches the latest published Linux repo release and package asset for this system. +pub async fn fetch_remote_metadata( + client: &Client, + releases_api_url: &str, + package_kind: PackageKind, +) -> Result { let response = client - .head(dmg_url) + .get(releases_api_url) + .header(header::ACCEPT, GITHUB_ACCEPT) + .header(header::USER_AGENT, USER_AGENT) .send() .await - .with_context(|| format!("Failed HEAD request for {dmg_url}"))? + .with_context(|| format!("Failed GET request for {releases_api_url}"))? .error_for_status() - .with_context(|| format!("HEAD request for {dmg_url} returned an error status"))?; - - let etag = response - .headers() - .get(header::ETAG) - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - let last_modified = response - .headers() - .get(header::LAST_MODIFIED) - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - let content_length = response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()); + .with_context(|| { + format!("GitHub releases request for {releases_api_url} returned an error status") + })?; + let etag = header_string(response.headers(), header::ETAG); + let last_modified = header_string(response.headers(), header::LAST_MODIFIED); + let body = response + .text() + .await + .with_context(|| format!("Failed reading GitHub releases body from {releases_api_url}"))?; + let releases = serde_json::from_str::>(&body).with_context(|| { + format!("Failed to parse GitHub releases response from {releases_api_url}") + })?; + let release = select_latest_release(&releases)?; + let asset = select_package_asset(release, package_kind)?; + let candidate_version = candidate_version_from_asset_name(package_kind, &asset.name) + .or_else(|| candidate_version_from_release_tag(&release.tag_name)) + .with_context(|| format!("Could not derive package version from {}", asset.name))?; + let asset_updated_at = asset + .updated_at + .map(|value| value.to_rfc3339()) + .unwrap_or_default(); + let content_length = asset.size; let headers_fingerprint = format!( - "etag={}|last_modified={}|content_length={}", + "release_api={releases_api_url}|etag={}|last_modified={}|release_id={}|tag={}|published_at={}|asset_id={}|asset_name={}|asset_size={}|asset_updated_at={asset_updated_at}", etag.as_deref().unwrap_or(""), last_modified.as_deref().unwrap_or(""), + release.id, + release.tag_name, + release + .published_at + .map(|value| value.to_rfc3339()) + .as_deref() + .unwrap_or(""), + asset.id, + asset.name, content_length .map(|value| value.to_string()) .as_deref() @@ -66,38 +140,44 @@ pub async fn fetch_remote_metadata(client: &Client, dmg_url: &str) -> Result, + candidate_version: &str, ) -> Result { tokio::fs::create_dir_all(destination_dir) .await .with_context(|| format!("Failed to create {}", destination_dir.display()))?; - let destination = destination_dir.join("Codex.dmg"); + let destination = destination_dir.join(safe_asset_file_name(asset_name)); let mut file = File::create(&destination) .await .with_context(|| format!("Failed to create {}", destination.display()))?; let response = client - .get(dmg_url) + .get(download_url) + .header(header::USER_AGENT, USER_AGENT) .send() .await - .with_context(|| format!("Failed GET request for {dmg_url}"))? + .with_context(|| format!("Failed GET request for {download_url}"))? .error_for_status() - .with_context(|| format!("GET request for {dmg_url} returned an error status"))?; + .with_context(|| format!("GET request for {download_url} returned an error status"))?; let mut hasher = Sha256::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = chunk.with_context(|| format!("Failed downloading {dmg_url}"))?; + let chunk = chunk.with_context(|| format!("Failed downloading {download_url}"))?; file.write_all(&chunk) .await .with_context(|| format!("Failed writing {}", destination.display()))?; @@ -113,7 +193,11 @@ pub async fn download_dmg( .iter() .map(|byte| format!("{byte:02x}")) .collect::(); - let candidate_version = derive_candidate_version(&sha256, version_timestamp)?; + let candidate_version = if candidate_version.is_empty() { + derive_candidate_version(&sha256, version_timestamp)? + } else { + candidate_version.to_string() + }; Ok(DownloadedDmg { path: destination, @@ -122,7 +206,7 @@ pub async fn download_dmg( }) } -/// Derives a local package version from the DMG hash and download timestamp. +/// Derives a local package version from a package hash and download timestamp. pub fn derive_candidate_version(sha256: &str, timestamp: DateTime) -> Result { let short_hash = sha256 .get(0..8) @@ -134,6 +218,176 @@ pub fn derive_candidate_version(sha256: &str, timestamp: DateTime) -> Resul )) } +fn header_string(headers: &header::HeaderMap, name: header::HeaderName) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn select_latest_release(releases: &[GithubRelease]) -> Result<&GithubRelease> { + let mut releases = releases + .iter() + .filter(|release| !release.draft) + .collect::>(); + if releases.is_empty() { + return Err(NoPublishedReleases.into()); + } + + releases.sort_by_key(|release| release.published_at); + releases.reverse(); + Ok(releases + .iter() + .copied() + .find(|release| !release.prerelease) + .or_else(|| releases.first().copied()) + .expect("non-empty releases should have first element")) +} + +fn select_package_asset( + release: &GithubRelease, + package_kind: PackageKind, +) -> Result<&GithubAsset> { + release + .assets + .iter() + .find(|asset| { + asset_matches_package_kind(&asset.name, package_kind) + && asset_arch_is_compatible(&asset.name) + }) + .ok_or_else(|| { + let assets = release + .assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>() + .join(", "); + anyhow!( + "Release {} has no matching {} package for {}; available assets: {}", + release.tag_name, + package_kind_label(package_kind), + std::env::consts::ARCH, + if assets.is_empty() { "none" } else { &assets } + ) + }) +} + +fn asset_matches_package_kind(name: &str, package_kind: PackageKind) -> bool { + let name = name.to_ascii_lowercase(); + match package_kind { + PackageKind::Deb => name.ends_with(".deb"), + PackageKind::Rpm => name.ends_with(".rpm"), + PackageKind::Pacman => PACMAN_SUFFIXES.iter().any(|suffix| name.ends_with(suffix)), + } +} + +fn asset_arch_is_compatible(name: &str) -> bool { + let normalized = normalize_asset_name(name); + let known_arches = [ + "amd64", "x86_64", "arm64", "aarch64", "armhf", "armv7", "i386", "i686", + ]; + let asset_arch = known_arches + .iter() + .find(|arch| normalized.contains(&format!("_{arch}_"))); + match asset_arch { + Some(arch) => current_arch_aliases().contains(arch), + None => true, + } +} + +fn current_arch_aliases() -> &'static [&'static str] { + match std::env::consts::ARCH { + "x86_64" => &["amd64", "x86_64"], + "aarch64" => &["arm64", "aarch64"], + "x86" | "i686" => &["i386", "i686"], + "arm" => &["armhf", "armv7"], + _ => &[], + } +} + +fn normalize_asset_name(name: &str) -> String { + let mut normalized = String::with_capacity(name.len() + 2); + normalized.push('_'); + for character in name.chars() { + if character.is_ascii_alphanumeric() { + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push('_'); + } + } + normalized.push('_'); + normalized +} + +fn candidate_version_from_asset_name( + package_kind: PackageKind, + asset_name: &str, +) -> Option { + match package_kind { + PackageKind::Deb => asset_name + .strip_suffix(".deb")? + .split('_') + .nth(1) + .filter(|version| !version.is_empty()) + .map(str::to_string), + PackageKind::Rpm => candidate_version_from_native_asset(asset_name, ".rpm"), + PackageKind::Pacman => PACMAN_SUFFIXES.iter().find_map(|suffix| { + asset_name + .strip_suffix(suffix) + .and_then(|name| candidate_version_from_native_asset(name, "")) + }), + } +} + +fn candidate_version_from_native_asset(asset_name: &str, suffix: &str) -> Option { + let without_suffix = asset_name.strip_suffix(suffix).unwrap_or(asset_name); + let without_arch = strip_arch_suffix(without_suffix)?; + without_arch + .strip_prefix("codex-desktop-") + .filter(|version| !version.is_empty()) + .map(str::to_string) +} + +fn strip_arch_suffix(value: &str) -> Option<&str> { + for arch in [ + "amd64", "x86_64", "arm64", "aarch64", "armhf", "armv7", "i386", "i686", + ] { + for separator in ['.', '-'] { + if let Some(stripped) = value.strip_suffix(&format!("{separator}{arch}")) { + return Some(stripped); + } + } + } + None +} + +fn candidate_version_from_release_tag(tag_name: &str) -> Option { + let tag = tag_name.trim().trim_start_matches('v'); + if tag.is_empty() { + None + } else { + Some(tag.to_string()) + } +} + +fn package_kind_label(package_kind: PackageKind) -> &'static str { + match package_kind { + PackageKind::Deb => "Debian", + PackageKind::Rpm => "RPM", + PackageKind::Pacman => "pacman", + } +} + +fn safe_asset_file_name(asset_name: &str) -> String { + asset_name + .chars() + .map(|character| match character { + '/' | '\\' | '\0' => '_', + _ => character, + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -146,38 +400,98 @@ mod tests { }; #[tokio::test] - async fn fetches_remote_metadata_from_head() -> Result<()> { + async fn fetches_linux_release_metadata_for_current_package_kind() -> Result<()> { let server = MockServer::start().await; - Mock::given(method("HEAD")) - .and(path("/Codex.dmg")) + let releases = format!( + r#"[ + {{ + "id": 1, + "tag_name": "v2026.05.19.111111", + "draft": false, + "prerelease": false, + "published_at": "2026-05-19T11:11:11Z", + "assets": [] + }}, + {{ + "id": 2, + "tag_name": "v2026.05.20.222222", + "draft": false, + "prerelease": false, + "published_at": "2026-05-20T22:22:22Z", + "assets": [ + {{ + "id": 21, + "name": "codex-desktop_2026.05.20.222222+new_amd64.deb", + "size": 300, + "browser_download_url": "{}/current.deb", + "updated_at": "2026-05-20T22:24:00Z" + }} + ] + }} +]"#, + server.uri() + ); + Mock::given(method("GET")) + .and(path("/releases")) .respond_with( ResponseTemplate::new(200) - .insert_header("ETag", "\"abc\"") - .insert_header("Last-Modified", "Tue, 25 Mar 2026 00:00:00 GMT") - .insert_header("Content-Length", "42"), + .insert_header("ETag", "\"release-list\"") + .set_body_string(releases), ) .mount(&server) .await; let client = Client::builder().build()?; - let metadata = - fetch_remote_metadata(&client, &format!("{}/Codex.dmg", server.uri())).await?; - assert_eq!(metadata.etag.as_deref(), Some("\"abc\"")); + let metadata = fetch_remote_metadata( + &client, + &format!("{}/releases", server.uri()), + PackageKind::Deb, + ) + .await?; + + assert_eq!(metadata.etag.as_deref(), Some("\"release-list\"")); + assert_eq!(metadata.content_length, Some(300)); assert_eq!( - metadata.last_modified.as_deref(), - Some("Tue, 25 Mar 2026 00:00:00 GMT") + metadata.download_url, + format!("{}/current.deb", server.uri()) ); - assert_eq!(metadata.content_length, Some(42)); - assert!(metadata.headers_fingerprint.contains("etag=\"abc\"")); + assert_eq!( + metadata.asset_name, + "codex-desktop_2026.05.20.222222+new_amd64.deb" + ); + assert_eq!(metadata.candidate_version, "2026.05.20.222222+new"); + assert!(metadata.headers_fingerprint.contains("asset_id=21")); Ok(()) } #[tokio::test] - async fn downloads_dmg_and_hashes_contents() -> Result<()> { + async fn no_published_releases_is_not_a_parse_error() -> Result<()> { let server = MockServer::start().await; - let body = b"codex-dmg-test-payload"; Mock::given(method("GET")) - .and(path("/Codex.dmg")) + .and(path("/releases")) + .respond_with(ResponseTemplate::new(200).set_body_string("[]")) + .mount(&server) + .await; + + let client = Client::builder().build()?; + let error = fetch_remote_metadata( + &client, + &format!("{}/releases", server.uri()), + PackageKind::Deb, + ) + .await + .expect_err("empty releases should fail"); + + assert!(error.downcast_ref::().is_some()); + Ok(()) + } + + #[tokio::test] + async fn downloads_release_package_and_hashes_contents() -> Result<()> { + let server = MockServer::start().await; + let body = b"codex-linux-release-package"; + Mock::given(method("GET")) + .and(path("/codex-desktop_2026.05.20.222222+new_amd64.deb")) .respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec())) .mount(&server) .await; @@ -186,21 +500,58 @@ mod tests { let temp = tempdir()?; let downloaded = download_dmg( &client, - &format!("{}/Codex.dmg", server.uri()), + &format!( + "{}/codex-desktop_2026.05.20.222222+new_amd64.deb", + server.uri() + ), + "codex-desktop_2026.05.20.222222+new_amd64.deb", temp.path(), Utc.with_ymd_and_hms(2026, 3, 24, 12, 0, 0).unwrap(), + "2026.05.20.222222+new", ) .await?; - assert_eq!(downloaded.path, temp.path().join("Codex.dmg")); + assert_eq!( + downloaded.path, + temp.path() + .join("codex-desktop_2026.05.20.222222+new_amd64.deb") + ); assert_eq!( downloaded.sha256, - "678cd508ffe0071e217020a7a4eecbebe25362c022ac78c13a5ae87b7a3a0c92" + "4728cddbf2d004106cfee05e16a5fdcc6db6dbb077876e575fc9cf31932db9b3" ); - assert_eq!(downloaded.candidate_version, "2026.03.24.120000+678cd508"); + assert_eq!(downloaded.candidate_version, "2026.05.20.222222+new"); Ok(()) } + #[test] + fn parses_candidate_versions_from_asset_names() { + assert_eq!( + candidate_version_from_asset_name( + PackageKind::Deb, + "codex-desktop_2026.05.20.222222+new_amd64.deb" + ) + .as_deref(), + Some("2026.05.20.222222+new") + ); + assert_eq!( + candidate_version_from_asset_name( + PackageKind::Rpm, + "codex-desktop-2026.05.20.222222-new.x86_64.rpm" + ) + .as_deref(), + Some("2026.05.20.222222-new") + ); + assert_eq!( + candidate_version_from_asset_name( + PackageKind::Pacman, + "codex-desktop-2026.05.20.333333+arch-1-x86_64.pkg.tar.zst" + ) + .as_deref(), + Some("2026.05.20.333333+arch-1") + ); + } + #[test] fn derive_candidate_version_rejects_short_hashes() { let error = derive_candidate_version("short", Utc::now()).expect_err("hash should fail"); From 1fc804667b6ad9e544395e7544ad67773446c4e1 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 20 May 2026 04:52:26 +0300 Subject: [PATCH 2/5] Fix Linux updater bridge binding detection --- scripts/lib/linux-update-bridge-patch.js | 35 +++++++++++++++++++++--- scripts/patch-linux-window-ui.test.js | 27 ++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index 18da3119..f7fc8c02 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -3,7 +3,17 @@ const path = require("path"); function requireName(source, moduleName) { const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return source.match(new RegExp(`([A-Za-z_$][\\w$]*)=require\\([\\\`'"]${escaped}[\\\`'"]\\)`))?.[1] ?? null; + const declarationPattern = /(?:^|[;{}\n])\s*(?:const|let|var)\s+([^;]+)/g; + const requirePattern = new RegExp( + `(?:^|,)\\s*([A-Za-z_$][\\w$]*)\\s*=\\s*require\\([\\\`'"]${escaped}[\\\`'"]\\)`, + ); + for (const declaration of source.matchAll(declarationPattern)) { + const match = declaration[1].match(requirePattern); + if (match != null) { + return match[1]; + } + } + return null; } function buildInstallAfterQuitSource(childProcessVar) { @@ -46,11 +56,28 @@ function buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar }) { return `function codexLinuxUpdateStatePath(){let e=process.env.XDG_STATE_HOME||process.env.HOME&&(0,${pathVar}.join)(process.env.HOME,\`.local\`,\`state\`);return e?(0,${pathVar}.join)(e,\`codex-update-manager\`,\`state.json\`):null}function codexLinuxReadUpdateState(){let e=codexLinuxUpdateStatePath();if(!e||!${fsVar}.existsSync(e))return null;try{let t=JSON.parse(${fsVar}.readFileSync(e,\`utf8\`));return t&&typeof t===\`object\`&&!Array.isArray(t)?t:null}catch{return null}}function codexLinuxUpdateLifecycleState(e){switch(e){case\`ready_to_install\`:case\`waiting_for_app_exit\`:return\`ready\`;case\`installing\`:return\`installing\`;case\`checking_upstream\`:case\`update_detected\`:case\`downloading_dmg\`:case\`preparing_workspace\`:case\`patching_app\`:case\`building_package\`:return\`checking\`;default:return\`idle\`}}function codexLinuxUpdateManagerPath(){let e=process.env.CODEX_UPDATE_MANAGER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-update-manager\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunUpdateManager(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxUpdateManagerPath(),e,{encoding:\`utf8\`,windowsHide:!0},(e,r,i)=>{if(e){e.stdout=r,e.stderr=i,n(e);return}t({stdout:r??\`\`,stderr:i??\`\`})})})}async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([\`--help\`])}async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}`; } -function migrateLinuxUpdaterBridgeSource(source) { +function migrateUpdaterElectronBinding(source, electronVar) { + if (electronVar == null) { + return source; + } + + let patchedSource = source.replace( + /async function codexLinuxShowUpdateMessage\(codexLinuxMessage,codexLinuxDetail\)\{try\{await [A-Za-z_$][\w$]*\.dialog\?\.showMessageBox\(/, + `async function codexLinuxShowUpdateMessage(codexLinuxMessage,codexLinuxDetail){try{await ${electronVar}.dialog?.showMessageBox(`, + ); + patchedSource = patchedSource.replace( + /function codexLinuxQuitForUpdate\(\)\{try\{(codexLinuxInstallAfterQuit\(\);)?let e=setTimeout\(\(\)=>[A-Za-z_$][\w$]*\.app\?\.exit\?\.\(0\),1500\);e\.unref\?\.\(\),[A-Za-z_$][\w$]*\.app\?\.quit\?\.\(\)\}catch\{\}\}/, + (_match, installPrefix) => buildQuitForUpdateSource(electronVar, installPrefix != null), + ); + return patchedSource; +} + +function migrateLinuxUpdaterBridgeSource(source, { electronVar } = {}) { let patchedSource = source.replace( "async function codexLinuxRefreshUpdateState(){await codexLinuxRunUpdateManager([`status`,`--json`]);return codexLinuxReadUpdateState()}", "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}", ); + patchedSource = migrateUpdaterElectronBinding(patchedSource, electronVar); const probeSource = "async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([`--help`])}"; const refreshSource = @@ -178,7 +205,7 @@ function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { ); } - patchedSource = migrateLinuxUpdaterBridgeSource(patchedSource); + patchedSource = migrateLinuxUpdaterBridgeSource(patchedSource, { electronVar }); const destructureRegex = /let\{startedAtMs:([A-Za-z_$][\w$]*),buildFlavor:([A-Za-z_$][\w$]*),desktopSentry:([A-Za-z_$][\w$]*),sparkleManager:([A-Za-z_$][\w$]*),setSparkleBridgeHandlers:([A-Za-z_$][\w$]*),setSecondInstanceArgsHandler:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(\),/; @@ -311,7 +338,7 @@ function applyLinuxAppUpdaterBridgePatch(currentSource) { patchedSource = patchedSource.replace(methodNeedle, `${methodPatch}${methodNeedle}`); } - return migrateLinuxUpdaterBridgeSource(patchedSource); + return migrateLinuxUpdaterBridgeSource(patchedSource, { electronVar }); } function applyLinuxAppUpdaterMenuPatch(currentSource) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index dbe2a7b3..e8c00015 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1499,6 +1499,18 @@ test("adds Linux package updater behind the existing app updater manager", () => assert.match(patched, /if\(t\?\.status===`waiting_for_app_exit`\)/); }); +test("ignores local require assignments when finding updater bridge module bindings", () => { + const source = [ + "async function choosePath(){let electron;try{electron=require(`electron`)}catch{return null}return electron.dialog.showOpenDialog({})}", + appUpdaterBundleFixture(), + ].join(""); + const patched = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, source); + + assert.match(patched, /t\.dialog\?\.showMessageBox\(\{type:`info`/); + assert.match(patched, /t\.app\?\.quit\?\.\(\)/); + assert.doesNotMatch(patched, /electron\.app\?\.quit/); +}); + test("does not run bootstrap probe-state migration on class-style updater bundles", () => { const source = `function unrelated(){i();let o=1;return o}${appUpdaterBundleFixture()}`; const patched = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, source); @@ -1667,6 +1679,21 @@ test("migrates an already-patched Linux updater bridge to relaunch after install assert.match(migrated, /\/usr\/bin\/codex-desktop >\/dev\/null 2>&1 &/); }); +test("migrates an already-patched Linux updater bridge away from a stale electron binding", () => { + const patched = applyLinuxAppUpdaterBridgePatch(appUpdaterBundleFixture()); + const stalePatched = patched + .replaceAll("t.dialog?.showMessageBox", "electron.dialog?.showMessageBox") + .replaceAll("t.app?.exit", "electron.app?.exit") + .replaceAll("t.app?.quit", "electron.app?.quit"); + assert.match(stalePatched, /electron\.app\?\.quit/); + + const migrated = applyLinuxAppUpdaterBridgePatch(stalePatched); + + assert.match(migrated, /t\.dialog\?\.showMessageBox\(\{type:`info`/); + assert.match(migrated, /t\.app\?\.quit\?\.\(\)/); + assert.doesNotMatch(migrated, /electron\.app\?\.quit/); +}); + test("enables the existing app update menu on Linux", () => { const source = "let{startedAtMs:r,buildFlavor:a,desktopSentry:o,sparkleManager:s,setSparkleBridgeHandlers:c,setSecondInstanceArgsHandler:l}=t.y(),u=t.Z(a),d=t.C.shouldIncludeSparkle(a,process.platform,process.env),f=t.C.shouldIncludeUpdater(a,process.platform,process.env);Yb({enableSparkle:d});"; From cd5d125ad171fb79b721cd360d8057d7a0b2d585 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 20 May 2026 05:15:03 +0300 Subject: [PATCH 3/5] Fix Linux updater feature rebuild handoff --- scripts/lib/linux-update-bridge-patch.js | 52 ++- scripts/patch-linux-window-ui.test.js | 61 ++- updater/src/app.rs | 186 ++++++-- updater/src/builder.rs | 11 + updater/src/cli.rs | 10 + updater/src/features.rs | 532 +++++++++++++++++++++++ updater/src/main.rs | 2 +- 7 files changed, 800 insertions(+), 54 deletions(-) create mode 100644 updater/src/features.rs diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index f7fc8c02..8a85cb8d 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -17,13 +17,13 @@ function requireName(source, moduleName) { } function buildInstallAfterQuitSource(childProcessVar) { - return `function codexLinuxInstallAfterQuit(){try{let e=${childProcessVar}.spawn(\`/bin/sh\`,[\`-c\`,\`for i in 1 2 3 4 5 6 7 8 9 10;do sleep 1;s="$("$1" status 2>/dev/null||true)";echo "$s"|grep -q "^status: WaitingForAppExit"&&continue;echo "$s"|grep -q "^status: Installing"&&continue;"$1" install-ready||exit $?;s="$("$1" status 2>/dev/null||true)";echo "$s"|grep -q "^status: WaitingForAppExit"&&continue;echo "$s"|grep -q "^status: Installing"&&continue;if echo "$s"|grep -q "^status: Installed";then (/usr/bin/codex-desktop >/dev/null 2>&1 &);fi;exit 0;done\`,\`codex-linux-update-install\`,codexLinuxUpdateManagerPath()],{detached:!0,stdio:\`ignore\`,windowsHide:!0});e.unref?.()}catch{}}`; + return `function codexLinuxAppLauncherPath(){let e=process.env.CODEX_LINUX_APP_ID||process.env.CODEX_APP_ID||\`codex-desktop\`;return typeof e===\`string\`&&/^[A-Za-z0-9._-]+$/.test(e)?\`/usr/bin/\${e}\`:\`/usr/bin/codex-desktop\`}function codexLinuxInstallAfterQuit(){try{let e=${childProcessVar}.spawn(\`/bin/sh\`,[\`-c\`,\`for i in 1 2 3 4 5 6 7 8 9 10;do sleep 1;s="$("$1" status 2>/dev/null||true)";echo "$s"|grep -q "^status: Installing"&&continue;"$1" install-ready||exit $?;s="$("$1" status 2>/dev/null||true)";echo "$s"|grep -q "^status: WaitingForAppExit"&&continue;echo "$s"|grep -q "^status: Installing"&&continue;if echo "$s"|grep -q "^status: Installed";then ("$2" >/dev/null 2>&1 &);fi;exit 0;done\`,\`codex-linux-update-install\`,codexLinuxUpdateManagerPath(),codexLinuxAppLauncherPath()],{detached:!0,stdio:\`ignore\`,windowsHide:!0});e.unref?.()}catch{}}`; } function replaceInstallAfterQuitSource(source, childProcessVar) { const pattern = - /function codexLinuxInstallAfterQuit\(\)\{try\{let e=[A-Za-z_$][\w$]*\.spawn\(`\/bin\/sh`,\[`-c`,[^]*?e\.unref\?\.\(\)\}catch\{\}\}/; - return source.replace(pattern, buildInstallAfterQuitSource(childProcessVar)); + /(function codexLinuxAppLauncherPath\(\)\{[^]*?\})?function codexLinuxInstallAfterQuit\(\)\{try\{let e=[A-Za-z_$][\w$]*\.spawn\(`\/bin\/sh`,\[`-c`,[^]*?e\.unref\?\.\(\)\}catch\{\}\}/; + return source.replace(pattern, () => buildInstallAfterQuitSource(childProcessVar)); } function replaceAfter(source, anchor, search, replacement) { @@ -53,7 +53,7 @@ function buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar }) { : `async function codexLinuxShowUpdateMessage(codexLinuxMessage,codexLinuxDetail){try{await ${electronVar}.dialog?.showMessageBox({type:\`info\`,buttons:[\`OK\`],defaultId:0,noLink:!0,message:codexLinuxMessage,detail:codexLinuxDetail})}catch{}}`; const installAfterQuit = buildInstallAfterQuitSource(childProcessVar); const quitForUpdate = buildQuitForUpdateSource(electronVar, true); - return `function codexLinuxUpdateStatePath(){let e=process.env.XDG_STATE_HOME||process.env.HOME&&(0,${pathVar}.join)(process.env.HOME,\`.local\`,\`state\`);return e?(0,${pathVar}.join)(e,\`codex-update-manager\`,\`state.json\`):null}function codexLinuxReadUpdateState(){let e=codexLinuxUpdateStatePath();if(!e||!${fsVar}.existsSync(e))return null;try{let t=JSON.parse(${fsVar}.readFileSync(e,\`utf8\`));return t&&typeof t===\`object\`&&!Array.isArray(t)?t:null}catch{return null}}function codexLinuxUpdateLifecycleState(e){switch(e){case\`ready_to_install\`:case\`waiting_for_app_exit\`:return\`ready\`;case\`installing\`:return\`installing\`;case\`checking_upstream\`:case\`update_detected\`:case\`downloading_dmg\`:case\`preparing_workspace\`:case\`patching_app\`:case\`building_package\`:return\`checking\`;default:return\`idle\`}}function codexLinuxUpdateManagerPath(){let e=process.env.CODEX_UPDATE_MANAGER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-update-manager\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunUpdateManager(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxUpdateManagerPath(),e,{encoding:\`utf8\`,windowsHide:!0},(e,r,i)=>{if(e){e.stdout=r,e.stderr=i,n(e);return}t({stdout:r??\`\`,stderr:i??\`\`})})})}async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([\`--help\`])}async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}`; + return `function codexLinuxUpdateStatePath(){let e=process.env.XDG_STATE_HOME||process.env.HOME&&(0,${pathVar}.join)(process.env.HOME,\`.local\`,\`state\`);return e?(0,${pathVar}.join)(e,\`codex-update-manager\`,\`state.json\`):null}function codexLinuxReadUpdateState(){let e=codexLinuxUpdateStatePath();if(!e||!${fsVar}.existsSync(e))return null;try{let t=JSON.parse(${fsVar}.readFileSync(e,\`utf8\`));return t&&typeof t===\`object\`&&!Array.isArray(t)?t:null}catch{return null}}function codexLinuxUpdateLifecycleState(e){switch(e){case\`ready_to_install\`:case\`waiting_for_app_exit\`:return\`ready\`;case\`installing\`:return\`installing\`;case\`checking_upstream\`:case\`update_detected\`:case\`downloading_dmg\`:case\`preparing_workspace\`:case\`patching_app\`:case\`building_package\`:return\`checking\`;default:return\`idle\`}}function codexLinuxUpdateManagerPath(){let e=process.env.CODEX_UPDATE_MANAGER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-update-manager\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunUpdateManager(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxUpdateManagerPath(),e,{encoding:\`utf8\`,windowsHide:!0},(e,r,i)=>{if(e){e.stdout=r,e.stderr=i,n(e);return}t({stdout:r??\`\`,stderr:i??\`\`})})})}async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([\`--help\`])}async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}async function codexLinuxCheckForUpdatesOnOpen(){try{await codexLinuxRunUpdateManager([\`check-now\`])}catch{}}`; } function migrateUpdaterElectronBinding(source, electronVar) { @@ -72,16 +72,21 @@ function migrateUpdaterElectronBinding(source, electronVar) { return patchedSource; } -function migrateLinuxUpdaterBridgeSource(source, { electronVar } = {}) { +function migrateLinuxUpdaterBridgeSource(source, { childProcessVar, electronVar } = {}) { let patchedSource = source.replace( "async function codexLinuxRefreshUpdateState(){await codexLinuxRunUpdateManager([`status`,`--json`]);return codexLinuxReadUpdateState()}", "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}", ); patchedSource = migrateUpdaterElectronBinding(patchedSource, electronVar); + if (childProcessVar != null && patchedSource.includes("function codexLinuxInstallAfterQuit(")) { + patchedSource = replaceInstallAfterQuitSource(patchedSource, childProcessVar); + } const probeSource = "async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([`--help`])}"; const refreshSource = "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}"; + const checkOnOpenSource = + "async function codexLinuxCheckForUpdatesOnOpen(){try{await codexLinuxRunUpdateManager([`check-now`])}catch{}}"; if ( patchedSource.includes("function codexLinuxRunUpdateManager(") && patchedSource.includes(refreshSource) && @@ -93,6 +98,14 @@ function migrateLinuxUpdaterBridgeSource(source, { electronVar } = {}) { ); } + if ( + patchedSource.includes("function codexLinuxRunUpdateManager(") && + patchedSource.includes(refreshSource) && + !patchedSource.includes(checkOnOpenSource) + ) { + patchedSource = patchedSource.replace(refreshSource, `${refreshSource}${checkOnOpenSource}`); + } + const bootstrapNeedle = "function codexLinuxCreatePackageUpdateManager("; const isBootstrapSource = patchedSource.includes(bootstrapNeedle); if ( @@ -102,7 +115,8 @@ function migrateLinuxUpdaterBridgeSource(source, { electronVar } = {}) { ) { const helperSource = `${patchedSource.includes(probeSource) ? "" : probeSource}` + - `${patchedSource.includes(refreshSource) ? "" : refreshSource}`; + `${patchedSource.includes(refreshSource) ? "" : refreshSource}` + + `${patchedSource.includes(checkOnOpenSource) ? "" : checkOnOpenSource}`; patchedSource = patchedSource.replace(bootstrapNeedle, `${helperSource}${bootstrapNeedle}`); } @@ -110,9 +124,27 @@ function migrateLinuxUpdaterBridgeSource(source, { electronVar } = {}) { "await codexLinuxRefreshUpdateState(),e()", "await codexLinuxProbeUpdateManager(),e()", ); + patchedSource = patchedSource.replace( + "await codexLinuxProbeUpdateManager(),e()}catch(e){", + "await codexLinuxProbeUpdateManager(),e(),codexLinuxCheckForUpdatesOnOpen().then(()=>e()).catch(()=>{})}catch(e){", + ); const probeStateSource = - "let s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o="; + "let s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a(),codexLinuxCheckForUpdatesOnOpen().then(()=>{i(),a()}).catch(()=>{});return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o="; + const commaProbeStateSource = + ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a(),codexLinuxCheckForUpdatesOnOpen().then(()=>{i(),a()}).catch(()=>{});return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o="; + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + commaProbeStateSource, + ); + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "let s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + probeStateSource, + ); const hasProbeState = () => patchedSource.includes("c=codexLinuxProbeUpdateManager().then("); if (isBootstrapSource && !hasProbeState() && patchedSource.includes(probeSource)) { patchedSource = replaceAfter( @@ -167,7 +199,7 @@ function migrateLinuxUpdaterBridgeSource(source, { electronVar } = {}) { } function buildBootstrapBridgeSource({ childProcessVar, electronVar, fsVar, pathVar }) { - return `${buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar })};function codexLinuxCreatePackageUpdateManager(e){let t=!1,n=\`idle\`,r=null,i=()=>{try{let e=codexLinuxReadUpdateState(),r=e?.status;t=r===\`ready_to_install\`||r===\`waiting_for_app_exit\`,n=codexLinuxUpdateLifecycleState(r);return e}catch{return null}},a=()=>{try{e.send({type:\`app-update-ready-changed\`,isUpdateReady:t}),e.send({type:\`app-update-lifecycle-state-changed\`,lifecycleState:n}),e.send({type:\`app-update-install-progress-changed\`,installProgressPercent:r})}catch{}},s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=\`idle\`,a();return!1});let o=()=>{e.allowQuit?.();codexLinuxQuitForUpdate()};return{manager:{getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:\`idle\`,getInstallProgressPercent:()=>r,checkForUpdates:async()=>{if(!await c)return;n=\`checking\`,a();try{await codexLinuxRunUpdateManager([\`check-now\`]),i(),a()}catch(e){n=t?\`ready\`:\`idle\`,a();throw e}},installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}r=0,n=\`installing\`,a();try{let e=await codexLinuxRunUpdateManager([\`install-ready\`]),s=i();if(s?.status===\`waiting_for_app_exit\`){r=null,n=\`ready\`,a(),o();return}r=null,a(),e.stdout?.includes(\`already installed\`)?await codexLinuxShowUpdateMessage(\`Codex Desktop update\`,\`The ready update is already installed.\`):e.stdout?.includes(\`No Codex Desktop update is ready\`)&&await codexLinuxShowUpdateMessage(\`Codex Desktop update\`,\`There is no rebuilt update waiting to install.\`)}catch(e){r=null,n=t?\`ready\`:\`idle\`,a();throw e}}},quitForUpdate:o,refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=\`idle\`;a()}}}`; + return `${buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar })};function codexLinuxCreatePackageUpdateManager(e){let t=!1,n=\`idle\`,r=null,i=()=>{try{let e=codexLinuxReadUpdateState(),r=e?.status;t=r===\`ready_to_install\`||r===\`waiting_for_app_exit\`,n=codexLinuxUpdateLifecycleState(r);return e}catch{return null}},a=()=>{try{e.send({type:\`app-update-ready-changed\`,isUpdateReady:t}),e.send({type:\`app-update-lifecycle-state-changed\`,lifecycleState:n}),e.send({type:\`app-update-install-progress-changed\`,installProgressPercent:r})}catch{}},s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a(),codexLinuxCheckForUpdatesOnOpen().then(()=>{i(),a()}).catch(()=>{});return!0}).catch(()=>{s=!1,t=!1,n=\`idle\`,a();return!1});let o=()=>{e.allowQuit?.();codexLinuxQuitForUpdate()};return{manager:{getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:\`idle\`,getInstallProgressPercent:()=>r,checkForUpdates:async()=>{if(!await c)return;n=\`checking\`,a();try{await codexLinuxRunUpdateManager([\`check-now\`]),i(),a()}catch(e){n=t?\`ready\`:\`idle\`,a();throw e}},installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}r=0,n=\`installing\`,a();try{let e=await codexLinuxRunUpdateManager([\`install-ready\`]),s=i();if(s?.status===\`waiting_for_app_exit\`){r=null,n=\`ready\`,a(),o();return}r=null,a(),e.stdout?.includes(\`already installed\`)?await codexLinuxShowUpdateMessage(\`Codex Desktop update\`,\`The ready update is already installed.\`):e.stdout?.includes(\`No Codex Desktop update is ready\`)&&await codexLinuxShowUpdateMessage(\`Codex Desktop update\`,\`There is no rebuilt update waiting to install.\`)}catch(e){r=null,n=t?\`ready\`:\`idle\`,a();throw e}}},quitForUpdate:o,refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=\`idle\`;a()}}}`; } function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { @@ -205,7 +237,7 @@ function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { ); } - patchedSource = migrateLinuxUpdaterBridgeSource(patchedSource, { electronVar }); + patchedSource = migrateLinuxUpdaterBridgeSource(patchedSource, { childProcessVar, electronVar }); const destructureRegex = /let\{startedAtMs:([A-Za-z_$][\w$]*),buildFlavor:([A-Za-z_$][\w$]*),desktopSentry:([A-Za-z_$][\w$]*),sparkleManager:([A-Za-z_$][\w$]*),setSparkleBridgeHandlers:([A-Za-z_$][\w$]*),setSecondInstanceArgsHandler:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(\),/; @@ -338,7 +370,7 @@ function applyLinuxAppUpdaterBridgePatch(currentSource) { patchedSource = patchedSource.replace(methodNeedle, `${methodPatch}${methodNeedle}`); } - return migrateLinuxUpdaterBridgeSource(patchedSource, { electronVar }); + return migrateLinuxUpdaterBridgeSource(patchedSource, { childProcessVar, electronVar }); } function applyLinuxAppUpdaterMenuPatch(currentSource) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index e8c00015..ea142583 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1467,15 +1467,23 @@ test("adds Linux package updater behind the existing app updater manager", () => assert.match(patched, /function codexLinuxUpdateLifecycleState\(e\)/); assert.match(patched, /function codexLinuxUpdateManagerPath\(\)/); assert.match(patched, /async function codexLinuxShowUpdateMessage\(codexLinuxMessage,codexLinuxDetail\)/); + assert.match(patched, /function codexLinuxAppLauncherPath\(\)/); + assert.match(patched, /process\.env\.CODEX_LINUX_APP_ID\|\|process\.env\.CODEX_APP_ID/); assert.match(patched, /function codexLinuxInstallAfterQuit\(\)/); assert.match(patched, /function codexLinuxQuitForUpdate\(\)/); assert.match(patched, /t\.dialog\?\.showMessageBox\(\{type:`info`/); assert.match(patched, /u\.spawn\(`\/bin\/sh`/); + assert.match(patched, /codexLinuxUpdateManagerPath\(\),codexLinuxAppLauncherPath\(\)\]/); assert.match(patched, /install-ready\|\|exit \$\?/); assert.match(patched, /grep -q "\^status: WaitingForAppExit"/); assert.match(patched, /status: Installing/); + assert.ok( + patched.indexOf('"$1" install-ready') < patched.indexOf('^status: WaitingForAppExit'), + "install-ready should run before checking whether WaitingForAppExit cleared", + ); + assert.doesNotMatch(patched, /"" install-ready/); assert.match(patched, /grep -q "\^status: Installed"/); - assert.match(patched, /\/usr\/bin\/codex-desktop >\/dev\/null 2>&1 &/); + assert.match(patched, /\("\$2" >\/dev\/null 2>&1 &\)/); assert.match(patched, /detached:!0,stdio:`ignore`/); assert.match(patched, /codexLinuxInstallAfterQuit\(\);let e=setTimeout/); assert.match(patched, /t\.app\?\.quit\?\.\(\)/); @@ -1485,8 +1493,9 @@ test("adds Linux package updater behind the existing app updater manager", () => assert.match(patched, /codexLinuxRunUpdateManager\(\[`--help`\]\)/); assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)/); assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); + assert.match(patched, /async function codexLinuxCheckForUpdatesOnOpen\(\)/); assert.doesNotMatch(patched, /codexLinuxRunUpdateManager\(\[`status`,`--json`\]\)/); - assert.match(patched, /await codexLinuxProbeUpdateManager\(\),e\(\)/); + assert.match(patched, /await codexLinuxProbeUpdateManager\(\),e\(\),codexLinuxCheckForUpdatesOnOpen\(\)/); assert.match(patched, /if\(!this\.options\.enableUpdater&&process\.platform!==`linux`\)/); assert.match(patched, /process\.platform===`linux`\?await this\.initializeLinuxPackageUpdater\(\)/); assert.match(patched, /async initializeLinuxPackageUpdater\(\)/); @@ -1533,7 +1542,7 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { assert.match(patched, /async function codexLinuxProbeUpdateManager\(\)/); assert.match(patched, /codexLinuxRunUpdateManager\(\[`--help`\]\)/); assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); - assert.match(patched, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}\)/); + assert.match(patched, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\),codexLinuxCheckForUpdatesOnOpen\(\)/); assert.match(patched, /getIsUpdateReady:\(\)=>s&&t/); assert.match(patched, /checkForUpdates:async\(\)=>\{if\(!await c\)return;n=`checking`/); assert.match(patched, /installUpdatesIfAvailable:async\(\)=>\{if\(!await c\)\{a\(\);return\}i\(\);if\(!t\)\{a\(\);return\}/); @@ -1541,12 +1550,39 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { assert.doesNotMatch(patched, /codexLinuxRunUpdateManager\(\[`status`,`--json`\]\)/); }); +test("migrates already-patched bootstrap updater bridge to current quit helper and open check", () => { + const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); + const oldHelper = + "function codexLinuxInstallAfterQuit(){try{let e=u.spawn(`/bin/sh`,[`-c`,`for i in 1 2 3 4 5 6 7 8 9 10;do sleep 1;s=\"$(\"$1\" status 2>/dev/null||true)\";echo \"$s\"|grep -q \"^status: WaitingForAppExit\"&&continue;echo \"$s\"|grep -q \"^status: Installing\"&&continue;\"$1\" install-ready||exit $?;s=\"$(\"$1\" status 2>/dev/null||true)\";echo \"$s\"|grep -q \"^status: WaitingForAppExit\"&&continue;echo \"$s\"|grep -q \"^status: Installing\"&&continue;if echo \"$s\"|grep -q \"^status: Installed\";then (/usr/bin/codex-desktop >/dev/null 2>&1 &);fi;exit 0;done`,`codex-linux-update-install`,codexLinuxUpdateManagerPath()],{detached:!0,stdio:`ignore`,windowsHide:!0});e.unref?.()}catch{}}"; + const oldPatched = patched + .replace( + /function codexLinuxInstallAfterQuit\(\)\{try\{let e=u\.spawn\(`\/bin\/sh`,\[`-c`,[^]*?e\.unref\?\.\(\)\}catch\{\}\}/, + oldHelper, + ) + .replace( + ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a(),codexLinuxCheckForUpdatesOnOpen().then(()=>{i(),a()}).catch(()=>{});return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + ); + + assert.match(oldPatched, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}/); + assert.doesNotMatch(oldPatched, /codexLinuxCheckForUpdatesOnOpen\(\)\.then\(\(\)=>\{i\(\),a\(\)\}/); + + const migrated = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, oldPatched); + + assert.ok( + migrated.indexOf('"$1" install-ready') < migrated.indexOf('^status: WaitingForAppExit'), + "bootstrap migration should run install-ready before waiting-state checks", + ); + assert.doesNotMatch(migrated, /"" install-ready/); + assert.match(migrated, /codexLinuxCheckForUpdatesOnOpen\(\)\.then\(\(\)=>\{i\(\),a\(\)\}/); +}); + test("migrates already-patched bootstrap updater bridge to probe before enabling UI", () => { const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); const oldPatched = patched .replace( - "let s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", - "i(),codexLinuxRefreshUpdateState().then(()=>{i(),a()}).catch(()=>{});let o=", + ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a(),codexLinuxCheckForUpdatesOnOpen().then(()=>{i(),a()}).catch(()=>{});return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + ";i(),codexLinuxRefreshUpdateState().then(()=>{i(),a()}).catch(()=>{});let o=", ) .replace( "getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:`idle`,", @@ -1565,9 +1601,12 @@ test("migrates already-patched bootstrap updater bridge to probe before enabling "refresh:async()=>{try{await codexLinuxRefreshUpdateState()}catch{}i(),a()}", ); + assert.doesNotMatch(oldPatched, /codexLinuxProbeUpdateManager\(\)\.then/); + assert.match(oldPatched, /codexLinuxRefreshUpdateState\(\)\.then/); + const migrated = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, oldPatched); - assert.match(migrated, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}\)/); + assert.match(migrated, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\),codexLinuxCheckForUpdatesOnOpen\(\)/); assert.match(migrated, /getIsUpdateReady:\(\)=>s&&t/); assert.match(migrated, /installUpdatesIfAvailable:async\(\)=>\{if\(!await c\)\{a\(\);return\}i\(\);if\(!t\)\{a\(\);return\}/); }); @@ -1584,7 +1623,7 @@ test("migrates previous bootstrap updater bridge without leaving undefined probe "", ) .replace( - ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + ",s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a(),codexLinuxCheckForUpdatesOnOpen().then(()=>{i(),a()}).catch(()=>{});return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", ";i();let o=", ) .replace( @@ -1671,12 +1710,16 @@ test("migrates an already-patched Linux updater bridge to relaunch after install /function codexLinuxInstallAfterQuit\(\)\{try\{let e=u\.spawn\(`\/bin\/sh`,\[`-c`,[^]*?e\.unref\?\.\(\)\}catch\{\}\}/, oldHelper, ); - assert.doesNotMatch(oldPatched, /\/usr\/bin\/codex-desktop/); + assert.doesNotMatch(oldPatched, /grep -q "\^status: Installed"/); const migrated = applyLinuxAppUpdaterBridgePatch(oldPatched); assert.match(migrated, /grep -q "\^status: Installed"/); - assert.match(migrated, /\/usr\/bin\/codex-desktop >\/dev\/null 2>&1 &/); + assert.match(migrated, /"\$1" install-ready/); + assert.doesNotMatch(migrated, /"" install-ready/); + assert.match(migrated, /function codexLinuxAppLauncherPath\(\)/); + assert.match(migrated, /\("\$2" >\/dev\/null 2>&1 &\)/); + assert.match(migrated, /codexLinuxUpdateManagerPath\(\),codexLinuxAppLauncherPath\(\)\]/); }); test("migrates an already-patched Linux updater bridge away from a stale electron binding", () => { diff --git a/updater/src/app.rs b/updater/src/app.rs index 83c82b15..081bd820 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -1,10 +1,11 @@ //! Application entrypoints and orchestration for the local updater daemon. use crate::{ + builder, cli::{Cli, Commands}, codex_cli, config::{RuntimeConfig, RuntimePaths}, - install, install_rollback, liveness, logging, notify, rollback, + features, install, install_rollback, liveness, logging, notify, rollback, state::{CliStatus, PersistedState, UpdateStatus}, upstream, }; @@ -63,6 +64,8 @@ pub async fn run(cli: Cli) -> Result<()> { print_path, } => run_prompt_install_cli(&mut state, &paths, cli_path, print_path), Commands::Status { json } => run_status(&mut state, &paths, json), + Commands::Features { json } => run_features(&config, &paths, json), + Commands::PromptFeatures { json } => run_prompt_features(&config, &paths, json), Commands::InstallReady => run_install_ready(&config, &mut state, &paths).await, Commands::Rollback => rollback::run(&config, &mut state, &paths).await, Commands::InstallDeb { path } => install::install_deb(&path), @@ -311,6 +314,57 @@ fn run_status(state: &mut PersistedState, paths: &RuntimePaths, json: bool) -> R Ok(()) } +fn run_features(config: &RuntimeConfig, paths: &RuntimePaths, json: bool) -> Result<()> { + let selection = features::selection(config, paths)?; + if json { + println!("{}", serde_json::to_string_pretty(&selection)?); + } else { + println!("features_config: {}", selection.config_path.display()); + println!( + "enabled_features: {}", + if selection.enabled.is_empty() { + "none".to_string() + } else { + selection.enabled.join(",") + } + ); + for option in selection.available { + println!( + "{} [{}] - {}", + option.id, + if option.enabled { + "enabled" + } else { + "disabled" + }, + option.title + ); + } + } + Ok(()) +} + +fn run_prompt_features(config: &RuntimeConfig, paths: &RuntimePaths, json: bool) -> Result<()> { + let outcome = features::prompt_for_update(config, paths)?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else if outcome.cancelled { + println!("Feature selection cancelled."); + } else if outcome.prompted { + println!( + "Enabled update features: {}", + if outcome.selection.enabled.is_empty() { + "none".to_string() + } else { + outcome.selection.enabled.join(",") + } + ); + } else { + println!("No graphical feature selection prompt was available."); + } + Ok(()) +} + fn update_error_status_line(state: &PersistedState) -> String { format!( "update_error: {}", @@ -561,64 +615,41 @@ async fn run_check_cycle( state.last_successful_check_at = Some(Utc::now()); if previous_headers_fingerprint.as_deref() == Some(metadata.headers_fingerprint.as_str()) - && state.dmg_sha256.is_some() && !retrying_failed_update + && installed_version_satisfies_candidate(&state.installed_version, &metadata.candidate_version) { set_status(state, paths, UpdateStatus::Idle)?; - info!("upstream fingerprint unchanged; skipping download"); + info!("Linux release fingerprint unchanged and installed version is current"); return Ok(()); } - set_status(state, paths, UpdateStatus::DownloadingDmg)?; - - let downloads_dir = config.workspace_root.join("downloads"); - let downloaded = upstream::download_dmg( - &client, - &metadata.download_url, - &metadata.asset_name, - &downloads_dir, - Utc::now(), - &metadata.candidate_version, - ) - .await?; - if state .rollback_blocked_candidate_version .as_deref() .is_some_and(|blocked| { - installed_version_matches_candidate(blocked, &downloaded.candidate_version) + installed_version_matches_candidate(blocked, &metadata.candidate_version) }) { state.status = UpdateStatus::Idle; state.error_message = Some(format!( "Candidate {} was rolled back and will not be reinstalled automatically", - downloaded.candidate_version + metadata.candidate_version )); persist_state(paths, state)?; info!( - candidate_version = %downloaded.candidate_version, + candidate_version = %metadata.candidate_version, "skipping candidate blocked by rollback" ); return Ok(()); } - if state.dmg_sha256.as_deref() == Some(downloaded.sha256.as_str()) - && !retrying_failed_update - { - state.status = UpdateStatus::Idle; - state.artifact_paths.package_path = Some(downloaded.path); - persist_state(paths, state)?; - info!("downloaded release package hash matches current cached package; no update detected"); - return Ok(()); - } - rollback::record_current_package_as_known_good(state); state.status = UpdateStatus::UpdateDetected; - state.candidate_version = Some(downloaded.candidate_version); - state.dmg_sha256 = Some(downloaded.sha256); + state.candidate_version = Some(metadata.candidate_version.clone()); + state.dmg_sha256 = None; state.artifact_paths.dmg_path = None; state.artifact_paths.workspace_dir = None; - state.artifact_paths.package_path = Some(downloaded.path.clone()); + state.artifact_paths.package_path = None; state.notified_events.clear(); state.save(&paths.state_file)?; @@ -628,7 +659,7 @@ async fn run_check_cycle( config.notifications, "update_detected", "New Codex Desktop update detected", - "Downloaded the matching package from the Codex Desktop Linux release.", + "Choose Update in Codex Desktop to select optional Linux features and prepare a local package.", )?; state.status = UpdateStatus::ReadyToInstall; @@ -769,6 +800,10 @@ async fn run_install_ready( } } + if !prepare_update_package_if_needed(config, state, paths).await? { + return Ok(()); + } + let Some(package_path) = state.artifact_paths.package_path.clone() else { mark_failed_and_persist(state, paths, "No ready update package is recorded")?; maybe_send_notification( @@ -817,6 +852,89 @@ async fn run_install_ready( trigger_install(state, paths, &package_path).await } +async fn prepare_update_package_if_needed( + config: &RuntimeConfig, + state: &mut PersistedState, + paths: &RuntimePaths, +) -> Result { + if state.artifact_paths.package_path.is_some() { + return Ok(true); + } + + let Some(candidate_version) = state.candidate_version.clone() else { + mark_failed_and_persist(state, paths, "No ready update candidate is recorded")?; + maybe_send_notification( + config.notifications, + "Codex update failed", + "The updater has no candidate version recorded for the ready update.", + ); + println!("No ready update candidate is recorded."); + return Ok(false); + }; + + let feature_outcome = features::prompt_for_update(config, paths)?; + if feature_outcome.cancelled { + maybe_send_notification( + config.notifications, + "Codex update cancelled", + "No update was installed because Linux feature selection was cancelled.", + ); + println!("Feature selection cancelled."); + return Ok(false); + } + + let client = Client::builder().build()?; + set_status(state, paths, UpdateStatus::DownloadingDmg)?; + + let downloads_dir = config.workspace_root.join("downloads"); + let downloaded = upstream::download_dmg( + &client, + &config.dmg_url, + "Codex.dmg", + &downloads_dir, + Utc::now(), + &candidate_version, + ) + .await?; + + if state + .rollback_blocked_candidate_version + .as_deref() + .is_some_and(|blocked| { + installed_version_matches_candidate(blocked, &downloaded.candidate_version) + }) + { + state.status = UpdateStatus::Idle; + state.error_message = Some(format!( + "Candidate {} was rolled back and will not be reinstalled automatically", + downloaded.candidate_version + )); + persist_state(paths, state)?; + info!( + candidate_version = %downloaded.candidate_version, + "skipping candidate blocked by rollback" + ); + return Ok(false); + } + + rollback::record_current_package_as_known_good(state); + state.status = UpdateStatus::UpdateDetected; + state.candidate_version = Some(downloaded.candidate_version); + state.dmg_sha256 = Some(downloaded.sha256); + state.artifact_paths.dmg_path = Some(downloaded.path.clone()); + state.artifact_paths.workspace_dir = None; + state.artifact_paths.package_path = None; + state.error_message = None; + persist_state(paths, state)?; + + let candidate_version = state + .candidate_version + .clone() + .expect("candidate version should be set before local build"); + builder::build_update(config, state, paths, &candidate_version, &downloaded.path).await?; + Ok(true) +} + fn complete_pending_install_if_already_installed( state: &mut PersistedState, paths: &RuntimePaths, @@ -1059,7 +1177,7 @@ fn maybe_notify_update_ready( if enabled { if let Err(error) = notify::send( "Codex Desktop update ready", - "A rebuilt Linux package is ready. Open Codex Desktop and choose Update to install it.", + "A Linux update is available. Open Codex Desktop and choose Update to select features and install it.", ) { warn!(?error, "failed to send update-ready notification"); } diff --git a/updater/src/builder.rs b/updater/src/builder.rs index 73b28a86..315bbcc6 100644 --- a/updater/src/builder.rs +++ b/updater/src/builder.rs @@ -77,6 +77,7 @@ pub async fn build_update( ) -> Result { let workspace = BuilderWorkspace::prepare(&config.workspace_root, candidate_version)?; let build_path = build_command_path(&config.builder_bundle_root); + let user_features_config = paths.config_dir.join("features.json"); state.status = UpdateStatus::PreparingWorkspace; state.artifact_paths.workspace_dir = Some(workspace.workspace_dir.clone()); @@ -94,6 +95,7 @@ pub async fn build_update( "CODEX_MANAGED_NODE_SOURCE", config.builder_bundle_root.join("node-runtime"), ) + .env("CODEX_LINUX_FEATURES_CONFIG", &user_features_config) .env("PATH", &build_path) .current_dir(&workspace.bundle_dir), &workspace.install_log, @@ -117,6 +119,7 @@ pub async fn build_update( .bundle_dir .join("packaging/linux/codex-update-manager.service"), ) + .env("CODEX_LINUX_FEATURES_CONFIG", &user_features_config) .env("PATH", &build_path) .current_dir(&workspace.bundle_dir), &workspace.build_log, @@ -435,6 +438,7 @@ mod tests { FakePackageOutput::Deb => { r#"#!/bin/bash set -euo pipefail +test -f "${CODEX_LINUX_FEATURES_CONFIG:?}" mkdir -p "${DIST_DIR_OVERRIDE}" touch "${DIST_DIR_OVERRIDE}/codex-desktop_${PACKAGE_VERSION}_amd64.deb" "# @@ -442,6 +446,7 @@ touch "${DIST_DIR_OVERRIDE}/codex-desktop_${PACKAGE_VERSION}_amd64.deb" FakePackageOutput::Rpm => { r#"#!/bin/bash set -euo pipefail +test -f "${CODEX_LINUX_FEATURES_CONFIG:?}" mkdir -p "${DIST_DIR_OVERRIDE}" touch "${DIST_DIR_OVERRIDE}/codex-desktop-${PACKAGE_VERSION}.x86_64.rpm" "# @@ -449,6 +454,7 @@ touch "${DIST_DIR_OVERRIDE}/codex-desktop-${PACKAGE_VERSION}.x86_64.rpm" FakePackageOutput::Pacman => { r#"#!/bin/bash set -euo pipefail +test -f "${CODEX_LINUX_FEATURES_CONFIG:?}" VER="${PACKAGE_VERSION%%+*}" mkdir -p "${DIST_DIR_OVERRIDE}" touch "${DIST_DIR_OVERRIDE}/codex-desktop-${VER}-1-x86_64.pkg.tar.zst" @@ -596,6 +602,7 @@ touch "${DIST_DIR_OVERRIDE}/codex-desktop-${VER}-1-x86_64.pkg.tar.zst" bundle_root.join("install.sh"), r#"#!/bin/bash set -euo pipefail +test -f "${CODEX_LINUX_FEATURES_CONFIG:?}" mkdir -p "${CODEX_INSTALL_DIR}" echo launcher > "${CODEX_INSTALL_DIR}/start.sh" chmod +x "${CODEX_INSTALL_DIR}/start.sh" @@ -652,6 +659,10 @@ chmod +x "${CODEX_INSTALL_DIR}/start.sh" config_dir: temp.path().join("config"), }; paths.ensure_dirs()?; + fs::write( + paths.config_dir.join("features.json"), + b"{\"enabled\":[\"example-feature\"]}\n", + )?; let config = RuntimeConfig { dmg_url: "https://example.com/Codex.dmg".to_string(), diff --git a/updater/src/cli.rs b/updater/src/cli.rs index c0244bcb..54d493e9 100644 --- a/updater/src/cli.rs +++ b/updater/src/cli.rs @@ -37,6 +37,16 @@ pub enum Commands { #[arg(long)] json: bool, }, + /// Print available optional Linux features and the current update selection. + Features { + #[arg(long)] + json: bool, + }, + /// Show a native feature-selection prompt for the next update rebuild. + PromptFeatures { + #[arg(long)] + json: bool, + }, /// Install the already rebuilt update package, if one is ready. InstallReady, /// Roll back to the last retained known-good package. diff --git a/updater/src/features.rs b/updater/src/features.rs new file mode 100644 index 00000000..9a702a31 --- /dev/null +++ b/updater/src/features.rs @@ -0,0 +1,532 @@ +//! Runtime Linux feature selection for app-driven update rebuilds. + +use crate::config::{RuntimeConfig, RuntimePaths}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PromptHelper { + Zenity, + Kdialog, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PromptHelperOutcome { + Selected(Vec), + Cancelled, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FeatureConfig { + pub enabled: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FeatureOption { + pub id: String, + pub title: String, + pub description: String, + pub enabled: bool, + pub default_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FeatureSelection { + pub config_path: PathBuf, + pub available: Vec, + pub enabled: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PromptFeaturesOutcome { + pub prompted: bool, + pub changed: bool, + pub cancelled: bool, + pub selection: FeatureSelection, +} + +#[derive(Debug, Deserialize)] +struct FeatureManifest { + id: String, + #[serde(default)] + title: Option, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default, rename = "defaultEnabled")] + default_enabled: bool, + #[serde(default)] + hidden: bool, +} + +pub fn user_features_config_path(paths: &RuntimePaths) -> PathBuf { + paths.config_dir.join("features.json") +} + +pub fn effective_enabled_feature_ids( + config: &RuntimeConfig, + paths: &RuntimePaths, +) -> Result> { + let path = user_features_config_path(paths); + if path.exists() { + return read_enabled_feature_ids(&path); + } + + let bundled = bundled_features_config_path(config); + if bundled.exists() { + return read_enabled_feature_ids(&bundled); + } + + Ok(Vec::new()) +} + +pub fn write_features_config(path: &Path, enabled: &[String]) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?; + let config = FeatureConfig { + enabled: normalize_feature_ids(enabled.iter().map(String::as_str)), + }; + fs::write( + path, + format!("{}\n", serde_json::to_string_pretty(&config)?), + ) + .with_context(|| format!("Failed to write {}", path.display())) +} + +pub fn selection(config: &RuntimeConfig, paths: &RuntimePaths) -> Result { + let enabled = effective_enabled_feature_ids(config, paths)?; + let enabled_set = enabled.iter().cloned().collect::>(); + let available = discover_feature_options(config)? + .into_iter() + .map(|mut option| { + option.enabled = enabled_set.contains(&option.id); + option + }) + .collect::>(); + + Ok(FeatureSelection { + config_path: user_features_config_path(paths), + available, + enabled, + }) +} + +pub fn prompt_for_update( + config: &RuntimeConfig, + paths: &RuntimePaths, +) -> Result { + let before = selection(config, paths)?; + if before.available.is_empty() || !has_graphical_session() { + return Ok(PromptFeaturesOutcome { + prompted: false, + changed: false, + cancelled: false, + selection: before, + }); + } + + let enabled = match prompt_with_available_helper(&before.available)? { + PromptHelperOutcome::Selected(enabled) => enabled, + PromptHelperOutcome::Cancelled => { + return Ok(PromptFeaturesOutcome { + prompted: true, + changed: false, + cancelled: true, + selection: before, + }); + } + PromptHelperOutcome::Unavailable => { + return Ok(PromptFeaturesOutcome { + prompted: false, + changed: false, + cancelled: false, + selection: before, + }); + } + }; + + let enabled = normalize_feature_ids(enabled.iter().map(String::as_str)); + let changed = enabled != before.enabled; + if changed { + write_features_config(&before.config_path, &enabled)?; + } + + Ok(PromptFeaturesOutcome { + prompted: true, + changed, + cancelled: false, + selection: selection(config, paths)?, + }) +} + +fn bundled_features_config_path(config: &RuntimeConfig) -> PathBuf { + config + .builder_bundle_root + .join("linux-features") + .join("features.json") +} + +fn read_enabled_feature_ids(path: &Path) -> Result> { + let contents = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + let config = serde_json::from_str::(&contents) + .with_context(|| format!("Failed to parse {}", path.display()))?; + Ok(normalize_feature_ids( + config.enabled.iter().map(String::as_str), + )) +} + +fn discover_feature_options(config: &RuntimeConfig) -> Result> { + let root = config.builder_bundle_root.join("linux-features"); + let mut options = Vec::new(); + if !root.is_dir() { + return Ok(options); + } + + for entry in + fs::read_dir(&root).with_context(|| format!("Failed to read {}", root.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let manifest_path = entry.path().join("feature.json"); + if !manifest_path.is_file() { + continue; + } + let contents = fs::read_to_string(&manifest_path) + .with_context(|| format!("Failed to read {}", manifest_path.display()))?; + let manifest = serde_json::from_str::(&contents) + .with_context(|| format!("Failed to parse {}", manifest_path.display()))?; + if manifest.hidden { + continue; + } + if !is_valid_feature_id(&manifest.id) { + continue; + } + let title = manifest + .title + .or(manifest.name) + .unwrap_or_else(|| manifest.id.clone()); + options.push(FeatureOption { + id: manifest.id, + title, + description: manifest.description.unwrap_or_default(), + enabled: false, + default_enabled: manifest.default_enabled, + }); + } + + options.sort_by(|left, right| left.title.cmp(&right.title).then(left.id.cmp(&right.id))); + Ok(options) +} + +fn prompt_with_available_helper(options: &[FeatureOption]) -> Result { + let helper = choose_prompt_helper( + prefers_kdialog(), + command_in_path("zenity").is_some(), + command_in_path("kdialog").is_some(), + ); + match helper { + Some(PromptHelper::Zenity) => run_zenity_checklist(options), + Some(PromptHelper::Kdialog) => run_kdialog_checklist(options), + None => Ok(PromptHelperOutcome::Unavailable), + } +} + +fn choose_prompt_helper( + prefers_kdialog: bool, + has_zenity: bool, + has_kdialog: bool, +) -> Option { + if prefers_kdialog && has_kdialog { + return Some(PromptHelper::Kdialog); + } + if has_zenity { + return Some(PromptHelper::Zenity); + } + if has_kdialog { + return Some(PromptHelper::Kdialog); + } + None +} + +fn run_zenity_checklist(options: &[FeatureOption]) -> Result { + let mut command = Command::new("zenity"); + command.args([ + "--list", + "--checklist", + "--title=Codex Desktop update", + "--text=Choose optional Linux features for this update rebuild.", + "--column=Use", + "--column=Id", + "--column=Feature", + "--column=Description", + "--hide-column=2", + "--print-column=2", + "--separator=,", + ]); + for option in options { + command + .arg(if option.enabled { "TRUE" } else { "FALSE" }) + .arg(&option.id) + .arg(&option.title) + .arg(option.description.trim()); + } + + let output = command.output().context("Failed to launch zenity")?; + if !output.status.success() { + return Ok(PromptHelperOutcome::Cancelled); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(PromptHelperOutcome::Selected( + stdout + .trim() + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect(), + )) +} + +fn run_kdialog_checklist(options: &[FeatureOption]) -> Result { + let mut command = Command::new("kdialog"); + command.args([ + "--title", + "Codex Desktop update", + "--checklist", + "Choose optional Linux features for this update rebuild.", + ]); + for option in options { + command + .arg(&option.id) + .arg(feature_prompt_label(option)) + .arg(if option.enabled { "on" } else { "off" }); + } + + let output = command.output().context("Failed to launch kdialog")?; + if !output.status.success() { + return Ok(PromptHelperOutcome::Cancelled); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(PromptHelperOutcome::Selected( + stdout + .split_whitespace() + .map(|value| value.trim_matches('"').to_string()) + .filter(|value| !value.is_empty()) + .collect(), + )) +} + +fn feature_prompt_label(option: &FeatureOption) -> String { + if option.description.trim().is_empty() { + return option.title.clone(); + } + format!("{} - {}", option.title, option.description) +} + +fn normalize_feature_ids<'a>(ids: impl IntoIterator) -> Vec { + let mut seen = BTreeSet::new(); + ids.into_iter() + .map(str::trim) + .filter(|id| is_valid_feature_id(id)) + .filter(|id| seen.insert((*id).to_string())) + .map(str::to_string) + .collect() +} + +fn is_valid_feature_id(id: &str) -> bool { + let mut chars = id.chars(); + matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase() || ch.is_ascii_digit()) + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +fn has_graphical_session() -> bool { + let has_display = + std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some(); + let has_dbus = std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some() + || std::env::var_os("XDG_RUNTIME_DIR").is_some(); + has_display && has_dbus +} + +fn prefers_kdialog() -> bool { + desktop_tokens().iter().any(|token| { + matches!( + token.as_str(), + "kde" | "plasma" | "plasmawayland" | "plasmax11" + ) + }) +} + +fn desktop_tokens() -> Vec { + [ + std::env::var("XDG_CURRENT_DESKTOP").ok(), + std::env::var("DESKTOP_SESSION").ok(), + ] + .into_iter() + .flatten() + .flat_map(|value| { + value + .split(':') + .map(|segment| segment.trim().to_ascii_lowercase()) + .collect::>() + }) + .filter(|token| !token.is_empty()) + .collect() +} + +fn command_in_path(name: &str) -> Option { + let path_env = std::env::var_os("PATH").unwrap_or_else(|| OsString::from("")); + std::env::split_paths(&path_env).find_map(|entry| { + let candidate = entry.join(name); + if is_executable_file(&candidate) { + Some(candidate) + } else { + None + } + }) +} + +fn is_executable_file(path: &Path) -> bool { + path.is_file() + && path + .metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_paths(root: &Path) -> RuntimePaths { + RuntimePaths { + config_file: root.join("config/config.toml"), + state_file: root.join("state/state.json"), + log_file: root.join("state/service.log"), + cache_dir: root.join("cache"), + state_dir: root.join("state"), + config_dir: root.join("config"), + } + } + + fn test_config(root: &Path) -> RuntimeConfig { + RuntimeConfig { + dmg_url: "https://example.com/Codex.dmg".to_string(), + initial_check_delay_seconds: 1, + check_interval_hours: 6, + auto_install_on_app_exit: true, + notifications: false, + workspace_root: root.join("cache"), + builder_bundle_root: root.join("builder"), + app_executable_path: root.join("not-running-electron"), + } + } + + #[test] + fn reads_user_feature_config_before_bundled_default() -> Result<()> { + let temp = tempdir()?; + let paths = test_paths(temp.path()); + let config = test_config(temp.path()); + let bundled = config + .builder_bundle_root + .join("linux-features") + .join("features.json"); + write_features_config(&bundled, &["read-aloud".to_string()])?; + write_features_config( + &user_features_config_path(&paths), + &["remote-control-ui".to_string()], + )?; + + assert_eq!( + effective_enabled_feature_ids(&config, &paths)?, + vec!["remote-control-ui"] + ); + Ok(()) + } + + #[test] + fn discovers_feature_manifest_options() -> Result<()> { + let temp = tempdir()?; + let paths = test_paths(temp.path()); + let config = test_config(temp.path()); + let feature_dir = config.builder_bundle_root.join("linux-features/read-aloud"); + fs::create_dir_all(&feature_dir)?; + fs::write( + feature_dir.join("feature.json"), + r#"{ + "id": "read-aloud", + "title": "Read Aloud", + "description": "Speak assistant responses", + "defaultEnabled": false +}"#, + )?; + write_features_config( + &user_features_config_path(&paths), + &["read-aloud".to_string()], + )?; + + let selection = selection(&config, &paths)?; + + assert_eq!(selection.enabled, vec!["read-aloud"]); + assert_eq!(selection.available.len(), 1); + assert!(selection.available[0].enabled); + assert_eq!(selection.available[0].title, "Read Aloud"); + Ok(()) + } + + #[test] + fn hidden_feature_manifests_are_not_prompted() -> Result<()> { + let temp = tempdir()?; + let config = test_config(temp.path()); + let feature_dir = config + .builder_bundle_root + .join("linux-features/example-feature"); + fs::create_dir_all(&feature_dir)?; + fs::write( + feature_dir.join("feature.json"), + r#"{ + "id": "example-feature", + "title": "Example Linux Feature", + "description": "Developer-only fixture", + "hidden": true +}"#, + )?; + + assert!(discover_feature_options(&config)?.is_empty()); + Ok(()) + } + + #[test] + fn prompt_helper_selection_degrades_when_dialog_helpers_are_missing() { + assert_eq!(choose_prompt_helper(false, false, false), None); + assert_eq!( + choose_prompt_helper(false, true, false), + Some(PromptHelper::Zenity) + ); + assert_eq!( + choose_prompt_helper(true, true, true), + Some(PromptHelper::Kdialog) + ); + assert_eq!( + choose_prompt_helper(false, false, true), + Some(PromptHelper::Kdialog) + ); + } +} diff --git a/updater/src/main.rs b/updater/src/main.rs index eb9042ba..49b12935 100644 --- a/updater/src/main.rs +++ b/updater/src/main.rs @@ -1,11 +1,11 @@ //! Binary entrypoint for the local Codex Desktop update manager. mod app; -#[cfg(test)] mod builder; mod cli; mod codex_cli; mod config; +mod features; mod install; mod install_rollback; mod liveness; From ffc6fe7e20a8fa5e5a24e59c36a2f09ecc0b1bf8 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 20 May 2026 05:20:33 +0300 Subject: [PATCH 4/5] Skip local rebuild when no Linux features are selected --- updater/src/app.rs | 141 +++++++++++++++++++++++++++++++++++++++++++ updater/src/state.rs | 6 ++ 2 files changed, 147 insertions(+) diff --git a/updater/src/app.rs b/updater/src/app.rs index 081bd820..d9d66886 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -619,6 +619,11 @@ async fn run_check_cycle( && installed_version_satisfies_candidate(&state.installed_version, &metadata.candidate_version) { set_status(state, paths, UpdateStatus::Idle)?; + state.candidate_version = None; + state.remote_package_download_url = None; + state.remote_package_asset_name = None; + state.artifact_paths.package_path = None; + persist_state(paths, state)?; info!("Linux release fingerprint unchanged and installed version is current"); return Ok(()); } @@ -646,6 +651,8 @@ async fn run_check_cycle( rollback::record_current_package_as_known_good(state); state.status = UpdateStatus::UpdateDetected; state.candidate_version = Some(metadata.candidate_version.clone()); + state.remote_package_download_url = Some(metadata.download_url.clone()); + state.remote_package_asset_name = Some(metadata.asset_name.clone()); state.dmg_sha256 = None; state.artifact_paths.dmg_path = None; state.artifact_paths.workspace_dir = None; @@ -883,6 +890,10 @@ async fn prepare_update_package_if_needed( return Ok(false); } + if feature_outcome.selection.enabled.is_empty() { + return download_ready_release_package(config, state, paths, &candidate_version).await; + } + let client = Client::builder().build()?; set_status(state, paths, UpdateStatus::DownloadingDmg)?; @@ -935,6 +946,66 @@ async fn prepare_update_package_if_needed( Ok(true) } +async fn download_ready_release_package( + config: &RuntimeConfig, + state: &mut PersistedState, + paths: &RuntimePaths, + candidate_version: &str, +) -> Result { + let client = Client::builder().build()?; + let (download_url, asset_name) = match ( + state.remote_package_download_url.clone(), + state.remote_package_asset_name.clone(), + ) { + (Some(download_url), Some(asset_name)) => (download_url, asset_name), + _ => { + let metadata = upstream::fetch_remote_metadata( + &client, + upstream::DEFAULT_RELEASES_API_URL, + install::PackageKind::detect(), + ) + .await?; + if metadata.candidate_version != candidate_version { + mark_failed_and_persist( + state, + paths, + format!( + "Ready update metadata is stale: expected {candidate_version}, found {}", + metadata.candidate_version + ), + )?; + return Ok(false); + } + state.remote_headers_fingerprint = Some(metadata.headers_fingerprint); + state.remote_package_download_url = Some(metadata.download_url.clone()); + state.remote_package_asset_name = Some(metadata.asset_name.clone()); + (metadata.download_url, metadata.asset_name) + } + }; + + set_status(state, paths, UpdateStatus::DownloadingDmg)?; + let downloads_dir = config.workspace_root.join("downloads"); + let downloaded = upstream::download_dmg( + &client, + &download_url, + &asset_name, + &downloads_dir, + Utc::now(), + candidate_version, + ) + .await?; + + state.status = UpdateStatus::ReadyToInstall; + state.candidate_version = Some(downloaded.candidate_version); + state.dmg_sha256 = Some(downloaded.sha256); + state.artifact_paths.dmg_path = None; + state.artifact_paths.workspace_dir = None; + state.artifact_paths.package_path = Some(downloaded.path); + state.error_message = None; + persist_state(paths, state)?; + Ok(true) +} + fn complete_pending_install_if_already_installed( state: &mut PersistedState, paths: &RuntimePaths, @@ -957,6 +1028,8 @@ fn complete_pending_install_if_already_installed( state.status = UpdateStatus::Installed; state.candidate_version = None; + state.remote_package_download_url = None; + state.remote_package_asset_name = None; if !candidate_is_installed { state.artifact_paths.package_path = None; } @@ -980,6 +1053,8 @@ fn recover_interrupted_install(state: &mut PersistedState, paths: &RuntimePaths) state.status = UpdateStatus::Installed; state.candidate_version = None; + state.remote_package_download_url = None; + state.remote_package_asset_name = None; if !candidate_is_installed { state.artifact_paths.package_path = None; } @@ -1217,6 +1292,8 @@ async fn trigger_install( state.status = UpdateStatus::Installed; state.installed_version = install::installed_package_version(); state.candidate_version = None; + state.remote_package_download_url = None; + state.remote_package_asset_name = None; state.rollback_blocked_candidate_version = None; state.error_message = None; state.notified_events.clear(); @@ -1326,6 +1403,10 @@ fn notify_failure( #[cfg(test)] mod tests { use super::*; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; #[test] fn upstream_check_freshness_respects_configured_interval() { @@ -1640,6 +1721,66 @@ mod tests { Ok(()) } + #[tokio::test] + async fn prepare_update_uses_release_package_when_no_features_are_selected() -> Result<()> { + let temp = tempfile::tempdir()?; + let paths = RuntimePaths { + config_file: temp.path().join("config/config.toml"), + state_file: temp.path().join("state/state.json"), + log_file: temp.path().join("state/service.log"), + cache_dir: temp.path().join("cache"), + state_dir: temp.path().join("state"), + config_dir: temp.path().join("config"), + }; + paths.ensure_dirs()?; + + let server = MockServer::start().await; + let body = b"native-release-package"; + Mock::given(method("GET")) + .and(path("/codex-desktop_2999.03.25.010203+deadbeef_amd64.deb")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec())) + .mount(&server) + .await; + + let config = RuntimeConfig { + dmg_url: format!("{}/Codex.dmg", server.uri()), + initial_check_delay_seconds: 1, + check_interval_hours: 6, + auto_install_on_app_exit: false, + notifications: false, + workspace_root: temp.path().join("cache"), + builder_bundle_root: temp.path().join("builder-without-features"), + app_executable_path: temp.path().join("not-running-electron"), + }; + + let mut state = PersistedState::new(false); + state.status = UpdateStatus::ReadyToInstall; + state.candidate_version = Some("2999.03.25.010203+deadbeef".to_string()); + state.remote_package_asset_name = + Some("codex-desktop_2999.03.25.010203+deadbeef_amd64.deb".to_string()); + state.remote_package_download_url = Some(format!( + "{}/codex-desktop_2999.03.25.010203+deadbeef_amd64.deb", + server.uri() + )); + + assert!(prepare_update_package_if_needed(&config, &mut state, &paths).await?); + + assert_eq!(state.status, UpdateStatus::ReadyToInstall); + assert_eq!( + state.candidate_version.as_deref(), + Some("2999.03.25.010203+deadbeef") + ); + assert_eq!(state.artifact_paths.dmg_path, None); + assert_eq!(state.artifact_paths.workspace_dir, None); + let package_path = state + .artifact_paths + .package_path + .as_ref() + .expect("release package should be prepared"); + assert_eq!(std::fs::read(package_path)?, body); + Ok(()) + } + #[tokio::test] async fn install_ready_marks_missing_artifact_failed() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/updater/src/state.rs b/updater/src/state.rs index 40c9c3b2..2b3b24d1 100644 --- a/updater/src/state.rs +++ b/updater/src/state.rs @@ -71,6 +71,10 @@ pub struct PersistedState { pub last_check_at: Option>, pub last_successful_check_at: Option>, pub remote_headers_fingerprint: Option, + #[serde(default)] + pub remote_package_download_url: Option, + #[serde(default)] + pub remote_package_asset_name: Option, pub dmg_sha256: Option, pub artifact_paths: ArtifactPaths, pub error_message: Option, @@ -108,6 +112,8 @@ impl PersistedState { last_check_at: None, last_successful_check_at: None, remote_headers_fingerprint: None, + remote_package_download_url: None, + remote_package_asset_name: None, dmg_sha256: None, artifact_paths: ArtifactPaths::default(), error_message: None, From 52311fbec2e6c107eb8666dba6532e76fc6613df Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Wed, 20 May 2026 05:30:24 +0300 Subject: [PATCH 5/5] Polish Linux release updater edge cases --- README.md | 13 +++++++----- updater/src/app.rs | 44 ++++++++++++++++++++++++++++++----------- updater/src/upstream.rs | 14 ++++++------- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 5ab2e9fd..2813801e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Codex Desktop for Linux -Unofficial Linux build of [OpenAI Codex Desktop](https://openai.com/codex/). The official Codex Desktop app is macOS-only — this project converts the upstream macOS `Codex.dmg` into a runnable Linux Electron app, ships native `.deb` / `.rpm` / `.pkg.tar.zst` packages plus local AppImage self-builds and a Nix flake, and includes a local auto-updater that rebuilds future native Linux packages from newer upstream DMGs. +Unofficial Linux build of [OpenAI Codex Desktop](https://openai.com/codex/). The official Codex Desktop app is macOS-only — this project converts the upstream macOS `Codex.dmg` into a runnable Linux Electron app, ships native `.deb` / `.rpm` / `.pkg.tar.zst` packages plus local AppImage self-builds and a Nix flake, and includes a local auto-updater that follows this repository's GitHub Releases. Before opening a pull request, please read [CONTRIBUTING.md](CONTRIBUTING.md). @@ -27,7 +27,7 @@ Anything systemd-based should work for the optional auto-updater service (`syste | Feature | Status | Notes | |---|---|---| | Standard Codex Desktop UI | ✅ always | Chats, browser, files, MCP plugins | -| Auto-updater (`codex-update-manager`) | ✅ native packages | Detects newer upstream DMGs, rebuilds + installs native packages locally | +| Auto-updater (`codex-update-manager`) | ✅ native packages | Detects newer Linux releases, installs the matching native package, and rebuilds locally only for selected opt-in Linux features | | Native packaging (`.deb` / `.rpm` / `.pkg.tar.zst`) | ✅ always | One-shot `make package` picks your distro | | AppImage self-build | ✅ manual | `make appimage` writes a local `dist/*.AppImage`; rebuild manually after upstream updates | | Linux tray + warm-start handoff | ✅ always | Single-instance lock, second-instance window focus | @@ -280,8 +280,11 @@ CODEX_MULTI_LAUNCH=1 CODEX_MULTI_LAUNCH_PORT_RANGE=5175-5199 ./codex-app/start.s By default, the native package installs a companion `systemd --user` service named `codex-update-manager`. -- It checks this repository's GitHub Releases on daemon startup, every 6 hours, and in the background on app launch when stale. -- When a newer release is available, it downloads the matching `.deb`, `.rpm`, or `.pkg.tar.*` asset for the current system. +- It checks this repository's GitHub Releases on daemon startup, every 6 hours, and in the background on app launch. +- When a newer release is available, Codex Desktop shows the existing Update action. +- Choosing Update asks which optional Linux features to include. +- If no optional features are selected, the updater downloads the matching `.deb`, `.rpm`, or `.pkg.tar.*` release asset for the current system. +- If optional features are selected, it rebuilds a local native package from the upstream `Codex.dmg` with that feature config. - If Codex Desktop is open, the final install waits until Electron exits. - The updater runs unprivileged and uses `pkexec` only for the final package install. - Codex CLI checks are best-effort and launcher-scoped. Set `CODEX_SYNC_CLI_PREFLIGHT=1` when debugging launch-time CLI preflight. @@ -504,7 +507,7 @@ make clean-state 6. It writes the Linux launcher into `codex-app/start.sh` (body sourced from `launcher/start.sh.template`) 7. `scripts/build-{deb,rpm,pacman}.sh` packages `codex-app/` into a native artifact; `scripts/build-appimage.sh` creates a local AppImage 8. Default native packages provide `codex-update-manager` plus a `systemd --user` service unit -9. The updater watches for newer upstream DMGs and rebuilds future native Linux packages locally, unless the package was built with `PACKAGE_WITH_UPDATER=0` +9. The updater watches this repository's GitHub Releases, installs matching native packages directly when no optional features are selected, and rebuilds locally from the upstream `Codex.dmg` only when selected Linux features require it The installer replaces the macOS Electron binary with a Linux build, recompiles native modules, and removes macOS-only pieces such as `sparkle`. diff --git a/updater/src/app.rs b/updater/src/app.rs index d9d66886..ca6996b9 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -577,8 +577,6 @@ async fn run_check_cycle( ); } - let retrying_failed_update = state.status == UpdateStatus::Failed; - let Some(_check_lock) = try_acquire_check_lock(paths)? else { return Ok(()); }; @@ -610,21 +608,21 @@ async fn run_check_cycle( } Err(error) => return Err(error), }; - let previous_headers_fingerprint = state.remote_headers_fingerprint.clone(); state.remote_headers_fingerprint = Some(metadata.headers_fingerprint.clone()); state.last_successful_check_at = Some(Utc::now()); - if previous_headers_fingerprint.as_deref() == Some(metadata.headers_fingerprint.as_str()) - && !retrying_failed_update - && installed_version_satisfies_candidate(&state.installed_version, &metadata.candidate_version) - { - set_status(state, paths, UpdateStatus::Idle)?; + if release_candidate_is_already_installed_or_superseded( + &state.installed_version, + &metadata.candidate_version, + ) { + state.status = UpdateStatus::Idle; state.candidate_version = None; state.remote_package_download_url = None; state.remote_package_asset_name = None; state.artifact_paths.package_path = None; + state.error_message = None; persist_state(paths, state)?; - info!("Linux release fingerprint unchanged and installed version is current"); + info!("Linux release candidate is already installed or superseded"); return Ok(()); } @@ -898,7 +896,7 @@ async fn prepare_update_package_if_needed( set_status(state, paths, UpdateStatus::DownloadingDmg)?; let downloads_dir = config.workspace_root.join("downloads"); - let downloaded = upstream::download_dmg( + let downloaded = upstream::download_asset( &client, &config.dmg_url, "Codex.dmg", @@ -985,7 +983,7 @@ async fn download_ready_release_package( set_status(state, paths, UpdateStatus::DownloadingDmg)?; let downloads_dir = config.workspace_root.join("downloads"); - let downloaded = upstream::download_dmg( + let downloaded = upstream::download_asset( &client, &download_url, &asset_name, @@ -1106,6 +1104,10 @@ fn installed_version_satisfies_candidate(installed: &str, candidate: &str) -> bo } } +fn release_candidate_is_already_installed_or_superseded(installed: &str, candidate: &str) -> bool { + installed_version_satisfies_candidate(installed, candidate) +} + fn installed_version_matches_candidate(installed: &str, candidate: &str) -> bool { if installed == "unknown" { return false; @@ -1431,6 +1433,26 @@ mod tests { assert!(!upstream_check_is_fresh(&config, &state)); } + #[test] + fn release_candidate_current_check_does_not_depend_on_cached_fingerprint() { + assert!(release_candidate_is_already_installed_or_superseded( + "2026.05.20.222222+new", + "2026.05.20.222222+new" + )); + assert!(release_candidate_is_already_installed_or_superseded( + "2026.05.21.010203+newer", + "2026.05.20.222222+new" + )); + assert!(!release_candidate_is_already_installed_or_superseded( + "2026.05.19.010203+old", + "2026.05.20.222222+new" + )); + assert!(!release_candidate_is_already_installed_or_superseded( + "unknown", + "2026.05.20.222222+new" + )); + } + #[test] fn plain_status_reports_update_error() { let mut state = PersistedState::new(true); diff --git a/updater/src/upstream.rs b/updater/src/upstream.rs index e1aeccd9..a2a8d26f 100644 --- a/updater/src/upstream.rs +++ b/updater/src/upstream.rs @@ -41,8 +41,8 @@ pub struct RemoteMetadata { } #[derive(Debug, Clone, PartialEq, Eq)] -/// Result of downloading the selected Linux release package. -pub struct DownloadedDmg { +/// Result of downloading a selected release or rebuild source asset. +pub struct DownloadedAsset { pub path: PathBuf, pub sha256: String, pub candidate_version: String, @@ -146,15 +146,15 @@ pub async fn fetch_remote_metadata( }) } -/// Downloads the selected Linux release package and hashes its contents. -pub async fn download_dmg( +/// Downloads a selected asset and hashes its contents. +pub async fn download_asset( client: &Client, download_url: &str, asset_name: &str, destination_dir: &Path, version_timestamp: DateTime, candidate_version: &str, -) -> Result { +) -> Result { tokio::fs::create_dir_all(destination_dir) .await .with_context(|| format!("Failed to create {}", destination_dir.display()))?; @@ -199,7 +199,7 @@ pub async fn download_dmg( candidate_version.to_string() }; - Ok(DownloadedDmg { + Ok(DownloadedAsset { path: destination, sha256, candidate_version, @@ -498,7 +498,7 @@ mod tests { let client = Client::builder().build()?; let temp = tempdir()?; - let downloaded = download_dmg( + let downloaded = download_asset( &client, &format!( "{}/codex-desktop_2026.05.20.222222+new_amd64.deb",