Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand All @@ -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 |
Expand Down Expand Up @@ -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 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 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.
Expand Down Expand Up @@ -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`.

Expand Down
81 changes: 70 additions & 11 deletions scripts/lib/linux-update-bridge-patch.js

Large diffs are not rendered by default.

88 changes: 79 additions & 9 deletions scripts/patch-linux-window-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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\?\.\(\)/);
Expand All @@ -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\(\)/);
Expand All @@ -1499,6 +1508,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);
Expand All @@ -1521,20 +1542,47 @@ 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\}/);
assert.match(patched, /refresh:async\(\)=>\{if\(await c\)\{try\{await codexLinuxRefreshUpdateState\(\)\}/);
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`,",
Expand All @@ -1553,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\}/);
});
Expand All @@ -1572,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(
Expand Down Expand Up @@ -1659,12 +1710,31 @@ 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", () => {
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", () => {
Expand Down
Loading
Loading