diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54a0bef1..0340e989 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,25 @@ jobs: bash -n contrib/user-local-install/files/.local/bin/codex-app-update bash -n contrib/user-local-install/files/.local/bin/codex-app-version bash -n contrib/user-local-install/files/share/common.sh + bash -n scripts/ci/update-nix-hashes.sh + + - name: Check Node patcher syntax + run: | + set -euo pipefail + node --check scripts/patch-linux-window-ui.js + node --check scripts/patch-linux-window-ui.test.js + for file in scripts/patches/*.js; do + node --check "$file" + done + for file in linux-features/*/patch.js; do + [ -f "$file" ] || continue + node --check "$file" + done + feature_tests=(linux-features/*/test.js) + if [ -e "${feature_tests[0]}" ]; then + node --test --test-concurrency=1 "${feature_tests[@]}" + fi + node --check scripts/ci/validate-patch-report.js - name: Check Rust formatting run: cargo fmt --check diff --git a/.github/workflows/update-codex-hash.yml b/.github/workflows/update-codex-hash.yml index 2edccc1e..2a7f8d6d 100644 --- a/.github/workflows/update-codex-hash.yml +++ b/.github/workflows/update-codex-hash.yml @@ -1,8 +1,8 @@ -name: Update Codex.dmg hash +name: Update Nix upstream hashes on: schedule: - - cron: '0 6 * * *' + - cron: '0 */2 * * *' workflow_dispatch: {} permissions: @@ -12,37 +12,29 @@ permissions: jobs: update-hash: runs-on: ubuntu-latest + env: + NIX_CONFIG: experimental-features = nix-command flakes steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 - - name: Download upstream Codex.dmg - run: curl -fL --retry 3 -o /tmp/Codex.dmg https://persistent.oaistatic.com/codex-app-prod/Codex.dmg + - name: Refresh Nix fixed-output hashes + run: scripts/ci/update-nix-hashes.sh - - name: Compute SRI hash and update flake.nix + - name: Commit refreshed hashes env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - NEW_HASH=$(nix hash file --sri --type sha256 /tmp/Codex.dmg) - echo "Upstream hash: $NEW_HASH" - - if ! [[ "$NEW_HASH" =~ ^sha256-[A-Za-z0-9+/=]{44}$ ]]; then - echo "Refusing to proceed: computed hash '$NEW_HASH' is not a valid SRI sha256." >&2 - exit 1 - fi - - CURRENT_HASH=$(sed -nE 's/.*hash = "(sha256-[^"]+)".*/\1/p' flake.nix | head -n 1) - echo "Current hash: $CURRENT_HASH" - - if [ "$NEW_HASH" = "$CURRENT_HASH" ]; then - echo "Hash unchanged, nothing to do." + if git diff --quiet -- flake.nix; then + echo "Nix hashes unchanged, nothing to do." exit 0 fi - sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" flake.nix + CODEX_DMG_HASH="$(scripts/ci/update-nix-hashes.sh read-flake-hash 'codexDmg = pkgs.fetchurl {' 'hash = ')" + PAYLOAD_HASH="$(scripts/ci/update-nix-hashes.sh read-flake-hash 'codexDesktopPayload = pkgs.stdenv.mkDerivation {' 'outputHash = ')" git config user.name "codex-dmg-hash-bot" git config user.email "actions@github.com" @@ -50,14 +42,16 @@ jobs: git fetch origin "$BRANCH:refs/remotes/origin/$BRANCH" || true git checkout -B "$BRANCH" git add flake.nix - git commit -m "fix(nix): update Codex.dmg hash - - Upstream binary changed; refreshed SRI hash to $NEW_HASH. - - [skip ci]" + git commit \ + -m "fix(nix): update upstream Nix hashes" \ + -m "Refreshed Codex.dmg SRI hash to $CODEX_DMG_HASH." \ + -m "Refreshed codexDesktopPayload recursive outputHash to $PAYLOAD_HASH." \ + -m "[skip ci]" git push --force-with-lease origin "$BRANCH" - PR_BODY="Upstream binary changed; refreshed SRI hash to \`$NEW_HASH\`. + PR_BODY="Refreshed Codex.dmg SRI hash to \`$CODEX_DMG_HASH\`. + + Refreshed codexDesktopPayload recursive outputHash to \`$PAYLOAD_HASH\`. This scheduled workflow opens a PR for maintainer review instead of pushing directly to main." @@ -65,13 +59,13 @@ jobs: if [ -n "$PR_NUMBER" ]; then gh pr edit "$PR_NUMBER" \ --repo nisavid/codex-app-linux \ - --title "fix(nix): update Codex.dmg hash" \ + --title "fix(nix): update upstream Nix hashes" \ --body "$PR_BODY" else gh pr create \ --repo nisavid/codex-app-linux \ --base main \ --head "$BRANCH" \ - --title "fix(nix): update Codex.dmg hash" \ + --title "fix(nix): update upstream Nix hashes" \ --body "$PR_BODY" fi diff --git a/.github/workflows/upstream-build-app.yml b/.github/workflows/upstream-build-app.yml index 19bc89e3..72c2ef28 100644 --- a/.github/workflows/upstream-build-app.yml +++ b/.github/workflows/upstream-build-app.yml @@ -134,7 +134,11 @@ jobs: JSON - name: Build app from upstream DMG - run: make build-app DMG=/tmp/codex-upstream-ci/Codex.dmg + run: | + set -euo pipefail + CODEX_PATCH_REPORT_JSON="$GITHUB_WORKSPACE/patch-report.json" \ + make build-app DMG=/tmp/codex-upstream-ci/Codex.dmg + node scripts/ci/validate-patch-report.js patch-report.json --profile upstream-build - name: Upload upstream DMG metadata artifact uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index b910ff7f..2872f380 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ bin/codex-* target/ dist/ dist-next/ +linux-features/features.json .idea/ .claude/ /.codex diff --git a/AGENTS.md b/AGENTS.md index 44ec0b0e..94895094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Treat this file as always-loaded agent policy. Keep detailed package recipes, ru - Linux package templates, maintainer scripts, desktop entry, service unit, packaged runtime helper: `packaging/linux/` - Rust updater service and CLI: `updater/` - Updater crate version and versioning policy: `updater/Cargo.toml` and - `docs/maintainers/package-runtime-maintenance.md` (current version: `0.7.0`) + `docs/maintainers/package-runtime-maintenance.md` (current version: `0.7.1`) - User-facing overview and install guidance: `README.md` - Webview server design decision and acceptance criteria: `docs/webview-server-evaluation.md` - Fork-specific contracts and upstream-sync review inventory: `docs/maintainers/fork-divergences.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index 562c407d..428f4400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.7.1] - 2026-05-06 + +### Fixed + +- The Chrome native-messaging host now evicts stale browser clients when a newer Codex browser client connects, preventing old Node REPL sessions from repeatedly reattaching CDP and driving extension service-worker CPU. +- The bundled Chrome plugin is now auto-installed during app startup, matching Browser Use, so the plugin page no longer falls back to an install button after restart when the Linux native host is already staged. +- Local auto-update rebuilds and package builds now find the Rust toolchain reliably when `cargo` is installed via `rustup` under `~/.cargo/bin`, even if the `codex-app-updater` user service or packaging scripts inherit a reduced `PATH`. + +### Added + +- Regression coverage for the build-environment fix: updater path construction now has a unit test for `~/.cargo/bin`, and packaging helpers resolve `cargo` through the same fallback path used by Linux Computer Use staging. + ## [0.7.0] - 2026-05-04 ### Added diff --git a/Cargo.lock b/Cargo.lock index 11bf9464..f314d2e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,9 +184,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -282,9 +282,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -292,9 +292,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -310,9 +310,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -359,9 +359,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -369,12 +369,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -452,7 +446,7 @@ dependencies = [ [[package]] name = "codex-app-updater" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "chrono", @@ -475,17 +469,21 @@ dependencies = [ [[package]] name = "codex-computer-use-linux" -version = "0.1.0" +version = "0.1.2-linux-alpha2" dependencies = [ "anyhow", "atspi", "base64", + "cosmic-protocols", "futures-util", + "libc", "rmcp", "schemars", "serde", "serde_json", "tokio", + "wayland-client", + "wayland-protocols", "zbus", ] @@ -536,6 +534,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cosmic-protocols" +version = "0.2.0" +source = "git+https://github.com/pop-os/cosmic-protocols?rev=160b086abe03cd34a8a375d7fbe47b24308d1f38#160b086abe03cd34a8a375d7fbe47b24308d1f38" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -623,9 +634,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", "const-oid", @@ -674,6 +685,12 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -752,9 +769,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -942,9 +959,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -970,9 +987,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1039,18 +1056,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1063,7 +1080,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1071,15 +1087,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1134,12 +1149,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1147,9 +1163,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1160,9 +1176,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1174,15 +1190,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1194,15 +1210,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1238,9 +1254,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1248,12 +1264,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1264,16 +1280,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1288,25 +1294,52 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", + "jni-macros", "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] [[package]] name = "jobserver" @@ -1320,10 +1353,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1342,15 +1377,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -1363,9 +1398,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" @@ -1442,9 +1477,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -1562,12 +1597,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.5" @@ -1579,6 +1608,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polling" version = "3.11.0" @@ -1595,9 +1630,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1656,9 +1691,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", "serde", @@ -1678,7 +1713,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -1700,7 +1735,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1717,7 +1752,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1778,7 +1813,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1899,7 +1934,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -1924,6 +1959,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1939,9 +1983,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -1965,9 +2009,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1975,9 +2019,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2087,9 +2131,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2201,6 +2245,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2285,7 +2345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", - "thiserror 2.0.18", + "thiserror", "windows", "windows-version", ] @@ -2303,33 +2363,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2373,9 +2413,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2473,9 +2513,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -2515,20 +2555,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2608,9 +2648,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -2667,9 +2707,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2710,11 +2750,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -2723,14 +2763,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2741,23 +2781,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2765,9 +2801,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2778,9 +2814,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2832,11 +2868,81 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -2854,9 +2960,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -3009,15 +3115,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3029,11 +3126,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.53.5", ] [[package]] @@ -3045,21 +3142,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3069,13 +3151,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -3094,12 +3193,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3107,10 +3200,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -3119,10 +3212,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "windows_i686_gnu" -version = "0.42.2" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -3130,6 +3223,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" @@ -3137,10 +3236,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows_i686_msvc" -version = "0.42.2" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -3149,10 +3248,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -3161,10 +3260,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -3173,10 +3272,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -3184,11 +3283,17 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -3225,6 +3330,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -3306,15 +3417,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3323,9 +3434,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3425,7 +3536,7 @@ version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" dependencies = [ - "quick-xml 0.39.2", + "quick-xml 0.39.4", "serde", "zbus_names", "zvariant", @@ -3453,18 +3564,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3480,9 +3591,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3491,9 +3602,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3502,9 +3613,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -3519,9 +3630,9 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.10.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", @@ -3533,9 +3644,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.10.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/README.md b/README.md index 516faf20..7fc8ad12 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ Native packages install `codex-app-updater`, a `systemd --user` service that checks for newer upstream DMGs, rebuilds the matching Linux package locally, and uses `pkexec` only for the final package install step. -Current updater crate version: `0.7.0`. +Current updater crate version: `0.7.1`. Useful service commands after installing a native package: diff --git a/computer-use-linux/Cargo.toml b/computer-use-linux/Cargo.toml index 55e928a4..464e338a 100644 --- a/computer-use-linux/Cargo.toml +++ b/computer-use-linux/Cargo.toml @@ -1,20 +1,28 @@ [package] name = "codex-computer-use-linux" -version = "0.1.0" +version = "0.1.2-linux-alpha2" edition = "2021" [[bin]] name = "codex-computer-use-linux" path = "src/main.rs" +[[bin]] +name = "codex-chrome-extension-host" +path = "src/bin/codex-chrome-extension-host.rs" + [dependencies] anyhow = "1.0.102" atspi = { version = "0.30.0", features = ["tokio"] } base64 = "0.22.1" +cosmic-protocols = { git = "https://github.com/pop-os/cosmic-protocols", rev = "160b086abe03cd34a8a375d7fbe47b24308d1f38", default-features = false, features = ["client"] } futures-util = "0.3.32" +libc = "0.2" rmcp = { version = "1.6.0", features = ["transport-io"] } schemars = "1.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { version = "1.52.3", features = ["macros", "rt", "time"] } +wayland-client = "0.31.11" +wayland-protocols = { version = "0.32.9", features = ["client", "staging"] } zbus = "5.15.0" diff --git a/computer-use-linux/src/bin/codex-chrome-extension-host.rs b/computer-use-linux/src/bin/codex-chrome-extension-host.rs new file mode 100644 index 00000000..cc1abe31 --- /dev/null +++ b/computer-use-linux/src/bin/codex-chrome-extension-host.rs @@ -0,0 +1,1093 @@ +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + env, fs, + fs::File, + io::{self, BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom, Write}, + net::Shutdown, + os::unix::{ + fs::{MetadataExt, PermissionsExt}, + io::AsRawFd, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + process, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const HOST_NAME: &str = "com.openai.codexextension"; +const SOCKET_DIR_ENV: &str = "CODEX_BROWSER_USE_SOCKET_DIR"; +const SESSIONS_DIR_ENV: &str = "CODEX_BROWSER_USE_SESSIONS_DIR"; +const DEFAULT_SOCKET_DIR: &str = "/tmp/codex-browser-use"; +const ROLLOUT_POLL_INTERVAL: Duration = Duration::from_millis(500); +const OBSERVED_TURN_TTL: Duration = Duration::from_secs(6 * 60 * 60); +const ROLLOUT_SEARCH_MAX_DEPTH: usize = 5; + +type SharedState = Arc>; +type SharedClientWriter = Arc>; + +#[derive(Clone)] +struct Client { + writer: SharedClientWriter, +} + +struct PendingChromeRequest { + client_id: usize, + client_request_id: Value, +} + +#[derive(Clone)] +struct PendingClientRequest { + client_id: usize, + chrome_request_id: Value, +} + +#[derive(Debug, PartialEq, Eq)] +enum ChromeClientRouteError { + NoClients, + MultipleClients, +} + +impl ChromeClientRouteError { + fn message(&self) -> &'static str { + match self { + Self::NoClients => "No Codex browser client is connected", + Self::MultipleClients => { + "Multiple Codex browser clients are connected; Chrome requests require exactly one" + } + } + } +} + +struct HostState { + stdout: Arc>, + rollout_tracker: RolloutTracker, + clients: HashMap, + pending_chrome_requests: HashMap, + pending_client_requests: HashMap, + next_client_id: usize, + next_chrome_id: u64, + next_client_request_id: u64, +} + +impl HostState { + fn new(stdout: Arc>, rollout_tracker: RolloutTracker) -> Self { + Self { + stdout, + rollout_tracker, + clients: HashMap::new(), + pending_chrome_requests: HashMap::new(), + pending_client_requests: HashMap::new(), + next_client_id: 1, + next_chrome_id: 1, + next_client_request_id: 1, + } + } + + fn replace_with_client(&mut self, writer: SharedClientWriter) -> (usize, Vec<(usize, Client)>) { + let evicted_clients = self.clients.drain().collect::>(); + if !evicted_clients.is_empty() { + self.pending_chrome_requests.clear(); + self.pending_client_requests.clear(); + } + + let id = self.next_client_id; + self.next_client_id += 1; + self.clients.insert(id, Client { writer }); + (id, evicted_clients) + } + + fn remove_client(&mut self, client_id: usize) { + self.clients.remove(&client_id); + remove_pending_requests_for_client( + &mut self.pending_chrome_requests, + &mut self.pending_client_requests, + client_id, + ); + } + + fn send_chrome(&self, message: &Value) { + let mut stdout = self.stdout.lock().expect("stdout mutex poisoned"); + if let Err(error) = write_frame(&mut *stdout, message) { + log(&format!("native stdout error: {error}")); + process::exit(1); + } + } + + fn send_client(&self, client_id: usize, message: &Value) { + let Some(client) = self.clients.get(&client_id) else { + return; + }; + + let mut writer = client.writer.lock().expect("client writer mutex poisoned"); + if let Err(error) = write_frame(&mut *writer, message) { + log(&format!("client socket write error: {error}")); + } + } + + fn broadcast_clients(&self, message: &Value) { + for client_id in self.clients.keys().copied().collect::>() { + self.send_client(client_id, message); + } + } +} + +#[derive(Clone)] +struct RolloutTracker { + inner: Arc>, + stdout: Arc>, + sessions_root: Option, +} + +struct RolloutTrackerState { + observed: HashMap, +} + +struct ObservedTurn { + session_id: String, + turn_id: String, + path: Option, + offset: u64, + created_at: Instant, +} + +impl RolloutTracker { + fn new(stdout: Arc>) -> Self { + let tracker = Self { + inner: Arc::new(Mutex::new(RolloutTrackerState { + observed: HashMap::new(), + })), + stdout, + sessions_root: sessions_root(), + }; + + let worker = tracker.clone(); + if let Err(error) = thread::Builder::new() + .name("codex-rollout-tracker".to_string()) + .spawn(move || worker.watch_loop()) + { + log(&format!("extension-host: rollout watcher error: {error}")); + } + + tracker + } + + fn observe_request(&self, message: &Value) { + let Some((session_id, turn_id)) = session_turn_from_message(message) else { + return; + }; + + let key = observed_turn_key(&session_id, &turn_id); + let mut state = self.inner.lock().expect("rollout watcher mutex poisoned"); + if state.observed.contains_key(&key) { + return; + } + + let (path, offset) = self + .sessions_root + .as_deref() + .and_then(|root| find_rollout_path(root, &session_id)) + .map(|path| { + let offset = file_len(&path).unwrap_or_default(); + (Some(path), offset) + }) + .unwrap_or((None, 0)); + + state.observed.insert( + key, + ObservedTurn { + session_id, + turn_id, + path, + offset, + created_at: Instant::now(), + }, + ); + } + + fn watch_loop(self) { + loop { + thread::sleep(ROLLOUT_POLL_INTERVAL); + if let Err(error) = self.process_rollouts() { + log(&format!("extension-host: rollout watcher error: {error}")); + } + } + } + + fn process_rollouts(&self) -> Result<()> { + let Some(sessions_root) = self.sessions_root.as_deref() else { + return Ok(()); + }; + + let mut completed = Vec::new(); + let mut expired = Vec::new(); + { + let mut state = self.inner.lock().expect("tracker mutex poisoned"); + for (key, observed) in &mut state.observed { + if observed.created_at.elapsed() >= OBSERVED_TURN_TTL { + expired.push(key.clone()); + continue; + } + + if observed.path.is_none() { + if let Some(path) = find_rollout_path(sessions_root, &observed.session_id) { + observed.offset = 0; + observed.path = Some(path); + } + } + + let Some(path) = observed.path.as_ref() else { + continue; + }; + + let (offset, is_complete) = + drain_rollout_file(path, observed.offset, &observed.turn_id).with_context( + || format!("failed to drain rollout file {}", path.display()), + )?; + observed.offset = offset; + if is_complete { + completed.push(( + key.clone(), + observed.session_id.clone(), + observed.turn_id.clone(), + )); + } + } + + for key in expired { + state.observed.remove(&key); + } + for (key, _, _) in &completed { + state.observed.remove(key); + } + } + + for (_, session_id, turn_id) in completed { + self.emit_turn_ended(&session_id, &turn_id); + } + + Ok(()) + } + + fn emit_turn_ended(&self, session_id: &str, turn_id: &str) { + let message = json!({ + "jsonrpc": "2.0", + "id": format!("native-turn-ended:{session_id}:{turn_id}"), + "method": "turnEnded", + "params": { + "session_id": session_id, + "turn_id": turn_id + } + }); + + let mut stdout = self.stdout.lock().expect("stdout writer mutex poisoned"); + if let Err(error) = write_frame(&mut *stdout, &message) { + log(&format!( + "extension-host: failed to emit turnEnded for session {session_id}: {error}" + )); + } + } +} + +fn main() -> Result<()> { + let socket_dir = socket_dir(); + prepare_socket_dir(&socket_dir)?; + let socket_path = socket_path(&socket_dir); + remove_socket_if_present(&socket_path)?; + + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("failed to bind {}", socket_path.display()))?; + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to chmod {}", socket_path.display()))?; + + let stdout = Arc::new(Mutex::new(io::stdout())); + let rollout_tracker = RolloutTracker::new(Arc::clone(&stdout)); + let state = Arc::new(Mutex::new(HostState::new(stdout, rollout_tracker))); + + log(&format!("listening on {}", socket_path.display())); + + { + let state = Arc::clone(&state); + thread::spawn(move || accept_clients(listener, state)); + } + + let result = read_chrome_messages(Arc::clone(&state)); + remove_socket_if_present(&socket_path)?; + result +} + +fn socket_dir() -> PathBuf { + env::var_os(SOCKET_DIR_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_DIR)) +} + +fn sessions_root() -> Option { + if let Some(path) = env::var_os(SESSIONS_DIR_ENV).map(PathBuf::from) { + return Some(path); + } + + if let Some(path) = env::var_os("CODEX_HOME").map(PathBuf::from) { + return Some(path.join("sessions")); + } + + env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".codex").join("sessions")) +} + +fn socket_path(socket_dir: &Path) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + socket_dir.join(format!("extension-{}-{nonce}.sock", process::id())) +} + +fn prepare_socket_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?; + + let metadata = + fs::symlink_metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + if !metadata.file_type().is_dir() { + bail!( + "unix socket directory path is not a directory: {}", + path.display() + ); + } + + let effective_uid = unsafe { libc::geteuid() }; + if metadata.uid() != effective_uid { + bail!( + "unix socket directory is owned by uid {}, expected {}: {}", + metadata.uid(), + effective_uid, + path.display() + ); + } + + if metadata.permissions().mode() & 0o777 != 0o700 { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to chmod {}", path.display()))?; + } + + Ok(()) +} + +fn remove_socket_if_present(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())), + } +} + +fn accept_clients(listener: UnixListener, state: SharedState) { + for stream in listener.incoming() { + let stream = match stream { + Ok(stream) => stream, + Err(error) => { + log(&format!("platform accept error: {error}")); + continue; + } + }; + + match authorize_peer(&stream) { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + log(&format!("peer authorization error: {error}")); + continue; + } + } + + let writer = match stream.try_clone() { + Ok(stream) => Arc::new(Mutex::new(stream)), + Err(error) => { + log(&format!("client socket clone error: {error}")); + continue; + } + }; + + let (client_id, evicted_clients) = { + let mut state = state.lock().expect("host state mutex poisoned"); + state.replace_with_client(writer) + }; + for (evicted_id, evicted_client) in evicted_clients { + log(&format!( + "evicting stale browser client {evicted_id} after a newer client connected" + )); + close_client_socket(&evicted_client); + } + + let state = Arc::clone(&state); + thread::spawn(move || read_client_messages(state, client_id, stream)); + } +} + +fn close_client_socket(client: &Client) { + match client.writer.lock() { + Ok(writer) => { + let _ = writer.shutdown(Shutdown::Both); + } + Err(error) => log(&format!("client socket close lock error: {error}")), + } +} + +fn authorize_peer(stream: &UnixStream) -> Result { + let credentials = peer_credentials(stream)?; + let effective_uid = unsafe { libc::geteuid() }; + + if credentials.uid != effective_uid { + log(&format!( + "rejecting peer pid {} uid {}, expected uid {}", + credentials.pid, credentials.uid, effective_uid + )); + return Ok(false); + } + + Ok(true) +} + +fn peer_credentials(stream: &UnixStream) -> Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut credentials as *mut libc::ucred).cast(), + &mut length, + ) + }; + + if result != 0 { + return Err(io::Error::last_os_error()).context("failed to read peer credentials"); + } + + Ok(credentials) +} + +fn read_chrome_messages(state: SharedState) -> Result<()> { + let stdin = io::stdin(); + let mut reader = stdin.lock(); + while let Some(message) = + read_frame(&mut reader).context("extension-host: platform reader error")? + { + handle_chrome_message(&state, message); + } + Ok(()) +} + +fn read_client_messages(state: SharedState, client_id: usize, stream: UnixStream) { + let mut stream = stream; + loop { + match read_frame(&mut stream) { + Ok(Some(message)) => handle_client_message(&state, client_id, message), + Ok(None) => break, + Err(error) => { + log(&format!("client socket read error: {error}")); + break; + } + } + } + + let mut state = state.lock().expect("host state mutex poisoned"); + state.remove_client(client_id); +} + +fn handle_client_message(state: &SharedState, client_id: usize, message: Value) { + { + let state = state.lock().expect("host state mutex poisoned"); + if !state.clients.contains_key(&client_id) { + return; + } + } + + if is_response(&message) { + let Some(id) = message_id_as_str(&message) else { + return; + }; + + let mut state = state.lock().expect("host state mutex poisoned"); + let Some(pending) = state.pending_client_requests.get(id).cloned() else { + return; + }; + if pending.client_id != client_id { + return; + } + state.pending_client_requests.remove(id); + + state.send_chrome(&with_id(message, pending.chrome_request_id)); + return; + } + + if !is_request(&message) { + let state = state.lock().expect("host state mutex poisoned"); + if state.clients.contains_key(&client_id) { + state.send_chrome(&message); + } + return; + } + + { + let tracker = { + let state = state.lock().expect("host state mutex poisoned"); + state.rollout_tracker.clone() + }; + tracker.observe_request(&message); + } + + if message.get("method").and_then(Value::as_str) == Some("ping") { + let Some(id) = message.get("id").cloned() else { + return; + }; + let state = state.lock().expect("host state mutex poisoned"); + state.send_client( + client_id, + &json!({ "jsonrpc": "2.0", "id": id, "result": "pong" }), + ); + return; + } + + let Some(client_request_id) = message.get("id").cloned() else { + return; + }; + + let mut state = state.lock().expect("host state mutex poisoned"); + if !state.clients.contains_key(&client_id) { + return; + } + let chrome_id = format!("linux-{}-{}", process::id(), state.next_chrome_id); + state.next_chrome_id += 1; + state.pending_chrome_requests.insert( + chrome_id.clone(), + PendingChromeRequest { + client_id, + client_request_id, + }, + ); + state.send_chrome(&with_id(message, Value::String(chrome_id))); +} + +fn handle_chrome_message(state: &SharedState, message: Value) { + if is_response(&message) { + let Some(id) = message_id_as_str(&message) else { + return; + }; + + let mut state = state.lock().expect("host state mutex poisoned"); + let Some(pending) = state.pending_chrome_requests.remove(id) else { + return; + }; + + state.send_client( + pending.client_id, + &with_id(message, pending.client_request_id), + ); + return; + } + + if !is_request(&message) { + let state = state.lock().expect("host state mutex poisoned"); + state.broadcast_clients(&message); + return; + } + + let chrome_request_id = message.get("id").cloned().unwrap_or(Value::Null); + let mut state = state.lock().expect("host state mutex poisoned"); + let client_id = match select_single_client_id(&state.clients) { + Ok(client_id) => client_id, + Err(error) => { + state.send_chrome(&json!({ + "jsonrpc": "2.0", + "id": chrome_request_id, + "error": { + "code": -32000, + "message": error.message() + } + })); + return; + } + }; + + let client_request_id = format!("chrome-{}-{}", process::id(), state.next_client_request_id); + state.next_client_request_id += 1; + state.pending_client_requests.insert( + client_request_id.clone(), + PendingClientRequest { + client_id, + chrome_request_id, + }, + ); + state.send_client( + client_id, + &with_id(message, Value::String(client_request_id)), + ); +} + +fn select_single_client_id( + clients: &HashMap, +) -> std::result::Result { + match clients.len() { + 0 => Err(ChromeClientRouteError::NoClients), + 1 => Ok(*clients.keys().next().expect("one client id")), + _ => Err(ChromeClientRouteError::MultipleClients), + } +} + +fn remove_pending_requests_for_client( + pending_chrome_requests: &mut HashMap, + pending_client_requests: &mut HashMap, + client_id: usize, +) { + pending_chrome_requests.retain(|_, pending| pending.client_id != client_id); + pending_client_requests.retain(|_, pending| pending.client_id != client_id); +} + +fn is_request(message: &Value) -> bool { + message.get("id").is_some() && message.get("method").and_then(Value::as_str).is_some() +} + +fn is_response(message: &Value) -> bool { + message.get("id").is_some() && message.get("method").and_then(Value::as_str).is_none() +} + +fn message_id_as_str(message: &Value) -> Option<&str> { + message.get("id").and_then(Value::as_str) +} + +fn with_id(mut message: Value, id: Value) -> Value { + if let Value::Object(ref mut object) = message { + object.insert("id".to_string(), id); + } + message +} + +fn session_turn_from_message(message: &Value) -> Option<(String, String)> { + let params = message.get("params")?; + let session_id = non_empty_string(params.get("session_id")?)?; + let turn_id = non_empty_string(params.get("turn_id")?)?; + Some((session_id.to_string(), turn_id.to_string())) +} + +fn non_empty_string(value: &Value) -> Option<&str> { + let value = value.as_str()?.trim(); + (!value.is_empty()).then_some(value) +} + +fn observed_turn_key(session_id: &str, turn_id: &str) -> String { + format!("{session_id}\n{turn_id}") +} + +fn file_len(path: &Path) -> io::Result { + Ok(fs::metadata(path)?.len()) +} + +fn rollout_file_name_matches_session(path: &Path, session_id: &str) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if !(file_name.ends_with(".jsonl") || file_name.ends_with(".json")) { + return false; + } + + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + return false; + }; + stem == session_id || stem.ends_with(&format!("-{session_id}")) +} + +fn find_rollout_path(root: &Path, session_id: &str) -> Option { + let mut stack = vec![(root.to_path_buf(), 0_usize)]; + let mut best: Option<(SystemTime, PathBuf)> = None; + + while let Some((dir, depth)) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_dir() { + if depth < ROLLOUT_SEARCH_MAX_DEPTH { + stack.push((path, depth + 1)); + } + continue; + } + + if !file_type.is_file() { + continue; + } + + if !rollout_file_name_matches_session(&path, session_id) { + continue; + } + + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(UNIX_EPOCH); + if best + .as_ref() + .is_none_or(|(best_modified, _)| modified > *best_modified) + { + best = Some((modified, path)); + } + } + } + + best.map(|(_, path)| path) +} + +fn drain_rollout_file(path: &Path, offset: u64, turn_id: &str) -> io::Result<(u64, bool)> { + let mut file = File::open(path)?; + let len = file.metadata()?.len(); + file.seek(SeekFrom::Start(offset.min(len)))?; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut is_complete = false; + + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + if line_marks_turn_complete(&line, turn_id) { + is_complete = true; + } + } + + Ok((reader.stream_position()?, is_complete)) +} + +fn line_marks_turn_complete(line: &str, turn_id: &str) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + + let payload = value.get("payload").unwrap_or(&value); + let payload_type = payload.get("type").and_then(Value::as_str); + let payload_turn_id = payload.get("turn_id").and_then(Value::as_str); + if payload_type == Some("task_complete") && payload_turn_id == Some(turn_id) { + return true; + } + + let top_level_type = value.get("type").and_then(Value::as_str); + let kind = value.get("kind").and_then(Value::as_str); + top_level_type == Some("turn") + && matches!(kind, Some("end" | "completed" | "complete")) + && value.get("turn_id").and_then(Value::as_str) == Some(turn_id) +} + +fn read_frame(reader: &mut impl Read) -> io::Result> { + loop { + let mut header = [0_u8; 4]; + match reader.read_exact(&mut header) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(error), + } + + let length = u32::from_ne_bytes(header) as usize; + let mut body = vec![0_u8; length]; + reader.read_exact(&mut body)?; + + match serde_json::from_slice(&body) { + Ok(message) => return Ok(Some(message)), + Err(error) => log(&format!("dropping invalid JSON frame: {error}")), + } + } +} + +fn write_frame(writer: &mut impl Write, message: &Value) -> io::Result<()> { + let body = serde_json::to_vec(message).map_err(io::Error::other)?; + if body.len() > u32::MAX as usize { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "message too large for 4-byte length prefix", + )); + } + + writer.write_all(&(body.len() as u32).to_ne_bytes())?; + writer.write_all(&body)?; + writer.flush() +} + +fn log(message: &str) { + let _ = writeln!(io::stderr(), "[{HOST_NAME}] {message}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_round_trip_uses_native_length_prefix() { + let message = json!({ "jsonrpc": "2.0", "id": "1", "method": "ping" }); + let mut encoded = Vec::new(); + write_frame(&mut encoded, &message).unwrap(); + + let length = u32::from_ne_bytes(encoded[..4].try_into().unwrap()) as usize; + assert_eq!(length, encoded.len() - 4); + + let mut cursor = io::Cursor::new(encoded); + assert_eq!(read_frame(&mut cursor).unwrap(), Some(message)); + } + + #[test] + fn id_replacement_preserves_other_fields() { + let message = json!({ "jsonrpc": "2.0", "id": 1, "method": "getTabs" }); + assert_eq!( + with_id(message, Value::String("linux-1-1".to_string())), + json!({ "jsonrpc": "2.0", "id": "linux-1-1", "method": "getTabs" }) + ); + } + + #[test] + fn extracts_session_turn_from_browser_request() { + let message = json!({ + "jsonrpc": "2.0", + "id": "request-1", + "method": "getTabs", + "params": { + "session_id": "session-1", + "turn_id": "turn-1" + } + }); + + assert_eq!( + session_turn_from_message(&message), + Some(("session-1".to_string(), "turn-1".to_string())) + ); + } + + #[test] + fn recognizes_task_complete_rollout_line() { + let line = r#"{"timestamp":"2026-05-09T12:00:00Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}"#; + assert!(line_marks_turn_complete(line, "turn-1")); + assert!(!line_marks_turn_complete(line, "turn-2")); + } + + #[test] + fn finds_nested_rollout_path_by_session_id() { + let root = unique_test_dir("codex-rollout-path"); + let nested = root.join("2026").join("05").join("09"); + fs::create_dir_all(&nested).unwrap(); + let path = nested.join("rollout-2026-05-09T12-00-00-session-1.jsonl"); + fs::write(&path, "{}\n").unwrap(); + + assert_eq!(find_rollout_path(&root, "session-1"), Some(path)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rollout_path_requires_exact_session_id_suffix() { + let root = unique_test_dir("codex-rollout-exact-session"); + fs::create_dir_all(&root).unwrap(); + let substring_path = root.join("rollout-session-10.jsonl"); + let exact_path = root.join("rollout-session-1.jsonl"); + fs::write(&substring_path, "{}\n").unwrap(); + fs::write(&exact_path, "{}\n").unwrap(); + + assert_eq!(find_rollout_path(&root, "session-1"), Some(exact_path)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn drains_rollout_file_from_offset() { + let root = unique_test_dir("codex-rollout-drain"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("rollout-session-1.jsonl"); + fs::write( + &path, + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"other\"}}\n", + ) + .unwrap(); + let offset = file_len(&path).unwrap(); + + let complete = + r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}"#; + writeln!( + fs::OpenOptions::new().append(true).open(&path).unwrap(), + "ignored\n{complete}" + ) + .unwrap(); + let (new_offset, is_complete) = drain_rollout_file(&path, offset, "turn-1").unwrap(); + + assert!(new_offset >= offset); + assert!(is_complete); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn late_discovered_rollout_file_scans_existing_content() { + let root = unique_test_dir("codex-rollout-late"); + let nested = root.join("2026").join("05").join("09"); + fs::create_dir_all(&nested).unwrap(); + let path = nested.join("rollout-session-1.jsonl"); + let complete = + r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}"#; + writeln!(File::create(&path).unwrap(), "{complete}").unwrap(); + + let discovered = find_rollout_path(&root, "session-1").unwrap(); + let (_, is_complete) = drain_rollout_file(&discovered, 0, "turn-1").unwrap(); + + assert!(is_complete); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_chrome_request_routing_without_exactly_one_client() { + let clients = HashMap::new(); + assert_eq!( + select_single_client_id(&clients), + Err(ChromeClientRouteError::NoClients) + ); + + let mut clients = HashMap::new(); + clients.insert(7, test_client()); + assert_eq!(select_single_client_id(&clients), Ok(7)); + + clients.insert(8, test_client()); + assert_eq!( + select_single_client_id(&clients), + Err(ChromeClientRouteError::MultipleClients) + ); + } + + #[test] + fn replacing_browser_client_evicts_stale_clients_and_pending_requests() { + let mut state = test_host_state(); + + let (first_client_id, evicted_clients) = + state.replace_with_client(test_client().writer.clone()); + assert!(evicted_clients.is_empty()); + assert!(state.clients.contains_key(&first_client_id)); + + state.pending_chrome_requests.insert( + "chrome-request".to_string(), + PendingChromeRequest { + client_id: first_client_id, + client_request_id: json!("client-request-1"), + }, + ); + state.pending_client_requests.insert( + "client-request".to_string(), + PendingClientRequest { + client_id: first_client_id, + chrome_request_id: json!("chrome-request-1"), + }, + ); + + let (second_client_id, evicted_clients) = + state.replace_with_client(test_client().writer.clone()); + + assert_ne!(first_client_id, second_client_id); + assert_eq!(evicted_clients.len(), 1); + assert_eq!(evicted_clients[0].0, first_client_id); + assert!(!state.clients.contains_key(&first_client_id)); + assert!(state.clients.contains_key(&second_client_id)); + assert!(state.pending_chrome_requests.is_empty()); + assert!(state.pending_client_requests.is_empty()); + } + + #[test] + fn evicted_client_requests_are_ignored() { + let state = Arc::new(Mutex::new(test_host_state())); + + handle_client_message( + &state, + 99, + json!({ "jsonrpc": "2.0", "id": 1, "method": "getTabs" }), + ); + + let state = state.lock().unwrap(); + assert!(state.pending_chrome_requests.is_empty()); + assert_eq!(state.next_chrome_id, 1); + } + + #[test] + fn disconnect_cleanup_removes_pending_state_for_client() { + let mut pending_chrome = HashMap::from([ + ( + "keep".to_string(), + PendingChromeRequest { + client_id: 1, + client_request_id: json!("chrome-request-1"), + }, + ), + ( + "drop".to_string(), + PendingChromeRequest { + client_id: 2, + client_request_id: json!("chrome-request-2"), + }, + ), + ]); + let mut pending_client = HashMap::from([ + ( + "keep".to_string(), + PendingClientRequest { + client_id: 1, + chrome_request_id: json!("client-request-1"), + }, + ), + ( + "drop".to_string(), + PendingClientRequest { + client_id: 2, + chrome_request_id: json!("client-request-2"), + }, + ), + ]); + + remove_pending_requests_for_client(&mut pending_chrome, &mut pending_client, 2); + + assert!(pending_chrome.contains_key("keep")); + assert!(!pending_chrome.contains_key("drop")); + assert!(pending_client.contains_key("keep")); + assert!(!pending_client.contains_key("drop")); + } + + fn test_client() -> Client { + let (stream, _peer) = UnixStream::pair().unwrap(); + Client { + writer: Arc::new(Mutex::new(stream)), + } + } + + fn test_host_state() -> HostState { + let stdout = Arc::new(Mutex::new(io::stdout())); + HostState::new( + Arc::clone(&stdout), + RolloutTracker { + inner: Arc::new(Mutex::new(RolloutTrackerState { + observed: HashMap::new(), + })), + stdout, + sessions_root: None, + }, + ) + } + + fn unique_test_dir(prefix: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{}-{nonce}", process::id())) + } +} diff --git a/computer-use-linux/src/bin/codex-computer-use-cosmic.rs b/computer-use-linux/src/bin/codex-computer-use-cosmic.rs new file mode 100644 index 00000000..0e7cfaf7 --- /dev/null +++ b/computer-use-linux/src/bin/codex-computer-use-cosmic.rs @@ -0,0 +1,705 @@ +use anyhow::{anyhow, bail, Context, Result}; +use cosmic_protocols::{ + toplevel_info::v1::client::{zcosmic_toplevel_handle_v1, zcosmic_toplevel_info_v1}, + toplevel_management::v1::client::zcosmic_toplevel_manager_v1, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use wayland_client::{ + event_created_child, + globals::{registry_queue_init, GlobalListContents}, + protocol::{wl_registry, wl_seat}, + Connection, Dispatch, Proxy, QueueHandle, WEnum, +}; +use wayland_protocols::ext::foreign_toplevel_list::v1::client::{ + ext_foreign_toplevel_handle_v1, ext_foreign_toplevel_list_v1, +}; + +const HELP: &str = "codex-computer-use-cosmic\n\nUsage:\n codex-computer-use-cosmic probe\n codex-computer-use-cosmic list-windows\n codex-computer-use-cosmic focused-window\n codex-computer-use-cosmic activate-window --window-id "; +const BACKEND: &str = "cosmic-wayland"; +const ACTIVATION_STATE_TTL: Duration = Duration::from_secs(5); +const EXT_FOREIGN_TOPLEVEL_LIST_VERSION: u32 = 1; +const ZCOSMIC_TOPLEVEL_INFO_VERSION: u32 = 3; +const ZCOSMIC_TOPLEVEL_MANAGER_VERSION: u32 = 4; +const WL_SEAT_VERSION: u32 = 9; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WindowInfo { + window_id: u64, + title: Option, + app_id: Option, + wm_class: Option, + pid: Option, + bounds: Option, + workspace: Option, + focused: bool, + hidden: bool, + client_type: Option, + backend: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WindowBounds { + x: Option, + y: Option, + width: u32, + height: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProbeOutput { + ok: bool, + can_list_windows: bool, + can_activate_windows: bool, + detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ActivationOutput { + ok: bool, + detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ActivationState { + window_id: u64, + timestamp_ms: u64, +} + +#[derive(Debug, Clone, Default)] +struct ToplevelRecord { + foreign: Option, + cosmic: Option, + identifier: Option, + title: Option, + app_id: Option, + focused: bool, + hidden: bool, +} + +impl ToplevelRecord { + fn to_window(&self) -> Option { + let identifier = self.identifier.as_deref()?; + Some(WindowInfo { + window_id: stable_window_id(identifier), + title: self.title.clone().filter(|value| !value.trim().is_empty()), + app_id: self.app_id.clone().filter(|value| !value.trim().is_empty()), + wm_class: None, + pid: None, + bounds: None, + workspace: None, + focused: self.focused, + hidden: self.hidden, + client_type: Some("wayland".to_string()), + backend: BACKEND.to_string(), + }) + } +} + +#[derive(Default)] +struct AppData { + toplevel_info: Option, + toplevel_manager: Option, + foreign_toplevel_list: Option, + seats: Vec, + capabilities: + Vec>, + records: Vec, + by_foreign_id: HashMap, + by_cosmic_id: HashMap, +} + +fn main() -> Result<()> { + match Command::parse(std::env::args().skip(1).collect())? { + Command::Probe => print_json(&probe()?), + Command::ListWindows => print_json(&collect_windows()?), + Command::FocusedWindow => print_json(&focused_window()?), + Command::ActivateWindow { window_id } => print_json(&activate_window(window_id)?), + } +} + +#[derive(Debug)] +enum Command { + Probe, + ListWindows, + FocusedWindow, + ActivateWindow { window_id: u64 }, +} + +impl Command { + fn parse(args: Vec) -> Result { + match args.as_slice() { + [command] if command == "probe" => Ok(Self::Probe), + [command] if command == "list-windows" => Ok(Self::ListWindows), + [command] if command == "focused-window" => Ok(Self::FocusedWindow), + [command, flag, value] if command == "activate-window" && flag == "--window-id" => { + Ok(Self::ActivateWindow { + window_id: value + .parse::() + .with_context(|| format!("invalid window id {value}"))?, + }) + } + [command] if command == "--help" || command == "-h" => { + println!("{HELP}"); + std::process::exit(0); + } + [] => { + println!("{HELP}"); + std::process::exit(0); + } + _ => bail!("unknown arguments. Expected one of: probe, list-windows, focused-window, activate-window --window-id "), + } + } +} + +fn probe() -> Result { + let snapshot = Snapshot::collect()?; + let windows = snapshot.windows(); + let can_activate = snapshot.can_activate_windows(); + Ok(ProbeOutput { + ok: true, + can_list_windows: true, + can_activate_windows: can_activate, + detail: if can_activate { + format!( + "COSMIC foreign toplevel listing is available and activation is supported for {} window(s).", + windows.len() + ) + } else { + format!( + "COSMIC foreign toplevel listing is available for {} window(s), but activation support is incomplete.", + windows.len() + ) + }, + }) +} + +fn collect_windows() -> Result> { + Ok(Snapshot::collect()?.windows()) +} + +fn focused_window() -> Result> { + let snapshot = Snapshot::collect()?; + if let Some(window) = snapshot.windows().into_iter().find(|window| window.focused) { + clear_activation_state(); + return Ok(Some(window)); + } + + let Some(state) = read_activation_state() else { + return Ok(None); + }; + + if state_is_stale(&state) { + clear_activation_state(); + return Ok(None); + } + + let mut window = snapshot + .windows() + .into_iter() + .find(|window| window.window_id == state.window_id); + if let Some(window) = window.as_mut() { + window.focused = true; + } + Ok(window) +} + +fn activate_window(window_id: u64) -> Result { + let mut snapshot = Snapshot::collect()?; + snapshot.activate(window_id)?; + write_activation_state(window_id)?; + Ok(ActivationOutput { + ok: true, + detail: format!("Requested COSMIC activation for window_id {window_id}."), + }) +} + +struct Snapshot { + event_queue: wayland_client::EventQueue, + app_data: AppData, +} + +impl Snapshot { + fn collect() -> Result { + let conn = Connection::connect_to_env().context("failed to connect to Wayland display")?; + let (globals, event_queue) = + registry_queue_init(&conn).context("failed to initialize Wayland registry queue")?; + let mut snapshot = Self { + event_queue, + app_data: AppData::default(), + }; + let qh = snapshot.event_queue.handle(); + snapshot.app_data.toplevel_info = globals + .bind::(&qh, 1..=3, ()) + .ok(); + snapshot.app_data.toplevel_manager = globals + .bind::(&qh, 1..=4, ()) + .ok(); + globals.contents().with_list(|entries| { + for global in entries { + if global.interface == "wl_seat" { + snapshot + .app_data + .seats + .push(globals.registry().bind::( + global.name, + global.version.min(9), + &qh, + (), + )); + } + } + }); + if snapshot.app_data.toplevel_info.is_some() { + snapshot.app_data.foreign_toplevel_list = globals + .bind::( + &qh, + 1..=1, + (), + ) + .ok(); + } + if snapshot.app_data.toplevel_info.is_none() { + bail!("COSMIC toplevel info protocol not advertised"); + } + if snapshot.app_data.foreign_toplevel_list.is_none() { + bail!("COSMIC foreign toplevel list protocol not advertised"); + } + snapshot.prime()?; + Ok(snapshot) + } + + fn prime(&mut self) -> Result<()> { + for _ in 0..4 { + self.event_queue + .roundtrip(&mut self.app_data) + .context("Wayland roundtrip failed")?; + } + Ok(()) + } + + fn windows(&self) -> Vec { + self.app_data + .records + .iter() + .filter_map(ToplevelRecord::to_window) + .collect() + } + + fn can_activate_windows(&self) -> bool { + !self.app_data.seats.is_empty() + && self.app_data.toplevel_manager.is_some() + && self.app_data.records.iter().any(|record| record.cosmic.is_some()) + && self.app_data.capabilities.iter().any(|capability| { + matches!( + capability, + WEnum::Value( + zcosmic_toplevel_manager_v1::ZcosmicToplelevelManagementCapabilitiesV1::Activate + ) + ) + }) + } + + fn activate(&mut self, window_id: u64) -> Result<()> { + if !self.can_activate_windows() { + bail!("COSMIC activation capability is unavailable"); + } + let seat = self + .app_data + .seats + .first() + .cloned() + .ok_or_else(|| anyhow!("no wl_seat available for activation"))?; + let record = self + .app_data + .records + .iter() + .find(|record| { + record + .identifier + .as_deref() + .is_some_and(|id| stable_window_id(id) == window_id) + }) + .ok_or_else(|| anyhow!("no COSMIC toplevel matched window_id {window_id}"))?; + let cosmic = record + .cosmic + .as_ref() + .ok_or_else(|| anyhow!("matched window has no COSMIC activation handle"))?; + let manager = self + .app_data + .toplevel_manager + .as_ref() + .ok_or_else(|| anyhow!("COSMIC toplevel management protocol not advertised"))?; + manager.activate(cosmic, &seat); + self.event_queue + .roundtrip(&mut self.app_data) + .context("Wayland roundtrip after activation failed")?; + Ok(()) + } +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + match interface.as_str() { + "ext_foreign_toplevel_list_v1" => { + app_data.foreign_toplevel_list = Some( + registry + .bind::( + name, + bind_global_version(version, EXT_FOREIGN_TOPLEVEL_LIST_VERSION), + qh, + (), + ), + ); + } + "zcosmic_toplevel_info_v1" => { + app_data.toplevel_info = Some( + registry.bind::( + name, + bind_global_version(version, ZCOSMIC_TOPLEVEL_INFO_VERSION), + qh, + (), + ), + ); + } + "zcosmic_toplevel_manager_v1" => { + app_data.toplevel_manager = Some( + registry + .bind::( + name, + bind_global_version(version, ZCOSMIC_TOPLEVEL_MANAGER_VERSION), + qh, + (), + ), + ); + } + "wl_seat" => { + app_data.seats.push(registry.bind::( + name, + bind_global_version(version, WL_SEAT_VERSION), + qh, + (), + )); + } + _ => {} + } + } + } +} + +fn bind_global_version(advertised: u32, supported: u32) -> u32 { + advertised.min(supported).max(1) +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + _list: &ext_foreign_toplevel_list_v1::ExtForeignToplevelListV1, + event: ext_foreign_toplevel_list_v1::Event, + _: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + match event { + ext_foreign_toplevel_list_v1::Event::Toplevel { toplevel } => { + let foreign_id = toplevel.id().protocol_id(); + let mut record = ToplevelRecord { + foreign: Some(toplevel.clone()), + ..Default::default() + }; + if let Some(info) = app_data.toplevel_info.as_ref() { + let cosmic = info.get_cosmic_toplevel(&toplevel, qh, ()); + app_data + .by_cosmic_id + .insert(cosmic.id().protocol_id(), app_data.records.len()); + record.cosmic = Some(cosmic); + } + app_data + .by_foreign_id + .insert(foreign_id, app_data.records.len()); + app_data.records.push(record); + } + ext_foreign_toplevel_list_v1::Event::Finished => {} + _ => unreachable!(), + } + } + + event_created_child!( + AppData, + ext_foreign_toplevel_list_v1::ExtForeignToplevelListV1, + [ + ext_foreign_toplevel_list_v1::EVT_TOPLEVEL_OPCODE => (ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1, ()), + ] + ); +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + handle: &ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1, + event: ext_foreign_toplevel_handle_v1::Event, + _: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + let Some(index) = app_data + .by_foreign_id + .get(&handle.id().protocol_id()) + .copied() + else { + return; + }; + let record = &mut app_data.records[index]; + match event { + ext_foreign_toplevel_handle_v1::Event::Identifier { identifier } => { + record.identifier = Some(identifier); + } + ext_foreign_toplevel_handle_v1::Event::Title { title } => { + record.title = Some(title); + } + ext_foreign_toplevel_handle_v1::Event::AppId { app_id } => { + record.app_id = Some(app_id); + } + ext_foreign_toplevel_handle_v1::Event::Done => {} + ext_foreign_toplevel_handle_v1::Event::Closed => { + app_data.records[index].foreign = None; + app_data.records[index].cosmic = None; + app_data.records[index].identifier = None; + } + _ => unreachable!(), + } + } +} + +impl Dispatch for AppData { + fn event( + _app_data: &mut Self, + _info: &zcosmic_toplevel_info_v1::ZcosmicToplevelInfoV1, + _event: zcosmic_toplevel_info_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } + + event_created_child!( + AppData, + zcosmic_toplevel_info_v1::ZcosmicToplevelInfoV1, + [ + zcosmic_toplevel_info_v1::EVT_TOPLEVEL_OPCODE => (zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, ()), + ] + ); +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + handle: &zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, + event: zcosmic_toplevel_handle_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let Some(index) = app_data + .by_cosmic_id + .get(&handle.id().protocol_id()) + .copied() + else { + return; + }; + let record = &mut app_data.records[index]; + match event { + zcosmic_toplevel_handle_v1::Event::State { state } => { + record.focused = false; + record.hidden = false; + for value in state.chunks_exact(4) { + if let Ok(parsed) = zcosmic_toplevel_handle_v1::State::try_from( + u32::from_ne_bytes(value.try_into().unwrap()), + ) { + if parsed == zcosmic_toplevel_handle_v1::State::Activated { + record.focused = true; + } + if parsed == zcosmic_toplevel_handle_v1::State::Minimized { + record.hidden = true; + } + } + } + } + zcosmic_toplevel_handle_v1::Event::Geometry { .. } + | zcosmic_toplevel_handle_v1::Event::OutputEnter { .. } + | zcosmic_toplevel_handle_v1::Event::OutputLeave { .. } + | zcosmic_toplevel_handle_v1::Event::WorkspaceEnter { .. } + | zcosmic_toplevel_handle_v1::Event::WorkspaceLeave { .. } + | zcosmic_toplevel_handle_v1::Event::ExtWorkspaceEnter { .. } + | zcosmic_toplevel_handle_v1::Event::ExtWorkspaceLeave { .. } + | zcosmic_toplevel_handle_v1::Event::Title { .. } + | zcosmic_toplevel_handle_v1::Event::AppId { .. } + | zcosmic_toplevel_handle_v1::Event::Done + | zcosmic_toplevel_handle_v1::Event::Closed => {} + _ => unreachable!(), + } + } +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + _manager: &zcosmic_toplevel_manager_v1::ZcosmicToplevelManagerV1, + event: zcosmic_toplevel_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zcosmic_toplevel_manager_v1::Event::Capabilities { capabilities } => { + app_data.capabilities = capabilities + .chunks(4) + .map(|chunk| WEnum::from(u32::from_ne_bytes(chunk.try_into().unwrap()))) + .collect(); + } + _ => unreachable!(), + } + } +} + +impl Dispatch for AppData { + fn event( + _app_data: &mut Self, + _seat: &wl_seat::WlSeat, + _event: wl_seat::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +fn stable_window_id(identifier: &str) -> u64 { + fnv1a_64(identifier.as_bytes()) +} + +fn fnv1a_64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn print_json(value: &T) -> Result<()> { + println!( + "{}", + serde_json::to_string_pretty(value).context("failed to serialize JSON output")? + ); + Ok(()) +} + +fn activation_state_path() -> PathBuf { + if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { + PathBuf::from(runtime_dir).join("codex-computer-use-cosmic-last-activation.json") + } else { + std::env::temp_dir().join("codex-computer-use-cosmic-last-activation.json") + } +} + +fn write_activation_state(window_id: u64) -> Result<()> { + let state = ActivationState { + window_id, + timestamp_ms: now_timestamp_ms()?, + }; + let path = activation_state_path(); + let json = serde_json::to_vec(&state).context("failed to serialize activation state")?; + std::fs::write(&path, json) + .with_context(|| format!("failed to write activation state to {}", path.display())) +} + +fn read_activation_state() -> Option { + let path = activation_state_path(); + let contents = std::fs::read(&path).ok()?; + serde_json::from_slice(&contents).ok() +} + +fn clear_activation_state() { + let _ = std::fs::remove_file(activation_state_path()); +} + +fn state_is_stale(state: &ActivationState) -> bool { + let Ok(now_ms) = now_timestamp_ms() else { + return false; + }; + now_ms.saturating_sub(state.timestamp_ms) > ACTIVATION_STATE_TTL.as_millis() as u64 +} + +fn now_timestamp_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_millis() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_activate_window_args_requires_numeric_id() { + let error = Command::parse(vec![ + "activate-window".to_string(), + "--window-id".to_string(), + "nope".to_string(), + ]) + .unwrap_err() + .to_string(); + + assert!(error.contains("invalid window id")); + } + + #[test] + fn stable_window_id_is_stable() { + assert_eq!(stable_window_id("window-1"), stable_window_id("window-1")); + } + + #[test] + fn bind_global_version_clamps_to_advertised_protocol_version() { + assert_eq!(bind_global_version(2, WL_SEAT_VERSION), 2); + assert_eq!(bind_global_version(12, WL_SEAT_VERSION), WL_SEAT_VERSION); + assert_eq!(bind_global_version(0, WL_SEAT_VERSION), 1); + } + + #[test] + fn activation_state_expires_after_ttl() { + let state = ActivationState { + window_id: 7, + timestamp_ms: now_timestamp_ms().unwrap() + - (ACTIVATION_STATE_TTL.as_millis() as u64 + 1), + }; + + assert!(state_is_stale(&state)); + } + + #[test] + fn activation_state_is_fresh_within_ttl() { + let state = ActivationState { + window_id: 7, + timestamp_ms: now_timestamp_ms().unwrap(), + }; + + assert!(!state_is_stale(&state)); + } +} diff --git a/computer-use-linux/src/cosmic_helper.rs b/computer-use-linux/src/cosmic_helper.rs new file mode 100644 index 00000000..8ebe398b --- /dev/null +++ b/computer-use-linux/src/cosmic_helper.rs @@ -0,0 +1,144 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +pub const COSMIC_HELPER_BINARY: &str = "codex-computer-use-cosmic"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CosmicHelperProbe { + pub ok: bool, + pub can_list_windows: bool, + pub can_activate_windows: bool, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CosmicHelperActivation { + pub ok: bool, + pub detail: String, +} + +pub fn resolve_helper_binary() -> Result { + if let Some(path) = env::var("CODEX_COMPUTER_USE_COSMIC_HELPER") + .ok() + .filter(|value| !value.trim().is_empty()) + { + let path = PathBuf::from(path); + if is_executable_file(&path) { + return Ok(path); + } + } + + if let Ok(current_exe) = env::current_exe() { + let sibling = current_exe.with_file_name(COSMIC_HELPER_BINARY); + if is_executable_file(&sibling) { + return Ok(sibling); + } + } + + if let Some(path) = command_path(COSMIC_HELPER_BINARY) { + return Ok(path); + } + + bail!("COSMIC helper binary {COSMIC_HELPER_BINARY} not found") +} + +pub fn probe() -> Result { + run_json_command(["probe"]) +} + +pub fn list_windows_json() -> Result { + run_text_command(["list-windows"]) +} + +pub fn focused_window_json() -> Result { + run_text_command(["focused-window"]) +} + +pub fn activate_window(window_id: u64) -> Result { + run_json_command(["activate-window", "--window-id", &window_id.to_string()]) +} + +fn run_json_command(args: I) -> Result +where + T: for<'de> Deserialize<'de>, + I: IntoIterator, + S: AsRef, +{ + let output = run_command(args)?; + serde_json::from_str(&output) + .with_context(|| format!("failed to parse {COSMIC_HELPER_BINARY} JSON output")) +} + +fn run_text_command(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + run_command(args) +} + +fn run_command(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let helper = resolve_helper_binary()?; + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect::>(); + let output = Command::new(&helper) + .args(&args) + .output() + .with_context(|| format!("failed to run {}", helper.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + bail!( + "{} {} failed{}", + helper.display(), + args.join(" "), + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + ); + } + String::from_utf8(output.stdout) + .map(|text| text.trim().to_string()) + .context("helper output was not valid UTF-8") +} + +fn command_path(binary: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|entry| entry.join(binary)) + .find(|candidate| is_executable_file(candidate)) +} + +fn is_executable_file(path: &Path) -> bool { + path.is_file() && is_executable(path) +} + +fn is_executable(path: &Path) -> bool { + std::fs::metadata(path) + .map(|metadata| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + metadata.is_file() + } + }) + .unwrap_or(false) +} diff --git a/computer-use-linux/src/diagnostics.rs b/computer-use-linux/src/diagnostics.rs index 45f15edf..efeb04d2 100644 --- a/computer-use-linux/src/diagnostics.rs +++ b/computer-use-linux/src/diagnostics.rs @@ -1,8 +1,14 @@ +use crate::windowing::registry::{ + self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, + HYPRLAND_BACKEND, KWIN_BACKEND, +}; use schemars::JsonSchema; use serde::Serialize; use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, env, fs, + fs::OpenOptions, + os::unix::net::UnixStream, path::{Path, PathBuf}, process::Command, }; @@ -64,6 +70,10 @@ pub struct AccessibilityReport { pub struct WindowingReport { pub gnome_shell_introspect: Check, pub codex_gnome_shell_extension: Check, + pub cosmic_helper: Check, + pub kwin: Check, + pub hyprland: Check, + pub backends: BTreeMap, pub can_list_windows: bool, pub can_focus_apps: bool, pub can_focus_windows: bool, @@ -130,7 +140,7 @@ pub fn doctor_report() -> DoctorReport { let accessibility = accessibility_report(); let windowing = windowing_report(); let input = input_report(); - let readiness = readiness_report(&accessibility, &windowing, &input); + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); DoctorReport { platform, @@ -343,37 +353,58 @@ fn accessibility_report() -> AccessibilityReport { } fn windowing_report() -> WindowingReport { - let gnome_shell_introspect = gdbus_call_check( - "org.gnome.Shell", - "/org/gnome/Shell/Introspect", - "org.gnome.Shell.Introspect.GetWindows", - &[], - ); - let codex_gnome_shell_extension = gdbus_call_check( - "com.openai.Codex.WindowControl", - "/com/openai/Codex/WindowControl", - "com.openai.Codex.WindowControl.ListWindows", - &[], - ); - let can_list_windows = gnome_shell_introspect.ok || codex_gnome_shell_extension.ok; - let can_focus_apps = gdbus_introspect_contains( - "org.gnome.Shell", - "/org/gnome/Shell", - "org.gnome.Shell", - "FocusApp", - ) - .ok; - let can_focus_windows = codex_gnome_shell_extension.ok; + let probes = registry::probe_backends(); + let backend_check = |id: &str| { + probes + .iter() + .find(|probe| probe.id == id) + .map(check_from_backend_probe) + .unwrap_or_else(|| Check::fail("backend probe did not run")) + }; + let gnome_shell_introspect = backend_check(GNOME_SHELL_INTROSPECT_BACKEND); + let codex_gnome_shell_extension = backend_check(GNOME_SHELL_EXTENSION_BACKEND); + let cosmic_helper = backend_check(COSMIC_WAYLAND_BACKEND); + let kwin = backend_check(KWIN_BACKEND); + let hyprland = backend_check(HYPRLAND_BACKEND); + let backends = probes + .iter() + .map(|probe| (probe.id.to_string(), check_from_backend_probe(probe))) + .collect::>(); + let can_list_windows = probes.iter().any(|probe| probe.can_list_windows); + let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps); + let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows); let note = if can_list_windows { - "A GNOME window listing backend is available for list_windows, focused_window, and targeted input verification." + let available = probes + .iter() + .filter(|probe| probe.can_list_windows) + .map(|probe| { + registry::descriptor(probe.id) + .map(|descriptor| descriptor.failure_label) + .unwrap_or(probe.id) + }) + .collect::>() + .join(", "); + format!( + "Window listing is available through {available} for list_windows, focused_window, and targeted input verification." + ) } else { - "GNOME window listing is unavailable or denied. Computer Use can still use screenshots, AT-SPI, and global ydotool input, but targeted window input cannot be verified. Run setup-window-targeting to install the optional GNOME Shell extension backend." - } - .to_string(); + let hints = registry::descriptors() + .iter() + .map(|descriptor| descriptor.missing_hint) + .collect::>() + .join(" "); + format!( + "Window listing is unavailable or denied. Computer Use can still use screenshots, AT-SPI, and global ydotool input, but targeted window input cannot be verified. {hints}" + ) + }; WindowingReport { gnome_shell_introspect, codex_gnome_shell_extension, + cosmic_helper, + kwin, + hyprland, + backends, can_list_windows, can_focus_apps, can_focus_windows, @@ -381,17 +412,25 @@ fn windowing_report() -> WindowingReport { } } +fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check { + if probe.ok { + Check::ok(probe.detail.clone()) + } else { + Check::fail(probe.detail.clone()) + } +} + fn input_report() -> InputReport { - let socket = ydotool_socket_path(); InputReport { ydotool: command_path_check("ydotool"), ydotoold: process_check("ydotoold"), - ydotool_socket: path_check(&socket), - uinput: path_check(Path::new("/dev/uinput")), + ydotool_socket: ydotool_socket_check(), + uinput: read_write_path_check(Path::new("/dev/uinput")), } } fn readiness_report( + platform: &PlatformReport, accessibility: &AccessibilityReport, windowing: &WindowingReport, input: &InputReport, @@ -402,7 +441,7 @@ fn readiness_report( let can_focus_apps = windowing.can_focus_apps; let can_focus_windows = windowing.can_focus_windows; let can_send_development_input = - input.ydotool.ok && input.ydotoold.ok && input.ydotool_socket.ok && input.uinput.ok; + input.ydotool.ok && input.ydotoold.ok && input.ydotool_socket.ok; if !can_build_accessibility_tree { blockers.push( @@ -412,22 +451,24 @@ fn readiness_report( } if !can_query_windows { - blockers.push( - "GNOME Shell window introspection is unavailable; targeted window focus and verification will be disabled." - .to_string(), - ); + blockers.push(if is_cosmic_wayland_platform(platform) { + "COSMIC Wayland window introspection is unavailable; targeted window focus and verification will be disabled.".to_string() + } else { + "Window introspection is unavailable; targeted window focus and verification will be disabled." + .to_string() + }); } if can_query_windows && !can_focus_windows { blockers.push( - "Exact GNOME Shell window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified." + "Exact window activation is unavailable; app-level focus may work, but window_id/title/terminal-targeted input cannot be verified." .to_string(), ); } if !can_send_development_input { blockers.push( - "Development input fallback is not fully available; ydotool, ydotoold, socket, and /dev/uinput are required." + "Development input fallback is unavailable; ydotool needs a running ydotoold daemon with a connectable ydotoold socket." .to_string(), ); } @@ -436,13 +477,21 @@ fn readiness_report( "Run setup_accessibility to enable GNOME accessibility before element-aware actions." .to_string() } else if !can_query_windows { - "Run setup-window-targeting to install the Codex GNOME Shell extension backend, or enable GNOME Shell window introspection before using targeted keyboard input.".to_string() + format!( + "Enable a supported window backend before using targeted keyboard input: {}", + registry::descriptors() + .iter() + .map(|descriptor| descriptor.missing_hint) + .collect::>() + .join(" ") + ) } else if !can_focus_windows { - "Run setup-window-targeting to install the Codex GNOME Shell extension backend before using exact window_id, title, or terminal-targeted input.".to_string() + "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string() } else if !can_send_development_input { - "Install and start ydotoold if development input fallback is needed.".to_string() + "Fix ydotool input access: start ydotoold with a socket accessible to this desktop user." + .to_string() } else { - "Computer Use is ready: AT-SPI tree support, GNOME window targeting, and ydotool input fallback are available." + "Computer Use is ready: AT-SPI tree support, window targeting, and ydotool input fallback are available." .to_string() }; @@ -458,6 +507,14 @@ fn readiness_report( } } +fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool { + platform + .xdg_current_desktop + .as_deref() + .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("cosmic")) + && platform.xdg_session_type.as_deref() == Some("wayland") +} + fn can_build_accessibility_tree(accessibility: &AccessibilityReport) -> bool { accessibility.at_spi_bus.ok && (check_detail_contains_true(&accessibility.at_spi_enabled) @@ -492,24 +549,32 @@ fn dbus_session_address() -> Option { }) } -fn ydotool_socket_path() -> PathBuf { +fn ydotool_socket_candidates() -> Vec { + let mut candidates = Vec::new(); if let Some(value) = env_var("YDOTOOL_SOCKET") { - return PathBuf::from(value); + candidates.push(PathBuf::from(value)); } - let runtime_socket = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")); - let tmp_socket = PathBuf::from("/tmp/.ydotool_socket"); + if let Some(runtime_socket) = xdg_runtime_dir().map(|runtime| runtime.join(".ydotool_socket")) { + candidates.push(runtime_socket); + } + candidates.push(PathBuf::from("/tmp/.ydotool_socket")); + candidates +} - for candidate in [runtime_socket.as_ref(), Some(&tmp_socket)] - .into_iter() - .flatten() - { - if candidate.exists() { - return candidate.to_path_buf(); +fn ydotool_socket_check() -> Check { + let mut checked = Vec::new(); + for candidate in ydotool_socket_candidates() { + match socket_connect_result(&candidate) { + Ok(()) => return Check::ok(format!("connectable: {}", candidate.display())), + Err(detail) => checked.push(detail), } } - runtime_socket.unwrap_or(tmp_socket) + Check::fail(format!( + "no connectable ydotool socket ({})", + checked.join("; ") + )) } fn user_id() -> Option { @@ -529,11 +594,33 @@ fn process_check(process_name: &str) -> Check { command_check("pgrep", &["-a", process_name]) } -fn path_check(path: &Path) -> Check { - if path.exists() { - Check::ok(path.display().to_string()) - } else { - Check::fail(format!("missing: {}", path.display())) +#[cfg(test)] +fn socket_connect_check(path: &Path) -> Check { + match socket_connect_result(path) { + Ok(()) => Check::ok(format!("connectable: {}", path.display())), + Err(detail) => Check::fail(detail), + } +} + +fn socket_connect_result(path: &Path) -> std::result::Result<(), String> { + if !path.exists() { + return Err(format!("missing: {}", path.display())); + } + + match UnixStream::connect(path) { + Ok(_) => Ok(()), + Err(error) => Err(format!("{}: {error}", path.display())), + } +} + +fn read_write_path_check(path: &Path) -> Check { + if !path.exists() { + return Check::fail(format!("missing: {}", path.display())); + } + + match OpenOptions::new().read(true).write(true).open(path) { + Ok(_) => Check::ok(format!("read/write: {}", path.display())), + Err(error) => Check::fail(format!("{}: {error}", path.display())), } } @@ -569,34 +656,6 @@ fn gdbus_call_check(destination: &str, object_path: &str, method: &str, args: &[ command_check_with_session_bus("gdbus", &command_args) } -fn gdbus_introspect_contains( - destination: &str, - object_path: &str, - interface: &str, - member: &str, -) -> Check { - let check = command_check_with_session_bus( - "gdbus", - &[ - "introspect", - "--session", - "--dest", - destination, - "--object-path", - object_path, - ], - ); - if !check.ok { - return check; - } - - if check.detail.contains(interface) && check.detail.contains(member) { - Check::ok(format!("{interface}.{member} is available")) - } else { - Check::fail(format!("{interface}.{member} was not advertised")) - } -} - fn command_check(command: &str, args: &[&str]) -> Check { run_command(command, args, false) } @@ -645,6 +704,21 @@ fn run_command(command: &str, args: &[&str], with_session_bus: bool) -> Check { mod tests { use super::*; + fn platform_report() -> PlatformReport { + PlatformReport { + os: "linux".to_string(), + arch: "x86_64".to_string(), + desktop_session: None, + xdg_session_type: Some("wayland".to_string()), + xdg_current_desktop: Some("GNOME".to_string()), + wayland_display: Some("wayland-0".to_string()), + display: Some(":0".to_string()), + dbus_session_bus_address: Some("unix:path=/run/user/1000/bus".to_string()), + xdg_runtime_dir: Some("/run/user/1000".to_string()), + gnome_shell_version: Check::ok("GNOME Shell 46.0"), + } + } + fn accessibility_report( at_spi_bus: Check, toolkit_accessibility: Check, @@ -669,6 +743,10 @@ mod tests { } else { Check::fail("missing") }, + cosmic_helper: Check::fail("missing"), + kwin: Check::fail("not a KWin session"), + hyprland: Check::fail("not a Hyprland session"), + backends: BTreeMap::new(), can_list_windows, can_focus_apps: true, can_focus_windows, @@ -682,11 +760,20 @@ mod tests { } else { Check::fail("missing") }; + input_report_parts(check.clone(), check.clone(), check.clone(), check) + } + + fn input_report_parts( + ydotool: Check, + ydotoold: Check, + ydotool_socket: Check, + uinput: Check, + ) -> InputReport { InputReport { - ydotool: check.clone(), - ydotoold: check.clone(), - ydotool_socket: check.clone(), - uinput: check, + ydotool, + ydotoold, + ydotool_socket, + uinput, } } @@ -731,35 +818,157 @@ mod tests { #[test] fn readiness_requires_exact_window_focus_for_targeted_input() { + let platform = platform_report(); let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); let windowing = windowing_report(true, false); let input = input_report(true); - let readiness = readiness_report(&accessibility, &windowing, &input); + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); assert!(readiness.can_query_windows); assert!(!readiness.can_focus_windows); assert!(readiness .recommended_next_step - .contains("setup-window-targeting")); + .contains("exact-focus window backend")); assert!(readiness .blockers .iter() - .any(|blocker| blocker.contains("Exact GNOME Shell window activation"))); + .any(|blocker| blocker.contains("Exact window activation"))); + } + + #[test] + fn readiness_treats_kwin_as_full_window_backend() { + let platform = platform_report(); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let mut windowing = windowing_report(false, false); + windowing.kwin = Check::ok("KWin scripting is available"); + windowing.can_list_windows = true; + windowing.can_focus_apps = true; + windowing.can_focus_windows = true; + let input = input_report(true); + + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); + + assert!(readiness.can_query_windows); + assert!(readiness.can_focus_apps); + assert!(readiness.can_focus_windows); + assert!(readiness.blockers.is_empty()); } #[test] - fn readiness_message_stays_within_pr1_scope() { + fn readiness_message_mentions_generic_window_targeting() { + let platform = platform_report(); let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); let windowing = windowing_report(true, true); let input = input_report(true); - let readiness = readiness_report(&accessibility, &windowing, &input); + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); assert!(readiness.blockers.is_empty()); assert!(readiness .recommended_next_step .contains("AT-SPI tree support")); - assert!(!readiness.recommended_next_step.contains("action/value")); + assert!(readiness.recommended_next_step.contains("window targeting")); + assert!(!readiness + .recommended_next_step + .contains("GNOME window targeting")); + } + + #[test] + fn readiness_accepts_connectable_ydotool_socket_without_direct_uinput_access() { + let platform = platform_report(); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::ok("ydotool"), + Check::ok("ydotoold"), + Check::ok("connectable: /tmp/.ydotool_socket"), + Check::fail("/dev/uinput: Permission denied"), + ); + + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); + + assert!(readiness.can_send_development_input); + assert!(readiness.blockers.is_empty()); + } + + #[test] + fn readiness_rejects_direct_uinput_without_connectable_ydotool_socket() { + let platform = platform_report(); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::ok("ydotool"), + Check::fail("ydotoold not running"), + Check::fail("no connectable ydotool socket"), + Check::ok("read/write: /dev/uinput"), + ); + + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); + + assert!(!readiness.can_send_development_input); + assert!(readiness + .blockers + .iter() + .any(|blocker| blocker.contains("connectable ydotoold socket"))); + } + + #[test] + fn readiness_rejects_inaccessible_ydotool_paths() { + let platform = platform_report(); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::ok("ydotool"), + Check::ok("ydotoold"), + Check::fail("/tmp/.ydotool_socket: Permission denied"), + Check::fail("/dev/uinput: Permission denied"), + ); + + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); + + assert!(!readiness.can_send_development_input); + assert!(readiness + .recommended_next_step + .contains("Fix ydotool input access")); + assert!(readiness + .blockers + .iter() + .any(|blocker| blocker.contains("connectable ydotoold socket"))); + } + + #[test] + fn ydotool_socket_check_requires_a_connectable_socket() { + let dir = std::env::temp_dir().join(format!( + "codex-computer-use-diagnostics-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp diagnostics dir"); + let socket = dir.join("ydotool.sock"); + let listener = + std::os::unix::net::UnixListener::bind(&socket).expect("bind temp diagnostics socket"); + + let check = socket_connect_check(&socket); + + assert!(check.ok, "{check:?}"); + drop(listener); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn readiness_reports_cosmic_window_blocker_on_cosmic() { + let mut platform = platform_report(); + platform.xdg_current_desktop = Some("COSMIC".to_string()); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(false, false); + let input = input_report(true); + + let readiness = readiness_report(&platform, &accessibility, &windowing, &input); + + assert!(readiness + .blockers + .iter() + .any(|blocker| blocker.contains("COSMIC Wayland window introspection"))); } } diff --git a/computer-use-linux/src/gnome_extension.rs b/computer-use-linux/src/gnome_extension.rs index 926efd8b..a1c5179d 100644 --- a/computer-use-linux/src/gnome_extension.rs +++ b/computer-use-linux/src/gnome_extension.rs @@ -1,5 +1,6 @@ use crate::diagnostics::hydrate_session_bus_env; -use crate::windows::{list_extension_windows, window_permission_hint, WindowInfo}; +use crate::windowing::backends::gnome::list_extension_windows; +use crate::windows::{window_permission_hint, WindowInfo}; use schemars::JsonSchema; use serde::Serialize; use std::{ diff --git a/computer-use-linux/src/main.rs b/computer-use-linux/src/main.rs index c3a31e0b..a6114594 100644 --- a/computer-use-linux/src/main.rs +++ b/computer-use-linux/src/main.rs @@ -1,10 +1,12 @@ mod atspi_tree; +mod cosmic_helper; mod diagnostics; mod gnome_extension; mod remote_desktop; mod screenshot; mod server; mod terminal; +mod windowing; mod windows; use anyhow::{Context, Result}; @@ -84,7 +86,7 @@ async fn main() -> Result<()> { Err(error) => { let error = format!("{error:#}"); serde_json::json!({ - "backend": windows::GNOME_SHELL_INTROSPECT_BACKEND, + "backend": "unavailable", "windows": [], "error": error, "permissions_hint": windows::window_permission_hint(&error), diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index 7aca01bd..cad63dc6 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -10,10 +10,11 @@ use crate::remote_desktop::{ start_portal_pointer_session, PointerButton, PortalPointerSession, ScrollDirection, }; use crate::screenshot::{capture_screenshot, ScreenshotCapture}; +use crate::windowing::registry; use crate::windows::{ focus_window_target, focused_window, list_windows, resolve_window_target, window_permission_hint, WindowFocusResult, WindowInfo, WindowTarget, - GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, + GNOME_SHELL_INTROSPECT_BACKEND, }; use anyhow::Result; use rmcp::{ @@ -24,12 +25,20 @@ use rmcp::{ use serde::{Deserialize, Serialize}; use std::{ env, + os::unix::net::UnixStream, path::PathBuf, - process::{Command, Output}, + process::{Command, Output, Stdio}, sync::{Arc, Mutex}, - thread, time::Duration, }; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + process::{Child as TokioChild, Command as TokioCommand}, + time::{sleep, timeout}, +}; + +const YDOTOOL_TIMEOUT: Duration = Duration::from_secs(10); +const YDOTOOL_TYPE_CHARS_PER_SECOND: u64 = 20; #[derive(Clone, Default)] pub struct ComputerUseLinux { @@ -103,7 +112,7 @@ impl ComputerUseLinux { error: None, permissions_hint: None, message: - "Focused window query completed through the available GNOME window backend." + "Focused window query completed through the available compositor window backend." .to_string(), }) } @@ -203,7 +212,11 @@ impl ComputerUseLinux { ), ) }; - self.cache_nodes(&accessibility_tree); + if accessibility_error.is_none() { + self.cache_nodes(&accessibility_tree); + } else { + self.clear_cached_nodes(); + } let mut message = if let Some(error) = &accessibility_error { format!("MCP registration is working, but AT-SPI tree extraction failed: {error}") } else if let Some(capture) = &screenshot { @@ -271,14 +284,12 @@ impl ComputerUseLinux { }; if let ClickTarget::PrimaryAction { object_ref, - action_index, action_name, + action_index, } = target { - let requested_action = action_index.to_string(); - return match invoke_accessibility_action(&object_ref, Some(requested_action.as_str())) - .await - { + let action_index = action_index.to_string(); + return match invoke_accessibility_action(&object_ref, Some(&action_index)).await { Ok(invocation) => Json(ActionOutput { ok: invocation.ok, implemented: true, @@ -365,7 +376,7 @@ impl ComputerUseLinux { Err(_) => {} } } - let result = run_ydotool_sequence_blocking(vec![ + let result = run_ydotool_sequence(&[ absolute_mousemove_args(x, y), vec![ "click".to_string(), @@ -386,11 +397,9 @@ impl ComputerUseLinux { &self, Parameters(params): Parameters, ) -> Json { - self.perform_element_action( - ¶ms, - Some(requested_action_or_primary(params.action.as_deref())), - ) - .await + let requested_action = requested_or_primary_action(params.action.as_deref()); + self.perform_element_action(¶ms, Some(requested_action)) + .await } #[tool( @@ -481,6 +490,7 @@ impl ComputerUseLinux { }); } }; + if let Some(session) = self.cached_portal_pointer_session() { match portal_scroll(&session, target_point, direction, units).await { Ok(()) => { @@ -535,7 +545,7 @@ impl ComputerUseLinux { sequence.push(absolute_mousemove_args(x, y)); } sequence.push(wheel_mousemove_args(dx, dy)); - let result = run_ydotool_sequence_blocking(sequence).await; + let result = run_ydotool_sequence(&sequence).await; Json(action_result("scroll", result, received)) } @@ -592,7 +602,7 @@ impl ComputerUseLinux { Err(_) => {} } } - let result = run_ydotool_sequence_blocking(vec![ + let result = run_ydotool_sequence(&[ absolute_mousemove_args(params.start_x, params.start_y), vec!["click".to_string(), "0x40".to_string()], absolute_mousemove_args(params.end_x, params.end_y), @@ -634,7 +644,7 @@ impl ComputerUseLinux { }; let mut args = vec!["key".to_string()]; args.extend(key_events); - let result = run_ydotool_blocking(args).await.map(|output| vec![output]); + let result = run_ydotool(&args).await.map(|output| vec![output]); Json(action_result_with_focus( "press_key", result, @@ -664,7 +674,7 @@ impl ComputerUseLinux { }); } }; - let result = run_ydotool_blocking(vec!["type".to_string(), "--".to_string(), params.text]) + let result = run_ydotool_type_text(¶ms.text) .await .map(|output| vec![output]); Json(action_result_with_focus( @@ -679,7 +689,7 @@ impl ComputerUseLinux { #[tool_handler( name = "codex-computer-use-linux", version = "0.1.0", - instructions = "Begin every turn that uses Computer Use by calling get_app_state. If diagnostics report disabled GNOME accessibility, call setup_accessibility before asking the user to retry. Use list_windows/focused_window before targeted keyboard input. If diagnostics report windowing.can_list_windows=false, call setup_window_targeting to install the optional GNOME Shell extension backend, then ask the user to log out and back in if the setup report says a shell reload is required. This Linux backend can capture screenshots through GNOME Shell or XDG Desktop Portal, read AT-SPI trees with action/value metadata, invoke native AT-SPI actions, set AT-SPI values or editable text, list/focus GNOME Shell windows when org.gnome.Shell.Introspect or the Codex GNOME Shell extension permits it, attach best-effort terminal tty/process metadata to terminal windows, and send coordinate or element-targeted click/scroll/drag input through the Wayland remote desktop portal when available or through ydotool otherwise. For element-targeted actions, prefer element_index from the latest get_app_state result; click, perform_action, and set_value can also use semantic role/name/text/states selectors when the target is unique. type_text and press_key accept optional window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd selectors and refuse targeted input if focus cannot be verified." + instructions = "Begin every turn that uses Computer Use by calling get_app_state. If diagnostics report disabled GNOME accessibility, call setup_accessibility before asking the user to retry. Use list_windows/focused_window before targeted keyboard input. If diagnostics report windowing.can_list_windows=false on GNOME, call setup_window_targeting to install the optional GNOME Shell extension backend, then ask the user to log out and back in if the setup report says a shell reload is required. This Linux backend can capture screenshots through GNOME Shell or XDG Desktop Portal, read AT-SPI trees with action/value metadata, invoke native AT-SPI actions, set AT-SPI values or editable text, list/focus compositor windows through registered Linux window backends when the session permits it, attach best-effort terminal tty/process metadata to terminal windows, and send coordinate or element-targeted click/scroll/drag input through the Wayland remote desktop portal when available or through ydotool otherwise. For element-targeted actions, prefer element_index from the latest get_app_state result; click, perform_action, and set_value can also use semantic role/name/text/states selectors when the target is unique. type_text and press_key accept optional window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd selectors and refuse targeted input if focus cannot be verified." )] impl ServerHandler for ComputerUseLinux {} @@ -1160,6 +1170,12 @@ impl ComputerUseLinux { } } + fn clear_cached_nodes(&self) { + if let Ok(mut cached) = self.last_nodes.lock() { + cached.clear(); + } + } + fn resolve_optional_target_point( &self, x: Option, @@ -1214,8 +1230,8 @@ impl ComputerUseLinux { }; Ok(ClickTarget::PrimaryAction { object_ref: node.object_ref.clone(), - action_index: action.index, action_name: Some(action.name.clone()), + action_index: action.index, }) } @@ -1343,8 +1359,8 @@ enum ClickTarget { Coordinates(i32, i32), PrimaryAction { object_ref: String, - action_index: i32, action_name: Option, + action_index: i32, }, } @@ -1468,7 +1484,7 @@ fn node_matches_resolve_purpose(node: &AccessibilityNode, purpose: ElementResolv match purpose { ElementResolvePurpose::Click => { node.bounds.as_ref().and_then(bounds_center).is_some() - || primary_action(&node.actions).is_some() + || primary_action_name(&node.actions).is_some() } ElementResolvePurpose::Action => !node.actions.is_empty(), ElementResolvePurpose::SetValue => node.supports_editable_text || node.value.is_some(), @@ -1547,10 +1563,7 @@ fn describe_matching_nodes(nodes: &[&AccessibilityNode]) -> String { } fn is_plain_left_click(button: Option<&str>, click_count: Option) -> bool { - let button = button - .map(str::trim) - .filter(|button| !button.is_empty()) - .unwrap_or("left"); + let button = button.unwrap_or("left"); let click_count = click_count.unwrap_or(1); matches!(button.to_ascii_lowercase().as_str(), "left" | "primary") && click_count == 1 } @@ -1559,7 +1572,11 @@ fn primary_action(actions: &[AccessibilityAction]) -> Option<&AccessibilityActio actions.first() } -fn requested_action_or_primary(action: Option<&str>) -> &str { +fn primary_action_name(actions: &[AccessibilityAction]) -> Option { + primary_action(actions).map(|action| action.name.clone()) +} + +fn requested_or_primary_action(action: Option<&str>) -> &str { action .map(str::trim) .filter(|action| !action.is_empty()) @@ -1609,12 +1626,6 @@ fn compact_accessibility_tree(nodes: Vec) -> Vec>(); - let child_counts = compacted.iter().filter_map(|node| node.parent_index).fold( vec![0_i32; compacted.len()], |mut counts, parent_index| { @@ -1624,30 +1635,12 @@ fn compact_accessibility_tree(nodes: Vec) -> Vec u32 { - let mut depth = 0; - let mut parent_index = nodes[index].parent_index; - let mut hops = 0; - - while let Some(parent) = parent_index.and_then(|parent| nodes.get(parent as usize)) { - depth += 1; - parent_index = parent.parent_index; - hops += 1; - if hops >= nodes.len() { - break; - } - } - - depth -} - fn nearest_kept_parent( keep: &[bool], nodes: &[AccessibilityNode], @@ -1676,7 +1669,7 @@ fn should_keep_accessibility_node(node: &AccessibilityNode) -> bool { matches!( node.role.as_str(), "page tab" | "menu item" | "menu" | "list item" | "tree item" - ) && !is_missing_bounds(node.bounds.as_ref()) + ) && !is_sentinel_or_missing_bounds(node.bounds.as_ref()) } fn is_actionable_accessibility_node(node: &AccessibilityNode) -> bool { @@ -1693,8 +1686,13 @@ fn has_non_empty_text(value: Option<&str>) -> bool { value.map(str::trim).is_some_and(|value| !value.is_empty()) } -fn is_missing_bounds(bounds: Option<&Bounds>) -> bool { - bounds.is_none() +fn is_sentinel_or_missing_bounds(bounds: Option<&Bounds>) -> bool { + bounds.is_none_or(|bounds| { + bounds.width <= 0 + || bounds.height <= 0 + || bounds.x <= i32::MIN / 2 + || bounds.y <= i32::MIN / 2 + }) } fn select_accessibility_object_ref( @@ -1733,6 +1731,7 @@ fn accessibility_filter_candidates(window_context: Option<&WindowInfo>) -> Vec ListWindowsOutput { match list_windows().await { Ok(windows) => { let backend = window_backend(windows.iter()); - let note = if backend == GNOME_SHELL_EXTENSION_BACKEND { - "Window list came from the Codex GNOME Shell extension. Terminal windows may include best-effort PTY and active-process context when the process tree is readable." - } else { - "Window list came from GNOME Shell Introspect. Terminal windows may include best-effort PTY and active-process context when the process tree is readable." - }; + let note = registry::list_note(&backend); ListWindowsOutput { backend, windows, @@ -1878,75 +1873,167 @@ fn wheel_mousemove_args(dx: i32, dy: i32) -> Vec { ] } -async fn run_ydotool_sequence_blocking( - commands: Vec>, +async fn run_ydotool_sequence( + commands: &[Vec], ) -> std::result::Result, String> { - tokio::task::spawn_blocking(move || run_ydotool_sequence(&commands)) - .await - .map_err(|error| format!("ydotool task failed: {error}"))? -} - -async fn run_ydotool_blocking(args: Vec) -> std::result::Result { - tokio::task::spawn_blocking(move || run_ydotool(&args)) - .await - .map_err(|error| format!("ydotool task failed: {error}"))? -} - -fn run_ydotool_sequence(commands: &[Vec]) -> std::result::Result, String> { let mut outputs = Vec::new(); for (index, args) in commands.iter().enumerate() { - outputs.push(run_ydotool(args)?); + outputs.push(run_ydotool(args).await?); if index + 1 < commands.len() { - thread::sleep(Duration::from_millis(35)); + sleep(Duration::from_millis(35)).await; } } Ok(outputs) } -fn run_ydotool(args: &[String]) -> std::result::Result { - let mut command = Command::new("ydotool"); +async fn run_ydotool(args: &[String]) -> std::result::Result { + let mut command = TokioCommand::new("ydotool"); command.args(args); if let Some(socket) = ydotool_socket() { command.env("YDOTOOL_SOCKET", socket); } + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); - match command.output() { - Ok(output) if output.status.success() => Ok(output), - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let detail = if stderr.is_empty() { stdout } else { stderr }; - Err(if detail.is_empty() { - format!("ydotool exited with {}", output.status) + match command.spawn() { + Ok(child) => match wait_for_ydotool_output(child).await { + Ok(output) if output.status.success() => Ok(output), + Ok(output) => Err(ydotool_output_error(output)), + Err(error) => Err(error), + }, + Err(error) => Err(format!("failed to run ydotool: {error}")), + } +} + +async fn run_ydotool_type_text(text: &str) -> std::result::Result { + let mut command = TokioCommand::new("ydotool"); + command.args(["type", "--file", "-"]); + if let Some(socket) = ydotool_socket() { + command.env("YDOTOOL_SOCKET", socket); + } + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + match command.spawn() { + Ok(mut child) => { + if let Some(mut stdin) = child.stdin.take() { + if let Err(error) = stdin.write_all(text.as_bytes()).await { + let _ = child.kill().await; + return Err(format!("failed to write text to ydotool stdin: {error}")); + } + } + let output = + wait_for_ydotool_output_with_timeout(child, ydotool_type_timeout(text)).await?; + if output.status.success() { + Ok(output) } else { - detail - }) + Err(ydotool_output_error(output)) + } } Err(error) => Err(format!("failed to run ydotool: {error}")), } } +async fn wait_for_ydotool_output(child: TokioChild) -> std::result::Result { + wait_for_ydotool_output_with_timeout(child, YDOTOOL_TIMEOUT).await +} + +async fn wait_for_ydotool_output_with_timeout( + mut child: TokioChild, + timeout_duration: Duration, +) -> std::result::Result { + let stdout_reader = read_child_pipe(child.stdout.take()); + let stderr_reader = read_child_pipe(child.stderr.take()); + let status = match timeout(timeout_duration, child.wait()).await { + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + stdout_reader.abort(); + stderr_reader.abort(); + return Err(format!( + "ydotool timed out after {}s", + timeout_duration.as_secs() + )); + } + Ok(result) => result.map_err(|error| format!("failed to wait for ydotool: {error}"))?, + }; + let stdout = stdout_reader.await.unwrap_or_default(); + let stderr = stderr_reader.await.unwrap_or_default(); + Ok(Output { + status, + stdout, + stderr, + }) +} + +fn read_child_pipe(pipe: Option) -> tokio::task::JoinHandle> +where + R: AsyncRead + Unpin + Send + 'static, +{ + tokio::spawn(async move { + let mut output = Vec::new(); + if let Some(mut pipe) = pipe { + let _ = pipe.read_to_end(&mut output).await; + } + output + }) +} + +fn ydotool_type_timeout(text: &str) -> Duration { + let text_seconds = (text.chars().count() as u64) + .div_ceil(YDOTOOL_TYPE_CHARS_PER_SECOND) + .max(YDOTOOL_TIMEOUT.as_secs()); + Duration::from_secs(text_seconds) +} + +fn ydotool_output_error(output: Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + if detail.is_empty() { + format!("ydotool exited with {}", output.status) + } else { + detail + } +} + fn ydotool_socket() -> Option { + if let Some(socket) = explicit_ydotool_socket() { + return Some(socket); + } + + connectable_ydotool_socket_from(fallback_ydotool_socket_candidates()) + .map(|path| path.display().to_string()) +} + +fn explicit_ydotool_socket() -> Option { if let Ok(socket) = env::var("YDOTOOL_SOCKET") { - if !socket.trim().is_empty() { - return Some(socket); + let socket = socket.trim(); + if !socket.is_empty() { + return Some(socket.to_string()); } } + None +} - let candidates = [ - env::var("XDG_RUNTIME_DIR") - .ok() - .map(PathBuf::from) - .or_else(|| user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))) - .map(|runtime| runtime.join(".ydotool_socket")), - Some(PathBuf::from("/tmp/.ydotool_socket")), - ]; +fn fallback_ydotool_socket_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Some(runtime) = env::var("XDG_RUNTIME_DIR") + .ok() + .map(PathBuf::from) + .or_else(|| user_id().map(|uid| PathBuf::from(format!("/run/user/{uid}")))) + { + candidates.push(runtime.join(".ydotool_socket")); + } + candidates.push(PathBuf::from("/tmp/.ydotool_socket")); + candidates +} +fn connectable_ydotool_socket_from(candidates: Vec) -> Option { candidates .into_iter() - .flatten() - .find(|path| path.exists()) - .map(|path| path.display().to_string()) + .find(|path| UnixStream::connect(path).is_ok()) } fn mouse_button_code(button: Option<&str>) -> String { @@ -2145,7 +2232,7 @@ fn looks_like_desktop_app(name: &str, command: &str) -> bool { mod tests { use super::*; use crate::atspi_tree::{AccessibilityAction, Bounds}; - use crate::windows::WindowBounds; + use crate::windows::{WindowBounds, GNOME_SHELL_EXTENSION_BACKEND}; fn node(index: u32, bounds: Option) -> AccessibilityNode { node_with_actions(index, bounds, Vec::new()) @@ -2212,7 +2299,7 @@ mod tests { } #[test] - fn accessibility_filter_candidates_skip_title_and_synthetic_app_id() { + fn accessibility_filter_candidates_prefer_title_and_skip_synthetic_app_id() { let window = window_info( 42, Some("CU ATSPI GTK Test"), @@ -2223,7 +2310,13 @@ mod tests { let candidates = accessibility_filter_candidates(Some(&window)); - assert_eq!(candidates, vec!["cu_atspi_gtk_test.py".to_string()]); + assert_eq!( + candidates, + vec![ + "CU ATSPI GTK Test".to_string(), + "cu_atspi_gtk_test.py".to_string(), + ] + ); } #[test] @@ -2346,9 +2439,6 @@ mod tests { assert_eq!(compacted[1].role, "frame"); assert_eq!(compacted[2].role, "button"); assert_eq!(compacted[2].parent_index, Some(1)); - assert_eq!(compacted[0].depth, 0); - assert_eq!(compacted[1].depth, 1); - assert_eq!(compacted[2].depth, 2); assert_eq!(compacted[1].child_count, 1); } @@ -2555,12 +2645,12 @@ mod tests { match target { ClickTarget::PrimaryAction { object_ref, - action_index, action_name, + action_index, } => { assert_eq!(object_ref, ":1.7/org/a11y/atspi/accessible/7"); - assert_eq!(action_index, 0); assert_eq!(action_name.as_deref(), Some("Click")); + assert_eq!(action_index, 0); } ClickTarget::Coordinates(_, _) => { panic!("expected AT-SPI primary-action fallback") @@ -2580,7 +2670,7 @@ mod tests { height: 1, }), vec![AccessibilityAction { - index: 3, + index: 0, name: "Click".to_string(), description: "Clicks the button".to_string(), keybinding: String::new(), @@ -2597,12 +2687,12 @@ mod tests { match target { ClickTarget::PrimaryAction { object_ref, - action_index, action_name, + action_index, } => { assert_eq!(object_ref, ":1.7/org/a11y/atspi/accessible/7"); - assert_eq!(action_index, 3); assert_eq!(action_name.as_deref(), Some("Click")); + assert_eq!(action_index, 0); } ClickTarget::Coordinates(_, _) => { panic!("expected AT-SPI primary-action fallback") @@ -2635,20 +2725,6 @@ mod tests { assert!(error.contains("No clickable bounds cached for element_index 7")); } - #[test] - fn blank_button_uses_plain_left_click_fallback() { - assert!(is_plain_left_click(Some(" "), None)); - assert!(is_plain_left_click(Some(" primary "), Some(1))); - assert!(!is_plain_left_click(Some(" "), Some(2))); - } - - #[test] - fn blank_action_defaults_to_primary_action() { - assert_eq!(requested_action_or_primary(None), "0"); - assert_eq!(requested_action_or_primary(Some(" ")), "0"); - assert_eq!(requested_action_or_primary(Some(" 3 ")), "3"); - } - #[test] fn absolute_mousemove_uses_coordinate_separator() { assert_eq!( @@ -2714,6 +2790,82 @@ mod tests { ); } + #[test] + fn ydotool_type_timeout_scales_with_text_length() { + assert_eq!(ydotool_type_timeout("short").as_secs(), 10); + assert!(ydotool_type_timeout(&"x".repeat(500)).as_secs() > 10); + } + + #[tokio::test] + async fn ydotool_wait_drains_output_before_exit() { + let mut command = TokioCommand::new("sh"); + command.args(["-c", "yes noisy | head -c 200000 >&2; exit 7"]); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let output = wait_for_ydotool_output_with_timeout( + command.spawn().expect("spawn noisy child"), + Duration::from_secs(5), + ) + .await + .expect("child should exit before timeout"); + + assert_eq!(output.status.code(), Some(7)); + assert!(output.stderr.len() >= 100_000); + } + + #[test] + fn ydotool_socket_selection_skips_unconnectable_candidates() { + let dir = + std::env::temp_dir().join(format!("codex-computer-use-server-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp server dir"); + let stale_socket = dir.join("stale.sock"); + std::fs::write(&stale_socket, b"not a socket").expect("write stale socket placeholder"); + let usable_socket = dir.join("usable.sock"); + let listener = + std::os::unix::net::UnixListener::bind(&usable_socket).expect("bind usable socket"); + + let selected = connectable_ydotool_socket_from(vec![stale_socket, usable_socket.clone()]) + .expect("usable socket should be selected"); + + assert_eq!(selected, usable_socket); + drop(listener); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn perform_action_defaults_to_primary_action_index() { + assert_eq!(requested_or_primary_action(None), "0"); + assert_eq!(requested_or_primary_action(Some(" ")), "0"); + assert_eq!( + requested_or_primary_action(Some(" show-menu ")), + "show-menu" + ); + } + + #[test] + fn explicit_ydotool_socket_is_used_without_connectability_probe() { + let key = "YDOTOOL_SOCKET"; + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, " /does/not/exist.sock "); + } + + let selected = explicit_ydotool_socket(); + + match original { + Some(value) => unsafe { + std::env::set_var(key, value); + }, + None => unsafe { + std::env::remove_var(key); + }, + } + + assert_eq!(selected.as_deref(), Some("/does/not/exist.sock")); + } + #[test] fn element_identifier_overrides_cached_object_ref() { let backend = ComputerUseLinux::default(); diff --git a/computer-use-linux/src/windowing/backends/cosmic.rs b/computer-use-linux/src/windowing/backends/cosmic.rs new file mode 100644 index 00000000..f1e29582 --- /dev/null +++ b/computer-use-linux/src/windowing/backends/cosmic.rs @@ -0,0 +1,59 @@ +use crate::cosmic_helper; +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::WindowInfo; +use anyhow::{bail, Context, Result}; + +pub const COSMIC_WAYLAND_BACKEND: &str = "cosmic-wayland"; + +pub fn probe() -> BackendProbe { + match cosmic_helper::probe() { + Ok(probe) => BackendProbe { + id: COSMIC_WAYLAND_BACKEND, + ok: probe.ok, + can_list_windows: probe.can_list_windows, + can_focus_apps: probe.can_activate_windows, + can_focus_windows: probe.can_activate_windows, + detail: probe.detail, + }, + Err(error) => BackendProbe { + id: COSMIC_WAYLAND_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: error.to_string(), + }, + } +} + +pub fn list_windows() -> Result> { + let json = cosmic_helper::list_windows_json()?; + let mut windows: Vec = + serde_json::from_str(&json).context("COSMIC helper returned invalid list-windows JSON")?; + for window in &mut windows { + window.backend = COSMIC_WAYLAND_BACKEND.to_string(); + } + windows.sort_by_key(|window| window.window_id); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub fn focused_window() -> Result> { + let json = cosmic_helper::focused_window_json()?; + let mut window: Option = serde_json::from_str(&json) + .context("COSMIC helper returned invalid focused-window JSON")?; + if let Some(window) = window.as_mut() { + window.backend = COSMIC_WAYLAND_BACKEND.to_string(); + } + Ok(window) +} + +pub fn activate_window(window_id: u64) -> Result<()> { + let activation = cosmic_helper::activate_window(window_id)?; + if activation.ok { + Ok(()) + } else { + bail!("COSMIC helper refused activation: {}", activation.detail); + } +} diff --git a/computer-use-linux/src/windowing/backends/gnome.rs b/computer-use-linux/src/windowing/backends/gnome.rs new file mode 100644 index 00000000..9b358f81 --- /dev/null +++ b/computer-use-linux/src/windowing/backends/gnome.rs @@ -0,0 +1,312 @@ +use crate::diagnostics::hydrate_session_bus_env; +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::{WindowBounds, WindowInfo}; +use anyhow::{bail, Context, Result}; +use std::collections::HashMap; +use std::process::Command; +use zbus::{zvariant::OwnedValue, Proxy}; + +pub const GNOME_SHELL_INTROSPECT_BACKEND: &str = "gnome-shell-introspect"; +pub const GNOME_SHELL_EXTENSION_BACKEND: &str = "gnome-shell-extension"; +pub const GNOME_SHELL_EXTENSION_SERVICE: &str = "com.openai.Codex.WindowControl"; +pub const GNOME_SHELL_EXTENSION_OBJECT_PATH: &str = "/com/openai/Codex/WindowControl"; + +pub fn probe_extension() -> BackendProbe { + hydrate_session_bus_env(); + + let check = gdbus_call_check( + "com.openai.Codex.WindowControl", + "/com/openai/Codex/WindowControl", + "com.openai.Codex.WindowControl.ListWindows", + &[], + ); + BackendProbe { + id: GNOME_SHELL_EXTENSION_BACKEND, + ok: check.ok, + can_list_windows: check.ok, + can_focus_apps: check.ok, + can_focus_windows: check.ok, + detail: check.detail, + } +} + +pub fn probe_introspect() -> BackendProbe { + hydrate_session_bus_env(); + + let list = gdbus_call_check( + "org.gnome.Shell", + "/org/gnome/Shell/Introspect", + "org.gnome.Shell.Introspect.GetWindows", + &[], + ); + let focus_apps = gdbus_introspect_contains( + "org.gnome.Shell", + "/org/gnome/Shell", + "org.gnome.Shell", + "FocusApp", + ); + BackendProbe { + id: GNOME_SHELL_INTROSPECT_BACKEND, + ok: list.ok, + can_list_windows: list.ok, + can_focus_apps: focus_apps.ok, + can_focus_windows: false, + detail: list.detail, + } +} + +pub async fn list_introspect_windows() -> Result> { + hydrate_session_bus_env(); + + let connection = zbus::Connection::session() + .await + .context("failed to connect to session bus")?; + let proxy = Proxy::new( + &connection, + "org.gnome.Shell", + "/org/gnome/Shell/Introspect", + "org.gnome.Shell.Introspect", + ) + .await + .context("failed to create GNOME Shell introspection proxy")?; + let windows: HashMap> = proxy + .call("GetWindows", &()) + .await + .context("GNOME Shell GetWindows call failed")?; + + let mut windows = windows + .into_iter() + .map(|(window_id, properties)| window_from_properties(window_id, &properties)) + .collect::>(); + windows.sort_by_key(|window| window.window_id); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub async fn list_extension_windows() -> Result> { + let json = call_extension_json("ListWindows").await?; + let mut windows: Vec = + serde_json::from_str(&json).context("Codex GNOME Shell extension returned invalid JSON")?; + for window in &mut windows { + window.backend = GNOME_SHELL_EXTENSION_BACKEND.to_string(); + } + windows.sort_by_key(|window| window.window_id); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub(crate) async fn focus_app(app_id: &str) -> Result<()> { + hydrate_session_bus_env(); + + let connection = zbus::Connection::session() + .await + .context("failed to connect to session bus")?; + let proxy = Proxy::new( + &connection, + "org.gnome.Shell", + "/org/gnome/Shell", + "org.gnome.Shell", + ) + .await + .context("failed to create GNOME Shell proxy")?; + let _: () = proxy + .call("FocusApp", &(app_id)) + .await + .with_context(|| format!("GNOME Shell FocusApp failed for app_id {app_id}"))?; + Ok(()) +} + +async fn call_extension_json(method: &str) -> Result { + hydrate_session_bus_env(); + + let connection = zbus::Connection::session() + .await + .context("failed to connect to session bus")?; + let proxy = Proxy::new( + &connection, + GNOME_SHELL_EXTENSION_SERVICE, + GNOME_SHELL_EXTENSION_OBJECT_PATH, + GNOME_SHELL_EXTENSION_SERVICE, + ) + .await + .context("failed to create Codex GNOME Shell extension proxy")?; + let json: String = proxy + .call(method, &()) + .await + .with_context(|| format!("Codex GNOME Shell extension {method} call failed"))?; + Ok(json) +} + +pub(crate) async fn activate_extension_window(window_id: u64) -> Result<()> { + hydrate_session_bus_env(); + + let connection = zbus::Connection::session() + .await + .context("failed to connect to session bus")?; + let proxy = Proxy::new( + &connection, + GNOME_SHELL_EXTENSION_SERVICE, + GNOME_SHELL_EXTENSION_OBJECT_PATH, + GNOME_SHELL_EXTENSION_SERVICE, + ) + .await + .context("failed to create Codex GNOME Shell extension proxy")?; + let (ok, message): (bool, String) = proxy + .call("ActivateWindow", &(window_id)) + .await + .with_context(|| { + format!("Codex GNOME Shell extension ActivateWindow failed for {window_id}") + })?; + if ok { + Ok(()) + } else { + bail!("Codex GNOME Shell extension refused activation: {message}"); + } +} + +pub(crate) fn window_from_properties( + window_id: u64, + properties: &HashMap, +) -> WindowInfo { + let width = get_u32(properties, "width"); + let height = get_u32(properties, "height"); + let bounds = width.zip(height).map(|(width, height)| WindowBounds { + x: get_i32(properties, "x"), + y: get_i32(properties, "y"), + width, + height, + }); + + WindowInfo { + window_id, + title: get_string(properties, "title"), + app_id: get_string(properties, "app-id"), + wm_class: get_string(properties, "wm-class"), + pid: get_u32(properties, "pid"), + bounds, + workspace: get_i32(properties, "workspace"), + focused: get_bool(properties, "has-focus").unwrap_or(false), + hidden: get_bool(properties, "is-hidden").unwrap_or(false), + client_type: get_u32(properties, "client-type").map(client_type_name), + backend: GNOME_SHELL_INTROSPECT_BACKEND.to_string(), + terminal: None, + } +} + +fn get_string(properties: &HashMap, key: &str) -> Option { + properties + .get(key) + .and_then(|value| <&str>::try_from(value).ok()) + .map(ToOwned::to_owned) +} + +fn get_bool(properties: &HashMap, key: &str) -> Option { + properties + .get(key) + .and_then(|value| bool::try_from(value).ok()) +} + +fn get_u32(properties: &HashMap, key: &str) -> Option { + properties + .get(key) + .and_then(|value| u32::try_from(value).ok()) +} + +fn get_i32(properties: &HashMap, key: &str) -> Option { + properties.get(key).and_then(|value| { + i32::try_from(value).ok().or_else(|| { + u32::try_from(value) + .ok() + .and_then(|value| value.try_into().ok()) + }) + }) +} + +fn client_type_name(value: u32) -> String { + match value { + 0 => "wayland", + 1 => "x11", + _ => "unknown", + } + .to_string() +} + +struct ProbeCheck { + ok: bool, + detail: String, +} + +fn gdbus_call_check( + destination: &str, + object_path: &str, + method: &str, + args: &[&str], +) -> ProbeCheck { + let mut command = Command::new("gdbus"); + command.args([ + "call", + "--session", + "--dest", + destination, + "--object-path", + object_path, + "--method", + method, + ]); + command.args(args); + run_probe_command(command) +} + +fn gdbus_introspect_contains( + destination: &str, + object_path: &str, + interface: &str, + member: &str, +) -> ProbeCheck { + let check = run_probe_command({ + let mut command = Command::new("gdbus"); + command.args([ + "introspect", + "--session", + "--dest", + destination, + "--object-path", + object_path, + ]); + command + }); + if !check.ok { + return check; + } + let needle = format!("{interface}.{member}"); + ProbeCheck { + ok: check.detail.contains(&needle) || check.detail.contains(member), + detail: if check.detail.contains(&needle) || check.detail.contains(member) { + format!("{interface}.{member} is present") + } else { + format!("{interface}.{member} not found") + }, + } +} + +fn run_probe_command(mut command: Command) -> ProbeCheck { + match command.output() { + Ok(output) if output.status.success() => ProbeCheck { + ok: true, + detail: String::from_utf8_lossy(&output.stdout).trim().to_string(), + }, + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + ProbeCheck { + ok: false, + detail: if stderr.is_empty() { stdout } else { stderr }, + } + } + Err(error) => ProbeCheck { + ok: false, + detail: error.to_string(), + }, + } +} diff --git a/computer-use-linux/src/windowing/backends/hyprland.rs b/computer-use-linux/src/windowing/backends/hyprland.rs new file mode 100644 index 00000000..51be5191 --- /dev/null +++ b/computer-use-linux/src/windowing/backends/hyprland.rs @@ -0,0 +1,164 @@ +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::{WindowBounds, WindowInfo}; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::process::Command; + +pub const HYPRLAND_BACKEND: &str = "hyprland"; + +pub fn probe() -> BackendProbe { + match Command::new("hyprctl").args(["clients", "-j"]).output() { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let ok = matches!( + serde_json::from_str::(&stdout), + Ok(serde_json::Value::Array(_)) + ); + BackendProbe { + id: HYPRLAND_BACKEND, + ok, + can_list_windows: ok, + can_focus_apps: ok, + can_focus_windows: ok, + detail: if ok { + "hyprctl clients -j returned a JSON array".to_string() + } else { + "hyprctl clients -j did not return a JSON array".to_string() + }, + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + BackendProbe { + id: HYPRLAND_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: if stderr.is_empty() { stdout } else { stderr }, + } + } + Err(error) => BackendProbe { + id: HYPRLAND_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: error.to_string(), + }, + } +} + +pub fn list_windows() -> Result> { + let output = Command::new("hyprctl") + .args(["clients", "-j"]) + .output() + .context("failed to run hyprctl clients -j")?; + if !output.status.success() { + bail!( + "hyprctl clients -j failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + parse_hyprland_clients(&String::from_utf8_lossy(&output.stdout)) +} + +pub(crate) fn parse_hyprland_clients(json: &str) -> Result> { + let clients: Vec = + serde_json::from_str(json).context("failed to parse hyprctl clients -j output")?; + + let mut windows = clients + .into_iter() + .filter(|client| client.mapped.unwrap_or(true)) + .map(WindowInfo::try_from) + .collect::>>()?; + windows.sort_by_key(|window| window.window_id); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub fn activate_window(window_id: u64) -> Result<()> { + let address = format!("address:0x{window_id:x}"); + let output = Command::new("hyprctl") + .args(["dispatch", "focuswindow", &address]) + .output() + .with_context(|| format!("failed to run hyprctl dispatch focuswindow {address}"))?; + if output.status.success() { + Ok(()) + } else { + bail!( + "hyprctl dispatch focuswindow {address} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } +} + +#[derive(Debug, Deserialize)] +struct HyprlandClient { + address: String, + mapped: Option, + hidden: Option, + at: Option<[i32; 2]>, + size: Option<[u32; 2]>, + workspace: Option, + #[serde(rename = "class")] + class_name: Option, + title: Option, + pid: Option, + xwayland: Option, + #[serde(rename = "focusHistoryID")] + focus_history_id: Option, +} + +#[derive(Debug, Deserialize)] +struct HyprlandWorkspace { + id: Option, +} + +impl TryFrom for WindowInfo { + type Error = anyhow::Error; + + fn try_from(client: HyprlandClient) -> Result { + let window_id = parse_hyprland_address(&client.address)?; + let bounds = client.size.map(|[width, height]| WindowBounds { + x: client.at.map(|[x, _]| x), + y: client.at.map(|[_, y]| y), + width, + height, + }); + let client_type = client.xwayland.map(|xwayland| { + if xwayland { + "x11".to_string() + } else { + "wayland".to_string() + } + }); + + Ok(WindowInfo { + window_id, + title: client.title, + app_id: client.class_name.clone(), + wm_class: client.class_name, + pid: client.pid.and_then(|pid| u32::try_from(pid).ok()), + bounds, + workspace: client.workspace.and_then(|workspace| workspace.id), + focused: client.focus_history_id == Some(0), + hidden: client.hidden.unwrap_or(false), + client_type, + backend: HYPRLAND_BACKEND.to_string(), + terminal: None, + }) + } +} + +fn parse_hyprland_address(address: &str) -> Result { + let hex = address + .trim() + .strip_prefix("0x") + .context("Hyprland window address did not start with 0x")?; + u64::from_str_radix(hex, 16) + .with_context(|| format!("failed to parse Hyprland window address {address}")) +} diff --git a/computer-use-linux/src/windowing/backends/i3.rs b/computer-use-linux/src/windowing/backends/i3.rs new file mode 100644 index 00000000..f56e5083 --- /dev/null +++ b/computer-use-linux/src/windowing/backends/i3.rs @@ -0,0 +1,309 @@ +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::{WindowBounds, WindowInfo}; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::{env, fs, os::unix::fs::FileTypeExt, path::PathBuf, process::Command}; + +pub const I3_BACKEND: &str = "i3"; + +pub fn probe() -> BackendProbe { + match i3_msg_command().args(["-t", "get_tree"]).output() { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let ok = matches!( + serde_json::from_str::(&stdout), + Ok(serde_json::Value::Object(_)) + ); + BackendProbe { + id: I3_BACKEND, + ok, + can_list_windows: ok, + can_focus_apps: ok, + can_focus_windows: ok, + detail: if ok { + "i3-msg get_tree returned a JSON tree".to_string() + } else { + "i3-msg get_tree did not return a JSON object".to_string() + }, + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + BackendProbe { + id: I3_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: if stderr.is_empty() { stdout } else { stderr }, + } + } + Err(error) => BackendProbe { + id: I3_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: error.to_string(), + }, + } +} + +pub fn list_windows() -> Result> { + let output = i3_msg_command() + .args(["-t", "get_tree"]) + .output() + .context("failed to run i3-msg -t get_tree")?; + if !output.status.success() { + bail!( + "i3-msg -t get_tree failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let mut windows = parse_i3_tree(&String::from_utf8_lossy(&output.stdout))?; + hydrate_i3_window_pids(&mut windows); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub(crate) fn parse_i3_tree(json: &str) -> Result> { + let root: I3Node = + serde_json::from_str(json).context("failed to parse i3-msg get_tree output")?; + let mut windows = Vec::new(); + collect_i3_windows(&root, None, false, &mut windows); + windows.sort_by_key(|window| window.window_id); + Ok(windows) +} + +pub fn activate_window(window_id: u64) -> Result<()> { + let selector = format!(r#"[id="{window_id}"] focus"#); + let output = i3_msg_command() + .arg(&selector) + .output() + .with_context(|| format!("failed to run i3-msg {selector}"))?; + if !output.status.success() { + bail!( + "i3-msg {selector} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let replies: Vec = + serde_json::from_slice(&output.stdout).context("failed to parse i3-msg focus reply")?; + if replies.iter().all(|reply| reply.success) { + Ok(()) + } else { + let details = replies + .into_iter() + .filter_map(|reply| reply.error) + .collect::>() + .join("; "); + bail!( + "i3-msg {selector} did not focus the window: {}", + if details.is_empty() { + "unknown i3 failure" + } else { + details.as_str() + } + ); + } +} + +fn collect_i3_windows( + node: &I3Node, + workspace: Option, + in_dockarea: bool, + windows: &mut Vec, +) { + let node_type = node.node_type.as_deref(); + let current_workspace = if node_type == Some("workspace") { + node.num + } else { + workspace + }; + let current_in_dockarea = in_dockarea || node_type == Some("dockarea"); + + if let Some(window) = node.to_window_info(current_workspace, current_in_dockarea) { + windows.push(window); + } + + for child in &node.nodes { + collect_i3_windows(child, current_workspace, current_in_dockarea, windows); + } + for child in &node.floating_nodes { + collect_i3_windows(child, current_workspace, current_in_dockarea, windows); + } +} + +fn hydrate_i3_window_pids(windows: &mut [WindowInfo]) { + for window in windows { + if window.pid.is_none() { + window.pid = i3_window_pid(window.window_id); + } + } +} + +fn i3_window_pid(window_id: u64) -> Option { + let output = Command::new("xprop") + .args(["-id", &window_id.to_string(), "_NET_WM_PID"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_xprop_pid(&String::from_utf8_lossy(&output.stdout)) +} + +pub(crate) fn parse_xprop_pid(output: &str) -> Option { + output.split('=').nth(1)?.trim().parse::().ok() +} + +fn i3_msg_command() -> Command { + let mut command = Command::new("i3-msg"); + if let Some(socket_path) = i3_socket_path() { + command.arg("-s").arg(socket_path); + } + command +} + +fn i3_socket_path() -> Option { + if let Some(value) = env_var("I3SOCK") { + return Some(PathBuf::from(value)); + } + + let socket_dir = xdg_runtime_dir()?.join("i3"); + let mut sockets = fs::read_dir(socket_dir) + .ok()? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let file_name = entry.file_name(); + let file_name = file_name.to_str()?; + if !file_name.starts_with("ipc-socket.") { + return None; + } + let metadata = entry.metadata().ok()?; + if !metadata.file_type().is_socket() { + return None; + } + let modified = metadata.modified().ok(); + Some((modified, entry.path())) + }) + .collect::>(); + sockets.sort_by_key(|(modified, _)| { + std::cmp::Reverse(modified.unwrap_or(std::time::SystemTime::UNIX_EPOCH)) + }); + sockets.into_iter().map(|(_, path)| path).next() +} + +fn xdg_runtime_dir() -> Option { + env_var("XDG_RUNTIME_DIR").map(PathBuf::from) +} + +fn env_var(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn clean_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "null") + .map(ToOwned::to_owned) +} + +#[derive(Debug, Deserialize)] +struct I3CommandReply { + success: bool, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct I3Node { + #[serde(rename = "type")] + node_type: Option, + name: Option, + window: Option, + window_type: Option, + window_properties: Option, + rect: Option, + geometry: Option, + #[serde(default)] + focused: bool, + #[serde(default)] + nodes: Vec, + #[serde(default)] + floating_nodes: Vec, + num: Option, + scratchpad_state: Option, +} + +#[derive(Debug, Deserialize)] +struct I3WindowProperties { + class: Option, + instance: Option, + title: Option, +} + +#[derive(Debug, Deserialize)] +struct I3Rect { + x: i32, + y: i32, + width: u32, + height: u32, +} + +impl I3Node { + fn to_window_info(&self, workspace: Option, in_dockarea: bool) -> Option { + if in_dockarea { + return None; + } + let window_id = self.window?; + if self.window_type.as_deref() == Some("dock") { + return None; + } + + let properties = self.window_properties.as_ref(); + let title = clean_string( + properties + .and_then(|properties| properties.title.as_deref()) + .or(self.name.as_deref()), + ); + let wm_class = clean_string( + properties + .and_then(|properties| properties.class.as_deref()) + .or_else(|| properties.and_then(|properties| properties.instance.as_deref())), + ); + let app_id = clean_string( + properties + .and_then(|properties| properties.instance.as_deref()) + .or(wm_class.as_deref()), + ); + let rect = self.rect.as_ref().or(self.geometry.as_ref()); + let bounds = rect.map(|rect| WindowBounds { + x: Some(rect.x), + y: Some(rect.y), + width: rect.width, + height: rect.height, + }); + + Some(WindowInfo { + window_id, + title, + app_id, + wm_class, + pid: None, + bounds, + workspace, + focused: self.focused, + hidden: self.scratchpad_state.as_deref() == Some("fresh"), + client_type: Some("x11".to_string()), + backend: I3_BACKEND.to_string(), + terminal: None, + }) + } +} diff --git a/computer-use-linux/src/windowing/backends/kwin.rs b/computer-use-linux/src/windowing/backends/kwin.rs new file mode 100644 index 00000000..d4354f30 --- /dev/null +++ b/computer-use-linux/src/windowing/backends/kwin.rs @@ -0,0 +1,767 @@ +use crate::diagnostics::hydrate_session_bus_env; +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::{WindowBounds, WindowInfo}; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::{ + fs::{self, OpenOptions}, + io::Write, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::mpsc; +use tokio::time::timeout; +use zbus::Proxy; + +pub const KWIN_BACKEND: &str = "kwin"; +const KWIN_SCRIPT_TIMEOUT: Duration = Duration::from_secs(2); +const KWIN_SCRIPTING_SERVICE: &str = "org.kde.KWin"; +const KWIN_SCRIPTING_OBJECT_PATH: &str = "/Scripting"; +const KWIN_SCRIPTING_INTERFACE: &str = "org.kde.kwin.Scripting"; +const KWIN_CALLBACK_OBJECT_PATH_PREFIX: &str = "/com/openai/Codex/KWinWindowQuery"; +const KWIN_CALLBACK_INTERFACE: &str = "com.openai.Codex.KWinWindowQuery"; + +pub fn probe() -> BackendProbe { + hydrate_session_bus_env(); + let check = gdbus_introspect_contains( + "org.kde.KWin", + "/Scripting", + "org.kde.kwin.Scripting", + "loadScript", + ); + BackendProbe { + id: KWIN_BACKEND, + ok: check.ok, + can_list_windows: check.ok, + can_focus_apps: check.ok, + can_focus_windows: check.ok, + detail: if check.ok { + "KWin scripting is available on the session bus".to_string() + } else { + format!("KWin scripting unavailable: {}", check.detail) + }, + } +} + +pub async fn list_windows() -> Result> { + let json = call_kwin_window_script().await?; + let mut windows = parse_kwin_windows(&json)?; + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub async fn activate_window(window_id: u64) -> Result<()> { + let uuid = kwin_uuid_for_window_id(window_id).await?.with_context(|| { + format!("No KWin window matched window_id {window_id} during activation") + })?; + call_kwin_activate_script(&uuid).await +} + +async fn kwin_uuid_for_window_id(window_id: u64) -> Result> { + let json = call_kwin_window_script().await?; + let snapshot = parse_kwin_snapshot(&json)?; + Ok(snapshot.windows.into_iter().find_map(|window| { + let uuid = window.kwin_uuid()?; + (kwin_window_id_from_uuid(&uuid) == window_id).then_some(uuid) + })) +} + +#[derive(Debug, Deserialize)] +struct KwinScriptResult { + #[serde(default)] + ok: bool, + error: Option, +} + +async fn call_kwin_activate_script(uuid: &str) -> Result<()> { + let uuid = uuid.to_string(); + let json = call_kwin_script(|service_name, callback_object_path, plugin_name| { + write_kwin_activate_script(service_name, callback_object_path, plugin_name, &uuid) + }) + .await?; + let result: KwinScriptResult = + serde_json::from_str(&json).context("failed to parse KWin activation script output")?; + + if result.ok { + Ok(()) + } else { + bail!( + "KWin activation script refused activation: {}", + result.error.unwrap_or_else(|| "unknown error".to_string()) + ); + } +} + +async fn call_kwin_window_script() -> Result { + call_kwin_script(write_kwin_window_script).await +} + +async fn call_kwin_script(write_script: F) -> Result +where + F: FnOnce(&str, &str, &str) -> Result, +{ + hydrate_session_bus_env(); + + let connection = zbus::Connection::session() + .await + .context("failed to connect to session bus")?; + let unique_name = connection + .unique_name() + .context("session bus did not assign a unique name")? + .to_string(); + let plugin_name = temporary_kwin_plugin_name(); + let callback_object_path = format!("{KWIN_CALLBACK_OBJECT_PATH_PREFIX}/{plugin_name}"); + let (sender, mut receiver) = mpsc::unbounded_channel(); + connection + .object_server() + .at(callback_object_path.as_str(), KwinWindowCallback { sender }) + .await + .context("failed to register temporary KWin callback object")?; + + let mut script_path = None; + let mut loaded_script = false; + let result = async { + let path = write_script(&unique_name, &callback_object_path, &plugin_name)?; + script_path = Some(path.clone()); + let scripting_proxy = Proxy::new( + &connection, + KWIN_SCRIPTING_SERVICE, + KWIN_SCRIPTING_OBJECT_PATH, + KWIN_SCRIPTING_INTERFACE, + ) + .await + .context("failed to create KWin scripting proxy")?; + + // Plasma 6 can return 0 here even when isScriptLoaded reports success; + // the callback below is the authoritative completion signal. + let _script_id: i32 = scripting_proxy + .call( + "loadScript", + &(path.to_string_lossy().as_ref(), plugin_name.as_str()), + ) + .await + .context("KWin loadScript failed")?; + loaded_script = true; + + let _: () = scripting_proxy + .call("start", &()) + .await + .context("KWin start failed after loading the temporary script")?; + + timeout(KWIN_SCRIPT_TIMEOUT, async move { + receiver.recv().await.ok_or_else(|| { + anyhow::anyhow!("KWin temporary script callback disconnected before returning data") + }) + }) + .await + .context("KWin temporary script did not return data before timeout")? + } + .await; + + if loaded_script { + if let Ok(scripting_proxy) = Proxy::new( + &connection, + KWIN_SCRIPTING_SERVICE, + KWIN_SCRIPTING_OBJECT_PATH, + KWIN_SCRIPTING_INTERFACE, + ) + .await + { + let _: Result = scripting_proxy + .call("unloadScript", &(plugin_name.as_str())) + .await; + } + } + let _: Result = connection + .object_server() + .remove::(callback_object_path.as_str()) + .await; + if let Some(script_path) = script_path { + let _ = fs::remove_file(script_path); + } + + result +} + +struct KwinWindowCallback { + sender: mpsc::UnboundedSender, +} + +#[zbus::interface(name = "com.openai.Codex.KWinWindowQuery")] +impl KwinWindowCallback { + fn receive_windows(&self, json: &str) -> zbus::fdo::Result<()> { + self.sender + .send(json.to_string()) + .map_err(|error| zbus::fdo::Error::Failed(error.to_string())) + } + + fn receive_result(&self, json: &str) -> zbus::fdo::Result<()> { + self.sender + .send(json.to_string()) + .map_err(|error| zbus::fdo::Error::Failed(error.to_string())) + } +} + +fn temporary_kwin_plugin_name() -> String { + let pid = std::process::id(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("codex_kwin_window_query_{pid}_{nanos}") +} + +fn write_kwin_window_script( + service_name: &str, + callback_object_path: &str, + plugin_name: &str, +) -> Result { + let script = kwin_window_script_source(service_name, callback_object_path, plugin_name)?; + write_kwin_script_file(plugin_name, &script) +} + +fn kwin_window_script_source( + service_name: &str, + callback_object_path: &str, + plugin_name: &str, +) -> Result { + let service_name = serde_json::to_string(service_name)?; + let object_path = serde_json::to_string(callback_object_path)?; + let interface = serde_json::to_string(KWIN_CALLBACK_INTERFACE)?; + let plugin_name_json = serde_json::to_string(plugin_name)?; + Ok(format!( + r#"(function() {{ + var serviceName = {service_name}; + var objectPath = {object_path}; + var iface = {interface}; + var pluginName = {plugin_name_json}; + + function read(obj, key) {{ + try {{ + if (obj === null || obj === undefined) {{ + return null; + }} + var value = obj[key]; + if (typeof value === "function") {{ + return null; + }} + return serialize(value); + }} catch (error) {{ + return null; + }} + }} + + function serialize(value) {{ + if (value === null || value === undefined) {{ + return null; + }} + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {{ + return value; + }} + if (Array.isArray(value)) {{ + return value.map(serialize); + }} + try {{ + if (typeof value.toString === "function") {{ + return value.toString(); + }} + }} catch (error) {{}} + return null; + }} + + function geometry(window) {{ + var frame = null; + try {{ + frame = window.frameGeometry; + }} catch (error) {{}} + var x = read(window, "x"); + var y = read(window, "y"); + var width = read(window, "width"); + var height = read(window, "height"); + return {{ + x: x !== null ? x : read(frame, "x"), + y: y !== null ? y : read(frame, "y"), + width: width !== null ? width : read(frame, "width"), + height: height !== null ? height : read(frame, "height") + }}; + }} + + function firstDesktop(window) {{ + var desktops = read(window, "desktops"); + if (!Array.isArray(desktops) || desktops.length === 0) {{ + return null; + }} + var first = desktops[0]; + var parsed = parseInt(first, 10); + return isFinite(parsed) ? parsed : null; + }} + + function clientType(window) {{ + if (read(window, "waylandClient")) {{ + return "wayland"; + }} + if (read(window, "x11Client")) {{ + return "x11"; + }} + return null; + }} + + var activeWindow = null; + try {{ + activeWindow = workspace.activeWindow; + }} catch (error) {{}} + var windows = workspace.windowList().map(function(window) {{ + var geo = geometry(window); + return {{ + uuid: read(window, "uuid"), + internalId: read(window, "internalId"), + caption: read(window, "caption"), + desktopFile: read(window, "desktopFile"), + resourceClass: read(window, "resourceClass"), + resourceName: read(window, "resourceName"), + windowClass: read(window, "windowClass"), + pid: read(window, "pid"), + x: geo.x, + y: geo.y, + width: geo.width, + height: geo.height, + workspace: firstDesktop(window), + minimized: read(window, "minimized"), + active: read(window, "active") || window === activeWindow, + clientType: clientType(window), + normalWindow: read(window, "normalWindow"), + desktopWindow: read(window, "desktopWindow"), + skipTaskbar: read(window, "skipTaskbar"), + dock: read(window, "dock") + }}; + }}); + + callDBus(serviceName, objectPath, iface, "ReceiveWindows", JSON.stringify({{ + backend: "kwin", + pluginName: pluginName, + windows: windows + }})); +}})(); +"# + )) +} + +fn write_kwin_activate_script( + service_name: &str, + callback_object_path: &str, + plugin_name: &str, + uuid: &str, +) -> Result { + let script = + kwin_activate_script_source(service_name, callback_object_path, plugin_name, uuid)?; + write_kwin_script_file(plugin_name, &script) +} + +pub(crate) fn kwin_activate_script_source( + service_name: &str, + callback_object_path: &str, + plugin_name: &str, + uuid: &str, +) -> Result { + let target_uuid = normalize_kwin_uuid(uuid).context("KWin activation requires a uuid")?; + let service_name = serde_json::to_string(service_name)?; + let object_path = serde_json::to_string(callback_object_path)?; + let interface = serde_json::to_string(KWIN_CALLBACK_INTERFACE)?; + let plugin_name_json = serde_json::to_string(plugin_name)?; + let target_uuid = serde_json::to_string(&target_uuid)?; + + Ok(format!( + r#"(function() {{ + var serviceName = {service_name}; + var objectPath = {object_path}; + var iface = {interface}; + var pluginName = {plugin_name_json}; + var targetUuid = {target_uuid}; + + function send(payload) {{ + payload.backend = "kwin"; + payload.pluginName = pluginName; + callDBus(serviceName, objectPath, iface, "ReceiveResult", JSON.stringify(payload)); + }} + + function serialize(value) {{ + if (value === null || value === undefined) {{ + return null; + }} + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {{ + return value; + }} + try {{ + if (typeof value.toString === "function") {{ + return value.toString(); + }} + }} catch (error) {{}} + return null; + }} + + function read(obj, key) {{ + try {{ + if (obj === null || obj === undefined) {{ + return null; + }} + var value = obj[key]; + if (typeof value === "function") {{ + return null; + }} + return serialize(value); + }} catch (error) {{ + return null; + }} + }} + + function normalizeUuid(value) {{ + var text = serialize(value); + if (text === null || text === undefined) {{ + return null; + }} + text = String(text).trim().toLowerCase(); + if (text.charAt(0) === "{{" && text.charAt(text.length - 1) === "}}") {{ + text = text.substring(1, text.length - 1); + }} + return text.length > 0 ? text : null; + }} + + function windowUuid(window) {{ + return normalizeUuid(read(window, "uuid")) || normalizeUuid(read(window, "internalId")); + }} + + function listWindows() {{ + try {{ + if (typeof workspace.windowList === "function") {{ + return workspace.windowList(); + }} + }} catch (error) {{}} + try {{ + if (workspace.stackingOrder && typeof workspace.stackingOrder.length === "number") {{ + return workspace.stackingOrder; + }} + }} catch (error) {{}} + return []; + }} + + function activateDesktop(window) {{ + var desktops = null; + try {{ + desktops = window.desktops; + }} catch (error) {{}} + if (desktops && desktops.length > 0) {{ + try {{ + workspace.currentDesktop = desktops[0]; + }} catch (error) {{}} + }} + }} + + try {{ + var targetWindow = null; + var windows = listWindows(); + for (var i = 0; i < windows.length; i++) {{ + if (windowUuid(windows[i]) === targetUuid) {{ + targetWindow = windows[i]; + break; + }} + }} + + if (!targetWindow) {{ + throw new Error("window not found: " + targetUuid); + }} + + try {{ + targetWindow.minimized = false; + }} catch (error) {{}} + activateDesktop(targetWindow); + + var activated = false; + var activationError = null; + try {{ + workspace.activeWindow = targetWindow; + activated = true; + }} catch (error) {{ + activationError = error; + }} + if (!activated) {{ + try {{ + workspace.activeClient = targetWindow; + activated = true; + }} catch (error) {{ + activationError = error; + }} + }} + if (!activated) {{ + try {{ + if (typeof targetWindow.activate === "function") {{ + targetWindow.activate(); + activated = true; + }} + }} catch (error) {{ + activationError = error; + }} + }} + if (!activated) {{ + throw activationError || new Error("workspace refused activeWindow assignment"); + }} + + try {{ + if (typeof workspace.raiseWindow === "function") {{ + workspace.raiseWindow(targetWindow); + }} + }} catch (error) {{}} + + send({{ + ok: true, + uuid: windowUuid(targetWindow) + }}); + }} catch (error) {{ + send({{ + ok: false, + error: String(error && error.message ? error.message : error) + }}); + }} +}})(); +"# + )) +} + +fn write_kwin_script_file(plugin_name: &str, script: &str) -> Result { + for attempt in 0..4 { + let filename = if attempt == 0 { + format!("{plugin_name}.js") + } else { + format!("{plugin_name}-{attempt}.js") + }; + let path = std::env::temp_dir().join(filename); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + if let Err(error) = file.write_all(script.as_bytes()) { + let _ = fs::remove_file(&path); + return Err(error).with_context(|| { + format!("failed to write temporary KWin script {}", path.display()) + }); + } + return Ok(path); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error).with_context(|| { + format!("failed to create temporary KWin script {}", path.display()) + }); + } + } + } + + bail!("failed to create a unique temporary KWin script path for {plugin_name}") +} + +pub(crate) fn parse_kwin_windows(json: &str) -> Result> { + let snapshot = parse_kwin_snapshot(json)?; + let mut windows = snapshot + .windows + .into_iter() + .filter(|window| !json_value_as_bool(window.desktop_window.as_ref()).unwrap_or(false)) + .filter(|window| !json_value_as_bool(window.dock.as_ref()).unwrap_or(false)) + .filter(|window| !json_value_as_bool(window.skip_taskbar.as_ref()).unwrap_or(false)) + .filter(|window| json_value_as_bool(window.normal_window.as_ref()).unwrap_or(true)) + .map(WindowInfo::try_from) + .collect::>>()?; + windows.sort_by_key(|window| window.window_id); + Ok(windows) +} + +fn parse_kwin_snapshot(json: &str) -> Result { + serde_json::from_str(json).context("failed to parse KWin temporary script output") +} + +#[derive(Debug, Deserialize)] +struct KwinWindowSnapshot { + windows: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KwinRawWindow { + uuid: Option, + internal_id: Option, + caption: Option, + desktop_file: Option, + resource_class: Option, + resource_name: Option, + window_class: Option, + pid: Option, + x: Option, + y: Option, + width: Option, + height: Option, + workspace: Option, + minimized: Option, + active: Option, + client_type: Option, + normal_window: Option, + desktop_window: Option, + skip_taskbar: Option, + dock: Option, +} + +impl KwinRawWindow { + fn kwin_uuid(&self) -> Option { + self.uuid + .as_deref() + .or(self.internal_id.as_deref()) + .and_then(normalize_kwin_uuid) + } +} + +impl TryFrom for WindowInfo { + type Error = anyhow::Error; + + fn try_from(window: KwinRawWindow) -> Result { + let uuid = window + .kwin_uuid() + .context("KWin window did not include uuid or internalId")?; + let width = json_value_as_u32(window.width.as_ref()); + let height = json_value_as_u32(window.height.as_ref()); + let bounds = width.zip(height).map(|(width, height)| WindowBounds { + x: json_value_as_i32(window.x.as_ref()), + y: json_value_as_i32(window.y.as_ref()), + width, + height, + }); + let app_id = clean_string(window.desktop_file.as_deref()) + .or_else(|| clean_string(window.resource_class.as_deref())); + let wm_class = clean_string(window.resource_class.as_deref()) + .or_else(|| clean_string(window.window_class.as_deref())) + .or_else(|| clean_string(window.resource_name.as_deref())); + let client_type = clean_string(window.client_type.as_deref()); + + Ok(WindowInfo { + window_id: kwin_window_id_from_uuid(&uuid), + title: clean_string(window.caption.as_deref()), + app_id, + wm_class, + pid: json_value_as_u32(window.pid.as_ref()), + bounds, + workspace: json_value_as_i32(window.workspace.as_ref()), + focused: json_value_as_bool(window.active.as_ref()).unwrap_or(false), + hidden: json_value_as_bool(window.minimized.as_ref()).unwrap_or(false), + client_type, + backend: KWIN_BACKEND.to_string(), + terminal: None, + }) + } +} + +pub(crate) fn kwin_window_id_from_uuid(uuid: &str) -> u64 { + let normalized = normalize_kwin_uuid(uuid).unwrap_or_else(|| uuid.trim().to_ascii_lowercase()); + let mut hash = 0xcbf29ce484222325_u64; + for byte in normalized.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn normalize_kwin_uuid(uuid: &str) -> Option { + let value = uuid + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim() + .to_ascii_lowercase(); + (!value.is_empty()).then_some(value) +} + +fn clean_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "null") + .map(ToOwned::to_owned) +} + +fn json_value_as_bool(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Bool(value) => Some(*value), + serde_json::Value::String(value) => match value.to_ascii_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }, + _ => None, + } +} + +fn json_value_as_u32(value: Option<&serde_json::Value>) -> Option { + let value = json_value_as_f64(value)?; + if !value.is_finite() || value < 0.0 || value > u32::MAX as f64 { + return None; + } + Some(value.round() as u32) +} + +fn json_value_as_i32(value: Option<&serde_json::Value>) -> Option { + let value = json_value_as_f64(value)?; + if !value.is_finite() || value < i32::MIN as f64 || value > i32::MAX as f64 { + return None; + } + Some(value.round() as i32) +} + +fn json_value_as_f64(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::Number(value) => value.as_f64(), + serde_json::Value::String(value) => value.parse::().ok(), + _ => None, + } +} + +struct ProbeCheck { + ok: bool, + detail: String, +} + +fn gdbus_introspect_contains( + destination: &str, + object_path: &str, + interface: &str, + member: &str, +) -> ProbeCheck { + match std::process::Command::new("gdbus") + .args([ + "introspect", + "--session", + "--dest", + destination, + "--object-path", + object_path, + ]) + .output() + { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let needle = format!("{interface}.{member}"); + let ok = stdout.contains(&needle) || stdout.contains(member); + ProbeCheck { + ok, + detail: if ok { + format!("{interface}.{member} is present") + } else { + format!("{interface}.{member} not found") + }, + } + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + ProbeCheck { + ok: false, + detail: if stderr.is_empty() { stdout } else { stderr }, + } + } + Err(error) => ProbeCheck { + ok: false, + detail: error.to_string(), + }, + } +} diff --git a/computer-use-linux/src/windowing/backends/mod.rs b/computer-use-linux/src/windowing/backends/mod.rs new file mode 100644 index 00000000..25cbff7e --- /dev/null +++ b/computer-use-linux/src/windowing/backends/mod.rs @@ -0,0 +1,5 @@ +pub mod cosmic; +pub mod gnome; +pub mod hyprland; +pub mod i3; +pub mod kwin; diff --git a/computer-use-linux/src/windowing/mod.rs b/computer-use-linux/src/windowing/mod.rs new file mode 100644 index 00000000..52add8a9 --- /dev/null +++ b/computer-use-linux/src/windowing/mod.rs @@ -0,0 +1,747 @@ +pub mod backends; +pub mod registry; +pub mod target; +pub mod types; + +#[allow(unused_imports)] +pub use registry::{ + COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, + HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, WINDOW_PERMISSION_HINT, +}; +#[allow(unused_imports)] +pub use target::{ + focus_window_target, focused_window, list_windows, resolve_window_target, + window_permission_hint, +}; +#[allow(unused_imports)] +pub use types::{WindowBounds, WindowFocusResult, WindowInfo, WindowTarget}; + +#[cfg(test)] +mod tests { + use super::backends::gnome::window_from_properties; + use super::backends::hyprland::{parse_hyprland_clients, HYPRLAND_BACKEND}; + use super::backends::i3::{parse_i3_tree, parse_xprop_pid, I3_BACKEND}; + use super::backends::kwin::{ + kwin_activate_script_source, kwin_window_id_from_uuid, parse_kwin_windows, KWIN_BACKEND, + }; + use super::registry::{ + descriptors, list_note, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, + GNOME_SHELL_INTROSPECT_BACKEND, + }; + use super::target::ensure_backend_can_focus_target; + use super::*; + use crate::terminal::{TerminalProcess, TerminalWindowContext}; + use std::collections::HashMap; + use zbus::zvariant::OwnedValue; + use zbus::zvariant::Value; + + fn owned_value(value: Value<'_>) -> OwnedValue { + OwnedValue::try_from(value).unwrap() + } + + #[test] + fn registry_keeps_stable_backend_order() { + let ids = descriptors() + .iter() + .map(|descriptor| descriptor.id) + .collect::>(); + + assert_eq!( + ids, + vec![ + GNOME_SHELL_EXTENSION_BACKEND, + GNOME_SHELL_INTROSPECT_BACKEND, + COSMIC_WAYLAND_BACKEND, + KWIN_BACKEND, + HYPRLAND_BACKEND, + I3_BACKEND, + ] + ); + } + + #[test] + fn registry_serves_backend_list_notes() { + assert!(list_note(COSMIC_WAYLAND_BACKEND).contains("COSMIC Wayland helper")); + assert!(list_note("missing-backend").contains("GNOME Shell Introspect")); + } + + fn window(window_id: u64, title: &str, app_id: &str, wm_class: &str) -> WindowInfo { + WindowInfo { + window_id, + title: Some(title.to_string()), + app_id: Some(app_id.to_string()), + wm_class: Some(wm_class.to_string()), + pid: Some(window_id as u32 + 1000), + bounds: Some(WindowBounds { + x: None, + y: None, + width: 800, + height: 600, + }), + workspace: None, + focused: false, + hidden: false, + client_type: Some("wayland".to_string()), + backend: GNOME_SHELL_INTROSPECT_BACKEND.to_string(), + terminal: None, + } + } + + fn terminal_window( + window_id: u64, + title: &str, + tty: &str, + active_pid: u32, + active_command: &str, + active_cwd: &str, + ) -> WindowInfo { + let mut window = window( + window_id, + title, + "com.mitchellh.ghostty.desktop", + "com.mitchellh.ghostty", + ); + window.terminal = Some(TerminalWindowContext { + tty: tty.to_string(), + root_process: TerminalProcess { + pid: active_pid - 1, + command_name: "zsh".to_string(), + command_line: "zsh --login".to_string(), + cwd: Some("/home/avifenesh".to_string()), + }, + active_process: Some(TerminalProcess { + pid: active_pid, + command_name: active_command.to_string(), + command_line: format!("{active_command} resume 123"), + cwd: Some(active_cwd.to_string()), + }), + process_count: 2, + confidence: "heuristic".to_string(), + match_reason: "test".to_string(), + }); + window + } + + #[test] + fn target_reports_when_any_selector_is_present() { + assert!(!WindowTarget::default().has_target()); + assert!(WindowTarget { + title: Some("Ghostty".to_string()), + ..Default::default() + } + .has_target()); + assert!(WindowTarget { + tty: Some("/dev/pts/1".to_string()), + ..Default::default() + } + .has_target()); + } + + #[test] + fn title_pid_and_window_id_targets_require_exact_focus() { + assert!(WindowTarget { + title: Some("Ghostty".to_string()), + ..Default::default() + } + .requires_exact_focus()); + assert!(WindowTarget { + pid: Some(123), + ..Default::default() + } + .requires_exact_focus()); + assert!(WindowTarget { + window_id: Some(123), + ..Default::default() + } + .requires_exact_focus()); + assert!(WindowTarget { + terminal_command: Some("codex".to_string()), + ..Default::default() + } + .requires_exact_focus()); + assert!(!WindowTarget { + app_id: Some("com.mitchellh.ghostty.desktop".to_string()), + ..Default::default() + } + .requires_exact_focus()); + } + + #[test] + fn exact_targets_require_extension_activation_backend() { + let window = window( + 2, + "Ghostty", + "com.mitchellh.ghostty.desktop", + "com.mitchellh.ghostty", + ); + + let error = ensure_backend_can_focus_target( + &WindowTarget { + terminal_command: Some("codex".to_string()), + ..Default::default() + }, + &window, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("Exact window targeting requires")); + } + + #[test] + fn app_targets_can_use_app_level_focus_backend() { + let window = window( + 2, + "Ghostty", + "com.mitchellh.ghostty.desktop", + "com.mitchellh.ghostty", + ); + + ensure_backend_can_focus_target( + &WindowTarget { + app_id: Some("com.mitchellh.ghostty.desktop".to_string()), + ..Default::default() + }, + &window, + ) + .unwrap(); + } + + #[test] + fn cosmic_backend_can_exact_focus_targets() { + let mut window = window(2, "Codex", "codex-app", "codex-app"); + window.backend = COSMIC_WAYLAND_BACKEND.to_string(); + + ensure_backend_can_focus_target( + &WindowTarget { + title: Some("Codex".to_string()), + ..Default::default() + }, + &window, + ) + .unwrap(); + } + + #[test] + fn i3_backend_can_exact_focus_targets() { + let mut window = window(2, "Codex", "codex-app", "codex-app"); + window.backend = I3_BACKEND.to_string(); + + ensure_backend_can_focus_target( + &WindowTarget { + title: Some("Codex".to_string()), + ..Default::default() + }, + &window, + ) + .unwrap(); + } + + #[test] + fn resolves_target_by_window_id_and_secondary_selectors() { + let windows = vec![ + window(1, "Codex", "codex.desktop", "Codex"), + window(2, "Ghostty", "com.mitchellh.ghostty.desktop", "Ghostty"), + ]; + + let matched = resolve_window_target( + &windows, + &WindowTarget { + window_id: Some(2), + title: Some("Ghostty".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(matched.window_id, 2); + + let err = resolve_window_target( + &windows, + &WindowTarget { + window_id: Some(2), + title: Some("Codex".to_string()), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("No window matched window_id 2")); + } + + #[test] + fn resolves_large_window_id_after_json_number_rounding() { + let exact_window_id = 7_511_476_032_840_641_491; + let rounded_window_id = 7_511_476_032_840_642_000; + assert_ne!(exact_window_id, rounded_window_id); + assert_eq!(exact_window_id as f64, rounded_window_id as f64); + + let windows = vec![window( + exact_window_id, + "Untitled — Kate", + "org.kde.kate", + "org.kde.kate", + )]; + + let matched = resolve_window_target( + &windows, + &WindowTarget { + window_id: Some(rounded_window_id), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(matched.window_id, exact_window_id); + } + + #[test] + fn pid_target_reports_ambiguous_matches() { + let mut first = window(1, "Ghostty One", "com.mitchellh.ghostty.desktop", "Ghostty"); + let mut second = window(2, "Ghostty Two", "com.mitchellh.ghostty.desktop", "Ghostty"); + first.pid = Some(300); + second.pid = Some(300); + + let error = resolve_window_target( + &[first, second], + &WindowTarget { + pid: Some(300), + ..Default::default() + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("pid 300 matched multiple windows")); + } + + #[test] + fn resolves_target_by_title_substring_case_insensitive() { + let windows = vec![window( + 2, + "avifenesh@host: ~/projects/codex", + "com.mitchellh.ghostty.desktop", + "Ghostty", + )]; + + let matched = resolve_window_target( + &windows, + &WindowTarget { + title: Some("PROJECTS/CODEX".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(matched.window_id, 2); + } + + #[test] + fn resolves_terminal_target_by_tty() { + let windows = vec![ + terminal_window(1, "Claude", "/dev/pts/0", 101, "claude", "/tmp"), + terminal_window(2, "Codex", "/dev/pts/1", 201, "codex", "/home/avifenesh"), + ]; + + let matched = resolve_window_target( + &windows, + &WindowTarget { + tty: Some("pts/1".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(matched.window_id, 2); + } + + #[test] + fn resolves_terminal_target_by_active_command() { + let windows = vec![ + terminal_window(1, "Claude", "/dev/pts/0", 101, "claude", "/tmp"), + terminal_window(2, "Codex", "/dev/pts/1", 201, "codex", "/home/avifenesh"), + ]; + + let matched = resolve_window_target( + &windows, + &WindowTarget { + terminal_command: Some("codex resume".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(matched.window_id, 2); + } + + #[test] + fn resolves_terminal_target_by_cwd_suffix() { + let windows = vec![ + terminal_window(1, "Home", "/dev/pts/0", 101, "zsh", "/home/avifenesh"), + terminal_window( + 2, + "Project", + "/dev/pts/1", + 201, + "codex", + "/home/avifenesh/projects/codex-app-linux", + ), + ]; + + let matched = resolve_window_target( + &windows, + &WindowTarget { + terminal_cwd: Some("projects/codex-app-linux".to_string()), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(matched.window_id, 2); + } + + #[test] + fn terminal_cwd_does_not_match_arbitrary_substrings() { + let windows = vec![terminal_window( + 1, + "Project", + "/dev/pts/1", + 201, + "codex", + "/home/avifenesh/projects/codex-app-linux", + )]; + + let error = resolve_window_target( + &windows, + &WindowTarget { + terminal_cwd: Some("fenesh/proj".to_string()), + ..Default::default() + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("No window matched terminal target")); + } + + #[test] + fn terminal_target_reports_ambiguous_matches() { + let windows = vec![ + terminal_window(1, "One", "/dev/pts/0", 101, "zsh", "/home/avifenesh"), + terminal_window(2, "Two", "/dev/pts/1", 201, "zsh", "/home/avifenesh"), + ]; + + let error = resolve_window_target( + &windows, + &WindowTarget { + terminal_command: Some("zsh".to_string()), + ..Default::default() + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("matched multiple windows")); + } + + #[test] + fn maps_access_denied_errors_to_permission_hint() { + let hint = window_permission_hint( + "GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: GetWindows is not allowed", + ); + + assert_eq!(hint.as_deref(), Some(WINDOW_PERMISSION_HINT)); + } + + #[test] + fn parses_hyprland_clients_as_window_info() { + let clients_json = r#"[ + { + "address": "0x559952b6db60", + "mapped": true, + "hidden": false, + "at": [10, 48], + "size": [1900, 1022], + "workspace": {"id": 2, "name": "2"}, + "class": "brave-browser", + "title": "Repo - Brave", + "pid": 24134, + "xwayland": false, + "focusHistoryID": 1 + }, + { + "address": "0x559952be43d0", + "mapped": true, + "hidden": false, + "at": [10, 48], + "size": [1900, 1022], + "workspace": {"id": 1, "name": "1"}, + "class": "codex-app", + "title": "Codex", + "pid": 68986, + "xwayland": false, + "focusHistoryID": 0 + }, + { + "address": "0x559952c99aa0", + "mapped": true, + "hidden": false, + "at": [0, 0], + "size": [400, 300], + "workspace": {"id": 3, "name": "3"}, + "class": "transient", + "title": "Transient", + "pid": -1, + "xwayland": false, + "focusHistoryID": 2 + } + ]"#; + + let windows = parse_hyprland_clients(clients_json).unwrap(); + + assert_eq!(windows.len(), 3); + assert_eq!(windows[0].window_id, 0x559952b6db60); + assert_eq!(windows[0].app_id.as_deref(), Some("brave-browser")); + assert_eq!(windows[0].wm_class.as_deref(), Some("brave-browser")); + assert_eq!(windows[0].title.as_deref(), Some("Repo - Brave")); + assert_eq!(windows[0].pid, Some(24134)); + assert_eq!(windows[0].bounds.as_ref().unwrap().x, Some(10)); + assert_eq!(windows[0].bounds.as_ref().unwrap().height, 1022); + assert_eq!(windows[0].workspace, Some(2)); + assert!(!windows[0].focused); + assert_eq!(windows[0].client_type.as_deref(), Some("wayland")); + assert_eq!(windows[0].backend, HYPRLAND_BACKEND); + assert!(windows[1].focused); + assert_eq!(windows[2].pid, None); + } + + #[test] + fn parses_i3_tree_as_window_info() { + let tree_json = r#"{ + "type": "root", + "focused": false, + "window": null, + "nodes": [ + { + "type": "output", + "focused": false, + "window": null, + "nodes": [ + { + "type": "dockarea", + "focused": false, + "window": null, + "nodes": [ + { + "type": "con", + "focused": false, + "window": 25165826, + "window_type": "unknown", + "name": "polybar", + "window_properties": { + "class": "Polybar", + "instance": "polybar", + "title": "polybar" + }, + "rect": {"x": 0, "y": 0, "width": 2560, "height": 40} + } + ] + }, + { + "type": "workspace", + "num": 2, + "focused": false, + "window": null, + "nodes": [ + { + "type": "con", + "focused": true, + "window": 67108868, + "window_type": "normal", + "name": "Codex", + "window_properties": { + "class": "Codex", + "instance": "codex", + "title": "Codex" + }, + "rect": {"x": 0, "y": 782, "width": 2560, "height": 1440} + } + ], + "floating_nodes": [ + { + "type": "con", + "focused": false, + "window": 73400323, + "window_type": "dialog", + "name": "Save File", + "window_properties": { + "class": "zenity", + "instance": "zenity", + "title": "Save File" + }, + "geometry": {"x": 100, "y": 120, "width": 600, "height": 400} + } + ] + } + ] + } + ] + }"#; + + let windows = parse_i3_tree(tree_json).unwrap(); + + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].window_id, 67108868); + assert_eq!(windows[0].title.as_deref(), Some("Codex")); + assert_eq!(windows[0].app_id.as_deref(), Some("codex")); + assert_eq!(windows[0].wm_class.as_deref(), Some("Codex")); + assert_eq!(windows[0].workspace, Some(2)); + assert!(windows[0].focused); + assert_eq!(windows[0].client_type.as_deref(), Some("x11")); + assert_eq!(windows[0].backend, I3_BACKEND); + assert_eq!(windows[0].bounds.as_ref().unwrap().x, Some(0)); + assert_eq!(windows[1].title.as_deref(), Some("Save File")); + assert_eq!(windows[1].bounds.as_ref().unwrap().width, 600); + } + + #[test] + fn parses_xprop_pid() { + assert_eq!( + parse_xprop_pid("_NET_WM_PID(CARDINAL) = 19313\n"), + Some(19313) + ); + assert_eq!(parse_xprop_pid("_NET_WM_PID: not found.\n"), None); + } + + #[test] + fn parses_kwin_windows_as_window_info() { + let uuid = "b4dfacf8-a559-43c9-8b1f-ecd5cfd78359"; + let windows_json = r#"{ + "backend": "kwin", + "windows": [ + { + "uuid": "{b4dfacf8-a559-43c9-8b1f-ecd5cfd78359}", + "caption": "Codex", + "desktopFile": "codex-app", + "resourceClass": "codex-app", + "resourceName": "codex", + "pid": 68986, + "x": 10, + "y": 48, + "width": 1200, + "height": 800, + "workspace": 1, + "minimized": false, + "active": true, + "clientType": "wayland", + "normalWindow": true, + "desktopWindow": false, + "dock": false + }, + { + "uuid": "{11111111-2222-3333-4444-555555555555}", + "caption": "Desktop", + "desktopWindow": true + } + ] + }"#; + + let windows = parse_kwin_windows(windows_json).unwrap(); + + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].window_id, kwin_window_id_from_uuid(uuid)); + assert_eq!(windows[0].title.as_deref(), Some("Codex")); + assert_eq!(windows[0].app_id.as_deref(), Some("codex-app")); + assert_eq!(windows[0].wm_class.as_deref(), Some("codex-app")); + assert_eq!(windows[0].pid, Some(68986)); + assert_eq!(windows[0].bounds.as_ref().unwrap().x, Some(10)); + assert_eq!(windows[0].bounds.as_ref().unwrap().height, 800); + assert_eq!(windows[0].workspace, Some(1)); + assert!(windows[0].focused); + assert!(!windows[0].hidden); + assert_eq!(windows[0].client_type.as_deref(), Some("wayland")); + assert_eq!(windows[0].backend, KWIN_BACKEND); + } + + #[test] + fn kwin_window_ids_are_stable_across_uuid_formats() { + let bare = "b4dfacf8-a559-43c9-8b1f-ecd5cfd78359"; + let braced_upper = "{B4DFACF8-A559-43C9-8B1F-ECD5CFD78359}"; + + assert_eq!( + kwin_window_id_from_uuid(bare), + kwin_window_id_from_uuid(braced_upper) + ); + } + + #[test] + fn kwin_activation_script_focuses_window_directly() { + let script = kwin_activate_script_source( + ":1.234", + "/com/openai/Codex/KWinWindowQuery/test", + "codex_kwin_window_query_test", + "{B4DFACF8-A559-43C9-8B1F-ECD5CFD78359}", + ) + .unwrap(); + + assert!(script.contains(r#"var targetUuid = "b4dfacf8-a559-43c9-8b1f-ecd5cfd78359";"#)); + assert!(script.contains("targetWindow.minimized = false;")); + assert!(script.contains("workspace.activeWindow = targetWindow;")); + assert!(script.contains(r#""ReceiveResult""#)); + assert!(!script.contains("WindowsRunner")); + } + + #[test] + fn hyprland_backend_can_exact_focus_targets() { + let mut window = window(2, "Codex", "codex-app", "codex-app"); + window.backend = HYPRLAND_BACKEND.to_string(); + + ensure_backend_can_focus_target( + &WindowTarget { + title: Some("Codex".to_string()), + ..Default::default() + }, + &window, + ) + .unwrap(); + } + + #[test] + fn kwin_backend_can_exact_focus_targets() { + let mut window = window(2, "Codex", "codex-app", "codex-app"); + window.backend = KWIN_BACKEND.to_string(); + + ensure_backend_can_focus_target( + &WindowTarget { + title: Some("Codex".to_string()), + ..Default::default() + }, + &window, + ) + .unwrap(); + } + + #[test] + fn extracts_known_window_properties() { + let properties = HashMap::from([ + ("title".to_string(), owned_value(Value::from("Ghostty"))), + ( + "app-id".to_string(), + owned_value(Value::from("com.mitchellh.ghostty.desktop")), + ), + ("wm-class".to_string(), owned_value(Value::from("Ghostty"))), + ("client-type".to_string(), owned_value(Value::from(0_u32))), + ("is-hidden".to_string(), owned_value(Value::from(false))), + ("has-focus".to_string(), owned_value(Value::from(true))), + ("width".to_string(), owned_value(Value::from(1200_u32))), + ("height".to_string(), owned_value(Value::from(800_u32))), + ]); + + let info = window_from_properties(42, &properties); + + assert_eq!(info.window_id, 42); + assert_eq!(info.title.as_deref(), Some("Ghostty")); + assert!(info.focused); + assert_eq!(info.client_type.as_deref(), Some("wayland")); + assert_eq!(info.bounds.unwrap().width, 1200); + } +} diff --git a/computer-use-linux/src/windowing/registry.rs b/computer-use-linux/src/windowing/registry.rs new file mode 100644 index 00000000..789ec5d0 --- /dev/null +++ b/computer-use-linux/src/windowing/registry.rs @@ -0,0 +1,292 @@ +use crate::windowing::backends::{cosmic, gnome, hyprland, i3, kwin}; +use crate::windowing::types::WindowInfo; +use anyhow::{anyhow, Result}; + +pub use cosmic::COSMIC_WAYLAND_BACKEND; +pub use gnome::{GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND}; +pub use hyprland::HYPRLAND_BACKEND; +pub use i3::I3_BACKEND; +pub use kwin::KWIN_BACKEND; + +pub const WINDOW_PERMISSION_HINT: &str = "Computer Use could not access a supported window list backend. Targeted window input requires session-bus access plus GNOME Shell Introspect, the Codex GNOME Shell extension, the COSMIC Wayland helper, KWin/Plasma DBus scripting, Hyprland hyprctl, or i3-msg. On GNOME, run setup_window_targeting to install the extension backend."; + +#[derive(Debug, Clone, Copy)] +pub struct BackendDescriptor { + pub id: &'static str, + pub failure_label: &'static str, + pub list_note: &'static str, + pub missing_hint: &'static str, + pub can_exact_focus: bool, +} + +#[derive(Debug, Clone)] +pub struct BackendProbe { + pub id: &'static str, + pub ok: bool, + pub can_list_windows: bool, + pub can_focus_apps: bool, + pub can_focus_windows: bool, + pub detail: String, +} + +#[derive(Debug, Clone, Copy)] +enum BackendKind { + GnomeExtension, + GnomeIntrospect, + Cosmic, + Kwin, + Hyprland, + I3, +} + +const BACKEND_ORDER: &[BackendKind] = &[ + BackendKind::GnomeExtension, + BackendKind::GnomeIntrospect, + BackendKind::Cosmic, + BackendKind::Kwin, + BackendKind::Hyprland, + BackendKind::I3, +]; + +const DESCRIPTORS: &[BackendDescriptor] = &[ + BackendDescriptor { + id: GNOME_SHELL_EXTENSION_BACKEND, + failure_label: "Codex GNOME Shell extension", + list_note: "Window list came from the Codex GNOME Shell extension. Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On GNOME, run setup_window_targeting to install the optional GNOME Shell extension backend.", + can_exact_focus: true, + }, + BackendDescriptor { + id: GNOME_SHELL_INTROSPECT_BACKEND, + failure_label: "GNOME Shell Introspect", + list_note: "Window list came from GNOME Shell Introspect. Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On GNOME, ensure org.gnome.Shell.Introspect is available on the session bus.", + can_exact_focus: false, + }, + BackendDescriptor { + id: COSMIC_WAYLAND_BACKEND, + failure_label: "COSMIC helper", + list_note: "Window list came from the COSMIC Wayland helper. Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On COSMIC, ensure the bundled COSMIC helper is present and can connect to the session.", + can_exact_focus: true, + }, + BackendDescriptor { + id: KWIN_BACKEND, + failure_label: "KWin", + list_note: "Window list came from KWin/Plasma DBus scripting. Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On KDE/Plasma, ensure KWin exposes org.kde.KWin scripting on the session bus.", + can_exact_focus: true, + }, + BackendDescriptor { + id: HYPRLAND_BACKEND, + failure_label: "Hyprland", + list_note: "Window list came from Hyprland hyprctl. Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On Hyprland, ensure hyprctl is available in the session.", + can_exact_focus: true, + }, + BackendDescriptor { + id: I3_BACKEND, + failure_label: "i3", + list_note: "Window list came from i3-msg. Terminal windows may include best-effort PTY and active-process context when xprop and the process tree are readable.", + missing_hint: "On i3, ensure i3-msg can reach the active i3 IPC socket.", + can_exact_focus: true, + }, +]; + +pub fn descriptors() -> &'static [BackendDescriptor] { + DESCRIPTORS +} + +pub fn descriptor(id: &str) -> Option<&'static BackendDescriptor> { + DESCRIPTORS.iter().find(|descriptor| descriptor.id == id) +} + +pub fn list_note(id: &str) -> &'static str { + descriptor(id) + .map(|descriptor| descriptor.list_note) + .unwrap_or_else(|| { + descriptor(GNOME_SHELL_INTROSPECT_BACKEND) + .expect("GNOME_SHELL_INTROSPECT_BACKEND must exist in DESCRIPTORS") + .list_note + }) +} + +pub fn backend_can_exact_focus(id: &str) -> bool { + descriptor(id).is_some_and(|descriptor| descriptor.can_exact_focus) +} + +pub async fn list_windows() -> Result> { + let mut errors = Vec::new(); + let mut saw_empty_success = false; + for backend in BACKEND_ORDER { + match usable_backend_windows(*backend, list_windows_for(*backend).await, &mut errors) { + BackendWindowResult::Windows(windows) => return Ok(windows), + BackendWindowResult::Empty => saw_empty_success = true, + BackendWindowResult::Unavailable => {} + } + } + if saw_empty_success { + return Ok(Vec::new()); + } + Err(anyhow!(errors.join("; "))) +} + +#[derive(Debug)] +enum BackendWindowResult { + Windows(Vec), + Empty, + Unavailable, +} + +fn usable_backend_windows( + backend: BackendKind, + result: Result>, + errors: &mut Vec, +) -> BackendWindowResult { + match result { + Ok(windows) if !windows.is_empty() => BackendWindowResult::Windows(windows), + Ok(_) => BackendWindowResult::Empty, + Err(error) => { + errors.push(format!("{} failed: {error:#}", backend.failure_label())); + BackendWindowResult::Unavailable + } + } +} + +async fn list_windows_for(backend: BackendKind) -> Result> { + match backend { + BackendKind::GnomeExtension => gnome::list_extension_windows().await, + BackendKind::GnomeIntrospect => gnome::list_introspect_windows().await, + BackendKind::Cosmic => cosmic::list_windows(), + BackendKind::Kwin => kwin::list_windows().await, + BackendKind::Hyprland => hyprland::list_windows(), + BackendKind::I3 => i3::list_windows(), + } +} + +pub async fn activate_window(window: &WindowInfo) -> Result<()> { + match window.backend.as_str() { + GNOME_SHELL_EXTENSION_BACKEND => gnome::activate_extension_window(window.window_id).await, + GNOME_SHELL_INTROSPECT_BACKEND => { + let app_id = window + .app_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow!( + "GNOME Shell can only focus by app_id; the matched window has no app_id" + ) + })?; + gnome::focus_app(app_id).await + } + COSMIC_WAYLAND_BACKEND => cosmic::activate_window(window.window_id), + KWIN_BACKEND => kwin::activate_window(window.window_id).await, + HYPRLAND_BACKEND => hyprland::activate_window(window.window_id), + I3_BACKEND => i3::activate_window(window.window_id), + backend => Err(anyhow!( + "Unsupported window backend for activation: {backend}" + )), + } +} + +pub fn focused_window_override() -> Option { + cosmic::focused_window().ok().flatten() +} + +pub fn probe_backends() -> Vec { + vec![ + gnome::probe_extension(), + gnome::probe_introspect(), + cosmic::probe(), + kwin::probe(), + hyprland::probe(), + i3::probe(), + ] +} + +impl BackendKind { + fn id(self) -> &'static str { + match self { + BackendKind::GnomeExtension => GNOME_SHELL_EXTENSION_BACKEND, + BackendKind::GnomeIntrospect => GNOME_SHELL_INTROSPECT_BACKEND, + BackendKind::Cosmic => COSMIC_WAYLAND_BACKEND, + BackendKind::Kwin => KWIN_BACKEND, + BackendKind::Hyprland => HYPRLAND_BACKEND, + BackendKind::I3 => I3_BACKEND, + } + } + + fn failure_label(self) -> &'static str { + descriptor(self.id()) + .map(|item| item.failure_label) + .unwrap_or(self.id()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::windowing::types::WindowBounds; + + fn window(backend: &str) -> WindowInfo { + WindowInfo { + window_id: 1, + title: Some("Codex".to_string()), + app_id: Some("codex-app".to_string()), + wm_class: Some("codex-app".to_string()), + pid: Some(1234), + bounds: Some(WindowBounds { + x: Some(0), + y: Some(0), + width: 800, + height: 600, + }), + workspace: None, + focused: true, + hidden: false, + client_type: Some("wayland".to_string()), + backend: backend.to_string(), + terminal: None, + } + } + + #[test] + fn skips_empty_backend_results_so_later_backends_can_answer() { + let mut errors = Vec::new(); + + assert!(matches!( + usable_backend_windows(BackendKind::GnomeIntrospect, Ok(Vec::new()), &mut errors,), + BackendWindowResult::Empty + )); + assert!(errors.is_empty()); + + let result = usable_backend_windows( + BackendKind::Kwin, + Ok(vec![window(KWIN_BACKEND)]), + &mut errors, + ); + + let BackendWindowResult::Windows(windows) = result else { + panic!("non-empty backend result should be accepted"); + }; + assert_eq!(windows[0].backend, KWIN_BACKEND); + assert!(errors.is_empty()); + } + + #[test] + fn records_backend_failures_with_registry_labels() { + let mut errors = Vec::new(); + + assert!(matches!( + usable_backend_windows( + BackendKind::Kwin, + Err(anyhow!("loadScript failed")), + &mut errors, + ), + BackendWindowResult::Unavailable + )); + + assert_eq!(errors, vec!["KWin failed: loadScript failed"]); + } +} diff --git a/computer-use-linux/src/windowing/target.rs b/computer-use-linux/src/windowing/target.rs new file mode 100644 index 00000000..8892196c --- /dev/null +++ b/computer-use-linux/src/windowing/target.rs @@ -0,0 +1,367 @@ +use crate::windowing::registry::{self, WINDOW_PERMISSION_HINT}; +use crate::windowing::types::{WindowFocusResult, WindowInfo, WindowTarget}; +use anyhow::{bail, Result}; +use tokio::time::{sleep, Duration}; + +const FOCUS_VERIFY_ATTEMPTS: usize = 6; +const FOCUS_VERIFY_DELAY: Duration = Duration::from_millis(50); + +pub async fn list_windows() -> Result> { + registry::list_windows().await +} + +pub async fn focused_window() -> Result> { + current_focused_window().await +} + +pub async fn focus_window_target(target: &WindowTarget) -> Result { + if !target.has_target() { + bail!("Pass window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd to target a window."); + } + + let windows = list_windows().await?; + let requested_window = resolve_window_target(&windows, target)?.clone(); + ensure_backend_can_focus_target(target, &requested_window)?; + + registry::activate_window(&requested_window).await?; + + let focused_window = wait_for_focused_window(&requested_window).await; + let exact_window_focused = focused_window + .as_ref() + .is_some_and(|window| window.window_id == requested_window.window_id); + let app_focused = focused_window + .as_ref() + .is_some_and(|window| same_optional_string(&window.app_id, &requested_window.app_id)); + + Ok(WindowFocusResult { + backend: requested_window.backend.clone(), + requested_window, + focused_window, + exact_window_focused, + app_focused, + note: "Computer Use activated the requested window through the available window backend, then verified focus through a fresh window query." + .to_string(), + }) +} + +pub(crate) fn ensure_backend_can_focus_target( + target: &WindowTarget, + window: &WindowInfo, +) -> Result<()> { + if target.requires_exact_focus() && !registry::backend_can_exact_focus(&window.backend) { + bail!( + "Exact window targeting requires an exact-focus window backend; {} can list the matched window but cannot activate a specific window safely.", + window.backend + ); + } + Ok(()) +} + +async fn current_focused_window() -> Result> { + if let Some(window) = registry::focused_window_override() { + return Ok(Some(window)); + } + + Ok(list_windows() + .await? + .into_iter() + .find(|window| window.focused)) +} + +async fn wait_for_focused_window(requested_window: &WindowInfo) -> Option { + let mut last_focused_window = None; + for attempt in 0..FOCUS_VERIFY_ATTEMPTS { + if let Ok(focused_window) = current_focused_window().await { + if focused_window + .as_ref() + .is_some_and(|window| window.window_id == requested_window.window_id) + { + return focused_window; + } + if focused_window.is_some() { + last_focused_window = focused_window; + } + } + + if attempt + 1 < FOCUS_VERIFY_ATTEMPTS { + sleep(FOCUS_VERIFY_DELAY).await; + } + } + last_focused_window +} + +pub fn resolve_window_target<'a>( + windows: &'a [WindowInfo], + target: &WindowTarget, +) -> Result<&'a WindowInfo> { + if let Some(window_id) = target.window_id { + if !window_matches_secondary_target_selector_present(target) { + return resolve_window_id_target(windows, window_id); + } + let matches = windows + .iter() + .filter(|window| { + window.window_id == window_id + || window_id_matches_json_number(window.window_id, window_id) + }) + .filter(|window| window_matches_secondary_target(window, target)) + .collect::>(); + return unique_window_match(matches, &format!("window_id {window_id}")); + } + + if target.has_terminal_target() { + let matches = windows + .iter() + .filter(|window| window_matches_terminal_target(window, target)) + .filter(|window| target.pid.is_none_or(|pid| window.pid == Some(pid))) + .filter(|window| optional_exact_match(&window.app_id, target.app_id.as_deref())) + .filter(|window| optional_exact_match(&window.wm_class, target.wm_class.as_deref())) + .filter(|window| optional_title_match(&window.title, target.title.as_deref())) + .collect::>(); + return unique_window_match(matches, "terminal target"); + } + + if let Some(pid) = target.pid { + let matches = windows + .iter() + .filter(|window| window.pid == Some(pid)) + .filter(|window| window_matches_secondary_target(window, target)) + .collect::>(); + return unique_window_match(matches, &format!("pid {pid}")); + } + + if let Some(app_id) = normalized_target(target.app_id.as_deref()) { + let matches = windows + .iter() + .filter(|window| { + window + .app_id + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(&app_id)) + }) + .filter(|window| window_matches_secondary_target(window, target)) + .collect::>(); + return unique_window_match(matches, &format!("app_id {app_id}")); + } + + if let Some(wm_class) = normalized_target(target.wm_class.as_deref()) { + let matches = windows + .iter() + .filter(|window| { + window + .wm_class + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(&wm_class)) + }) + .filter(|window| window_matches_secondary_target(window, target)) + .collect::>(); + return unique_window_match(matches, &format!("wm_class {wm_class}")); + } + + if let Some(title) = normalized_target(target.title.as_deref()) { + let title_lower = title.to_ascii_lowercase(); + let matches = windows + .iter() + .filter(|window| { + window + .title + .as_deref() + .is_some_and(|value| value.to_ascii_lowercase().contains(&title_lower)) + }) + .filter(|window| window_matches_secondary_target(window, target)) + .collect::>(); + return unique_window_match(matches, &format!("title {title}")); + } + + bail!("Pass window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd to target a window."); +} + +fn resolve_window_id_target(windows: &[WindowInfo], window_id: u64) -> Result<&WindowInfo> { + if let Some(window) = windows.iter().find(|window| window.window_id == window_id) { + return Ok(window); + } + + let matches = windows + .iter() + .filter(|window| window_id_matches_json_number(window.window_id, window_id)) + .collect::>(); + match matches.as_slice() { + [window] => Ok(*window), + [] => Err(anyhow::anyhow!("No window matched window_id {window_id}.")), + windows => { + let ids = windows + .iter() + .map(|window| window.window_id.to_string()) + .collect::>() + .join(", "); + bail!( + "window_id {window_id} matched multiple windows after JSON number rounding ({ids}); add title, pid, app_id, or wm_class to disambiguate." + ); + } + } +} + +fn window_matches_secondary_target(window: &WindowInfo, target: &WindowTarget) -> bool { + target.pid.is_none_or(|pid| window.pid == Some(pid)) + && optional_exact_match(&window.app_id, target.app_id.as_deref()) + && optional_exact_match(&window.wm_class, target.wm_class.as_deref()) + && optional_title_match(&window.title, target.title.as_deref()) + && (!target.has_terminal_target() || window_matches_terminal_target(window, target)) +} + +fn window_matches_secondary_target_selector_present(target: &WindowTarget) -> bool { + target.pid.is_some() + || normalized_target(target.app_id.as_deref()).is_some() + || normalized_target(target.wm_class.as_deref()).is_some() + || normalized_target(target.title.as_deref()).is_some() + || target.has_terminal_target() +} + +fn window_id_matches_json_number(actual: u64, requested: u64) -> bool { + const JS_SAFE_INTEGER_MAX: u64 = (1_u64 << 53) - 1; + (actual > JS_SAFE_INTEGER_MAX || requested > JS_SAFE_INTEGER_MAX) + && (actual as f64) == (requested as f64) +} + +fn unique_window_match<'a>( + matches: Vec<&'a WindowInfo>, + description: &str, +) -> Result<&'a WindowInfo> { + match matches.as_slice() { + [window] => Ok(*window), + [] => bail!("No window matched {description}."), + windows => { + let ids = windows + .iter() + .map(|window| window.window_id.to_string()) + .collect::>() + .join(", "); + bail!( + "{description} matched multiple windows ({ids}); add window_id, tty, title, or terminal_command to disambiguate." + ); + } + } +} + +fn window_matches_terminal_target(window: &WindowInfo, target: &WindowTarget) -> bool { + let Some(terminal) = &window.terminal else { + return false; + }; + + if let Some(tty) = normalized_target(target.tty.as_deref()) { + if !tty_matches(&terminal.tty, &tty) { + return false; + } + } + + if let Some(pid) = target.terminal_pid { + let active_pid = terminal.active_process.as_ref().map(|process| process.pid); + if active_pid != Some(pid) && terminal.root_process.pid != pid { + return false; + } + } + + if let Some(command) = normalized_target(target.terminal_command.as_deref()) { + let command = command.to_ascii_lowercase(); + let active_matches = terminal + .active_process + .as_ref() + .is_some_and(|process| terminal_process_matches_command(process, &command)); + if !active_matches && !terminal_process_matches_command(&terminal.root_process, &command) { + return false; + } + } + + if let Some(cwd) = normalized_target(target.terminal_cwd.as_deref()) { + let active_matches = terminal + .active_process + .as_ref() + .is_some_and(|process| terminal_process_matches_cwd(process, &cwd)); + if !active_matches && !terminal_process_matches_cwd(&terminal.root_process, &cwd) { + return false; + } + } + + true +} + +fn terminal_process_matches_command( + process: &crate::terminal::TerminalProcess, + command_lower: &str, +) -> bool { + process + .command_name + .to_ascii_lowercase() + .contains(command_lower) + || process + .command_line + .to_ascii_lowercase() + .contains(command_lower) +} + +fn terminal_process_matches_cwd(process: &crate::terminal::TerminalProcess, cwd: &str) -> bool { + let requested = cwd.trim_end_matches('/'); + process.cwd.as_deref().is_some_and(|value| { + let actual = value.trim_end_matches('/'); + actual == requested + || (!requested.starts_with('/') + && actual + .strip_suffix(requested) + .is_some_and(|prefix| prefix.ends_with('/'))) + }) +} + +fn tty_matches(actual: &str, requested: &str) -> bool { + actual == requested + || actual + .strip_prefix("/dev/") + .is_some_and(|value| value == requested) + || actual + .strip_prefix("/dev/pts/") + .is_some_and(|value| value == requested) +} + +fn optional_exact_match(actual: &Option, requested: Option<&str>) -> bool { + normalized_target(requested).is_none_or(|requested| { + actual + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(&requested)) + }) +} + +fn optional_title_match(actual: &Option, requested: Option<&str>) -> bool { + normalized_target(requested).is_none_or(|requested| { + let requested = requested.to_ascii_lowercase(); + actual + .as_deref() + .is_some_and(|value| value.to_ascii_lowercase().contains(&requested)) + }) +} + +pub fn window_permission_hint(error: &str) -> Option { + let lower = error.to_ascii_lowercase(); + if lower.contains("accessdenied") + || lower.contains("access denied") + || lower.contains("not allowed") + || lower.contains("operation not permitted") + || lower.contains("failed to connect to session bus") + { + Some(WINDOW_PERMISSION_HINT.to_string()) + } else { + None + } +} + +fn normalized_target(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn same_optional_string(left: &Option, right: &Option) -> bool { + match (left.as_deref(), right.as_deref()) { + (Some(left), Some(right)) => left.eq_ignore_ascii_case(right), + _ => false, + } +} diff --git a/computer-use-linux/src/windowing/types.rs b/computer-use-linux/src/windowing/types.rs new file mode 100644 index 00000000..8282d94e --- /dev/null +++ b/computer-use-linux/src/windowing/types.rs @@ -0,0 +1,106 @@ +use crate::terminal::TerminalWindowContext; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct WindowInfo { + pub window_id: u64, + pub title: Option, + pub app_id: Option, + pub wm_class: Option, + pub pid: Option, + pub bounds: Option, + pub workspace: Option, + pub focused: bool, + pub hidden: bool, + pub client_type: Option, + pub backend: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct WindowBounds { + pub x: Option, + pub y: Option, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] +pub struct WindowTarget { + #[serde(default)] + pub window_id: Option, + #[serde(default)] + pub pid: Option, + #[serde(default)] + pub tty: Option, + #[serde(default)] + pub terminal_pid: Option, + #[serde(default)] + pub terminal_command: Option, + #[serde(default)] + pub terminal_cwd: Option, + #[serde(default)] + pub app_id: Option, + #[serde(default)] + pub wm_class: Option, + #[serde(default)] + pub title: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct WindowFocusResult { + pub requested_window: WindowInfo, + pub focused_window: Option, + pub exact_window_focused: bool, + pub app_focused: bool, + pub backend: String, + pub note: String, +} + +impl WindowTarget { + pub fn has_target(&self) -> bool { + self.window_id.is_some() + || self.pid.is_some() + || self.has_terminal_target() + || self + .app_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .wm_class + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .title + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } + + pub fn requires_exact_focus(&self) -> bool { + self.window_id.is_some() + || self.pid.is_some() + || self.has_terminal_target() + || self + .title + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } + + pub(crate) fn has_terminal_target(&self) -> bool { + self.terminal_pid.is_some() + || self + .tty + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .terminal_command + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || self + .terminal_cwd + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } +} diff --git a/computer-use-linux/src/windows.rs b/computer-use-linux/src/windows.rs index 7650da94..c8a64d13 100644 --- a/computer-use-linux/src/windows.rs +++ b/computer-use-linux/src/windows.rs @@ -1,980 +1 @@ -use crate::diagnostics::hydrate_session_bus_env; -use crate::terminal::{enrich_terminal_windows, TerminalWindowContext}; -use anyhow::{bail, Context, Result}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, time::Duration}; -use tokio::time::sleep; -use zbus::{zvariant::OwnedValue, Proxy}; - -pub const GNOME_SHELL_INTROSPECT_BACKEND: &str = "gnome-shell-introspect"; -pub const GNOME_SHELL_EXTENSION_BACKEND: &str = "gnome-shell-extension"; -pub const GNOME_SHELL_EXTENSION_SERVICE: &str = "com.openai.Codex.WindowControl"; -pub const GNOME_SHELL_EXTENSION_OBJECT_PATH: &str = "/com/openai/Codex/WindowControl"; -pub const WINDOW_PERMISSION_HINT: &str = "Computer Use could not access a GNOME window list backend. Targeted window input requires session-bus access plus either GNOME Shell Introspect permission or the Codex GNOME Shell extension backend. Run setup-window-targeting to install the extension backend."; -const FOCUS_VERIFY_ATTEMPTS: usize = 6; -const FOCUS_VERIFY_DELAY: Duration = Duration::from_millis(50); - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -pub struct WindowInfo { - pub window_id: u64, - pub title: Option, - pub app_id: Option, - pub wm_class: Option, - pub pid: Option, - pub bounds: Option, - pub workspace: Option, - pub focused: bool, - pub hidden: bool, - pub client_type: Option, - pub backend: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub terminal: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -pub struct WindowBounds { - pub x: Option, - pub y: Option, - pub width: u32, - pub height: u32, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -pub struct WindowTarget { - #[serde(default)] - pub window_id: Option, - #[serde(default)] - pub pid: Option, - #[serde(default)] - pub tty: Option, - #[serde(default)] - pub terminal_pid: Option, - #[serde(default)] - pub terminal_command: Option, - #[serde(default)] - pub terminal_cwd: Option, - #[serde(default)] - pub app_id: Option, - #[serde(default)] - pub wm_class: Option, - #[serde(default)] - pub title: Option, -} - -#[derive(Debug, Clone, Serialize, JsonSchema)] -pub struct WindowFocusResult { - pub requested_window: WindowInfo, - pub focused_window: Option, - pub exact_window_focused: bool, - pub app_focused: bool, - pub backend: String, - pub note: String, -} - -impl WindowTarget { - pub fn has_target(&self) -> bool { - self.window_id.is_some() - || self.pid.is_some() - || self.has_terminal_target() - || self - .app_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || self - .wm_class - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || self - .title - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - } - - pub fn requires_exact_focus(&self) -> bool { - self.window_id.is_some() - || self.pid.is_some() - || self.has_terminal_target() - || self - .title - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - } - - fn has_terminal_target(&self) -> bool { - self.terminal_pid.is_some() - || self - .tty - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || self - .terminal_command - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || self - .terminal_cwd - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - } -} - -pub async fn list_windows() -> Result> { - match list_extension_windows().await { - Ok(windows) => Ok(windows), - Err(extension_error) => match list_gnome_shell_introspect_windows().await { - Ok(windows) => Ok(windows), - Err(introspect_error) => Err(anyhow::anyhow!( - "Codex GNOME Shell extension failed: {extension_error:#}; GNOME Shell Introspect failed: {introspect_error:#}" - )), - }, - } -} - -async fn list_gnome_shell_introspect_windows() -> Result> { - hydrate_session_bus_env(); - - let connection = zbus::Connection::session() - .await - .context("failed to connect to session bus")?; - let proxy = Proxy::new( - &connection, - "org.gnome.Shell", - "/org/gnome/Shell/Introspect", - "org.gnome.Shell.Introspect", - ) - .await - .context("failed to create GNOME Shell introspection proxy")?; - let windows: HashMap> = proxy - .call("GetWindows", &()) - .await - .context("GNOME Shell GetWindows call failed")?; - - let mut windows = windows - .into_iter() - .map(|(window_id, properties)| window_from_properties(window_id, &properties)) - .collect::>(); - windows.sort_by_key(|window| window.window_id); - enrich_terminal_windows(&mut windows); - Ok(windows) -} - -pub async fn list_extension_windows() -> Result> { - let json = call_extension_json("ListWindows").await?; - let mut windows: Vec = - serde_json::from_str(&json).context("Codex GNOME Shell extension returned invalid JSON")?; - for window in &mut windows { - window.backend = GNOME_SHELL_EXTENSION_BACKEND.to_string(); - } - windows.sort_by_key(|window| window.window_id); - enrich_terminal_windows(&mut windows); - Ok(windows) -} - -pub async fn focused_window() -> Result> { - current_focused_window().await -} - -pub async fn focus_window_target(target: &WindowTarget) -> Result { - if !target.has_target() { - bail!("Pass window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd to target a window."); - } - - let windows = list_windows().await?; - let requested_window = resolve_window_target(&windows, target)?.clone(); - ensure_backend_can_focus_target(target, &requested_window)?; - - if requested_window.backend == GNOME_SHELL_EXTENSION_BACKEND { - activate_extension_window(requested_window.window_id).await?; - } else { - let app_id = requested_window - .app_id - .as_deref() - .or(target.app_id.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .context("GNOME Shell can only focus by app_id; the matched window has no app_id")? - .to_string(); - focus_app(&app_id).await?; - } - - let focused_window = wait_for_focused_window(&requested_window).await; - let exact_window_focused = focused_window - .as_ref() - .is_some_and(|window| window.window_id == requested_window.window_id); - let app_focused = focused_window - .as_ref() - .is_some_and(|window| same_optional_string(&window.app_id, &requested_window.app_id)); - - Ok(WindowFocusResult { - backend: requested_window.backend.clone(), - requested_window, - focused_window, - exact_window_focused, - app_focused, - note: "Computer Use activated the requested window through the available window backend, then verified focus through a fresh window query." - .to_string(), - }) -} - -fn ensure_backend_can_focus_target(target: &WindowTarget, window: &WindowInfo) -> Result<()> { - if target.requires_exact_focus() && window.backend != GNOME_SHELL_EXTENSION_BACKEND { - bail!( - "Exact window targeting requires the Codex GNOME Shell extension backend; {} can list the matched window but cannot activate a specific window safely.", - window.backend - ); - } - Ok(()) -} - -async fn current_focused_window() -> Result> { - Ok(list_windows() - .await? - .into_iter() - .find(|window| window.focused)) -} - -async fn wait_for_focused_window(requested_window: &WindowInfo) -> Option { - let mut last_focused_window = None; - for attempt in 0..FOCUS_VERIFY_ATTEMPTS { - if let Ok(focused_window) = current_focused_window().await { - if focused_window - .as_ref() - .is_some_and(|window| window.window_id == requested_window.window_id) - { - return focused_window; - } - if focused_window.is_some() { - last_focused_window = focused_window; - } - } - - if attempt + 1 < FOCUS_VERIFY_ATTEMPTS { - sleep(FOCUS_VERIFY_DELAY).await; - } - } - last_focused_window -} - -pub fn resolve_window_target<'a>( - windows: &'a [WindowInfo], - target: &WindowTarget, -) -> Result<&'a WindowInfo> { - if let Some(window_id) = target.window_id { - return windows - .iter() - .find(|window| window.window_id == window_id) - .with_context(|| format!("No window matched window_id {window_id}.")); - } - - if target.has_terminal_target() { - let matches = windows - .iter() - .filter(|window| window_matches_terminal_target(window, target)) - .filter(|window| target.pid.is_none_or(|pid| window.pid == Some(pid))) - .filter(|window| optional_exact_match(&window.app_id, target.app_id.as_deref())) - .filter(|window| optional_exact_match(&window.wm_class, target.wm_class.as_deref())) - .filter(|window| optional_title_match(&window.title, target.title.as_deref())) - .collect::>(); - return unique_window_match(matches, "terminal target"); - } - - if let Some(pid) = target.pid { - let matches = windows - .iter() - .filter(|window| window.pid == Some(pid)) - .collect::>(); - return unique_window_match(matches, &format!("pid {pid}")); - } - - if let Some(app_id) = normalized_target(target.app_id.as_deref()) { - if let Some(window) = windows.iter().find(|window| { - window - .app_id - .as_deref() - .is_some_and(|value| value.eq_ignore_ascii_case(&app_id)) - }) { - return Ok(window); - } - bail!("No window matched app_id {app_id}."); - } - - if let Some(wm_class) = normalized_target(target.wm_class.as_deref()) { - if let Some(window) = windows.iter().find(|window| { - window - .wm_class - .as_deref() - .is_some_and(|value| value.eq_ignore_ascii_case(&wm_class)) - }) { - return Ok(window); - } - bail!("No window matched wm_class {wm_class}."); - } - - if let Some(title) = normalized_target(target.title.as_deref()) { - let title_lower = title.to_ascii_lowercase(); - return unique_window_match( - windows - .iter() - .filter(|window| { - window - .title - .as_deref() - .is_some_and(|value| value.to_ascii_lowercase().contains(&title_lower)) - }) - .collect(), - &format!("title containing {title}"), - ); - } - - bail!("Pass window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd to target a window."); -} - -fn unique_window_match<'a>( - matches: Vec<&'a WindowInfo>, - description: &str, -) -> Result<&'a WindowInfo> { - match matches.as_slice() { - [window] => Ok(*window), - [] => bail!("No window matched {description}."), - windows => { - let ids = windows - .iter() - .map(|window| window.window_id.to_string()) - .collect::>() - .join(", "); - bail!( - "{description} matched multiple windows ({ids}); add window_id, tty, title, or terminal_command to disambiguate." - ); - } - } -} - -fn window_matches_terminal_target(window: &WindowInfo, target: &WindowTarget) -> bool { - let Some(terminal) = &window.terminal else { - return false; - }; - - if let Some(tty) = normalized_target(target.tty.as_deref()) { - if !tty_matches(&terminal.tty, &tty) { - return false; - } - } - - if let Some(pid) = target.terminal_pid { - let active_pid = terminal.active_process.as_ref().map(|process| process.pid); - if active_pid != Some(pid) && terminal.root_process.pid != pid { - return false; - } - } - - if let Some(command) = normalized_target(target.terminal_command.as_deref()) { - let command = command.to_ascii_lowercase(); - let active_matches = terminal - .active_process - .as_ref() - .is_some_and(|process| terminal_process_matches_command(process, &command)); - if !active_matches && !terminal_process_matches_command(&terminal.root_process, &command) { - return false; - } - } - - if let Some(cwd) = normalized_target(target.terminal_cwd.as_deref()) { - let active_matches = terminal - .active_process - .as_ref() - .is_some_and(|process| terminal_process_matches_cwd(process, &cwd)); - if !active_matches && !terminal_process_matches_cwd(&terminal.root_process, &cwd) { - return false; - } - } - - true -} - -fn terminal_process_matches_command( - process: &crate::terminal::TerminalProcess, - command_lower: &str, -) -> bool { - process - .command_name - .to_ascii_lowercase() - .contains(command_lower) - || process - .command_line - .to_ascii_lowercase() - .contains(command_lower) -} - -fn terminal_process_matches_cwd(process: &crate::terminal::TerminalProcess, cwd: &str) -> bool { - let requested = cwd.trim_end_matches('/'); - process.cwd.as_deref().is_some_and(|value| { - let actual = value.trim_end_matches('/'); - actual == requested - || (!requested.starts_with('/') - && actual - .strip_suffix(requested) - .is_some_and(|prefix| prefix.ends_with('/'))) - }) -} - -fn tty_matches(actual: &str, requested: &str) -> bool { - actual == requested - || actual - .strip_prefix("/dev/") - .is_some_and(|value| value == requested) - || actual - .strip_prefix("/dev/pts/") - .is_some_and(|value| value == requested) -} - -fn optional_exact_match(actual: &Option, requested: Option<&str>) -> bool { - normalized_target(requested).is_none_or(|requested| { - actual - .as_deref() - .is_some_and(|value| value.eq_ignore_ascii_case(&requested)) - }) -} - -fn optional_title_match(actual: &Option, requested: Option<&str>) -> bool { - normalized_target(requested).is_none_or(|requested| { - let requested = requested.to_ascii_lowercase(); - actual - .as_deref() - .is_some_and(|value| value.to_ascii_lowercase().contains(&requested)) - }) -} - -pub fn window_permission_hint(error: &str) -> Option { - let lower = error.to_ascii_lowercase(); - if lower.contains("accessdenied") - || lower.contains("access denied") - || lower.contains("not allowed") - || lower.contains("operation not permitted") - || lower.contains("failed to connect to session bus") - { - Some(WINDOW_PERMISSION_HINT.to_string()) - } else { - None - } -} - -async fn focus_app(app_id: &str) -> Result<()> { - let connection = zbus::Connection::session() - .await - .context("failed to connect to session bus")?; - let proxy = Proxy::new( - &connection, - "org.gnome.Shell", - "/org/gnome/Shell", - "org.gnome.Shell", - ) - .await - .context("failed to create GNOME Shell proxy")?; - let _: () = proxy - .call("FocusApp", &(app_id)) - .await - .with_context(|| format!("GNOME Shell FocusApp failed for app_id {app_id}"))?; - Ok(()) -} - -async fn call_extension_json(method: &str) -> Result { - hydrate_session_bus_env(); - - let connection = zbus::Connection::session() - .await - .context("failed to connect to session bus")?; - let proxy = Proxy::new( - &connection, - GNOME_SHELL_EXTENSION_SERVICE, - GNOME_SHELL_EXTENSION_OBJECT_PATH, - GNOME_SHELL_EXTENSION_SERVICE, - ) - .await - .context("failed to create Codex GNOME Shell extension proxy")?; - let json: String = proxy - .call(method, &()) - .await - .with_context(|| format!("Codex GNOME Shell extension {method} call failed"))?; - Ok(json) -} - -async fn activate_extension_window(window_id: u64) -> Result<()> { - hydrate_session_bus_env(); - - let connection = zbus::Connection::session() - .await - .context("failed to connect to session bus")?; - let proxy = Proxy::new( - &connection, - GNOME_SHELL_EXTENSION_SERVICE, - GNOME_SHELL_EXTENSION_OBJECT_PATH, - GNOME_SHELL_EXTENSION_SERVICE, - ) - .await - .context("failed to create Codex GNOME Shell extension proxy")?; - let (ok, message): (bool, String) = proxy - .call("ActivateWindow", &(window_id)) - .await - .with_context(|| { - format!("Codex GNOME Shell extension ActivateWindow failed for {window_id}") - })?; - if ok { - Ok(()) - } else { - bail!("Codex GNOME Shell extension refused activation: {message}"); - } -} - -fn window_from_properties(window_id: u64, properties: &HashMap) -> WindowInfo { - let width = get_u32(properties, "width"); - let height = get_u32(properties, "height"); - let bounds = width.zip(height).map(|(width, height)| WindowBounds { - x: get_i32(properties, "x"), - y: get_i32(properties, "y"), - width, - height, - }); - - WindowInfo { - window_id, - title: get_string(properties, "title"), - app_id: get_string(properties, "app-id"), - wm_class: get_string(properties, "wm-class"), - pid: get_u32(properties, "pid"), - bounds, - workspace: get_i32(properties, "workspace"), - focused: get_bool(properties, "has-focus").unwrap_or(false), - hidden: get_bool(properties, "is-hidden").unwrap_or(false), - client_type: get_u32(properties, "client-type").map(client_type_name), - backend: GNOME_SHELL_INTROSPECT_BACKEND.to_string(), - terminal: None, - } -} - -fn get_string(properties: &HashMap, key: &str) -> Option { - properties - .get(key) - .and_then(|value| <&str>::try_from(value).ok()) - .map(ToOwned::to_owned) -} - -fn get_bool(properties: &HashMap, key: &str) -> Option { - properties - .get(key) - .and_then(|value| bool::try_from(value).ok()) -} - -fn get_u32(properties: &HashMap, key: &str) -> Option { - properties - .get(key) - .and_then(|value| u32::try_from(value).ok()) -} - -fn get_i32(properties: &HashMap, key: &str) -> Option { - properties.get(key).and_then(|value| { - i32::try_from(value).ok().or_else(|| { - u32::try_from(value) - .ok() - .and_then(|value| value.try_into().ok()) - }) - }) -} - -fn client_type_name(value: u32) -> String { - match value { - 0 => "wayland", - 1 => "x11", - _ => "unknown", - } - .to_string() -} - -fn normalized_target(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn same_optional_string(left: &Option, right: &Option) -> bool { - match (left.as_deref(), right.as_deref()) { - (Some(left), Some(right)) => left.eq_ignore_ascii_case(right), - _ => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::terminal::{TerminalProcess, TerminalWindowContext}; - use zbus::zvariant::Value; - - fn owned_value(value: Value<'_>) -> OwnedValue { - OwnedValue::try_from(value).unwrap() - } - - fn window(window_id: u64, title: &str, app_id: &str, wm_class: &str) -> WindowInfo { - WindowInfo { - window_id, - title: Some(title.to_string()), - app_id: Some(app_id.to_string()), - wm_class: Some(wm_class.to_string()), - pid: Some(window_id as u32 + 1000), - bounds: Some(WindowBounds { - x: None, - y: None, - width: 800, - height: 600, - }), - workspace: None, - focused: false, - hidden: false, - client_type: Some("wayland".to_string()), - backend: GNOME_SHELL_INTROSPECT_BACKEND.to_string(), - terminal: None, - } - } - - fn terminal_window( - window_id: u64, - title: &str, - tty: &str, - active_pid: u32, - active_command: &str, - active_cwd: &str, - ) -> WindowInfo { - let mut window = window( - window_id, - title, - "com.mitchellh.ghostty.desktop", - "com.mitchellh.ghostty", - ); - window.terminal = Some(TerminalWindowContext { - tty: tty.to_string(), - root_process: TerminalProcess { - pid: active_pid - 1, - command_name: "zsh".to_string(), - command_line: "zsh --login".to_string(), - cwd: Some("/home/avifenesh".to_string()), - }, - active_process: Some(TerminalProcess { - pid: active_pid, - command_name: active_command.to_string(), - command_line: format!("{active_command} resume 123"), - cwd: Some(active_cwd.to_string()), - }), - process_count: 2, - confidence: "heuristic".to_string(), - match_reason: "test".to_string(), - }); - window - } - - #[test] - fn target_reports_when_any_selector_is_present() { - assert!(!WindowTarget::default().has_target()); - assert!(WindowTarget { - title: Some("Ghostty".to_string()), - ..Default::default() - } - .has_target()); - assert!(WindowTarget { - tty: Some("/dev/pts/1".to_string()), - ..Default::default() - } - .has_target()); - } - - #[test] - fn title_pid_and_window_id_targets_require_exact_focus() { - assert!(WindowTarget { - title: Some("Ghostty".to_string()), - ..Default::default() - } - .requires_exact_focus()); - assert!(WindowTarget { - pid: Some(123), - ..Default::default() - } - .requires_exact_focus()); - assert!(WindowTarget { - window_id: Some(123), - ..Default::default() - } - .requires_exact_focus()); - assert!(WindowTarget { - terminal_command: Some("codex".to_string()), - ..Default::default() - } - .requires_exact_focus()); - assert!(!WindowTarget { - app_id: Some("com.mitchellh.ghostty.desktop".to_string()), - ..Default::default() - } - .requires_exact_focus()); - } - - #[test] - fn exact_targets_require_extension_activation_backend() { - let window = window( - 2, - "Ghostty", - "com.mitchellh.ghostty.desktop", - "com.mitchellh.ghostty", - ); - - let error = ensure_backend_can_focus_target( - &WindowTarget { - terminal_command: Some("codex".to_string()), - ..Default::default() - }, - &window, - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("Exact window targeting requires")); - } - - #[test] - fn app_targets_can_use_app_level_focus_backend() { - let window = window( - 2, - "Ghostty", - "com.mitchellh.ghostty.desktop", - "com.mitchellh.ghostty", - ); - - ensure_backend_can_focus_target( - &WindowTarget { - app_id: Some("com.mitchellh.ghostty.desktop".to_string()), - ..Default::default() - }, - &window, - ) - .unwrap(); - } - - #[test] - fn resolves_target_by_window_id_first() { - let windows = vec![ - window(1, "Codex", "codex.desktop", "Codex"), - window(2, "Ghostty", "com.mitchellh.ghostty.desktop", "Ghostty"), - ]; - - let matched = resolve_window_target( - &windows, - &WindowTarget { - window_id: Some(2), - title: Some("Codex".to_string()), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(matched.window_id, 2); - } - - #[test] - fn pid_target_reports_ambiguous_matches() { - let mut first = window(1, "Ghostty One", "com.mitchellh.ghostty.desktop", "Ghostty"); - let mut second = window(2, "Ghostty Two", "com.mitchellh.ghostty.desktop", "Ghostty"); - first.pid = Some(300); - second.pid = Some(300); - - let error = resolve_window_target( - &[first, second], - &WindowTarget { - pid: Some(300), - ..Default::default() - }, - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("pid 300 matched multiple windows")); - } - - #[test] - fn resolves_target_by_title_substring_case_insensitive() { - let windows = vec![window( - 2, - "avifenesh@host: ~/projects/codex", - "com.mitchellh.ghostty.desktop", - "Ghostty", - )]; - - let matched = resolve_window_target( - &windows, - &WindowTarget { - title: Some("PROJECTS/CODEX".to_string()), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(matched.window_id, 2); - } - - #[test] - fn title_target_reports_ambiguous_matches() { - let windows = vec![ - window(1, "Codex - one", "codex.desktop", "Codex"), - window(2, "Codex - two", "codex.desktop", "Codex"), - ]; - - let error = resolve_window_target( - &windows, - &WindowTarget { - title: Some("Codex".to_string()), - ..Default::default() - }, - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("title containing Codex matched multiple windows")); - } - - #[test] - fn resolves_terminal_target_by_tty() { - let windows = vec![ - terminal_window(1, "Claude", "/dev/pts/0", 101, "claude", "/tmp"), - terminal_window(2, "Codex", "/dev/pts/1", 201, "codex", "/home/avifenesh"), - ]; - - let matched = resolve_window_target( - &windows, - &WindowTarget { - tty: Some("pts/1".to_string()), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(matched.window_id, 2); - } - - #[test] - fn resolves_terminal_target_by_active_command() { - let windows = vec![ - terminal_window(1, "Claude", "/dev/pts/0", 101, "claude", "/tmp"), - terminal_window(2, "Codex", "/dev/pts/1", 201, "codex", "/home/avifenesh"), - ]; - - let matched = resolve_window_target( - &windows, - &WindowTarget { - terminal_command: Some("codex resume".to_string()), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(matched.window_id, 2); - } - - #[test] - fn resolves_terminal_target_by_cwd_suffix() { - let windows = vec![ - terminal_window(1, "Home", "/dev/pts/0", 101, "zsh", "/home/avifenesh"), - terminal_window( - 2, - "Project", - "/dev/pts/1", - 201, - "codex", - "/home/avifenesh/projects/codex-desktop-linux", - ), - ]; - - let matched = resolve_window_target( - &windows, - &WindowTarget { - terminal_cwd: Some("projects/codex-desktop-linux".to_string()), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(matched.window_id, 2); - } - - #[test] - fn terminal_cwd_does_not_match_arbitrary_substrings() { - let windows = vec![terminal_window( - 1, - "Project", - "/dev/pts/1", - 201, - "codex", - "/home/avifenesh/projects/codex-desktop-linux", - )]; - - let error = resolve_window_target( - &windows, - &WindowTarget { - terminal_cwd: Some("fenesh/proj".to_string()), - ..Default::default() - }, - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("No window matched terminal target")); - } - - #[test] - fn terminal_target_reports_ambiguous_matches() { - let windows = vec![ - terminal_window(1, "One", "/dev/pts/0", 101, "zsh", "/home/avifenesh"), - terminal_window(2, "Two", "/dev/pts/1", 201, "zsh", "/home/avifenesh"), - ]; - - let error = resolve_window_target( - &windows, - &WindowTarget { - terminal_command: Some("zsh".to_string()), - ..Default::default() - }, - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("matched multiple windows")); - } - - #[test] - fn maps_access_denied_errors_to_permission_hint() { - let hint = window_permission_hint( - "GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: GetWindows is not allowed", - ); - - assert_eq!(hint.as_deref(), Some(WINDOW_PERMISSION_HINT)); - } - - #[test] - fn extracts_known_window_properties() { - let properties = HashMap::from([ - ("title".to_string(), owned_value(Value::from("Ghostty"))), - ( - "app-id".to_string(), - owned_value(Value::from("com.mitchellh.ghostty.desktop")), - ), - ("wm-class".to_string(), owned_value(Value::from("Ghostty"))), - ("client-type".to_string(), owned_value(Value::from(0_u32))), - ("is-hidden".to_string(), owned_value(Value::from(false))), - ("has-focus".to_string(), owned_value(Value::from(true))), - ("width".to_string(), owned_value(Value::from(1200_u32))), - ("height".to_string(), owned_value(Value::from(800_u32))), - ]); - - let info = window_from_properties(42, &properties); - - assert_eq!(info.window_id, 42); - assert_eq!(info.title.as_deref(), Some("Ghostty")); - assert!(info.focused); - assert_eq!(info.client_type.as_deref(), Some("wayland")); - assert_eq!(info.bounds.unwrap().width, 1200); - } -} +pub use crate::windowing::*; diff --git a/docs/maintainers/fork-divergences.md b/docs/maintainers/fork-divergences.md index c849bd26..175bf30d 100644 --- a/docs/maintainers/fork-divergences.md +++ b/docs/maintainers/fork-divergences.md @@ -5,7 +5,7 @@ last synced upstream ref. Use it during upstream syncs to preserve local contracts and keep divergence claims grounded in the actual upstream baseline. The current comparison baseline is upstream commit -`446843757d54d41b53e6244d49ddd9375acb3fc2` (2026-05-06). Claims below describe +`5c3cf8b7b026ff02a61a155f709bcab115832e5e` (2026-05-10). Claims below describe the current tree's diff against that baseline, with current source files taking precedence over generated output. diff --git a/docs/maintainers/package-runtime-maintenance.md b/docs/maintainers/package-runtime-maintenance.md index 4cb3203d..cae9f35b 100644 --- a/docs/maintainers/package-runtime-maintenance.md +++ b/docs/maintainers/package-runtime-maintenance.md @@ -231,7 +231,7 @@ State handling matters: ## Crate Versioning Policy The updater crate version is in `updater/Cargo.toml`. The current version is -`0.7.0`. Keep the changelog and any user-facing version references in sync. +`0.7.1`. Keep the changelog and any user-facing version references in sync. Use: diff --git a/docs/usage/build-and-run.md b/docs/usage/build-and-run.md index f7f68174..a5bf7615 100644 --- a/docs/usage/build-and-run.md +++ b/docs/usage/build-and-run.md @@ -68,9 +68,9 @@ Run the dependency helper: bash scripts/install-deps.sh ``` -It installs Python, 7z, curl, build tools, verifies or installs the Node.js/npm -development toolchain required by the helper workflow, and bootstraps Rust -through `rustup` if `cargo` is missing. +It installs Python, 7z, curl, build tools, and bootstraps Rust through `rustup` +if `cargo` is missing. Fedora 41+ uses the app's managed Node.js runtime +instead of requiring distro `nodejs` and `npm` packages. ### Arch Linux diff --git a/flake.nix b/flake.nix index b8a833af..2a88acd0 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ codexDmg = pkgs.fetchurl { url = "https://persistent.oaistatic.com/codex-app-prod/Codex.dmg"; - hash = "sha256-WSs2iN4Ojk0Ky2FlGsOc8CayZaFHio9Wse+YbpFUE2Y="; + hash = "sha256-4FroU+UDXJSbB5FfjGhiGyXrQ/R+UYXuaYPoR7oXbyc="; }; electronLibs = with pkgs; [ @@ -115,6 +115,36 @@ codex_nixos_add_runtime_library_dirs() {\ \ codex_nixos_add_runtime_library_dirs' "${installDir}/start.sh" fi + if ! grep -q "Browser Use bundled marketplace metadata" "${installDir}/start.sh"; then + ${pkgs.python3}/bin/python3 - "${installDir}/start.sh" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() +needle = ' [ -f "$source_client" ] || return 0\n\n' +insert = "\n".join([ + " # Browser Use bundled marketplace metadata for app-server plugin discovery.", + " local source_marketplace=\"$SCRIPT_DIR/resources/plugins/openai-bundled/.agents/plugins/marketplace.json\"", + " local marketplace_root=\"$codex_home/.tmp/bundled-marketplaces/openai-bundled\"", + " local marketplace_plugins_dir=\"$marketplace_root/.agents/plugins\"", + " if [ -f \"$source_marketplace\" ]; then", + " mkdir -p \"$marketplace_plugins_dir\"", + " rm -f \"$marketplace_plugins_dir/marketplace.json\"", + " cp \"$source_marketplace\" \"$marketplace_plugins_dir/marketplace.json\" && \\", + " chmod u+w \"$marketplace_plugins_dir/marketplace.json\" || \\", + " echo \"Browser Use bundled marketplace sync failed; continuing with existing marketplace cache.\"", + " fi", + "", + "", +]) +if insert not in text: + if needle not in text: + raise SystemExit("Browser Use plugin cache insertion point not found") + text = text.replace(needle, needle + insert, 1) + path.write_text(text) +PY + fi fi # Patch the Electron binary for NixOS. @@ -151,7 +181,7 @@ codex_nixos_add_runtime_library_dirs' "${installDir}/start.sh" codexAppPayload = pkgs.stdenv.mkDerivation { pname = "codex-app-payload"; - version = "unstable-2026-05-02"; + version = "26.506.21252"; src = sourceRoot; __structuredAttrs = true; @@ -172,7 +202,7 @@ codex_nixos_add_runtime_library_dirs' "${installDir}/start.sh" outputHashAlgo = "sha256"; outputHashMode = "recursive"; - outputHash = "sha256-5bB5LHtOL0x3XAaUrRKRTTxsovHP6VVsgv/dcSfriGs="; + outputHash = "sha256-Mckt6xOP0tcmhsezQTboLTB7u3Xws+3ruq2A6jo5R5I="; unsafeDiscardReferences.out = true; dontConfigure = true; @@ -187,6 +217,7 @@ codex_nixos_add_runtime_library_dirs' "${installDir}/start.sh" export NIX_SSL_CERT_FILE="$SSL_CERT_FILE" export npm_config_cafile="$SSL_CERT_FILE" export CARGO_HOME="$TMPDIR/cargo-home" + export CODEX_MANAGED_NODE_SOURCE="${pkgs.nodejs}" mkdir -p "$HOME" "$npm_config_cache" "$CARGO_HOME" source_dir="$TMPDIR/codex-source" @@ -234,7 +265,7 @@ NODE codexApp = pkgs.stdenv.mkDerivation { pname = "codex-app"; - version = "unstable-2026-05-02"; + version = "26.506.21252"; src = codexAppPayload; nativeBuildInputs = [ @@ -252,6 +283,12 @@ NODE mkdir -p "$out/opt" cp -aT "$src/opt/codex-app" "$out/opt/codex-app" chmod -R u+w "$out/opt/codex-app" + rm -rf "$out/opt/codex-app/resources/node-runtime" + ln -s ${pkgs.nodejs} "$out/opt/codex-app/resources/node-runtime" + if [ -e "$out/opt/codex-app/update-builder/node-runtime" ]; then + rm -rf "$out/opt/codex-app/update-builder/node-runtime" + ln -s ${pkgs.nodejs} "$out/opt/codex-app/update-builder/node-runtime" + fi resources_dir="$out/opt/codex-app/resources" (cd "$resources_dir/app-extracted" && find . -type f | LC_ALL=C sort | sed 's#^\./##') > "$TMPDIR/app.asar.ordering" @@ -260,6 +297,12 @@ NODE --unpack "{*.node,*.so,*.dylib}" rm -rf "$resources_dir/app-extracted" + if [ -f "$resources_dir/node_repl" ]; then + patchelf --set-interpreter "$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker)" \ + --set-rpath "${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.glibc ]}" \ + "$resources_dir/node_repl" + fi + ${patchNixInstalledApp "$out/opt/codex-app"} install -Dm0644 "$out/opt/codex-app/.codex-linux/codex-app.png" \ @@ -322,6 +365,7 @@ NODE cd "$source_dir" export CODEX_INSTALL_DIR="''${CODEX_INSTALL_DIR:-$root_dir/codex-app}" + export CODEX_MANAGED_NODE_SOURCE="${pkgs.nodejs}" ${pkgs.bash}/bin/bash "$source_dir/install.sh" "$source_dir/Codex.dmg" "$@" install_dir="''${CODEX_INSTALL_DIR:-$root_dir/codex-app}" diff --git a/install.sh b/install.sh index 18d5380e..6bd83964 100755 --- a/install.sh +++ b/install.sh @@ -35,6 +35,7 @@ ICON_SOURCE="$SCRIPT_DIR/assets/codex.png" . "$SCRIPT_DIR/scripts/lib/asar-patch.sh" . "$SCRIPT_DIR/scripts/lib/webview-install.sh" . "$SCRIPT_DIR/scripts/lib/bundled-plugins.sh" +. "$SCRIPT_DIR/scripts/lib/linux-features.sh" . "$SCRIPT_DIR/scripts/lib/rebuild-report.sh" # ---- Create start script ---- @@ -112,6 +113,7 @@ main() { extract_webview "$INSTALL_DIR" install_app install_bundled_plugin_resources "$app_dir" + run_linux_feature_stage_hooks "$app_dir" create_start_script if [ -n "${CODEX_REBUILD_REPORT_JSON:-}" ] && [ -n "${CODEX_PATCH_REPORT_JSON:-}" ]; then diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 81353efe..2a25d3c9 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -104,6 +104,12 @@ Options: Default launch keeps Electron GPU enabled and lets Electron choose the platform. Extra flags are passed directly to Electron. +Environment: + CODEX_LINUX_RENDERING_MODE=auto|default|wslg + Auto-detect WSLg, keep generic Linux defaults, or force the WSLg profile + CODEX_ELECTRON_DISABLE_GPU_COMPOSITING=0|1 + Override the launcher's default --disable-gpu-compositing decision + Logs: ${XDG_CACHE_HOME:-$HOME/.cache}/$CODEX_LINUX_APP_ID/launcher.log HELP exit 0 @@ -320,7 +326,7 @@ prepend_managed_node_runtime_to_path() { export CODEX_MANAGED_NODE_RUNTIME_DIR="$SCRIPT_DIR/resources/node-runtime" } -browser_use_plugin_version() { +bundled_plugin_version() { local plugin_json="$1" python3 - "$plugin_json" <<'PY' @@ -339,29 +345,55 @@ print(version.strip()) PY } +sync_bundled_marketplace_cache() { + local label="$1" + local source_marketplace="$2" + local marketplace_plugins_dir="$3" + local target_marketplace="$marketplace_plugins_dir/marketplace.json" + local tmp_marketplace="$marketplace_plugins_dir/.marketplace.json.tmp.$$" + + [ -f "$source_marketplace" ] || return 0 + + if mkdir -p "$marketplace_plugins_dir" && \ + cp "$source_marketplace" "$tmp_marketplace" && \ + chmod u+w "$tmp_marketplace" && \ + mv "$tmp_marketplace" "$target_marketplace"; then + return 0 + fi + + rm -f "$tmp_marketplace" + echo "$label bundled marketplace sync failed; continuing with existing marketplace cache." +} + sync_browser_use_bundled_plugin_cache() { local source_plugin="$SCRIPT_DIR/resources/plugins/openai-bundled/plugins/browser-use" + local source_marketplace="$SCRIPT_DIR/resources/plugins/openai-bundled/.agents/plugins/marketplace.json" local plugin_json="$source_plugin/.codex-plugin/plugin.json" local source_client="$source_plugin/scripts/browser-client.mjs" local codex_home="${CODEX_HOME:-$HOME/.codex}" local version local cache_root local cache_plugin - local cache_client local cache_parent local tmp_plugin + local marketplace_root + local marketplace_plugins_dir [ -f "$plugin_json" ] || return 0 [ -f "$source_client" ] || return 0 - version="$(browser_use_plugin_version "$plugin_json" 2>/dev/null || true)" + marketplace_root="$codex_home/.tmp/bundled-marketplaces/openai-bundled" + marketplace_plugins_dir="$marketplace_root/.agents/plugins" + sync_bundled_marketplace_cache "Browser Use" "$source_marketplace" "$marketplace_plugins_dir" + + version="$(bundled_plugin_version "$plugin_json" 2>/dev/null || true)" [ -n "$version" ] || return 0 cache_root="$codex_home/plugins/cache/openai-bundled/browser-use" cache_plugin="$cache_root/$version" - cache_client="$cache_plugin/scripts/browser-client.mjs" - if [ -f "$cache_client" ] && cmp -s "$source_client" "$cache_client"; then + if [ -d "$cache_plugin" ] && \ + diff -qr --exclude='*:com.apple.*' "$source_plugin" "$cache_plugin" >/dev/null 2>&1; then return 0 fi @@ -381,6 +413,223 @@ sync_browser_use_bundled_plugin_cache() { fi } +chrome_extension_host_arch() { + case "$(uname -m)" in + x86_64) echo "x64" ;; + aarch64|arm64) echo "arm64" ;; + *) return 1 ;; + esac +} + +write_chrome_native_host_manifests() { + local host_path="$1" + local plugin_dir="$2" + + python3 - "$host_path" "$HOME" "$plugin_dir" <<'PY' +import json +import pathlib +import re +import sys + +host_path = sys.argv[1] +home = pathlib.Path(sys.argv[2]) +plugin_dir = pathlib.Path(sys.argv[3]) +scripts_dir = plugin_dir / "scripts" + +extension_id = None +host_name = None + +extension_id_json = scripts_dir / "extension-id.json" +try: + data = json.loads(extension_id_json.read_text(encoding="utf-8")) + extension_id = data.get("extensionId") + host_name = data.get("extensionHostName") +except (OSError, json.JSONDecodeError): + pass + +if extension_id is None or host_name is None: + install_manifest = (scripts_dir / "installManifest.mjs").read_text(encoding="utf-8") + extension_id_match = re.search(r'extensionId\s*:\s*"([a-p]{32})"', install_manifest) + host_name_match = re.search(r'extensionHostName\s*:\s*"([A-Za-z0-9_.]+)"', install_manifest) + if extension_id is None and extension_id_match is not None: + extension_id = extension_id_match.group(1) + if host_name is None and host_name_match is not None: + host_name = host_name_match.group(1) + +if not isinstance(extension_id, str) or re.fullmatch(r"[a-p]{32}", extension_id) is None: + raise SystemExit("Invalid Chrome extension id in bundled plugin metadata") +if not isinstance(host_name, str) or re.fullmatch(r"[A-Za-z0-9_.]+", host_name) is None: + raise SystemExit("Invalid Chrome native host name in bundled plugin metadata") + +manifest_name = f"{host_name}.json" +manifest = { + "name": host_name, + "description": "Codex chrome native messaging host", + "type": "stdio", + "path": host_path, + "allowed_origins": [f"chrome-extension://{extension_id}/"], +} +text = json.dumps(manifest, separators=(",", ":")) + +for relative in ( + ".config/google-chrome/NativeMessagingHosts", + ".config/google-chrome-beta/NativeMessagingHosts", + ".config/google-chrome-unstable/NativeMessagingHosts", + ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", + ".config/chromium/NativeMessagingHosts", +): + directory = home / relative + directory.mkdir(parents=True, exist_ok=True) + path = directory / manifest_name + try: + if path.read_text(encoding="utf-8") == text: + continue + except OSError: + pass + path.write_text(text, encoding="utf-8") +PY +} + +sync_chrome_bundled_plugin_cache() { + local source_plugin="$SCRIPT_DIR/resources/plugins/openai-bundled/plugins/chrome" + local source_marketplace="$SCRIPT_DIR/resources/plugins/openai-bundled/.agents/plugins/marketplace.json" + local plugin_json="$source_plugin/.codex-plugin/plugin.json" + local codex_home="${CODEX_HOME:-$HOME/.codex}" + local extension_arch + local source_host + local source_client + local source_install_manifest + local version + local cache_root + local cache_plugin + local cache_host + local cache_client + local cache_install_manifest + local cache_parent + local tmp_plugin + local marketplace_root + local marketplace_plugins_dir + local marketplace_plugin_link + local host_path + local needs_copy=1 + + [ -f "$plugin_json" ] || return 0 + [ -d "$source_plugin" ] || return 0 + + if ! extension_arch="$(chrome_extension_host_arch)"; then + return 0 + fi + + source_host="$source_plugin/extension-host/linux/$extension_arch/extension-host" + source_client="$source_plugin/scripts/browser-client.mjs" + source_install_manifest="$source_plugin/scripts/installManifest.mjs" + [ -x "$source_host" ] || return 0 + [ -f "$source_client" ] || return 0 + [ -f "$source_install_manifest" ] || return 0 + + version="$(bundled_plugin_version "$plugin_json" 2>/dev/null || true)" + [ -n "$version" ] || return 0 + + cache_root="$codex_home/plugins/cache/openai-bundled/chrome" + cache_plugin="$cache_root/$version" + if [ -d "$cache_plugin" ] && \ + diff -qr --exclude='*:com.apple.*' "$source_plugin" "$cache_plugin" >/dev/null 2>&1; then + needs_copy=0 + fi + + if [ "$needs_copy" -eq 1 ]; then + cache_parent="$(dirname "$cache_plugin")" + tmp_plugin="$cache_parent/.chrome-$version.tmp.$$" + rm -rf "$tmp_plugin" + mkdir -p "$cache_parent" + + if cp -R "$source_plugin" "$tmp_plugin"; then + find "$tmp_plugin" -type f -name '*:com.apple.*' -delete + rm -rf "$cache_plugin" + mv "$tmp_plugin" "$cache_plugin" + echo "Chrome plugin cache synced from bundled resources: $cache_plugin" + else + rm -rf "$tmp_plugin" + echo "Chrome plugin cache sync failed; continuing with existing cache." + return 0 + fi + fi + + if [ -e "$cache_root/latest" ] && [ ! -L "$cache_root/latest" ]; then + rm -rf "$cache_root/latest" + fi + ln -sfn "$version" "$cache_root/latest" + + marketplace_root="$codex_home/.tmp/bundled-marketplaces/openai-bundled" + marketplace_plugins_dir="$marketplace_root/.agents/plugins" + marketplace_plugin_link="$marketplace_root/plugins/chrome" + mkdir -p "$marketplace_plugins_dir" "$marketplace_root/plugins" + sync_bundled_marketplace_cache "Chrome" "$source_marketplace" "$marketplace_plugins_dir" + if [ -e "$marketplace_plugin_link" ] && [ ! -L "$marketplace_plugin_link" ]; then + rm -rf "$marketplace_plugin_link" + fi + ln -sfn "$cache_root/latest" "$marketplace_plugin_link" + + host_path="$cache_root/latest/extension-host/linux/$extension_arch/extension-host" + [ -x "$host_path" ] || return 0 + write_chrome_native_host_manifests "$host_path" "$cache_root/latest" || \ + echo "Chrome native host manifest sync failed; continuing with existing browser manifests." +} + +sync_computer_use_bundled_plugin_cache() { + local source_plugin="$SCRIPT_DIR/resources/plugins/openai-bundled/plugins/computer-use" + local source_marketplace="$SCRIPT_DIR/resources/plugins/openai-bundled/.agents/plugins/marketplace.json" + local plugin_json="$source_plugin/.codex-plugin/plugin.json" + local source_backend="$source_plugin/bin/codex-computer-use-linux" + local source_cosmic_helper="$source_plugin/bin/codex-computer-use-cosmic" + local codex_home="${CODEX_HOME:-$HOME/.codex}" + local version + local cache_root + local cache_plugin + local cache_backend + local cache_cosmic_helper + local cache_parent + local tmp_plugin + local marketplace_root + local marketplace_plugins_dir + + [ -f "$plugin_json" ] || return 0 + [ -f "$source_backend" ] || return 0 + [ -f "$source_cosmic_helper" ] || return 0 + + marketplace_root="$codex_home/.tmp/bundled-marketplaces/openai-bundled" + marketplace_plugins_dir="$marketplace_root/.agents/plugins" + sync_bundled_marketplace_cache "Computer Use" "$source_marketplace" "$marketplace_plugins_dir" + + version="$(bundled_plugin_version "$plugin_json" 2>/dev/null || true)" + [ -n "$version" ] || return 0 + + cache_root="$codex_home/plugins/cache/openai-bundled/computer-use" + cache_plugin="$cache_root/$version" + cache_backend="$cache_plugin/bin/codex-computer-use-linux" + cache_cosmic_helper="$cache_plugin/bin/codex-computer-use-cosmic" + + if [ -d "$cache_plugin" ] && \ + diff -qr --exclude='*:com.apple.*' "$source_plugin" "$cache_plugin" >/dev/null 2>&1; then + return 0 + fi + + cache_parent="$(dirname "$cache_plugin")" + tmp_plugin="$cache_parent/.computer-use-$version.tmp.$$" + rm -rf "$tmp_plugin" + mkdir -p "$cache_parent" + + if cp -R "$source_plugin" "$tmp_plugin"; then + find "$tmp_plugin" -type f -name '*:com.apple.*' -delete + rm -rf "$cache_plugin" + mv "$tmp_plugin" "$cache_plugin" + echo "Computer Use plugin cache synced from bundled resources: $cache_plugin" + else + rm -rf "$tmp_plugin" + echo "Computer Use plugin cache sync failed; continuing with existing cache." + fi +} + resolve_browser_use_runtime_env() { if [ -z "${CODEX_ELECTRON_RESOURCES_PATH:-}" ]; then export CODEX_ELECTRON_RESOURCES_PATH="$SCRIPT_DIR/resources" @@ -562,6 +811,7 @@ find_codex_cli() { local candidate for candidate in \ + "$HOME/.bun/bin/codex" \ "$HOME/.nvm/versions/node/current/bin/codex" \ "$HOME/.nvm/versions/node"/*/bin/codex \ "$HOME/.local/share/pnpm/codex" \ @@ -1085,18 +1335,180 @@ clear_stale_pid_file() { fi } +reconcile_runtime_state() { + local live_app_pid="" + local webview_pid="" + + if live_app_pid="$(find_running_app_pid)" || { [ -S "$LAUNCH_ACTION_SOCKET" ] && live_app_pid="$(discover_running_app_pid)"; }; then + echo "$live_app_pid" > "$APP_PID_FILE" + return 0 + fi + + clear_stale_pid_file + + if [ -e "$LAUNCH_ACTION_SOCKET" ]; then + rm -f "$LAUNCH_ACTION_SOCKET" + fi + + if [ ! -f "$WEBVIEW_PID_FILE" ]; then + return 0 + fi + + webview_pid="$(cat "$WEBVIEW_PID_FILE" 2>/dev/null || true)" + if [ -z "$webview_pid" ] || { ! pid_is_webview_server "$webview_pid" && ! pid_is_stale_webview_server "$webview_pid"; }; then + rm -f "$WEBVIEW_PID_FILE" + fi +} + +is_wsl_environment() { + if [ -n "${WSL_INTEROP:-}" ] || [ -n "${WSL_DISTRO_NAME:-}" ]; then + return 0 + fi + + grep -qiE "(microsoft|wsl)" /proc/sys/kernel/osrelease 2>/dev/null +} + +is_wslg_session() { + is_wsl_environment || return 1 + + if [ -n "${WAYLAND_DISPLAY:-}" ]; then + return 0 + fi + + [ -n "${DISPLAY:-}" ] && [ -e /mnt/wslg ] +} + +normalize_linux_rendering_mode() { + case "${CODEX_LINUX_RENDERING_MODE:-auto}" in + auto|default|wslg) + echo "${CODEX_LINUX_RENDERING_MODE:-auto}" + ;; + *) + echo "Invalid CODEX_LINUX_RENDERING_MODE='${CODEX_LINUX_RENDERING_MODE:-}'; using auto" >&2 + echo "auto" + ;; + esac +} + +truthy_env_value() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +falsey_env_value() { + case "${1:-}" in + 0|false|FALSE|no|NO|off|OFF) return 0 ;; + *) return 1 ;; + esac +} + +scan_electron_rendering_arg() { + case "$1" in + --ozone-platform|--ozone-platform=*|--ozone-platform-hint|--ozone-platform-hint=*) + ELECTRON_OZONE_SWITCH_IN_ARGS=1 + ;; + --use-gl|--use-gl=*|--use-angle|--use-angle=*) + ELECTRON_GL_SWITCH_PROVIDED=1 + ;; + --disable-gpu|--disable-gpu=*) + ELECTRON_GPU_DISABLE_SWITCH_IN_ARGS=1 + ;; + --disable-gpu-compositing|--disable-gpu-compositing=*) + ELECTRON_GPU_COMPOSITING_SWITCH_PROVIDED=1 + ;; + esac +} + +apply_electron_rendering_profile() { + local requested_mode + requested_mode="$(normalize_linux_rendering_mode)" + + ELECTRON_RENDERING_MODE="$requested_mode" + ELECTRON_WSLG_DETECTED=0 + if is_wslg_session; then + ELECTRON_WSLG_DETECTED=1 + fi + + case "$requested_mode" in + auto) + if [ "$ELECTRON_WSLG_DETECTED" -eq 1 ]; then + ELECTRON_RENDERING_MODE="wslg" + else + ELECTRON_RENDERING_MODE="default" + fi + ;; + default) + return 0 + ;; + esac + + [ "$ELECTRON_RENDERING_MODE" = "wslg" ] || return 0 + + if [ "$ELECTRON_PLATFORM_EXPLICIT" -eq 0 ] && [ "$ELECTRON_OZONE_SWITCH_IN_ARGS" -eq 0 ]; then + ELECTRON_OZONE_PLATFORM="x11" + ELECTRON_OZONE_HINT="" + fi + + if [ "$ELECTRON_GL_SWITCH_PROVIDED" -eq 0 ] && [ "$ELECTRON_GPU_ENABLED" = "1" ] && [ "$ELECTRON_GPU_DISABLE_SWITCH_IN_ARGS" -eq 0 ]; then + ELECTRON_ARGS+=(--use-gl=angle) + ELECTRON_GL_SWITCH_PROVIDED=1 + ELECTRON_GL_SWITCH_ADDED=1 + fi +} + +should_disable_gpu_compositing() { + if [ "$ELECTRON_GPU_COMPOSITING_SWITCH_PROVIDED" -eq 1 ]; then + return 0 + fi + + if [ -n "${CODEX_ELECTRON_DISABLE_GPU_COMPOSITING:-}" ]; then + if truthy_env_value "$CODEX_ELECTRON_DISABLE_GPU_COMPOSITING"; then + return 0 + fi + if falsey_env_value "$CODEX_ELECTRON_DISABLE_GPU_COMPOSITING"; then + return 1 + fi + echo "Invalid CODEX_ELECTRON_DISABLE_GPU_COMPOSITING='${CODEX_ELECTRON_DISABLE_GPU_COMPOSITING:-}'; using rendering profile default" >&2 + fi + + [ "$ELECTRON_RENDERING_MODE" != "wslg" ] +} + set_electron_defaults() { ELECTRON_OZONE_PLATFORM="" ELECTRON_OZONE_HINT="auto" ELECTRON_GPU_ENABLED=1 + ELECTRON_RENDERING_MODE="default" + ELECTRON_WSLG_DETECTED=0 + ELECTRON_PLATFORM_EXPLICIT=0 + ELECTRON_OZONE_SWITCH_IN_ARGS=0 + ELECTRON_GL_SWITCH_PROVIDED=0 + ELECTRON_GL_SWITCH_ADDED=0 + ELECTRON_GPU_DISABLE_SWITCH_IN_ARGS=0 + ELECTRON_GPU_COMPOSITING_DISABLED=0 + ELECTRON_GPU_COMPOSITING_SWITCH_PROVIDED=0 ELECTRON_ARGS=() + local passthrough=0 while [ "$#" -gt 0 ]; do + if [ "$passthrough" -eq 1 ]; then + ELECTRON_ARGS+=("$1") + scan_electron_rendering_arg "$1" + shift + continue + fi + case "$1" in + --) + passthrough=1 + ;; --safe-mode) ELECTRON_OZONE_PLATFORM="x11" ELECTRON_OZONE_HINT="" ELECTRON_GPU_ENABLED=0 + ELECTRON_PLATFORM_EXPLICIT=1 ;; --disable-gpu) ELECTRON_GPU_ENABLED=0 @@ -1107,21 +1519,26 @@ set_electron_defaults() { --x11) ELECTRON_OZONE_PLATFORM="x11" ELECTRON_OZONE_HINT="" + ELECTRON_PLATFORM_EXPLICIT=1 ;; --wayland) ELECTRON_OZONE_PLATFORM="wayland" ELECTRON_OZONE_HINT="" + ELECTRON_PLATFORM_EXPLICIT=1 ;; --ozone-platform=*) ELECTRON_OZONE_PLATFORM="${1#--ozone-platform=}" ELECTRON_OZONE_HINT="" + ELECTRON_PLATFORM_EXPLICIT=1 ;; --ozone-platform-hint=*) ELECTRON_OZONE_HINT="${1#--ozone-platform-hint=}" ELECTRON_OZONE_PLATFORM="" + ELECTRON_PLATFORM_EXPLICIT=1 ;; *) ELECTRON_ARGS+=("$1") + scan_electron_rendering_arg "$1" ;; esac shift @@ -1147,6 +1564,8 @@ set_electron_defaults() { ELECTRON_OZONE_HINT="" echo "Detected Sommelier (SOMMELIER_VERSION=$SOMMELIER_VERSION); forcing --ozone-platform=x11 (Wayland windows are not visible under ChromeOS Crostini)" fi + + apply_electron_rendering_profile } build_electron_launch_args() { @@ -1155,13 +1574,19 @@ build_electron_launch_args() { --app-id="$CODEX_LINUX_APP_ID" --disable-dev-shm-usage --disable-gpu-sandbox - --disable-gpu-compositing ) if [[ "${CODEX_APP_DISABLE_ELECTRON_SANDBOX:-0}" =~ ^(1|true|yes|on)$ ]]; then ELECTRON_LAUNCH_ARGS+=(--no-sandbox) fi + if should_disable_gpu_compositing; then + ELECTRON_LAUNCH_ARGS+=(--disable-gpu-compositing) + ELECTRON_GPU_COMPOSITING_DISABLED=1 + else + ELECTRON_GPU_COMPOSITING_DISABLED=0 + fi + if [ "$CODEX_LINUX_APP_ID" != "codex-app" ]; then ELECTRON_LAUNCH_ARGS+=(--user-data-dir="${CODEX_ELECTRON_USER_DATA_DIR:-$APP_STATE_DIR/electron-user-data}") elif [ -n "${CODEX_ELECTRON_USER_DATA_DIR:-}" ]; then @@ -1170,7 +1595,7 @@ build_electron_launch_args() { if [ -n "$ELECTRON_OZONE_PLATFORM" ]; then ELECTRON_LAUNCH_ARGS+=(--ozone-platform="$ELECTRON_OZONE_PLATFORM") - elif [ -n "$ELECTRON_OZONE_HINT" ]; then + elif [ -n "$ELECTRON_OZONE_HINT" ] && [ "$ELECTRON_OZONE_SWITCH_IN_ARGS" -eq 0 ]; then ELECTRON_LAUNCH_ARGS+=(--ozone-platform-hint="$ELECTRON_OZONE_HINT") fi @@ -1222,12 +1647,12 @@ launch_electron() { build_electron_launch_args if [ "$WARM_START" -eq 1 ]; then - echo "Electron warm-start handoff: pid=$RUNNING_APP_PID ozone_platform=${ELECTRON_OZONE_PLATFORM:-default} ozone_hint=${ELECTRON_OZONE_HINT:-none} gpu_enabled=$ELECTRON_GPU_ENABLED" + echo "Electron warm-start handoff: pid=$RUNNING_APP_PID rendering_mode=$ELECTRON_RENDERING_MODE wslg_detected=$ELECTRON_WSLG_DETECTED ozone_platform=${ELECTRON_OZONE_PLATFORM:-default} ozone_hint=${ELECTRON_OZONE_HINT:-none} gpu_enabled=$ELECTRON_GPU_ENABLED gpu_disable_arg=$ELECTRON_GPU_DISABLE_SWITCH_IN_ARGS gpu_compositing_disabled=$ELECTRON_GPU_COMPOSITING_DISABLED gl_switch_added=$ELECTRON_GL_SWITCH_ADDED" "$SCRIPT_DIR/electron" "${ELECTRON_LAUNCH_ARGS[@]}" "${ELECTRON_ARGS[@]}" return $? fi - echo "Electron launch mode: ozone_platform=${ELECTRON_OZONE_PLATFORM:-default} ozone_hint=${ELECTRON_OZONE_HINT:-none} gpu_enabled=$ELECTRON_GPU_ENABLED" + echo "Electron launch mode: rendering_mode=$ELECTRON_RENDERING_MODE wslg_detected=$ELECTRON_WSLG_DETECTED ozone_platform=${ELECTRON_OZONE_PLATFORM:-default} ozone_hint=${ELECTRON_OZONE_HINT:-none} gpu_enabled=$ELECTRON_GPU_ENABLED gpu_disable_arg=$ELECTRON_GPU_DISABLE_SWITCH_IN_ARGS gpu_compositing_disabled=$ELECTRON_GPU_COMPOSITING_DISABLED gl_switch_added=$ELECTRON_GL_SWITCH_ADDED" "$SCRIPT_DIR/electron" "${ELECTRON_LAUNCH_ARGS[@]}" "${ELECTRON_ARGS[@]}" & ELECTRON_PID=$! if [ -n "${RUNNING_APP_PID:-}" ] && pid_matches_executable "$RUNNING_APP_PID" "$SCRIPT_DIR/electron"; then @@ -1249,7 +1674,7 @@ configure_side_by_side_app_env load_packaged_runtime_helper prepend_managed_node_runtime_to_path register_url_scheme_handlers -clear_stale_pid_file +reconcile_runtime_state detect_warm_start trap cleanup_launcher EXIT @@ -1313,6 +1738,8 @@ fi export_packaged_runtime_env if needs_cold_start; then sync_browser_use_bundled_plugin_cache + sync_chrome_bundled_plugin_cache + sync_computer_use_bundled_plugin_cache fi resolve_browser_use_runtime_env diff --git a/linux-features/README.md b/linux-features/README.md new file mode 100644 index 00000000..36430971 --- /dev/null +++ b/linux-features/README.md @@ -0,0 +1,42 @@ +# Linux Features + +`linux-features/` contains opt-in Linux integration modules for this wrapper. +These are not upstream Codex plugins; they are Linux-side extensions that can +add ASAR patches, staged resources, or build/install hooks. + +By default, no optional Linux features are enabled. Copy +`features.example.json` to `features.json` before running `./install.sh` or +building packages, then list the feature ids you want: + +```json +{ + "enabled": [ + "example-feature" + ] +} +``` + +`features.json` is ignored by git so local choices do not leak into commits. +Feature choices are read during the install/build pipeline; if you change this +file after an app has already been generated, rerun the install/build step. + +Each feature directory should include: + +- `feature.json` — metadata and entrypoints +- `README.md` — what it does, how to test it, and known risks +- optional `patch.js` — exports `applyMainBundlePatch(source, context)` +- optional `stage.sh` — install/build staging hook +- optional `test.js` — self-contained tests for the feature + +`stage.sh` hooks run with `SCRIPT_DIR`, `INSTALL_DIR`, `WORK_DIR`, `ARCH`, and +`CODEX_UPSTREAM_APP_DIR` in the environment. + +Feature self-tests live inside each feature directory. Run them with: + +```bash +node --test linux-features/*/test.js +``` + +Core Linux compatibility patches should stay in `scripts/patches/` until they +are deliberately migrated. Use `linux-features/` for additions that are useful +for some users but not mandatory for every Linux build. diff --git a/linux-features/example-feature/README.md b/linux-features/example-feature/README.md new file mode 100644 index 00000000..85b76866 --- /dev/null +++ b/linux-features/example-feature/README.md @@ -0,0 +1,18 @@ +# Example Linux Feature + +This is a disabled-by-default example that documents the `linux-features` +contract. It is intentionally harmless and does not patch the real Codex bundle. + +To try it locally, copy `linux-features/features.example.json` to +`linux-features/features.json` and add: + +```json +{ + "enabled": [ + "example-feature" + ] +} +``` + +The example `patch.js` replaces a synthetic marker used only in tests. The +example `stage.sh` is a no-op hook that prints a short message. diff --git a/linux-features/example-feature/feature.json b/linux-features/example-feature/feature.json new file mode 100644 index 00000000..b91f475d --- /dev/null +++ b/linux-features/example-feature/feature.json @@ -0,0 +1,10 @@ +{ + "id": "example-feature", + "title": "Example Linux Feature", + "description": "Disabled example showing the linux-features ASAR patch and stage hook contract.", + "defaultEnabled": false, + "entrypoints": { + "mainBundlePatch": "./patch.js", + "stageHook": "./stage.sh" + } +} diff --git a/linux-features/example-feature/patch.js b/linux-features/example-feature/patch.js new file mode 100644 index 00000000..d85a3788 --- /dev/null +++ b/linux-features/example-feature/patch.js @@ -0,0 +1,14 @@ +"use strict"; + +function applyMainBundlePatch(source) { + const marker = "codexLinuxExampleFeatureDisabled()"; + if (!source.includes(marker)) { + console.warn("WARN: Example Linux feature marker not found — skipping example feature patch"); + return source; + } + return source.replace(marker, "codexLinuxExampleFeatureEnabled()"); +} + +module.exports = { + applyMainBundlePatch, +}; diff --git a/linux-features/example-feature/stage.sh b/linux-features/example-feature/stage.sh new file mode 100755 index 00000000..f7987b50 --- /dev/null +++ b/linux-features/example-feature/stage.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -Eeuo pipefail + +if [ -n "${CODEX_EXAMPLE_FEATURE_STAGE_MARKER:-}" ]; then + printf 'example-stage:%s:%s\n' "${ARCH:-unknown}" "${INSTALL_DIR:-unknown}" > "$CODEX_EXAMPLE_FEATURE_STAGE_MARKER" +fi + +echo "Example Linux feature stage hook: no-op" >&2 diff --git a/linux-features/example-feature/test.js b/linux-features/example-feature/test.js new file mode 100755 index 00000000..3067fd57 --- /dev/null +++ b/linux-features/example-feature/test.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { applyMainBundlePatch } = require("./patch.js"); +const { + enabledLinuxFeatureIds, + enabledLinuxFeatureStageHooks, + loadLinuxFeatureMainBundlePatches, +} = require("../../scripts/lib/linux-features.js"); +const { + createPatchReport, + patchExtractedApp, + patchMainBundleSource, +} = require("../../scripts/patch-linux-window-ui.js"); + +function withTempFeatureRoot(enabled, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-example-feature-test-")); + try { + fs.writeFileSync(path.join(root, "features.example.json"), JSON.stringify({ enabled: [] }, null, 2)); + fs.writeFileSync(path.join(root, "features.json"), JSON.stringify({ enabled }, null, 2)); + fs.cpSync(__dirname, path.join(root, "example-feature"), { recursive: true }); + return fn(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("example feature patches only its synthetic marker", () => { + assert.equal( + applyMainBundlePatch("before;codexLinuxExampleFeatureDisabled();after"), + "before;codexLinuxExampleFeatureEnabled();after", + ); +}); + +test("example feature is a no-op without its synthetic marker", () => { + const originalWarn = console.warn; + console.warn = () => {}; + try { + assert.equal(applyMainBundlePatch("real codex bundle"), "real codex bundle"); + } finally { + console.warn = originalWarn; + } +}); + +test("example feature stays disabled until listed in features.json", () => { + withTempFeatureRoot([], (root) => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot: root }), []); + assert.deepEqual(enabledLinuxFeatureStageHooks({ featuresRoot: root }), []); + assert.deepEqual(loadLinuxFeatureMainBundlePatches({ featuresRoot: root }), []); + }); +}); + +test("example feature exposes its patch and stage hook when enabled", () => { + withTempFeatureRoot(["example-feature"], (root) => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot: root }), ["example-feature"]); + + const hooks = enabledLinuxFeatureStageHooks({ featuresRoot: root }); + assert.equal(hooks.length, 1); + assert.equal(hooks[0].id, "example-feature"); + assert.equal(path.basename(hooks[0].path), "stage.sh"); + + const patches = loadLinuxFeatureMainBundlePatches({ featuresRoot: root }); + assert.equal(patches.length, 1); + assert.equal(patches[0].name, "feature:example-feature"); + assert.equal( + patches[0].apply("codexLinuxExampleFeatureDisabled()", {}), + "codexLinuxExampleFeatureEnabled()", + ); + }); +}); + +test("example feature participates in main bundle patching and patch reports", () => { + withTempFeatureRoot(["example-feature"], (root) => { + const originalRoot = process.env.CODEX_LINUX_FEATURES_ROOT; + process.env.CODEX_LINUX_FEATURES_ROOT = root; + const tempApp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-example-feature-app-")); + try { + assert.equal( + patchMainBundleSource("codexLinuxExampleFeatureDisabled()", null), + "codexLinuxExampleFeatureEnabled()", + ); + + const buildDir = path.join(tempApp, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, "main.js"), "codexLinuxExampleFeatureDisabled()"); + + const report = createPatchReport(); + patchExtractedApp(tempApp, { report }); + + assert.match(fs.readFileSync(path.join(buildDir, "main.js"), "utf8"), /codexLinuxExampleFeatureEnabled\(\)/); + assert.ok(report.patches.some((patch) => patch.name === "feature:example-feature" && patch.status === "applied")); + } finally { + if (originalRoot == null) { + delete process.env.CODEX_LINUX_FEATURES_ROOT; + } else { + process.env.CODEX_LINUX_FEATURES_ROOT = originalRoot; + } + fs.rmSync(tempApp, { recursive: true, force: true }); + } + }); +}); + +test("example feature stage hook is runnable through the Linux feature shell runner", () => { + withTempFeatureRoot(["example-feature"], (root) => { + const marker = path.join(root, "stage-marker.txt"); + const repoRoot = path.resolve(__dirname, "..", ".."); + const runner = path.join(repoRoot, "scripts", "lib", "linux-features.sh"); + const result = spawnSync( + "bash", + [ + "-lc", + [ + "source \"$LINUX_FEATURES_RUNNER\"", + "info(){ echo \"$*\" >&2; }", + "warn(){ echo \"$*\" >&2; }", + "SCRIPT_DIR=\"$REPO_ROOT\"", + "INSTALL_DIR=\"$TMP_INSTALL_DIR\"", + "WORK_DIR=\"$TMP_WORK_DIR\"", + "ARCH=x86_64", + "run_linux_feature_stage_hooks", + ].join("\n"), + ], + { + env: { + ...process.env, + LINUX_FEATURES_RUNNER: runner, + REPO_ROOT: repoRoot, + TMP_INSTALL_DIR: path.join(root, "install"), + TMP_WORK_DIR: path.join(root, "work"), + CODEX_LINUX_FEATURES_ROOT: root, + CODEX_EXAMPLE_FEATURE_STAGE_MARKER: marker, + }, + encoding: "utf8", + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(fs.readFileSync(marker, "utf8"), /example-stage:x86_64:/); + assert.match(result.stderr, /Running Linux feature stage hook: example-feature/); + }); +}); + +test("Linux feature shell runner fails when an enabled stage hook fails", () => { + withTempFeatureRoot(["example-feature"], (root) => { + fs.writeFileSync( + path.join(root, "example-feature", "stage.sh"), + "#!/bin/bash\nset -Eeuo pipefail\nexit 42\n", + ); + const repoRoot = path.resolve(__dirname, "..", ".."); + const runner = path.join(repoRoot, "scripts", "lib", "linux-features.sh"); + const result = spawnSync( + "bash", + [ + "-lc", + [ + "source \"$LINUX_FEATURES_RUNNER\"", + "info(){ echo \"$*\" >&2; }", + "warn(){ echo \"$*\" >&2; }", + "SCRIPT_DIR=\"$REPO_ROOT\"", + "INSTALL_DIR=\"$TMP_INSTALL_DIR\"", + "WORK_DIR=\"$TMP_WORK_DIR\"", + "ARCH=x86_64", + "run_linux_feature_stage_hooks", + ].join("\n"), + ], + { + env: { + ...process.env, + LINUX_FEATURES_RUNNER: runner, + REPO_ROOT: repoRoot, + TMP_INSTALL_DIR: path.join(root, "install"), + TMP_WORK_DIR: path.join(root, "work"), + CODEX_LINUX_FEATURES_ROOT: root, + }, + encoding: "utf8", + }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Linux feature stage hook failed: example-feature/); + }); +}); diff --git a/linux-features/features.example.json b/linux-features/features.example.json new file mode 100644 index 00000000..8f6a2b87 --- /dev/null +++ b/linux-features/features.example.json @@ -0,0 +1,3 @@ +{ + "enabled": [] +} diff --git a/linux-features/zed-opener/README.md b/linux-features/zed-opener/README.md new file mode 100644 index 00000000..39f603a4 --- /dev/null +++ b/linux-features/zed-opener/README.md @@ -0,0 +1,49 @@ +# Zed Opener + +Adds Zed as an opt-in Linux editor opener in Codex App. The patch extends +the upstream Zed opener block with a Linux platform entry and reuses the +upstream `path:line:column` argument builder. + +This feature is opt-in. The loader reads enabled feature ids from the root +config at `linux-features/features.json`, then loads this feature's manifest +from `linux-features/zed-opener/feature.json`. + +To enable it locally, create the root config if needed: + +```bash +cp linux-features/features.example.json linux-features/features.json +``` + +Then list `zed-opener` in `linux-features/features.json`: + +```json +{ + "enabled": [ + "zed-opener" + ] +} +``` + +The Linux opener detects these commands in `PATH`, in order: + +- `zed` +- `zeditor` +- `zedit` +- `zed-cli` + +Run the feature tests with: + +```bash +node --test linux-features/zed-opener/test.js +``` + +To validate it against an extracted app bundle, enable `zed-opener` in a Linux +features config and run: + +```bash +node scripts/patch-linux-window-ui.js /path/to/extracted/app.asar +``` + +Known risk: the patch depends on the upstream minified Zed opener block. If +that block changes shape, the feature fails soft and leaves the bundle +unchanged. diff --git a/linux-features/zed-opener/feature.json b/linux-features/zed-opener/feature.json new file mode 100644 index 00000000..2298ba56 --- /dev/null +++ b/linux-features/zed-opener/feature.json @@ -0,0 +1,9 @@ +{ + "id": "zed-opener", + "title": "Zed Opener", + "description": "Adds Zed as an opt-in Linux editor opener when a supported Zed CLI binary is available.", + "defaultEnabled": false, + "entrypoints": { + "mainBundlePatch": "./patch.js" + } +} diff --git a/linux-features/zed-opener/patch.js b/linux-features/zed-opener/patch.js new file mode 100644 index 00000000..b367deae --- /dev/null +++ b/linux-features/zed-opener/patch.js @@ -0,0 +1,101 @@ +"use strict"; + +const { + escapeRegExp, + findMatchingBrace, +} = require("../../scripts/patches/shared.js"); + +const PATCH_NAME = "zed-opener feature patch"; + +function warn(message) { + console.warn(`WARN: ${message} - skipping ${PATCH_NAME}`); +} + +function findZedOpenerBlock(source) { + const markerStart = source.indexOf("id:`zed`"); + if (markerStart === -1) { + warn("Could not find Zed opener block"); + return null; + } + + const blockStart = Math.max( + source.lastIndexOf("var ", markerStart), + source.lastIndexOf("let ", markerStart), + source.lastIndexOf("const ", markerStart), + ); + const objectStart = blockStart === -1 ? -1 : source.indexOf("{", blockStart); + const objectEnd = objectStart === -1 ? -1 : findMatchingBrace(source, objectStart); + if (blockStart === -1 || objectStart === -1 || objectEnd === -1) { + warn("Could not parse Zed opener block"); + return null; + } + + const blockEnd = source[objectEnd + 1] === ";" ? objectEnd + 2 : objectEnd + 1; + return { + start: blockStart, + end: blockEnd, + text: source.slice(blockStart, blockEnd), + }; +} + +function findZedPathLookupFunction(source, detectFn) { + const detectFunctionRegex = new RegExp( + `function ${escapeRegExp(detectFn)}\\(\\)\\{return ([A-Za-z_$][\\w$]*)\\(\\\`zed\\\`\\)`, + ); + return source.match(detectFunctionRegex)?.[1] ?? null; +} + +function applyMainBundlePatch(currentSource) { + const block = findZedOpenerBlock(currentSource); + if (block == null) { + return currentSource; + } + if (block.text.includes("linux:{")) { + return currentSource; + } + + const argsFn = block.text.match(/\bargs:([A-Za-z_$][\w$]*)/)?.[1]; + const detectFn = block.text.match(/\bdarwin:\{[^}]*\bdetect:([A-Za-z_$][\w$]*)/)?.[1]; + if (argsFn == null || detectFn == null) { + warn("Could not identify Zed opener helpers"); + return currentSource; + } + + const pathLookupFn = findZedPathLookupFunction(currentSource, detectFn); + if (pathLookupFn == null) { + warn("Could not identify Zed path lookup helper"); + return currentSource; + } + + let insertionPoint = block.text.lastIndexOf("}}};"); + if (insertionPoint === -1) { + insertionPoint = block.text.lastIndexOf("}}}"); + } + if (insertionPoint === -1) { + warn("Could not find Zed opener insertion point"); + return currentSource; + } + + const linuxZed = + `,linux:{label:\`Zed\`,icon:\`apps/zed.png\`,kind:\`editor\`,detect:()=>${pathLookupFn}(\`zed\`)??${pathLookupFn}(\`zeditor\`)??${pathLookupFn}(\`zedit\`)??${pathLookupFn}(\`zed-cli\`),args:${argsFn}}`; + const patchedBlock = + block.text.slice(0, insertionPoint + 1) + linuxZed + block.text.slice(insertionPoint + 1); + const patchedSource = + currentSource.slice(0, block.start) + patchedBlock + currentSource.slice(block.end); + const patchedBlockCheck = patchedSource.slice(block.start, block.start + patchedBlock.length); + if ( + !patchedBlockCheck.includes("id:`zed`") || + !patchedBlockCheck.includes("linux:{label:`Zed`") || + !patchedBlockCheck.includes(`${pathLookupFn}(\`zeditor\`)`) || + !patchedBlockCheck.includes(`args:${argsFn}`) + ) { + console.warn(`WARN: Failed to apply ${PATCH_NAME}`); + return currentSource; + } + + return patchedSource; +} + +module.exports = { + applyMainBundlePatch, +}; diff --git a/linux-features/zed-opener/test.js b/linux-features/zed-opener/test.js new file mode 100644 index 00000000..fc4ece22 --- /dev/null +++ b/linux-features/zed-opener/test.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { applyMainBundlePatch } = require("./patch.js"); +const { + enabledLinuxFeatureIds, + loadLinuxFeatureMainBundlePatches, +} = require("../../scripts/lib/linux-features.js"); +const { + createPatchReport, + patchExtractedApp, + patchMainBundleSource, +} = require("../../scripts/patch-linux-window-ui.js"); + +const zedOpenerBundle = + "function Tw(e,t){return t?[`${e}:${t.line}:${t.column}`]:[e]}function Rp(e){return e}var eT={id:`zed`,platforms:{darwin:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:tT,args:Tw,open:async({command:e,path:t,location:n})=>{await aT(e,t,n)}},win32:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:nT,args:Tw}}};function tT(){return Rp(`zed`)??nC(`Zed`,`zed`)}function nT(){let e=Rp(`zed.exe`)??Rp(`zed`);return e}"; + +function applyPatchTwice(patchFn, source, ...args) { + const patched = patchFn(source, ...args); + assert.equal(patchFn(patched, ...args), patched); + return patched; +} + +function captureWarns(fn) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => { + warnings.push(args.map(String).join(" ")); + }; + try { + return { value: fn(), warnings }; + } finally { + console.warn = originalWarn; + } +} + +function withTempFeatureConfig(enabled, fn) { + const originalConfig = process.env.CODEX_LINUX_FEATURES_CONFIG; + const root = path.resolve(__dirname, ".."); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-zed-feature-test-")); + process.env.CODEX_LINUX_FEATURES_CONFIG = path.join(tempDir, "features.json"); + try { + fs.writeFileSync(process.env.CODEX_LINUX_FEATURES_CONFIG, JSON.stringify({ enabled }, null, 2)); + return fn(root); + } finally { + if (originalConfig == null) { + delete process.env.CODEX_LINUX_FEATURES_CONFIG; + } else { + process.env.CODEX_LINUX_FEATURES_CONFIG = originalConfig; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withLinuxFeatureRootEnv(root, fn) { + const originalRoot = process.env.CODEX_LINUX_FEATURES_ROOT; + process.env.CODEX_LINUX_FEATURES_ROOT = root; + try { + return fn(); + } finally { + if (originalRoot == null) { + delete process.env.CODEX_LINUX_FEATURES_ROOT; + } else { + process.env.CODEX_LINUX_FEATURES_ROOT = originalRoot; + } + } +} + +test("Zed opener feature adds Linux editor support to the upstream opener block", () => { + const patched = applyPatchTwice(applyMainBundlePatch, zedOpenerBundle); + + assert.match(patched, /linux:\{label:`Zed`,icon:`apps\/zed\.png`,kind:`editor`/); + assert.match( + patched, + /detect:\(\)=>Rp\(`zed`\)\?\?Rp\(`zeditor`\)\?\?Rp\(`zedit`\)\?\?Rp\(`zed-cli`\)/, + ); + assert.match(patched, /args:Tw/); +}); + +test("Zed opener feature is a no-op when Linux support is already present", () => { + const patched = applyMainBundlePatch(zedOpenerBundle); + + assert.equal(applyMainBundlePatch(patched), patched); +}); + +test("Zed opener feature fails soft when the opener block is missing", () => { + const { value, warnings } = captureWarns(() => applyMainBundlePatch("real codex bundle")); + + assert.equal(value, "real codex bundle"); + assert.match(warnings.join("\n"), /Could not find Zed opener block/); +}); + +test("Zed opener feature stays disabled until listed in features.json", () => { + withTempFeatureConfig([], (root) => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot: root }), []); + assert.deepEqual(loadLinuxFeatureMainBundlePatches({ featuresRoot: root }), []); + + withLinuxFeatureRootEnv(root, () => { + const { value: patched } = captureWarns(() => patchMainBundleSource(zedOpenerBundle, null)); + assert.doesNotMatch(patched, /linux:\{label:`Zed`/); + }); + }); +}); + +test("Zed opener feature exposes its patch when enabled", () => { + withTempFeatureConfig(["zed-opener"], (root) => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot: root }), ["zed-opener"]); + + const patches = loadLinuxFeatureMainBundlePatches({ featuresRoot: root }); + assert.equal(patches.length, 1); + assert.equal(patches[0].name, "feature:zed-opener"); + assert.match(patches[0].apply(zedOpenerBundle, {}), /linux:\{label:`Zed`/); + }); +}); + +test("Zed opener feature participates in main bundle patching and patch reports", () => { + withTempFeatureConfig(["zed-opener"], (root) => { + withLinuxFeatureRootEnv(root, () => { + assert.match( + captureWarns(() => patchMainBundleSource(zedOpenerBundle, null)).value, + /linux:\{label:`Zed`/, + ); + + const tempApp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-zed-feature-app-")); + try { + const buildDir = path.join(tempApp, ".vite", "build"); + const assetsDir = path.join(tempApp, "webview", "assets"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, "main.js"), zedOpenerBundle); + fs.writeFileSync(path.join(tempApp, "package.json"), JSON.stringify({ name: "codex" })); + + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(tempApp, { report })); + + assert.match(fs.readFileSync(path.join(buildDir, "main.js"), "utf8"), /linux:\{label:`Zed`/); + assert.ok( + report.patches.some((patch) => patch.name === "feature:zed-opener" && patch.status === "applied"), + ); + } finally { + fs.rmSync(tempApp, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/packaging/linux/codex-app.spec b/packaging/linux/codex-app.spec index 37efa442..ac342892 100644 --- a/packaging/linux/codex-app.spec +++ b/packaging/linux/codex-app.spec @@ -6,8 +6,10 @@ License: Proprietary ExclusiveArch: __ARCH__ Provides: codex-desktop Obsoletes: codex-desktop +%global __requires_exclude_from ^/opt/__PACKAGE_NAME__/.*$ +%global __provides_exclude_from ^/opt/__PACKAGE_NAME__/.*$ -Requires: python3, p7zip, polkit, curl, unzip, gcc-c++, make +Requires: python3, /usr/bin/7z, polkit, curl, unzip, gcc-c++, make Requires: alsa-lib, at-spi2-atk, atk, glib2, gtk3, libdrm Requires: nspr, nss, pango, libstdc++, libX11, libxcb Requires: libXcomposite, libXdamage, libXext, libXfixes, libxkbcommon, libXrandr diff --git a/plugins/openai-bundled/plugins/computer-use/.codex-plugin/plugin.json b/plugins/openai-bundled/plugins/computer-use/.codex-plugin/plugin.json index 1e43f8c1..71e048e3 100644 --- a/plugins/openai-bundled/plugins/computer-use/.codex-plugin/plugin.json +++ b/plugins/openai-bundled/plugins/computer-use/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "computer-use", - "version": "0.1.1-linux-alpha1", + "version": "0.1.2-linux-alpha2", "description": "Control desktop apps on Linux from Codex through Computer Use.", "author": { "name": "avifenesh" @@ -13,6 +13,7 @@ "linux", "wayland", "gnome", + "cosmic", "accessibility" ], "mcpServers": "./.mcp.json", diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh new file mode 100755 index 00000000..30715872 --- /dev/null +++ b/scripts/ci-local.sh @@ -0,0 +1,226 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +CI_PACKAGE_VERSION="${CI_PACKAGE_VERSION:-}" +CI_CACHE_DIR="${CI_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/codex-app-linux-ci}" + +IMAGE_UBUNTU_24="${CI_IMAGE_UBUNTU_24:-docker.io/library/ubuntu:24.04@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b}" +IMAGE_UBUNTU_22="${CI_IMAGE_UBUNTU_22:-docker.io/library/ubuntu:22.04@sha256:962f6cadeae0ea6284001009daa4cc9a8c37e75d1f5191cf0eb83fe565b63dd7}" +IMAGE_DEBIAN_12="${CI_IMAGE_DEBIAN_12:-docker.io/library/debian:12@sha256:8a8cd02c5912770b4980228a54d4aff9e4f986f1eb2525d2d371dec5232cefcc}" +IMAGE_FEDORA_42="${CI_IMAGE_FEDORA_42:-docker.io/library/fedora:42@sha256:99e203b80b1c3d8f7e161ec10a68fd02b081ef83a3963553e513c82846b97814}" +IMAGE_ARCH_BASE_DEVEL="${CI_IMAGE_ARCH_BASE_DEVEL:-docker.io/library/archlinux:base-devel@sha256:fdff15f24df062598faebf380430955a9bd2109736e179ebb354f1208f725774}" +IMAGE_NIX="${CI_IMAGE_NIX:-docker.io/nixos/nix:latest@sha256:bf1d938835ab96312f098fa6c2e9cab367728e0aad0646ee3e02a787c80d8fb8}" + +usage() { + cat <<'HELP' +Usage: ./scripts/ci-local.sh [target...] + +Targets: + pr Run the standard pull-request suite: core, deb, rpm, pacman + all Run pr plus install-deps, nix, and upstream + core Run shell, Rust, Node patcher, and smoke tests + deb Build and inspect the Debian package + rpm Build and inspect the RPM package + pacman Build and inspect the pacman package + install-deps Test install-deps on Ubuntu 22.04, Ubuntu 24.04, Debian 12, and Fedora 42 + install-deps:ubuntu-22.04 Test install-deps on one apt image + install-deps:ubuntu-24.04 Test install-deps on one apt image + install-deps:debian-12 Test install-deps on one apt image + install-deps:fedora-42 Test install-deps on one dnf5 image + nix Run the heavy Nix flake build checks + upstream Build the app against the upstream DMG + +Environment: + CI_CONTAINER_ENGINE=docker|podman + CI_PACKAGE_VERSION= + CI_DMG_PATH=/path/to/Codex.dmg + CI_SKIP_PULL=1 + CI_CACHE_DIR=/path/to/cache + +Note: package targets recreate generated codex-app/ and dist/ just like GitHub CI. +HELP +} + +info() { + echo "[ci-local] $*" >&2 +} + +error() { + echo "[ci-local][ERROR] $*" >&2 + exit 1 +} + +container_engine() { + if [ -n "${CI_CONTAINER_ENGINE:-}" ]; then + command -v "$CI_CONTAINER_ENGINE" >/dev/null 2>&1 || error "CI_CONTAINER_ENGINE is not available: $CI_CONTAINER_ENGINE" + echo "$CI_CONTAINER_ENGINE" + return + fi + + if command -v docker >/dev/null 2>&1; then + echo docker + return + fi + if command -v podman >/dev/null 2>&1; then + echo podman + return + fi + + error "Docker or Podman is required. Install one, or set CI_CONTAINER_ENGINE explicitly." +} + +image_for_key() { + case "$1" in + ubuntu-24.04) echo "$IMAGE_UBUNTU_24" ;; + ubuntu-22.04) echo "$IMAGE_UBUNTU_22" ;; + debian-12) echo "$IMAGE_DEBIAN_12" ;; + fedora-42) echo "$IMAGE_FEDORA_42" ;; + archlinux-base-devel) echo "$IMAGE_ARCH_BASE_DEVEL" ;; + nix) echo "$IMAGE_NIX" ;; + *) error "Unknown CI image key: $1" ;; + esac +} + +image_key_for_job() { + case "$1" in + core|deb|upstream) echo "ubuntu-24.04" ;; + rpm) echo "fedora-42" ;; + pacman) echo "archlinux-base-devel" ;; + nix) echo "nix" ;; + *) error "No default image for job: $1" ;; + esac +} + +mount_github_summary_args() { + local -n _args="$1" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + local summary_dir + summary_dir="$(dirname "$GITHUB_STEP_SUMMARY")" + mkdir -p "$summary_dir" + _args+=(-e "GITHUB_STEP_SUMMARY=$GITHUB_STEP_SUMMARY" -v "$summary_dir:$summary_dir") + fi +} + +mount_upstream_args() { + local -n _args="$1" + local upstream_dir="/tmp/codex-upstream-ci" + mkdir -p "$upstream_dir" + _args+=(-v "$upstream_dir:$upstream_dir") + + if [ -n "${CI_DMG_PATH:-}" ] && [ "${CI_DMG_PATH#/}" != "$CI_DMG_PATH" ]; then + local dmg_dir + dmg_dir="$(dirname "$CI_DMG_PATH")" + mkdir -p "$dmg_dir" + _args+=(-v "$dmg_dir:$dmg_dir") + fi +} + +run_container_job() { + local job="$1" + local image_key="$2" + local engine + local image + engine="$(container_engine)" + image="$(image_for_key "$image_key")" + + mkdir -p "$CI_CACHE_DIR" + + if [ "${CI_SKIP_PULL:-0}" != "1" ]; then + info "Pulling $image_key image" + "$engine" pull "$image" >/dev/null + fi + + local -a args=( + run + --rm + -e "CI_JOB=$job" + -e "CI_IMAGE_KEY=$image_key" + -e "CI_HOST_UID=$(id -u)" + -e "CI_HOST_GID=$(id -g)" + -e "CARGO_TERM_COLOR=${CARGO_TERM_COLOR:-always}" + -e "UPSTREAM_DMG_URL=${UPSTREAM_DMG_URL:-https://persistent.oaistatic.com/codex-app-prod/Codex.dmg}" + -e "UPSTREAM_DMG_PATH=${UPSTREAM_DMG_PATH:-/tmp/codex-upstream-ci/Codex.dmg}" + -v "$REPO_DIR:/work" + -v "$CI_CACHE_DIR:/ci-cache" + -w /work + ) + if [ -n "$CI_PACKAGE_VERSION" ]; then + args+=( + -e "CI_PACKAGE_VERSION=$CI_PACKAGE_VERSION" + -e "PACKAGE_VERSION=$CI_PACKAGE_VERSION" + ) + fi + + if [ -n "${CI_DMG_PATH:-}" ]; then + args+=(-e "CI_DMG_PATH=$CI_DMG_PATH") + fi + if [ -n "${UPSTREAM_DMG_CACHE_HIT:-}" ]; then + args+=(-e "UPSTREAM_DMG_CACHE_HIT=$UPSTREAM_DMG_CACHE_HIT") + fi + + mount_github_summary_args args + if [ "$job" = "upstream" ]; then + mount_upstream_args args + fi + + info "Running $job in $image_key" + "$engine" "${args[@]}" "$image" bash /work/scripts/ci/container-entrypoint.sh "$job" +} + +run_target() { + local target="$1" + + case "$target" in + -h|--help|help) + usage + ;; + pr) + run_target core + run_target deb + run_target rpm + run_target pacman + ;; + all) + run_target pr + run_target install-deps + run_target nix + run_target upstream + ;; + core|deb|rpm|pacman|nix|upstream) + run_container_job "$target" "$(image_key_for_job "$target")" + ;; + install-deps) + run_target install-deps:ubuntu-22.04 + run_target install-deps:ubuntu-24.04 + run_target install-deps:debian-12 + run_target install-deps:fedora-42 + ;; + install-deps:ubuntu-22.04) + run_container_job install-deps ubuntu-22.04 + ;; + install-deps:ubuntu-24.04) + run_container_job install-deps ubuntu-24.04 + ;; + install-deps:debian-12) + run_container_job install-deps debian-12 + ;; + install-deps:fedora-42) + run_container_job install-deps fedora-42 + ;; + *) + usage >&2 + error "Unknown target: $target" + ;; + esac +} + +if [ "$#" -eq 0 ]; then + set -- pr +fi + +for target in "$@"; do + run_target "$target" +done diff --git a/scripts/ci/container-entrypoint.sh b/scripts/ci/container-entrypoint.sh new file mode 100755 index 00000000..43cff652 --- /dev/null +++ b/scripts/ci/container-entrypoint.sh @@ -0,0 +1,528 @@ +#!/bin/bash +set -Eeuo pipefail + +REPO_DIR="/work" +CI_JOB="${1:-${CI_JOB:-}}" +CI_IMAGE_KEY="${CI_IMAGE_KEY:-unknown}" +CI_HOST_UID="${CI_HOST_UID:-1000}" +CI_HOST_GID="${CI_HOST_GID:-1000}" +CI_PACKAGE_VERSION="${CI_PACKAGE_VERSION:-2026.04.28.000000+local}" +CI_CARGO_HOME="${CARGO_HOME:-/ci-cache/cargo}" +CI_RUSTUP_HOME="${RUSTUP_HOME:-/ci-cache/rustup}" +CI_NPM_CACHE="${npm_config_cache:-/ci-cache/npm}" + +info() { + echo "[ci:$CI_JOB] $*" >&2 +} + +error() { + echo "[ci:$CI_JOB][ERROR] $*" >&2 + exit 1 +} + +append_summary() { + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 + { + echo "## $1" + echo "" + shift + for line in "$@"; do + echo "- $line" + done + echo "" + } >> "$GITHUB_STEP_SUMMARY" +} + +apt_install() { + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y --no-install-recommends "$@" + rm -rf /var/lib/apt/lists/* +} + +prepare_apt_ci() { + apt_install \ + bash \ + ca-certificates \ + curl \ + file \ + g++ \ + gcc \ + git \ + libssl-dev \ + make \ + nodejs \ + npm \ + p7zip-full \ + pkg-config \ + python3 \ + tar \ + unzip \ + xz-utils +} + +prepare_apt_install_deps() { + apt_install ca-certificates curl sudo +} + +fedora_install() { + if command -v dnf5 >/dev/null 2>&1; then + dnf5 install -y "$@" + dnf5 clean all + elif command -v dnf >/dev/null 2>&1; then + dnf install -y "$@" + dnf clean all + else + error "Fedora image is missing dnf/dnf5" + fi +} + +prepare_fedora_install_deps() { + fedora_install ca-certificates curl sudo +} + +prepare_install_deps_bootstrap() { + case "$CI_IMAGE_KEY" in + fedora-*) prepare_fedora_install_deps ;; + *) prepare_apt_install_deps ;; + esac +} + +prepare_fedora_ci() { + dnf install -y \ + bash \ + ca-certificates \ + curl \ + findutils \ + gcc \ + gcc-c++ \ + git \ + make \ + nodejs \ + npm \ + openssl-devel \ + pkgconf-pkg-config \ + python3 \ + rpm \ + rpm-build \ + shadow-utils \ + tar \ + unzip \ + which \ + xz + dnf clean all +} + +prepare_arch_ci() { + pacman -Syu --noconfirm --needed \ + base-devel \ + ca-certificates \ + curl \ + git \ + nodejs \ + npm \ + python \ + rustup \ + sudo \ + unzip \ + xz \ + zstd +} + +ensure_ci_user() { + if [ "$CI_HOST_UID" = "0" ]; then + CI_USER="root" + CI_HOME="/root" + return 0 + fi + + local group_name + if getent group "$CI_HOST_GID" >/dev/null 2>&1; then + group_name="$(getent group "$CI_HOST_GID" | cut -d: -f1)" + else + group_name="ci" + groupadd -g "$CI_HOST_GID" "$group_name" + fi + + if getent passwd "$CI_HOST_UID" >/dev/null 2>&1; then + CI_USER="$(getent passwd "$CI_HOST_UID" | cut -d: -f1)" + else + CI_USER="ci" + useradd -m -u "$CI_HOST_UID" -g "$CI_HOST_GID" -s /bin/bash "$CI_USER" + fi + + CI_HOME="$(getent passwd "$CI_HOST_UID" | cut -d: -f6)" + mkdir -p "$CI_HOME" "$CI_CARGO_HOME" "$CI_RUSTUP_HOME" "$CI_NPM_CACHE" + chown -R "$CI_HOST_UID:$CI_HOST_GID" "$CI_HOME" /ci-cache +} + +quote_args() { + printf '%q ' "$@" +} + +run_as_ci_user() { + local script_path="$REPO_DIR/scripts/ci/container-entrypoint.sh" + local -a env_cmd=( + env + "HOME=$CI_HOME" + "USER=$CI_USER" + "LOGNAME=$CI_USER" + "CI=true" + "CI_CONTAINER_PHASE=job" + "CI_JOB=$CI_JOB" + "CI_IMAGE_KEY=$CI_IMAGE_KEY" + "CI_PACKAGE_VERSION=$CI_PACKAGE_VERSION" + "PACKAGE_VERSION=$CI_PACKAGE_VERSION" + "CI_DMG_PATH=${CI_DMG_PATH:-}" + "UPSTREAM_DMG_URL=${UPSTREAM_DMG_URL:-https://persistent.oaistatic.com/codex-app-prod/Codex.dmg}" + "UPSTREAM_DMG_PATH=${UPSTREAM_DMG_PATH:-/tmp/codex-upstream-ci/Codex.dmg}" + "UPSTREAM_DMG_CACHE_HIT=${UPSTREAM_DMG_CACHE_HIT:-}" + "GITHUB_STEP_SUMMARY=${GITHUB_STEP_SUMMARY:-}" + "CARGO_HOME=$CI_CARGO_HOME" + "RUSTUP_HOME=$CI_RUSTUP_HOME" + "npm_config_cache=$CI_NPM_CACHE" + "CARGO_TERM_COLOR=${CARGO_TERM_COLOR:-always}" + "PATH=$CI_CARGO_HOME/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + bash + "$script_path" + "$CI_JOB" + ) + + if [ "$CI_HOST_UID" = "0" ]; then + "${env_cmd[@]}" + else + su -s /bin/bash "$CI_USER" -c "$(quote_args "${env_cmd[@]}")" + fi +} + +enter_workspace() { + cd "$REPO_DIR" + git config --global --add safe.directory "$REPO_DIR" >/dev/null 2>&1 || true +} + +ensure_rust_toolchain() { + mkdir -p "$CI_CARGO_HOME" "$CI_RUSTUP_HOME" + if ! command -v rustup >/dev/null 2>&1; then + info "Installing Rust toolchain with rustup" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable + fi + + rustup toolchain install stable --profile minimal --component rustfmt --component clippy + rustup default stable + rustc --version + cargo --version +} + +package_file_or_fail() { + local pattern="$1" + local package_file + package_file="$(find dist -maxdepth 1 -name "$pattern" -print -quit)" + [ -n "$package_file" ] || error "No package found matching: $pattern" + printf '%s\n' "$package_file" +} + +assert_contains_file() { + local path="$1" + local pattern="$2" + grep -q -- "$pattern" "$path" || error "Expected '$pattern' in $path" +} + +prepare_package_fixture() { + rm -rf codex-app dist + tests/fixtures/create-packaged-app-fixture.sh codex-app +} + +package_target_dir() { + local target_dir="/ci-cache/target/$CI_IMAGE_KEY" + mkdir -p "$target_dir" + printf '%s\n' "$target_dir" +} + +run_core_job() { + enter_workspace + ensure_rust_toolchain + + bash -n install.sh + bash -n scripts/lib/*.sh + bash -n launcher/start.sh.template + bash -n scripts/install-deps.sh + bash -n scripts/build-deb.sh + bash -n scripts/build-rpm.sh + bash -n scripts/build-pacman.sh + bash -n scripts/ci-local.sh + bash -n scripts/ci/*.sh + + cargo fmt --check + cargo clippy --workspace --all-targets -- -D warnings + cargo check --workspace --all-targets + cargo test --workspace --all-targets + + node --check scripts/patch-linux-window-ui.js + node --check scripts/patch-linux-window-ui.test.js + for file in scripts/patches/*.js; do + node --check "$file" + done + node --check scripts/ci/validate-patch-report.js + node --test scripts/patch-linux-window-ui.test.js + + bash tests/scripts_smoke.sh + + append_summary "Rust and Smoke Tests" \ + "Shell syntax checks passed." \ + "Rust formatting, clippy, check, and tests passed." \ + "Node patcher checks and script smoke tests passed." +} + +run_deb_job() { + enter_workspace + ensure_rust_toolchain + prepare_package_fixture + + local target_dir + target_dir="$(package_target_dir)" + CARGO_TARGET_DIR="$target_dir" \ + UPDATER_BINARY_SOURCE="$target_dir/release/codex-app-updater" \ + PACKAGE_VERSION="$CI_PACKAGE_VERSION" \ + ./scripts/build-deb.sh + + local deb_file + deb_file="$(package_file_or_fail 'codex-app_*.deb')" + dpkg-deb -I "$deb_file" + dpkg-deb -c "$deb_file" | tee /tmp/deb-contents.txt >/dev/null + assert_contains_file /tmp/deb-contents.txt './usr/bin/codex-app-updater' + assert_contains_file /tmp/deb-contents.txt './usr/lib/systemd/user/codex-app-updater.service' + assert_contains_file /tmp/deb-contents.txt './usr/lib/codex-app/update-builder/install.sh' + assert_contains_file /tmp/deb-contents.txt './usr/lib/codex-app/packaged-runtime.sh' + + append_summary "Debian Package Validation" \ + "Built: \`$(basename "$deb_file")\`" \ + "Verified updater binary, user service, update-builder bundle, and packaged runtime helper." +} + +run_rpm_job() { + enter_workspace + ensure_rust_toolchain + prepare_package_fixture + + local target_dir + target_dir="$(package_target_dir)" + CARGO_TARGET_DIR="$target_dir" \ + UPDATER_BINARY_SOURCE="$target_dir/release/codex-app-updater" \ + PACKAGE_VERSION="$CI_PACKAGE_VERSION" \ + ./scripts/build-rpm.sh + + local rpm_file + rpm_file="$(package_file_or_fail 'codex-app-*.rpm')" + rpm -qip "$rpm_file" + rpm -qlp "$rpm_file" | tee /tmp/rpm-contents.txt >/dev/null + assert_contains_file /tmp/rpm-contents.txt '/usr/bin/codex-app-updater' + assert_contains_file /tmp/rpm-contents.txt '/usr/lib/systemd/user/codex-app-updater.service' + assert_contains_file /tmp/rpm-contents.txt '/usr/lib/codex-app/update-builder/install.sh' + assert_contains_file /tmp/rpm-contents.txt '/usr/lib/codex-app/packaged-runtime.sh' + + append_summary "RPM Package Validation" \ + "Built: \`$(basename "$rpm_file")\`" \ + "Verified updater binary, user service, update-builder bundle, and packaged runtime helper." +} + +run_pacman_job() { + enter_workspace + ensure_rust_toolchain + prepare_package_fixture + + local target_dir + target_dir="$(package_target_dir)" + CARGO_TARGET_DIR="$target_dir" cargo build --release -p codex-app-updater + CARGO_TARGET_DIR="$target_dir" \ + UPDATER_BINARY_SOURCE="$target_dir/release/codex-app-updater" \ + PACKAGE_VERSION="$CI_PACKAGE_VERSION" \ + ./scripts/build-pacman.sh + + local pkg_file + pkg_file="$(package_file_or_fail 'codex-app-*.pkg.tar.*')" + pacman -Qip "$pkg_file" + pacman -Qlp "$pkg_file" | tee /tmp/pacman-contents.txt >/dev/null + assert_contains_file /tmp/pacman-contents.txt 'usr/bin/codex-app-updater' + assert_contains_file /tmp/pacman-contents.txt 'usr/lib/systemd/user/codex-app-updater.service' + assert_contains_file /tmp/pacman-contents.txt 'usr/lib/codex-app/update-builder/install.sh' + assert_contains_file /tmp/pacman-contents.txt 'usr/lib/codex-app/packaged-runtime.sh' + + append_summary "Pacman Package Validation" \ + "Built: \`$(basename "$pkg_file")\`" \ + "Verified updater binary, user service, update-builder bundle, and packaged runtime helper." +} + +run_install_deps_job_as_root() { + enter_workspace + + bash scripts/install-deps.sh + export PATH="$HOME/.local/bin:$PATH" + + if command -v node >/dev/null 2>&1; then + node -p "process.versions.node" + node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' + npm -v + npx -v + if command -v dpkg-query >/dev/null 2>&1; then + dpkg-query -W -f='${Provides}\n' nodejs | grep -E '(^|[,[:space:]])npm([,[:space:](]|$)' + elif command -v rpm >/dev/null 2>&1; then + rpm -q nodejs npm >/dev/null + elif command -v pacman >/dev/null 2>&1; then + pacman -Q nodejs npm >/dev/null + fi + else + case "$CI_IMAGE_KEY" in + fedora-*) info "System Node.js is not required for $CI_IMAGE_KEY; install.sh provides the managed runtime" ;; + *) error "Node.js 20+ with npm and npx should be installed by install-deps" ;; + esac + fi + + local output + local status + output="$(./install.sh /tmp/nope.dmg 2>&1)" && status=0 || status=$? + printf '%s\n' "$output" + [ "$status" -ne 0 ] || error "Installer should fail for a missing DMG" + printf '%s\n' "$output" | grep -q "Provided DMG not found: /tmp/nope.dmg" + if printf '%s\n' "$output" | grep -q "Node.js 20+ required"; then + error "Installer still failed Node preflight after install-deps" + fi + + append_summary "Install Dependencies Validation" \ + "Image: \`$CI_IMAGE_KEY\`" \ + "Node.js, npm, npx, and installer preflight passed." +} + +capture_upstream_metadata() { + local dmg_path="$1" + local headers_file + headers_file="$(mktemp)" + + local last_modified="unknown" + local etag="no-etag" + local content_length="unknown" + if curl -fsSLI "$UPSTREAM_DMG_URL" > "$headers_file"; then + last_modified="$(awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {sub(/\r$/,""); sub(/^[^:]+: /,""); print; exit}' "$headers_file")" + etag="$(awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/,""); sub(/^[^:]+: /,""); gsub(/"/,""); print; exit}' "$headers_file")" + content_length="$(awk 'BEGIN{IGNORECASE=1} /^content-length:/ {sub(/\r$/,""); sub(/^[^:]+: /,""); print; exit}' "$headers_file")" + fi + rm -f "$headers_file" + + [ -n "$last_modified" ] || last_modified="unknown" + [ -n "$etag" ] || etag="no-etag" + [ -n "$content_length" ] || content_length="unknown" + + local dmg_sha256 + local dmg_size_bytes + local tested_at_utc + dmg_sha256="$(sha256sum "$dmg_path" | cut -d' ' -f1)" + dmg_size_bytes="$(stat -c '%s' "$dmg_path")" + tested_at_utc="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + + node - "$UPSTREAM_DMG_URL" "$dmg_path" "$last_modified" "$etag" "$content_length" "$dmg_sha256" "$dmg_size_bytes" "$tested_at_utc" "${UPSTREAM_DMG_CACHE_HIT:-unknown}" <<'NODE' +const fs = require("node:fs"); +const [url, path, lastModified, etag, contentLength, sha256, sizeBytes, testedAtUtc, cacheHit] = process.argv.slice(2); +const metadata = { + url, + path, + last_modified: lastModified, + etag, + content_length: contentLength, + sha256, + size_bytes: Number(sizeBytes), + tested_at_utc: testedAtUtc, + cache_hit: cacheHit, +}; +fs.writeFileSync("upstream-dmg-metadata.json", JSON.stringify(metadata, null, 2) + "\n"); +NODE + + append_summary "Upstream Build App" \ + "DMG URL: \`$UPSTREAM_DMG_URL\`" \ + "DMG Last-Modified: \`$last_modified\`" \ + "DMG ETag: \`$etag\`" \ + "DMG Content-Length: \`$content_length\`" \ + "DMG SHA-256: \`$dmg_sha256\`" \ + "DMG Size (bytes): \`$dmg_size_bytes\`" \ + "Tested At (UTC): \`$tested_at_utc\`" \ + "Cache Hit: \`${UPSTREAM_DMG_CACHE_HIT:-unknown}\`" \ + "Build command: \`make build-app DMG=$dmg_path\`" +} + +run_upstream_job() { + enter_workspace + ensure_rust_toolchain + + local dmg_path="${CI_DMG_PATH:-${UPSTREAM_DMG_PATH:-/tmp/codex-upstream-ci/Codex.dmg}}" + mkdir -p "$(dirname "$dmg_path")" + + if [ ! -s "$dmg_path" ]; then + info "Downloading upstream DMG" + curl -fL --retry 3 -o "$dmg_path" "$UPSTREAM_DMG_URL" + else + info "Using cached upstream DMG: $dmg_path" + fi + + capture_upstream_metadata "$dmg_path" + make build-app DMG="$dmg_path" +} + +run_nix_job_as_root() { + enter_workspace + export NIX_CONFIG="${NIX_CONFIG:-experimental-features = nix-command flakes}" + + nix flake check --no-write-lock-file --option sandbox false + nix build .#codex-app --no-link --print-build-logs --option sandbox false + nix build .#installer --no-link --print-build-logs --option sandbox false + + append_summary "Nix Validation" \ + "Flake check passed." \ + "Built .#codex-app and .#installer without result links." +} + +run_job_as_current_user() { + case "$CI_JOB" in + core) run_core_job ;; + deb) run_deb_job ;; + rpm) run_rpm_job ;; + pacman) run_pacman_job ;; + upstream) run_upstream_job ;; + *) error "Unsupported user-phase job: $CI_JOB" ;; + esac +} + +if [ -z "$CI_JOB" ]; then + error "Missing CI job name" +fi + +if [ "${CI_CONTAINER_PHASE:-root}" = "job" ]; then + run_job_as_current_user + exit 0 +fi + +case "$CI_JOB" in + core|deb|upstream) + prepare_apt_ci + ensure_ci_user + run_as_ci_user + ;; + rpm) + prepare_fedora_ci + ensure_ci_user + run_as_ci_user + ;; + pacman) + prepare_arch_ci + ensure_ci_user + run_as_ci_user + ;; + install-deps) + prepare_install_deps_bootstrap + run_install_deps_job_as_root + ;; + nix) + run_nix_job_as_root + ;; + *) + error "Unsupported CI job: $CI_JOB" + ;; +esac diff --git a/scripts/ci/update-nix-hashes.sh b/scripts/ci/update-nix-hashes.sh new file mode 100755 index 00000000..ac83fce3 --- /dev/null +++ b/scripts/ci/update-nix-hashes.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_DIR="${REPO_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +FLAKE_FILE="${FLAKE_FILE:-$REPO_DIR/flake.nix}" +UPSTREAM_DMG_URL="${UPSTREAM_DMG_URL:-https://persistent.oaistatic.com/codex-app-prod/Codex.dmg}" +UPSTREAM_DMG_PATH="${UPSTREAM_DMG_PATH:-/tmp/Codex.dmg}" +BUILD_LOG="${BUILD_LOG:-/tmp/codex-nix-build.log}" +VERIFY_LOG="${VERIFY_LOG:-/tmp/codex-nix-build-verify.log}" + +validate_sri_hash() { + local hash="$1" + [[ "$hash" =~ ^sha256-[A-Za-z0-9+/=]{44}$ ]] +} + +replace_flake_hash() { + local anchor="$1" + local key="$2" + local new_hash="$3" + + python3 - "$FLAKE_FILE" "$anchor" "$key" "$new_hash" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +anchor = sys.argv[2] +key = sys.argv[3] +new_hash = sys.argv[4] + +lines = path.read_text().splitlines(keepends=True) +in_block = False +for index, line in enumerate(lines): + if anchor in line: + in_block = True + continue + if not in_block: + continue + if key in line: + lines[index] = re.sub(r'sha256-[^"]+', new_hash, line, count=1) + path.write_text("".join(lines)) + raise SystemExit(0) + if line.strip() == "};": + break + +raise SystemExit(f"Could not find {key!r} after {anchor!r} in {path}") +PY +} + +read_flake_hash() { + local anchor="$1" + local key="$2" + + python3 - "$FLAKE_FILE" "$anchor" "$key" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +anchor = sys.argv[2] +key = sys.argv[3] + +in_block = False +for line in path.read_text().splitlines(): + if anchor in line: + in_block = True + continue + if not in_block: + continue + if key in line: + match = re.search(r'sha256-[^"]+', line) + if match: + print(match.group(0)) + raise SystemExit(0) + if line.strip() == "};": + break + +raise SystemExit(f"Could not find {key!r} after {anchor!r} in {path}") +PY +} + +extract_got_sri_hash() { + local log_path="$1" + + python3 - "$log_path" <<'PY' +from pathlib import Path +import re +import sys + +text = Path(sys.argv[1]).read_text(errors="replace") +text = re.sub(r"\x1b\[[0-9;]*m", "", text) +matches = re.findall(r"got:\s*(sha256-[A-Za-z0-9+/=]{44})", text) +if not matches: + raise SystemExit(1) +print(matches[-1]) +PY +} + +run_nix_build() { + local log_path="$1" + rm -f "$log_path" + set +e + ( + cd "$REPO_DIR" || exit 1 + nix build .#codex-app --no-link --print-build-logs + ) 2>&1 | tee "$log_path" + local status="${PIPESTATUS[0]}" + set -e + return "$status" +} + +main() { + mkdir -p "$(dirname "$UPSTREAM_DMG_PATH")" + curl -fL --retry 3 -o "$UPSTREAM_DMG_PATH" "$UPSTREAM_DMG_URL" + + new_dmg_hash="$(nix hash file --sri --type sha256 "$UPSTREAM_DMG_PATH")" + if ! validate_sri_hash "$new_dmg_hash"; then + echo "Refusing to proceed: computed DMG hash '$new_dmg_hash' is not a valid SRI sha256." >&2 + exit 1 + fi + + current_dmg_hash="$(read_flake_hash "codexDmg = pkgs.fetchurl {" "hash = ")" + echo "Current Codex.dmg hash: $current_dmg_hash" + echo "Upstream Codex.dmg hash: $new_dmg_hash" + replace_flake_hash "codexDmg = pkgs.fetchurl {" "hash = " "$new_dmg_hash" + + # Seed the Nix store so the build can reuse the DMG that was already downloaded + # for hashing instead of fetching the same 300MB artifact again. + if ! nix-store --add-fixed sha256 "$UPSTREAM_DMG_PATH" >/dev/null; then + echo "Warning: failed to seed Codex.dmg into the Nix store; continuing with normal fetch path." >&2 + fi + + if run_nix_build "$BUILD_LOG"; then + echo "Nix build succeeded with the current payload outputHash." + exit 0 + fi + + new_payload_hash="$(extract_got_sri_hash "$BUILD_LOG" || true)" + if [ -z "$new_payload_hash" ]; then + echo "Nix build failed without a fixed-output hash mismatch; leaving log at $BUILD_LOG" >&2 + exit 1 + fi + + if ! validate_sri_hash "$new_payload_hash"; then + echo "Refusing to proceed: extracted payload hash '$new_payload_hash' is not a valid SRI sha256." >&2 + exit 1 + fi + + current_payload_hash="$(read_flake_hash "codexDesktopPayload = pkgs.stdenv.mkDerivation {" "outputHash = ")" + echo "Current payload outputHash: $current_payload_hash" + echo "Actual payload outputHash: $new_payload_hash" + replace_flake_hash "codexDesktopPayload = pkgs.stdenv.mkDerivation {" "outputHash = " "$new_payload_hash" + + run_nix_build "$VERIFY_LOG" + echo "Nix build succeeded after refreshing the payload outputHash." +} + +case "${1:-}" in + read-flake-hash) + if [ "$#" -ne 3 ]; then + echo "usage: $0 read-flake-hash " >&2 + exit 2 + fi + read_flake_hash "$2" "$3" + ;; + "") + main + ;; + *) + echo "unknown command: $1" >&2 + exit 2 + ;; +esac diff --git a/scripts/ci/validate-patch-report.js b/scripts/ci/validate-patch-report.js new file mode 100644 index 00000000..8c160e40 --- /dev/null +++ b/scripts/ci/validate-patch-report.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const { + requiredPatchNamesForProfile, +} = require("../patches/registry.js"); + +const SUCCESS_STATUSES = new Set(["applied", "already-applied"]); +const KNOWN_PROFILES = new Set(["upstream-build"]); + +function usage() { + return "Usage: validate-patch-report.js [--profile upstream-build]"; +} + +function parseArgs(argv) { + let profile = "upstream-build"; + const positional = []; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--profile") { + profile = argv[index + 1]; + if (!profile) { + throw new Error(usage()); + } + index += 1; + } else if (arg === "--help" || arg === "-h") { + console.log(usage()); + process.exit(0); + } else { + positional.push(arg); + } + } + + if (positional.length !== 1) { + throw new Error(usage()); + } + if (!KNOWN_PROFILES.has(profile)) { + throw new Error(`Unknown patch validation profile: ${profile}`); + } + + return { profile, reportPath: positional[0] }; +} + +function readReport(reportPath) { + const raw = fs.readFileSync(reportPath, "utf8"); + const report = JSON.parse(raw); + if (report == null || typeof report !== "object" || !Array.isArray(report.patches)) { + throw new Error(`Invalid patch report: ${reportPath}`); + } + return report; +} + +function validateReport(report, profile) { + const requiredNames = requiredPatchNamesForProfile(profile); + const entriesByName = new Map(); + const failures = []; + + for (const [index, patch] of report.patches.entries()) { + if (patch == null || typeof patch !== "object") { + failures.push(`patch[${index}]: malformed patch entry`); + continue; + } + if (typeof patch.name !== "string" || patch.name.length === 0) { + failures.push(`patch[${index}]: missing patch name`); + continue; + } + if (typeof patch.status !== "string" || patch.status.length === 0) { + failures.push(`${patch.name}: missing patch status`); + } + if (!entriesByName.has(patch.name)) { + entriesByName.set(patch.name, []); + } + entriesByName.get(patch.name).push(patch); + } + + for (const [name, entries] of entriesByName) { + if (entries.length > 1) { + failures.push(`${name}: duplicate patch entries`); + } + } + + for (const name of requiredNames) { + const patches = entriesByName.get(name); + if (patches == null) { + failures.push(`${name}: missing from patch report`); + continue; + } + if (patches.length !== 1 || typeof patches[0].status !== "string") { + continue; + } + const patch = patches[0]; + if (!SUCCESS_STATUSES.has(patch.status)) { + failures.push(`${name}: ${patch.status}${patch.reason ? ` (${patch.reason})` : ""}`); + } + } + + return failures; +} + +function main() { + try { + const { profile, reportPath } = parseArgs(process.argv.slice(2)); + const report = readReport(reportPath); + const failures = validateReport(report, profile); + if (failures.length > 0) { + console.error(`Required patch validation failed for profile ${profile}:`); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); + } + console.log(`Required patch validation passed for profile ${profile}.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + KNOWN_PROFILES, + SUCCESS_STATUSES, + readReport, + validateReport, +}; diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh index 5f486413..c1419589 100755 --- a/scripts/install-deps.sh +++ b/scripts/install-deps.sh @@ -158,9 +158,14 @@ ensure_nodejs_compatible() { return fi + if [ "$distro" = "dnf5" ]; then + info "Skipping system Node.js check; install.sh provides the managed Node.js runtime" + return + fi + if [ "$distro" != "apt" ]; then error "Node.js ${MIN_NODE_MAJOR}+ with npm and npx is required$(current_node_version_suffix). -Install a supported Node.js version for this distro, then re-run this script." +Install a supported Node.js version for this distro, or use install.sh to download the managed runtime, then re-run this script." fi warn "Node.js ${MIN_NODE_MAJOR}+ with npm and npx is required$(current_node_version_suffix)" @@ -185,8 +190,71 @@ Check apt output above or install Node.js ${MIN_NODE_MAJOR}+ manually." # --------------------------------------------------------------------------- # Distro detection # --------------------------------------------------------------------------- +os_release_field() { + local field="$1" + local file line value + + for file in ${OS_RELEASE_FILE:-} /etc/os-release /usr/lib/os-release; do + [ -n "$file" ] || continue + [ -r "$file" ] || continue + while IFS= read -r line; do + case "$line" in + "$field="*) + value="${line#*=}" + value="${value#\"}" + value="${value%\"}" + value="${value#\'}" + value="${value%\'}" + printf '%s\n' "${value,,}" + return 0 + ;; + esac + done < "$file" + done + + return 1 +} + +os_release_matches() { + local expected token + for expected in "$@"; do + [ "${OS_RELEASE_ID:-}" = "$expected" ] && return 0 + for token in ${OS_RELEASE_ID_LIKE:-}; do + [ "$token" = "$expected" ] && return 0 + done + done + return 1 +} + +os_release_version_major() { + local version="${OS_RELEASE_VERSION_ID:-}" + version="${version%%.*}" + case "$version" in + ''|*[!0-9]*) return 1 ;; + *) printf '%s\n' "$version" ;; + esac +} + detect_distro() { - if command -v apt-get &>/dev/null; then + if os_release_matches debian ubuntu linuxmint pop elementary zorin && command -v apt-get &>/dev/null; then + echo "apt" + elif os_release_matches arch archlinux manjaro endeavouros artix && command -v pacman &>/dev/null; then + echo "pacman" + elif os_release_matches opensuse suse sles && command -v zypper &>/dev/null; then + echo "zypper" + elif os_release_matches fedora rhel centos rocky almalinux ol; then + local major + major="$(os_release_version_major 2>/dev/null || true)" + if [ "${OS_RELEASE_ID:-}" = "fedora" ] && [ -n "$major" ] && [ "$major" -lt 41 ] && command -v dnf &>/dev/null; then + echo "dnf" + elif command -v dnf5 &>/dev/null; then + echo "dnf5" + elif command -v dnf &>/dev/null; then + echo "dnf" + else + echo "unknown" + fi + elif command -v apt-get &>/dev/null; then echo "apt" elif command -v dnf5 &>/dev/null; then echo "dnf5" @@ -227,16 +295,17 @@ install_apt() { } install_dnf5() { - info "Detected Fedora 41+ (dnf5)" - # dnf5: 7zip provides /usr/bin/7z; @development-tools is the group syntax - sudo dnf install -y \ - nodejs npm python3 \ - 7zip curl unzip \ + info "Detected RPM-based distro (dnf5)" + # Fedora 42 still packages 7z via p7zip + p7zip-plugins; @development-tools is the group syntax. + # Node.js is provided by install.sh's managed runtime on Fedora 41+. + sudo dnf5 install -y \ + python3 p7zip p7zip-plugins curl unzip \ + gcc-c++ make \ @development-tools } install_dnf() { - info "Detected Fedora/RHEL (dnf)" + info "Detected RPM-based distro (dnf)" # Older dnf: 7z comes from p7zip + p7zip-plugins sudo dnf install -y \ nodejs npm python3 \ @@ -269,7 +338,7 @@ install_gui_prompt_helper() { sudo apt-get install -y "$package" ;; dnf5) - sudo dnf install -y "$package" + sudo dnf5 install -y "$package" ;; dnf) sudo dnf install -y "$package" @@ -390,8 +459,17 @@ install_rust() { # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- +OS_RELEASE_ID="$(os_release_field ID 2>/dev/null || true)" +OS_RELEASE_ID_LIKE="$(os_release_field ID_LIKE 2>/dev/null || true)" +OS_RELEASE_VERSION_ID="$(os_release_field VERSION_ID 2>/dev/null || true)" DISTRO="$(detect_distro)" +if [ "${DETECT_ONLY:-0}" = "1" ]; then + info "Detected dependency profile: $DISTRO" + info "os-release: ID=${OS_RELEASE_ID:-unknown} ID_LIKE=${OS_RELEASE_ID_LIKE:-unknown} VERSION_ID=${OS_RELEASE_VERSION_ID:-unknown}" + exit 0 +fi + case "$DISTRO" in apt) install_apt ;; dnf5) install_dnf5 ;; @@ -402,7 +480,7 @@ case "$DISTRO" in error "Unsupported package manager. Install manually: # Debian/Ubuntu: install Node.js 20+ with npm/npx from NodeSource, nvm, or another compatible source, then: sudo apt install python3 p7zip-full curl unzip coreutils tar build-essential # Debian/Ubuntu - sudo dnf install nodejs npm python3 7zip curl unzip coreutils tar @development-tools # Fedora 41+ (dnf5) + sudo dnf5 install python3 p7zip p7zip-plugins curl unzip coreutils tar gcc-c++ make @development-tools # Fedora 41+ (dnf5; install.sh provides managed Node.js) sudo dnf install nodejs npm python3 p7zip p7zip-plugins curl unzip coreutils tar # Fedora <41 (dnf) sudo dnf groupinstall 'Development Tools' # Fedora <41 (dnf) sudo pacman -S nodejs npm python p7zip curl unzip coreutils tar zstd base-devel # Arch diff --git a/scripts/lib/bundled-plugins.sh b/scripts/lib/bundled-plugins.sh index d0406252..b6b6e1f9 100644 --- a/scripts/lib/bundled-plugins.sh +++ b/scripts/lib/bundled-plugins.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Bundled-plugin staging — Linux Computer Use backend build, plugin manifest, marketplace. +# Bundled-plugin staging — Browser Use, Chrome, Linux Computer Use, manifests, marketplace. # # Sourced by install.sh. Do not run directly. # shellcheck shell=bash @@ -22,6 +22,7 @@ find_cargo_for_linux_computer_use() { build_linux_computer_use_backend() { local crate_dir="$SCRIPT_DIR/computer-use-linux" local backend_binary="$SCRIPT_DIR/target/release/codex-computer-use-linux" + local cosmic_helper_binary="$SCRIPT_DIR/target/release/codex-computer-use-cosmic" local cargo_cmd="" if [ ! -d "$crate_dir" ]; then @@ -45,13 +46,20 @@ build_linux_computer_use_backend() { return 1 } - echo "$backend_binary" + [ -x "$cosmic_helper_binary" ] || { + warn "Linux Computer Use COSMIC helper binary missing after build: $cosmic_helper_binary" + return 1 + } + + printf '%s\n%s\n' "$backend_binary" "$cosmic_helper_binary" } stage_linux_computer_use_plugin() { local target_plugins="$1" local plugin_template="$SCRIPT_DIR/plugins/openai-bundled/plugins/computer-use" + local build_outputs="" local backend_binary="" + local cosmic_helper_binary="" local target_plugin="$target_plugins/computer-use" if [ ! -d "$plugin_template" ]; then @@ -59,16 +67,20 @@ stage_linux_computer_use_plugin() { return 1 fi - if ! backend_binary="$(build_linux_computer_use_backend)"; then + if ! build_outputs="$(build_linux_computer_use_backend)"; then return 1 fi + backend_binary="$(printf '%s\n' "$build_outputs" | sed -n '1p')" + cosmic_helper_binary="$(printf '%s\n' "$build_outputs" | sed -n '2p')" rm -rf "$target_plugin" mkdir -p "$target_plugin" cp -R "$plugin_template/." "$target_plugin/" mkdir -p "$target_plugin/bin" cp "$backend_binary" "$target_plugin/bin/codex-computer-use-linux" + cp "$cosmic_helper_binary" "$target_plugin/bin/codex-computer-use-cosmic" chmod 0755 "$target_plugin/bin/codex-computer-use-linux" + chmod 0755 "$target_plugin/bin/codex-computer-use-cosmic" if [ -f "$ICON_SOURCE" ]; then mkdir -p "$target_plugin/assets" @@ -118,14 +130,23 @@ install_linux_executable_resource() { local source="$1" local destination="$2" local label="$3" + local log_level="${4:-warn}" if [ ! -f "$source" ]; then - warn "Browser Use $label not found in upstream resources; skipping" + if [ "$log_level" = "info" ]; then + info "Browser Use $label not found in upstream resources; skipping" + else + warn "Browser Use $label not found in upstream resources; skipping" + fi return 1 fi if ! is_host_linux_elf_executable "$source"; then - warn "Browser Use $label is not a Linux executable for $ARCH; skipping" + if [ "$log_level" = "info" ]; then + info "Browser Use $label is not a Linux executable for $ARCH; skipping" + else + warn "Browser Use $label is not a Linux executable for $ARCH; skipping" + fi return 1 fi @@ -208,9 +229,7 @@ install_browser_use_node_repl_resource() { for source in \ "${CODEX_LINUX_NODE_REPL_SOURCE:-}" \ - "${CODEX_NODE_REPL_PATH:-}" \ - "${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes/codex-primary-runtime/dependencies/bin/node_repl" \ - "$upstream_source" + "${CODEX_NODE_REPL_PATH:-}" do [ -n "$source" ] || continue if install_linux_executable_resource "$source" "$destination" "node_repl runtime"; then @@ -218,6 +237,15 @@ install_browser_use_node_repl_resource() { fi done + source="${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes/codex-primary-runtime/dependencies/bin/node_repl" + if [ -f "$source" ] && install_linux_executable_resource "$source" "$destination" "node_repl runtime"; then + return 0 + fi + + if [ -n "$upstream_source" ] && install_linux_executable_resource "$upstream_source" "$destination" "node_repl runtime" "info"; then + return 0 + fi + install_node_repl_from_primary_runtime_archive "$destination" } @@ -226,6 +254,108 @@ remove_macos_sidecar_files() { find "$root" -type f -name '*:com.apple.*' -delete } +chrome_extension_host_arch() { + case "$ARCH" in + x86_64) echo "x64" ;; + aarch64|arm64) echo "arm64" ;; + *) return 1 ;; + esac +} + +build_chrome_extension_host() { + local source_binary="$SCRIPT_DIR/target/release/codex-chrome-extension-host" + local cargo_cmd="" + + if ! cargo_cmd="$(find_cargo_for_linux_computer_use)"; then + warn "cargo not found; Chrome extension host will be unavailable" + return 1 + fi + + info "Building Chrome extension host..." + if ! (cd "$SCRIPT_DIR" && "$cargo_cmd" build --release -p codex-computer-use-linux --bin codex-chrome-extension-host >&2); then + warn "Failed to build Chrome extension host" + return 1 + fi + + if [ ! -x "$source_binary" ]; then + warn "Chrome extension host binary missing after build: $source_binary" + return 1 + fi + + printf '%s\n' "$source_binary" +} + +install_chrome_extension_host_resource() { + local target_plugin="$1" + local source_host="" + local extension_arch + local target_host + + if ! extension_arch="$(chrome_extension_host_arch)"; then + warn "Chrome extension host is unavailable for $ARCH; skipping Chrome plugin" + return 1 + fi + + if ! source_host="$(build_chrome_extension_host)"; then + return 1 + fi + + target_host="$target_plugin/extension-host/linux/$extension_arch/extension-host" + mkdir -p "$(dirname "$target_host")" + install -m 0755 "$source_host" "$target_host" +} + +patch_chrome_plugin_for_linux() { + local target_plugin="$1" + local patcher="$SCRIPT_DIR/scripts/lib/patch-chrome-plugin.js" + + if [ ! -f "$patcher" ]; then + warn "Chrome plugin patch helper not found at $patcher; leaving upstream scripts unchanged" + return 0 + fi + + if ! node "$patcher" "$target_plugin" >&2; then + warn "Chrome plugin Linux patch helper failed; leaving upstream scripts as-is" + fi +} + +stage_chrome_plugin_from_upstream() { + local source_plugin="$1" + local target_plugins="$2" + local target_plugin="$target_plugins/chrome" + local source_manifest="$source_plugin/.codex-plugin/plugin.json" + local source_client="$source_plugin/scripts/browser-client.mjs" + local source_install_manifest="$source_plugin/scripts/installManifest.mjs" + + if [ ! -d "$source_plugin" ]; then + warn "Chrome bundled plugin resources not found in upstream app; skipping Chrome" + return 1 + fi + + if [ ! -f "$source_manifest" ]; then + warn "Chrome plugin manifest not found in upstream app; skipping Chrome" + return 1 + fi + + if [ ! -f "$source_client" ] || [ ! -f "$source_install_manifest" ]; then + warn "Chrome plugin scripts not found in upstream app; skipping Chrome" + return 1 + fi + + rm -rf "$target_plugin" + cp -R "$source_plugin" "$target_plugin" + remove_macos_sidecar_files "$target_plugin" + patch_chrome_plugin_for_linux "$target_plugin" + patch_browser_use_site_status_allowlist_fallback "$target_plugin/scripts/browser-client.mjs" + if ! install_chrome_extension_host_resource "$target_plugin"; then + rm -rf "$target_plugin" + return 1 + fi + + info "Chrome plugin staged from upstream DMG" + return 0 +} + patch_browser_use_site_status_allowlist_fallback() { local client="$1" @@ -235,29 +365,45 @@ patch_browser_use_site_status_allowlist_fallback() { python3 - "$client" <<'PY' from pathlib import Path +import re import sys path = Path(sys.argv[1]) source = path.read_text(encoding="utf-8") -needle = ( - 'async fetchBlocked(t){let n=await MT(t.endpoint,{method:"GET"});' - 'if(!n.ok)throw new Error(Rt(`Browser Use cannot determine if ${t.displayUrl} is allowed. ' - 'Please try again later or use another source.`));let r=await n.json();return R7(r)}' +pattern = re.compile( + r'async fetchBlocked\((?P[A-Za-z_$][\w$]*)\)\{' + r'let (?P[A-Za-z_$][\w$]*)=await (?P[A-Za-z_$][\w$]*)' + r'\((?P=url)\.endpoint,\{method:"GET"\}\);' + r'if\(!(?P=response)\.ok\)throw new Error\((?P[A-Za-z_$][\w$]*)' + r'\(`Browser Use cannot determine if \$\{(?P=url)\.displayUrl\} is allowed\. ' + r'Please try again later or use another source\.`\)\);' + r'let (?P[A-Za-z_$][\w$]*)=await (?P=response)\.json\(\);' + r'return (?P[A-Za-z_$][\w$]*)\((?P=json)\)\}' ) -replacement = ( - 'async fetchBlocked(t){let n;try{n=await MT(t.endpoint,{method:"GET"})}catch(r){' - 'if(String(t?.endpoint??"").includes("/aura/site_status")&&String(r?.message??r).includes("URL is not allowlisted"))return console.warn' - '("codexLinuxSiteStatusAllowlistFallback",t.endpoint),!1;throw r}' - 'if(!n.ok)throw new Error(Rt(`Browser Use cannot determine if ${t.displayUrl} is allowed. ' - 'Please try again later or use another source.`));let r=await n.json();return R7(r)}' -) -if needle not in source: +match = pattern.search(source) +if match is None: print( "WARN: Could not find Browser Use site_status allowlist fallback insertion point — leaving browser-client.mjs unchanged", file=sys.stderr, ) raise SystemExit(0) -path.write_text(source.replace(needle, replacement, 1), encoding="utf-8") + +url = match.group("url") +response = match.group("response") +fetch = match.group("fetch") +formatter = match.group("format") +json_value = match.group("json") +status = match.group("status") +error = "__codexLinuxErr" +replacement = ( + f'async fetchBlocked({url}){{let {response};try{{{response}=await {fetch}({url}.endpoint,{{method:"GET"}})}}' + f'catch({error}){{if(String({url}?.endpoint??"").includes("/aura/site_status")&&' + f'String({error}?.message??{error}).toLowerCase().includes("allowlist"))return console.warn' + f'("codexLinuxSiteStatusAllowlistFallback",{url}.endpoint),!1;throw {error}}}' + f'if(!{response}.ok)throw new Error({formatter}(`Browser Use cannot determine if ${{{url}.displayUrl}} is allowed. ' + f'Please try again later or use another source.`));let {json_value}=await {response}.json();return {status}({json_value})}}' +) +path.write_text(source[:match.start()] + replacement + source[match.end():], encoding="utf-8") PY } @@ -296,16 +442,18 @@ write_bundled_plugins_marketplace() { local source="$1" local destination="$2" local include_browser="$3" - local include_computer_use="$4" + local include_chrome="$4" + local include_computer_use="$5" - node - "$source" "$destination" "$include_browser" "$include_computer_use" <<'NODE' + node - "$source" "$destination" "$include_browser" "$include_chrome" "$include_computer_use" <<'NODE' const fs = require("fs"); const path = require("path"); const sourcePath = process.argv[2]; const destinationPath = process.argv[3]; const includeBrowser = process.argv[4] === "1"; -const includeComputerUse = process.argv[5] === "1"; +const includeChrome = process.argv[5] === "1"; +const includeComputerUse = process.argv[6] === "1"; const marketplace = JSON.parse(fs.readFileSync(sourcePath, "utf8")); const sourcePlugins = marketplace.plugins || []; const plugins = []; @@ -318,6 +466,14 @@ if (includeBrowser) { plugins.push(browserUse); } +if (includeChrome) { + const chrome = sourcePlugins.find((plugin) => plugin.name === "chrome"); + if (chrome == null) { + throw new Error("Bundled marketplace does not contain chrome plugin"); + } + plugins.push(chrome); +} + if (includeComputerUse) { plugins.push({ name: "computer-use", @@ -343,10 +499,12 @@ install_bundled_plugin_resources() { local app_dir="$1" local upstream_resources="$app_dir/Contents/Resources" local source_marketplace="$upstream_resources/plugins/openai-bundled/.agents/plugins/marketplace.json" - local source_plugin="$upstream_resources/plugins/openai-bundled/plugins/browser-use" + local source_browser_plugin="$upstream_resources/plugins/openai-bundled/plugins/browser-use" + local source_chrome_plugin="$upstream_resources/plugins/openai-bundled/plugins/chrome" local resources_dir="$INSTALL_DIR/resources" local bundled_plugins_dir="$resources_dir/plugins/openai-bundled" local include_browser=0 + local include_chrome=0 local include_computer_use=0 if [ ! -f "$source_marketplace" ]; then @@ -356,24 +514,28 @@ install_bundled_plugin_resources() { mkdir -p "$bundled_plugins_dir/plugins" "$bundled_plugins_dir/.agents/plugins" - if stage_browser_use_plugin_from_upstream "$source_plugin" "$bundled_plugins_dir/plugins"; then + if stage_browser_use_plugin_from_upstream "$source_browser_plugin" "$bundled_plugins_dir/plugins"; then include_browser=1 fi + if stage_chrome_plugin_from_upstream "$source_chrome_plugin" "$bundled_plugins_dir/plugins"; then + include_chrome=1 + fi + if stage_linux_computer_use_plugin "$bundled_plugins_dir/plugins"; then include_computer_use=1 else warn "Linux Computer Use plugin will be unavailable" fi - if [ "$include_browser" -eq 0 ] && [ "$include_computer_use" -eq 0 ]; then + if [ "$include_browser" -eq 0 ] && [ "$include_chrome" -eq 0 ] && [ "$include_computer_use" -eq 0 ]; then warn "No Linux-safe bundled plugins were staged" return 0 fi - write_bundled_plugins_marketplace "$source_marketplace" "$bundled_plugins_dir/.agents/plugins/marketplace.json" "$include_browser" "$include_computer_use" + write_bundled_plugins_marketplace "$source_marketplace" "$bundled_plugins_dir/.agents/plugins/marketplace.json" "$include_browser" "$include_chrome" "$include_computer_use" - install_linux_executable_resource "$upstream_resources/node" "$resources_dir/node" "node runtime" || true + install_linux_executable_resource "$upstream_resources/node" "$resources_dir/node" "node runtime" "info" || true install_browser_use_node_repl_resource "$upstream_resources/node_repl" "$resources_dir/node_repl" || true info "Linux-safe bundled plugins installed" diff --git a/scripts/lib/install-helpers.sh b/scripts/lib/install-helpers.sh index 28ab0ec1..2940f031 100644 --- a/scripts/lib/install-helpers.sh +++ b/scripts/lib/install-helpers.sh @@ -20,7 +20,7 @@ Run the helper to install them automatically: Or install manually: sudo apt install python3 p7zip-full curl unzip coreutils tar build-essential # Debian/Ubuntu - sudo dnf install python3 7zip curl unzip coreutils tar @development-tools # Fedora 41+ (dnf5) + sudo dnf5 install python3 p7zip p7zip-plugins curl unzip coreutils tar gcc-c++ make @development-tools # Fedora 41+ (dnf5) sudo dnf install python3 p7zip p7zip-plugins curl unzip coreutils tar # Fedora <41 (dnf) sudo dnf groupinstall 'Development Tools' # Fedora <41 (dnf) sudo pacman -S python p7zip curl unzip coreutils tar zstd base-devel # Arch @@ -156,7 +156,9 @@ select_seven_zip_cmd() { SEVEN_ZIP_CMD="7z" fi - if "$SEVEN_ZIP_CMD" 2>&1 | grep -m 1 "7-Zip" | grep -q "16.02"; then + local seven_zip_banner + seven_zip_banner="$("$SEVEN_ZIP_CMD" 2>&1 | grep -m 1 "7-Zip" || true)" + if [[ "$seven_zip_banner" == *"16.02"* ]]; then error "System 7-zip is too old for modern APFS DMGs. Install a newer 7zz first by running: bash scripts/install-deps.sh diff --git a/scripts/lib/linux-features.js b/scripts/lib/linux-features.js new file mode 100644 index 00000000..0289963a --- /dev/null +++ b/scripts/lib/linux-features.js @@ -0,0 +1,196 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +function defaultLinuxFeaturesRoot() { + return path.resolve(__dirname, "..", "..", "linux-features"); +} + +function linuxFeaturesRoot(options = {}) { + if (options.featuresRoot != null) { + return path.resolve(options.featuresRoot); + } + if (process.env.CODEX_LINUX_FEATURES_ROOT?.trim()) { + return path.resolve(process.env.CODEX_LINUX_FEATURES_ROOT.trim()); + } + return defaultLinuxFeaturesRoot(); +} + +function linuxFeaturesConfigPath(featuresRoot) { + if (process.env.CODEX_LINUX_FEATURES_CONFIG?.trim()) { + return path.resolve(process.env.CODEX_LINUX_FEATURES_CONFIG.trim()); + } + const localConfig = path.join(featuresRoot, "features.json"); + if (fs.existsSync(localConfig)) { + return localConfig; + } + return path.join(featuresRoot, "features.example.json"); +} + +function readJsonFile(filePath, label) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + console.warn(`WARN: Could not read ${label} at ${filePath}: ${error.message}`); + return null; + } +} + +function normalizeEnabledFeatureIds(value, sourcePath) { + if (!Array.isArray(value)) { + console.warn(`WARN: Linux features config ${sourcePath} must contain an enabled array`); + return []; + } + + const seen = new Set(); + const ids = []; + for (const item of value) { + if (typeof item !== "string" || !FEATURE_ID_PATTERN.test(item)) { + console.warn(`WARN: Invalid Linux feature id in ${sourcePath}: ${String(item)}`); + continue; + } + if (seen.has(item)) { + continue; + } + seen.add(item); + ids.push(item); + } + return ids; +} + +function enabledLinuxFeatureIds(options = {}) { + const featuresRoot = linuxFeaturesRoot(options); + const configPath = linuxFeaturesConfigPath(featuresRoot); + if (!fs.existsSync(configPath)) { + return []; + } + + const config = readJsonFile(configPath, "Linux features config"); + if (config == null) { + return []; + } + return normalizeEnabledFeatureIds(config.enabled, configPath); +} + +function loadLinuxFeatureManifest(featuresRoot, id) { + const featureDir = path.join(featuresRoot, id); + const manifestPath = path.join(featureDir, "feature.json"); + if (!fs.existsSync(manifestPath)) { + console.warn(`WARN: Enabled Linux feature '${id}' does not have feature.json`); + return null; + } + + const manifest = readJsonFile(manifestPath, `Linux feature '${id}' manifest`); + if (manifest == null) { + return null; + } + if (manifest.id !== id) { + console.warn(`WARN: Linux feature '${id}' manifest id mismatch: ${String(manifest.id)}`); + return null; + } + + return { id, dir: featureDir, manifestPath, manifest }; +} + +function loadEnabledLinuxFeatures(options = {}) { + const featuresRoot = linuxFeaturesRoot(options); + return enabledLinuxFeatureIds({ ...options, featuresRoot }) + .map((id) => loadLinuxFeatureManifest(featuresRoot, id)) + .filter(Boolean); +} + +function resolveFeatureEntrypoint(feature, key) { + const relativePath = feature.manifest.entrypoints?.[key]; + if (relativePath == null) { + return null; + } + if (typeof relativePath !== "string" || relativePath.trim().length === 0) { + console.warn(`WARN: Linux feature '${feature.id}' has invalid ${key} entrypoint`); + return null; + } + if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) { + console.warn(`WARN: Linux feature '${feature.id}' ${key} entrypoint must stay inside the feature directory`); + return null; + } + const entrypoint = path.resolve(feature.dir, relativePath); + if (!fs.existsSync(entrypoint)) { + console.warn(`WARN: Linux feature '${feature.id}' ${key} entrypoint not found: ${entrypoint}`); + return null; + } + return entrypoint; +} + +function loadLinuxFeatureMainBundlePatches(options = {}) { + const patches = []; + for (const feature of loadEnabledLinuxFeatures(options)) { + const entrypoint = resolveFeatureEntrypoint(feature, "mainBundlePatch"); + if (entrypoint == null) { + continue; + } + + let moduleExports; + try { + moduleExports = require(entrypoint); + } catch (error) { + console.warn(`WARN: Could not load Linux feature '${feature.id}' mainBundlePatch: ${error.message}`); + continue; + } + + const apply = moduleExports.applyMainBundlePatch ?? moduleExports.apply ?? moduleExports; + if (typeof apply !== "function") { + console.warn(`WARN: Linux feature '${feature.id}' mainBundlePatch must export a function`); + continue; + } + + patches.push({ + name: `feature:${feature.id}`, + ciPolicy: "optional", + apply: (source, context) => apply(source, { ...context, feature }), + }); + } + return patches; +} + +function enabledLinuxFeatureStageHooks(options = {}) { + return loadEnabledLinuxFeatures(options) + .map((feature) => ({ + id: feature.id, + path: resolveFeatureEntrypoint(feature, "stageHook"), + })) + .filter((hook) => hook.path != null); +} + +function main() { + const command = process.argv[2]; + if (command === "--stage-hooks") { + for (const hook of enabledLinuxFeatureStageHooks()) { + process.stdout.write(`${hook.id}\t${hook.path}\n`); + } + return; + } + if (command === "--enabled") { + for (const id of enabledLinuxFeatureIds()) { + process.stdout.write(`${id}\n`); + } + return; + } + console.error("Usage: linux-features.js --enabled | --stage-hooks"); + process.exit(1); +} + +if (require.main === module) { + main(); +} + +module.exports = { + enabledLinuxFeatureIds, + enabledLinuxFeatureStageHooks, + loadEnabledLinuxFeatures, + loadLinuxFeatureMainBundlePatches, + linuxFeaturesConfigPath, + linuxFeaturesRoot, + resolveFeatureEntrypoint, +}; diff --git a/scripts/lib/linux-features.sh b/scripts/lib/linux-features.sh new file mode 100644 index 00000000..05c8fa61 --- /dev/null +++ b/scripts/lib/linux-features.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Opt-in Linux feature staging hooks. +# +# Sourced by install.sh. Do not run directly. +# shellcheck shell=bash + +run_linux_feature_stage_hooks() { + local app_dir="${1:-}" + local feature_helper="$SCRIPT_DIR/scripts/lib/linux-features.js" + local hooks_dir="" + local hooks_file="" + local feature_id + local hook_path + + [ -f "$feature_helper" ] || { + warn "Linux feature helper not found at $feature_helper" + return 0 + } + + hooks_dir="${WORK_DIR:-/tmp}" + [ -d "$hooks_dir" ] || hooks_dir="/tmp" + hooks_file="$(mktemp "$hooks_dir/codex-linux-feature-hooks.XXXXXX")" || return 1 + if ! node "$feature_helper" --stage-hooks >"$hooks_file"; then + warn "Linux feature stage hook enumeration failed" + rm -f "$hooks_file" + return 1 + fi + + while IFS=$'\t' read -r feature_id hook_path; do + [ -n "$feature_id" ] || continue + [ -n "$hook_path" ] || continue + info "Running Linux feature stage hook: $feature_id" + if ! SCRIPT_DIR="$SCRIPT_DIR" INSTALL_DIR="$INSTALL_DIR" WORK_DIR="$WORK_DIR" ARCH="$ARCH" CODEX_UPSTREAM_APP_DIR="$app_dir" bash "$hook_path"; then + warn "Linux feature stage hook failed: $feature_id" + rm -f "$hooks_file" + return 1 + fi + done <"$hooks_file" + rm -f "$hooks_file" +} diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index bfaaed3a..b66261a1 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -7,7 +7,7 @@ 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: 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 &);exit 0;fi;done;exit 1\`,\`codex-linux-update-install\`,codexLinuxAppUpdaterPath(),codexLinuxAppLauncherPath()],{detached:!0,stdio:\`ignore\`,windowsHide:!0});e.unref?.()}catch{}}`; + 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 ("$2" >/dev/null 2>&1 &);exit 0;fi;echo "$s"|grep -q "^status: Failed"&&exit 1;exit 1;done;exit 1\`,\`codex-linux-update-install\`,codexLinuxAppUpdaterPath(),process.env.CODEX_APP_LAUNCHER_PATH||\`/usr/bin/codex-app\`],{detached:!0,stdio:\`ignore\`,windowsHide:!0});e.unref?.()}catch{}}`; } function replaceInstallAfterQuitSource(source, childProcessVar) { @@ -16,7 +16,22 @@ function replaceInstallAfterQuitSource(source, childProcessVar) { return source.replace(pattern, buildInstallAfterQuitSource(childProcessVar)); } +function replaceAfter(source, anchor, search, replacement) { + const anchorIndex = source.indexOf(anchor); + if (anchorIndex === -1) { + return source; + } + const matchIndex = source.indexOf(search, anchorIndex); + if (matchIndex === -1) { + return source; + } + return source.slice(0, matchIndex) + replacement + source.slice(matchIndex + search.length); +} + function buildQuitForUpdateSource(electronVar, callInstallAfterQuit) { + if (electronVar == null) { + return "function codexLinuxQuitForUpdate(){codexLinuxInstallAfterQuit()}"; + } const prefix = callInstallAfterQuit ? "codexLinuxInstallAfterQuit();" : ""; return `function codexLinuxQuitForUpdate(){try{${prefix}let e=setTimeout(()=>${electronVar}.app?.exit?.(0),1500);e.unref?.(),${electronVar}.app?.quit?.()}catch{}}`; } @@ -25,15 +40,193 @@ function buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar }) { const showUpdateMessage = electronVar == null ? "async function codexLinuxShowUpdateMessage(){}" - : `async function codexLinuxShowUpdateMessage(e,n){try{await ${electronVar}.dialog?.showMessageBox({type:\`info\`,buttons:[\`OK\`],defaultId:0,noLink:!0,message:e,detail:n})}catch{}}`; + : `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-app-updater\`,\`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 codexLinuxAppUpdaterPath(){let e=process.env.CODEX_APP_UPDATER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-app-updater\`}function codexLinuxAppLauncherPath(){let e=process.env.CODEX_APP_LAUNCHER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-app\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunAppUpdater(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxAppUpdaterPath(),e,{encoding:\`utf8\`,windowsHide:!0},(e,r,i)=>{if(e){e.stdout=r,e.stderr=i,n(e);return}t({stdout:r??\`\`,stderr:i??\`\`})})})}`; + 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-app-updater\`,\`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 codexLinuxAppUpdaterPath(){let e=process.env.CODEX_APP_UPDATER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-app-updater\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunAppUpdater(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxAppUpdaterPath(),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 codexLinuxProbeAppUpdater(){await codexLinuxRunAppUpdater([\`--help\`])}async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}`; +} + +function migrateLinuxUpdaterBridgeSource(source) { + let patchedSource = source.replace( + "async function codexLinuxRefreshUpdateState(){await codexLinuxRunAppUpdater([`status`,`--json`]);return codexLinuxReadUpdateState()}", + "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}", + ); + const probeSource = + "async function codexLinuxProbeAppUpdater(){await codexLinuxRunAppUpdater([`--help`])}"; + const refreshSource = + "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}"; + if ( + patchedSource.includes("function codexLinuxRunAppUpdater(") && + patchedSource.includes(refreshSource) && + !patchedSource.includes(probeSource) + ) { + patchedSource = patchedSource.replace( + refreshSource, + `${probeSource}${refreshSource}`, + ); + } + + const bootstrapNeedle = "function codexLinuxCreatePackageAppUpdater("; + const isBootstrapSource = patchedSource.includes(bootstrapNeedle); + if ( + patchedSource.includes("function codexLinuxRunAppUpdater(") && + isBootstrapSource && + (!patchedSource.includes(probeSource) || !patchedSource.includes(refreshSource)) + ) { + const helperSource = + `${patchedSource.includes(probeSource) ? "" : probeSource}` + + `${patchedSource.includes(refreshSource) ? "" : refreshSource}`; + patchedSource = patchedSource.replace(bootstrapNeedle, `${helperSource}${bootstrapNeedle}`); + } + + patchedSource = patchedSource.replace( + "await codexLinuxRefreshUpdateState(),e()", + "await codexLinuxProbeAppUpdater(),e()", + ); + + const probeStateSource = + "let s=!1,c=codexLinuxProbeAppUpdater().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o="; + const hasProbeState = () => patchedSource.includes("c=codexLinuxProbeAppUpdater().then("); + if (isBootstrapSource && !hasProbeState() && patchedSource.includes(probeSource)) { + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "i(),codexLinuxRefreshUpdateState().then(()=>{i(),a()}).catch(()=>{});let o=", + probeStateSource, + ); + patchedSource = replaceAfter(patchedSource, bootstrapNeedle, "i();let o=", probeStateSource); + } + + if (!isBootstrapSource || !hasProbeState()) { + return patchedSource; + } + + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "getIsUpdateReady:()=>t,getUpdateLifecycleState:()=>n,", + "getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:`idle`,", + ); + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "checkForUpdates:async()=>{n=`checking`,a();try{", + "checkForUpdates:async()=>{if(!await c)return;n=`checking`,a();try{", + ); + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "installUpdatesIfAvailable:async()=>{i();if(!t){a();return}", + "installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}", + ); + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "installUpdatesIfAvailable:async()=>{i();if(!t)return;", + "installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}", + ); + patchedSource = replaceAfter( + patchedSource, + bootstrapNeedle, + "refresh:async()=>{try{await codexLinuxRefreshUpdateState()}catch{}i(),a()}", + "refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=`idle`;a()}", + ); + return replaceAfter( + patchedSource, + bootstrapNeedle, + "refresh:()=>{i(),a()}", + "refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=`idle`;a()}", + ); +} + +function buildBootstrapBridgeSource({ childProcessVar, electronVar, fsVar, pathVar }) { + return `${buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar })};function codexLinuxCreatePackageAppUpdater(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=codexLinuxProbeAppUpdater().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 codexLinuxRunAppUpdater([\`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 codexLinuxRunAppUpdater([\`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 App update\`,\`The ready update is already installed.\`):e.stdout?.includes(\`No Codex App update is ready\`)&&await codexLinuxShowUpdateMessage(\`Codex App 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) { + if ( + !currentSource.includes("setSparkleBridgeHandlers") || + !currentSource.includes("sparkleManager:") || + !currentSource.includes("onInstallUpdatesRequested") + ) { + return currentSource; + } + + const childProcessVar = + requireName(currentSource, "node:child_process") ?? requireName(currentSource, "child_process"); + const electronVar = requireName(currentSource, "electron") ?? requireName(currentSource, "node:electron"); + const fsVar = requireName(currentSource, "node:fs") ?? requireName(currentSource, "fs"); + const pathVar = requireName(currentSource, "node:path") ?? requireName(currentSource, "path"); + if (childProcessVar == null || fsVar == null || pathVar == null) { + console.warn("WARN: Could not find updater bridge module bindings - skipping Linux updater bridge patch"); + return currentSource; + } + + let patchedSource = currentSource; + if (!patchedSource.includes("function codexLinuxCreatePackageAppUpdater(")) { + if (!patchedSource.includes("state:`disabled`")) { + return currentSource; + } + const bootstrapNeedle = "var rK={enabled:!1,running:!1,state:`disabled`};"; + if (!patchedSource.includes(bootstrapNeedle)) { + console.warn("WARN: Could not find current updater bridge insertion point - skipping Linux updater bridge patch"); + return currentSource; + } + patchedSource = patchedSource.replace( + bootstrapNeedle, + `${buildBootstrapBridgeSource({ childProcessVar, electronVar, fsVar, pathVar })};${bootstrapNeedle}`, + ); + } + + patchedSource = migrateLinuxUpdaterBridgeSource(patchedSource); + + 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$]*)\(\),/; + const destructureMatch = patchedSource.match(destructureRegex); + const sparkleVar = destructureMatch?.[4] ?? null; + const setSparkleBridgeHandlersVar = destructureMatch?.[5] ?? null; + if (sparkleVar == null) { + console.warn("WARN: Could not identify current sparkleManager binding - skipping Linux updater bridge patch"); + return currentSource; + } + const bridgeHandlersStart = setSparkleBridgeHandlersVar == null + ? -1 + : patchedSource.indexOf(`${setSparkleBridgeHandlersVar}({`, destructureMatch.index ?? 0); + const bridgeHandlersSlice = bridgeHandlersStart === -1 + ? "" + : patchedSource.slice(bridgeHandlersStart, bridgeHandlersStart + 1500); + const messageDispatcherVar = bridgeHandlersSlice.match( + /([A-Za-z_$][\w$]*)\.sendMessageToAllRegisteredWindows\(\{type:`app-update-ready-changed`/, + )?.[1] ?? null; + if (messageDispatcherVar == null) { + console.warn("WARN: Could not identify current updater window message dispatcher - skipping Linux updater bridge patch"); + return currentSource; + } + + if (!patchedSource.includes("codexLinuxPackageUpdateBridge=process.platform===`linux`")) { + const bridgeRegex = + /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\),([A-Za-z_$][\w$]*)=\(\)=>\{\1\.allowQuitTemporarilyForUpdateInstall\(\),([A-Za-z_$][\w$]*)\.app\.quit\(\)\};/; + const bridgeMatch = patchedSource.match(bridgeRegex); + if (bridgeMatch == null) { + console.warn("WARN: Could not find current updater callback bridge - skipping Linux updater bridge patch"); + return currentSource; + } + const [, quitControllerVar, quitFactoryVar, quitFnVar, electronBindingVar] = bridgeMatch; + const replacement = + `let ${quitControllerVar}=${quitFactoryVar}(),${quitFnVar}=()=>{${quitControllerVar}.allowQuitTemporarilyForUpdateInstall(),${electronBindingVar}.app.quit()},codexLinuxPackageUpdateBridge=process.platform===\`linux\`?codexLinuxCreatePackageAppUpdater({allowQuit:()=>${quitControllerVar}.allowQuitTemporarilyForUpdateInstall(),send:e=>${messageDispatcherVar}.sendMessageToAllRegisteredWindows(e)}):null;codexLinuxPackageUpdateBridge!=null&&(${sparkleVar}=codexLinuxPackageUpdateBridge.manager,${quitFnVar}=codexLinuxPackageUpdateBridge.quitForUpdate,setInterval(()=>codexLinuxPackageUpdateBridge.refresh(),3e4).unref?.());`; + patchedSource = patchedSource.replace(bridgeRegex, replacement); + } + + return patchedSource; } function applyLinuxAppUpdaterBridgePatch(currentSource) { - if (!currentSource.includes("initializeMacSparkle")) { - console.warn("WARN: Could not find updater initializeMacSparkle marker - skipping Linux updater bridge patch"); + const currentBootstrapPatched = applyCurrentBootstrapUpdaterBridgePatch(currentSource); + if (currentBootstrapPatched !== currentSource) { + return currentBootstrapPatched; + } + + if (!currentSource.includes("var tD=class{") || !currentSource.includes("initializeMacSparkle")) { return currentSource; } @@ -42,14 +235,18 @@ function applyLinuxAppUpdaterBridgePatch(currentSource) { const electronVar = requireName(currentSource, "electron") ?? requireName(currentSource, "node:electron"); const fsVar = requireName(currentSource, "node:fs") ?? requireName(currentSource, "fs"); const pathVar = requireName(currentSource, "node:path") ?? requireName(currentSource, "path"); - if (childProcessVar == null || electronVar == null || fsVar == null || pathVar == null) { + if (childProcessVar == null || fsVar == null || pathVar == null) { console.warn("WARN: Could not find updater bridge module bindings - skipping Linux updater bridge patch"); return currentSource; } let patchedSource = currentSource; if (!patchedSource.includes("function codexLinuxUpdateLifecycleState(")) { - patchedSource = `${buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar })};${patchedSource}`; + const classNeedle = "var tD=class{"; + patchedSource = patchedSource.replace( + classNeedle, + `${buildBridgeSource({ childProcessVar, electronVar, fsVar, pathVar })};${classNeedle}`, + ); } if (!patchedSource.includes("function codexLinuxQuitForUpdate(")) { const quitSource = `${buildInstallAfterQuitSource(childProcessVar)}${buildQuitForUpdateSource(electronVar, true)}`; @@ -106,7 +303,7 @@ function applyLinuxAppUpdaterBridgePatch(currentSource) { if (!patchedSource.includes("async initializeLinuxPackageUpdater(){")) { const methodNeedle = "async initializeWindowsUpdater(){"; const methodPatch = - "async initializeLinuxPackageUpdater(){if(process.platform!==`linux`){this.lastUnavailableReason=`unsupported platform`;return}let e=()=>{let e=codexLinuxReadUpdateState(),t=e?.status;this.setUpdateReady(t===`ready_to_install`||t===`waiting_for_app_exit`),this.setUpdateLifecycleState(codexLinuxUpdateLifecycleState(t)),this.lastUnavailableReason=null;return e};try{await codexLinuxRunAppUpdater([`--help`]),e()}catch(e){this.lastUnavailableReason=e?.code===`ENOENT`?`codex-app-updater not found`:`codex-app-updater unavailable`,ZE().warning(`Linux updater unavailable`,{safe:{reason:this.lastUnavailableReason},sensitive:{error:e}});return}this.updater={checkForUpdates:async()=>{this.setUpdateLifecycleState(`checking`);try{await codexLinuxRunAppUpdater([`check-now`]),e()}catch(t){this.setUpdateLifecycleState(this.isUpdateReady?`ready`:`idle`);throw t}},installUpdatesIfAvailable:async()=>{e();if(!this.isUpdateReady)return;this.setInstallProgressPercent(0),this.setUpdateLifecycleState(`installing`);try{let n=await codexLinuxRunAppUpdater([`install-ready`]),t=e();if(t?.status===`waiting_for_app_exit`){this.setInstallProgressPercent(null),codexLinuxQuitForUpdate();return}this.setInstallProgressPercent(null),n.stdout?.includes(`already installed`)?await codexLinuxShowUpdateMessage(`Codex App update`,`The ready update is already installed.`):n.stdout?.includes(`No Codex App update is ready`)&&await codexLinuxShowUpdateMessage(`Codex App update`,`There is no rebuilt update waiting to install.`)}catch(e){this.setInstallProgressPercent(null),this.setUpdateLifecycleState(this.isUpdateReady?`ready`:`idle`);throw e}}};let t=setInterval(()=>{try{e()}catch(e){ZE().warning(`Linux updater state refresh failed`,{safe:{},sensitive:{error:e}})}},3e4);t.unref?.()}"; + "async initializeLinuxPackageUpdater(){if(process.platform!==`linux`){this.lastUnavailableReason=`unsupported platform`;return}let e=()=>{let e=codexLinuxReadUpdateState(),t=e?.status;this.setUpdateReady(t===`ready_to_install`||t===`waiting_for_app_exit`),this.setUpdateLifecycleState(codexLinuxUpdateLifecycleState(t)),this.lastUnavailableReason=null;return e};try{await codexLinuxProbeAppUpdater(),e()}catch(e){this.lastUnavailableReason=e?.code===`ENOENT`?`codex-app-updater not found`:`codex-app-updater unavailable`,ZE().warning(`Linux updater unavailable`,{safe:{reason:this.lastUnavailableReason},sensitive:{error:e}});return}this.updater={checkForUpdates:async()=>{this.setUpdateLifecycleState(`checking`);try{await codexLinuxRunAppUpdater([`check-now`]),e()}catch(t){this.setUpdateLifecycleState(this.isUpdateReady?`ready`:`idle`);throw t}},installUpdatesIfAvailable:async()=>{e();if(!this.isUpdateReady)return;this.setInstallProgressPercent(0),this.setUpdateLifecycleState(`installing`);try{let n=await codexLinuxRunAppUpdater([`install-ready`]),t=e();if(t?.status===`waiting_for_app_exit`){this.setInstallProgressPercent(null),codexLinuxQuitForUpdate();return}this.setInstallProgressPercent(null),n.stdout?.includes(`already installed`)?await codexLinuxShowUpdateMessage(`Codex App update`,`The ready update is already installed.`):n.stdout?.includes(`No Codex App update is ready`)&&await codexLinuxShowUpdateMessage(`Codex App update`,`There is no rebuilt update waiting to install.`)}catch(e){this.setInstallProgressPercent(null),this.setUpdateLifecycleState(this.isUpdateReady?`ready`:`idle`);throw e}}};let t=setInterval(()=>{codexLinuxRefreshUpdateState().then(()=>e()).catch(e=>{ZE().warning(`Linux updater state refresh failed`,{safe:{},sensitive:{error:e}})})},3e4);t.unref?.()}"; if (!patchedSource.includes(methodNeedle)) { console.warn("WARN: Could not find updater method insertion point - skipping Linux updater bridge patch"); return currentSource; @@ -114,23 +311,26 @@ function applyLinuxAppUpdaterBridgePatch(currentSource) { patchedSource = patchedSource.replace(methodNeedle, `${methodPatch}${methodNeedle}`); } - return patchedSource; + return migrateLinuxUpdaterBridgeSource(patchedSource); } function applyLinuxAppUpdaterMenuPatch(currentSource) { - const menuNeedle = "d=t.C.shouldIncludeSparkle(a,process.platform,process.env)"; - const menuPatch = "d=t.C.shouldIncludeSparkle(a,process.platform,process.env)||process.platform===`linux`"; + const menuNeedles = [ + "d=t.C.shouldIncludeSparkle(a,process.platform,process.env)", + "d=t.T.shouldIncludeSparkle(a,process.platform,process.env)", + ]; - if (currentSource.includes(menuPatch)) { + if (/d=t\.[A-Za-z_$][\w$]*\.shouldIncludeSparkle\(a,process\.platform,process\.env\)\|\|process\.platform===`linux`/.test(currentSource)) { return currentSource; } - if (!currentSource.includes(menuNeedle)) { + const menuNeedle = menuNeedles.find((needle) => currentSource.includes(needle)); + if (menuNeedle == null) { if (currentSource.includes("enableSparkle") && currentSource.includes("shouldIncludeSparkle")) { console.warn("WARN: Could not find update menu feature gate - skipping Linux update menu patch"); } return currentSource; } - return currentSource.replace(menuNeedle, menuPatch); + return currentSource.replace(menuNeedle, `${menuNeedle}||process.platform===\`linux\``); } function patchLinuxAppUpdaterBridge(extractedDir) { @@ -145,23 +345,11 @@ function patchLinuxAppUpdaterBridge(extractedDir) { for (const fileName of fs.readdirSync(buildDir).filter((name) => name.endsWith(".js")).sort()) { const filePath = path.join(buildDir, fileName); const source = fs.readFileSync(filePath, "utf8"); - if (!source.includes("initializeMacSparkle") && !source.includes("shouldIncludeSparkle")) { - continue; - } - let patched = source; - let fileMatched = false; - if (source.includes("shouldIncludeSparkle")) { - patched = applyLinuxAppUpdaterMenuPatch(patched); - fileMatched = true; - } - if (source.includes("initializeMacSparkle")) { - patched = applyLinuxAppUpdaterBridgePatch(patched); - fileMatched = true; - } - if (!fileMatched) { + if (!source.includes("var tD=class{") && !source.includes("shouldIncludeSparkle")) { continue; } matched += 1; + const patched = applyLinuxAppUpdaterBridgePatch(applyLinuxAppUpdaterMenuPatch(source)); if (patched !== source) { fs.writeFileSync(filePath, patched, "utf8"); changed += 1; diff --git a/scripts/lib/node-runtime.sh b/scripts/lib/node-runtime.sh index 6a75f566..0e08089d 100644 --- a/scripts/lib/node-runtime.sh +++ b/scripts/lib/node-runtime.sh @@ -73,13 +73,14 @@ node_version_parts() { node_runtime_compatible() { local node_path="$1" + local parts local major local minor local patch - if ! read -r major minor patch < <(node_version_parts "$node_path"); then - return 1 - fi + parts="$(node_version_parts "$node_path" 2>/dev/null || true)" + [ -n "$parts" ] || return 1 + read -r major minor patch <<< "$parts" if [ "$major" -gt "$MANAGED_NODE_MIN_MAJOR" ]; then return 0 fi diff --git a/scripts/lib/package-common.sh b/scripts/lib/package-common.sh index 32088bcb..96b73ab4 100755 --- a/scripts/lib/package-common.sh +++ b/scripts/lib/package-common.sh @@ -151,19 +151,35 @@ updater_binary_is_stale() { return 1 } +find_cargo_command() { + if command -v cargo >/dev/null 2>&1; then + command -v cargo + return 0 + fi + + if [ -n "${HOME-}" ] && [ -x "$HOME/.cargo/bin/cargo" ]; then + echo "$HOME/.cargo/bin/cargo" + return 0 + fi + + return 1 +} + ensure_updater_binary() { + local cargo_cmd="" + if [ -x "$UPDATER_BINARY_SOURCE" ] && ! updater_binary_is_stale "$UPDATER_BINARY_SOURCE"; then return fi [ -f "$REPO_DIR/Cargo.toml" ] || error "Missing updater binary: $UPDATER_BINARY_SOURCE" - command -v cargo >/dev/null 2>&1 || error "cargo is required to build codex-app-updater. + cargo_cmd="$(find_cargo_command)" || error "cargo is required to build codex-app-updater. Install the Rust toolchain: bash scripts/install-deps.sh # auto-installs via rustup # or manually: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" info "Building codex-app-updater release binary" - cargo build --release -p codex-app-updater >&2 + "$cargo_cmd" build --release -p codex-app-updater >&2 [ -x "$UPDATER_BINARY_SOURCE" ] || error "Failed to build updater binary: $UPDATER_BINARY_SOURCE" } @@ -209,7 +225,10 @@ stage_update_builder_bundle() { mkdir -p \ "$update_builder_root/scripts" \ + "$update_builder_root/scripts/lib" \ + "$update_builder_root/scripts/patches" \ "$update_builder_root/launcher" \ + "$update_builder_root/linux-features" \ "$update_builder_root/packaging/linux" \ "$update_builder_root/assets" @@ -227,7 +246,22 @@ stage_update_builder_bundle() { cp "$REPO_DIR/scripts/build-pacman.sh" "$update_builder_root/scripts/build-pacman.sh" cp "$REPO_DIR/scripts/rebuild-candidate.sh" "$update_builder_root/scripts/rebuild-candidate.sh" cp "$REPO_DIR/scripts/patch-linux-window-ui.js" "$update_builder_root/scripts/patch-linux-window-ui.js" - cp -r "$REPO_DIR/scripts/lib" "$update_builder_root/scripts/lib" + cp -r "$REPO_DIR/scripts/patches/." "$update_builder_root/scripts/patches/" + cp "$REPO_DIR/scripts/lib/package-common.sh" "$update_builder_root/scripts/lib/package-common.sh" + cp "$REPO_DIR/scripts/lib/patch-chrome-plugin.js" "$update_builder_root/scripts/lib/patch-chrome-plugin.js" + cp "$REPO_DIR/scripts/lib/node-runtime.sh" "$update_builder_root/scripts/lib/node-runtime.sh" + cp "$REPO_DIR/scripts/lib/install-helpers.sh" "$update_builder_root/scripts/lib/install-helpers.sh" + cp "$REPO_DIR/scripts/lib/process-detection.sh" "$update_builder_root/scripts/lib/process-detection.sh" + cp "$REPO_DIR/scripts/lib/dmg.sh" "$update_builder_root/scripts/lib/dmg.sh" + cp "$REPO_DIR/scripts/lib/native-modules.sh" "$update_builder_root/scripts/lib/native-modules.sh" + cp "$REPO_DIR/scripts/lib/asar-patch.sh" "$update_builder_root/scripts/lib/asar-patch.sh" + cp "$REPO_DIR/scripts/lib/webview-install.sh" "$update_builder_root/scripts/lib/webview-install.sh" + cp "$REPO_DIR/scripts/lib/bundled-plugins.sh" "$update_builder_root/scripts/lib/bundled-plugins.sh" + cp "$REPO_DIR/scripts/lib/linux-features.js" "$update_builder_root/scripts/lib/linux-features.js" + cp "$REPO_DIR/scripts/lib/linux-features.sh" "$update_builder_root/scripts/lib/linux-features.sh" + cp "$REPO_DIR/scripts/lib/linux-update-bridge-patch.js" "$update_builder_root/scripts/lib/linux-update-bridge-patch.js" + cp "$REPO_DIR/scripts/lib/patch-report.js" "$update_builder_root/scripts/lib/patch-report.js" + cp "$REPO_DIR/scripts/lib/rebuild-report.sh" "$update_builder_root/scripts/lib/rebuild-report.sh" cp "$REPO_DIR/packaging/linux/control" "$update_builder_root/packaging/linux/control" cp "$REPO_DIR/packaging/linux/codex-app.spec" "$update_builder_root/packaging/linux/codex-app.spec" cp "$REPO_DIR/packaging/linux/codex-app.desktop" "$update_builder_root/packaging/linux/codex-app.desktop" @@ -241,6 +275,8 @@ stage_update_builder_bundle() { cp "$UPDATER_SERVICE_SOURCE" "$update_builder_root/packaging/linux/codex-app-updater.service" cp "$REPO_DIR/packaging/linux/codex-app-updater.postinst" "$update_builder_root/packaging/linux/codex-app-updater.postinst" cp "$REPO_DIR/packaging/linux/codex-app-updater.prerm" "$update_builder_root/packaging/linux/codex-app-updater.prerm" + cp -r "$REPO_DIR/linux-features/." "$update_builder_root/linux-features/" + rm -f "$update_builder_root/linux-features/features.json" cp "$REPO_DIR/packaging/linux/codex-app-updater.postrm" "$update_builder_root/packaging/linux/codex-app-updater.postrm" cp "$REPO_DIR/assets/codex.png" "$update_builder_root/assets/codex.png" if [ -d "$node_runtime_source" ]; then diff --git a/scripts/lib/patch-chrome-plugin.js b/scripts/lib/patch-chrome-plugin.js new file mode 100644 index 00000000..636a5508 --- /dev/null +++ b/scripts/lib/patch-chrome-plugin.js @@ -0,0 +1,385 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +function warn(message) { + process.stderr.write(`WARN: ${message}\n`); +} + +function patchFile(filePath, patches) { + let source; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch (error) { + warn(`Could not read ${filePath}: ${error.message}`); + return; + } + + let changed = false; + for (const { label, oldText, newText, alreadyText = newText } of patches) { + const matchIndex = source.indexOf(oldText); + if (matchIndex === -1) { + if (source.includes(newText) || source.includes(alreadyText)) { + console.log(`${path.basename(filePath)} already patched: ${label}`); + continue; + } + warn(`${path.basename(filePath)} missing patch target for ${label}`); + continue; + } + + source = `${source.slice(0, matchIndex)}${newText}${source.slice(matchIndex + oldText.length)}`; + changed = true; + console.log(`Patched ${path.basename(filePath)}: ${label}`); + } + + if (changed) { + fs.writeFileSync(filePath, source, "utf8"); + } +} + +function patchFileFirstMatch(filePath, { label, oldTexts, newText, alreadyText = newText }) { + let source; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch (error) { + warn(`Could not read ${filePath}: ${error.message}`); + return; + } + + const match = oldTexts + .map((candidate) => typeof candidate === "string" ? { oldText: candidate, newText } : candidate) + .map((candidate) => ({ ...candidate, index: source.indexOf(candidate.oldText) })) + .find((candidate) => candidate.index !== -1); + if (!match) { + if (source.includes(newText) || source.includes(alreadyText)) { + console.log(`${path.basename(filePath)} already patched: ${label}`); + return; + } + warn(`${path.basename(filePath)} missing patch target for ${label}`); + return; + } + + const replacement = match.newText ?? newText; + fs.writeFileSync( + filePath, + `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match.oldText.length)}`, + "utf8", + ); + console.log(`Patched ${path.basename(filePath)}: ${label}`); +} + +const pluginDir = process.argv[2]; +if (!pluginDir) { + throw new Error("Usage: patch-chrome-plugin.js /path/to/chrome/plugin"); +} + +const scriptsDir = path.resolve(pluginDir, "scripts"); + +const linuxExtensionAwareUserDataFallback = ` const linuxChromeUserDataDirectory = path.join(os.homedir(), ".config", "google-chrome"); + const linuxChromiumUserDataDirectory = path.join(os.homedir(), ".config", "chromium"); + const linuxBraveUserDataDirectory = path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + ); + const linuxUserDataCandidates = [ + linuxBraveUserDataDirectory, + linuxChromeUserDataDirectory, + linuxChromiumUserDataDirectory, + ].filter((candidate) => fs.existsSync(candidate)); + const linuxCandidateWithInstalledExtension = linuxUserDataCandidates.find( + (candidate) => { + try { + const extensionId = loadRemoteChromeExtensionId(); + return findLatestChromeProfile(candidate) != null && + fs.existsSync( + path.join( + candidate, + resolveChromeProfileDirectory(candidate), + "Extensions", + extensionId, + ), + ); + } catch { + return false; + } + }, + ); + if (linuxCandidateWithInstalledExtension) { + return linuxCandidateWithInstalledExtension; + } + + if (linuxUserDataCandidates.length > 0) return linuxUserDataCandidates[0]; + + return linuxChromeUserDataDirectory;`; + +const linuxDefaultBrowserUserDataFallback = ` const linuxChromeUserDataDirectory = path.join(os.homedir(), ".config", "google-chrome"); + const linuxChromiumUserDataDirectory = path.join(os.homedir(), ".config", "chromium"); + const linuxBraveUserDataDirectory = path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + ); + const defaultBrowser = runCommand(["xdg-settings", "get", "default-web-browser"]); + if ( + defaultBrowser === "brave-browser.desktop" && + fs.existsSync(linuxBraveUserDataDirectory) + ) { + return linuxBraveUserDataDirectory; + } + if ( + ["chromium.desktop", "chromium-browser.desktop"].includes(defaultBrowser) && + fs.existsSync(linuxChromiumUserDataDirectory) + ) { + return linuxChromiumUserDataDirectory; + } + + if (fs.existsSync(linuxBraveUserDataDirectory)) return linuxBraveUserDataDirectory; + if (fs.existsSync(linuxChromeUserDataDirectory)) return linuxChromeUserDataDirectory; + if (fs.existsSync(linuxChromiumUserDataDirectory)) return linuxChromiumUserDataDirectory; + + return linuxChromeUserDataDirectory;`; + +const linuxNativeHostManifestFallback = ` if (process.platform === "linux") { + const manifestPaths = [ + path.join( + os.homedir(), + ".config", + "google-chrome", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + path.join( + os.homedir(), + ".config", + "chromium", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + ]; + + return { + manifestPath: + manifestPaths.find((candidate) => fs.existsSync(candidate)) || + manifestPaths[0], + registryKey: null, + registryManifestPath: null, + registryKeyExists: null, + }; + }`; + +patchFileFirstMatch(path.join(scriptsDir, "installManifest.mjs"), { + label: "Linux browser native host manifest locations", + oldTexts: [ + 'linux:[".config/google-chrome/NativeMessagingHosts"]', + 'linux:[".config/google-chrome/NativeMessagingHosts",".config/BraveSoftware/Brave-Browser/NativeMessagingHosts"]', + ], + newText: + 'linux:[".config/google-chrome/NativeMessagingHosts",".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",".config/chromium/NativeMessagingHosts"]', +}); + +patchFile(path.join(scriptsDir, "check-native-host-manifest.js"), [ + { + label: "Linux native host manifest locations", + oldText: ` if (process.platform === "win32") { + const registryKey = \`\${WINDOWS_NATIVE_HOST_REGISTRY_KEY_PREFIX}\\\\\${expectedHostName}\`; + const registryManifestPath = readWindowsRegistryDefaultValue(registryKey); + + return { + manifestPath: registryManifestPath || getDefaultWindowsManifestPath(), + registryKey, + registryManifestPath, + registryKeyExists: registryManifestPath != null, + }; + } + + throw new Error( + \`Unsupported platform for native host manifest check: \${process.platform}. This script supports macOS and Windows.\`, + );`, + newText: ` if (process.platform === "win32") { + const registryKey = \`\${WINDOWS_NATIVE_HOST_REGISTRY_KEY_PREFIX}\\\\\${expectedHostName}\`; + const registryManifestPath = readWindowsRegistryDefaultValue(registryKey); + + return { + manifestPath: registryManifestPath || getDefaultWindowsManifestPath(), + registryKey, + registryManifestPath, + registryKeyExists: registryManifestPath != null, + }; + } + +${linuxNativeHostManifestFallback} + + throw new Error( + \`Unsupported platform for native host manifest check: \${process.platform}. This script supports macOS, Linux, and Windows.\`, + );`, + alreadyText: '"chromium",\n "NativeMessagingHosts"', + }, + { + label: "Linux browser native host manifest fallback", + oldText: ` if (process.platform === "linux") { + return { + manifestPath: path.join( + os.homedir(), + ".config", + "google-chrome", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + registryKey: null, + registryManifestPath: null, + registryKeyExists: null, + }; + }`, + newText: linuxNativeHostManifestFallback, + alreadyText: '"chromium",\n "NativeMessagingHosts"', + }, +]); + +patchFileFirstMatch(path.join(scriptsDir, "browser-client.mjs"), { + label: "Linux Chrome profile path", + oldTexts: [ + { + oldText: 'var Tc=GF(VF(),WF()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":"Library/Application Support/Google/Chrome");', + newText: 'var Tc=GF(VF(),WF()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":WF()==="linux"?".config/google-chrome":"Library/Application Support/Google/Chrome");', + }, + { + oldText: 'var Ic=eO(tO(),rO()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":"Library/Application Support/Google/Chrome");', + newText: 'var Ic=eO(tO(),rO()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":rO()==="linux"?".config/google-chrome":"Library/Application Support/Google/Chrome");', + }, + ], + alreadyText: '".config/google-chrome"', +}); + +patchFile(path.join(scriptsDir, "installed-browsers.js"), [ + { + label: "Linux browser inventory", + oldText: `const KNOWN_BROWSERS = [ + { + name: "Google Chrome", + bundleIds: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + commands: ["google-chrome", "chrome"], + windowsExecutable: "chrome.exe", + }, +];`, + newText: `const KNOWN_BROWSERS = [ + { + name: "Google Chrome", + bundleIds: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + commands: ["google-chrome", "chrome"], + windowsExecutable: "chrome.exe", + }, + { + name: "Brave Browser", + bundleIds: ["com.brave.Browser"], + appNames: ["Brave Browser.app"], + commands: ["brave-browser", "brave"], + windowsExecutable: "brave.exe", + }, + { + name: "Chromium", + bundleIds: ["org.chromium.Chromium"], + appNames: ["Chromium.app"], + commands: ["chromium", "chromium-browser"], + windowsExecutable: "chrome.exe", + }, +];`, + }, +]); + +patchFile(path.join(scriptsDir, "chrome-is-running.js"), [ + { + label: "Linux browser running-process detection", + oldText: `const CHROME_PROCESS_NAMES_BY_PLATFORM = { + darwin: new Set(["Google Chrome", "Google Chrome Helper"]), + win32: new Set(["chrome.exe"]), +};`, + newText: `const CHROME_PROCESS_NAMES_BY_PLATFORM = { + darwin: new Set(["Google Chrome", "Google Chrome Helper"]), + linux: new Set(["chrome", "google-chrome", "brave", "brave-browser", "chromium", "chromium-browser"]), + win32: new Set(["chrome.exe"]), +};`, + }, +]); + +patchFileFirstMatch(path.join(scriptsDir, "check-extension-installed.js"), { + label: "Linux extension-aware browser profile fallback", + oldTexts: [ + ` return path.join(os.homedir(), ".config", "google-chrome");`, + ` const linuxChromeUserDataDirectory = path.join(os.homedir(), ".config", "google-chrome"); + if (fs.existsSync(linuxChromeUserDataDirectory)) return linuxChromeUserDataDirectory; + + const linuxBraveUserDataDirectory = path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + ); + if (fs.existsSync(linuxBraveUserDataDirectory)) return linuxBraveUserDataDirectory; + + return linuxChromeUserDataDirectory;`, + ], + newText: linuxExtensionAwareUserDataFallback, + alreadyText: "linuxChromiumUserDataDirectory", +}); + +patchFileFirstMatch(path.join(scriptsDir, "open-chrome-window.js"), { + label: "Linux default-browser profile fallback", + oldTexts: [ + ` return path.join(os.homedir(), ".config", "google-chrome");`, + ` const linuxChromeUserDataDirectory = path.join(os.homedir(), ".config", "google-chrome"); + if (fs.existsSync(linuxChromeUserDataDirectory)) return linuxChromeUserDataDirectory; + + const linuxBraveUserDataDirectory = path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + ); + if (fs.existsSync(linuxBraveUserDataDirectory)) return linuxBraveUserDataDirectory; + + return linuxChromeUserDataDirectory;`, + ], + newText: linuxDefaultBrowserUserDataFallback, + alreadyText: "linuxChromiumUserDataDirectory", +}); + +patchFile(path.join(scriptsDir, "open-chrome-window.js"), [ + { + label: "Linux browser window command", + oldText: ` return { + command: "google-chrome", + args: chromeArgs, + };`, + newText: ` const linuxUserDataDirectory = resolveChromeUserDataDirectory(); + let linuxCommand = commandPath("google-chrome") || commandPath("chrome") || "google-chrome"; + if ( + linuxUserDataDirectory.includes( + path.join(".config", "BraveSoftware", "Brave-Browser"), + ) + ) { + linuxCommand = commandPath("brave-browser") || commandPath("brave") || "brave-browser"; + } else if (linuxUserDataDirectory.includes(path.join(".config", "chromium"))) { + linuxCommand = commandPath("chromium") || commandPath("chromium-browser") || "chromium"; + } + + return { + command: linuxCommand, + args: chromeArgs, + };`, + }, +]); diff --git a/scripts/lib/rebuild-report.sh b/scripts/lib/rebuild-report.sh index a41278b4..25c16f16 100644 --- a/scripts/lib/rebuild-report.sh +++ b/scripts/lib/rebuild-report.sh @@ -6,6 +6,10 @@ default_rebuild_report_dir() { prepare_rebuild_report_dir() { local report_dir="$1" + case "$report_dir" in + /*) ;; + *) report_dir="$PWD/$report_dir" ;; + esac mkdir -p "$report_dir" echo "$report_dir" } diff --git a/scripts/patch-linux-window-ui.js b/scripts/patch-linux-window-ui.js index 6ac2d00e..bc749a45 100644 --- a/scripts/patch-linux-window-ui.js +++ b/scripts/patch-linux-window-ui.js @@ -1,2127 +1,80 @@ #!/usr/bin/env node +"use strict"; -const fs = require("fs"); -const path = require("path"); const { - captureWarnings, createPatchReport, - patchStatusFromChange, - recordPatch, writePatchReport, } = require("./lib/patch-report.js"); const { - applyLinuxAppUpdaterBridgePatch, - applyLinuxAppUpdaterMenuPatch, - patchLinuxAppUpdaterBridge, -} = require("./lib/linux-update-bridge-patch.js"); - -function readDirectoryNames(dir) { - if (!fs.existsSync(dir)) { - return []; - } - return fs.readdirSync(dir); -} - -function findMainBundle(extractedDir) { - const buildDir = path.join(extractedDir, ".vite", "build"); - const mainBundle = readDirectoryNames(buildDir).find((name) => - /^main(?:-[^.]+)?\.js$/.test(name), - ); - - return mainBundle == null ? null : { buildDir, mainBundle }; -} - -function findIconAsset(extractedDir) { - const assetsDir = path.join(extractedDir, "webview", "assets"); - return readDirectoryNames(assetsDir).find((name) => /^app-.*\.png$/.test(name)) ?? null; -} - -const keybindsSettingsAsset = "keybinds-settings-linux.js"; -const linuxKeybindOverridesKey = "codex-linux-keybind-overrides"; - -const COMPUTER_USE_UI_ENV_VAR = "CODEX_LINUX_ENABLE_COMPUTER_USE_UI"; -const COMPUTER_USE_UI_SETTINGS_KEY = "codex-linux-computer-use-ui-enabled"; - -// Two opt-in surfaces, both checked at build time: -// -// 1. Env var `CODEX_LINUX_ENABLE_COMPUTER_USE_UI=1` — for ad-hoc builds -// (`make build-app`, manual `make package`). -// 2. Persisted flag `codex-linux-computer-use-ui-enabled: true` in -// `~/.config/codex-app/settings.json` — for the auto-updater path, -// where the systemd user service does not inherit interactive shell env. -// -// Either path enables the three Statsig-bypass-style Computer Use UI patches -// (`applyLinuxComputerUseFeaturePatch`, `applyLinuxComputerUseRendererAvailabilityPatch`, -// `applyLinuxComputerUseInstallFlowPatch`). The plugin manifest gate -// (`applyLinuxComputerUsePluginGatePatch`) is pure platform-port glue and -// stays unconditional — it is what we have shipped on by default since the -// project's first release. -function isComputerUseUiEnabled(env = process.env) { - if (env[COMPUTER_USE_UI_ENV_VAR] === "1") { - return true; - } - return readComputerUseUiSettingsFlag(env); -} - -function readComputerUseUiSettingsFlag(env) { - const settingsPath = computerUseUiSettingsPath(env); - if (settingsPath == null) { - return false; - } - try { - if (!fs.existsSync(settingsPath)) { - return false; - } - const raw = fs.readFileSync(settingsPath, "utf8"); - const parsed = JSON.parse(raw); - if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { - return false; - } - return parsed[COMPUTER_USE_UI_SETTINGS_KEY] === true; - } catch { - return false; - } -} - -function computerUseUiSettingsPath(env) { - const xdgConfig = env.XDG_CONFIG_HOME; - const home = env.HOME; - const configHome = (xdgConfig && xdgConfig.length > 0) - ? xdgConfig - : home - ? path.join(home, ".config") - : null; - return configHome == null ? null : path.join(configHome, "codex-app", "settings.json"); -} - -// Lookback/lookahead windows used when searching for the nearest minified -// identifier or surrounding context around a regex anchor in the bundle. -// Sized empirically to the typical distance between a feature's anchor and -// the helper aliases it depends on. -const TRAY_GUARD_LOOKAHEAD = 1200; -const CLOSE_GATE_PREFIX_LOOKBACK = 8000; -const HANDLER_PREFIX_LOOKBACK = 12000; -const DIRECT_HANDLER_PROXIMITY = 1200; - -const linuxSettingsKeys = { - promptWindow: "codex-linux-prompt-window-enabled", - systemTray: "codex-linux-system-tray-enabled", - warmStart: "codex-linux-warm-start-enabled", -}; - -const unsafeJavaScriptLiteralChars = /[<>/\u2028\u2029]/g; -const unsafeJavaScriptLiteralEscapes = { - "<": "\\u003C", - ">": "\\u003E", - "/": "\\u002F", - "\u2028": "\\u2028", - "\u2029": "\\u2029", -}; -const safeWebviewJavaScriptAssetName = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/; - -function jsStringLiteral(value) { - return JSON.stringify(String(value)).replace( - unsafeJavaScriptLiteralChars, - (char) => unsafeJavaScriptLiteralEscapes[char], - ); -} - -function assertSafeWebviewJavaScriptAssetName(value, description) { - if (typeof value !== "string" || !safeWebviewJavaScriptAssetName.test(value)) { - throw new Error(`Required Keybinds settings patch failed: unsafe ${description} ${JSON.stringify(value)}`); - } - return value; -} - -function jsModuleSpecifier(assetName, description) { - return jsStringLiteral(`./${assertSafeWebviewJavaScriptAssetName(assetName, description)}`); -} - -function patchAssetFiles(extractedDir, filenamePattern, patchFn, missingWarnMessage) { - const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); - if (!fs.existsSync(webviewAssetsDir)) { - console.warn( - `WARN: Could not find webview assets directory in ${webviewAssetsDir} — skipping asset patch`, - ); - return { matched: 0, changed: 0 }; - } - - const candidates = fs - .readdirSync(webviewAssetsDir) - .filter((name) => filenamePattern.test(name)) - .sort(); - - if (candidates.length === 0) { - console.warn(missingWarnMessage); - return { matched: 0, changed: 0 }; - } - - let changed = 0; - for (const candidate of candidates) { - const filePath = path.join(webviewAssetsDir, candidate); - const currentSource = fs.readFileSync(filePath, "utf8"); - const patchedSource = patchFn(currentSource); - if (patchedSource !== currentSource) { - fs.writeFileSync(filePath, patchedSource, "utf8"); - changed += 1; - } - } - - return { matched: candidates.length, changed }; -} - -function readWebviewAsset(webviewAssetsDir, assetName) { - return fs.readFileSync(path.join(webviewAssetsDir, assetName), "utf8"); -} - -function findRequiredWebviewAsset(webviewAssetsDir, filenamePattern, marker, description) { - if (!fs.existsSync(webviewAssetsDir)) { - throw new Error(`Required Keybinds settings patch failed: missing webview assets directory ${webviewAssetsDir}`); - } - - const candidates = fs - .readdirSync(webviewAssetsDir) - .filter((name) => filenamePattern.test(name)) - .sort(); - const matches = marker == null - ? candidates - : candidates.filter((name) => readWebviewAsset(webviewAssetsDir, name).includes(marker)); - - if (matches.length === 0) { - throw new Error(`Required Keybinds settings patch failed: could not find ${description}`); - } - - return matches[0]; -} - -function findImportedAsset(webviewAssetsDir, importerAsset, description) { - const importedAsset = readWebviewAsset(webviewAssetsDir, importerAsset).match(/from"\.\/([^"]+)"/)?.[1]; - if (!importedAsset || !fs.existsSync(path.join(webviewAssetsDir, importedAsset))) { - throw new Error(`Required Keybinds settings patch failed: could not find ${description}`); - } - return importedAsset; -} - -function buildKeybindsSettingsSource({ - chunkAsset, - reactAsset, - jsxRuntimeAsset, - vscodeApiAsset, - hotkeySettingsAsset, - toggleAsset, - settingsRowAsset, - settingsLayoutAsset, -}) { - const imports = { - chunk: jsModuleSpecifier(chunkAsset, "React shared chunk asset"), - react: jsModuleSpecifier(reactAsset, "React asset"), - jsxRuntime: jsModuleSpecifier(jsxRuntimeAsset, "JSX runtime asset"), - vscodeApi: jsModuleSpecifier(vscodeApiAsset, "VS Code API asset"), - hotkeySettings: jsModuleSpecifier(hotkeySettingsAsset, "hotkey settings asset"), - toggle: jsModuleSpecifier(toggleAsset, "toggle asset"), - settingsRow: jsModuleSpecifier(settingsRowAsset, "settings row asset"), - settingsLayout: jsModuleSpecifier(settingsLayoutAsset, "settings content layout asset"), - }; - const keybindGroups = [ - { - title: "Core", - actions: [ - { id: "newThread", label: "New chat", description: "Start a new chat." }, - { id: "quickChat", label: "Quick chat", description: "Open a quick chat window." }, - { id: "newThreadAlt", label: "New chat alternate", description: "Alternate shortcut for a new chat." }, - { id: "openFolder", label: "Open folder", description: "Open a workspace folder." }, - { id: "settings", label: "Settings", description: "Open settings." }, - { id: "openCommandMenu", label: "Command menu", description: "Open the command menu." }, - { id: "openCommandMenuAlt", label: "Command menu alternate", description: "Alternate shortcut for the command menu." }, - { id: "searchChats", label: "Search chats", description: "Search existing chats." }, - { id: "searchFiles", label: "Search files", description: "Search files in the current workspace." }, - { id: "newWindow", label: "New window", description: "Open a new app window." }, - ], - }, - { - title: "Thread", - actions: [ - { id: "findInThread", label: "Find in thread", description: "Search inside the current thread." }, - { id: "copyConversationPath", label: "Copy conversation path", description: "Copy the current conversation path." }, - { id: "toggleThreadPin", label: "Toggle thread pin", description: "Pin or unpin the current thread." }, - { id: "renameThread", label: "Rename thread", description: "Rename the current thread." }, - { id: "archiveThread", label: "Archive thread", description: "Archive the current thread." }, - { id: "copyWorkingDirectory", label: "Copy working directory", description: "Copy the current working directory." }, - { id: "copySessionId", label: "Copy session ID", description: "Copy the current session ID." }, - { id: "copyDeeplink", label: "Copy deeplink", description: "Copy a deeplink for the current thread." }, - { id: "previousThread", label: "Previous thread", description: "Move to the previous thread." }, - { id: "nextThread", label: "Next thread", description: "Move to the next thread." }, - { id: "thread1", label: "Thread 1", description: "Jump to thread slot 1." }, - { id: "thread2", label: "Thread 2", description: "Jump to thread slot 2." }, - { id: "thread3", label: "Thread 3", description: "Jump to thread slot 3." }, - { id: "thread4", label: "Thread 4", description: "Jump to thread slot 4." }, - { id: "thread5", label: "Thread 5", description: "Jump to thread slot 5." }, - { id: "thread6", label: "Thread 6", description: "Jump to thread slot 6." }, - { id: "thread7", label: "Thread 7", description: "Jump to thread slot 7." }, - { id: "thread8", label: "Thread 8", description: "Jump to thread slot 8." }, - { id: "thread9", label: "Thread 9", description: "Jump to thread slot 9." }, - ], - }, - { - title: "Panels", - actions: [ - { id: "toggleSidebar", label: "Toggle sidebar", description: "Show or hide the sidebar." }, - { id: "toggleTerminal", label: "Toggle terminal", description: "Show or hide the terminal." }, - { id: "toggleFileTreePanel", label: "Toggle file tree", description: "Show or hide the file tree." }, - { id: "openBrowserTab", label: "Open browser tab", description: "Open a browser tab." }, - { id: "reloadBrowserPage", label: "Reload browser page", description: "Reload the active browser page." }, - { id: "hardReloadBrowserPage", label: "Hard reload browser page", description: "Hard reload the active browser page." }, - { id: "toggleBrowserPanel", label: "Toggle browser panel", description: "Show or hide the browser panel." }, - { id: "toggleDiffPanel", label: "Toggle review panel", description: "Show or hide the review panel." }, - { id: "openThreadOverlay", label: "Open thread switcher", description: "Open the thread switcher." }, - { id: "openAvatarOverlay", label: "Open account menu", description: "Open the account menu." }, - ], - }, - { - title: "System", - actions: [ - { id: "toggleTraceRecording", label: "Toggle trace recording", description: "Start or stop trace recording." }, - { id: "dictation", label: "Dictation", description: "Start dictation." }, - ], - }, - ]; - - return `import{s as __toESM}from${imports.chunk};import{t as __reactFactory}from${imports.react};import{t as __jsxFactory}from${imports.jsxRuntime};import{n as __post,rn as DEFAULT_SHORTCUTS}from${imports.vscodeApi};import{i as HotkeyWindowHotkeyRow}from${imports.hotkeySettings};import{t as Toggle}from${imports.toggle};import{n as SettingsRow}from${imports.settingsRow};import{r as SettingsSection,n as SettingsGroup,t as SettingsPage}from${imports.settingsLayout};var React=__toESM(__reactFactory(),1),$=__jsxFactory(),KEYS={promptWindow:${jsStringLiteral(linuxSettingsKeys.promptWindow)},systemTray:${jsStringLiteral(linuxSettingsKeys.systemTray)},warmStart:${jsStringLiteral(linuxSettingsKeys.warmStart)}},KEYBIND_OVERRIDES_KEY=${jsStringLiteral(linuxKeybindOverridesKey)},KEYBIND_GROUPS=${JSON.stringify(keybindGroups)};function normalizeOverrides(value){if(!value||typeof value!="object"||Array.isArray(value))return{};return Object.fromEntries(Object.entries(value).filter(([key,accelerator])=>typeof key=="string"&&typeof accelerator=="string"&&accelerator.trim().length>0).map(([key,accelerator])=>[key,accelerator.trim()]))}function readLocalOverrides(){try{return normalizeOverrides(JSON.parse(localStorage.getItem(KEYBIND_OVERRIDES_KEY)||"{}"))}catch{return{}}}function writeLocalOverrides(next){try{localStorage.setItem(KEYBIND_OVERRIDES_KEY,JSON.stringify(next)),window.dispatchEvent(new CustomEvent("codex-linux-keybind-overrides-changed",{detail:next}))}catch{}}function useKeybindOverrides(){let[overrides,setOverrides]=React.useState(()=>readLocalOverrides()),[error,setError]=React.useState(null);React.useEffect(()=>{let alive=!0;__post("get-global-state",{params:{key:KEYBIND_OVERRIDES_KEY}}).then(result=>{if(!alive)return;let next=normalizeOverrides(result?.value);Object.keys(next).length>0?(setOverrides(next),writeLocalOverrides(next)):setOverrides(readLocalOverrides());setError(null)}).catch(err=>{alive&&setError(err instanceof Error?err.message:String(err))});return()=>{alive=!1}},[]);let update=React.useCallback((actionId,accelerator)=>{setOverrides(previous=>{let next={...previous},defaultValue=typeof DEFAULT_SHORTCUTS[actionId]=="string"?DEFAULT_SHORTCUTS[actionId]:"",trimmed=String(accelerator??"").trim();trimmed.length===0||trimmed===defaultValue?delete next[actionId]:next[actionId]=trimmed;writeLocalOverrides(next);__post("set-global-state",{params:{key:KEYBIND_OVERRIDES_KEY,value:next}}).then(()=>setError(null)).catch(err=>setError(err instanceof Error?err.message:String(err)));return next})},[]);return{overrides,error,update}}function useLinuxSetting(key,defaultValue){let[value,setValue]=React.useState(defaultValue),[isLoading,setIsLoading]=React.useState(!0),[error,setError]=React.useState(null);React.useEffect(()=>{let alive=!0;setIsLoading(!0);__post("get-global-state",{params:{key}}).then(result=>{alive&&(setValue(result?.value??defaultValue),setError(null))}).catch(err=>{alive&&setError(err instanceof Error?err.message:String(err))}).finally(()=>{alive&&setIsLoading(!1)});return()=>{alive=!1}},[key,defaultValue]);let update=React.useCallback(next=>{let previous=value;setValue(next);setError(null);__post("set-global-state",{params:{key,value:next}}).catch(err=>{setValue(previous);setError(err instanceof Error?err.message:String(err))})},[key,value]);return{value,isLoading,error,update}}function LinuxToggle({settingKey,label,description,defaultValue=!0}){let{value,isLoading,error,update}=useLinuxSetting(settingKey,defaultValue),details=error?$.jsxs("div",{className:"flex flex-col gap-1",children:[$.jsx("span",{children:description}),$.jsx("span",{className:"text-token-error-foreground",children:error})]}):description;return $.jsx(SettingsRow,{label,description:details,control:$.jsx(Toggle,{checked:value,disabled:isLoading,onChange:update,ariaLabel:label})})}function normalizeCapturedKey(key){let map={" ":"Space",ArrowUp:"Up",ArrowDown:"Down",ArrowLeft:"Left",ArrowRight:"Right",Escape:"Esc",",":",",".":".","/":"/","\\\\":"\\\\","[":"[","]":"]",";":";","'":"'","-":"-","=":"=","+":"Plus"};if(map[key])return map[key];if(/^.$/.test(key))return key.toUpperCase();return key}function formatAcceleratorForInput(event){if(!(event.ctrlKey||event.altKey||event.metaKey))return null;if(["Control","Shift","Alt","Meta"].includes(event.key))return null;let parts=[];event.ctrlKey&&parts.push("Ctrl");event.altKey&&parts.push("Alt");event.shiftKey&&parts.push("Shift");event.metaKey&&parts.push("Command");let key=normalizeCapturedKey(event.key);return key?[...parts,key].join("+"):null}function ShortcutInput({value,defaultValue,changed,onChange}){let[draft,setDraft]=React.useState(value);React.useEffect(()=>setDraft(value),[value]);let commit=next=>onChange(String(next??"").trim());return $.jsxs("div",{className:"flex min-w-[260px] items-center justify-end gap-2",children:[$.jsx("input",{className:"h-8 w-[190px] rounded-md border border-token-border-default bg-token-bg-primary px-2 text-sm text-token-text-primary outline-none focus:border-token-border-strong","data-codex-keybind-input":!0,value:draft,placeholder:defaultValue,onChange:event=>{setDraft(event.target.value),onChange(event.target.value)},onBlur:()=>commit(draft),onKeyDown:event=>{if(event.key==="Escape"){setDraft(value);return}if(event.key==="Enter"){event.preventDefault(),commit(draft);return}let captured=formatAcceleratorForInput(event);captured&&(event.preventDefault(),setDraft(captured),onChange(captured))}}),$.jsx("button",{type:"button",className:"h-8 rounded-md border border-token-border-default px-2 text-xs text-token-text-secondary disabled:opacity-40",disabled:!changed,onClick:()=>onChange(""),children:"Reset"})]})}function KeybindRow({action,overrides,update}){let defaultValue=typeof DEFAULT_SHORTCUTS[action.id]=="string"?DEFAULT_SHORTCUTS[action.id]:action.defaultAccelerator??"",hasOverride=Object.prototype.hasOwnProperty.call(overrides,action.id),value=hasOverride?overrides[action.id]:defaultValue,changed=hasOverride&&value!==defaultValue,description=$.jsxs("div",{className:"flex flex-col gap-1",children:[$.jsx("span",{children:action.description}),$.jsxs("span",{className:"text-token-text-tertiary",children:["Default: ",defaultValue||"Unassigned"]})]});return $.jsx(SettingsRow,{label:action.label,description,control:$.jsx(ShortcutInput,{value,defaultValue,changed,onChange:next=>update(action.id,next)})})}function KeybindGroup({group,overrides,update}){return $.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:group.title}),$.jsx(SettingsSection.Content,{children:$.jsx(SettingsGroup,{children:group.actions.map(action=>$.jsx(KeybindRow,{action,overrides,update},action.id))})})]},group.title)}function KeybindsSettings(){let{overrides,error,update}=useKeybindOverrides();return $.jsx(SettingsPage,{title:"Keybinds",subtitle:"App shortcuts and Linux desktop behavior.",children:$.jsxs("div",{className:"flex flex-col gap-6",children:[$.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:"App shortcuts"}),error?$.jsx("div",{className:"px-1 text-sm text-token-error-foreground",children:error}):null]}),...KEYBIND_GROUPS.map(group=>$.jsx(KeybindGroup,{group,overrides,update},group.title)),$.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:"Global shortcuts"}),$.jsx(SettingsSection.Content,{children:$.jsxs(SettingsGroup,{children:[$.jsx(HotkeyWindowHotkeyRow,{}),$.jsx(LinuxToggle,{settingKey:KEYS.promptWindow,label:"Compact prompt window",description:"Allow --prompt-chat and --hotkey-window to open the compact prompt window and keep it prewarmed."})]})})]}),$.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:"Linux desktop"}),$.jsx(SettingsSection.Content,{children:$.jsxs(SettingsGroup,{children:[$.jsx(LinuxToggle,{settingKey:KEYS.systemTray,label:"System tray",description:"Show the Codex system tray icon and keep the app available from the tray."}),$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."})]})})]})]})})}export{KeybindsSettings,KeybindsSettings as default};\n//# sourceMappingURL=keybinds-settings-linux.js.map\n`; -} - -function resolveKeybindsSettingsAsset(extractedDir) { - const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); - if (!fs.existsSync(webviewAssetsDir)) { - throw new Error(`Required Keybinds settings patch failed: missing webview assets directory ${webviewAssetsDir}`); - } - - const reactAsset = findRequiredWebviewAsset(webviewAssetsDir, /^react-.*\.js$/, "react.transitional.element", "React asset"); - const chunkAsset = findImportedAsset(webviewAssetsDir, reactAsset, "React shared chunk asset"); - const jsxRuntimeAsset = findRequiredWebviewAsset(webviewAssetsDir, /^jsx-runtime-.*\.js$/, "react.transitional.element", "JSX runtime asset"); - const vscodeApiAsset = findRequiredWebviewAsset(webviewAssetsDir, /^vscode-api-.*\.js$/, "vscode://codex", "VS Code API asset"); - const hotkeySettingsAsset = findRequiredWebviewAsset( - webviewAssetsDir, - /^general-settings-.*\.js$/, - "hotkey-window-hotkey-state", - "hotkey settings asset", - ); - const toggleAsset = findRequiredWebviewAsset(webviewAssetsDir, /^toggle-.*\.js$/, null, "toggle asset"); - const settingsRowAsset = findRequiredWebviewAsset(webviewAssetsDir, /^settings-row-.*\.js$/, null, "settings row asset"); - const settingsLayoutAsset = findRequiredWebviewAsset( - webviewAssetsDir, - /^settings-content-layout-.*\.js$/, - null, - "settings content layout asset", - ); - const filePath = path.join(webviewAssetsDir, keybindsSettingsAsset); - - return { - filePath, - source: buildKeybindsSettingsSource({ - chunkAsset: assertSafeWebviewJavaScriptAssetName(chunkAsset, "React shared chunk asset"), - reactAsset: assertSafeWebviewJavaScriptAssetName(reactAsset, "React asset"), - jsxRuntimeAsset: assertSafeWebviewJavaScriptAssetName(jsxRuntimeAsset, "JSX runtime asset"), - vscodeApiAsset: assertSafeWebviewJavaScriptAssetName(vscodeApiAsset, "VS Code API asset"), - hotkeySettingsAsset: assertSafeWebviewJavaScriptAssetName(hotkeySettingsAsset, "hotkey settings asset"), - toggleAsset: assertSafeWebviewJavaScriptAssetName(toggleAsset, "toggle asset"), - settingsRowAsset: assertSafeWebviewJavaScriptAssetName(settingsRowAsset, "settings row asset"), - settingsLayoutAsset: assertSafeWebviewJavaScriptAssetName(settingsLayoutAsset, "settings content layout asset"), - }), - }; -} - -function collectRequiredAssetPatches(extractedDir, filenamePattern, patchFn, description) { - const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); - if (!fs.existsSync(webviewAssetsDir)) { - throw new Error(`Required Keybinds settings patch failed: missing webview assets directory ${webviewAssetsDir}`); - } - - const candidates = fs - .readdirSync(webviewAssetsDir) - .filter((name) => filenamePattern.test(name)) - .sort(); - if (candidates.length === 0) { - throw new Error(`Required Keybinds settings patch failed: could not find ${description}`); - } - - return candidates.map((candidate) => { - const filePath = path.join(webviewAssetsDir, candidate); - const currentSource = fs.readFileSync(filePath, "utf8"); - return { - filePath, - currentSource, - patchedSource: patchFn(currentSource), - }; - }); -} - -function patchKeybindsSettingsAssets(extractedDir) { - try { - const keybindsAsset = resolveKeybindsSettingsAsset(extractedDir); - const keybindsAssetExists = fs.existsSync(keybindsAsset.filePath); - const previousKeybindsSource = keybindsAssetExists - ? fs.readFileSync(keybindsAsset.filePath, "utf8") - : null; - const patches = [ - ...collectRequiredAssetPatches( - extractedDir, - /^settings-sections-.*\.js$/, - applyKeybindsSettingsSectionsPatch, - "settings sections bundle", - ), - ...collectRequiredAssetPatches( - extractedDir, - /^settings-shared-.*\.js$/, - applyKeybindsSettingsSharedPatch, - "settings shared bundle", - ), - ...collectRequiredAssetPatches( - extractedDir, - /^index-.*\.js$/, - applyKeybindsSettingsIndexPatch, - "webview index bundle", - ), - ]; - - fs.writeFileSync(keybindsAsset.filePath, keybindsAsset.source, "utf8"); - let changed = previousKeybindsSource !== keybindsAsset.source ? 1 : 0; - for (const patch of patches) { - if (patch.patchedSource !== patch.currentSource) { - fs.writeFileSync(patch.filePath, patch.patchedSource, "utf8"); - changed += 1; - } - } - return { matched: true, changed }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`WARN: Keybinds settings patch skipped: ${message}`); - return { matched: false, changed: 0, reason: message }; - } -} - -function applyKeybindsSettingsSectionsPatch(currentSource) { - let patchedSource = currentSource; - - if ( - patchedSource.includes("slug:`keybinds`") || - patchedSource.includes("slug:`keyboard-shortcuts`") - ) { - return patchedSource; - } - - const sectionsNeedle = "var e=`general-settings`,t=`mcp-settings`,n=[{slug:e},"; - const sectionsPatch = "var e=`general-settings`,t=`mcp-settings`,n=[{slug:e},{slug:`keybinds`},"; - if (patchedSource.includes(sectionsNeedle)) { - return patchedSource.replace(sectionsNeedle, sectionsPatch); - } - - const currentNeedle = "n=[{slug:e},{slug:`appearance`}"; - if (patchedSource.includes(currentNeedle)) { - return patchedSource.replace(currentNeedle, "n=[{slug:e},{slug:`keybinds`},{slug:`appearance`}"); - } - - const literalNeedle = "n=[{slug:`general-settings`},{slug:`appearance`}"; - if (patchedSource.includes(literalNeedle)) { - return patchedSource.replace(literalNeedle, "n=[{slug:`general-settings`},{slug:`keybinds`},{slug:`appearance`}"); - } - - throw new Error("Required Keybinds settings patch failed: could not add keybinds settings section"); -} - -function applyKeybindsSettingsSharedPatch(currentSource) { - let patchedSource = currentSource; - - if ( - patchedSource.includes("settings.nav.keyboard-shortcuts") && - patchedSource.includes("settings.section.keyboard-shortcuts") - ) { - return patchedSource; - } - - if (!patchedSource.includes("settings.nav.keybinds")) { - const navNeedle = - '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},'; - const navPatch = - '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},keybinds:{id:`settings.nav.keybinds`,defaultMessage:`Keybinds`,description:`Title for keybinds settings section`},'; - if (!patchedSource.includes(navNeedle)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds nav label"); - } - patchedSource = patchedSource.replace(navNeedle, navPatch); - } - - if (!patchedSource.includes("settings.section.keybinds")) { - const sectionNeedle = - "case`general-settings`:{let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`,description:`Title for general settings section`}),t[2]=e):e=t[2],e}"; - const sectionPatch = - "case`general-settings`:{let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`,description:`Title for general settings section`}),t[2]=e):e=t[2],e}case`keybinds`:{return (0,d.jsx)(n,{id:`settings.section.keybinds`,defaultMessage:`Keybinds`,description:`Title for keybinds settings section`})}"; - if (!patchedSource.includes(sectionNeedle)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds section title"); - } - patchedSource = patchedSource.replace(sectionNeedle, sectionPatch); - } - - return patchedSource; -} - -function applyLinuxKeybindOverridesRuntimePatch(currentSource) { - const runtimePatch = `;function codexLinuxKeybindOverridesRuntime(){try{if(typeof window=="undefined")return;let storageKey=${jsStringLiteral(linuxKeybindOverridesKey)},defaultMap=typeof Ct=="object"&&Ct?Ct:{},overrides={};function loadOverrides(){try{let value=JSON.parse(localStorage.getItem(storageKey)||"{}");overrides=value&&typeof value=="object"&&!Array.isArray(value)?value:{}}catch{overrides={}}}function isShortcutCaptureTarget(event){let target=event.target;return target instanceof Element&&target.closest("[data-codex-keybind-input]")!=null}function normalizeKeyName(key){let map={Space:" ",Esc:"Escape",Up:"ArrowUp",Down:"ArrowDown",Left:"ArrowLeft",Right:"ArrowRight",Plus:"+",Comma:",",Period:".",Slash:"/"};return map[key]??(/^.$/.test(key)?key.toUpperCase():key)}function parseAccelerator(accelerator){if(typeof accelerator!="string"||accelerator.trim().length===0)return null;let isMac=/Mac/.test(navigator.platform||""),parts=accelerator.split("+").map(part=>part.trim()).filter(Boolean),parsed={ctrl:false,alt:false,shift:false,meta:false,key:null};for(let part of parts){switch(part){case"CmdOrCtrl":isMac?parsed.meta=true:parsed.ctrl=true;break;case"Command":case"Cmd":case"Meta":case"Super":case"Win":parsed.meta=true;break;case"Control":case"Ctrl":parsed.ctrl=true;break;case"Alt":case"Option":parsed.alt=true;break;case"Shift":parsed.shift=true;break;default:parsed.key=normalizeKeyName(part);break}}return parsed.key?parsed:null}function matches(event,parsed){return event.ctrlKey===parsed.ctrl&&event.altKey===parsed.alt&&event.shiftKey===parsed.shift&&event.metaKey===parsed.meta&&normalizeKeyName(event.key)===parsed.key}function dispatchHost(message){if(typeof E=="object"&&E&&typeof E.dispatchHostMessage=="function"){E.dispatchHostMessage(message);return true}return false}function dispatchElectron(type,params={}){if(typeof E=="object"&&E&&typeof E.dispatchMessage=="function"){E.dispatchMessage(type,params);return true}return false}let hostActionTypes={newThread:"new-chat",quickChat:"new-quick-chat",newThreadAlt:"new-chat",toggleSidebar:"toggle-sidebar",toggleTerminal:"toggle-terminal",toggleBrowserPanel:"toggle-browser-panel",toggleDiffPanel:"toggle-diff-panel",findInThread:"find-in-thread",navigateBack:"navigate-back",navigateForward:"navigate-forward",previousThread:"previous-thread",nextThread:"next-thread",copyConversationPath:"copy-conversation-path",toggleThreadPin:"toggle-thread-pin",renameThread:"rename-thread",archiveThread:"archive-thread",copyWorkingDirectory:"copy-working-directory",copySessionId:"copy-session-id",copyDeeplink:"copy-deeplink",toggleFileTreePanel:"toggle-file-tree-panel"};function runAction(id){if(/^thread[1-9]$/.test(id))return dispatchHost({type:"go-to-thread-index",index:Number(id.slice(6))-1});switch(id){case"openCommandMenu":case"openCommandMenuAlt":return dispatchHost({type:"command-menu",query:""});case"searchChats":return dispatchHost({type:"chat-search-command-menu"});case"searchFiles":return dispatchHost({type:"file-search-command-menu"});case"openFolder":return dispatchElectron("electron-create-new-workspace-root-option",{});case"settings":return dispatchElectron("show-settings",{section:"general-settings"});case"openBrowserTab":return dispatchHost({type:"browser-sidebar-command",command:{type:"new-tab"}});case"reloadBrowserPage":return dispatchHost({type:"browser-sidebar-command",command:{type:"reload"}});case"hardReloadBrowserPage":return dispatchHost({type:"browser-sidebar-command",command:{type:"hard-reload"}});case"dictation":return dispatchElectron("global-dictation-start",{});default:return hostActionTypes[id]?dispatchHost({type:hostActionTypes[id]}):false}}loadOverrides();window.addEventListener("storage",event=>{event.key===storageKey&&loadOverrides()});window.addEventListener("codex-linux-keybind-overrides-changed",loadOverrides);window.addEventListener("keydown",event=>{if(event.defaultPrevented||event.repeat||isShortcutCaptureTarget(event))return;for(let[id,accelerator]of Object.entries(overrides)){if(typeof accelerator!="string"||accelerator.trim().length===0||accelerator.trim()===(defaultMap[id]||""))continue;let parsed=parseAccelerator(accelerator);if(parsed&&matches(event,parsed)&&runAction(id)){event.preventDefault();event.stopPropagation();break}}},true)}catch{}}codexLinuxKeybindOverridesRuntime();`; - - const runtimeMarker = ";function codexLinuxKeybindOverridesRuntime()"; - const existingRuntimeIndex = currentSource.indexOf(runtimeMarker); - if (existingRuntimeIndex !== -1) { - return `${currentSource.slice(0, existingRuntimeIndex).trimEnd()}\n${runtimePatch}`; - } - - return `${currentSource}\n${runtimePatch}`; -} - -function applyKeybindsSettingsIndexPatch(currentSource) { - let patchedSource = currentSource; - - if (patchedSource.includes('"keyboard-shortcuts":')) { - if (!patchedSource.includes(`${keybindsSettingsAsset}`)) { - const routePattern = - /"keyboard-shortcuts":\(0,([A-Za-z_$][\w$]*)\.lazy\)\(\(\)=>([A-Za-z_$][\w$]*)\(\(\)=>import\(`\.\/[^`]+`\)[\s\S]*?,import\.meta\.url\)\),((?:"[^"]+"|[A-Za-z_$][\w$]*):)/; - if (!routePattern.test(patchedSource)) { - throw new Error( - "Required Keybinds settings patch failed: could not replace keyboard shortcuts route", - ); - } - patchedSource = patchedSource.replace( - routePattern, - `"keyboard-shortcuts":(0,$1.lazy)(()=>$2(()=>import(\`./${keybindsSettingsAsset}\`),[],import.meta.url)),$3`, - ); - } - - return applyLinuxKeybindOverridesRuntimePatch(patchedSource); - } - - if (!patchedSource.includes(`${keybindsSettingsAsset}`)) { - const routePattern = - /((?:var|let|const) [A-Za-z_$][\w$]*=\{(?:\.\.\.[A-Za-z_$][\w$]*,)?)"general-settings":(?=\(0,([A-Za-z_$][\w$]*)\.lazy\)\(\(\)=>([A-Za-z_$][\w$]*)\()/; - if (!routePattern.test(patchedSource)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds route"); - } - patchedSource = patchedSource.replace( - routePattern, - `$1keybinds:(0,$2.lazy)(()=>$3(()=>import(\`./${keybindsSettingsAsset}\`),[],import.meta.url)),"general-settings":`, - ); - } - - if (!/[,{]keybinds:[A-Za-z_$][\w$]*,"general-settings":/.test(patchedSource)) { - const iconPattern = /([A-Za-z_$][\w$]*=\{)"general-settings":([A-Za-z_$][\w$]*),/; - if (!iconPattern.test(patchedSource)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds icon"); - } - patchedSource = patchedSource.replace( - iconPattern, - (_match, prefix, icon) => `${prefix}keybinds:${icon},"general-settings":${icon},`, - ); - } - - if (!/=\[`general-settings`,`keybinds`/.test(patchedSource)) { - const orderPattern = /([A-Za-z_$][\w$]*=\[`general-settings`,)`appearance`/; - if (!orderPattern.test(patchedSource)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds nav order"); - } - patchedSource = patchedSource.replace(orderPattern, "$1`keybinds`,`appearance`"); - } - - if (!patchedSource.includes("slugs:[`general-settings`,`keybinds`")) { - const groupNeedle = "slugs:[`general-settings`,`appearance`,`connections`,`git-settings`,`usage`]"; - const groupPatch = "slugs:[`general-settings`,`keybinds`,`appearance`,`connections`,`git-settings`,`usage`]"; - if (!patchedSource.includes(groupNeedle)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds nav group"); - } - patchedSource = patchedSource.replace(groupNeedle, groupPatch); - } - - if (!patchedSource.includes("case`keybinds`:return l===`electron`")) { - const visibilityNeedle = - "case`appearance`:case`git-settings`:case`worktrees`:case`local-environments`:case`data-controls`:case`environments`:return l===`electron`;"; - const visibilityPatch = - "case`keybinds`:return l===`electron`;case`appearance`:case`git-settings`:case`worktrees`:case`local-environments`:case`data-controls`:case`environments`:return l===`electron`;"; - if (!patchedSource.includes(visibilityNeedle)) { - throw new Error("Required Keybinds settings patch failed: could not add keybinds visibility"); - } - patchedSource = patchedSource.replace(visibilityNeedle, visibilityPatch); - } - - if (!patchedSource.includes("case`keybinds`:k=!1;break bb0;")) { - const redirectNeedle = - "case`appearance`:case`general-settings`:case`agent`:case`git-settings`:case`account`:case`data-controls`:case`personalization`:k=!1;break bb0;"; - const redirectPatch = - "case`keybinds`:k=!1;break bb0;case`appearance`:case`general-settings`:case`agent`:case`git-settings`:case`account`:case`data-controls`:case`personalization`:k=!1;break bb0;"; - if (patchedSource.includes(redirectNeedle)) { - patchedSource = patchedSource.replace(redirectNeedle, redirectPatch); - } - } - - return applyLinuxKeybindOverridesRuntimePatch(patchedSource); -} - -function applyLinuxSettingsPersistencePatch(currentSource) { - let patchedSource = currentSource; - - if ( - !patchedSource.includes('"set-global-state"') && - !patchedSource.includes("var Yb=`.codex-global-state.json`;") - ) { - return patchedSource; - } - - if (!patchedSource.includes("function codexLinuxPersistSettingsState(")) { - const stateFileNeedle = "var Yb=`.codex-global-state.json`;"; - const stateFilePatch = - `var Yb=\`.codex-global-state.json\`;function codexLinuxSettingsPath(){let e=process.env.XDG_CONFIG_HOME||process.env.HOME&&i.join(process.env.HOME,\`.config\`);return e?i.join(e,\`codex-app\`,\`settings.json\`):null}function codexLinuxReadSettingsFile(){let e=codexLinuxSettingsPath();if(!e||!o.existsSync(e))return{};try{let t=o.readFileSync(e,\`utf8\`),n=JSON.parse(t);return n&&typeof n===\`object\`&&!Array.isArray(n)?n:{}}catch(e){return{}}}function codexLinuxPersistSettingsState(e,t){if(process.platform!==\`linux\`||![${Object.values(linuxSettingsKeys).map((key) => `\`${key}\``).join(",")}].includes(e))return;try{let n=codexLinuxSettingsPath();if(!n)return;let r=codexLinuxReadSettingsFile();t===void 0?delete r[e]:r[e]=t,o.mkdirSync(i.dirname(n),{recursive:!0,mode:448}),o.writeFileSync(n,JSON.stringify(r,null,2)+\`\\n\`,\`utf8\`)}catch(e){}}`; - if (!patchedSource.includes(stateFileNeedle)) { - console.warn("WARN: Could not find Linux settings state file marker — skipping settings persistence patch"); - return patchedSource; - } - patchedSource = patchedSource.replace(stateFileNeedle, stateFilePatch); - } - - const setGlobalStateNeedle = - '"set-global-state":async({key:t,value:n,origin:r})=>(this.globalState.set(t,n),t===e.Tt.REMOTE_PROJECTS&&r.send(H,{type:`workspace-root-options-updated`}),{success:!0})'; - const setGlobalStatePatch = - '"set-global-state":async({key:t,value:n,origin:r})=>(this.globalState.set(t,n),codexLinuxPersistSettingsState(t,n),t===e.Tt.REMOTE_PROJECTS&&r.send(H,{type:`workspace-root-options-updated`}),{success:!0})'; - if (patchedSource.includes(setGlobalStatePatch)) { - return patchedSource; - } - if (!patchedSource.includes(setGlobalStateNeedle)) { - console.warn("WARN: Could not find Linux set-global-state needle — skipping settings persistence hook"); - return patchedSource; - } - - return patchedSource.replace(setGlobalStateNeedle, setGlobalStatePatch); -} - -function applyLinuxOpaqueWindowsDefaultPatch(currentSource) { - let patchedSource = currentSource; - - const mergeNeedle = "opaqueWindows:e?.opaqueWindows??n.opaqueWindows,semanticColors:"; - const mergePatch = - "opaqueWindows:e?.opaqueWindows??(typeof navigator<`u`&&((navigator.userAgentData?.platform??navigator.platform??navigator.userAgent).toLowerCase().includes(`linux`))?!0:n.opaqueWindows),semanticColors:"; - - if (patchedSource.includes("opaqueWindows:e?.opaqueWindows??(typeof navigator<`u`&&")) { - // Already patched. - } else if (patchedSource.includes(mergeNeedle)) { - patchedSource = patchedSource.replace(mergeNeedle, mergePatch); - } else if (patchedSource.includes("opaqueWindows") && patchedSource.includes("semanticColors")) { - console.warn( - "WARN: Could not find Linux opaque window default insertion point — skipping settings default patch", - ); - } - - const settingsNeedle = - "let d=ot(r,e),f=at(e),p={codeThemeId:tt(a,e).id,theme:d},"; - const settingsPatch = - "let d=ot(r,e);navigator.userAgent.includes(`Linux`)&&r?.opaqueWindows==null&&(d={...d,opaqueWindows:!0});let f=at(e),p={codeThemeId:tt(a,e).id,theme:d},"; - if (patchedSource.includes("navigator.userAgent.includes(`Linux`)&&r?.opaqueWindows==null")) { - // Already patched. - } else if (patchedSource.includes(settingsNeedle)) { - patchedSource = patchedSource.replace(settingsNeedle, settingsPatch); - } - - const currentSettingsNeedle = "setThemePatch:b,theme:x}=ne(t),S=$t(i,t),"; - const currentSettingsPatch = - "setThemePatch:b,theme:x}=ne(t);navigator.userAgent.includes(`Linux`)&&x?.opaqueWindows==null&&(x={...x,opaqueWindows:!0});let S=$t(i,t),"; - if (patchedSource.includes("navigator.userAgent.includes(`Linux`)&&x?.opaqueWindows==null")) { - // Already patched. - } else if (patchedSource.includes(currentSettingsNeedle)) { - patchedSource = patchedSource.replace(currentSettingsNeedle, currentSettingsPatch); - } - - const runtimeNeedle = - "let T=o===`light`?C:w,E;if(T.opaqueWindows&&!XZ()){"; - const runtimePatch = - "let T=o===`light`?C:w,E;document.documentElement.dataset.codexOs===`linux`&&((o===`light`?l:f)?.opaqueWindows==null&&(T={...T,opaqueWindows:!0}));if(T.opaqueWindows&&!XZ()){"; - if (patchedSource.includes("document.documentElement.dataset.codexOs===`linux`&&((o===`light`?l:f)?.opaqueWindows==null")) { - // Already patched. - } else if (patchedSource.includes(runtimeNeedle)) { - patchedSource = patchedSource.replace(runtimeNeedle, runtimePatch); - } - - const currentRuntimeNeedle = "let T=s===`light`?S:w,E;"; - const currentRuntimePatch = - "let T=s===`light`?S:w,E;document.documentElement.dataset.codexOs===`linux`&&((s===`light`?u:p)?.opaqueWindows==null&&(T={...T,opaqueWindows:!0}));"; - if (patchedSource.includes("document.documentElement.dataset.codexOs===`linux`&&((s===`light`?u:p)?.opaqueWindows==null")) { - // Already patched. - } else if (patchedSource.includes(currentRuntimeNeedle)) { - patchedSource = patchedSource.replace(currentRuntimeNeedle, currentRuntimePatch); - } - - return patchedSource; -} - -function requireName(source, moduleName) { - const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = source.match(new RegExp(`([A-Za-z_$][\\w$]*)=require\\(\`${escaped}\`\\)`)); - return match?.[1] ?? null; -} - -function inferModuleAlias(source, moduleName) { - const requiredName = requireName(source, moduleName); - if (requiredName != null) { - return requiredName; - } - - if (moduleName === "electron") { - return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{app:\{/u)?.[1] ?? null; - } - if (moduleName === "node:path") { - return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{default:\{dirname\(/u)?.[1] ?? null; - } - if (moduleName === "node:fs") { - return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{mkdirSync\(/u)?.[1] ?? null; - } - if (moduleName === "node:net") { - return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{default:\{createServer\(/u)?.[1] ?? null; - } - - return null; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function findCallBlock(source, marker) { - const markerStart = source.indexOf(marker); - if (markerStart === -1) { - return null; - } - - const blockStart = Math.max( - source.lastIndexOf("var ", markerStart), - source.lastIndexOf("let ", markerStart), - source.lastIndexOf("const ", markerStart), - ); - const blockEnd = source.indexOf("});", markerStart); - if (blockStart === -1 || blockEnd === -1) { - return null; - } - - return { - start: blockStart, - end: blockEnd + "});".length, - text: source.slice(blockStart, blockEnd + "});".length), - }; -} - -function applyLinuxFileManagerPatch(currentSource) { - const block = findCallBlock(currentSource, "id:`fileManager`"); - if (block == null) { - console.error("Failed to apply Linux File Manager Patch"); - return currentSource; - } - - if (block.text.includes("linux:{")) { - return currentSource; - } - - const electronVar = requireName(currentSource, "electron"); - const fsVar = requireName(currentSource, "node:fs"); - const pathVar = requireName(currentSource, "node:path"); - if (electronVar == null || fsVar == null || pathVar == null) { - console.error("Failed to apply Linux File Manager Patch"); - return currentSource; - } - - const insertionPoint = block.text.lastIndexOf("}});"); - if (insertionPoint === -1) { - console.error("Failed to apply Linux File Manager Patch"); - return currentSource; - } - - const linuxFileManager = - `,linux:{label:\`File Manager\`,icon:\`apps/file-explorer.png\`,detect:()=>\`linux-file-manager\`,args:e=>[e],open:async({path:e})=>{let __codexResolved=e;for(;;){if((0,${fsVar}.existsSync)(__codexResolved))break;let __codexParent=(0,${pathVar}.dirname)(__codexResolved);if(__codexParent===__codexResolved){__codexResolved=null;break}__codexResolved=__codexParent}let __codexOpenTarget=__codexResolved??e;if((0,${fsVar}.existsSync)(__codexOpenTarget)&&(0,${fsVar}.statSync)(__codexOpenTarget).isFile())__codexOpenTarget=(0,${pathVar}.dirname)(__codexOpenTarget);let __codexError=await ${electronVar}.shell.openPath(__codexOpenTarget);if(__codexError)throw Error(__codexError)}}`; - - const patchedBlock = - block.text.slice(0, insertionPoint + 1) + - linuxFileManager + - block.text.slice(insertionPoint + 1); - const patchedSource = - currentSource.slice(0, block.start) + patchedBlock + currentSource.slice(block.end); - - const patchedBlockCheck = patchedSource.slice(block.start, block.start + patchedBlock.length); - if ( - !patchedBlockCheck.includes("linux:{label:`File Manager`") || - !patchedBlockCheck.includes("detect:()=>`linux-file-manager`") || - !patchedBlockCheck.includes(`${electronVar}.shell.openPath(__codexOpenTarget)`) - ) { - console.error("Failed to apply Linux File Manager Patch"); - return currentSource; - } - - return patchedSource; -} - -function applyLinuxWindowOptionsPatch(currentSource, iconAsset) { - if (iconAsset == null) { - return currentSource; - } - - const windowOptionsNeedle = "...process.platform===`win32`?{autoHideMenuBar:!0}:{},"; - const iconPathExpression = `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``; - const iconPathNeedle = `icon:${iconPathExpression}`; - const windowOptionsReplacement = - `...process.platform===\`win32\`||process.platform===\`linux\`?{autoHideMenuBar:!0,...process.platform===\`linux\`?{${iconPathNeedle}}:{}}:{},`; - - if (currentSource.includes(iconPathNeedle)) { - return currentSource; - } - - if (currentSource.includes(windowOptionsNeedle)) { - return currentSource.replace(windowOptionsNeedle, windowOptionsReplacement); - } - - console.warn("WARN: Could not find BrowserWindow autoHideMenuBar snippet — skipping window options patch"); - return currentSource; -} - -function applyLinuxMenuPatch(currentSource) { - const menuRegex = /process\.platform===`win32`&&([A-Za-z_$][\w$]*)\.removeMenu\(\),/g; - let patchedAny = false; - const patchedSource = currentSource.replace(menuRegex, (match, windowVar) => { - const linuxPatch = `process.platform===\`linux\`&&${windowVar}.setMenuBarVisibility(!1),`; - if (currentSource.includes(`${linuxPatch}${match}`)) { - return match; - } - patchedAny = true; - return `${linuxPatch}${match}`; - }); - - if (!patchedAny && menuRegex.test(currentSource) && !currentSource.includes("setMenuBarVisibility(!1),process.platform===`win32`")) { - console.warn("WARN: Could not find window menu visibility snippet — skipping menu patch"); - } - - return patchedSource; -} - -function applyLinuxSetIconPatch(currentSource, iconAsset) { - if (iconAsset == null) { - return currentSource; - } - - const iconPathExpression = `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``; - if (currentSource.includes(`setIcon(${iconPathExpression})`)) { - return currentSource; - } - - const readyRegex = /([A-Za-z_$][\w$]*)\.once\(`ready-to-show`,\(\)=>\{/; - const match = currentSource.match(readyRegex); - if (match == null) { - console.warn("WARN: Could not find window setIcon insertion point — skipping setIcon patch"); - return currentSource; - } - - const windowVar = match[1]; - return currentSource.replace( - readyRegex, - `process.platform===\`linux\`&&${windowVar}.setIcon(${iconPathExpression}),${match[0]}`, - ); -} - -function applyLinuxOpaqueBackgroundPatch(currentSource) { - const colorConstRegex = - /([A-Za-z_$][\w$]*)=`#00000000`,([A-Za-z_$][\w$]*)=`#000000`,([A-Za-z_$][\w$]*)=`#f9f9f9`/; - const colorMatch = currentSource.match(colorConstRegex); - - if (!colorMatch) { - console.warn( - "WARN: Could not find color constants (#00000000, #000000, #f9f9f9) — skipping background patch", - ); - return currentSource; - } - - const [, transparentVar, darkVar, lightVar] = colorMatch; - const funcParamRegex = - /\{platform:([A-Za-z_$][\w$]*),appearance:([A-Za-z_$][\w$]*),opaqueWindowsEnabled:[A-Za-z_$][\w$]*,prefersDarkColors:([A-Za-z_$][\w$]*)\}\)\{return\s*([A-Za-z_$][\w$]*)===`win32`&&!([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)/; - const funcMatch = currentSource.match(funcParamRegex); - - if (funcMatch == null) { - console.warn( - "WARN: Could not find BrowserWindow appearance guard — skipping background patch", - ); - return currentSource; - } - - const [ - , - platformParam, - appearanceParam, - darkColorsParam, - win32PlatformParam, - appearanceGuardFn, - appearanceGuardArg, - ] = funcMatch; - if (platformParam !== win32PlatformParam || appearanceParam !== appearanceGuardArg) { - console.warn( - "WARN: BrowserWindow appearance guard shape was inconsistent — skipping background patch", - ); - return currentSource; - } - const bgNeedle = - `backgroundMaterial:\`mica\`}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`; - const linuxBgGuard = `${platformParam}===\`linux\`&&!${appearanceGuardFn}(${appearanceParam})`; - const bgReplacement = - `backgroundMaterial:\`mica\`}:${linuxBgGuard}?{backgroundColor:${darkColorsParam}?${darkVar}:${lightVar},backgroundMaterial:null}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`; - const previousLinuxBgPatches = [ - `backgroundMaterial:\`mica\`}:process.platform===\`linux\`?{backgroundColor:${darkColorsParam}?${darkVar}:${lightVar},backgroundMaterial:null}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`, - `backgroundMaterial:\`mica\`}:process.platform===\`linux\`&&!gw(${appearanceParam})?{backgroundColor:${darkColorsParam}?${darkVar}:${lightVar},backgroundMaterial:null}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`, - ]; - - if (currentSource.includes(bgReplacement)) { - return currentSource; - } - - if (currentSource.includes(bgNeedle)) { - return currentSource.replace(bgNeedle, bgReplacement); - } - for (const previousPatch of previousLinuxBgPatches) { - if (currentSource.includes(previousPatch)) { - return currentSource.replace(previousPatch, bgReplacement); - } - } - - console.warn("WARN: Could not find BrowserWindow background color needle — skipping background patch"); - return currentSource; -} - -function findNamedFunctionBody(source, functionName) { - const functionMatch = source.match( - new RegExp(`(?:async\\s+)?function\\s+${escapeRegExp(functionName)}\\([^)]*\\)\\{`), - ); - if (functionMatch == null) { - return null; - } - - const openIndex = functionMatch.index + functionMatch[0].length - 1; - const closeIndex = findMatchingBrace(source, openIndex); - return closeIndex === -1 ? null : source.slice(openIndex, closeIndex + 1); -} - -function isTrayFactoryFunction(source, functionName) { - const body = findNamedFunctionBody(source, functionName); - return body != null && /new [A-Za-z_$][\w$]*\.Tray\(/.test(body); -} - -function findDynamicTraySetup(source) { - const setupRegex = - /let ([A-Za-z_$][\w$]*)=async\(\)=>\{[A-Za-z_$][\w$]*=!0;try\{await ([A-Za-z_$][\w$]*)\(\{buildFlavor:/g; - let match; - while ((match = setupRegex.exec(source)) != null) { - const [, setupFn, factoryFn] = match; - if (isTrayFactoryFunction(source, factoryFn)) { - return { setupFn, index: match.index }; - } - } - return null; -} - -function findDynamicTrayStartupCall(source, setupFn, startIndex) { - const startupRegex = new RegExp(`([A-Za-z_$][\\w$]*)&&${escapeRegExp(setupFn)}\\(\\);`, "g"); - startupRegex.lastIndex = startIndex; - return startupRegex.exec(source); -} - -function isLikelyModuleScopeOffset(source, offset) { - if (offset < 0 || offset > source.length) { - return false; - } - - let depth = 0; - let quote = null; - let escaped = false; - for (let index = 0; index < offset; index += 1) { - const char = source[index]; - if (quote != null) { - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === quote) { - quote = null; - } - continue; - } - - if (char === "\"" || char === "'" || char === "`") { - quote = char; - } else if (char === "{") { - depth += 1; - } else if (char === "}") { - depth = Math.max(0, depth - 1); - } - } - - return depth === 0; -} - -function hasNearbyModuleScopeQuitGuard(source, electronRequireIndex) { - const quitGuardNeedle = "let codexLinuxQuitInProgress=!1"; - let searchIndex = 0; - while (true) { - const quitGuardIndex = source.indexOf(quitGuardNeedle, searchIndex); - if (quitGuardIndex === -1) { - return false; - } - - if ( - isLikelyModuleScopeOffset(source, quitGuardIndex) && - (quitGuardIndex < electronRequireIndex || quitGuardIndex - electronRequireIndex < 1000) - ) { - return true; - } - - searchIndex = quitGuardIndex + quitGuardNeedle.length; - } -} - -function applyLinuxQuitGuardPatch(currentSource) { - let patchedSource = currentSource; - - const quitGuardNeedle = "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`);"; - const quitGuardPatch = - "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`);let codexLinuxQuitInProgress=!1,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0;"; - const quitGuardSuffix = - "let codexLinuxQuitInProgress=!1,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0;"; - - const electronRequireIndex = patchedSource.indexOf("require(`electron`)"); - if (electronRequireIndex !== -1 && hasNearbyModuleScopeQuitGuard(patchedSource, electronRequireIndex)) { - return patchedSource; - } - - if (patchedSource.includes(quitGuardNeedle)) { - return patchedSource.replace(quitGuardNeedle, quitGuardPatch); - } - - const splitQuitGuardNeedle = - /let ([A-Za-z_$][\w$]*)=require\(`electron`\);(?:\1=[^;]+;)?let ([A-Za-z_$][\w$]*)=require\(`node:path`\);(?:\2=[^;]+;)?let ([A-Za-z_$][\w$]*)=require\(`node:fs`\);(?:\3=[^;]+;)?/; - const splitQuitGuardMatch = patchedSource.match(splitQuitGuardNeedle); - if (splitQuitGuardMatch != null) { - const matchedPrefix = splitQuitGuardMatch[0]; - return patchedSource.replace(matchedPrefix, `${matchedPrefix}${quitGuardSuffix}`); - } - - const electronRequireNeedle = - /let ([A-Za-z_$][\w$]*)=require\(`electron`\);(?:\1=[^;]+;)?/; - const electronRequireMatch = patchedSource.match(electronRequireNeedle); - if (electronRequireMatch != null) { - const matchedPrefix = electronRequireMatch[0]; - return patchedSource.replace(matchedPrefix, `${matchedPrefix}${quitGuardSuffix}`); - } - - if (patchedSource.includes("require(`electron`)")) { - return `${quitGuardSuffix}${patchedSource}`; - } - - return patchedSource; -} - -function applyLinuxTrayPatch(currentSource, iconPathExpression) { - let patchedSource = currentSource; - - const trayGuardNeedle = - "process.platform!==`win32`&&process.platform!==`darwin`?null:"; - const trayGuardPatch = - "process.platform!==`win32`&&process.platform!==`darwin`&&process.platform!==`linux`?null:"; - const trayGuardIndex = patchedSource.indexOf(trayGuardNeedle); - if (patchedSource.includes(trayGuardPatch)) { - // Already patched. - } else if ( - trayGuardIndex !== -1 && - patchedSource.slice(trayGuardIndex, trayGuardIndex + TRAY_GUARD_LOOKAHEAD).includes("new n.Tray") - ) { - patchedSource = patchedSource.replace(trayGuardNeedle, trayGuardPatch); - } else { - console.warn("WARN: Could not find tray platform guard — skipping Linux tray guard patch"); - } - - if (iconPathExpression != null) { - const trayIconNeedle = - "for(let e of o){let t=n.nativeImage.createFromPath(e);if(!t.isEmpty())return{defaultIcon:t,chronicleRunningIcon:null}}return{defaultIcon:await n.app.getFileIcon(process.execPath,{size:process.platform===`win32`?`small`:`normal`}),chronicleRunningIcon:null}}"; - const trayIconPatch = - `for(let e of o){let t=n.nativeImage.createFromPath(e);if(!t.isEmpty())return{defaultIcon:t,chronicleRunningIcon:null}}if(process.platform===\`linux\`){let e=n.nativeImage.createFromPath(${iconPathExpression});if(!e.isEmpty())return{defaultIcon:e,chronicleRunningIcon:null}}return{defaultIcon:await n.app.getFileIcon(process.execPath,{size:process.platform===\`win32\`?\`small\`:\`normal\`}),chronicleRunningIcon:null}}`; - if (patchedSource.includes(`nativeImage.createFromPath(${iconPathExpression})`)) { - // Already patched. - } else if (patchedSource.includes(trayIconNeedle)) { - patchedSource = patchedSource.replace(trayIconNeedle, trayIconPatch); - } else { - console.warn("WARN: Could not find tray icon fallback — skipping Linux tray icon patch"); - } - } - - const closeToTrayNeedle = - "if(process.platform===`win32`&&f===`local`&&!this.isAppQuitting&&this.options.canHideLastLocalWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}"; - const closeToTrayExistingPatch = - "if((process.platform===`win32`||process.platform===`linux`)&&f===`local`&&!this.isAppQuitting&&this.options.canHideLastLocalWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}"; - const closeToTrayPatch = - "if((process.platform===`win32`||process.platform===`linux`)&&f===`local`&&!this.isAppQuitting&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.options.canHideLastLocalWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}"; - const patchedCloseToTrayRegex = - /if\(\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&[A-Za-z_$][\w$]*===`local`&&!this\.isAppQuitting&&!\(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress\(\)\)&&this\.options\.canHideLastLocalWindowToTray\?\.\(\)===!0&&![A-Za-z_$][\w$]*\)\{[A-Za-z_$][\w$]*\.preventDefault\(\),[A-Za-z_$][\w$]*\.hide\(\);return\}/; - if (patchedSource.includes(closeToTrayPatch)) { - // Already patched. - } else if (patchedSource.includes(closeToTrayExistingPatch)) { - patchedSource = patchedSource.replace(closeToTrayExistingPatch, closeToTrayPatch); - } else if (patchedSource.includes(closeToTrayNeedle)) { - patchedSource = patchedSource.replace(closeToTrayNeedle, closeToTrayPatch); - } else if (patchedCloseToTrayRegex.test(patchedSource)) { - // Already patched with a newer minifier's window variable. - } else { - const closeToTrayRegex = - /if\(process\.platform===`win32`&&([A-Za-z_$][\w$]*)===`local`&&!this\.isAppQuitting&&this\.options\.canHideLastLocalWindowToTray\?\.\(\)===!0&&!([A-Za-z_$][\w$]*)\)\{([A-Za-z_$][\w$]*)\.preventDefault\(\),([A-Za-z_$][\w$]*)\.hide\(\);return\}/; - const closeToTrayMatch = patchedSource.match(closeToTrayRegex); - if (closeToTrayMatch != null) { - const [, hostVar, hasOtherWindowVar, eventVar, windowVar] = closeToTrayMatch; - patchedSource = patchedSource.replace( - closeToTrayRegex, - `if((process.platform===\`win32\`||process.platform===\`linux\`)&&${hostVar}===\`local\`&&!this.isAppQuitting&&!(typeof codexLinuxIsQuitInProgress===\`function\`&&codexLinuxIsQuitInProgress())&&this.options.canHideLastLocalWindowToTray?.()===!0&&!${hasOtherWindowVar}){${eventVar}.preventDefault(),${windowVar}.hide();return}`, - ); - } else { - console.warn("WARN: Could not find close-to-tray condition — skipping Linux close-to-tray patch"); - } - } - - const trayContextMethodNeedle = - "trayMenuThreads={runningThreads:[],unreadThreads:[],pinnedThreads:[],recentThreads:[],usageLimits:[]};constructor("; - const trayContextMethodPatch = - "trayMenuThreads={runningThreads:[],unreadThreads:[],pinnedThreads:[],recentThreads:[],usageLimits:[]};setLinuxTrayContextMenu(){let e=n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());this.tray.setContextMenu?.(e);return e}constructor("; - if (patchedSource.includes("setLinuxTrayContextMenu(){")) { - // Already patched. - } else if (patchedSource.includes(trayContextMethodNeedle)) { - patchedSource = patchedSource.replace(trayContextMethodNeedle, trayContextMethodPatch); - } else { - console.warn("WARN: Could not find tray controller fields — skipping Linux tray context menu method patch"); - } - - const trayClickNeedle = - "this.tray.on(`click`,()=>{this.onTrayButtonClick()}),this.tray.on(`right-click`,()=>{this.openNativeTrayMenu()})}"; - const trayClickPatchWithoutContextSetup = - "this.tray.on(`click`,()=>{process.platform===`linux`?this.openNativeTrayMenu():this.onTrayButtonClick()}),this.tray.on(`right-click`,()=>{this.openNativeTrayMenu()})}"; - const trayClickPatch = - "process.platform===`linux`&&this.setLinuxTrayContextMenu(),this.tray.on(`click`,()=>{process.platform===`linux`?this.openNativeTrayMenu():this.onTrayButtonClick()}),this.tray.on(`right-click`,()=>{this.openNativeTrayMenu()})}"; - const canSetLinuxTrayContextMenu = patchedSource.includes("setLinuxTrayContextMenu(){"); - if (patchedSource.includes("process.platform===`linux`&&this.setLinuxTrayContextMenu(),this.tray.on(`click`")) { - // Already patched. - } else if (patchedSource.includes(trayClickNeedle)) { - patchedSource = patchedSource.replace( - trayClickNeedle, - canSetLinuxTrayContextMenu ? trayClickPatch : trayClickPatchWithoutContextSetup, - ); - } else if (canSetLinuxTrayContextMenu && patchedSource.includes(trayClickPatchWithoutContextSetup)) { - patchedSource = patchedSource.replace(trayClickPatchWithoutContextSetup, trayClickPatch); - } else { - console.warn("WARN: Could not find tray click handler — skipping Linux tray menu click patch"); - } - - const trayMenuBuildNeedle = - "openNativeTrayMenu(){this.updateChronicleTrayIcon();let e=n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());"; - const trayMenuBuildExistingPatch = - "openNativeTrayMenu(){this.updateChronicleTrayIcon();let e=process.platform===`linux`&&this.setLinuxTrayContextMenu?this.setLinuxTrayContextMenu():n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());"; - const trayMenuBuildPatch = - "openNativeTrayMenu(){if(process.platform===`linux`&&(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress()))return;this.updateChronicleTrayIcon();let e=process.platform===`linux`&&this.setLinuxTrayContextMenu?this.setLinuxTrayContextMenu():n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());"; - if (patchedSource.includes("openNativeTrayMenu(){if(process.platform===`linux`&&(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress()))return;")) { - // Already patched. - } else if (patchedSource.includes(trayMenuBuildExistingPatch)) { - patchedSource = patchedSource.replace(trayMenuBuildExistingPatch, trayMenuBuildPatch); - } else if (patchedSource.includes(trayMenuBuildNeedle)) { - patchedSource = patchedSource.replace(trayMenuBuildNeedle, trayMenuBuildPatch); - } else { - console.warn("WARN: Could not find tray native menu builder — skipping Linux tray context menu builder patch"); - } - - const trayContextMenuNeedle = - "e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),this.tray.popUpContextMenu(e)}"; - const trayContextMenuPatch = - "if(process.platform===`linux`)return;e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),this.tray.popUpContextMenu(e)}"; - const oldLinuxPopupPatch = - "e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),process.platform===`linux`&&this.tray.setContextMenu?.(e),this.tray.popUpContextMenu(e)}"; - const badLinuxPopupPatch = - "e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),if(process.platform===`linux`)return;e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),this.tray.popUpContextMenu(e)}"; - if (patchedSource.includes("if(process.platform===`linux`)return;e.once(`menu-will-show`")) { - // Already patched. - } else if (patchedSource.includes(badLinuxPopupPatch)) { - patchedSource = patchedSource.replace(badLinuxPopupPatch, trayContextMenuPatch); - } else if (patchedSource.includes(oldLinuxPopupPatch)) { - patchedSource = patchedSource.replace(oldLinuxPopupPatch, trayContextMenuPatch); - } else if (patchedSource.includes(trayContextMenuNeedle)) { - patchedSource = patchedSource.replace(trayContextMenuNeedle, trayContextMenuPatch); - } else { - console.warn("WARN: Could not find tray native menu popup — skipping Linux tray popup guard patch"); - } - - const trayMenuThreadsNeedle = - "case`tray-menu-threads-changed`:this.trayMenuThreads=e.trayMenuThreads;return"; - const trayMenuThreadsExistingPatch = - "case`tray-menu-threads-changed`:this.trayMenuThreads=e.trayMenuThreads,process.platform===`linux`&&this.setLinuxTrayContextMenu?.();return"; - const trayMenuThreadsPatch = - "case`tray-menu-threads-changed`:this.trayMenuThreads=e.trayMenuThreads,process.platform===`linux`&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.setLinuxTrayContextMenu?.();return"; - if (patchedSource.includes("this.trayMenuThreads=e.trayMenuThreads,process.platform===`linux`&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.setLinuxTrayContextMenu?.()")) { - // Already patched. - } else if (patchedSource.includes(trayMenuThreadsExistingPatch)) { - patchedSource = patchedSource.replace(trayMenuThreadsExistingPatch, trayMenuThreadsPatch); - } else if (patchedSource.includes(trayMenuThreadsNeedle)) { - patchedSource = patchedSource.replace(trayMenuThreadsNeedle, trayMenuThreadsPatch); - } else { - console.warn("WARN: Could not find tray menu thread update handler — skipping Linux tray context refresh patch"); - } - - const trayStartupNeedle = "E&&oe();"; - const previousTrayStartupPatch = "(E||process.platform===`linux`)&&oe();"; - const linuxTrayEnabledPredicate = - "process.platform===`linux`&&(typeof codexLinuxIsTrayEnabled!==`function`||codexLinuxIsTrayEnabled())"; - const trayStartupPatch = - `(E||${linuxTrayEnabledPredicate})&&oe();`; - if (patchedSource.includes(trayStartupPatch)) { - // Already patched. - } else if (patchedSource.includes(previousTrayStartupPatch)) { - patchedSource = patchedSource.replace(previousTrayStartupPatch, trayStartupPatch); - } else if (patchedSource.includes(trayStartupNeedle)) { - patchedSource = patchedSource.replace(trayStartupNeedle, trayStartupPatch); - } else { - const traySetup = findDynamicTraySetup(patchedSource); - const dynamicTrayStartupMatch = traySetup == null - ? null - : findDynamicTrayStartupCall(patchedSource, traySetup.setupFn, traySetup.index); - if ( - traySetup != null && - patchedSource.includes(`${linuxTrayEnabledPredicate})&&${traySetup.setupFn}();`) - ) { - // Already patched with a newer minifier's tray setup identifier. - } else if (dynamicTrayStartupMatch != null) { - const isWindowsVar = dynamicTrayStartupMatch[1]; - patchedSource = `${patchedSource.slice(0, dynamicTrayStartupMatch.index)}(${isWindowsVar}||${linuxTrayEnabledPredicate})&&${traySetup.setupFn}();${patchedSource.slice(dynamicTrayStartupMatch.index + dynamicTrayStartupMatch[0].length)}`; - } else { - console.warn("WARN: Could not find tray startup call — skipping Linux tray startup patch"); - } - } - - return patchedSource; -} - -function applyLinuxSingleInstancePatch(currentSource) { - let patchedSource = currentSource; - - const singleInstanceLockNeedle = - "agentRunId:process.env.CODEX_ELECTRON_AGENT_RUN_ID?.trim()||null}});let A=Date.now();await n.app.whenReady()"; - const singleInstanceLockPatch = - "agentRunId:process.env.CODEX_ELECTRON_AGENT_RUN_ID?.trim()||null}});if(process.platform===`linux`&&!n.app.requestSingleInstanceLock()){n.app.quit();return}let A=Date.now();await n.app.whenReady()"; - if (patchedSource.includes("process.platform===`linux`&&!n.app.requestSingleInstanceLock()")) { - // Already patched. - } else if (patchedSource.includes(singleInstanceLockNeedle)) { - patchedSource = patchedSource.replace(singleInstanceLockNeedle, singleInstanceLockPatch); - } else if (patchedSource.includes("setSecondInstanceArgsHandler")) { - // Newer bundles take the single-instance lock in bootstrap.js and hand args into main here. - } else { - console.warn("WARN: Could not find startup handoff point — skipping Linux single-instance lock patch"); - } - - const secondInstanceHandlerNeedle = - "l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae="; - const secondInstanceHandlerExistingPatch = - "let codexLinuxSecondInstanceHandler=(e,t)=>{R.deepLinks.queueProcessArgs(t)||ie()};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae="; - const secondInstanceHandlerPatch = - "let codexLinuxSecondInstanceHandler=(e,t)=>{(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(t)||ie()},codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()};process.platform===`linux`&&(n.app.on(`before-quit`,codexLinuxBeforeQuitHandler),k.add(()=>{n.app.off(`before-quit`,codexLinuxBeforeQuitHandler)}),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(e)||ie()});let ae="; - if ( - patchedSource.includes("codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()}") && - patchedSource.includes("(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(t)||ie()") && - patchedSource.includes("l(e=>{(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(e)||ie()})") - ) { - // Already patched. - } else if (patchedSource.includes(secondInstanceHandlerExistingPatch)) { - patchedSource = patchedSource.replace(secondInstanceHandlerExistingPatch, secondInstanceHandlerPatch); - } else if (patchedSource.includes(secondInstanceHandlerNeedle)) { - patchedSource = patchedSource.replace(secondInstanceHandlerNeedle, secondInstanceHandlerPatch); - } else if (patchedSource.includes("setSecondInstanceArgsHandler")) { - // bootstrap.js owns the Electron second-instance event and calls this bundle's handler. - } else { - console.warn("WARN: Could not find second-instance handler — skipping Linux second-instance focus patch"); - } - - return patchedSource; -} - -function parseDestructuredParamAliases(paramsText) { - const aliases = Object.create(null); - for (const rawPart of paramsText.split(",")) { - const part = rawPart.trim(); - const match = part.match(/^([A-Za-z_$][\w$]*)(?::([A-Za-z_$][\w$]*))?$/); - if (match != null) { - aliases[match[1]] = match[2] ?? match[1]; - } - } - return aliases; -} - -function buildComputerUseGate({ nameExpr, featuresVar, platformVar, migrateVar }) { - return `{installWhenMissing:!0,name:${nameExpr},isEnabled:({features:${featuresVar},platform:${platformVar}})=>(${platformVar}===\`darwin\`||${platformVar}===\`linux\`)&&${featuresVar}.computerUse,migrate:${migrateVar}}`; -} - -function hasComputerUseLiteral(source) { - return /(?:`computer-use`|"computer-use"|'computer-use')/.test(source); -} - -function isComputerUseNameExpr(nameExpr, computerUseNameVar) { - return /^(?:`computer-use`|"computer-use"|'computer-use')$/.test(nameExpr) || nameExpr === computerUseNameVar; -} - -function applyLinuxComputerUsePluginGatePatch(currentSource) { - if (!hasComputerUseLiteral(currentSource)) { - return currentSource; - } - - const computerUseNameVar = currentSource.match(/([A-Za-z_$][\w$]*)=(?:`computer-use`|"computer-use"|'computer-use')/)?.[1] ?? null; - const gateRegex = - /\{(installWhenMissing:!0,)?name:([A-Za-z_$][\w$]*|`computer-use`|"computer-use"|'computer-use'),isEnabled:\(\{([^}]*)\}\)=>([^{}]*?\.computerUse),migrate:([A-Za-z_$][\w$]*)\}/g; - let sawEnabledGate = false; - let sawUnpatchableGate = false; - let match; - while ((match = gateRegex.exec(currentSource)) != null) { - const [gateSource, installWhenMissing, nameExpr, paramsText, expression, migrateVar] = match; - if (!isComputerUseNameExpr(nameExpr, computerUseNameVar)) { - continue; - } - - const aliases = parseDestructuredParamAliases(paramsText); - const featuresVar = aliases.features; - const platformVar = aliases.platform; - if (featuresVar == null || platformVar == null) { - continue; - } - - const darwinOnlyExpression = `${platformVar}===\`darwin\`&&${featuresVar}.computerUse`; - const linuxExpression = `(${platformVar}===\`darwin\`||${platformVar}===\`linux\`)&&${featuresVar}.computerUse`; - if (installWhenMissing != null && expression === linuxExpression) { - sawEnabledGate = true; - continue; - } - if (expression === darwinOnlyExpression || expression === linuxExpression) { - const replacement = buildComputerUseGate({ nameExpr, featuresVar, platformVar, migrateVar }); - return `${currentSource.slice(0, match.index)}${replacement}${currentSource.slice(match.index + gateSource.length)}`; - } - sawUnpatchableGate = true; - } - - if (sawEnabledGate && !sawUnpatchableGate) { - return currentSource; - } - - if (hasComputerUseLiteral(currentSource) && currentSource.includes("computerUse")) { - console.warn( - "WARN: Required Linux Computer Use plugin gate patch failed: skipping bundled Computer Use Linux enablement", - ); - } - - return currentSource; -} - -function applyLinuxComputerUseFeaturePatch(currentSource) { - const patchedFeaturePattern = - /function [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*,\{env:[A-Za-z_$][\w$]*=process\.env,platform:[A-Za-z_$][\w$]*=process\.platform\}=\{\}\)\{return [A-Za-z_$][\w$]*===`linux`\?\{\.\.\.[A-Za-z_$][\w$]*,computerUse:!0,computerUseNodeRepl:!0\}:/; - const windowsOnlyFeaturePattern = - /function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*),\{env:([A-Za-z_$][\w$]*)=process\.env,platform:([A-Za-z_$][\w$]*)=process\.platform\}=\{\}\)\{return \4!==`win32`\|\|\3\.CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE!==`1`\?\2:\{\.\.\.\2,computerUse:!0,computerUseNodeRepl:!0\}\}/; - - if (patchedFeaturePattern.test(currentSource)) { - return currentSource; - } - - if (windowsOnlyFeaturePattern.test(currentSource)) { - return currentSource.replace( - windowsOnlyFeaturePattern, - (_, fnName, featuresVar, envVar, platformVar) => - `function ${fnName}(${featuresVar},{env:${envVar}=process.env,platform:${platformVar}=process.platform}={}){return ${platformVar}===\`linux\`?{...${featuresVar},computerUse:!0,computerUseNodeRepl:!0}:${platformVar}!==\`win32\`||${envVar}.CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE!==\`1\`?${featuresVar}:{...${featuresVar},computerUse:!0,computerUseNodeRepl:!0}}`, - ); - } - - if (currentSource.includes("CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE")) { - console.warn( - "WARN: Could not find Computer Use desktop feature gate — skipping Linux Computer Use feature patch", - ); - } - - return currentSource; -} - -function applyLinuxComputerUseRendererAvailabilityPatch(currentSource) { - let patchedSource = currentSource; - - const platformPredicateNeedle = "function hae(e){return e===`macOS`||e===`windows`}"; - const platformPredicatePatch = - "function hae(e){return e===`macOS`||e===`windows`||e===`linux`}"; - if (patchedSource.includes(platformPredicatePatch)) { - // Already patched. - } else if (patchedSource.includes(platformPredicateNeedle)) { - patchedSource = patchedSource.replace(platformPredicateNeedle, platformPredicatePatch); - } - - const availabilityNeedle = - "let m=a&&i&&s===`electron`&&u&&(c||p),h=m&&!c&&f.enabled&&!f.isLoading,g=m&&f.isLoading,_=m&&(c||f.isLoading),v;"; - const availabilityHostLocalLinuxPatch = - "let m=a&&i&&s===`electron`&&(l===`linux`||u&&(c||p)),h=m&&!c&&(l===`linux`||f.enabled)&&!f.isLoading,g=m&&l!==`linux`&&f.isLoading,_=m&&(c||l!==`linux`&&f.isLoading),v;"; - const availabilityPatch = - "let m=a&&(i||l===`linux`)&&s===`electron`&&(l===`linux`||u&&(c||p)),h=m&&!c&&(l===`linux`||f.enabled)&&!f.isLoading,g=m&&l!==`linux`&&f.isLoading,_=m&&(c||l!==`linux`&&f.isLoading),v;"; - if (patchedSource.includes(availabilityPatch)) { - return patchedSource; - } - - if (patchedSource.includes(availabilityHostLocalLinuxPatch)) { - return patchedSource.replace(availabilityHostLocalLinuxPatch, availabilityPatch); - } - - if (patchedSource.includes(availabilityNeedle)) { - return patchedSource.replace(availabilityNeedle, availabilityPatch); - } - - if (currentSource.includes("featureName:`computer_use`") && currentSource.includes("isComputerUseAvailable")) { - console.warn( - "WARN: Could not find Computer Use renderer availability gate — skipping Linux Computer Use UI availability patch", - ); - } - - return patchedSource; -} - -function applyLinuxComputerUseInstallFlowPatch(currentSource) { - const availabilityNeedle = - "ne=f({featureName:`computer_use`,hostId:t}),re=!ne.isLoading&&ne.enabled,"; - const availabilityPatch = - "ne=f({featureName:`computer_use`,hostId:t}),re=!ne.isLoading&&ne.enabled||navigator.userAgent.includes(`Linux`),"; - - if (currentSource.includes(availabilityPatch)) { - return currentSource; - } - - if (currentSource.includes(availabilityNeedle)) { - return currentSource.replace(availabilityNeedle, availabilityPatch); - } - - if (currentSource.includes("featureName:`computer_use`")) { - console.warn( - "WARN: Could not find Computer Use install flow gate — skipping Linux Computer Use install flow patch", - ); - } - - return currentSource; -} - -function applyBrowserUseNodeReplApprovalPatch(currentSource) { - const approvalPatch = - "command:i.nodeReplPath,args:[],startup_timeout_sec:120,tools:{js:{approval_mode:`approve`}},env:{"; - if (currentSource.includes(approvalPatch)) { - return currentSource; - } - - const needle = "command:i.nodeReplPath,args:[],startup_timeout_sec:120,env:{"; - if (!currentSource.includes(needle)) { - console.warn( - "WARN: Could not find Browser Use node_repl config insertion point — skipping node_repl approval patch", - ); - return currentSource; - } - - return currentSource.replace(needle, approvalPatch); -} - -function applyBrowserAnnotationScreenshotPatch(currentSource) { - let patchedSource = currentSource; - - const liveElementScreenshotNeedle = - "if(M&&j?.anchor.kind===`element`){let e=qu(j,y.current)??null,t=e==null?null:rd(e);he=t?.rect??md(j.anchor),_e=t?.borderRadius}"; - const storedAnchorScreenshotPatch = - "if(M&&j?.anchor.kind===`element`){he=md(j.anchor),_e=void 0}"; - if (patchedSource.includes(storedAnchorScreenshotPatch)) { - // Already patched. - } else if (patchedSource.includes(liveElementScreenshotNeedle)) { - patchedSource = patchedSource.replace(liveElementScreenshotNeedle, storedAnchorScreenshotPatch); - } else { - console.warn("WARN: Could not find browser annotation screenshot element highlight — skipping screenshot anchor patch"); - } - - const allMarkersInScreenshotNeedle = - "de=u?.target.mode===`create`?ce.find(e=>Sd(e.anchor,u.anchor.value))??null:null,fe=!M&&de!=null?ce.filter(e=>e.id!==de.id):ce,"; - const selectedMarkerInScreenshotPatch = - "de=u?.target.mode===`create`?ce.find(e=>Sd(e.anchor,u.anchor.value))??null:null,fe=M?ue:!M&&de!=null?ce.filter(e=>e.id!==de.id):ce,"; - if (patchedSource.includes(selectedMarkerInScreenshotPatch)) { - // Already patched. - } else if (patchedSource.includes(allMarkersInScreenshotNeedle)) { - patchedSource = patchedSource.replace(allMarkersInScreenshotNeedle, selectedMarkerInScreenshotPatch); - } else { - console.warn("WARN: Could not find browser annotation screenshot markers — skipping screenshot marker patch"); - } - - return patchedSource; -} - -function applyLinuxTrayCloseSettingPatch(currentSource) { - let patchedSource = currentSource; - - const patchedCloseGateRegex = new RegExp( - `canHideLastLocalWindowToTray:\\(\\)=>[A-Za-z_$][\\w$]*&&\\(process\\.platform!==\`linux\`\\|\\|[^,{}]+\\.get\\(\`${escapeRegExp(linuxSettingsKeys.systemTray)}\`\\)!==!1\\),disposables:[A-Za-z_$][\\w$]*`, - ); - if (patchedCloseGateRegex.test(patchedSource)) { - return patchedSource; - } - - const closeGateRegex = - /canHideLastLocalWindowToTray:\(\)=>([A-Za-z_$][\w$]*),disposables:([A-Za-z_$][\w$]*)/; - const closeGateMatch = patchedSource.match(closeGateRegex); - if (closeGateMatch != null) { - const [, trayReadyVar, disposableVar] = closeGateMatch; - const prefix = patchedSource.slice( - Math.max(0, closeGateMatch.index - CLOSE_GATE_PREFIX_LOOKBACK), - closeGateMatch.index, - ); - const globalStateExpr = findLinuxGlobalStateExpression(prefix); - if (globalStateExpr != null) { - return patchedSource.replace( - closeGateRegex, - `canHideLastLocalWindowToTray:()=>${trayReadyVar}&&(process.platform!==\`linux\`||${globalStateExpr}.get(\`${linuxSettingsKeys.systemTray}\`)!==!1),disposables:${disposableVar}`, - ); - } - } - - if (patchedSource.includes("canHideLastLocalWindowToTray") && patchedSource.includes("Launching app")) { - console.warn("WARN: Could not find close-to-tray settings needle - skipping Linux tray settings patch"); - return currentSource; - } - - return patchedSource; -} - -function findMatchingBrace(source, openIndex) { - let depth = 0; - let quote = null; - let escaped = false; - - for (let i = openIndex; i < source.length; i += 1) { - const char = source[i]; - if (quote != null) { - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === quote) { - quote = null; - } - continue; - } - - if (char === "'" || char === '"' || char === "`") { - quote = char; - } else if (char === "{") { - depth += 1; - } else if (char === "}") { - depth -= 1; - if (depth === 0) { - return i; - } - } - } - - return -1; -} - -function findLastRegexMatch(source, regex) { - regex.lastIndex = 0; - let lastMatch = null; - let match; - while ((match = regex.exec(source)) != null) { - lastMatch = match; - if (match[0].length === 0) { - regex.lastIndex += 1; - } - } - return lastMatch; -} - -function findLinuxGlobalStateExpression(prefix) { - const objectStateMatch = findLastRegexMatch(prefix, /(?:let|,)\s*([A-Za-z_$][\w$]*)=\{globalState:/g); - const propertyStateMatch = findLastRegexMatch(prefix, /globalState:([A-Za-z_$][\w$]*)\.globalState/g); - - if (objectStateMatch != null && (propertyStateMatch == null || objectStateMatch.index > propertyStateMatch.index)) { - return `${objectStateMatch[1]}.globalState`; - } - if (propertyStateMatch != null) { - return `${propertyStateMatch[1]}.globalState`; - } - - return null; -} - -function findDisposableVar(prefix) { - const explicitVar = findLastRegexMatch(prefix, /disposables:([A-Za-z_$][\w$]*)/g)?.[1]; - if (explicitVar != null) { - return explicitVar; - } - - const adjacentCtorVar = findLastRegexMatch( - prefix, - /([A-Za-z_$][\w$]*)=new [A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*;\1\.add\(/g, - )?.[1]; - if (adjacentCtorVar != null) { - return adjacentCtorVar; - } - - const constructedVar = findLastRegexMatch( - prefix, - /([A-Za-z_$][\w$]*)=new [A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*/g, - )?.[1]; - if (constructedVar != null && prefix.includes(`${constructedVar}.add(`)) { - return constructedVar; - } - - return null; -} - -function buildSemanticLinuxLaunchActionPatch({ - setterVar, - deepLinksVar, - fallbackFn, - openerFn, - windowManagerVar, - hostExpr, - currentWindowVar, - createdWindowVar, - routeVar, - focusFn, - notificationVar, - globalStateExpr, - reporterVar, - disposableVar, - pathVar, - fsVar, - netVar, - appVar, - electronVar, -}) { - const notificationPrefix = notificationVar == null - ? "" - : `${notificationVar}.desktopNotificationManager.dismissByNavigationPath(e),`; - const beforeQuitAppVar = appVar ?? electronVar; - const beforeQuitHandler = beforeQuitAppVar == null - ? "" - : ",codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()}"; - const directHandler = appVar == null - ? beforeQuitHandler - : `,codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{${fallbackFn}()})}${beforeQuitHandler}`; - const beforeQuitStartup = beforeQuitAppVar == null - ? "codexLinuxStartLaunchActionSocket()" - : `${beforeQuitAppVar}.app.on(\`before-quit\`,codexLinuxBeforeQuitHandler),${disposableVar}.add(()=>{${beforeQuitAppVar}.app.off(\`before-quit\`,codexLinuxBeforeQuitHandler)}),codexLinuxStartLaunchActionSocket()`; - const startup = appVar == null - ? `process.platform===\`linux\`&&(${beforeQuitStartup});${setterVar}(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{${fallbackFn}()})});` - : `process.platform===\`linux\`&&(${appVar}.app.on(\`before-quit\`,codexLinuxBeforeQuitHandler),${disposableVar}.add(()=>{${appVar}.app.off(\`before-quit\`,codexLinuxBeforeQuitHandler)}),codexLinuxStartLaunchActionSocket(),${appVar}.app.on(\`second-instance\`,codexLinuxSecondInstanceHandler),${disposableVar}.add(()=>{${appVar}.app.off(\`second-instance\`,codexLinuxSecondInstanceHandler)}));${setterVar}(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{${fallbackFn}()})});`; - - return `void 0;let codexLinuxGetSetting=e=>process.platform!==\`linux\`||${globalStateExpr}.get(e)!==!1,codexLinuxIsTrayEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.systemTray}\`),codexLinuxIsWarmStartEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.warmStart}\`),codexLinuxIsPromptWindowEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.promptWindow}\`),${openerFn}=async(e,t)=>{${windowManagerVar}.hotkeyWindowLifecycleManager.hide();let ${currentWindowVar}=${windowManagerVar}.getPrimaryWindow(${hostExpr}),${createdWindowVar}=${currentWindowVar}??await ${windowManagerVar}.createFreshLocalWindow(e);${createdWindowVar}!=null&&(${notificationPrefix}${currentWindowVar}!=null&&t.navigateExistingWindow&&${routeVar}.navigateToRoute(${createdWindowVar},e),${focusFn}(${createdWindowVar}))},codexLinuxGetHotkeyWindowController=()=>typeof ${windowManagerVar}.hotkeyWindowLifecycleManager.ensureHotkeyWindowController===\`function\`?${windowManagerVar}.hotkeyWindowLifecycleManager.ensureHotkeyWindowController():${windowManagerVar}.hotkeyWindowLifecycleManager,codexLinuxShowHotkeyWindow=async()=>{let e=codexLinuxGetHotkeyWindowController();typeof e.openHome===\`function\`?await e.openHome():typeof e.show===\`function\`?await e.show():await ${windowManagerVar}.ensureHostWindow(${hostExpr})},codexLinuxOpenQuickChat=async()=>{${windowManagerVar}.hotkeyWindowLifecycleManager.hide();let e=${windowManagerVar}.getPrimaryWindow(${hostExpr}),t=e??await ${windowManagerVar}.createFreshLocalWindow(\`/\`);t!=null&&(${windowManagerVar}.windowManager.sendMessageToWindow(t,{type:\`new-quick-chat\`}),${focusFn}(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===\`string\`&&(e.startsWith(\`codex://\`)||e.startsWith(\`codex-browser-sidebar://\`))),codexLinuxHandleLaunchActionArgs=async e=>(typeof codexLinuxIsQuitInProgress===\`function\`&&codexLinuxIsQuitInProgress())?!0:codexLinuxHasDeepLink(e)&&${deepLinksVar}.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(\`--prompt-chat\`)||e.includes(\`--hotkey-window\`))?(codexLinuxIsPromptWindowEnabled()?(await codexLinuxShowHotkeyWindow(),!0):!1):Array.isArray(e)&&e.includes(\`--quick-chat\`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(\`--new-chat\`)?(await ${openerFn}(\`/\`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{if(typeof codexLinuxIsQuitInProgress===\`function\`&&codexLinuxIsQuitInProgress())return;codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to handle Linux launch action\`,{kind:\`linux-launch-action-failed\`}),t()})},codexLinuxPrewarmHotkeyWindow=()=>{if(!codexLinuxIsPromptWindowEnabled())return;try{let e=codexLinuxGetHotkeyWindowController();typeof e.prewarm===\`function\`&&e.prewarm()}catch(e){${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to prewarm Linux hotkey window\`,{kind:\`linux-hotkey-window-prewarm-failed\`})}},codexLinuxStartLaunchActionSocket=()=>{let e=process.env.CODEX_APP_LAUNCH_ACTION_SOCKET?.trim();if(process.platform!==\`linux\`||!e||!codexLinuxIsWarmStartEnabled())return;try{${fsVar}.mkdirSync(${pathVar}.default.dirname(e),{recursive:!0,mode:448}),${fsVar}.rmSync(e,{force:!0});let t=${netVar}.default.createServer(t=>{let n=\`\`,r=!1,i=()=>{if(r)return;r=!0;let i=[];try{let e=JSON.parse(n.trim());Array.isArray(e.argv)&&(i=e.argv.filter(e=>typeof e===\`string\`))}catch(e){t.end?.(\`error\\n\`);return}codexLinuxHandleLaunchActionArgs(i).then(e=>e?void 0:${fallbackFn}()).then(()=>{t.end?.(\`ok\\n\`)}).catch(e=>{${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to handle Linux launch action socket\`,{kind:\`linux-launch-action-socket-failed\`}),t.end?.(\`error\\n\`)})};t.setEncoding?.(\`utf8\`),t.on(\`data\`,e=>{n+=e,n.includes(\`\\n\`)?i():n.length>65536&&t.destroy()}),t.on(\`end\`,i)});t.on(\`error\`,e=>{${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed Linux launch action socket\`,{kind:\`linux-launch-action-socket-error\`})}),t.listen(e),${disposableVar}.add(()=>{t.close(),${fsVar}.rmSync(e,{force:!0})})}catch(e){${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to start Linux launch action socket\`,{kind:\`linux-launch-action-socket-start-failed\`})}}${directHandler};${startup}`; -} - -function applySemanticLinuxLaunchActionArgsPatch(currentSource) { - const handlerRegex = - /([A-Za-z_$][\w$]*)\(e=>\{([A-Za-z_$][\w$]*)\.deepLinks\.queueProcessArgs\(e\)\|\|([A-Za-z_$][\w$]*)\(\)\}\);let ([A-Za-z_$][\w$]*)=async\(e,t\)=>\{/g; - let match; - while ((match = handlerRegex.exec(currentSource)) != null) { - const [, setterVar, deepLinksVar, fallbackFn, openerFn] = match; - // handlerRegex ends with `let =async(e,t)=>{`, so the opening - // brace's position is determined directly by the match. - const openerBraceIndex = match.index + match[0].length - 1; - const openerLetIndex = openerBraceIndex - `let ${openerFn}=async(e,t)=>`.length; - const openerEnd = findMatchingBrace(currentSource, openerBraceIndex); - if (openerEnd === -1) { - continue; - } - - const separator = currentSource[openerEnd + 1]; - if (separator !== ";" && separator !== ",") { - continue; - } - - const openerText = currentSource.slice(openerLetIndex, openerEnd + 1); - const openerVars = openerText.match( - /([A-Za-z_$][\w$]*)\.hotkeyWindowLifecycleManager\.hide\(\);let ([A-Za-z_$][\w$]*)=\1\.getPrimaryWindow\(([^)]+)\),([A-Za-z_$][\w$]*)=\2\?\?await \1\.createFreshLocalWindow\(e\);/, - ); - if (openerVars == null) { - continue; - } - - const [, windowManagerVar, currentWindowVar, hostExpr, createdWindowVar] = openerVars; - const routeVar = openerText.match(/([A-Za-z_$][\w$]*)\.navigateToRoute\([A-Za-z_$][\w$]*,e\)/)?.[1]; - const focusFn = openerText.match(new RegExp(`,([A-Za-z_$][\\w$]*)\\(${escapeRegExp(createdWindowVar)}\\)\\)\\}$`))?.[1]; - if (routeVar == null || focusFn == null) { - continue; - } - - const prefix = currentSource.slice(Math.max(0, match.index - HANDLER_PREFIX_LOOKBACK), match.index); - const globalStateExpr = findLinuxGlobalStateExpression(prefix); - const reporterVar = findLastRegexMatch( - prefix, - /([A-Za-z_$][\w$]*)\.reportNonFatal\(e instanceof Error\?e:`Failed to open window on second instance`/g, - )?.[1] ?? findLastRegexMatch(prefix, /([A-Za-z_$][\w$]*)=\{reportNonFatal/g)?.[1]; - const disposableVar = findDisposableVar(prefix); - const pathVar = inferModuleAlias(currentSource, "node:path"); - const fsVar = inferModuleAlias(currentSource, "node:fs"); - const netVar = inferModuleAlias(currentSource, "node:net"); - const electronVar = inferModuleAlias(currentSource, "electron"); - if (globalStateExpr == null || reporterVar == null || disposableVar == null || pathVar == null || fsVar == null || netVar == null) { - continue; - } - - let replaceStart = match.index; - let appVar = null; - const directStart = currentSource.lastIndexOf("let codexLinuxSecondInstanceHandler=", match.index); - if (directStart !== -1 && match.index - directStart < DIRECT_HANDLER_PROXIMITY) { - const directBlock = currentSource.slice(directStart, match.index); - const appMatch = directBlock.match(/([A-Za-z_$][\w$]*)\.app\.on\(`second-instance`,codexLinuxSecondInstanceHandler\)/); - replaceStart = directStart; - appVar = appMatch?.[1] ?? inferModuleAlias(currentSource, "electron"); - } - - const notificationVar = openerText.match( - /([A-Za-z_$][\w$]*)\.desktopNotificationManager\.dismissByNavigationPath\(e\)/, - )?.[1] ?? null; - const replacement = buildSemanticLinuxLaunchActionPatch({ - setterVar, - deepLinksVar, - fallbackFn, - openerFn, - windowManagerVar, - hostExpr: hostExpr.trim(), - currentWindowVar, - createdWindowVar, - routeVar, - focusFn, - notificationVar, - globalStateExpr, - reporterVar, - disposableVar, - pathVar, - fsVar, - netVar, - appVar, - electronVar, - }); - const suffix = separator === "," ? "let " : ""; - return currentSource.slice(0, replaceStart) + replacement + suffix + currentSource.slice(openerEnd + 2); - } - - return currentSource; -} - -function applyLinuxLaunchActionArgsPatch(currentSource) { - let patchedSource = currentSource; - - const launchActionNeedle = - "let codexLinuxSecondInstanceHandler=(e,t)=>{R.deepLinks.queueProcessArgs(t)||ie()};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},oe=async()=>{"; - const launchActionNeedleWithQuitGuard = - "let codexLinuxSecondInstanceHandler=(e,t)=>{(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(t)||ie()},codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()};process.platform===`linux`&&(n.app.on(`before-quit`,codexLinuxBeforeQuitHandler),k.add(()=>{n.app.off(`before-quit`,codexLinuxBeforeQuitHandler)}),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(e)||ie()});let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},oe=async()=>{"; - const oldLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHandleLaunchActionArgs=async e=>Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{R.deepLinks.queueProcessArgs(t)||ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{R.deepLinks.queueProcessArgs(e)||ie()})});let oe=async()=>{"; - const deepLinkFirstLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHandleLaunchActionArgs=async e=>Array.isArray(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; - const deepLinkAwareExistingWindowLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; - const openHomeHotkeyWindowLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxShowHotkeyWindow=async()=>{let e=P.hotkeyWindowLifecycleManager;typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; - const socketHotkeyWindowLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxShowHotkeyWindow=async()=>{let e=P.hotkeyWindowLifecycleManager;typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxStartLaunchActionSocket=()=>{let e=process.env.CODEX_APP_LAUNCH_ACTION_SOCKET?.trim();if(process.platform!==`linux`||!e)return;try{o.mkdirSync(i.default.dirname(e),{recursive:!0,mode:448}),o.rmSync(e,{force:!0});let t=u.default.createServer(t=>{let n=``,r=!1,i=()=>{if(r)return;r=!0;let i=[];try{let e=JSON.parse(n.trim());Array.isArray(e.argv)&&(i=e.argv.filter(e=>typeof e===`string`))}catch(e){t.end?.(`error\\n`);return}codexLinuxHandleLaunchActionArgs(i).then(e=>e?void 0:ie()).then(()=>{t.end?.(`ok\\n`)}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action socket`,{kind:`linux-launch-action-socket-failed`}),t.end?.(`error\\n`)})};t.setEncoding?.(`utf8`),t.on(`data`,e=>{n+=e,n.includes(`\\n`)?i():n.length>65536&&t.destroy()}),t.on(`end`,i)});t.on(`error`,e=>{g.reportNonFatal(e instanceof Error?e:`Failed Linux launch action socket`,{kind:`linux-launch-action-socket-error`})}),t.listen(e),k.add(()=>{t.close(),o.rmSync(e,{force:!0})})}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to start Linux launch action socket`,{kind:`linux-launch-action-socket-start-failed`})}},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(codexLinuxStartLaunchActionSocket(),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; - const hotkeyWindowLaunchActionPatch = socketHotkeyWindowLaunchActionPatch - .replace( - "let ae=async(e,t)=>{", - `let codexLinuxGetSetting=e=>process.platform!==\`linux\`||M.globalState.get(e)!==!1,codexLinuxIsTrayEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.systemTray}\`),codexLinuxIsWarmStartEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.warmStart}\`),codexLinuxIsPromptWindowEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.promptWindow}\`),ae=async(e,t)=>{`, - ) - .replace( - "codexLinuxShowHotkeyWindow=async()=>{let e=P.hotkeyWindowLifecycleManager;typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)}", - "codexLinuxGetHotkeyWindowController=()=>typeof P.hotkeyWindowLifecycleManager.ensureHotkeyWindowController===`function`?P.hotkeyWindowLifecycleManager.ensureHotkeyWindowController():P.hotkeyWindowLifecycleManager,codexLinuxShowHotkeyWindow=async()=>{let e=codexLinuxGetHotkeyWindowController();typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)}", - ) - .replace( - "Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0)", - "Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(codexLinuxIsPromptWindowEnabled()?(await codexLinuxShowHotkeyWindow(),!0):!1)", - ) - .replace( - "codexLinuxHandleLaunchActionArgs=async e=>", - "codexLinuxHandleLaunchActionArgs=async e=>(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?!0:", - ) - .replace( - "codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{", - "codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{if(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())return;", - ) - .replace( - "if(process.platform!==`linux`||!e)return;", - "if(process.platform!==`linux`||!e||!codexLinuxIsWarmStartEnabled())return;", - ) - .replace( - "codexLinuxStartLaunchActionSocket=()=>{", - "codexLinuxPrewarmHotkeyWindow=()=>{try{let e=codexLinuxGetHotkeyWindowController();typeof e.prewarm===`function`&&e.prewarm()}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to prewarm Linux hotkey window`,{kind:`linux-hotkey-window-prewarm-failed`})}},codexLinuxStartLaunchActionSocket=()=>{", - ) - .replace( - "codexLinuxPrewarmHotkeyWindow=()=>{try{", - "codexLinuxPrewarmHotkeyWindow=()=>{if(!codexLinuxIsPromptWindowEnabled())return;try{", - ) - .replace( - "process.platform===`linux`&&(codexLinuxStartLaunchActionSocket(),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});", - "let codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()};process.platform===`linux`&&(n.app.on(`before-quit`,codexLinuxBeforeQuitHandler),k.add(()=>{n.app.off(`before-quit`,codexLinuxBeforeQuitHandler)}),codexLinuxStartLaunchActionSocket(),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});", - ); - const showBasedHotkeyWindowLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxShowHotkeyWindow=async()=>{P.hotkeyWindowLifecycleManager.show()||await P.ensureHostWindow(z)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; - const freshWindowLaunchActionPatch = - "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenNewChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=await P.createFreshLocalWindow(`/`);e!=null&&re(e)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=await P.createFreshLocalWindow(`/`);e!=null&&(P.windowManager.sendMessageToWindow(e,{type:`new-quick-chat`}),re(e))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await codexLinuxOpenNewChat(),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; - const launchActionPatch = - hotkeyWindowLaunchActionPatch; - const currentLaunchActionNeedle = - "l(e=>{z.deepLinks.queueProcessArgs(e)||oe()});let se=async(e,t)=>{M.hotkeyWindowLifecycleManager.hide();let n=M.getPrimaryWindow(B),r=n??await M.createFreshLocalWindow(e);r!=null&&(R.desktopNotificationManager.dismissByNavigationPath(e),n!=null&&t.navigateExistingWindow&&z.navigateToRoute(r,e),ae(r))}"; - const currentLaunchActionPatch = - "l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{z.deepLinks.queueProcessArgs(e)||oe()})});let se=async(e,t)=>{M.hotkeyWindowLifecycleManager.hide();let n=M.getPrimaryWindow(B),r=n??await M.createFreshLocalWindow(e);r!=null&&(R.desktopNotificationManager.dismissByNavigationPath(e),n!=null&&t.navigateExistingWindow&&z.navigateToRoute(r,e),ae(r))},codexLinuxGetSetting=e=>process.platform!==`linux`||j.globalState.get(e)!==!1,codexLinuxIsTrayEnabled=()=>codexLinuxGetSetting(`codex-linux-system-tray-enabled`),codexLinuxIsWarmStartEnabled=()=>codexLinuxGetSetting(`codex-linux-warm-start-enabled`),codexLinuxIsPromptWindowEnabled=()=>codexLinuxGetSetting(`codex-linux-prompt-window-enabled`),codexLinuxGetHotkeyWindowController=()=>typeof M.hotkeyWindowLifecycleManager.ensureHotkeyWindowController===`function`?M.hotkeyWindowLifecycleManager.ensureHotkeyWindowController():M.hotkeyWindowLifecycleManager,codexLinuxShowHotkeyWindow=async()=>{let e=codexLinuxGetHotkeyWindowController();typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await M.ensureHostWindow(B)},codexLinuxOpenQuickChat=async()=>{M.hotkeyWindowLifecycleManager.hide();let e=M.getPrimaryWindow(B),t=e??await M.createFreshLocalWindow(`/`);t!=null&&(M.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),ae(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&z.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(codexLinuxIsPromptWindowEnabled()?(await codexLinuxShowHotkeyWindow(),!0):!1):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await se(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxPrewarmHotkeyWindow=()=>{if(!codexLinuxIsPromptWindowEnabled())return;try{let e=codexLinuxGetHotkeyWindowController();typeof e.prewarm===`function`&&e.prewarm()}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to prewarm Linux hotkey window`,{kind:`linux-hotkey-window-prewarm-failed`})}},codexLinuxStartLaunchActionSocket=()=>{let e=process.env.CODEX_APP_LAUNCH_ACTION_SOCKET?.trim();if(process.platform!==`linux`||!e||!codexLinuxIsWarmStartEnabled())return;try{o.mkdirSync(i.default.dirname(e),{recursive:!0,mode:448}),o.rmSync(e,{force:!0});let t=u.default.createServer(t=>{let n=``,r=!1,i=()=>{if(r)return;r=!0;let i=[];try{let e=JSON.parse(n.trim());Array.isArray(e.argv)&&(i=e.argv.filter(e=>typeof e===`string`))}catch(e){t.end?.(`error\\n`);return}codexLinuxHandleLaunchActionArgs(i).then(e=>e?void 0:oe()).then(()=>{t.end?.(`ok\\n`)}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action socket`,{kind:`linux-launch-action-socket-failed`}),t.end?.(`error\\n`)})};t.setEncoding?.(`utf8`),t.on(`data`,e=>{n+=e,n.includes(`\\n`)?i():n.length>65536&&t.destroy()}),t.on(`end`,i)});t.on(`error`,e=>{g.reportNonFatal(e instanceof Error?e:`Failed Linux launch action socket`,{kind:`linux-launch-action-socket-error`})}),t.listen(e),k.add(()=>{t.close(),o.rmSync(e,{force:!0})})}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to start Linux launch action socket`,{kind:`linux-launch-action-socket-start-failed`})}};process.platform===`linux`&&codexLinuxStartLaunchActionSocket()"; - - if ( - patchedSource.includes("codexLinuxQuitInProgress=!1") && - patchedSource.includes("codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0}") && - patchedSource.includes("codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0") && - patchedSource.includes("codexLinuxGetSetting=e=>") && - patchedSource.includes("codexLinuxGetHotkeyWindowController=()=>") && - patchedSource.includes("codexLinuxHandleLaunchActionArgs=async e=>") && - patchedSource.includes("codexLinuxPrewarmHotkeyWindow=()=>") && - patchedSource.includes("codexLinuxStartLaunchActionSocket=()=>") && - patchedSource.includes("n.app.on(`before-quit`,codexLinuxBeforeQuitHandler)") && - !patchedSource.includes("codexLinuxOpenNewChat") - ) { - return patchedSource; - } - - // Try cheap exact-string legacy needles first; only fall through to the - // semantic regex+capture pass if no known shape matches. - if (patchedSource.includes(oldLaunchActionPatch)) { - patchedSource = patchedSource.replace(oldLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(deepLinkFirstLaunchActionPatch)) { - patchedSource = patchedSource.replace(deepLinkFirstLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(deepLinkAwareExistingWindowLaunchActionPatch)) { - patchedSource = patchedSource.replace(deepLinkAwareExistingWindowLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(openHomeHotkeyWindowLaunchActionPatch)) { - patchedSource = patchedSource.replace(openHomeHotkeyWindowLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(socketHotkeyWindowLaunchActionPatch)) { - patchedSource = patchedSource.replace(socketHotkeyWindowLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(showBasedHotkeyWindowLaunchActionPatch)) { - patchedSource = patchedSource.replace(showBasedHotkeyWindowLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(freshWindowLaunchActionPatch)) { - patchedSource = patchedSource.replace(freshWindowLaunchActionPatch, launchActionPatch); - } else if (patchedSource.includes(launchActionNeedle)) { - patchedSource = patchedSource.replace(launchActionNeedle, launchActionPatch); - } else if (patchedSource.includes(launchActionNeedleWithQuitGuard)) { - patchedSource = patchedSource.replace(launchActionNeedleWithQuitGuard, launchActionPatch); - } else { - const semanticLaunchActionPatch = applySemanticLinuxLaunchActionArgsPatch(patchedSource); - if (semanticLaunchActionPatch !== patchedSource) { - return applyLinuxQuitGuardPatch(semanticLaunchActionPatch); - } - - if (patchedSource.includes(currentLaunchActionNeedle)) { - patchedSource = patchedSource.replace(currentLaunchActionNeedle, currentLaunchActionPatch); - return applyLinuxQuitGuardPatch(patchedSource); - } - - const existingLinuxLaunchActionBlock = patchedSource.match( - /let ae=async\(e,t\)=>\{P\.hotkeyWindowLifecycleManager\.hide\(\);.*?;let oe=async\(\)=>\{/, - )?.[0]; - if (existingLinuxLaunchActionBlock?.includes("codexLinuxHandleLaunchActionArgs")) { - patchedSource = patchedSource.replace(existingLinuxLaunchActionBlock, launchActionPatch); - } else if ( - patchedSource.includes("Launching app") && - patchedSource.includes("deepLinks") - ) { - console.warn("WARN: Could not find Linux launch action handler - skipping --new-chat/--quick-chat/--prompt-chat patch"); - return currentSource; - } else { - console.warn("WARN: Could not find Linux launch action handler - skipping --new-chat/--quick-chat/--prompt-chat patch"); - } - } - - if (patchedSource.includes("Launching app") && !patchedSource.includes("codexLinuxGetSetting=e=>")) { - console.warn("WARN: Linux launch action patch was skipped before settings gates were added"); - return currentSource; - } - - return applyLinuxQuitGuardPatch(patchedSource); -} - -function applyLinuxHotkeyWindowPrewarmPatch(currentSource) { - let patchedSource = currentSource; - - if (!patchedSource.includes("codexLinuxPrewarmHotkeyWindow=()=>")) { - return patchedSource; - } - - const startupPrewarmPatch = - "process.platform===`linux`&&codexLinuxPrewarmHotkeyWindow(),A=Date.now(),await R.deepLinks.flushPendingDeepLinks()"; - - if (patchedSource.includes(startupPrewarmPatch)) { - return patchedSource; - } - - if ( - /process\.platform===`linux`&&codexLinuxPrewarmHotkeyWindow\(\),[A-Za-z_$][\w$]*=Date\.now\(\),await [A-Za-z_$][\w$]*\.deepLinks\.flushPendingDeepLinks\(\)/.test(patchedSource) - ) { - return patchedSource; - } - - const startupPrewarmNeedle = - "w(`local window ensured`,A,{hostId:z,localWindowVisible:me?.isVisible()??!1}),A=Date.now(),await R.deepLinks.flushPendingDeepLinks()"; - const currentStartupPrewarmNeedle = - "w(`local window ensured`,A,{hostId:B,localWindowVisible:be?.isVisible()??!1}),A=Date.now(),await z.deepLinks.flushPendingDeepLinks()"; - - if (patchedSource.includes(startupPrewarmNeedle)) { - patchedSource = patchedSource.replace(startupPrewarmNeedle, `w(\`local window ensured\`,A,{hostId:z,localWindowVisible:me?.isVisible()??!1}),${startupPrewarmPatch}`); - } else if (patchedSource.includes(currentStartupPrewarmNeedle)) { - patchedSource = patchedSource.replace( - currentStartupPrewarmNeedle, - "w(`local window ensured`,A,{hostId:B,localWindowVisible:be?.isVisible()??!1}),process.platform===`linux`&&codexLinuxPrewarmHotkeyWindow(),A=Date.now(),await z.deepLinks.flushPendingDeepLinks()", - ); - } else if ( - patchedSource.includes("process.platform===`linux`&&codexLinuxPrewarmHotkeyWindow(),A=Date.now(),await R.deepLinks.flushPendingDeepLinks()") - ) { - // Already patched by an older run. - } else { - const dynamicStartupPrewarmRegex = - /(w\(`local window ensured`,([A-Za-z_$][\w$]*),\{hostId:([A-Za-z_$][\w$]*),localWindowVisible:[^}]+\}\),)\2=Date\.now\(\),await ([A-Za-z_$][\w$]*)\.deepLinks\.flushPendingDeepLinks\(\)/; - const dynamicStartupPrewarmMatch = patchedSource.match(dynamicStartupPrewarmRegex); - if (dynamicStartupPrewarmMatch != null) { - const [, prefix, timeVar, , deepLinksVar] = dynamicStartupPrewarmMatch; - patchedSource = patchedSource.replace( - dynamicStartupPrewarmRegex, - `${prefix}process.platform===\`linux\`&&codexLinuxPrewarmHotkeyWindow(),${timeVar}=Date.now(),await ${deepLinksVar}.deepLinks.flushPendingDeepLinks()`, - ); - } else { - console.warn("WARN: Could not find Linux hotkey window prewarm insertion point — skipping startup prewarm patch"); - } - } - - return patchedSource; -} - - -function patchMainBundleSource(source, iconAsset) { - let patched = source; - const iconPathExpression = - iconAsset == null ? null : `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``; - const enableComputerUseUi = isComputerUseUiEnabled(); - patched = applyLinuxWindowOptionsPatch(patched, iconAsset); - patched = applyLinuxMenuPatch(patched); - patched = applyLinuxSetIconPatch(patched, iconAsset); - patched = applyLinuxOpaqueBackgroundPatch(patched); - patched = applyLinuxFileManagerPatch(patched); - patched = applyLinuxTrayPatch(patched, iconPathExpression); - patched = applyLinuxSingleInstancePatch(patched); - if (enableComputerUseUi) { - patched = applyLinuxComputerUseFeaturePatch(patched); - } - patched = applyLinuxComputerUsePluginGatePatch(patched); - patched = applyBrowserUseNodeReplApprovalPatch(patched); - patched = applyLinuxAppUpdaterMenuPatch(patched); - patched = applyLinuxTrayCloseSettingPatch(patched); - patched = applyLinuxSettingsPersistencePatch(patched); - patched = applyLinuxLaunchActionArgsPatch(patched); - patched = applyLinuxQuitGuardPatch(patched); - patched = applyLinuxHotkeyWindowPrewarmPatch(patched); - return patched; -} - -function patchPackageJson(extractedDir) { - const packageJsonPath = path.join(extractedDir, "package.json"); - if (!fs.existsSync(packageJsonPath)) { - return null; - } - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - const desktopName = resolveDesktopName(); - if (packageJson.desktopName !== desktopName) { - packageJson.desktopName = desktopName; - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); - } - return packageJson.desktopName; -} - -function resolveDesktopName(env = process.env) { - const appId = env.CODEX_APP_ID || "codex-app"; - if (!/^[A-Za-z0-9._-]+$/.test(appId)) { - throw new Error("CODEX_APP_ID must contain only letters, numbers, dots, underscores, and hyphens"); - } - return `${appId}.desktop`; -} - -function patchCommentPreloadBundle(extractedDir) { - const commentPreloadBundle = path.join(extractedDir, ".vite", "build", "comment-preload.js"); - if (!fs.existsSync(commentPreloadBundle)) { - console.warn( - `WARN: Could not find comment preload bundle in ${path.dirname(commentPreloadBundle)} — skipping annotation screenshot patch`, - ); - return { matched: false, changed: false }; - } - - const source = fs.readFileSync(commentPreloadBundle, "utf8"); - const patchedSource = applyBrowserAnnotationScreenshotPatch(source); - if (patchedSource !== source) { - fs.writeFileSync(commentPreloadBundle, patchedSource, "utf8"); - return { matched: true, changed: true }; - } - return { matched: true, changed: false }; -} - -function recordAssetPatch(report, name, patchResult, warnings) { - if (patchResult.matched === 0) { - recordPatch(report, name, "skipped-optional", warnings[0] ?? "no matching bundle found"); - return; - } - if (patchResult.changed === 0 && warnings.length > 0) { - recordPatch(report, name, "skipped-optional", warnings[0] ?? "no matching bundle found"); - return; - } - - recordPatch( - report, - name, - patchResult.changed > 0 ? "applied" : "already-applied", - ); -} - -function patchExtractedApp(extractedDir, options = {}) { - const report = options.report ?? null; - const main = findMainBundle(extractedDir); - if (report != null) { - report.mainBundle = main?.mainBundle ?? null; - report.target = main == null ? null : path.join(main.buildDir, main.mainBundle); - } - if (main == null) { - const reason = `Could not find main bundle in ${path.join(extractedDir, ".vite", "build")}`; - console.warn(`WARN: ${reason} — skipping main-process UI patches`); - recordPatch(report, "main-process-ui", "failed-required", reason); - } - - const iconAsset = findIconAsset(extractedDir); - if (report != null) { - report.iconAsset = iconAsset; - } - if (iconAsset == null) { - console.warn( - `WARN: Could not find app icon asset in ${path.join(extractedDir, "webview", "assets")} — skipping icon patches`, - ); - } - - if (main != null) { - const target = path.join(main.buildDir, main.mainBundle); - const source = fs.readFileSync(target, "utf8"); - const { value: patchedSource, warnings } = captureWarnings(() => - patchMainBundleSource(source, iconAsset), - ); - if (patchedSource !== source) { - fs.writeFileSync(target, patchedSource, "utf8"); - } - recordPatch( - report, - "main-process-ui", - patchStatusFromChange(patchedSource !== source, warnings), - warnings[0] ?? null, - ); - } - - { - const { value: result, warnings } = captureWarnings(() => patchLinuxAppUpdaterBridge(extractedDir)); - recordAssetPatch(report, "linux-app-updater-bridge", result, warnings); - } - - { - const { value: result, warnings } = captureWarnings(() => patchCommentPreloadBundle(extractedDir)); - recordPatch( - report, - "browser-annotation-screenshot", - patchStatusFromChange(result.changed, warnings), - warnings[0] ?? null, - ); - } - - for (const [name, pattern, warning] of [ - [ - "opaque-window-default-code-theme", - /^code-theme-.*\.js$/, - `WARN: Could not find code theme bundle in ${path.join(extractedDir, "webview", "assets")} — skipping translucent sidebar default patch`, - ], - [ - "opaque-window-default-general-settings", - /^general-settings-.*\.js$/, - `WARN: Could not find general settings bundle in ${path.join(extractedDir, "webview", "assets")} — skipping translucent sidebar default patch`, - ], - [ - "opaque-window-default-webview-index", - /^index-.*\.js$/, - `WARN: Could not find webview index bundle in ${path.join(extractedDir, "webview", "assets")} — skipping translucent sidebar default patch`, - ], - [ - "opaque-window-default-resolved-theme", - /^use-resolved-theme-variant-.*\.js$/, - `WARN: Could not find resolved theme bundle in ${path.join(extractedDir, "webview", "assets")} — skipping translucent sidebar default patch`, - ], - ]) { - const { value: result, warnings } = captureWarnings(() => - patchAssetFiles(extractedDir, pattern, applyLinuxOpaqueWindowsDefaultPatch, warning), - ); - recordAssetPatch(report, name, result, warnings); - } - - if (isComputerUseUiEnabled()) { - for (const [name, pattern, patchFn, warning] of [ - [ - "linux-computer-use-ui-availability", - /^use-model-settings-.*\.js$/, - applyLinuxComputerUseRendererAvailabilityPatch, - `WARN: Could not find model settings bundle in ${path.join(extractedDir, "webview", "assets")} — skipping Linux Computer Use UI availability patch`, - ], - [ - "linux-computer-use-install-flow", - /^use-plugin-install-flow-.*\.js$/, - applyLinuxComputerUseInstallFlowPatch, - `WARN: Could not find plugin install flow bundle in ${path.join(extractedDir, "webview", "assets")} — skipping Linux Computer Use install flow patch`, - ], - ]) { - const { value: result, warnings } = captureWarnings(() => - patchAssetFiles(extractedDir, pattern, patchFn, warning), - ); - recordAssetPatch(report, name, result, warnings); - } - } - - { - const { value: result, warnings } = captureWarnings(() => patchKeybindsSettingsAssets(extractedDir)); - recordPatch( - report, - "keybinds-settings", - result.changed > 0 ? "applied" : result.matched ? "already-applied" : "skipped-optional", - result.reason ?? warnings[0] ?? null, - ); - } - - const packageJsonPath = path.join(extractedDir, "package.json"); - const previousPackageJson = fs.existsSync(packageJsonPath) - ? fs.readFileSync(packageJsonPath, "utf8") - : null; - const desktopName = patchPackageJson(extractedDir); - const nextPackageJson = fs.existsSync(packageJsonPath) - ? fs.readFileSync(packageJsonPath, "utf8") - : null; - if (report != null) { - report.desktopName = desktopName; - } - recordPatch( - report, - "package-desktop-name", - desktopName == null - ? "skipped-optional" - : previousPackageJson !== nextPackageJson ? "applied" : "already-applied", - desktopName == null ? "package.json not found" : null, - ); - console.log("Patched Linux window, shell, and appearance behavior:", { - target: main == null ? null : path.join(main.buildDir, main.mainBundle), - mainBundle: main?.mainBundle ?? null, - iconAsset, - desktopName, - }); -} + enabledLinuxFeatureIds, + enabledLinuxFeatureStageHooks, + loadEnabledLinuxFeatures, + loadLinuxFeatureMainBundlePatches, +} = require("./lib/linux-features.js"); +const { + applyLinuxAppUpdaterBridgePatch, + applyLinuxAppUpdaterMenuPatch, + patchLinuxAppUpdaterBridge, +} = require("./lib/linux-update-bridge-patch.js"); +const { + applyLinuxChromePluginAutoInstallPatch, +} = require("./patches/chrome-plugin.js"); +const { + COMPUTER_USE_UI_ENV_VAR, + COMPUTER_USE_UI_SETTINGS_KEY, + applyLinuxComputerUseFeaturePatch, + applyLinuxComputerUseInstallFlowPatch, + applyLinuxComputerUsePluginGatePatch, + applyLinuxComputerUseRendererAvailabilityPatch, + isComputerUseUiEnabled, +} = require("./patches/computer-use.js"); +const { + applyKeybindsSettingsIndexPatch, + applyKeybindsSettingsSectionsPatch, + applyKeybindsSettingsSharedPatch, + applyLinuxKeybindOverridesRuntimePatch, + patchKeybindsSettingsAssets, + resolveKeybindsSettingsAsset, +} = require("./patches/keybinds-settings.js"); +const { + applyLinuxHotkeyWindowPrewarmPatch, + applyLinuxLaunchActionArgsPatch, + applyLinuxSettingsPersistencePatch, + applyLinuxTrayCloseSettingPatch, +} = require("./patches/launch-actions.js"); +const { + applyLinuxAvatarOverlayMousePassthroughPatch, + applyBrowserUseNodeReplApprovalPatch, + applyLinuxBrowserUseIabVisibleOnCreatePatch, + applyLinuxChromeExtensionStatusPatch, + applyLinuxExplicitIpcQuitPatch, + applyLinuxExplicitQuitPromptBypassPatch, + applyLinuxExplicitTrayQuitPatch, + applyLinuxFileManagerPatch, + applyLinuxGitOriginsSourceFallbackPatch, + applyLinuxMenuPatch, + applyLinuxOpaqueBackgroundPatch, + applyLinuxQuitGuardPatch, + applyLinuxSetIconPatch, + applyLinuxSingleInstancePatch, + applyLinuxTrayPatch, + applyLinuxWillQuitDrainTimeoutPatch, + applyLinuxWindowOptionsPatch, +} = require("./patches/main-process.js"); +const { + patchPackageJson, + resolveDesktopName, +} = require("./patches/package-json.js"); +const { + patchExtractedApp, + patchMainBundleSource, +} = require("./patches/registry.js"); +const { + applyBrowserAnnotationScreenshotPatch, + applyLinuxAppSunsetPatch, + applyLinuxOpaqueWindowsDefaultPatch, + patchCommentPreloadBundle, +} = require("./patches/webview-assets.js"); function main() { const args = process.argv.slice(2); @@ -2153,21 +106,20 @@ function main() { } const report = reportJson == null ? null : createPatchReport(); - let fatalError = null; try { patchExtractedApp(extractedDir, { report }); } catch (error) { - fatalError = error; if (report != null) { - report.fatalError = error?.stack ?? String(error); - recordPatch(report, "patcher-cli", "failed-required", error?.message ?? String(error)); + report.patches.push({ + name: "patch-linux-window-ui", + status: "failed-required", + reason: error instanceof Error ? error.message : String(error), + }); + writePatchReport(reportJson, report); } - } finally { - writePatchReport(reportJson, report); - } - if (fatalError != null) { - throw fatalError; + throw error; } + writePatchReport(reportJson, report); } if (require.main === module) { @@ -2178,38 +130,52 @@ module.exports = { COMPUTER_USE_UI_ENV_VAR, COMPUTER_USE_UI_SETTINGS_KEY, applyBrowserAnnotationScreenshotPatch, + applyBrowserUseNodeReplApprovalPatch, applyKeybindsSettingsIndexPatch, applyKeybindsSettingsSectionsPatch, applyKeybindsSettingsSharedPatch, - applyLinuxComputerUsePluginGatePatch, - applyLinuxComputerUseFeaturePatch, - applyLinuxComputerUseRendererAvailabilityPatch, - applyLinuxComputerUseInstallFlowPatch, - applyBrowserUseNodeReplApprovalPatch, + applyLinuxAppSunsetPatch, applyLinuxAppUpdaterBridgePatch, applyLinuxAppUpdaterMenuPatch, - patchLinuxAppUpdaterBridge, + applyLinuxAvatarOverlayMousePassthroughPatch, + applyLinuxBrowserUseIabVisibleOnCreatePatch, + applyLinuxChromeExtensionStatusPatch, + applyLinuxChromePluginAutoInstallPatch, + applyLinuxComputerUseFeaturePatch, + applyLinuxComputerUseInstallFlowPatch, + applyLinuxComputerUsePluginGatePatch, + applyLinuxComputerUseRendererAvailabilityPatch, + applyLinuxExplicitIpcQuitPatch, + applyLinuxExplicitQuitPromptBypassPatch, + applyLinuxExplicitTrayQuitPatch, applyLinuxFileManagerPatch, + applyLinuxGitOriginsSourceFallbackPatch, applyLinuxHotkeyWindowPrewarmPatch, applyLinuxKeybindOverridesRuntimePatch, - applyLinuxQuitGuardPatch, - isComputerUseUiEnabled, applyLinuxLaunchActionArgsPatch, applyLinuxMenuPatch, applyLinuxOpaqueBackgroundPatch, applyLinuxOpaqueWindowsDefaultPatch, + applyLinuxQuitGuardPatch, applyLinuxSetIconPatch, - applyLinuxSingleInstancePatch, applyLinuxSettingsPersistencePatch, + applyLinuxSingleInstancePatch, applyLinuxTrayCloseSettingPatch, applyLinuxTrayPatch, + applyLinuxWillQuitDrainTimeoutPatch, applyLinuxWindowOptionsPatch, + createPatchReport, + enabledLinuxFeatureIds, + enabledLinuxFeatureStageHooks, + isComputerUseUiEnabled, + loadEnabledLinuxFeatures, + loadLinuxFeatureMainBundlePatches, patchCommentPreloadBundle, - patchKeybindsSettingsAssets, patchExtractedApp, + patchKeybindsSettingsAssets, + patchLinuxAppUpdaterBridge, patchMainBundleSource, patchPackageJson, - createPatchReport, resolveDesktopName, resolveKeybindsSettingsAsset, }; diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index cc97ba02..0a9967f7 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -17,20 +17,29 @@ const { applyLinuxComputerUseInstallFlowPatch, applyLinuxComputerUsePluginGatePatch, applyLinuxComputerUseRendererAvailabilityPatch, + applyLinuxAvatarOverlayMousePassthroughPatch, applyBrowserUseNodeReplApprovalPatch, + applyLinuxBrowserUseIabVisibleOnCreatePatch, + applyLinuxChromeExtensionStatusPatch, + applyLinuxChromePluginAutoInstallPatch, applyLinuxAppUpdaterBridgePatch, applyLinuxAppUpdaterMenuPatch, + applyLinuxExplicitIpcQuitPatch, + applyLinuxExplicitQuitPromptBypassPatch, + applyLinuxExplicitTrayQuitPatch, applyLinuxFileManagerPatch, + applyLinuxGitOriginsSourceFallbackPatch, applyLinuxQuitGuardPatch, applyLinuxHotkeyWindowPrewarmPatch, applyLinuxLaunchActionArgsPatch, applyLinuxMenuPatch, + applyLinuxAppSunsetPatch, applyLinuxOpaqueBackgroundPatch, - applyLinuxKeybindOverridesRuntimePatch, applyLinuxSetIconPatch, applyLinuxSingleInstancePatch, applyLinuxTrayCloseSettingPatch, applyLinuxTrayPatch, + applyLinuxWillQuitDrainTimeoutPatch, applyLinuxWindowOptionsPatch, isComputerUseUiEnabled, patchMainBundleSource, @@ -41,6 +50,9 @@ const { resolveDesktopName, resolveKeybindsSettingsAsset, } = require("./patch-linux-window-ui.js"); +const { + validateReport, +} = require("./ci/validate-patch-report.js"); const mainBundlePrefix = "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`);"; @@ -61,6 +73,19 @@ function applyPatchTwice(patchFn, source, ...args) { return patched; } +function captureWarns(fn) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => { + warnings.push(args.map(String).join(" ")); + }; + try { + return { value: fn(), warnings }; + } finally { + console.warn = originalWarn; + } +} + function trayBundleFixture() { return [ "async function Hw(e){return process.platform!==`win32`&&process.platform!==`darwin`?null:(zw=!0,Lw??Rw??(Rw=(async()=>{let r=await Ww(e.buildFlavor,e.repoRoot),i=new n.Tray(r.defaultIcon);return i})()))}", @@ -78,12 +103,22 @@ function singleInstanceBundleFixture() { ].join(""); } -function currentLaunchActionBundleFixture() { +function explicitQuitBundleFixture() { return [ - "let ae=e=>{e.isMinimized()&&e.restore(),e.show(),e.focus()},oe=async()=>{try{M.hotkeyWindowLifecycleManager.hide();let e=M.getPrimaryWindow(`local`)??await M.createFreshLocalWindow(`/`);if(e==null)return;ae(e)}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to open window on second instance`,{kind:`second-instance-open-window-failed`})}};", - "l(e=>{z.deepLinks.queueProcessArgs(e)||oe()});", - "let se=async(e,t)=>{M.hotkeyWindowLifecycleManager.hide();let n=M.getPrimaryWindow(B),r=n??await M.createFreshLocalWindow(e);r!=null&&(R.desktopNotificationManager.dismissByNavigationPath(e),n!=null&&t.navigateExistingWindow&&z.navigateToRoute(r,e),ae(r))};", - "w(`local window ensured`,A,{hostId:B,localWindowVisible:be?.isVisible()??!1}),A=Date.now(),await z.deepLinks.flushPendingDeepLinks()", + "var pb=class{getNativeTrayMenuItems(){return[{label:rB(this.appName),click:()=>{n.app.quit()}}]}};", + "if(o.type===`quit-app`){n.app.quit();return}", + ].join(""); +} + +function beforeQuitConfirmationBundleFixture() { + return [ + "n.app.on(`before-quit`,o=>{let s=BI(),c=t.sr().some(e=>e.status===`ACTIVE`);if(e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}let l=n.app.getName();if(n.dialog.showMessageBoxSync({type:`warning`,buttons:[`Quit`,`Cancel`],defaultId:0,cancelId:1,noLink:!0,title:`Quit ${l}?`,message:`Quit ${l}?`,detail:vB({hasInProgressLocalConversation:s,hasEnabledAutomations:c})})!==0){o.preventDefault();return}i.markQuitApproved(),g=!0,a.markAppQuitting()});", + ].join(""); +} + +function willQuitDrainBundleFixture() { + return [ + "n.app.on(`will-quit`,e=>{if(g=!0,!h){if(i.shouldSkipDrainBeforeQuit()){mB({hotkeyWindowLifecycleManager:c,globalDictationLifecycleManager:l,flushAndDisposeContexts:d,disposables:f});return}e.preventDefault(),h=!0,c.dispose(),l.dispose(),Promise.all([...u.values()].map(e=>e.flush())).finally(()=>{d(),f.dispose(),n.app.quit()})}});", ].join(""); } @@ -94,6 +129,13 @@ function computerUseGateBundleFixture() { ].join(""); } +function currentPluginGateBundleFixture() { + return [ + "var lt=`browser-use`,ut=`chrome`,dt=`chrome-internal`,ft=`computer-use`,pt=`latex-tectonic`;", + "var Kr=[{forceReload:!0,installWhenMissing:!0,name:lt,isAvailable:({features:e})=>e.inAppBrowserUseAllowed,migrate:rr},{forceReload:!0,name:dt,isAvailable:({buildFlavor:e,features:t})=>Qn(e)&&t.externalBrowserUseAllowed},{forceReload:!0,name:ut,isAvailable:({buildFlavor:e,features:t})=>t.externalBrowserUseAllowed&&$n(e)},{name:ft,isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:vr},{installWhenMissing:!0,name:ft,isAvailable:({buildFlavor:e,features:n,platform:r})=>t.T.isInternal(e)&&r===`win32`&&n.computerUse},{name:pt,isAvailable:()=>!0}];", + ].join(""); +} + function computerUseFeatureBundleFixture() { return "function me(e,{env:t=process.env,platform:n=process.platform}={}){return n!==`win32`||t.CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE!==`1`?e:{...e,computerUse:!0,computerUseNodeRepl:!0}}"; } @@ -110,6 +152,29 @@ function computerUseInstallFlowBundleFixture() { return "function Qe({forceReloadPlugins:e,hostId:t}){let ne=f({featureName:`computer_use`,hostId:t}),re=!ne.isLoading&&ne.enabled,[L,R]=(0,Z.useState)({});return re}"; } +function chromeExtensionStatusBundleFixture() { + return [ + "let r=require(`node:os`),i=require(`node:path`),o=require(`node:fs`);", + "var am=`com.google.Chrome`,om=`/usr/bin/open`,sm=/^[a-p]{32}$/;", + "function pm(e){if(!sm.test(e))throw Error(`Invalid extension id`);return e}", + "function cm(e){return`chrome://extensions/?id=${pm(e)}`}", + "function lm({extensionId:e,homeDir:t=(0,r.homedir)(),localAppDataDir:n=process.env.LOCALAPPDATA,platform:a=process.platform}){let s=pm(e),c=mm({homeDir:t,localAppDataDir:n,platform:a});return c==null||!(0,o.existsSync)(c)?!1:(0,o.readdirSync)(c,{withFileTypes:!0}).some(e=>e.isDirectory()&&(0,o.existsSync)((0,i.join)(c,e.name,`Extensions`,s)))}async function um({extensionId:e,platform:t=process.platform,detectChromeCommand:n=dm,runCommand:r=Hp}){if(t===`darwin`){await r(om,[`-b`,am,cm(e)]);return}if(t===`win32`){let t=n();if(t==null)throw Error(`Google Chrome is not installed`);await r(t,[cm(e)]);return}throw Error(`Opening Chrome extension settings is only supported on macOS and Windows`)}function dm(){return Rp(`google-chrome`)}", + "function mm({homeDir:e,localAppDataDir:t,platform:n}){return n===`darwin`?(0,i.join)(e,`Library`,`Application Support`,`Google`,`Chrome`):n===`win32`?(0,i.join)(t??(0,i.join)(e,`AppData`,`Local`),`Google`,`Chrome`,`User Data`):null}", + "function Rp(e){return e}async function Hp(){}", + ].join(""); +} + +function currentChromeExtensionStatusBundleFixture() { + return [ + "let r=require(`node:os`),i=require(`node:path`),o=require(`node:fs`);", + "var nm=`com.google.Chrome`,rm=`/usr/bin/open`,im=/^[a-p]{32}$/;", + "function am(e){return`chrome://extensions/?id=${um(e)}`}", + "function om({extensionId:e,homeDir:t=(0,r.homedir)(),localAppDataDir:n=process.env.LOCALAPPDATA,platform:a=process.platform}){let s=um(e),c=dm({homeDir:t,localAppDataDir:n,platform:a});return c==null||!(0,o.existsSync)(c)?!1:(0,o.readdirSync)(c,{withFileTypes:!0}).some(e=>e.isDirectory()&&(0,o.existsSync)((0,i.join)(c,e.name,`Extensions`,s)))}async function sm({extensionId:e,platform:t=process.platform,detectChromeCommand:n=cm,runCommand:r=zp}){if(t===`darwin`){await r(rm,[`-b`,nm,am(e)]);return}if(t===`win32`){let t=n();if(t==null)throw Error(`Google Chrome is not installed`);await r(t,[am(e)]);return}throw Error(`Opening Chrome extension settings is only supported on macOS and Windows`)}function cm(){return Fp(`chrome.exe`)}", + "function lm(){return null}function um(e){let t=e.trim();if(!im.test(t))throw Error(`Invalid Chrome extension id`);return t}function dm({homeDir:e,localAppDataDir:t,platform:n}){return n===`darwin`?(0,i.join)(e,`Library`,`Application Support`,`Google`,`Chrome`):n===`win32`?(0,i.join)(t??(0,i.join)(e,`AppData`,`Local`),`Google`,`Chrome`,`User Data`):null}", + "function Fp(e){return e}async function zp(){}", + ].join(""); +} + function currentLaunchActionBundleFixture() { return [ "const e={gr:e=>({default:e,...e})};let n=require(`electron`);let i=require(`node:path`);i=e.gr(i);let o=require(`node:fs`);o=e.gr(o);let f=require(`node:net`);f=e.gr(f);", @@ -120,7 +185,7 @@ function currentLaunchActionBundleFixture() { function keybindsIndexBundleFixture() { return [ "var Kge={\"general-settings\":xh,appearance:Pf,\"git-settings\":t1};", - "var i_e={\"general-settings\":(0,Q.lazy)(()=>it(()=>import(`./general-settings-DsLl9t6Z.js`),[],import.meta.url)),appearance:(0,Q.lazy)(()=>it(()=>import(`./appearance.js`),[],import.meta.url))};", + "var i_e={\"general-settings\":(0,Z.lazy)(()=>s(()=>import(`./general-settings-DsLl9t6Z.js`),[],import.meta.url)),appearance:(0,Z.lazy)(()=>s(()=>import(`./appearance.js`),[],import.meta.url))};", "qge=[`general-settings`,`appearance`,`connections`,`git-settings`,`usage`];", "Jge=[{key:`app`,heading:H7.appHeading,slugs:[`general-settings`,`appearance`,`connections`,`git-settings`,`usage`]}];", "switch(e){case`appearance`:case`git-settings`:case`worktrees`:case`local-environments`:case`data-controls`:case`environments`:return l===`electron`;}", @@ -174,6 +239,21 @@ function writeMinimalKeybindsSettingsAssets(tempRoot, overrides = {}) { return names; } +function appSunsetBundleFixture() { + return [ + "function IT(){return null}", + "function LT(e){let t=(0,Z.c)(3),{children:n}=e;if(ms(`2929582856`)){let e;return t[0]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,$.jsx)(IT,{}),t[0]=e):e=t[0],e}let r;return t[1]===n?r=t[2]:(r=(0,$.jsx)($.Fragment,{children:n}),t[1]=n,t[2]=r),r}", + ].join(""); +} + +function appSunsetBundleWithDriftingAliasFixture() { + return appSunsetBundleFixture().replace("if(ms(`2929582856`)){", "if(xs(`2929582856`)){"); +} + +function appSunsetBundleWithDriftingGateFixture() { + return appSunsetBundleFixture().replace("if(ms(`2929582856`)){", "if(ms?.(`2929582856`)){"); +} + function appUpdaterBundleFixture() { return [ "let t=require(`electron`),i=require(`node:path`),s=require(`node:fs`),u=require(`node:child_process`);", @@ -182,6 +262,38 @@ function appUpdaterBundleFixture() { ].join(""); } +function currentBootstrapUpdaterBundleFixture() { + return [ + "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`),u=require(`node:child_process`);", + "c({onUpdateReadyChanged:e=>{a.sendMessageToAllRegisteredWindows({type:`app-update-ready-changed`,isUpdateReady:e})}});", + "var rK={enabled:!1,running:!1,state:`disabled`};", + "async function iK(){", + "let{startedAtMs:r,buildFlavor:a,desktopSentry:o,sparkleManager:s,setSparkleBridgeHandlers:c,setSecondInstanceArgsHandler:l}=t.x(),d=t.T.shouldIncludeSparkle(a,process.platform,process.env);", + "let M=oG({});let ee=pB(),te=()=>{ee.allowQuitTemporarilyForUpdateInstall(),n.app.quit()};", + "c({onInstallProgressChanged:e=>{E&&M.sendMessageToAllRegisteredWindows({type:`app-update-install-progress-changed`,installProgressPercent:e})},onUpdateReadyChanged:e=>{M.sendMessageToAllRegisteredWindows({type:`app-update-ready-changed`,isUpdateReady:e})},onUpdateLifecycleStateChanged:e=>{M.sendMessageToAllRegisteredWindows({type:`app-update-lifecycle-state-changed`,lifecycleState:e})},onInstallUpdatesRequested:()=>{te()},isTrustedIpcEvent:N});", + "}", + ].join(""); +} + +function avatarOverlayBundleFixture() { + return [ + "var rV=`/avatar-overlay`,zB={width:356,height:320},oV={width:112,height:121},sV={width:276,height:131};", + "var fV=class{window=null;openingWindowPromise=null;anchor=pV({x:0,y:0,...zB},oV);dragState=null;layout=null;mascotSize=oV;momentumTimer=null;mousePassthroughEnabled=!1;placement=`top-end`;pointerInteractive=!1;rendererReady=!1;traySize=null;", + "constructor(e,t){this.windowManager=e,this.globalState=t}", + "isOpen(){let e=this.window;return e!=null&&!e.isDestroyed()&&e.isVisible()}", + "startDrag(e,{pointerWindowX:t,pointerWindowY:r}){let i=this.window;if(i==null||i.isDestroyed()||i.webContents.id!==e)return;this.cancelMomentum();let a=this.getLayout(i);this.dragState={pointerAnchorX:t-a.mascot.left,pointerAnchorY:r-a.mascot.top,hasMoved:!1,displayBounds:n.screen.getDisplayNearestPoint(n.screen.getCursorScreenPoint()).bounds}}", + "moveDrag(e){let t=this.window;t==null||t.isDestroyed()||t.webContents.id!==e||this.dragState==null||(this.cancelMomentum(),this.dragState.hasMoved=!0,this.moveDragToCurrentCursor(t))}", + "endDrag(e){let t=this.window;t==null||t.isDestroyed()||t.webContents.id!==e||(this.dragState?.hasMoved&&this.moveDragToCurrentCursor(t),this.dragState=null,this.reclampWindowToVisibleDisplay({shouldPersist:!0}))}", + "setElementSize(e,{mascot:t,tray:n}){let r=this.window;r==null||r.isDestroyed()||r.webContents.id!==e||(this.cancelMomentum(),this.anchor={...this.anchor,width:t.width,height:t.height},this.mascotSize=t,this.traySize=n,this.applyLayout(r))}", + "async createWindow(e){let t=await this.windowManager.createWindow({title:n.app.getName(),width:zB.width,height:zB.height,appearance:`avatarOverlay`,focusable:!1,show:!1,initialRoute:rV,hostId:this.windowManager.getHostIdForWebContents(e)??`local`});return this.window=t,this.rendererReady=this.windowManager.isWebContentsReady(t.webContents.id),this.dragState=null,this.layout=null,this.mascotSize=oV,this.mousePassthroughEnabled=!1,this.placement=`top-end`,this.pointerInteractive=!1,this.traySize=null,t.once(`ready-to-show`,()=>{t.isDestroyed()||!this.rendererReady||(this.showWindow(t),this.applyPointerInteractivityPolicy())}),t.on(`closed`,()=>{this.window===t&&(this.cancelMomentum(),this.window=null,this.dragState=null,this.layout=null,this.rendererReady=!1,this.pointerInteractive=!1,this.mousePassthroughEnabled=!1,this.globalState.set(Te,!1),this.broadcastOpenState())}),t}", + "applyLayout(e,t=n.screen.getDisplayNearestPoint(hV(this.anchor)).bounds){if(e.isDestroyed())return;let r=UB({anchor:this.anchor,displayBounds:t,mascotSize:this.mascotSize,previousPlacement:this.placement,traySize:this.traySize??sV});this.anchor=r.anchor,this.layout=r,this.placement=r.placement,this.setWindowBounds(e,r.windowBounds),this.sendLayoutToRenderer(e)}getLayout(e){if(this.layout??this.applyLayout(e),this.layout==null)throw Error(`Expected avatar overlay layout`);return this.layout}", + "showWindow(e){if(e.isDestroyed())return;let t=this.isOpen();e.moveTop(),e.showInactive(),!t&&this.isOpen()&&this.broadcastOpenState()}broadcastOpenState(){this.windowManager.sendMessageToAllRegisteredWindows({type:`avatar-overlay-open-state-changed`,isOpen:this.isOpen()})}", + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1;return}let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}", + "refreshCursorAtCurrentMousePosition(e){if(e.isDestroyed())return;let t=n.screen.getCursorScreenPoint(),r=e.getContentBounds(),i=t.x-r.x,a=t.y-r.y;i<0||a<0||i>r.width||a>r.height||e.webContents.sendInputEvent({type:`mouseMove`,x:i,y:a,movementX:0,movementY:0})}", + "};", + ].join(""); +} + test("adds Linux file manager support without relying on exact minified variable names", () => { const source = `${mainBundlePrefix}${fileManagerBundle}`; @@ -199,47 +311,113 @@ test("adds the Linux quit guard when electron/path/fs requires are split across const patched = applyPatchTwice(applyLinuxQuitGuardPatch, source); assert.match(patched, /let codexLinuxQuitInProgress=!1/); + assert.match(patched, /codexLinuxExplicitQuitApproved=!1/); assert.match(patched, /codexLinuxMarkQuitInProgress=\(\)=>\{codexLinuxQuitInProgress=!0\}/); + assert.match(patched, /codexLinuxPrepareForExplicitQuit=\(\)=>\{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress\(\)\}/); + assert.match(patched, /codexLinuxShouldBypassQuitPrompt=\(\)=>codexLinuxExplicitQuitApproved===!0/); assert.match(patched, /codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0/); }); -test("adds the Linux quit guard after the Electron import as a fallback", () => { - const source = "const e={gr:e=>({default:e,...e})};let n=require(`electron`);n=e.gr(n);"; +test("adds the Linux quit guard when only the Electron require is recognizable", () => { + const source = + "const e=require(`./app-session.js`);let t=require(`electron`);class WindowManager{}"; const patched = applyPatchTwice(applyLinuxQuitGuardPatch, source); - assert.match(patched, /let n=require\(`electron`\);n=e\.gr\(n\);let codexLinuxQuitInProgress=!1/); - assert.equal( - patched.match(/let codexLinuxQuitInProgress=!1/g)?.length ?? 0, - 1, - "quit state should be declared exactly once", - ); + assert.match(patched, /^let codexLinuxQuitInProgress=!1/); + assert.match(patched, /codexLinuxExplicitQuitApproved=!1/); + assert.match(patched, /codexLinuxMarkQuitInProgress=\(\)=>\{codexLinuxQuitInProgress=!0\}/); + assert.match(patched, /codexLinuxPrepareForExplicitQuit=\(\)=>\{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress\(\)\}/); + assert.match(patched, /codexLinuxShouldBypassQuitPrompt=\(\)=>codexLinuxExplicitQuitApproved===!0/); + assert.match(patched, /codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0/); + assert.equal((patched.match(/codexLinuxQuitInProgress=!1/g) ?? []).length, 1); }); -test("does not treat a nested quit guard as the module-scope Linux quit guard", () => { +test("upgrades the legacy Linux quit guard helper when re-patching older bundles", () => { const source = - "function boot(){let codexLinuxQuitInProgress=!1,codexLinuxIsQuitInProgress=()=>!0;}let n=require(`electron`);n=e.gr(n);"; + "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`);let codexLinuxQuitInProgress=!1,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0;var x=1;"; const patched = applyPatchTwice(applyLinuxQuitGuardPatch, source); - assert.match(patched, /let n=require\(`electron`\);n=e\.gr\(n\);let codexLinuxQuitInProgress=!1/); - assert.equal( - patched.match(/let codexLinuxQuitInProgress=!1/g)?.length ?? 0, - 2, - "nested stale guard plus module-scope guard should be present exactly once each", + assert.doesNotMatch(patched, /let codexLinuxQuitInProgress=!1,codexLinuxMarkQuitInProgress=\(\)=>\{codexLinuxQuitInProgress=!0\},codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0;/); + assert.match(patched, /codexLinuxPrepareForExplicitQuit=\(\)=>\{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress\(\)\}/); + assert.match(patched, /codexLinuxShouldBypassQuitPrompt=\(\)=>codexLinuxExplicitQuitApproved===!0/); +}); + +test("bypasses the upstream before-quit confirmation after a Linux explicit quit", () => { + const source = `${mainBundlePrefix}${beforeQuitConfirmationBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxExplicitQuitPromptBypassPatch, + applyLinuxQuitGuardPatch(source), + ); + + assert.match( + patched, + /if\(\(typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt\(\)\)\|\|e\|\|i\.canQuitWithoutPrompt\(\)\|\|r\|\|!s&&!c\)\{g=!0,a\.markAppQuitting\(\);return\}/, ); }); -test("adds the Linux quit guard when only the Electron require is recognizable", () => { +test("adds a bounded will-quit drain fallback for Linux explicit quit", () => { + const source = `${mainBundlePrefix}${willQuitDrainBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxWillQuitDrainTimeoutPatch, + applyLinuxQuitGuardPatch(source), + ); + + assert.match(patched, /codexLinuxExplicitQuitDrainTimeoutMs=3e3/); + assert.match(patched, /\(\(\)=>\{let codexLinuxFinalizeQuit=\(\)=>\{d\(\),f\.dispose\(\),n\.app\.quit\(\)\},codexLinuxDrainPromise=Promise\.all\(\[\.\.\.u\.values\(\)\]\.map\(e=>e\.flush\(\)\)\);/); + assert.match(patched, /if\(process\.platform===`linux`&&\(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress\(\)\)\)\{Promise\.race\(\[codexLinuxDrainPromise,new Promise\(e=>setTimeout\(e,typeof codexLinuxExplicitQuitDrainTimeoutMs===`number`\?codexLinuxExplicitQuitDrainTimeoutMs:3e3\)\)\]\)\.finally\(codexLinuxFinalizeQuit\);return\}/); + assert.doesNotMatch(patched, /\\`number\\`/); + assert.match(patched, /codexLinuxDrainPromise\.finally\(codexLinuxFinalizeQuit\)\}\)\(\)/); + assert.doesNotThrow(() => new Function(patched)); +}); + +test("marks Linux quit-in-progress for the tray quit path", () => { + const source = `${mainBundlePrefix}${explicitQuitBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxExplicitTrayQuitPatch, + applyLinuxQuitGuardPatch(source), + ); + + assert.match( + patched, + /\{label:rB\(this\.appName\),click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}/, + ); +}); + +test("marks Linux quit-in-progress for the quit-app IPC path", () => { + const source = `${mainBundlePrefix}${explicitQuitBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxExplicitIpcQuitPatch, + applyLinuxQuitGuardPatch(source), + ); + + assert.match( + patched, + /if\(o\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/, + ); +}); + +test("supports explicit tray quit patching when minified aliases drift", () => { const source = - "const e=require(`./app-session.js`);this.windowManager=require(`electron`);class WindowManager{}"; + "let x=require(`electron`);var q=class{getNativeTrayMenuItems(){return[{label:rB(this.appName),click:()=>{x.app.quit()}}]}};if(m.type===`quit-app`){x.app.quit();return}"; + const patched = applyPatchTwice(applyLinuxExplicitTrayQuitPatch, source); - const patched = applyPatchTwice(applyLinuxQuitGuardPatch, source); + assert.match( + patched, + /\{label:rB\(this\.appName\),click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),x\.app\.quit\(\)\}\}/, + ); +}); - assert.match(patched, /^let codexLinuxQuitInProgress=!1/); - assert.match(patched, /codexLinuxMarkQuitInProgress=\(\)=>\{codexLinuxQuitInProgress=!0\}/); - assert.match(patched, /codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0/); - assert.equal((patched.match(/codexLinuxQuitInProgress=!1/g) ?? []).length, 1); +test("supports explicit IPC quit patching when minified aliases drift", () => { + const source = + "let x=require(`electron`);var q=class{getNativeTrayMenuItems(){return[{label:rB(this.appName),click:()=>{x.app.quit()}}]}};if(m.type===`quit-app`){x.app.quit();return}"; + const patched = applyPatchTwice(applyLinuxExplicitIpcQuitPatch, source); + + assert.match( + patched, + /if\(m\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),x\.app\.quit\(\);return\}/, + ); }); test("adds Linux menu hiding next to Windows removeMenu calls", () => { @@ -267,6 +445,40 @@ test("uses the local transparent appearance predicate for Linux opaque backgroun assert.doesNotMatch(patched, /process\.platform===`linux`&&!gw\(t\)/); }); +test("adds Linux avatar overlay mouse passthrough recovery", () => { + const patched = applyPatchTwice( + applyLinuxAvatarOverlayMousePassthroughPatch, + avatarOverlayBundleFixture(), + ); + + assert.match(patched, /codexLinuxAvatarPassthroughRecoveryTimer/); + assert.match(patched, /codexLinuxStartAvatarPassthroughRecovery\(\)/); + assert.match(patched, /codexLinuxStopAvatarPassthroughRecovery\(\)/); + assert.match(patched, /codexLinuxSyncAvatarPointerInteractivity\(e\)/); + assert.match(patched, /codexLinuxBuildAvatarInputShape\(e\)/); + assert.match(patched, /codexLinuxApplyAvatarInputShape\(e\)/); + assert.match(patched, /typeof e\.setShape==`function`/); + assert.match(patched, /if\(t==null\)return null/); + assert.match(patched, /if\(t==null\)return!1;let n=JSON\.stringify\(t\)/); + assert.match(patched, /e\.setShape\(t\),this\.codexLinuxAvatarInputShapeKey=n;return!0/); + assert.match(patched, /return\[i\(t\.mascot\),i\(t\.tray\)\]\.filter\(Boolean\)/); + assert.match(patched, /process\.platform!==`linux`/); + assert.match(patched, /setInterval\(\(\)=>\{let e=this\.window/); + assert.match(patched, /\},32\)/); + assert.doesNotMatch(patched, /typeof e\.setShape==`function`\)return;this\.codexLinuxAvatarPassthroughRecoveryTimer=setInterval/); + assert.match(patched, /this\.dragState!=null/); + assert.match(patched, /this\.codexLinuxIsCursorInAvatarInteractiveRegion\(e\)/); + assert.match(patched, /catch\{t=!0\}/); + assert.match(patched, /this\.pointerInteractive=t/); + assert.match(patched, /displayBounds:n\.screen\.getDisplayNearestPoint\(n\.screen\.getCursorScreenPoint\(\)\)\.bounds\},process\.platform===`linux`&&\(this\.pointerInteractive=!0,this\.applyPointerInteractivityPolicy\(\)\)\}moveDrag\(e\)/); + assert.match(patched, /this\.dragState=null,this\.reclampWindowToVisibleDisplay\(\{shouldPersist:!0\}\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)/); + assert.match(patched, /this\.applyLayout\(r\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)/); + assert.match(patched, /this\.setWindowBounds\(e,r\.windowBounds\),this\.sendLayoutToRenderer\(e\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)/); + assert.match(patched, /e\.moveTop\(\),e\.showInactive\(\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)/); + assert.doesNotMatch(patched, /codexLinuxRecoverAvatarPointerInteractivity/); + assert.match(patched, /this\.window===t&&\(this\.codexLinuxStopAvatarPassthroughRecovery\(\),this\.codexLinuxAvatarInputShapeKey=null,this\.cancelMomentum\(\)/); +}); + test("adds Linux window icon handling when an icon asset is available", () => { const iconAsset = "app-test.png"; const iconPathExpression = "process.resourcesPath+`/../content/webview/assets/app-test.png`"; @@ -339,6 +551,7 @@ test("adds Linux tray support including the platform guard", () => { patched, /\(E\|\|process\.platform===`linux`&&\(typeof codexLinuxIsTrayEnabled!==`function`\|\|codexLinuxIsTrayEnabled\(\)\)\)&&oe\(\);/, ); + assert.doesNotMatch(patched, /process\.platform===`linux`&&codexLinuxIsTrayEnabled\(\)/); }); test("adds Linux tray support for current minified window and startup identifiers", () => { @@ -383,6 +596,20 @@ test("scopes dynamic tray startup matching to the tray initializer", () => { ); }); +test("migrates Linux tray startup patch to tolerate missing settings helper", () => { + const source = [ + "async function eN(e){let t=await Ww(e.buildFlavor,e.repoRoot),r=new n.Tray(t.defaultIcon);return r}", + "let ce$=async()=>{O=!0;try{await eN({buildFlavor:a,repoRoot:j.repoRoot})}catch(e){O=!1}};(E||process.platform===`linux`&&codexLinuxIsTrayEnabled())&&ce$();", + ].join(""); + + const patched = applyPatchTwice(applyLinuxTrayPatch, source, null); + + assert.match( + patched, + /\(E\|\|process\.platform===`linux`&&\(typeof codexLinuxIsTrayEnabled!==`function`\|\|codexLinuxIsTrayEnabled\(\)\)\)&&ce\$\(\);/, + ); +}); + test("scopes close-to-tray already-patched detection to the handler", () => { const source = [ "let unrelated=(process.platform===`win32`||process.platform===`linux`)&&x===`local`;", @@ -424,12 +651,6 @@ test("adds Linux launch actions through current setSecondInstanceArgsHandler bun ); const prewarmPatched = applyPatchTwice(applyLinuxHotkeyWindowPrewarmPatch, launchPatched); - assert.match(prewarmPatched, /codexLinuxHandleLaunchActionArgsFallback/); - assert.match(prewarmPatched, /CODEX_APP_LAUNCH_ACTION_SOCKET/); - assert.doesNotMatch(prewarmPatched, /CODEX_DESKTOP_LAUNCH_ACTION_SOCKET/); - assert.match(prewarmPatched, /e\.includes\(`--new-chat`\)\?\(await se/); - assert.match(prewarmPatched, /e\.includes\(`--quick-chat`\)\?\(await codexLinuxOpenQuickChat/); - assert.match(prewarmPatched, /process\.platform===`linux`&&codexLinuxPrewarmHotkeyWindow\(\),A=Date\.now\(\),await z\.deepLinks\.flushPendingDeepLinks\(\)/); assert.match(launchPatched, /codexLinuxGetSetting=e=>process\.platform!==`linux`\|\|j\.globalState\.get\(e\)!==!1/); assert.match(launchPatched, /codexLinuxStartLaunchActionSocket=\(\)=>/); assert.match(launchPatched, /f\.default\.createServer/); @@ -439,9 +660,7 @@ test("adds Linux launch actions through current setSecondInstanceArgsHandler bun assert.match(launchPatched, /e\.includes\(`--prompt-chat`\)/); assert.match(launchPatched, /e\.includes\(`--quick-chat`\)/); assert.match(launchPatched, /e\.includes\(`--new-chat`\)/); - assert.match(launchPatched, /codexLinuxBeforeQuitHandler=\(\)=>\{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\)\}/); - assert.match(launchPatched, /n\.app\.on\(`before-quit`,codexLinuxBeforeQuitHandler\)/); - assert.match(launchPatched, /process\.platform===`linux`&&\(n\.app\.on\(`before-quit`,codexLinuxBeforeQuitHandler\),k\.add\(\(\)=>\{n\.app\.off\(`before-quit`,codexLinuxBeforeQuitHandler\)\}\),codexLinuxStartLaunchActionSocket\(\)\);l\(e=>/); + assert.match(launchPatched, /process\.platform===`linux`&&codexLinuxStartLaunchActionSocket\(\);l\(e=>/); assert.doesNotMatch(launchPatched, /l\(e=>\{z\.deepLinks\.queueProcessArgs\(e\)\|\|oe\(\)\}\)/); assert.match( prewarmPatched, @@ -449,20 +668,6 @@ test("adds Linux launch actions through current setSecondInstanceArgsHandler bun ); }); -test("keeps semantic Linux launch-action fallback valid after comma expressions", () => { - const source = currentLaunchActionBundleFixture().replace( - "l(e=>{z.deepLinks.queueProcessArgs(e)||oe()});", - "E&&ce(),l(e=>{z.deepLinks.queueProcessArgs(e)||oe()});", - ); - - const launchPatched = applyPatchTwice(applyLinuxLaunchActionArgsPatch, source); - const patched = applyPatchTwice(applyLinuxHotkeyWindowPrewarmPatch, launchPatched); - - assert.doesNotMatch(patched, /,let codexLinuxGetSetting/); - assert.match(patched, /process\.platform===`linux`&&codexLinuxPrewarmHotkeyWindow\(\)/); - assert.doesNotThrow(() => new Function(patched)); -}); - test("adds Linux launch actions when captured window identifiers contain dollar signs", () => { const source = currentLaunchActionBundleFixture().replace( "let se=async(e,t)=>{M.hotkeyWindowLifecycleManager.hide();let n=M.getPrimaryWindow(B),r=n??await M.createFreshLocalWindow(e);r!=null&&(R.desktopNotificationManager.dismissByNavigationPath(e),n!=null&&t.navigateExistingWindow&&z.navigateToRoute(r,e),ae(r))};", @@ -474,12 +679,10 @@ test("adds Linux launch actions when captured window identifiers contain dollar assert.match(patched, /codexLinuxHandleLaunchActionArgs/); assert.match(patched, /z\.navigateToRoute\(r\$,e\),ae\(r\$\)/); assert.match(patched, /codexLinuxQuitInProgress=!1/); - assert.equal( - patched.match(/let codexLinuxQuitInProgress=!1/g)?.length ?? 0, - 1, - "quit state should be declared exactly once", - ); + assert.match(patched, /codexLinuxExplicitQuitApproved=!1/); assert.match(patched, /codexLinuxMarkQuitInProgress=\(\)=>\{codexLinuxQuitInProgress=!0\}/); + assert.match(patched, /codexLinuxPrepareForExplicitQuit=\(\)=>\{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress\(\)\}/); + assert.match(patched, /codexLinuxShouldBypassQuitPrompt=\(\)=>codexLinuxExplicitQuitApproved===!0/); assert.match(patched, /codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0/); assert.match(patched, /codexLinuxGetSetting=e=>/); assert.match(patched, /codexLinuxHandleLaunchActionArgs=async e=>/); @@ -491,32 +694,6 @@ test("adds Linux launch actions when captured window identifiers contain dollar assert.match(patched, /e\.includes\(`--quick-chat`\)/); assert.match(patched, /e\.includes\(`--prompt-chat`\)/); assert.match(patched, /e\.includes\(`--hotkey-window`\)/); - - const fullyPatched = applyPatchTwice(patchMainBundleSource, source, null); - assert.equal( - fullyPatched.match(/let codexLinuxQuitInProgress=!1/g)?.length ?? 0, - 1, - "full main-bundle patch should declare quit state exactly once", - ); -}); - -test("declares the Linux quit guard before top-level tray references", () => { - const source = `${mainBundlePrefix}${trayBundleFixture()}${currentLaunchActionBundleFixture()}`; - - const patched = applyPatchTwice(patchMainBundleSource, source, null); - - const declarationIndex = patched.indexOf("let codexLinuxQuitInProgress=!1"); - const trayReferenceIndex = patched.indexOf( - "process.platform===`linux`&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.setLinuxTrayContextMenu?.()", - ); - assert.notEqual(declarationIndex, -1, "quit guard declaration was not inserted"); - assert.notEqual(trayReferenceIndex, -1, "top-level tray reference was not patched"); - assert(declarationIndex < trayReferenceIndex, "quit guard must be module-scoped before tray references"); - assert.equal( - patched.match(/let codexLinuxQuitInProgress=!1/g)?.length ?? 0, - 1, - "full main-bundle patch should declare quit state exactly once", - ); }); test("skips the launch-action patch without throwing when upstream startup architecture changes", () => { @@ -588,6 +765,22 @@ test("chooses the nearest globalState alias for close-to-tray settings", () => { assert.doesNotMatch(patched, /stale\.globalState\.get\(`codex-linux-system-tray-enabled`\)/); }); +test("warns instead of throwing when the close-to-tray setting shape drifts", () => { + const source = [ + "let M=FM({buildFlavor:a,globalState:j.globalState,canHideLastLocalWindowToTray(){return O},disposables:k});", + "t.Mr().info(`Launching app`);", + ].join(""); + + const { value: patched, warnings } = captureWarns(() => + applyLinuxTrayCloseSettingPatch(source), + ); + + assert.equal(patched, source); + assert.deepEqual(warnings, [ + "WARN: Could not find Linux tray settings close gate needle — skipping tray setting patch", + ]); +}); + test("allows bundled Computer Use on Linux as well as macOS", () => { const patched = applyPatchTwice( applyLinuxComputerUsePluginGatePatch, @@ -606,8 +799,9 @@ test("adds Keybinds settings route after upstream minified variable drift", () = assert.match( patched, - /var i_e=\{keybinds:\(0,Q\.lazy\)\(\(\)=>it\(\(\)=>import\(`\.\/keybinds-settings-linux\.js`\)/, + /var i_e=\{keybinds:\(0,Z\.lazy\)\(\(\)=>s\(\(\)=>import\("\.\/keybinds-settings-linux\.js"\)/, ); + assert.doesNotMatch(patched, /typeof Ct==/); assert.match(patched, /var Kge=\{keybinds:xh,"general-settings":xh,/); assert.match(patched, /qge=\[`general-settings`,`keybinds`,`appearance`/); assert.match(patched, /slugs:\[`general-settings`,`keybinds`,`appearance`/); @@ -616,7 +810,21 @@ test("adds Keybinds settings route after upstream minified variable drift", () = assert.match(patched, /codexLinuxKeybindOverridesRuntime/); }); -test("adds Keybinds settings route when the route table starts with a spread", () => { +test("captures Keybinds settings route helper aliases after minifier renames", () => { + const fixture = keybindsIndexBundleFixture() + .replaceAll("Z.lazy", "R.lazy") + .replaceAll("s(()=>import", "u(()=>import"); + const patched = applyPatchTwice(applyKeybindsSettingsIndexPatch, fixture); + + assert.match( + patched, + /var i_e=\{keybinds:\(0,R\.lazy\)\(\(\)=>u\(\(\)=>import\("\.\/keybinds-settings-linux\.js"\)/, + ); + assert.doesNotMatch(patched, /Z\.lazy/); + assert.doesNotMatch(patched, /s\(\(\)=>import\("\.\/keybinds-settings-linux\.js"\)/); +}); + +test("adds Keybinds settings route to spread route maps", () => { const patched = applyPatchTwice( applyKeybindsSettingsIndexPatch, spreadKeybindsIndexBundleFixture(), @@ -624,9 +832,9 @@ test("adds Keybinds settings route when the route table starts with a spread", ( assert.match( patched, - /const allRoutes=\{\.\.\.base,keybinds:\(0,Q\.lazy\)\(\(\)=>it\(\(\)=>import\(`\.\/keybinds-settings-linux\.js`\)/, + /const allRoutes=\{\.\.\.base,keybinds:\(0,Z\.lazy\)\(\(\)=>s\(\(\)=>import\("\.\/keybinds-settings-linux\.js"\)/, ); - assert.match(patched, /"general-settings":\(0,Q\.lazy\)/); + assert.match(patched, /"general-settings":\(0,Z\.lazy\)/); assert.match(patched, /codexLinuxKeybindOverridesRuntime/); }); @@ -638,7 +846,7 @@ test("uses upstream Keyboard Shortcuts settings surface when available", () => { assert.match( patched, - /"keyboard-shortcuts":\(0,Q\.lazy\)\(\(\)=>it\(\(\)=>import\(`\.\/keybinds-settings-linux\.js`\),\[\],import\.meta\.url\)\),agent:/, + /"keyboard-shortcuts":\(0,Q\.lazy\)\(\(\)=>it\(\(\)=>import\("\.\/keybinds-settings-linux\.js"\),\[\],import\.meta\.url\)\),agent:/, ); assert.doesNotMatch(patched, /keybinds:\(0,Q\.lazy/); assert.match(patched, /"keyboard-shortcuts":ue/); @@ -689,10 +897,10 @@ test("builds Keybinds settings source from safe asset names", () => { const keybindsAsset = resolveKeybindsSettingsAsset(tempRoot); assert.equal(keybindsAsset.filePath, path.join(tempRoot, "webview", "assets", "keybinds-settings-linux.js")); - assert.ok(keybindsAsset.source.includes(`from".\\u002F${names.chunkAsset}"`)); - assert.ok(keybindsAsset.source.includes(`from".\\u002F${names.reactAsset}"`)); - assert.ok(keybindsAsset.source.includes(`from".\\u002F${names.jsxRuntimeAsset}"`)); - assert.ok(keybindsAsset.source.includes(`from".\\u002F${names.vscodeApiAsset}"`)); + assert.ok(keybindsAsset.source.includes(`from"./${names.chunkAsset}"`)); + assert.ok(keybindsAsset.source.includes(`from"./${names.reactAsset}"`)); + assert.ok(keybindsAsset.source.includes(`from"./${names.jsxRuntimeAsset}"`)); + assert.ok(keybindsAsset.source.includes(`from"./${names.vscodeApiAsset}"`)); assert.match(keybindsAsset.source, /\/\/# sourceMappingURL=keybinds-settings-linux\.js\.map\n$/); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -710,32 +918,60 @@ test("does not duplicate upstream Keyboard Shortcuts settings metadata", () => { assert.equal(applyKeybindsSettingsSharedPatch(shared), shared); }); +test("disables the upstream app sunset gate in the Linux wrapper webview", () => { + const patched = applyPatchTwice(applyLinuxAppSunsetPatch, appSunsetBundleFixture()); + + assert.match(patched, /if\(!1&&ms\(`2929582856`\)\)\{/); + assert.doesNotMatch(patched, /if\(ms\(`2929582856`\)\)\{/); +}); + +test("disables the upstream app sunset gate after minified alias drift", () => { + const patched = applyPatchTwice(applyLinuxAppSunsetPatch, appSunsetBundleWithDriftingAliasFixture()); + + assert.match(patched, /if\(!1&&xs\(`2929582856`\)\)\{/); + assert.doesNotMatch(patched, /if\(xs\(`2929582856`\)\)\{/); +}); + +test("warns when the app sunset key is present but the gate shape drifts", () => { + const { value: patched, warnings } = captureWarns(() => + applyLinuxAppSunsetPatch(appSunsetBundleWithDriftingGateFixture()), + ); + + assert.equal(patched, appSunsetBundleWithDriftingGateFixture()); + assert.deepEqual(warnings, [ + "WARN: Could not find app sunset gate needle — skipping Linux app sunset patch", + ]); +}); + test("adds Linux package updater behind the existing app updater manager", () => { const patched = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, appUpdaterBundleFixture()); assert.match(patched, /function codexLinuxReadUpdateState\(\)/); assert.match(patched, /function codexLinuxUpdateLifecycleState\(e\)/); assert.match(patched, /function codexLinuxAppUpdaterPath\(\)/); - assert.match(patched, /function codexLinuxAppLauncherPath\(\)/); - assert.match(patched, /async function codexLinuxShowUpdateMessage\(e,n\)/); + assert.match(patched, /async function codexLinuxShowUpdateMessage\(codexLinuxMessage,codexLinuxDetail\)/); 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, /install-ready\|\|exit \$\?/); - assert.doesNotMatch(patched, /WaitingForAppExit"&&continue;echo "\$s"\|grep -q "\^status: Installing"&&continue;"\$1" install-ready/); assert.match(patched, /grep -q "\^status: WaitingForAppExit"/); assert.match(patched, /status: Installing/); assert.match(patched, /grep -q "\^status: Installed"/); - assert.match(patched, /\("\$2" >\/dev\/null 2>&1 &\)/); - assert.match(patched, /exit 0;fi;done;exit 1/); - assert.match(patched, /codexLinuxAppUpdaterPath\(\),codexLinuxAppLauncherPath\(\)/); + assert.match(patched, /process\.env\.CODEX_APP_LAUNCHER_PATH\|\|`\/usr\/bin\/codex-app`/); + assert.match(patched, /\("\$2" >\/dev\/null 2>&1 &\);exit 0/); + assert.match(patched, /done;exit 1/); assert.match(patched, /detached:!0,stdio:`ignore`/); assert.match(patched, /codexLinuxInstallAfterQuit\(\);let e=setTimeout/); assert.match(patched, /t\.app\?\.quit\?\.\(\)/); assert.match(patched, /t\.app\?\.exit\?\.\(0\)/); assert.match(patched, /execFile\(codexLinuxAppUpdaterPath\(\),e/); + assert.match(patched, /async function codexLinuxProbeAppUpdater\(\)/); assert.match(patched, /codexLinuxRunAppUpdater\(\[`--help`\]\)/); + assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)/); + assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); + assert.doesNotMatch(patched, /codexLinuxRunAppUpdater\(\[`status`,`--json`\]\)/); + assert.match(patched, /await codexLinuxProbeAppUpdater\(\),e\(\)/); assert.match(patched, /if\(!this\.options\.enableUpdater&&process\.platform!==`linux`\)/); assert.match(patched, /process\.platform===`linux`\?await this\.initializeLinuxPackageUpdater\(\)/); assert.match(patched, /async initializeLinuxPackageUpdater\(\)/); @@ -748,28 +984,136 @@ test("adds Linux package updater behind the existing app updater manager", () => assert.match(patched, /if\(t\?\.status===`waiting_for_app_exit`\)/); }); -test("adds Linux package updater when the minified updater class name changes", () => { - const source = appUpdaterBundleFixture().replace("var tD=class{", "var qR=class{"); +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); - assert.match(patched, /function codexLinuxUpdateLifecycleState\(e\)/); - assert.match(patched, /async initializeLinuxPackageUpdater\(\)/); - assert.match(patched, /process\.platform===`linux`\?await this\.initializeLinuxPackageUpdater\(\)/); + assert.match(patched, /function unrelated\(\)\{i\(\);let o=1;return o\}/); + assert.match(patched, /await codexLinuxProbeAppUpdater\(\),e\(\)/); + assert.doesNotMatch(patched, /let s=!1,c=codexLinuxProbeAppUpdater/); + assert.doesNotMatch(patched, /getIsUpdateReady:\(\)=>s&&t/); }); -test("skips Linux updater bridge when Electron binding is absent", () => { - const source = appUpdaterBundleFixture().replace("let t=require(`electron`),", "let "); - const warnings = []; - const originalWarn = console.warn; - console.warn = (...args) => warnings.push(args.map(String).join(" ")); - try { - const patched = applyLinuxAppUpdaterBridgePatch(source); +test("adds Linux package updater to current bootstrap updater wiring", () => { + const patched = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, currentBootstrapUpdaterBundleFixture()); - assert.equal(patched, source); - assert.ok(warnings.some((warning) => warning.includes("Could not find updater bridge module bindings"))); - } finally { - console.warn = originalWarn; - } + assert.match(patched, /function codexLinuxCreatePackageAppUpdater\(/); + assert.match(patched, /codexLinuxPackageUpdateBridge=process\.platform===`linux`/); + assert.match(patched, /send:e=>M\.sendMessageToAllRegisteredWindows\(e\)/); + assert.doesNotMatch(patched, /send:e=>a\.sendMessageToAllRegisteredWindows\(e\)/); + assert.match(patched, /s=codexLinuxPackageUpdateBridge\.manager/); + assert.match(patched, /te=codexLinuxPackageUpdateBridge\.quitForUpdate/); + assert.match(patched, /async function codexLinuxProbeAppUpdater\(\)/); + assert.match(patched, /codexLinuxRunAppUpdater\(\[`--help`\]\)/); + assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); + assert.match(patched, /codexLinuxProbeAppUpdater\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}\)/); + 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, /codexLinuxRunAppUpdater\(\[`status`,`--json`\]\)/); +}); + +test("migrates already-patched bootstrap updater bridge to probe before enabling UI", () => { + const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); + const oldPatched = patched + .replace( + "let s=!1,c=codexLinuxProbeAppUpdater().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=", + ) + .replace( + "getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:`idle`,", + "getIsUpdateReady:()=>t,getUpdateLifecycleState:()=>n,", + ) + .replace( + "checkForUpdates:async()=>{if(!await c)return;n=`checking`,a();try{", + "checkForUpdates:async()=>{n=`checking`,a();try{", + ) + .replace( + "installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}", + "installUpdatesIfAvailable:async()=>{i();if(!t)return;", + ) + .replace( + "refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=`idle`;a()}", + "refresh:async()=>{try{await codexLinuxRefreshUpdateState()}catch{}i(),a()}", + ); + + const migrated = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, oldPatched); + + assert.match(migrated, /codexLinuxProbeAppUpdater\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}\)/); + assert.match(migrated, /getIsUpdateReady:\(\)=>s&&t/); + assert.match(migrated, /installUpdatesIfAvailable:async\(\)=>\{if\(!await c\)\{a\(\);return\}i\(\);if\(!t\)\{a\(\);return\}/); +}); + +test("migrates previous bootstrap updater bridge without leaving undefined probe state", () => { + const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); + const oldPatched = patched + .replace( + "async function codexLinuxProbeAppUpdater(){await codexLinuxRunAppUpdater([`--help`])}", + "", + ) + .replace( + "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}", + "", + ) + .replace( + ",s=!1,c=codexLinuxProbeAppUpdater().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=`idle`,a();return!1});let o=", + ";i();let o=", + ) + .replace( + "getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:`idle`,", + "getIsUpdateReady:()=>t,getUpdateLifecycleState:()=>n,", + ) + .replace( + "checkForUpdates:async()=>{if(!await c)return;n=`checking`,a();try{", + "checkForUpdates:async()=>{n=`checking`,a();try{", + ) + .replace( + "installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}", + "installUpdatesIfAvailable:async()=>{i();if(!t)return;", + ) + .replace( + "refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=`idle`;a()}", + "refresh:()=>{i(),a()}", + ); + + assert.doesNotMatch(oldPatched, /codexLinuxProbeAppUpdater/); + assert.doesNotMatch(oldPatched, /codexLinuxRefreshUpdateState/); + assert.match(oldPatched, /i\(\);let o=/); + + const migrated = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, oldPatched); + + assert.match(migrated, /async function codexLinuxProbeAppUpdater\(\)\{await codexLinuxRunAppUpdater\(\[`--help`\]\)\}/); + assert.match(migrated, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); + assert.match(migrated, /let s=!1,c=codexLinuxProbeAppUpdater\(\)\.then/); + assert.match(migrated, /getIsUpdateReady:\(\)=>s&&t/); + assert.match(migrated, /checkForUpdates:async\(\)=>\{if\(!await c\)return;n=`checking`/); + assert.match(migrated, /installUpdatesIfAvailable:async\(\)=>\{if\(!await c\)\{a\(\);return\}i\(\);if\(!t\)\{a\(\);return\}/); + assert.match(migrated, /refresh:async\(\)=>\{if\(await c\)\{try\{await codexLinuxRefreshUpdateState\(\)\}/); +}); + +test("migrates already-patched Linux updater bridge to probe without mutating refresh", () => { + const patched = applyLinuxAppUpdaterBridgePatch(appUpdaterBundleFixture()); + const oldPatched = patched + .replace( + "async function codexLinuxProbeAppUpdater(){await codexLinuxRunAppUpdater([`--help`])}", + "", + ) + .replace( + "async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}", + "async function codexLinuxRefreshUpdateState(){await codexLinuxRunAppUpdater([`status`,`--json`]);return codexLinuxReadUpdateState()}", + ) + .replace( + "await codexLinuxProbeAppUpdater(),e()", + "await codexLinuxRefreshUpdateState(),e()", + ); + + const migrated = applyPatchTwice(applyLinuxAppUpdaterBridgePatch, oldPatched); + + assert.match(migrated, /async function codexLinuxProbeAppUpdater\(\)\{await codexLinuxRunAppUpdater\(\[`--help`\]\)\}/); + assert.match(migrated, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); + assert.match(migrated, /await codexLinuxProbeAppUpdater\(\),e\(\)/); + assert.doesNotMatch(migrated, /codexLinuxRunAppUpdater\(\[`status`,`--json`\]\)/); }); test("migrates an already-patched Linux updater bridge to quit before install", () => { @@ -800,14 +1144,14 @@ 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, /\("\$2" >\/dev\/null 2>&1 &\)/); + assert.doesNotMatch(oldPatched, /\/usr\/bin\/codex-app/); const migrated = applyLinuxAppUpdaterBridgePatch(oldPatched); assert.match(migrated, /grep -q "\^status: Installed"/); - assert.match(migrated, /codexLinuxAppLauncherPath\(\)/); - assert.match(migrated, /\("\$2" >\/dev\/null 2>&1 &\)/); - assert.match(migrated, /exit 0;fi;done;exit 1/); + assert.match(migrated, /process\.env\.CODEX_APP_LAUNCHER_PATH\|\|`\/usr\/bin\/codex-app`/); + assert.match(migrated, /\("\$2" >\/dev\/null 2>&1 &\);exit 0/); + assert.match(migrated, /done;exit 1/); }); test("enables the existing app update menu on Linux", () => { @@ -823,9 +1167,6 @@ test("enables the existing app update menu on Linux", () => { test("patchLinuxAppUpdaterBridge scans build bundles and stays idempotent", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-update-bridge-test-")); - const warnings = []; - const originalWarn = console.warn; - console.warn = (...args) => warnings.push(args.map(String).join(" ")); try { const buildDir = path.join(tempRoot, ".vite", "build"); fs.mkdirSync(buildDir, { recursive: true }); @@ -844,9 +1185,7 @@ test("patchLinuxAppUpdaterBridge scans build bundles and stays idempotent", () = assert.deepEqual(second, { matched: 2, changed: 0 }); assert.match(manager, /initializeLinuxPackageUpdater/); assert.match(main, /\|\|process\.platform===`linux`/); - assert.ok(!warnings.some((warning) => warning.includes("initializeMacSparkle marker"))); } finally { - console.warn = originalWarn; fs.rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -942,16 +1281,121 @@ test("patches the current Computer Use gate without touching the Windows-interna assert.equal((patched.match(/installWhenMissing:!0,name:Ze/g) || []).length, 2); }); -test("skips the Computer Use gate when it is recognizable but unpatchable", () => { +test("patches the current isAvailable Computer Use gate shape", () => { + const source = currentPluginGateBundleFixture(); + + const patched = applyPatchTwice(applyLinuxComputerUsePluginGatePatch, source); + + assert.match(patched, /name:ft,isAvailable:\(\{features:e,platform:t\}\)=>\(t===`darwin`\|\|t===`linux`\)&&e\.computerUse,migrate:vr/); + assert.match(patched, /t\.T\.isInternal\(e\)&&r===`win32`&&n\.computerUse/); + assert.equal((patched.match(/installWhenMissing:!0,name:ft/g) || []).length, 2); +}); + +test("auto-installs the current Chrome plugin gate shape", () => { + const patched = applyPatchTwice( + applyLinuxChromePluginAutoInstallPatch, + currentPluginGateBundleFixture(), + ); + + assert.match( + patched, + /\{forceReload:!0,installWhenMissing:!0,name:ut,isAvailable:\(\{buildFlavor:e,features:t\}\)=>t\.externalBrowserUseAllowed&&\$n\(e\)\}/, + ); + assert.match(patched, /name:dt,isAvailable:\(\{buildFlavor:e,features:t\}\)=>Qn\(e\)&&t\.externalBrowserUseAllowed/); + assert.equal((patched.match(/installWhenMissing:!0,name:ut/g) || []).length, 1); + assert.equal((patched.match(/installWhenMissing:!0,name:dt/g) || []).length, 0); +}); + +test("keeps an already auto-installed Chrome plugin gate unchanged", () => { + const source = currentPluginGateBundleFixture().replace( + "{forceReload:!0,name:ut,isAvailable:", + "{forceReload:!0,installWhenMissing:!0,name:ut,isAvailable:", + ); + + assert.equal(applyPatchTwice(applyLinuxChromePluginAutoInstallPatch, source), source); +}); + +test("handles literal Chrome plugin gate names", () => { const source = - "var tn=`computer-use`;var x=[{name:tn,isEnabled:({features:e,platform:t})=>isMac(t)&&e.computerUse,migrate:wn}];"; + "var Kr=[{forceReload:!0,name:'chrome',isEnabled:({features:t})=>t.externalBrowserUseAllowed},{forceReload:!0,name:'chrome-internal',isEnabled:({features:t})=>t.externalBrowserUseAllowed}];"; + + const patched = applyPatchTwice(applyLinuxChromePluginAutoInstallPatch, source); + + assert.match(patched, /installWhenMissing:!0,name:'chrome'/); + assert.doesNotMatch(patched, /installWhenMissing:!0,name:'chrome-internal'/); +}); + +test("reports missing required Chrome plugin auto-install gate as upstream validation failure", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-missing-chrome-")); + try { + const buildDir = path.join(tempRoot, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, "main.js"), `${mainBundlePrefix}var plugins=[];`); + + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(tempRoot, { report })); + + const pluginGatePatch = report.patches.find((patch) => patch.name === "linux-chrome-plugin-auto-install"); + assert.equal(pluginGatePatch.status, "failed-required"); + assert.match(pluginGatePatch.reason, /Could not find Chrome plugin gate literal/); + assert.ok( + validateReport(report, "upstream-build").some((failure) => + failure.startsWith("linux-chrome-plugin-auto-install: failed-required"), + ), + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("patches Computer Use gates that use imported namespace constants", () => { + const source = [ + "var lt=`computer-use`;", + "var Ur=[{autoInstallOptOutKey:e.Nn(e.Dn),forceReload:!0,installWhenMissing:!0,name:e.Dn,isAvailable:({features:e})=>e.inAppBrowserUseAllowed,migrate:$n},{name:e.kn,isAvailable:({features:e,platform:t})=>t===`darwin`&&e.computerUse,migrate:mr},{installWhenMissing:!0,name:e.kn,isAvailable:({buildFlavor:e,features:n,platform:r})=>t.T.isInternal(e)&&r===`win32`&&n.computerUse},{name:e.An,isAvailable:()=>!0}];", + ].join(""); + const patched = applyPatchTwice(applyLinuxComputerUsePluginGatePatch, source); + + assert.match(patched, /installWhenMissing:!0,name:e\.kn,isAvailable:\(\{features:e,platform:t\}\)=>\(t===`darwin`\|\|t===`linux`\)&&e\.computerUse,migrate:mr/); + assert.match(patched, /t\.T\.isInternal\(e\)&&r===`win32`&&n\.computerUse/); + assert.equal((patched.match(/installWhenMissing:!0,name:e\.kn/g) || []).length, 2); +}); + +test("warns when the Computer Use gate is recognizable but unpatchable", () => { + const { value: patched, warnings } = captureWarns(() => + applyLinuxComputerUsePluginGatePatch("var tn=`computer-use`;var x=[{name:tn,isEnabled:({features:e,platform:t})=>isMac(t)&&e.computerUse,migrate:wn}];"), + ); + + assert.match(warnings.join("\n"), /Required Linux Computer Use plugin gate patch failed/); assert.equal( - applyLinuxComputerUsePluginGatePatch(source), - source, + patched, + "var tn=`computer-use`;var x=[{name:tn,isEnabled:({features:e,platform:t})=>isMac(t)&&e.computerUse,migrate:wn}];", ); }); +test("reports missing required Computer Use plugin gate as upstream validation failure", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-missing-computer-use-")); + try { + const buildDir = path.join(tempRoot, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, "main.js"), `${mainBundlePrefix}var plugins=[];`); + + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(tempRoot, { report })); + + const pluginGatePatch = report.patches.find((patch) => patch.name === "linux-computer-use-plugin-gate"); + assert.equal(pluginGatePatch.status, "failed-required"); + assert.match(pluginGatePatch.reason, /Could not find Computer Use plugin gate literal/); + assert.ok( + validateReport(report, "upstream-build").some((failure) => + failure.startsWith("linux-computer-use-plugin-gate: failed-required"), + ), + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test("enables Computer Use desktop features on Linux", () => { const patched = applyPatchTwice( applyLinuxComputerUseFeaturePatch, @@ -978,6 +1422,20 @@ test("shows Computer Use plugin UI on Linux without the upstream rollout flag", ); }); +test("shows current Computer Use plugin UI on Linux without the upstream rollout flag", () => { + const source = + "function g(e){return e===`macOS`||e===`windows`}" + + "function _(e){let t=(0,d.c)(8),{enabled:n,hostId:r,isHostLocal:i}=e,a=n===void 0?!0:n,{isLoading:o,platform:c}=u(),l=s(`1506311413`),f;t[0]===r?f=t[1]:(f={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=f);let p=h(f),m;t[2]===c?m=t[3]:(m=g(c),t[2]=c,t[3]=m);let _=a&&i&&l&&(o||m),v=_&&!o&&p.enabled&&!p.isLoading,y=_&&p.isLoading,b=_&&(o||p.isLoading),x;return x}"; + + const patched = applyPatchTwice(applyLinuxComputerUseRendererAvailabilityPatch, source); + + assert.match(patched, /function g\(e\)\{return e===`macOS`\|\|e===`windows`\|\|e===`linux`\}/); + assert.match( + patched, + /let _=a&&i&&\(c===`linux`\|\|l&&\(o\|\|m\)\),v=_&&!o&&\(c===`linux`\|\|p\.enabled\)&&!p\.isLoading,y=_&&c!==`linux`&&p\.isLoading,b=_&&\(o\|\|c!==`linux`&&p\.isLoading\),x;/, + ); +}); + test("allows Computer Use install flow on Linux", () => { const patched = applyPatchTwice( applyLinuxComputerUseInstallFlowPatch, @@ -990,6 +1448,18 @@ test("allows Computer Use install flow on Linux", () => { ); }); +test("allows current Computer Use install flow on Linux", () => { + const source = + "te=ne({featureName:`computer_use`,hostId:t}),z=B({hostId:t,isHostLocal:m}),ie=re({hostId:t,isHostLocal:m}),U=!te.isLoading&&te.enabled,G=z.available,oe=ie.available,"; + + const patched = applyPatchTwice(applyLinuxComputerUseInstallFlowPatch, source); + + assert.equal( + patched, + "te=ne({featureName:`computer_use`,hostId:t}),z=B({hostId:t,isHostLocal:m}),ie=re({hostId:t,isHostLocal:m}),U=!te.isLoading&&te.enabled||navigator.userAgent.includes(`Linux`),G=z.available,oe=ie.available,", + ); +}); + test("auto-approves the app-provided Browser Use node_repl bridge", () => { const source = "return{[`mcp_servers.${pt}`]:{command:i.nodeReplPath,args:[],startup_timeout_sec:120,env:{[dt]:l,[ft]:i.nodePath}}}"; @@ -1000,19 +1470,65 @@ test("auto-approves the app-provided Browser Use node_repl bridge", () => { assert.match(patched, /env:\{\[dt\]:l,\[ft\]:i\.nodePath/); }); -test("only auto-approves the Browser Use node_repl bridge", () => { +test("shows the Linux IAB panel after Browser Use creates or reuses a tab", () => { const source = - "return{other:{command:`node`,startup_timeout_sec:120,env:{FOO:`bar`}},[`mcp_servers.${pt}`]:{command:i.nodeReplPath,args:[],startup_timeout_sec:120,env:{[dt]:l,[ft]:i.nodePath}}}"; + "var CF=class{async createTabForBrowserUse(e){let t=this.getActiveBrowserUseTab(e,{assertCurrentPageAllowed:!1});if(t!=null)return await this.navigateTabToInitialPage(t),this.serializeTab(t);let n=this.getRequiredBrowserHost(e);n.setBrowserUseActive(!0,e.turnId);let r=await n.openPageForBrowserUse({startingUrl:this.initialPageUrl,turnId:e.turnId}),i=this.updateTabForPage(r,n.routeKey);return SF().info(`IAB_LIFECYCLE iab createTab mapped page to tab`,{}),this.markBrowserUseCommandForTab(e,i),this.selectedTabIdsByRouteKey.set(n.routeKey,i.cdpTabId),this.serializeTab(i)}};"; - const patched = applyPatchTwice(applyBrowserUseNodeReplApprovalPatch, source); + const patched = applyPatchTwice(applyLinuxBrowserUseIabVisibleOnCreatePatch, source); + + assert.match( + patched, + /this\.getRequiredBrowserHost\(e\)\.setBrowserVisibleForBrowserUse\(!0,e\.turnId\)/, + ); + assert.match(patched, /n\.setBrowserVisibleForBrowserUse\(!0,e\.turnId\)/); + assert.match(patched, /codexLinuxBrowserUseAutoVisible/); + assert.match( + patched, + /return \(\(\)=>\{try\{n\.setBrowserVisibleForBrowserUse\(!0,e\.turnId\)\}/, + ); +}); + +test("detects Chrome extension installation from Linux browser profiles", () => { + const patched = applyPatchTwice( + applyLinuxChromeExtensionStatusPatch, + chromeExtensionStatusBundleFixture(), + ); + assert.match(patched, /function codexLinuxChromeProfileRoots/); + assert.match(patched, /`BraveSoftware`,`Brave-Browser`/); + assert.match(patched, /`google-chrome-unstable`/); assert.match( patched, - /other:\{command:`node`,startup_timeout_sec:120,env:\{FOO:`bar`\}\}/, + /if\(a===`linux`\)return codexLinuxChromeHasExtension\(\{extensionId:e,homeDir:t,platform:a\}\)/, ); assert.match( patched, - /command:i\.nodeReplPath,args:\[\],startup_timeout_sec:120,tools:\{js:\{approval_mode:`approve`\}\},env:\{\[dt\]:l,\[ft\]:i\.nodePath/, + /if\(t===`linux`\)\{let o=codexLinuxChromeExtensionCommands\(\{extensionId:e,homeDir:process\.env\.HOME\?\?``,platform:t\}\),t=codexLinuxChromeCommand\(o\)\?\?n\(\);if\(t==null\)throw Error\(`Google Chrome, Brave, or Chromium is not installed`\);await r\(t,\[cm\(e\)\]\);return\}/, + ); + assert.match(patched, /process\.env\.PATH\?\?``/); + assert.match(patched, /commands:\[`google-chrome-beta`,`google-chrome`,`google-chrome-stable`\]/); + assert.doesNotMatch(patched, /function codexLinuxChromeCommand\(\)\{for\(let e of\[[^\]]+\]\)\{let t=Rp/); +}); + +test("detects Chrome extension installation after upstream minifier renames", () => { + const patched = applyPatchTwice( + applyLinuxChromeExtensionStatusPatch, + currentChromeExtensionStatusBundleFixture(), + ); + + assert.match(patched, /function codexLinuxChromeProfileRoots/); + assert.match(patched, /let r=um\(e\);for\(let e of codexLinuxChromeProfileRoots/); + assert.match(patched, /function codexLinuxChromeExtensionCommands/); + assert.match(patched, /function om\(\{extensionId:e,homeDir:t=\(0,r\.homedir\)\(\)/); + assert.match(patched, /c=dm\(\{homeDir:t,localAppDataDir:n,platform:a\}\)/); + assert.match( + patched, + /async function sm\(\{extensionId:e,platform:t=process\.platform,detectChromeCommand:n=cm,runCommand:r=zp\}\)/, + ); + assert.match(patched, /await r\(rm,\[`-b`,nm,am\(e\)\]\)/); + assert.match( + patched, + /if\(t===`linux`\)\{let o=codexLinuxChromeExtensionCommands\(\{extensionId:e,homeDir:process\.env\.HOME\?\?``,platform:t\}\),t=codexLinuxChromeCommand\(o\)\?\?n\(\);if\(t==null\)throw Error\(`Google Chrome, Brave, or Chromium is not installed`\);await r\(t,\[am\(e\)\]\);return\}/, ); }); @@ -1189,7 +1705,9 @@ test("patchMainBundleSource keeps non-icon patches active without an icon asset" const patched = applyPatchTwice(patchMainBundleSource, source, null); assert.match(patched, /codexLinuxQuitInProgress=!1/); + assert.match(patched, /codexLinuxExplicitQuitApproved=!1/); assert.match(patched, /codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0/); + assert.match(patched, /codexLinuxShouldBypassQuitPrompt=\(\)=>codexLinuxExplicitQuitApproved===!0/); assert.match(patched, /n\.app\.on\(`before-quit`,codexLinuxBeforeQuitHandler\)/); assert.match(patched, /process\.platform===`linux`&&k\.setMenuBarVisibility\(!1\)/); assert.match(patched, /linux:\{label:`File Manager`/); @@ -1206,14 +1724,17 @@ test("patchMainBundleSource keeps non-icon patches active without an icon asset" ); }); -test("adds keybind override runtime with escaped source literals", () => { - const source = "var Ct={newThread:`Ctrl+N`};"; +test("adds a fallback source for renderer git-origins requests without weakening other git operations", () => { + const source = + "handleVSCodeRequest(n,r,i,a,o){try{let s=r,c=this.handlers[s];if(typeof c!=`function`)throw Error(`${r} not implemented in the current Electron process. Restart Codex to load the latest Electron handlers.`);let l=()=>c({...a,origin:n,windowHostId:i});if(o==null){if(e.qt(r))throw Error(`Missing git operation source for ${r}`);return l()}return t.Gt({source:o,requestKind:r},l)}catch(e){throw e}}"; - const patched = applyPatchTwice(applyLinuxKeybindOverridesRuntimePatch, source); + const patched = applyPatchTwice(applyLinuxGitOriginsSourceFallbackPatch, source); - assert.match(patched, /let storageKey="codex-linux-keybind-overrides"/); - assert.match(patched, /codexLinuxKeybindOverridesRuntime\(\)/); - assert.doesNotMatch(patched, /JSON\.stringify\(linuxKeybindOverridesKey\)/); + assert.match( + patched, + /if\(r===`git-origins`\)return t\.Gt\(\{source:`linux_git_origins_missing_source_fallback`,requestKind:r\},l\)/, + ); + assert.match(patched, /throw Error\(`Missing git operation source for \$\{r\}`\)/); }); test("missing icon asset skips only icon patches", () => { @@ -1270,6 +1791,45 @@ test("missing icon asset skips only icon patches", () => { } }); +test("patchExtractedApp scans apps bundles for Computer Use availability when UI is enabled", () => { + withIsolatedHome(() => { + process.env[COMPUTER_USE_UI_ENV_VAR] = "1"; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-computer-use-apps-assets-test-")); + try { + const buildDir = path.join(tempRoot, ".vite", "build"); + const assetsDir = path.join(tempRoot, "webview", "assets"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + path.join(buildDir, "main.js"), + [ + mainBundlePrefix, + "process.platform===`win32`&&k.removeMenu(),", + alreadyOpaqueBackgroundBundle, + fileManagerBundle, + trayBundleFixture(), + singleInstanceBundleFixture(), + ].join(""), + ); + fs.writeFileSync( + path.join(assetsDir, "apps-current.js"), + "function g(e){return e===`macOS`||e===`windows`}" + + "function _(e){let t=(0,d.c)(8),{enabled:n,hostId:r,isHostLocal:i}=e,a=n===void 0?!0:n,{isLoading:o,platform:c}=u(),l=s(`1506311413`),f;t[0]===r?f=t[1]:(f={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=f);let p=h(f),m;t[2]===c?m=t[3]:(m=g(c),t[2]=c,t[3]=m);let _=a&&i&&l&&(o||m),v=_&&!o&&p.enabled&&!p.isLoading,y=_&&p.isLoading,b=_&&(o||p.isLoading),x;return x}", + ); + fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({ name: "codex" })); + + patchExtractedApp(tempRoot); + + assert.match( + fs.readFileSync(path.join(assetsDir, "apps-current.js"), "utf8"), + /let _=a&&i&&\(c===`linux`\|\|l&&\(o\|\|m\)\),v=_&&!o&&\(c===`linux`\|\|p\.enabled\)&&!p\.isLoading/, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + test("patchExtractedApp records a structured patch report", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-test-")); try { @@ -1289,10 +1849,6 @@ test("patchExtractedApp records a structured patch report", () => { ].join(""), ); fs.writeFileSync(path.join(assetsDir, "app-test.png"), ""); - fs.writeFileSync( - path.join(assetsDir, "code-theme-test.js"), - "opaqueWindows:e?.opaqueWindows??n.opaqueWindows,semanticColors:", - ); fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({ name: "codex" })); const report = createPatchReport(); @@ -1301,56 +1857,44 @@ test("patchExtractedApp records a structured patch report", () => { assert.equal(report.mainBundle, "main.js"); assert.equal(report.iconAsset, "app-test.png"); assert.equal(report.desktopName, "codex-app.desktop"); - assert.ok(report.patches.some((patch) => patch.name === "main-process-ui" && patch.status === "applied")); - assert.ok(report.patches.some((patch) => patch.name === "opaque-window-default-code-theme" && patch.status === "applied")); + assert.ok(report.patches.some((patch) => patch.name === "main-process-ui")); assert.ok(report.patches.some((patch) => patch.name === "keybinds-settings" && patch.status === "skipped-optional")); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); -test("patcher CLI writes --report-json output", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-cli-test-")); +test("patch report marks warned required asset patches as failed", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-warned-asset-")); try { - const buildDir = path.join(tempRoot, ".vite", "build"); const assetsDir = path.join(tempRoot, "webview", "assets"); - const reportPath = path.join(tempRoot, "reports", "patch-report.json"); - fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true }); - fs.writeFileSync( - path.join(buildDir, "main.js"), - [ - mainBundlePrefix, - "process.platform===`win32`&&k.removeMenu(),", - alreadyOpaqueBackgroundBundle, - fileManagerBundle, - trayBundleFixture(), - singleInstanceBundleFixture(), - ].join(""), - ); - fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({ name: "codex" })); + fs.writeFileSync(path.join(assetsDir, "index-test.js"), appSunsetBundleWithDriftingGateFixture()); - const result = spawnSync( - process.execPath, - [path.join(__dirname, "patch-linux-window-ui.js"), "--report-json", reportPath, tempRoot], - { encoding: "utf8" }, - ); + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(tempRoot, { report })); - assert.equal(result.status, 0, result.stderr); - const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); - assert.equal(report.mainBundle, "main.js"); - assert.ok(report.patches.some((patch) => patch.name === "main-process-ui")); + const sunsetPatch = report.patches.find((patch) => patch.name === "linux-app-sunset-gate"); + assert.equal(sunsetPatch.status, "failed-required"); + assert.match(sunsetPatch.reason, /Could not find app sunset gate needle/); + assert.ok( + validateReport(report, "upstream-build").some((failure) => + failure.startsWith("linux-app-sunset-gate: failed-required"), + ), + ); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); -test("patcher CLI writes --report-json output when patching fails", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-failure-test-")); +test("patcher CLI writes --report-json output", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-cli-test-")); try { const buildDir = path.join(tempRoot, ".vite", "build"); + const assetsDir = path.join(tempRoot, "webview", "assets"); const reportPath = path.join(tempRoot, "reports", "patch-report.json"); fs.mkdirSync(buildDir, { recursive: true }); + fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync( path.join(buildDir, "main.js"), [ @@ -1362,7 +1906,7 @@ test("patcher CLI writes --report-json output when patching fails", () => { singleInstanceBundleFixture(), ].join(""), ); - fs.writeFileSync(path.join(tempRoot, "package.json"), "{"); + fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({ name: "codex" })); const result = spawnSync( process.execPath, @@ -1370,12 +1914,10 @@ test("patcher CLI writes --report-json output when patching fails", () => { { encoding: "utf8" }, ); - assert.notEqual(result.status, 0); + assert.equal(result.status, 0, result.stderr); const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); - assert.match(report.fatalError, /SyntaxError|Expected property name|JSON/); - assert.ok( - report.patches.some((patch) => patch.name === "patcher-cli" && patch.status === "failed-required"), - ); + assert.equal(report.mainBundle, "main.js"); + assert.ok(report.patches.some((patch) => patch.name === "main-process-ui")); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/scripts/patches/chrome-plugin.js b/scripts/patches/chrome-plugin.js new file mode 100644 index 00000000..09afbf09 --- /dev/null +++ b/scripts/patches/chrome-plugin.js @@ -0,0 +1,73 @@ +"use strict"; + +function hasChromePluginLiteral(source) { + return /(?:`chrome`|"chrome"|'chrome')/.test(source); +} + +function isChromeNameExpr(nameExpr, chromeNameVar) { + return /^(?:`chrome`|"chrome"|'chrome')$/.test(nameExpr) || + nameExpr === chromeNameVar; +} + +function hasChromeAutoInstall(source, chromeNameVar) { + const namePatterns = [String.raw`\`chrome\``, "\"chrome\"", "'chrome'"]; + if (chromeNameVar != null) { + namePatterns.push(chromeNameVar); + } + return new RegExp(String.raw`installWhenMissing:!0,name:(?:${namePatterns.join("|")})`).test(source); +} + +function applyLinuxChromePluginAutoInstallPatch(currentSource) { + if (!hasChromePluginLiteral(currentSource)) { + console.warn( + "WARN: Could not find Chrome plugin gate literal — skipping Linux Chrome plugin auto-install patch", + ); + return currentSource; + } + + const chromeNameVar = currentSource.match(/([A-Za-z_$][\w$]*)=(?:`chrome`|"chrome"|'chrome')/)?.[1] ?? null; + const nameExpressionPattern = String.raw`(?:[A-Za-z_$][\w$]*|` + + String.raw`\`chrome\`|"chrome"|'chrome')`; + const gateRegex = + new RegExp(String.raw`\{([^{}]*?)(installWhenMissing:!0,)?name:(${nameExpressionPattern}),(isEnabled|isAvailable):\(\{([^}]*)\}\)=>([^{}]*?externalBrowserUseAllowed[^{}]*?)(,migrate:[A-Za-z_$][\w$]*)?\}`, "g"); + + let sawChromeGate = false; + let sawAlreadyInstalledGate = false; + const patched = currentSource.replace( + gateRegex, + (gateSource, prefix, installWhenMissing, nameExpr, availabilityProp, paramsText, expression, migrateSuffix = "") => { + if (!isChromeNameExpr(nameExpr, chromeNameVar)) { + return gateSource; + } + + sawChromeGate = true; + if (installWhenMissing != null || prefix.includes("installWhenMissing:!0")) { + sawAlreadyInstalledGate = true; + return gateSource; + } + + return `{${prefix}installWhenMissing:!0,name:${nameExpr},${availabilityProp}:({${paramsText}})=>${expression}${migrateSuffix}}`; + }, + ); + + if (patched !== currentSource || (sawChromeGate && sawAlreadyInstalledGate)) { + return patched; + } + + if (hasChromeAutoInstall(currentSource, chromeNameVar)) { + return currentSource; + } + + if (currentSource.includes("externalBrowserUseAllowed")) { + throw new Error("Required Linux Chrome plugin auto-install patch failed: could not enable bundled Chrome auto-install"); + } + + console.warn( + "WARN: Could not find Chrome plugin auto-install gate — skipping Linux Chrome plugin auto-install patch", + ); + return currentSource; +} + +module.exports = { + applyLinuxChromePluginAutoInstallPatch, +}; diff --git a/scripts/patches/computer-use.js b/scripts/patches/computer-use.js new file mode 100644 index 00000000..e0525981 --- /dev/null +++ b/scripts/patches/computer-use.js @@ -0,0 +1,267 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + CLOSE_GATE_PREFIX_LOOKBACK, + COMPUTER_USE_UI_ENV_VAR, + COMPUTER_USE_UI_SETTINGS_KEY, + DIRECT_HANDLER_PROXIMITY, + HANDLER_PREFIX_LOOKBACK, + TRAY_GUARD_LOOKAHEAD, + linuxSettingsKeys, +} = require("./shared.js"); + +// Computer Use has two postures: the bundled plugin gate is default-on Linux +// platform glue; the visible UI gates remain opt-in because they bypass rollout +// checks in upstream webview code. +function isComputerUseUiEnabled(env = process.env) { + if (env[COMPUTER_USE_UI_ENV_VAR] === "1") { + return true; + } + return readComputerUseUiSettingsFlag(env); +} + +function readComputerUseUiSettingsFlag(env) { + const settingsPath = computerUseUiSettingsPath(env); + if (settingsPath == null) { + return false; + } + try { + if (!fs.existsSync(settingsPath)) { + return false; + } + const raw = fs.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return false; + } + return parsed[COMPUTER_USE_UI_SETTINGS_KEY] === true; + } catch { + return false; + } +} + +function computerUseUiSettingsPath(env) { + const xdgConfig = env.XDG_CONFIG_HOME; + const home = env.HOME; + const configHome = (xdgConfig && xdgConfig.length > 0) + ? xdgConfig + : home + ? path.join(home, ".config") + : null; + return configHome == null ? null : path.join(configHome, "codex-app", "settings.json"); +} + +function parseDestructuredParamAliases(paramsText) { + const aliases = Object.create(null); + for (const rawPart of paramsText.split(",")) { + const part = rawPart.trim(); + const match = part.match(/^([A-Za-z_$][\w$]*)(?::([A-Za-z_$][\w$]*))?$/); + if (match != null) { + aliases[match[1]] = match[2] ?? match[1]; + } + } + return aliases; +} + +function buildComputerUseGate({ nameExpr, availabilityProp, featuresVar, platformVar, migrateVar }) { + return `{installWhenMissing:!0,name:${nameExpr},${availabilityProp}:({features:${featuresVar},platform:${platformVar}})=>(${platformVar}===\`darwin\`||${platformVar}===\`linux\`)&&${featuresVar}.computerUse,migrate:${migrateVar}}`; +} + +function hasComputerUseLiteral(source) { + return /(?:`computer-use`|"computer-use"|'computer-use')/.test(source); +} + +function isComputerUseNameExpr(nameExpr, computerUseNameVar) { + return /^(?:`computer-use`|"computer-use"|'computer-use')$/.test(nameExpr) || + nameExpr === computerUseNameVar || + /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/.test(nameExpr); +} + +function applyLinuxComputerUsePluginGatePatch(currentSource) { + if (!hasComputerUseLiteral(currentSource)) { + console.warn( + "WARN: Could not find Computer Use plugin gate literal — skipping Linux Computer Use plugin gate patch", + ); + return currentSource; + } + + const computerUseNameVar = currentSource.match(/([A-Za-z_$][\w$]*)=(?:`computer-use`|"computer-use"|'computer-use')/)?.[1] ?? null; + const nameExpressionPattern = String.raw`(?:[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?|` + + String.raw`\`computer-use\`|"computer-use"|'computer-use')`; + const gateRegex = + new RegExp(String.raw`\{(installWhenMissing:!0,)?name:(${nameExpressionPattern}),(isEnabled|isAvailable):\(\{([^}]*)\}\)=>([^{}]*?\.computerUse),migrate:([A-Za-z_$][\w$]*)\}`, "g"); + let sawEnabledGate = false; + let sawUnpatchableGate = false; + let match; + while ((match = gateRegex.exec(currentSource)) != null) { + const [gateSource, installWhenMissing, nameExpr, availabilityProp, paramsText, expression, migrateVar] = match; + if (!isComputerUseNameExpr(nameExpr, computerUseNameVar)) { + continue; + } + + const aliases = parseDestructuredParamAliases(paramsText); + const featuresVar = aliases.features; + const platformVar = aliases.platform; + if (featuresVar == null || platformVar == null) { + continue; + } + + const darwinOnlyExpression = `${platformVar}===\`darwin\`&&${featuresVar}.computerUse`; + const linuxExpression = `(${platformVar}===\`darwin\`||${platformVar}===\`linux\`)&&${featuresVar}.computerUse`; + if (installWhenMissing != null && expression === linuxExpression) { + sawEnabledGate = true; + continue; + } + if (expression === darwinOnlyExpression || expression === linuxExpression) { + const replacement = buildComputerUseGate({ nameExpr, availabilityProp, featuresVar, platformVar, migrateVar }); + return `${currentSource.slice(0, match.index)}${replacement}${currentSource.slice(match.index + gateSource.length)}`; + } + sawUnpatchableGate = true; + } + + if (sawEnabledGate && !sawUnpatchableGate) { + return currentSource; + } + + if (hasComputerUseLiteral(currentSource) && currentSource.includes("computerUse")) { + console.warn( + "WARN: Required Linux Computer Use plugin gate patch failed: could not enable bundled Computer Use on Linux", + ); + return currentSource; + } + + return currentSource; +} + +function applyLinuxComputerUseFeaturePatch(currentSource) { + const patchedFeaturePattern = + /function [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*,\{env:[A-Za-z_$][\w$]*=process\.env,platform:[A-Za-z_$][\w$]*=process\.platform\}=\{\}\)\{return [A-Za-z_$][\w$]*===`linux`\?\{\.\.\.[A-Za-z_$][\w$]*,computerUse:!0,computerUseNodeRepl:!0\}:/; + const windowsOnlyFeaturePattern = + /function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*),\{env:([A-Za-z_$][\w$]*)=process\.env,platform:([A-Za-z_$][\w$]*)=process\.platform\}=\{\}\)\{return \4!==`win32`\|\|\3\.CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE!==`1`\?\2:\{\.\.\.\2,computerUse:!0,computerUseNodeRepl:!0\}\}/; + + if (patchedFeaturePattern.test(currentSource)) { + return currentSource; + } + + if (windowsOnlyFeaturePattern.test(currentSource)) { + return currentSource.replace( + windowsOnlyFeaturePattern, + (_, fnName, featuresVar, envVar, platformVar) => + `function ${fnName}(${featuresVar},{env:${envVar}=process.env,platform:${platformVar}=process.platform}={}){return ${platformVar}===\`linux\`?{...${featuresVar},computerUse:!0,computerUseNodeRepl:!0}:${platformVar}!==\`win32\`||${envVar}.CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE!==\`1\`?${featuresVar}:{...${featuresVar},computerUse:!0,computerUseNodeRepl:!0}}`, + ); + } + + if (currentSource.includes("CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE")) { + console.warn( + "WARN: Could not find Computer Use desktop feature gate — skipping Linux Computer Use feature patch", + ); + } + + return currentSource; +} + +function applyLinuxComputerUseRendererAvailabilityPatch(currentSource) { + let patchedSource = currentSource; + + const platformPredicateNeedle = "function hae(e){return e===`macOS`||e===`windows`}"; + const platformPredicatePatch = + "function hae(e){return e===`macOS`||e===`windows`||e===`linux`}"; + const currentPlatformPredicateNeedle = + /function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{return \2===`macOS`\|\|\2===`windows`\}/; + const currentPlatformPredicatePatch = (_, fnName, platformVar) => + `function ${fnName}(${platformVar}){return ${platformVar}===\`macOS\`||${platformVar}===\`windows\`||${platformVar}===\`linux\`}`; + if (patchedSource.includes(platformPredicatePatch)) { + // Already patched. + } else if (patchedSource.includes(platformPredicateNeedle)) { + patchedSource = patchedSource.replace(platformPredicateNeedle, platformPredicatePatch); + } else if (currentPlatformPredicateNeedle.test(patchedSource)) { + patchedSource = patchedSource.replace(currentPlatformPredicateNeedle, currentPlatformPredicatePatch); + } + + const availabilityNeedle = + "let m=a&&i&&s===`electron`&&u&&(c||p),h=m&&!c&&f.enabled&&!f.isLoading,g=m&&f.isLoading,_=m&&(c||f.isLoading),v;"; + const availabilityHostLocalLinuxPatch = + "let m=a&&i&&s===`electron`&&(l===`linux`||u&&(c||p)),h=m&&!c&&(l===`linux`||f.enabled)&&!f.isLoading,g=m&&l!==`linux`&&f.isLoading,_=m&&(c||l!==`linux`&&f.isLoading),v;"; + const availabilityPatch = + "let m=a&&(i||l===`linux`)&&s===`electron`&&(l===`linux`||u&&(c||p)),h=m&&!c&&(l===`linux`||f.enabled)&&!f.isLoading,g=m&&l!==`linux`&&f.isLoading,_=m&&(c||l!==`linux`&&f.isLoading),v;"; + if (patchedSource.includes(availabilityPatch)) { + return patchedSource; + } + + if (patchedSource.includes(availabilityHostLocalLinuxPatch)) { + return patchedSource.replace(availabilityHostLocalLinuxPatch, availabilityPatch); + } + + if (patchedSource.includes(availabilityNeedle)) { + return patchedSource.replace(availabilityNeedle, availabilityPatch); + } + + const currentAvailabilityNeedle = + "let _=a&&i&&l&&(o||m),v=_&&!o&&p.enabled&&!p.isLoading,y=_&&p.isLoading,b=_&&(o||p.isLoading),x;"; + const currentAvailabilityPatch = + "let _=a&&i&&(c===`linux`||l&&(o||m)),v=_&&!o&&(c===`linux`||p.enabled)&&!p.isLoading,y=_&&c!==`linux`&&p.isLoading,b=_&&(o||c!==`linux`&&p.isLoading),x;"; + if (patchedSource.includes(currentAvailabilityPatch)) { + return patchedSource; + } + + if (patchedSource.includes(currentAvailabilityNeedle)) { + return patchedSource.replace(currentAvailabilityNeedle, currentAvailabilityPatch); + } + + if (currentSource.includes("featureName:`computer_use`") && currentSource.includes("isComputerUseAvailable")) { + console.warn( + "WARN: Could not find Computer Use renderer availability gate — skipping Linux Computer Use UI availability patch", + ); + } + + return patchedSource; +} + +function applyLinuxComputerUseInstallFlowPatch(currentSource) { + const availabilityNeedle = + "ne=f({featureName:`computer_use`,hostId:t}),re=!ne.isLoading&&ne.enabled,"; + const availabilityPatch = + "ne=f({featureName:`computer_use`,hostId:t}),re=!ne.isLoading&&ne.enabled||navigator.userAgent.includes(`Linux`),"; + const currentAvailabilityPattern = + /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\{featureName:`computer_use`,hostId:([^}]+)\}\),([^;]{0,300}?)([A-Za-z_$][\w$]*)=!\1\.isLoading&&\1\.enabled,/; + + if (currentSource.includes(availabilityPatch)) { + return currentSource; + } + + if (currentSource.includes(availabilityNeedle)) { + return currentSource.replace(availabilityNeedle, availabilityPatch); + } + + if (/=[^=]+\.isLoading&&[^=]+\.enabled\|\|navigator\.userAgent\.includes\(`Linux`\),/.test(currentSource)) { + return currentSource; + } + + if (currentAvailabilityPattern.test(currentSource)) { + return currentSource.replace( + currentAvailabilityPattern, + (_, queryVar, queryFn, hostExpr, between, availableVar) => + `${queryVar}=${queryFn}({featureName:\`computer_use\`,hostId:${hostExpr}}),${between}${availableVar}=!${queryVar}.isLoading&&${queryVar}.enabled||navigator.userAgent.includes(\`Linux\`),`, + ); + } + + if (currentSource.includes("featureName:`computer_use`")) { + console.warn( + "WARN: Could not find Computer Use install flow gate — skipping Linux Computer Use install flow patch", + ); + } + + return currentSource; +} + +module.exports = { + COMPUTER_USE_UI_ENV_VAR, + COMPUTER_USE_UI_SETTINGS_KEY, + applyLinuxComputerUseFeaturePatch, + applyLinuxComputerUseInstallFlowPatch, + applyLinuxComputerUsePluginGatePatch, + applyLinuxComputerUseRendererAvailabilityPatch, + isComputerUseUiEnabled, +}; diff --git a/scripts/patches/keybinds-settings.js b/scripts/patches/keybinds-settings.js new file mode 100644 index 00000000..4167ef50 --- /dev/null +++ b/scripts/patches/keybinds-settings.js @@ -0,0 +1,514 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + findImportedAsset, + findRequiredWebviewAsset, + keybindsSettingsAsset, + linuxKeybindOverridesKey, + linuxSettingsKeys, +} = require("./shared.js"); + +// Keybind settings are transactional: either all dependent webview assets are +// updated together, or the patch logs a warning and leaves the app usable. +const defaultShortcuts = { + newThread: "CmdOrCtrl+N", + quickChat: "CmdOrCtrl+Shift+K", + newThreadAlt: "CmdOrCtrl+Shift+N", + openFolder: "CmdOrCtrl+O", + settings: "CmdOrCtrl+,", + openCommandMenu: "CmdOrCtrl+K", + openCommandMenuAlt: "CmdOrCtrl+Shift+P", + searchChats: "CmdOrCtrl+Shift+F", + searchFiles: "CmdOrCtrl+P", + findInThread: "CmdOrCtrl+F", + toggleSidebar: "CmdOrCtrl+B", + toggleTerminal: "Ctrl+`", + toggleFileTreePanel: "CmdOrCtrl+Shift+E", + toggleBrowserPanel: "CmdOrCtrl+Shift+B", + toggleDiffPanel: "CmdOrCtrl+Shift+D", +}; + +function requireBundleIdentifier(value, label) { + if (!/^[A-Za-z_$][\w$]*$/.test(value)) { + throw new Error(`Required Keybinds settings patch failed: invalid ${label} identifier`); + } + return value; +} + +const unsafeJavaScriptLiteralChars = /[<>\u2028\u2029]/g; +const unsafeJavaScriptLiteralEscapes = { + "<": "\\u003C", + ">": "\\u003E", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; +const safeWebviewJavaScriptAssetName = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/; + +function jsStringLiteral(value) { + return JSON.stringify(String(value)).replace( + unsafeJavaScriptLiteralChars, + (char) => unsafeJavaScriptLiteralEscapes[char], + ); +} + +function jsDataLiteral(value, description) { + const literal = JSON.stringify(value); + if (typeof literal !== "string") { + throw new Error(`Required Keybinds settings patch failed: invalid ${description}`); + } + return literal.replace( + unsafeJavaScriptLiteralChars, + (char) => unsafeJavaScriptLiteralEscapes[char], + ); +} + +function assertSafeWebviewJavaScriptAssetName(value, description) { + if (typeof value !== "string" || !safeWebviewJavaScriptAssetName.test(value)) { + throw new Error(`Required Keybinds settings patch failed: unsafe ${description} ${JSON.stringify(value)}`); + } + return value; +} + +function jsModuleSpecifier(assetName, description) { + return jsStringLiteral(`./${assertSafeWebviewJavaScriptAssetName(assetName, description)}`); +} + +function requireRouteKey(value, label) { + const quotedRouteKey = /^"[A-Za-z0-9_-]+":$/; + const identifierRouteKey = /^[A-Za-z_$][\w$]*:$/; + if (!quotedRouteKey.test(value) && !identifierRouteKey.test(value)) { + throw new Error(`Required Keybinds settings patch failed: invalid ${label} route key`); + } + return value; +} + +function buildKeybindsSettingsSource({ + chunkAsset, + reactAsset, + reactExportName = "t", + jsxRuntimeAsset, + vscodeApiAsset, + hotkeySettingsAsset, + toggleAsset, + settingsRowAsset, + settingsPageAsset, + settingsPageExportName = "t", + settingsSectionAsset, + settingsSectionExportName = "r", + settingsGroupAsset, + settingsGroupExportName = "n", +}) { + const imports = { + chunk: jsModuleSpecifier(chunkAsset, "React shared chunk asset"), + react: jsModuleSpecifier(reactAsset, "React asset"), + jsxRuntime: jsModuleSpecifier(jsxRuntimeAsset, "JSX runtime asset"), + vscodeApi: jsModuleSpecifier(vscodeApiAsset, "VS Code API asset"), + hotkeySettings: jsModuleSpecifier(hotkeySettingsAsset, "hotkey settings asset"), + toggle: jsModuleSpecifier(toggleAsset, "toggle asset"), + settingsRow: jsModuleSpecifier(settingsRowAsset, "settings row asset"), + settingsPage: jsModuleSpecifier(settingsPageAsset, "settings page asset"), + settingsSection: jsModuleSpecifier(settingsSectionAsset, "settings section asset"), + settingsGroup: jsModuleSpecifier(settingsGroupAsset, "settings group asset"), + }; + const reactImport = reactAsset === jsxRuntimeAsset + ? `import{${reactExportName} as __reactFactory,t as __jsxFactory}from${imports.jsxRuntime};` + : `import{${reactExportName} as __reactFactory}from${imports.react};import{t as __jsxFactory}from${imports.jsxRuntime};`; + const keybindGroups = [ + { + title: "Core", + actions: [ + { id: "newThread", label: "New chat", description: "Start a new chat." }, + { id: "quickChat", label: "Quick chat", description: "Open a quick chat window." }, + { id: "newThreadAlt", label: "New chat alternate", description: "Alternate shortcut for a new chat." }, + { id: "openFolder", label: "Open folder", description: "Open a workspace folder." }, + { id: "settings", label: "Settings", description: "Open settings." }, + { id: "openCommandMenu", label: "Command menu", description: "Open the command menu." }, + { id: "openCommandMenuAlt", label: "Command menu alternate", description: "Alternate shortcut for the command menu." }, + { id: "searchChats", label: "Search chats", description: "Search existing chats." }, + { id: "searchFiles", label: "Search files", description: "Search files in the current workspace." }, + ], + }, + { + title: "Thread", + actions: [ + { id: "findInThread", label: "Find in thread", description: "Search inside the current thread." }, + { id: "copyConversationPath", label: "Copy conversation path", description: "Copy the current conversation path." }, + { id: "toggleThreadPin", label: "Toggle thread pin", description: "Pin or unpin the current thread." }, + { id: "renameThread", label: "Rename thread", description: "Rename the current thread." }, + { id: "archiveThread", label: "Archive thread", description: "Archive the current thread." }, + { id: "copyWorkingDirectory", label: "Copy working directory", description: "Copy the current working directory." }, + { id: "copySessionId", label: "Copy session ID", description: "Copy the current session ID." }, + { id: "copyDeeplink", label: "Copy deeplink", description: "Copy a deeplink for the current thread." }, + { id: "previousThread", label: "Previous thread", description: "Move to the previous thread." }, + { id: "nextThread", label: "Next thread", description: "Move to the next thread." }, + { id: "thread1", label: "Thread 1", description: "Jump to thread slot 1." }, + { id: "thread2", label: "Thread 2", description: "Jump to thread slot 2." }, + { id: "thread3", label: "Thread 3", description: "Jump to thread slot 3." }, + { id: "thread4", label: "Thread 4", description: "Jump to thread slot 4." }, + { id: "thread5", label: "Thread 5", description: "Jump to thread slot 5." }, + { id: "thread6", label: "Thread 6", description: "Jump to thread slot 6." }, + { id: "thread7", label: "Thread 7", description: "Jump to thread slot 7." }, + { id: "thread8", label: "Thread 8", description: "Jump to thread slot 8." }, + { id: "thread9", label: "Thread 9", description: "Jump to thread slot 9." }, + ], + }, + { + title: "Panels", + actions: [ + { id: "toggleSidebar", label: "Toggle sidebar", description: "Show or hide the sidebar." }, + { id: "toggleTerminal", label: "Toggle terminal", description: "Show or hide the terminal." }, + { id: "toggleFileTreePanel", label: "Toggle file tree", description: "Show or hide the file tree." }, + { id: "openBrowserTab", label: "Open browser tab", description: "Open a browser tab." }, + { id: "reloadBrowserPage", label: "Reload browser page", description: "Reload the active browser page." }, + { id: "hardReloadBrowserPage", label: "Hard reload browser page", description: "Hard reload the active browser page." }, + { id: "toggleBrowserPanel", label: "Toggle browser panel", description: "Show or hide the browser panel." }, + { id: "toggleDiffPanel", label: "Toggle review panel", description: "Show or hide the review panel." }, + ], + }, + { + title: "System", + actions: [ + { id: "dictation", label: "Dictation", description: "Start dictation." }, + ], + }, + ]; + + return `import{s as __toESM}from${imports.chunk};${reactImport}import{n as __post}from${imports.vscodeApi};import{i as HotkeyWindowHotkeyRow}from${imports.hotkeySettings};import{t as Toggle}from${imports.toggle};import{n as SettingsRow}from${imports.settingsRow};import{${settingsSectionExportName} as SettingsSection}from${imports.settingsSection};import{${settingsGroupExportName} as SettingsGroup}from${imports.settingsGroup};import{${settingsPageExportName} as SettingsPage}from${imports.settingsPage};var React=__toESM(__reactFactory(),1),$=__jsxFactory(),KEYS={promptWindow:${jsStringLiteral(linuxSettingsKeys.promptWindow)},systemTray:${jsStringLiteral(linuxSettingsKeys.systemTray)},warmStart:${jsStringLiteral(linuxSettingsKeys.warmStart)}},KEYBIND_OVERRIDES_KEY=${jsStringLiteral(linuxKeybindOverridesKey)},DEFAULT_SHORTCUTS=${jsDataLiteral(defaultShortcuts, "default shortcuts")},KEYBIND_GROUPS=${jsDataLiteral(keybindGroups, "keybind groups")};function normalizeOverrides(value){if(!value||typeof value!="object"||Array.isArray(value))return{};return Object.fromEntries(Object.entries(value).filter(([key,accelerator])=>typeof key=="string"&&typeof accelerator=="string"&&accelerator.trim().length>0).map(([key,accelerator])=>[key,accelerator.trim()]))}function readLocalOverrides(){try{return normalizeOverrides(JSON.parse(localStorage.getItem(KEYBIND_OVERRIDES_KEY)||"{}"))}catch{return{}}}function writeLocalOverrides(next){try{localStorage.setItem(KEYBIND_OVERRIDES_KEY,JSON.stringify(next)),window.dispatchEvent(new CustomEvent("codex-linux-keybind-overrides-changed",{detail:next}))}catch{}}function useKeybindOverrides(){let[overrides,setOverrides]=React.useState(()=>readLocalOverrides()),[error,setError]=React.useState(null);React.useEffect(()=>{let alive=!0;__post("get-global-state",{params:{key:KEYBIND_OVERRIDES_KEY}}).then(result=>{if(!alive)return;let next=normalizeOverrides(result?.value);Object.keys(next).length>0?(setOverrides(next),writeLocalOverrides(next)):setOverrides(readLocalOverrides());setError(null)}).catch(err=>{alive&&setError(err instanceof Error?err.message:String(err))});return()=>{alive=!1}},[]);let update=React.useCallback((actionId,accelerator)=>{setOverrides(previous=>{let next={...previous},defaultValue=typeof DEFAULT_SHORTCUTS[actionId]=="string"?DEFAULT_SHORTCUTS[actionId]:"",trimmed=String(accelerator??"").trim();trimmed.length===0||trimmed===defaultValue?delete next[actionId]:next[actionId]=trimmed;writeLocalOverrides(next);__post("set-global-state",{params:{key:KEYBIND_OVERRIDES_KEY,value:next}}).then(()=>setError(null)).catch(err=>setError(err instanceof Error?err.message:String(err)));return next})},[]);return{overrides,error,update}}function useLinuxSetting(key,defaultValue){let[value,setValue]=React.useState(defaultValue),[isLoading,setIsLoading]=React.useState(!0),[error,setError]=React.useState(null);React.useEffect(()=>{let alive=!0;setIsLoading(!0);__post("get-global-state",{params:{key}}).then(result=>{alive&&(setValue(result?.value??defaultValue),setError(null))}).catch(err=>{alive&&setError(err instanceof Error?err.message:String(err))}).finally(()=>{alive&&setIsLoading(!1)});return()=>{alive=!1}},[key,defaultValue]);let update=React.useCallback(next=>{let previous=value;setValue(next);setError(null);__post("set-global-state",{params:{key,value:next}}).catch(err=>{setValue(previous);setError(err instanceof Error?err.message:String(err))})},[key,value]);return{value,isLoading,error,update}}function LinuxToggle({settingKey,label,description,defaultValue=!0}){let{value,isLoading,error,update}=useLinuxSetting(settingKey,defaultValue),details=error?$.jsxs("div",{className:"flex flex-col gap-1",children:[$.jsx("span",{children:description}),$.jsx("span",{className:"text-token-error-foreground",children:error})]}):description;return $.jsx(SettingsRow,{label,description:details,control:$.jsx(Toggle,{checked:value,disabled:isLoading,onChange:update,ariaLabel:label})})}function normalizeCapturedKey(key){let map={" ":"Space",ArrowUp:"Up",ArrowDown:"Down",ArrowLeft:"Left",ArrowRight:"Right",Escape:"Esc",",":",",".":".","/":"/","\\\\":"\\\\","[":"[","]":"]",";":";","'":"'","-":"-","=":"=","+":"Plus"};if(map[key])return map[key];if(/^.$/.test(key))return key.toUpperCase();return key}function formatAcceleratorForInput(event){if(!(event.ctrlKey||event.altKey||event.metaKey))return null;if(["Control","Shift","Alt","Meta"].includes(event.key))return null;let parts=[];event.ctrlKey&&parts.push("Ctrl");event.altKey&&parts.push("Alt");event.shiftKey&&parts.push("Shift");event.metaKey&&parts.push("Command");let key=normalizeCapturedKey(event.key);return key?[...parts,key].join("+"):null}function ShortcutInput({value,defaultValue,changed,onChange}){let[draft,setDraft]=React.useState(value);React.useEffect(()=>setDraft(value),[value]);let commit=next=>onChange(String(next??"").trim());return $.jsxs("div",{className:"flex min-w-[260px] items-center justify-end gap-2",children:[$.jsx("input",{className:"h-8 w-[190px] rounded-md border border-token-border-default bg-token-bg-primary px-2 text-sm text-token-text-primary outline-none focus:border-token-border-strong","data-codex-keybind-input":!0,value:draft,placeholder:defaultValue,onChange:event=>{setDraft(event.target.value),onChange(event.target.value)},onBlur:()=>commit(draft),onKeyDown:event=>{if(event.key==="Escape"){setDraft(value);return}if(event.key==="Enter"){event.preventDefault(),commit(draft);return}let captured=formatAcceleratorForInput(event);captured&&(event.preventDefault(),setDraft(captured),onChange(captured))}}),$.jsx("button",{type:"button",className:"h-8 rounded-md border border-token-border-default px-2 text-xs text-token-text-secondary disabled:opacity-40",disabled:!changed,onClick:()=>onChange(""),children:"Reset"})]})}function KeybindRow({action,overrides,update}){let defaultValue=typeof DEFAULT_SHORTCUTS[action.id]=="string"?DEFAULT_SHORTCUTS[action.id]:action.defaultAccelerator??"",hasOverride=Object.prototype.hasOwnProperty.call(overrides,action.id),value=hasOverride?overrides[action.id]:defaultValue,changed=hasOverride&&value!==defaultValue,description=$.jsxs("div",{className:"flex flex-col gap-1",children:[$.jsx("span",{children:action.description}),$.jsxs("span",{className:"text-token-text-tertiary",children:["Default: ",defaultValue||"Unassigned"]})]});return $.jsx(SettingsRow,{label:action.label,description,control:$.jsx(ShortcutInput,{value,defaultValue,changed,onChange:next=>update(action.id,next)})})}function KeybindGroup({group,overrides,update}){return $.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:group.title}),$.jsx(SettingsSection.Content,{children:$.jsx(SettingsGroup,{children:group.actions.map(action=>$.jsx(KeybindRow,{action,overrides,update},action.id))})})]},group.title)}function KeybindsSettings(){let{overrides,error,update}=useKeybindOverrides();return $.jsx(SettingsPage,{title:"Keybinds",subtitle:"App shortcuts and Linux desktop behavior.",children:$.jsxs("div",{className:"flex flex-col gap-6",children:[$.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:"App shortcuts"}),error?$.jsx("div",{className:"px-1 text-sm text-token-error-foreground",children:error}):null]}),...KEYBIND_GROUPS.map(group=>$.jsx(KeybindGroup,{group,overrides,update},group.title)),$.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:"Global shortcuts"}),$.jsx(SettingsSection.Content,{children:$.jsxs(SettingsGroup,{children:[$.jsx(HotkeyWindowHotkeyRow,{}),$.jsx(LinuxToggle,{settingKey:KEYS.promptWindow,label:"Compact prompt window",description:"Allow --prompt-chat and --hotkey-window to open the compact prompt window and keep it prewarmed."})]})})]}),$.jsxs(SettingsSection,{className:"gap-2",children:[$.jsx(SettingsSection.Header,{title:"Linux desktop"}),$.jsx(SettingsSection.Content,{children:$.jsxs(SettingsGroup,{children:[$.jsx(LinuxToggle,{settingKey:KEYS.systemTray,label:"System tray",description:"Show the Codex system tray icon and keep the app available from the tray."}),$.jsx(LinuxToggle,{settingKey:KEYS.warmStart,label:"Warm start",description:"Use the running app for launch actions instead of starting a fresh Electron instance."})]})})]})]})})}export{KeybindsSettings,KeybindsSettings as default};\n//# sourceMappingURL=${keybindsSettingsAsset}.map\n`; +} + +function resolveKeybindsSettingsAsset(extractedDir) { + const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); + if (!fs.existsSync(webviewAssetsDir)) { + throw new Error(`Required Keybinds settings patch failed: missing webview assets directory ${webviewAssetsDir}`); + } + + const jsxRuntimeAsset = findRequiredWebviewAsset(webviewAssetsDir, /^jsx-runtime-.*\.js$/, "react.transitional.element", "JSX runtime asset"); + const jsxRuntimeSource = fs.readFileSync(path.join(webviewAssetsDir, jsxRuntimeAsset), "utf8"); + const jsxExportsReactFactory = /export\{[^}]*\bn\b/.test(jsxRuntimeSource); + const reactAsset = jsxExportsReactFactory + ? jsxRuntimeAsset + : findRequiredWebviewAsset(webviewAssetsDir, /^react-.*\.js$/, "react.transitional.element", "React asset"); + const reactExportName = jsxExportsReactFactory ? "n" : "t"; + const chunkAsset = findImportedAsset(webviewAssetsDir, reactAsset, "React shared chunk asset"); + const vscodeApiAsset = findRequiredWebviewAsset(webviewAssetsDir, /^vscode-api-.*\.js$/, "vscode://codex", "VS Code API asset"); + const hotkeySettingsAsset = findRequiredWebviewAsset( + webviewAssetsDir, + /^general-settings-.*\.js$/, + "hotkey-window-hotkey-state", + "hotkey settings asset", + ); + const toggleAsset = findRequiredWebviewAsset(webviewAssetsDir, /^toggle-.*\.js$/, null, "toggle asset"); + const settingsRowAsset = findRequiredWebviewAsset(webviewAssetsDir, /^settings-row-.*\.js$/, null, "settings row asset"); + const settingsLayoutAsset = findRequiredWebviewAsset( + webviewAssetsDir, + /^settings-content-layout-.*\.js$/, + null, + "settings content layout asset", + ); + const settingsGroupCandidate = fs + .readdirSync(webviewAssetsDir) + .filter((name) => /^settings-group-.*\.js$/.test(name)) + .sort()[0] ?? null; + const settingsSurfaceCandidate = fs + .readdirSync(webviewAssetsDir) + .filter((name) => /^settings-surface-.*\.js$/.test(name)) + .sort()[0] ?? null; + const filePath = path.join(webviewAssetsDir, keybindsSettingsAsset); + + return { + filePath, + source: buildKeybindsSettingsSource({ + chunkAsset, + reactAsset, + reactExportName, + jsxRuntimeAsset, + vscodeApiAsset, + hotkeySettingsAsset, + toggleAsset, + settingsRowAsset, + settingsPageAsset: settingsLayoutAsset, + settingsPageExportName: "t", + settingsSectionAsset: settingsGroupCandidate ?? settingsLayoutAsset, + settingsSectionExportName: settingsGroupCandidate == null ? "r" : "t", + settingsGroupAsset: settingsSurfaceCandidate ?? settingsLayoutAsset, + settingsGroupExportName: settingsSurfaceCandidate == null ? "n" : "t", + }), + }; +} + +function collectRequiredAssetPatches(extractedDir, filenamePattern, patchFn, description) { + const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); + if (!fs.existsSync(webviewAssetsDir)) { + throw new Error(`Required Keybinds settings patch failed: missing webview assets directory ${webviewAssetsDir}`); + } + + const candidates = fs + .readdirSync(webviewAssetsDir) + .filter((name) => filenamePattern.test(name)) + .sort(); + if (candidates.length === 0) { + throw new Error(`Required Keybinds settings patch failed: could not find ${description}`); + } + + return candidates.map((candidate) => { + const filePath = path.join(webviewAssetsDir, candidate); + const currentSource = fs.readFileSync(filePath, "utf8"); + return { + filePath, + currentSource, + patchedSource: patchFn(currentSource), + }; + }); +} + +function hasNativeKeyboardShortcutsSettings(extractedDir) { + const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); + if (!fs.existsSync(webviewAssetsDir)) { + return false; + } + + const hasSettingsRoute = fs + .readdirSync(webviewAssetsDir) + .filter((name) => /^settings-sections-.*\.js$/.test(name)) + .some((name) => fs.readFileSync(path.join(webviewAssetsDir, name), "utf8").includes("slug:`keyboard-shortcuts`")); + if (!hasSettingsRoute) { + return false; + } + + return fs + .readdirSync(webviewAssetsDir) + .some((name) => /^keyboard-shortcuts-settings-.*\.js$/.test(name)); +} + +function patchKeybindsSettingsAssets(extractedDir) { + if (hasNativeKeyboardShortcutsSettings(extractedDir)) { + return { + matched: true, + changed: 0, + reason: "upstream keyboard shortcuts settings are present", + }; + } + + try { + const keybindsAsset = resolveKeybindsSettingsAsset(extractedDir); + const keybindsAssetExists = fs.existsSync(keybindsAsset.filePath); + const previousKeybindsSource = keybindsAssetExists + ? fs.readFileSync(keybindsAsset.filePath, "utf8") + : null; + const patches = [ + ...collectRequiredAssetPatches( + extractedDir, + /^settings-sections-.*\.js$/, + applyKeybindsSettingsSectionsPatch, + "settings sections bundle", + ), + ...collectRequiredAssetPatches( + extractedDir, + /^settings-shared-.*\.js$/, + applyKeybindsSettingsSharedPatch, + "settings shared bundle", + ), + ...collectRequiredAssetPatches( + extractedDir, + /^index-.*\.js$/, + applyKeybindsSettingsIndexPatch, + "webview index bundle", + ), + ]; + + fs.writeFileSync(keybindsAsset.filePath, keybindsAsset.source, "utf8"); + let changed = previousKeybindsSource !== keybindsAsset.source ? 1 : 0; + for (const patch of patches) { + if (patch.patchedSource !== patch.currentSource) { + fs.writeFileSync(patch.filePath, patch.patchedSource, "utf8"); + changed += 1; + } + } + return { matched: true, changed }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`WARN: Keybinds settings patch skipped: ${message}`); + return { matched: false, changed: 0, reason: message }; + } +} + +function applyKeybindsSettingsSectionsPatch(currentSource) { + let patchedSource = currentSource; + + if (patchedSource.includes("slug:`keybinds`") || patchedSource.includes("slug:`keyboard-shortcuts`")) { + return patchedSource; + } + + const sectionsNeedle = "var e=`general-settings`,t=`mcp-settings`,n=[{slug:e},"; + const sectionsPatch = "var e=`general-settings`,t=`mcp-settings`,n=[{slug:e},{slug:`keybinds`},"; + if (patchedSource.includes(sectionsNeedle)) { + return patchedSource.replace(sectionsNeedle, sectionsPatch); + } + + const currentNeedle = "n=[{slug:e},{slug:`appearance`}"; + if (patchedSource.includes(currentNeedle)) { + return patchedSource.replace(currentNeedle, "n=[{slug:e},{slug:`keybinds`},{slug:`appearance`}"); + } + + const literalNeedle = "n=[{slug:`general-settings`},{slug:`appearance`}"; + if (patchedSource.includes(literalNeedle)) { + return patchedSource.replace(literalNeedle, "n=[{slug:`general-settings`},{slug:`keybinds`},{slug:`appearance`}"); + } + + throw new Error("Required Keybinds settings patch failed: could not add keybinds settings section"); +} + +function applyKeybindsSettingsSharedPatch(currentSource) { + let patchedSource = currentSource; + + if (patchedSource.includes("settings.nav.keyboard-shortcuts")) { + return patchedSource; + } + + if (!patchedSource.includes("settings.nav.keybinds")) { + const navNeedle = + '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},'; + const navPatch = + '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},keybinds:{id:`settings.nav.keybinds`,defaultMessage:`Keybinds`,description:`Title for keybinds settings section`},'; + if (!patchedSource.includes(navNeedle)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds nav label"); + } + patchedSource = patchedSource.replace(navNeedle, navPatch); + } + + if (!patchedSource.includes("settings.section.keybinds")) { + const sectionNeedle = + "case`general-settings`:{let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`,description:`Title for general settings section`}),t[2]=e):e=t[2],e}"; + const sectionPatch = + "case`general-settings`:{let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`,description:`Title for general settings section`}),t[2]=e):e=t[2],e}case`keybinds`:{return (0,d.jsx)(n,{id:`settings.section.keybinds`,defaultMessage:`Keybinds`,description:`Title for keybinds settings section`})}"; + if (!patchedSource.includes(sectionNeedle)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds section title"); + } + patchedSource = patchedSource.replace(sectionNeedle, sectionPatch); + } + + return patchedSource; +} + +function applyLinuxKeybindOverridesRuntimePatch(currentSource) { + const runtimePatch = `;function codexLinuxKeybindOverridesRuntime(){try{if(typeof window=="undefined")return;let storageKey=${jsStringLiteral(linuxKeybindOverridesKey)},defaultMap=${jsDataLiteral(defaultShortcuts, "default shortcuts")},overrides={};function loadOverrides(){try{let value=JSON.parse(localStorage.getItem(storageKey)||"{}");overrides=value&&typeof value=="object"&&!Array.isArray(value)?value:{}}catch{overrides={}}}function isShortcutCaptureTarget(event){let target=event.target;return target instanceof Element&&target.closest("[data-codex-keybind-input]")!=null}function normalizeKeyName(key){let map={Space:" ",Esc:"Escape",Up:"ArrowUp",Down:"ArrowDown",Left:"ArrowLeft",Right:"ArrowRight",Plus:"+",Comma:",",Period:".",Slash:"/"};return map[key]??(/^.$/.test(key)?key.toUpperCase():key)}function parseAccelerator(accelerator){if(typeof accelerator!="string"||accelerator.trim().length===0)return null;let isMac=/Mac/.test(navigator.platform||""),parts=accelerator.split("+").map(part=>part.trim()).filter(Boolean),parsed={ctrl:false,alt:false,shift:false,meta:false,key:null};for(let part of parts){switch(part){case"CmdOrCtrl":isMac?parsed.meta=true:parsed.ctrl=true;break;case"Command":case"Cmd":case"Meta":case"Super":case"Win":parsed.meta=true;break;case"Control":case"Ctrl":parsed.ctrl=true;break;case"Alt":case"Option":parsed.alt=true;break;case"Shift":parsed.shift=true;break;default:parsed.key=normalizeKeyName(part);break}}return parsed.key?parsed:null}function matches(event,parsed){return event.ctrlKey===parsed.ctrl&&event.altKey===parsed.alt&&event.shiftKey===parsed.shift&&event.metaKey===parsed.meta&&normalizeKeyName(event.key)===parsed.key}function dispatchHost(message){if(typeof E=="object"&&E&&typeof E.dispatchHostMessage=="function"){E.dispatchHostMessage(message);return true}return false}function dispatchElectron(type,params={}){if(typeof E=="object"&&E&&typeof E.dispatchMessage=="function"){E.dispatchMessage(type,params);return true}return false}function knownAction(id){return typeof defaultMap[id]=="string"||/^thread[1-9]$/.test(id)||["openBrowserTab","reloadBrowserPage","hardReloadBrowserPage","dictation"].includes(id)}function hostActionType(id){switch(id){case"newThread":case"newThreadAlt":return"new-chat";case"quickChat":return"new-quick-chat";case"toggleSidebar":return"toggle-sidebar";case"toggleTerminal":return"toggle-terminal";case"toggleBrowserPanel":return"toggle-browser-panel";case"toggleDiffPanel":return"toggle-diff-panel";case"findInThread":return"find-in-thread";case"navigateBack":return"navigate-back";case"navigateForward":return"navigate-forward";case"previousThread":return"previous-thread";case"nextThread":return"next-thread";case"copyConversationPath":return"copy-conversation-path";case"toggleThreadPin":return"toggle-thread-pin";case"renameThread":return"rename-thread";case"archiveThread":return"archive-thread";case"copyWorkingDirectory":return"copy-working-directory";case"copySessionId":return"copy-session-id";case"copyDeeplink":return"copy-deeplink";case"toggleFileTreePanel":return"toggle-file-tree-panel";default:return null}}function runAction(id){if(!knownAction(id))return false;if(/^thread[1-9]$/.test(id))return dispatchHost({type:"go-to-thread-index",index:Number(id.slice(6))-1});switch(id){case"openCommandMenu":case"openCommandMenuAlt":return dispatchHost({type:"command-menu",query:""});case"searchChats":return dispatchHost({type:"chat-search-command-menu"});case"searchFiles":return dispatchHost({type:"file-search-command-menu"});case"openFolder":return dispatchElectron("electron-create-new-workspace-root-option",{});case"settings":return dispatchElectron("show-settings",{section:"general-settings"});case"openBrowserTab":return dispatchHost({type:"browser-sidebar-command",command:{type:"new-tab"}});case"reloadBrowserPage":return dispatchHost({type:"browser-sidebar-command",command:{type:"reload"}});case"hardReloadBrowserPage":return dispatchHost({type:"browser-sidebar-command",command:{type:"hard-reload"}});case"dictation":return dispatchElectron("global-dictation-start",{});default:{let type=hostActionType(id);return type?dispatchHost({type}):false}}}loadOverrides();window.addEventListener("storage",event=>{event.key===storageKey&&loadOverrides()});window.addEventListener("codex-linux-keybind-overrides-changed",loadOverrides);window.addEventListener("keydown",event=>{if(event.defaultPrevented||event.repeat||isShortcutCaptureTarget(event))return;for(let[id,accelerator]of Object.entries(overrides)){if(!knownAction(id)||typeof accelerator!="string"||accelerator.trim().length===0||accelerator.trim()===(defaultMap[id]||""))continue;let parsed=parseAccelerator(accelerator);if(parsed&&matches(event,parsed)&&runAction(id)){event.preventDefault();event.stopPropagation();break}}},true)}catch{}}codexLinuxKeybindOverridesRuntime();`; + + const runtimeMarker = ";function codexLinuxKeybindOverridesRuntime()"; + const existingRuntimeIndex = currentSource.indexOf(runtimeMarker); + if (existingRuntimeIndex !== -1) { + return `${currentSource.slice(0, existingRuntimeIndex).trimEnd()}\n${runtimePatch}`; + } + + return `${currentSource}\n${runtimePatch}`; +} + +function applyKeybindsSettingsIndexPatch(currentSource) { + let patchedSource = currentSource; + + if (patchedSource.includes('"keyboard-shortcuts":')) { + if (!patchedSource.includes(`${keybindsSettingsAsset}`)) { + const routePattern = + /"keyboard-shortcuts":\(0,([A-Za-z_$][\w$]*)\.lazy\)\(\(\)=>([A-Za-z_$][\w$]*)\(\(\)=>import\((["'`])\.\/[^"'`]+\3\)[\s\S]*?,import\.meta\.url\)\),((?:"[^"]+"|[A-Za-z_$][\w$]*):)/; + if (!routePattern.test(patchedSource)) { + throw new Error( + "Required Keybinds settings patch failed: could not replace keyboard shortcuts route", + ); + } + const keybindsImportPath = jsModuleSpecifier(keybindsSettingsAsset, "Keybinds settings asset"); + patchedSource = patchedSource.replace( + routePattern, + (_match, lazyModule, importHelper, _quote, nextRouteKey) => { + const lazy = requireBundleIdentifier(lazyModule, "keyboard shortcuts lazy module"); + const helper = requireBundleIdentifier(importHelper, "keyboard shortcuts import helper"); + const followingRoute = requireRouteKey(nextRouteKey, "following settings"); + return `"keyboard-shortcuts":(0,${lazy}.lazy)(()=>${helper}(()=>import(${keybindsImportPath}),[],import.meta.url)),${followingRoute}`; + }, + ); + } + + return applyLinuxKeybindOverridesRuntimePatch(patchedSource); + } + + if (!patchedSource.includes(`${keybindsSettingsAsset}`)) { + const routePattern = + /((?:var|let|const) ([A-Za-z_$][\w$]*)=\{)(?:\.\.\.([A-Za-z_$][\w$]*),)?"general-settings":(?=\(0,([A-Za-z_$][\w$]*)\.lazy\)\(\(\)=>([A-Za-z_$][\w$]*)\()/; + if (!routePattern.test(patchedSource)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds route"); + } + const keybindsImportPath = jsModuleSpecifier(keybindsSettingsAsset, "Keybinds settings asset"); + patchedSource = patchedSource.replace( + routePattern, + (_match, declarationPrefix, routeMapName, spreadName, lazyModule, importHelper) => { + requireBundleIdentifier(routeMapName, "route map"); + const spread = spreadName == null ? "" : `...${requireBundleIdentifier(spreadName, "spread route map")},`; + const lazy = requireBundleIdentifier(lazyModule, "settings lazy module"); + const helper = requireBundleIdentifier(importHelper, "settings import helper"); + return `${declarationPrefix}${spread}keybinds:(0,${lazy}.lazy)(()=>${helper}(()=>import(${keybindsImportPath}),[],import.meta.url)),"general-settings":`; + }, + ); + } + + if (!/[,{]keybinds:[A-Za-z_$][\w$]*,"general-settings":/.test(patchedSource)) { + const iconPattern = /([A-Za-z_$][\w$]*=\{)"general-settings":([A-Za-z_$][\w$]*),/; + if (!iconPattern.test(patchedSource)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds icon"); + } + patchedSource = patchedSource.replace( + iconPattern, + (_match, prefix, icon) => `${prefix}keybinds:${icon},"general-settings":${icon},`, + ); + } + + if (!/=\[`general-settings`,`keybinds`/.test(patchedSource)) { + const orderPattern = /([A-Za-z_$][\w$]*=\[`general-settings`,)`appearance`/; + if (!orderPattern.test(patchedSource)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds nav order"); + } + patchedSource = patchedSource.replace(orderPattern, "$1`keybinds`,`appearance`"); + } + + if (!patchedSource.includes("slugs:[`general-settings`,`keybinds`")) { + const groupNeedle = "slugs:[`general-settings`,`appearance`,`connections`,`git-settings`,`usage`]"; + const groupPatch = "slugs:[`general-settings`,`keybinds`,`appearance`,`connections`,`git-settings`,`usage`]"; + if (!patchedSource.includes(groupNeedle)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds nav group"); + } + patchedSource = patchedSource.replace(groupNeedle, groupPatch); + } + + if (!patchedSource.includes("case`keybinds`:return l===`electron`")) { + const visibilityNeedle = + "case`appearance`:case`git-settings`:case`worktrees`:case`local-environments`:case`data-controls`:case`environments`:return l===`electron`;"; + const visibilityPatch = + "case`keybinds`:return l===`electron`;case`appearance`:case`git-settings`:case`worktrees`:case`local-environments`:case`data-controls`:case`environments`:return l===`electron`;"; + if (!patchedSource.includes(visibilityNeedle)) { + throw new Error("Required Keybinds settings patch failed: could not add keybinds visibility"); + } + patchedSource = patchedSource.replace(visibilityNeedle, visibilityPatch); + } + + if (!patchedSource.includes("case`keybinds`:k=!1;break bb0;")) { + const redirectNeedle = + "case`appearance`:case`general-settings`:case`agent`:case`git-settings`:case`account`:case`data-controls`:case`personalization`:k=!1;break bb0;"; + const redirectPatch = + "case`keybinds`:k=!1;break bb0;case`appearance`:case`general-settings`:case`agent`:case`git-settings`:case`account`:case`data-controls`:case`personalization`:k=!1;break bb0;"; + if (patchedSource.includes(redirectNeedle)) { + patchedSource = patchedSource.replace(redirectNeedle, redirectPatch); + } + } + + return applyLinuxKeybindOverridesRuntimePatch(patchedSource); +} + +module.exports = { + applyKeybindsSettingsIndexPatch, + applyKeybindsSettingsSectionsPatch, + applyKeybindsSettingsSharedPatch, + applyLinuxKeybindOverridesRuntimePatch, + keybindsSettingsAsset, + linuxKeybindOverridesKey, + patchKeybindsSettingsAssets, + resolveKeybindsSettingsAsset, +}; diff --git a/scripts/patches/launch-actions.js b/scripts/patches/launch-actions.js new file mode 100644 index 00000000..0cf080bc --- /dev/null +++ b/scripts/patches/launch-actions.js @@ -0,0 +1,484 @@ +"use strict"; + +const { + CLOSE_GATE_PREFIX_LOOKBACK, + DIRECT_HANDLER_PROXIMITY, + HANDLER_PREFIX_LOOKBACK, + escapeRegExp, + findDisposableVar, + findLastRegexMatch, + findLinuxGlobalStateExpression, + findMatchingBrace, + inferModuleAlias, + linuxSettingsKeys, +} = require("./shared.js"); + +// Launch-action patches keep second launches, hotkey windows, and persisted +// Linux settings coordinated with the generated launcher. +const linuxQuitStateHelpers = + "let codexLinuxQuitInProgress=!1,codexLinuxExplicitQuitApproved=!1,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxPrepareForExplicitQuit=()=>{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress()},codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0,codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0,"; + +function applyLinuxSettingsPersistencePatch(currentSource) { + let patchedSource = currentSource; + + if ( + !patchedSource.includes('"set-global-state"') && + !patchedSource.includes(".codex-global-state.json") + ) { + return patchedSource; + } + + if (!patchedSource.includes("function codexLinuxPersistSettingsState(")) { + const stateFileRegex = /var ([A-Za-z_$][\w$]*)=`\.codex-global-state\.json`;/; + const stateFileMatch = patchedSource.match(stateFileRegex); + const pathVar = inferModuleAlias(patchedSource, "node:path"); + const fsVar = inferModuleAlias(patchedSource, "node:fs"); + if (stateFileMatch == null || pathVar == null || fsVar == null) { + console.warn("WARN: Could not find Linux settings state file marker — skipping settings persistence patch"); + return patchedSource; + } + const stateFilePatch = + `var ${stateFileMatch[1]}=\`.codex-global-state.json\`;function codexLinuxSettingsPath(){let e=process.env.XDG_CONFIG_HOME||process.env.HOME&&${pathVar}.join(process.env.HOME,\`.config\`);return e?${pathVar}.join(e,\`codex-app\`,\`settings.json\`):null}function codexLinuxReadSettingsFile(){let e=codexLinuxSettingsPath();if(!e||!${fsVar}.existsSync(e))return{};try{let t=${fsVar}.readFileSync(e,\`utf8\`),n=JSON.parse(t);return n&&typeof n===\`object\`&&!Array.isArray(n)?n:{}}catch(e){return{}}}function codexLinuxPersistSettingsState(e,t){if(process.platform!==\`linux\`||![${Object.values(linuxSettingsKeys).map((key) => `\`${key}\``).join(",")}].includes(e))return;try{let n=codexLinuxSettingsPath();if(!n)return;let r=codexLinuxReadSettingsFile();t===void 0?delete r[e]:r[e]=t,${fsVar}.mkdirSync(${pathVar}.dirname(n),{recursive:!0,mode:448}),${fsVar}.writeFileSync(n,JSON.stringify(r,null,2)+\`\\n\`,\`utf8\`)}catch(e){}}`; + patchedSource = patchedSource.replace(stateFileRegex, stateFilePatch); + } + + if (/"set-global-state":async\(\{key:[A-Za-z_$][\w$]*,value:[A-Za-z_$][\w$]*,origin:[A-Za-z_$][\w$]*\}\)=>\(this\.globalState\.set\([A-Za-z_$][\w$]*,[A-Za-z_$][\w$]*\),codexLinuxPersistSettingsState\(/.test(patchedSource)) { + return patchedSource; + } + const setGlobalStateRegex = + /"set-global-state":async\(\{key:([A-Za-z_$][\w$]*),value:([A-Za-z_$][\w$]*),origin:([A-Za-z_$][\w$]*)\}\)=>\(this\.globalState\.set\(\1,\2\),/; + if (!setGlobalStateRegex.test(patchedSource)) { + console.warn("WARN: Could not find Linux set-global-state needle — skipping settings persistence hook"); + return patchedSource; + } + + return patchedSource.replace( + setGlobalStateRegex, + (_match, keyVar, valueVar, originVar) => + `"set-global-state":async({key:${keyVar},value:${valueVar},origin:${originVar}})=>(this.globalState.set(${keyVar},${valueVar}),codexLinuxPersistSettingsState(${keyVar},${valueVar}),`, + ); +} + +function applyLinuxTrayCloseSettingPatch(currentSource) { + let patchedSource = currentSource; + + const patchedCloseGateRegex = new RegExp( + `canHideLastLocalWindowToTray:\\(\\)=>[A-Za-z_$][\\w$]*&&\\(process\\.platform!==\`linux\`\\|\\|[^,{}]+\\.get\\(\`${escapeRegExp(linuxSettingsKeys.systemTray)}\`\\)!==!1\\),disposables:[A-Za-z_$][\\w$]*`, + ); + if (patchedCloseGateRegex.test(patchedSource)) { + return patchedSource; + } + + const closeGateRegex = + /canHideLastLocalWindowToTray:\(\)=>([A-Za-z_$][\w$]*),disposables:([A-Za-z_$][\w$]*)/; + const closeGateMatch = patchedSource.match(closeGateRegex); + if (closeGateMatch != null) { + const [, trayReadyVar, disposableVar] = closeGateMatch; + const prefix = patchedSource.slice( + Math.max(0, closeGateMatch.index - CLOSE_GATE_PREFIX_LOOKBACK), + closeGateMatch.index, + ); + const globalStateExpr = findLinuxGlobalStateExpression(prefix); + if (globalStateExpr != null) { + return patchedSource.replace( + closeGateRegex, + `canHideLastLocalWindowToTray:()=>${trayReadyVar}&&(process.platform!==\`linux\`||${globalStateExpr}.get(\`${linuxSettingsKeys.systemTray}\`)!==!1),disposables:${disposableVar}`, + ); + } + } + + if (patchedSource.includes("canHideLastLocalWindowToTray") && patchedSource.includes("Launching app")) { + console.warn("WARN: Could not find Linux tray settings close gate needle — skipping tray setting patch"); + } + + return patchedSource; +} + +function buildSemanticLinuxLaunchActionPatch({ + quitStatePrefix = linuxQuitStateHelpers, + setterVar, + deepLinksVar, + fallbackFn, + openerFn, + windowManagerVar, + hostExpr, + currentWindowVar, + createdWindowVar, + routeVar, + focusFn, + notificationVar, + globalStateExpr, + reporterVar, + disposableVar, + pathVar, + fsVar, + netVar, + appVar, +}) { + const notificationPrefix = notificationVar == null + ? "" + : `${notificationVar}.desktopNotificationManager.dismissByNavigationPath(e),`; + const quitState = quitStatePrefix; + const directHandler = appVar == null + ? "" + : `,codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{${fallbackFn}()})},codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===\`function\`&&codexLinuxMarkQuitInProgress()}`; + const startup = appVar == null + ? `process.platform===\`linux\`&&codexLinuxStartLaunchActionSocket();${setterVar}(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{${fallbackFn}()})});` + : `process.platform===\`linux\`&&(${appVar}.app.on(\`before-quit\`,codexLinuxBeforeQuitHandler),${disposableVar}.add(()=>{${appVar}.app.off(\`before-quit\`,codexLinuxBeforeQuitHandler)}),codexLinuxStartLaunchActionSocket(),${appVar}.app.on(\`second-instance\`,codexLinuxSecondInstanceHandler),${disposableVar}.add(()=>{${appVar}.app.off(\`second-instance\`,codexLinuxSecondInstanceHandler)}));${setterVar}(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{${fallbackFn}()})});`; + + return `${quitState}codexLinuxGetSetting=e=>process.platform!==\`linux\`||${globalStateExpr}.get(e)!==!1,codexLinuxIsTrayEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.systemTray}\`),codexLinuxIsWarmStartEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.warmStart}\`),codexLinuxIsPromptWindowEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.promptWindow}\`),${openerFn}=async(e,t)=>{${windowManagerVar}.hotkeyWindowLifecycleManager.hide();let ${currentWindowVar}=${windowManagerVar}.getPrimaryWindow(${hostExpr}),${createdWindowVar}=${currentWindowVar}??await ${windowManagerVar}.createFreshLocalWindow(e);${createdWindowVar}!=null&&(${notificationPrefix}${currentWindowVar}!=null&&t.navigateExistingWindow&&${routeVar}.navigateToRoute(${createdWindowVar},e),${focusFn}(${createdWindowVar}))},codexLinuxGetHotkeyWindowController=()=>typeof ${windowManagerVar}.hotkeyWindowLifecycleManager.ensureHotkeyWindowController===\`function\`?${windowManagerVar}.hotkeyWindowLifecycleManager.ensureHotkeyWindowController():${windowManagerVar}.hotkeyWindowLifecycleManager,codexLinuxShowHotkeyWindow=async()=>{let e=codexLinuxGetHotkeyWindowController();typeof e.openHome===\`function\`?await e.openHome():typeof e.show===\`function\`?await e.show():await ${windowManagerVar}.ensureHostWindow(${hostExpr})},codexLinuxOpenQuickChat=async()=>{${windowManagerVar}.hotkeyWindowLifecycleManager.hide();let e=${windowManagerVar}.getPrimaryWindow(${hostExpr}),t=e??await ${windowManagerVar}.createFreshLocalWindow(\`/\`);t!=null&&(${windowManagerVar}.windowManager.sendMessageToWindow(t,{type:\`new-quick-chat\`}),${focusFn}(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===\`string\`&&(e.startsWith(\`codex://\`)||e.startsWith(\`codex-browser-sidebar://\`))),codexLinuxHandleLaunchActionArgs=async e=>(typeof codexLinuxIsQuitInProgress===\`function\`&&codexLinuxIsQuitInProgress())?!0:codexLinuxHasDeepLink(e)&&${deepLinksVar}.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(\`--prompt-chat\`)||e.includes(\`--hotkey-window\`))?(codexLinuxIsPromptWindowEnabled()?(await codexLinuxShowHotkeyWindow(),!0):!1):Array.isArray(e)&&e.includes(\`--quick-chat\`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(\`--new-chat\`)?(await ${openerFn}(\`/\`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{if(typeof codexLinuxIsQuitInProgress===\`function\`&&codexLinuxIsQuitInProgress())return;codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to handle Linux launch action\`,{kind:\`linux-launch-action-failed\`}),t()})},codexLinuxPrewarmHotkeyWindow=()=>{if(!codexLinuxIsPromptWindowEnabled())return;try{let e=codexLinuxGetHotkeyWindowController();typeof e.prewarm===\`function\`&&e.prewarm()}catch(e){${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to prewarm Linux hotkey window\`,{kind:\`linux-hotkey-window-prewarm-failed\`})}},codexLinuxStartLaunchActionSocket=()=>{let e=process.env.CODEX_APP_LAUNCH_ACTION_SOCKET?.trim();if(process.platform!==\`linux\`||!e||!codexLinuxIsWarmStartEnabled())return;try{${fsVar}.mkdirSync(${pathVar}.default.dirname(e),{recursive:!0,mode:448}),${fsVar}.rmSync(e,{force:!0});let t=${netVar}.default.createServer(t=>{let n=\`\`,r=!1,i=()=>{if(r)return;r=!0;let i=[];try{let e=JSON.parse(n.trim());Array.isArray(e.argv)&&(i=e.argv.filter(e=>typeof e===\`string\`))}catch(e){t.end?.(\`error\\n\`);return}codexLinuxHandleLaunchActionArgs(i).then(e=>e?void 0:${fallbackFn}()).then(()=>{t.end?.(\`ok\\n\`)}).catch(e=>{${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to handle Linux launch action socket\`,{kind:\`linux-launch-action-socket-failed\`}),t.end?.(\`error\\n\`)})};t.setEncoding?.(\`utf8\`),t.on(\`data\`,e=>{n+=e,n.includes(\`\\n\`)?i():n.length>65536&&t.destroy()}),t.on(\`end\`,i)});t.on(\`error\`,e=>{${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed Linux launch action socket\`,{kind:\`linux-launch-action-socket-error\`})}),t.listen(e),${disposableVar}.add(()=>{t.close(),${fsVar}.rmSync(e,{force:!0})})}catch(e){${reporterVar}.reportNonFatal(e instanceof Error?e:\`Failed to start Linux launch action socket\`,{kind:\`linux-launch-action-socket-start-failed\`})}}${directHandler};${startup}`; +} + +function applySemanticLinuxLaunchActionArgsPatch(currentSource) { + const handlerRegex = + /([A-Za-z_$][\w$]*)\(e=>\{([A-Za-z_$][\w$]*)\.deepLinks\.queueProcessArgs\(e\)\|\|([A-Za-z_$][\w$]*)\(\)\}\);let ([A-Za-z_$][\w$]*)=async\(e,t\)=>\{/g; + let match; + while ((match = handlerRegex.exec(currentSource)) != null) { + const [, setterVar, deepLinksVar, fallbackFn, openerFn] = match; + // handlerRegex ends with `let =async(e,t)=>{`, so the opening + // brace's position is determined directly by the match. + const openerBraceIndex = match.index + match[0].length - 1; + const openerLetIndex = openerBraceIndex - `let ${openerFn}=async(e,t)=>`.length; + const openerEnd = findMatchingBrace(currentSource, openerBraceIndex); + if (openerEnd === -1) { + continue; + } + + const separator = currentSource[openerEnd + 1]; + if (separator !== ";" && separator !== ",") { + continue; + } + + const openerText = currentSource.slice(openerLetIndex, openerEnd + 1); + const openerVars = openerText.match( + /([A-Za-z_$][\w$]*)\.hotkeyWindowLifecycleManager\.hide\(\);let ([A-Za-z_$][\w$]*)=\1\.getPrimaryWindow\(([^)]+)\),([A-Za-z_$][\w$]*)=\2\?\?await \1\.createFreshLocalWindow\(e\);/, + ); + if (openerVars == null) { + continue; + } + + const [, windowManagerVar, currentWindowVar, hostExpr, createdWindowVar] = openerVars; + const routeVar = openerText.match(/([A-Za-z_$][\w$]*)\.navigateToRoute\([A-Za-z_$][\w$]*,e\)/)?.[1]; + const focusFn = openerText.match(new RegExp(`,([A-Za-z_$][\\w$]*)\\(${escapeRegExp(createdWindowVar)}\\)\\)\\}$`))?.[1]; + if (routeVar == null || focusFn == null) { + continue; + } + + const prefix = currentSource.slice(Math.max(0, match.index - HANDLER_PREFIX_LOOKBACK), match.index); + const globalStateExpr = findLinuxGlobalStateExpression(prefix); + const reporterVar = findLastRegexMatch( + prefix, + /([A-Za-z_$][\w$]*)\.reportNonFatal\(e instanceof Error\?e:`Failed to open window on second instance`/g, + )?.[1] ?? findLastRegexMatch(prefix, /([A-Za-z_$][\w$]*)=\{reportNonFatal/g)?.[1]; + const disposableVar = findDisposableVar(prefix); + const pathVar = inferModuleAlias(currentSource, "node:path"); + const fsVar = inferModuleAlias(currentSource, "node:fs"); + const netVar = inferModuleAlias(currentSource, "node:net"); + if (globalStateExpr == null || reporterVar == null || disposableVar == null || pathVar == null || fsVar == null || netVar == null) { + continue; + } + + let replaceStart = match.index; + let appVar = null; + const directStart = currentSource.lastIndexOf("let codexLinuxSecondInstanceHandler=", match.index); + if (directStart !== -1 && match.index - directStart < DIRECT_HANDLER_PROXIMITY) { + const directBlock = currentSource.slice(directStart, match.index); + const appMatch = directBlock.match(/([A-Za-z_$][\w$]*)\.app\.on\(`second-instance`,codexLinuxSecondInstanceHandler\)/); + replaceStart = directStart; + appVar = appMatch?.[1] ?? inferModuleAlias(currentSource, "electron"); + } + + const notificationVar = openerText.match( + /([A-Za-z_$][\w$]*)\.desktopNotificationManager\.dismissByNavigationPath\(e\)/, + )?.[1] ?? null; + const replacement = buildSemanticLinuxLaunchActionPatch({ + quitStatePrefix: currentSource.includes("codexLinuxQuitInProgress=!1") ? "" : linuxQuitStateHelpers, + setterVar, + deepLinksVar, + fallbackFn, + openerFn, + windowManagerVar, + hostExpr: hostExpr.trim(), + currentWindowVar, + createdWindowVar, + routeVar, + focusFn, + notificationVar, + globalStateExpr, + reporterVar, + disposableVar, + pathVar, + fsVar, + netVar, + appVar, + }); + const suffix = separator === "," ? "let " : ""; + return currentSource.slice(0, replaceStart) + replacement + suffix + currentSource.slice(openerEnd + 2); + } + + return currentSource; +} + +function applyCurrentSemanticLinuxLaunchActionArgsPatch(currentSource) { + const handlerRegex = + /([A-Za-z_$][\w$]*)\(e=>\{let ([A-Za-z_$][\w$]*)=[^;{}]+;if\(([A-Za-z_$][\w$]*)\.deepLinks\.queueProcessArgs\(e\)\)\{\2&&([A-Za-z_$][\w$]*)\(\);return\}if\(\2\)\{\4\(\);return\}\4\(\)\}\);let ([A-Za-z_$][\w$]*)=async\(e,t\)=>\{/g; + let match; + while ((match = handlerRegex.exec(currentSource)) != null) { + const [, setterVar, , deepLinksVar, fallbackFn, openerFn] = match; + const openerBraceIndex = match.index + match[0].length - 1; + const openerLetIndex = openerBraceIndex - `let ${openerFn}=async(e,t)=>`.length; + const openerEnd = findMatchingBrace(currentSource, openerBraceIndex); + if (openerEnd === -1) { + continue; + } + + const separator = currentSource[openerEnd + 1]; + if (separator !== ";" && separator !== ",") { + continue; + } + + const openerText = currentSource.slice(openerLetIndex, openerEnd + 1); + const openerVars = openerText.match( + /([A-Za-z_$][\w$]*)\.hotkeyWindowLifecycleManager\.hide\(\);let ([A-Za-z_$][\w$]*)=\1\.getPrimaryWindow\(([^)]+)\),([A-Za-z_$][\w$]*)=\2\?\?await \1\.createFreshLocalWindow\(e\);/, + ); + if (openerVars == null) { + continue; + } + + const [, windowManagerVar, currentWindowVar, hostExpr, createdWindowVar] = openerVars; + const routeVar = openerText.match(/([A-Za-z_$][\w$]*)\.navigateToRoute\([A-Za-z_$][\w$]*,e\)/)?.[1]; + const focusFn = openerText.match(new RegExp(`,([A-Za-z_$][\\w$]*)\\(${escapeRegExp(createdWindowVar)}\\)\\)\\}$`))?.[1]; + if (routeVar == null || focusFn == null) { + continue; + } + + const prefix = currentSource.slice(Math.max(0, match.index - HANDLER_PREFIX_LOOKBACK), match.index); + const globalStateExpr = findLinuxGlobalStateExpression(prefix); + const reporterVar = findLastRegexMatch( + prefix, + /([A-Za-z_$][\w$]*)\.reportNonFatal\(e instanceof Error\?e:`Failed to open window on second instance`/g, + )?.[1] ?? findLastRegexMatch(prefix, /([A-Za-z_$][\w$]*)=\{reportNonFatal/g)?.[1]; + const disposableVar = findDisposableVar(prefix); + const pathVar = inferModuleAlias(currentSource, "node:path"); + const fsVar = inferModuleAlias(currentSource, "node:fs"); + const netVar = inferModuleAlias(currentSource, "node:net"); + if (globalStateExpr == null || reporterVar == null || disposableVar == null || pathVar == null || fsVar == null || netVar == null) { + continue; + } + + const notificationVar = openerText.match( + /([A-Za-z_$][\w$]*)\.desktopNotificationManager\.dismissByNavigationPath\(e\)/, + )?.[1] ?? null; + const replacement = buildSemanticLinuxLaunchActionPatch({ + quitStatePrefix: currentSource.includes("codexLinuxQuitInProgress=!1") ? "" : linuxQuitStateHelpers, + setterVar, + deepLinksVar, + fallbackFn, + openerFn, + windowManagerVar, + hostExpr: hostExpr.trim(), + currentWindowVar, + createdWindowVar, + routeVar, + focusFn, + notificationVar, + globalStateExpr, + reporterVar, + disposableVar, + pathVar, + fsVar, + netVar, + appVar: null, + }); + const suffix = separator === "," ? "let " : ""; + return currentSource.slice(0, match.index) + replacement + suffix + currentSource.slice(openerEnd + 2); + } + + return currentSource; +} + +function applyLinuxLaunchActionArgsPatch(currentSource) { + let patchedSource = currentSource; + const quitStatePrefix = patchedSource.includes("codexLinuxQuitInProgress=!1") ? "" : linuxQuitStateHelpers; + + const launchActionNeedle = + "let codexLinuxSecondInstanceHandler=(e,t)=>{R.deepLinks.queueProcessArgs(t)||ie()};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},oe=async()=>{"; + const oldLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHandleLaunchActionArgs=async e=>Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{R.deepLinks.queueProcessArgs(t)||ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{R.deepLinks.queueProcessArgs(e)||ie()})});let oe=async()=>{"; + const deepLinkFirstLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHandleLaunchActionArgs=async e=>Array.isArray(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; + const deepLinkAwareExistingWindowLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; + const openHomeHotkeyWindowLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxShowHotkeyWindow=async()=>{let e=P.hotkeyWindowLifecycleManager;typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; + const socketHotkeyWindowLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxShowHotkeyWindow=async()=>{let e=P.hotkeyWindowLifecycleManager;typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxStartLaunchActionSocket=()=>{let e=process.env.CODEX_APP_LAUNCH_ACTION_SOCKET?.trim();if(process.platform!==`linux`||!e)return;try{o.mkdirSync(i.default.dirname(e),{recursive:!0,mode:448}),o.rmSync(e,{force:!0});let t=u.default.createServer(t=>{let n=``,r=!1,i=()=>{if(r)return;r=!0;let i=[];try{let e=JSON.parse(n.trim());Array.isArray(e.argv)&&(i=e.argv.filter(e=>typeof e===`string`))}catch(e){t.end?.(`error\\n`);return}codexLinuxHandleLaunchActionArgs(i).then(e=>e?void 0:ie()).then(()=>{t.end?.(`ok\\n`)}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action socket`,{kind:`linux-launch-action-socket-failed`}),t.end?.(`error\\n`)})};t.setEncoding?.(`utf8`),t.on(`data`,e=>{n+=e,n.includes(`\\n`)?i():n.length>65536&&t.destroy()}),t.on(`end`,i)});t.on(`error`,e=>{g.reportNonFatal(e instanceof Error?e:`Failed Linux launch action socket`,{kind:`linux-launch-action-socket-error`})}),t.listen(e),k.add(()=>{t.close(),o.rmSync(e,{force:!0})})}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to start Linux launch action socket`,{kind:`linux-launch-action-socket-start-failed`})}},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(codexLinuxStartLaunchActionSocket(),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; + const hotkeyWindowLaunchActionPatch = socketHotkeyWindowLaunchActionPatch + .replace( + "let ae=async(e,t)=>{", + `${quitStatePrefix}codexLinuxGetSetting=e=>process.platform!==\`linux\`||M.globalState.get(e)!==!1,codexLinuxIsTrayEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.systemTray}\`),codexLinuxIsWarmStartEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.warmStart}\`),codexLinuxIsPromptWindowEnabled=()=>codexLinuxGetSetting(\`${linuxSettingsKeys.promptWindow}\`),ae=async(e,t)=>{`, + ) + .replace( + "codexLinuxShowHotkeyWindow=async()=>{let e=P.hotkeyWindowLifecycleManager;typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)}", + "codexLinuxGetHotkeyWindowController=()=>typeof P.hotkeyWindowLifecycleManager.ensureHotkeyWindowController===`function`?P.hotkeyWindowLifecycleManager.ensureHotkeyWindowController():P.hotkeyWindowLifecycleManager,codexLinuxShowHotkeyWindow=async()=>{let e=codexLinuxGetHotkeyWindowController();typeof e.openHome===`function`?await e.openHome():typeof e.show===`function`?await e.show():await P.ensureHostWindow(z)}", + ) + .replace( + "Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0)", + "Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(codexLinuxIsPromptWindowEnabled()?(await codexLinuxShowHotkeyWindow(),!0):!1)", + ) + .replace( + "codexLinuxHandleLaunchActionArgs=async e=>", + "codexLinuxHandleLaunchActionArgs=async e=>(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?!0:", + ) + .replace( + "codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{", + "codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{if(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())return;", + ) + .replace( + "if(process.platform!==`linux`||!e)return;", + "if(process.platform!==`linux`||!e||!codexLinuxIsWarmStartEnabled())return;", + ) + .replace( + "codexLinuxStartLaunchActionSocket=()=>{", + "codexLinuxPrewarmHotkeyWindow=()=>{try{let e=codexLinuxGetHotkeyWindowController();typeof e.prewarm===`function`&&e.prewarm()}catch(e){g.reportNonFatal(e instanceof Error?e:`Failed to prewarm Linux hotkey window`,{kind:`linux-hotkey-window-prewarm-failed`})}},codexLinuxStartLaunchActionSocket=()=>{", + ) + .replace( + "codexLinuxPrewarmHotkeyWindow=()=>{try{", + "codexLinuxPrewarmHotkeyWindow=()=>{if(!codexLinuxIsPromptWindowEnabled())return;try{", + ) + .replace( + "process.platform===`linux`&&(codexLinuxStartLaunchActionSocket(),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});", + "let codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()};process.platform===`linux`&&(n.app.on(`before-quit`,codexLinuxBeforeQuitHandler),k.add(()=>{n.app.off(`before-quit`,codexLinuxBeforeQuitHandler)}),codexLinuxStartLaunchActionSocket(),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});", + ); + const showBasedHotkeyWindowLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxShowHotkeyWindow=async()=>{P.hotkeyWindowLifecycleManager.show()||await P.ensureHostWindow(z)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=P.getPrimaryWindow(z),t=e??await P.createFreshLocalWindow(`/`);t!=null&&(P.windowManager.sendMessageToWindow(t,{type:`new-quick-chat`}),re(t))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&(e.includes(`--prompt-chat`)||e.includes(`--hotkey-window`))?(await codexLinuxShowHotkeyWindow(),!0):Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await ae(`/`,{navigateExistingWindow:!0}),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; + const freshWindowLaunchActionPatch = + "let ae=async(e,t)=>{P.hotkeyWindowLifecycleManager.hide();let n=P.getPrimaryWindow(z),r=n??await P.createFreshLocalWindow(e);r!=null&&(n!=null&&t.navigateExistingWindow&&R.navigateToRoute(r,e),re(r))},codexLinuxOpenNewChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=await P.createFreshLocalWindow(`/`);e!=null&&re(e)},codexLinuxOpenQuickChat=async()=>{P.hotkeyWindowLifecycleManager.hide();let e=await P.createFreshLocalWindow(`/`);e!=null&&(P.windowManager.sendMessageToWindow(e,{type:`new-quick-chat`}),re(e))},codexLinuxHasDeepLink=e=>Array.isArray(e)&&e.some(e=>typeof e===`string`&&(e.startsWith(`codex://`)||e.startsWith(`codex-browser-sidebar://`))),codexLinuxHandleLaunchActionArgs=async e=>codexLinuxHasDeepLink(e)&&R.deepLinks.queueProcessArgs(e)?!0:Array.isArray(e)&&e.includes(`--quick-chat`)?(await codexLinuxOpenQuickChat(),!0):Array.isArray(e)&&e.includes(`--new-chat`)?(await codexLinuxOpenNewChat(),!0):!1,codexLinuxHandleLaunchActionArgsFallback=(e,t)=>{codexLinuxHandleLaunchActionArgs(e).then(e=>{e||t()}).catch(e=>{g.reportNonFatal(e instanceof Error?e:`Failed to handle Linux launch action`,{kind:`linux-launch-action-failed`}),t()})},codexLinuxSecondInstanceHandler=(e,t)=>{codexLinuxHandleLaunchActionArgsFallback(t,()=>{ie()})};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{codexLinuxHandleLaunchActionArgsFallback(e,()=>{ie()})});let oe=async()=>{"; + const launchActionPatch = + hotkeyWindowLaunchActionPatch; + + if ( + patchedSource.includes("codexLinuxQuitInProgress=!1") && + patchedSource.includes("codexLinuxExplicitQuitApproved=!1") && + patchedSource.includes("codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0}") && + patchedSource.includes("codexLinuxPrepareForExplicitQuit=()=>{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress()}") && + patchedSource.includes("codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0") && + patchedSource.includes("codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0") && + patchedSource.includes("codexLinuxGetSetting=e=>") && + patchedSource.includes("codexLinuxGetHotkeyWindowController=()=>") && + patchedSource.includes("codexLinuxPrewarmHotkeyWindow=()=>") && + patchedSource.includes("codexLinuxStartLaunchActionSocket=()=>") && + ( + patchedSource.includes("n.app.on(`before-quit`,codexLinuxBeforeQuitHandler)") || + /process\.platform===`linux`&&codexLinuxStartLaunchActionSocket\(\);[A-Za-z_$][\w$]*\(e=>\{codexLinuxHandleLaunchActionArgsFallback\(e,\(\)=>\{[A-Za-z_$][\w$]*\(\)\}\)\}\)/.test(patchedSource) + ) && + !patchedSource.includes("codexLinuxOpenNewChat") + ) { + return patchedSource; + } + + // Try cheap exact-string legacy needles first; only fall through to the + // semantic regex+capture pass if no known shape matches. + if (patchedSource.includes(oldLaunchActionPatch)) { + patchedSource = patchedSource.replace(oldLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(deepLinkFirstLaunchActionPatch)) { + patchedSource = patchedSource.replace(deepLinkFirstLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(deepLinkAwareExistingWindowLaunchActionPatch)) { + patchedSource = patchedSource.replace(deepLinkAwareExistingWindowLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(openHomeHotkeyWindowLaunchActionPatch)) { + patchedSource = patchedSource.replace(openHomeHotkeyWindowLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(socketHotkeyWindowLaunchActionPatch)) { + patchedSource = patchedSource.replace(socketHotkeyWindowLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(showBasedHotkeyWindowLaunchActionPatch)) { + patchedSource = patchedSource.replace(showBasedHotkeyWindowLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(freshWindowLaunchActionPatch)) { + patchedSource = patchedSource.replace(freshWindowLaunchActionPatch, launchActionPatch); + } else if (patchedSource.includes(launchActionNeedle)) { + patchedSource = patchedSource.replace(launchActionNeedle, launchActionPatch); + } else { + const semanticLaunchActionPatch = applySemanticLinuxLaunchActionArgsPatch(patchedSource); + if (semanticLaunchActionPatch !== patchedSource) { + return semanticLaunchActionPatch; + } + const currentSemanticLaunchActionPatch = applyCurrentSemanticLinuxLaunchActionArgsPatch(patchedSource); + if (currentSemanticLaunchActionPatch !== patchedSource) { + return currentSemanticLaunchActionPatch; + } + + const existingLinuxLaunchActionBlock = patchedSource.match( + /let ae=async\(e,t\)=>\{P\.hotkeyWindowLifecycleManager\.hide\(\);.*?;let oe=async\(\)=>\{/, + )?.[0]; + if (existingLinuxLaunchActionBlock?.includes("codexLinuxHandleLaunchActionArgs")) { + patchedSource = patchedSource.replace(existingLinuxLaunchActionBlock, launchActionPatch); + } else if ( + patchedSource.includes("Launching app") && + patchedSource.includes("deepLinks") + ) { + console.warn("WARN: Could not find Linux launch action handler - skipping --new-chat/--quick-chat/--prompt-chat patch"); + return patchedSource; + } else { + console.warn("WARN: Could not find Linux launch action handler - skipping --new-chat/--quick-chat/--prompt-chat patch"); + } + } + + if (patchedSource.includes("Launching app") && !patchedSource.includes("codexLinuxGetSetting=e=>")) { + console.warn("WARN: Linux launch action patch was not settings-gated - skipping --new-chat/--quick-chat/--prompt-chat patch"); + } + + return patchedSource; +} + +function applyLinuxHotkeyWindowPrewarmPatch(currentSource) { + let patchedSource = currentSource; + + if (!patchedSource.includes("codexLinuxPrewarmHotkeyWindow=()=>")) { + return patchedSource; + } + + const startupPrewarmPatch = + "process.platform===`linux`&&codexLinuxPrewarmHotkeyWindow(),A=Date.now(),await R.deepLinks.flushPendingDeepLinks()"; + + if (patchedSource.includes(startupPrewarmPatch)) { + return patchedSource; + } + + if ( + /process\.platform===`linux`&&codexLinuxPrewarmHotkeyWindow\(\),[A-Za-z_$][\w$]*=Date\.now\(\),await [A-Za-z_$][\w$]*\.deepLinks\.flushPendingDeepLinks\(\)/.test(patchedSource) + ) { + return patchedSource; + } + + const startupPrewarmNeedle = + "w(`local window ensured`,A,{hostId:z,localWindowVisible:me?.isVisible()??!1}),A=Date.now(),await R.deepLinks.flushPendingDeepLinks()"; + + if (patchedSource.includes(startupPrewarmNeedle)) { + patchedSource = patchedSource.replace(startupPrewarmNeedle, `w(\`local window ensured\`,A,{hostId:z,localWindowVisible:me?.isVisible()??!1}),${startupPrewarmPatch}`); + } else if ( + patchedSource.includes("process.platform===`linux`&&codexLinuxPrewarmHotkeyWindow(),A=Date.now(),await R.deepLinks.flushPendingDeepLinks()") + ) { + // Already patched by an older run. + } else { + const dynamicStartupPrewarmRegex = + /(w\(`local window ensured`,([A-Za-z_$][\w$]*),\{hostId:([A-Za-z_$][\w$]*),localWindowVisible:[^}]+\}\),)\2=Date\.now\(\),await ([A-Za-z_$][\w$]*)\.deepLinks\.flushPendingDeepLinks\(\)/; + const dynamicStartupPrewarmMatch = patchedSource.match(dynamicStartupPrewarmRegex); + if (dynamicStartupPrewarmMatch != null) { + const [, prefix, timeVar, , deepLinksVar] = dynamicStartupPrewarmMatch; + patchedSource = patchedSource.replace( + dynamicStartupPrewarmRegex, + `${prefix}process.platform===\`linux\`&&codexLinuxPrewarmHotkeyWindow(),${timeVar}=Date.now(),await ${deepLinksVar}.deepLinks.flushPendingDeepLinks()`, + ); + } else { + console.warn("WARN: Could not find Linux hotkey window prewarm insertion point — skipping startup prewarm patch"); + } + } + + return patchedSource; +} + +module.exports = { + applyLinuxHotkeyWindowPrewarmPatch, + applyLinuxLaunchActionArgsPatch, + applyLinuxSettingsPersistencePatch, + applyLinuxTrayCloseSettingPatch, + applySemanticLinuxLaunchActionArgsPatch, +}; diff --git a/scripts/patches/main-process.js b/scripts/patches/main-process.js new file mode 100644 index 00000000..c4762c0e --- /dev/null +++ b/scripts/patches/main-process.js @@ -0,0 +1,986 @@ +"use strict"; + +const { + TRAY_GUARD_LOOKAHEAD, + escapeRegExp, + findCallBlock, + findMatchingBrace, + inferModuleAlias, + requireName, +} = require("./shared.js"); + +// Main-process patches adapt Electron shell behavior: windows, tray, menu, +// single-instance handling, file manager integration, and packaged runtime glue. +function applyLinuxFileManagerPatch(currentSource) { + const block = findCallBlock(currentSource, "id:`fileManager`"); + if (block == null) { + console.warn("Failed to apply Linux File Manager Patch"); + return currentSource; + } + + if (block.text.includes("linux:{")) { + return currentSource; + } + + const electronVar = requireName(currentSource, "electron"); + const fsVar = requireName(currentSource, "node:fs"); + const pathVar = requireName(currentSource, "node:path"); + if (electronVar == null || fsVar == null || pathVar == null) { + console.warn("Failed to apply Linux File Manager Patch"); + return currentSource; + } + + const insertionPoint = block.text.lastIndexOf("}});"); + if (insertionPoint === -1) { + console.warn("Failed to apply Linux File Manager Patch"); + return currentSource; + } + + const linuxFileManager = + `,linux:{label:\`File Manager\`,icon:\`apps/file-explorer.png\`,detect:()=>\`linux-file-manager\`,args:e=>[e],open:async({path:e})=>{let __codexResolved=e;for(;;){if((0,${fsVar}.existsSync)(__codexResolved))break;let __codexParent=(0,${pathVar}.dirname)(__codexResolved);if(__codexParent===__codexResolved){__codexResolved=null;break}__codexResolved=__codexParent}let __codexOpenTarget=__codexResolved??e;if((0,${fsVar}.existsSync)(__codexOpenTarget)&&(0,${fsVar}.statSync)(__codexOpenTarget).isFile())__codexOpenTarget=(0,${pathVar}.dirname)(__codexOpenTarget);let __codexError=await ${electronVar}.shell.openPath(__codexOpenTarget);if(__codexError)throw Error(__codexError)}}`; + + const patchedBlock = + block.text.slice(0, insertionPoint + 1) + + linuxFileManager + + block.text.slice(insertionPoint + 1); + const patchedSource = + currentSource.slice(0, block.start) + patchedBlock + currentSource.slice(block.end); + + const patchedBlockCheck = patchedSource.slice(block.start, block.start + patchedBlock.length); + if ( + !patchedBlockCheck.includes("linux:{label:`File Manager`") || + !patchedBlockCheck.includes("detect:()=>`linux-file-manager`") || + !patchedBlockCheck.includes(`${electronVar}.shell.openPath(__codexOpenTarget)`) + ) { + console.warn("Failed to apply Linux File Manager Patch"); + return currentSource; + } + + return patchedSource; +} + +function applyLinuxWindowOptionsPatch(currentSource, iconAsset) { + if (iconAsset == null) { + return currentSource; + } + + const windowOptionsNeedle = "...process.platform===`win32`?{autoHideMenuBar:!0}:{},"; + const iconPathExpression = `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``; + const iconPathNeedle = `icon:${iconPathExpression}`; + const windowOptionsReplacement = + `...process.platform===\`win32\`||process.platform===\`linux\`?{autoHideMenuBar:!0,...process.platform===\`linux\`?{${iconPathNeedle}}:{}}:{},`; + + if (currentSource.includes(iconPathNeedle)) { + return currentSource; + } + + if (currentSource.includes(windowOptionsNeedle)) { + return currentSource.replace(windowOptionsNeedle, windowOptionsReplacement); + } + + console.warn("WARN: Could not find BrowserWindow autoHideMenuBar snippet — skipping window options patch"); + return currentSource; +} + +function applyLinuxMenuPatch(currentSource) { + const menuRegex = /process\.platform===`win32`&&([A-Za-z_$][\w$]*)\.removeMenu\(\),/g; + let patchedAny = false; + const patchedSource = currentSource.replace(menuRegex, (match, windowVar) => { + const linuxPatch = `process.platform===\`linux\`&&${windowVar}.setMenuBarVisibility(!1),`; + if (currentSource.includes(`${linuxPatch}${match}`)) { + return match; + } + patchedAny = true; + return `${linuxPatch}${match}`; + }); + + if (!patchedAny && menuRegex.test(currentSource) && !currentSource.includes("setMenuBarVisibility(!1),process.platform===`win32`")) { + console.warn("WARN: Could not find window menu visibility snippet — skipping menu patch"); + } + + return patchedSource; +} + +function applyLinuxSetIconPatch(currentSource, iconAsset) { + if (iconAsset == null) { + return currentSource; + } + + const iconPathExpression = `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``; + if (currentSource.includes(`setIcon(${iconPathExpression})`)) { + return currentSource; + } + + const readyRegex = /([A-Za-z_$][\w$]*)\.once\(`ready-to-show`,\(\)=>\{/; + const match = currentSource.match(readyRegex); + if (match == null) { + console.warn("WARN: Could not find window setIcon insertion point — skipping setIcon patch"); + return currentSource; + } + + const windowVar = match[1]; + return currentSource.replace( + readyRegex, + `process.platform===\`linux\`&&${windowVar}.setIcon(${iconPathExpression}),${match[0]}`, + ); +} + +function applyLinuxAvatarOverlayMousePassthroughPatch(currentSource) { + let patchedSource = currentSource; + + const interactivityNeedle = + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1;return}let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}refreshCursorAtCurrentMousePosition(e){"; + const previousInteractivityNeedle = + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1,this.codexLinuxStopAvatarPassthroughRecovery();return}let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0}),this.codexLinuxStartAvatarPassthroughRecovery();return}this.codexLinuxStopAvatarPassthroughRecovery(),e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}else t&&this.codexLinuxStartAvatarPassthroughRecovery()}codexLinuxStopAvatarPassthroughRecovery(){this.codexLinuxAvatarPassthroughRecoveryTimer!=null&&(clearInterval(this.codexLinuxAvatarPassthroughRecoveryTimer),this.codexLinuxAvatarPassthroughRecoveryTimer=null)}codexLinuxRecoverAvatarPointerInteractivity(){this.pointerInteractive=!0,this.applyPointerInteractivityPolicy()}codexLinuxStartAvatarPassthroughRecovery(){if(process.platform!==`linux`||this.codexLinuxAvatarPassthroughRecoveryTimer!=null)return;this.codexLinuxAvatarPassthroughRecoveryTimer=setInterval(()=>{let e=this.window;if(e==null||e.isDestroyed()||!this.mousePassthroughEnabled){this.codexLinuxStopAvatarPassthroughRecovery();return}let t;try{t=this.codexLinuxIsCursorInAvatarInteractiveRegion(e)}catch{this.codexLinuxRecoverAvatarPointerInteractivity();return}t&&this.codexLinuxRecoverAvatarPointerInteractivity()},80),this.codexLinuxAvatarPassthroughRecoveryTimer.unref?.()}codexLinuxIsCursorInAvatarInteractiveRegion(e){let t=this.layout;if(t==null)return!1;let r=n.screen.getCursorScreenPoint(),i=e.getContentBounds(),a=r.x-i.x,o=r.y-i.y,s=e=>e!=null&&a>=e.left&&a<=e.left+e.width&&o>=e.top&&o<=e.top+e.height;return s(t.mascot)||s(t.tray)}refreshCursorAtCurrentMousePosition(e){"; + const previousSyncInteractivityNeedle = + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1,this.codexLinuxStopAvatarPassthroughRecovery();return}process.platform===`linux`&&(this.codexLinuxStartAvatarPassthroughRecovery(),this.codexLinuxSyncAvatarPointerInteractivity(e));let t=!this.pointerInteractive;this.dragState!=null&&(t=!1);if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}codexLinuxStopAvatarPassthroughRecovery(){this.codexLinuxAvatarPassthroughRecoveryTimer!=null&&(clearInterval(this.codexLinuxAvatarPassthroughRecoveryTimer),this.codexLinuxAvatarPassthroughRecoveryTimer=null)}codexLinuxStartAvatarPassthroughRecovery(){if(process.platform!==`linux`||this.codexLinuxAvatarPassthroughRecoveryTimer!=null)return;this.codexLinuxAvatarPassthroughRecoveryTimer=setInterval(()=>{let e=this.window;if(e==null||e.isDestroyed()||!e.isVisible()){this.codexLinuxStopAvatarPassthroughRecovery();return}this.codexLinuxSyncAvatarPointerInteractivity(e)&&this.applyPointerInteractivityPolicy()},32),this.codexLinuxAvatarPassthroughRecoveryTimer.unref?.()}codexLinuxSyncAvatarPointerInteractivity(e){if(process.platform!==`linux`||e==null||e.isDestroyed())return!1;if(this.dragState!=null){if(this.pointerInteractive)return!1;return this.pointerInteractive=!0,!0}let t;try{t=this.codexLinuxIsCursorInAvatarInteractiveRegion(e)}catch{t=!0}return this.pointerInteractive===t?!1:(this.pointerInteractive=t,!0)}codexLinuxIsCursorInAvatarInteractiveRegion(e){let t=this.layout;if(t==null)return!1;let r=n.screen.getCursorScreenPoint(),i=e.getContentBounds(),a=r.x-i.x,o=r.y-i.y;if(a<0||o<0||a>i.width||o>i.height)return!1;let s=e=>e!=null&&a>=e.left&&a<=e.left+e.width&&o>=e.top&&o<=e.top+e.height;return s(t.mascot)||s(t.tray)}refreshCursorAtCurrentMousePosition(e){"; + const interactivityPatch = + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1,this.codexLinuxStopAvatarPassthroughRecovery();return}if(process.platform===`linux`&&typeof e.setShape==`function`){this.codexLinuxStopAvatarPassthroughRecovery(),this.mousePassthroughEnabled&&(this.mousePassthroughEnabled=!1,e.setIgnoreMouseEvents(!1));if(this.codexLinuxApplyAvatarInputShape(e))return}process.platform===`linux`&&(this.codexLinuxStartAvatarPassthroughRecovery(),this.codexLinuxSyncAvatarPointerInteractivity(e));let t=!this.pointerInteractive;this.dragState!=null&&(t=!1);if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}codexLinuxStopAvatarPassthroughRecovery(){this.codexLinuxAvatarPassthroughRecoveryTimer!=null&&(clearInterval(this.codexLinuxAvatarPassthroughRecoveryTimer),this.codexLinuxAvatarPassthroughRecoveryTimer=null)}codexLinuxBuildAvatarInputShape(e){let t=this.layout;if(t==null)return null;if(this.dragState!=null){let t=e.getContentBounds();return[{x:0,y:0,width:t.width,height:t.height}]}let r=e.getContentBounds(),i=e=>{if(e==null)return null;let t=Math.max(0,e.left),n=Math.max(0,e.top),i=Math.min(r.width,e.left+e.width)-t,a=Math.min(r.height,e.top+e.height)-n;return i<=0||a<=0?null:{x:t,y:n,width:i,height:a}};return[i(t.mascot),i(t.tray)].filter(Boolean)}codexLinuxApplyAvatarInputShape(e){if(process.platform!==`linux`||e==null||e.isDestroyed()||typeof e.setShape!=`function`)return!1;let t=this.codexLinuxBuildAvatarInputShape(e);if(t==null)return!1;let n=JSON.stringify(t);if(this.codexLinuxAvatarInputShapeKey===n)return!0;try{e.setShape(t),this.codexLinuxAvatarInputShapeKey=n;return!0}catch{this.codexLinuxAvatarInputShapeKey=null;return!1}}codexLinuxStartAvatarPassthroughRecovery(){if(process.platform!==`linux`||this.codexLinuxAvatarPassthroughRecoveryTimer!=null)return;this.codexLinuxAvatarPassthroughRecoveryTimer=setInterval(()=>{let e=this.window;if(e==null||e.isDestroyed()||!e.isVisible()){this.codexLinuxStopAvatarPassthroughRecovery();return}this.codexLinuxSyncAvatarPointerInteractivity(e)&&this.applyPointerInteractivityPolicy()},32),this.codexLinuxAvatarPassthroughRecoveryTimer.unref?.()}codexLinuxSyncAvatarPointerInteractivity(e){if(process.platform!==`linux`||e==null||e.isDestroyed())return!1;if(this.dragState!=null){if(this.pointerInteractive)return!1;return this.pointerInteractive=!0,!0}let t;try{t=this.codexLinuxIsCursorInAvatarInteractiveRegion(e)}catch{t=!0}return this.pointerInteractive===t?!1:(this.pointerInteractive=t,!0)}codexLinuxIsCursorInAvatarInteractiveRegion(e){let t=this.layout;if(t==null)return!1;let r=n.screen.getCursorScreenPoint(),i=e.getContentBounds(),a=r.x-i.x,o=r.y-i.y;if(a<0||o<0||a>i.width||o>i.height)return!1;let s=e=>e!=null&&a>=e.left&&a<=e.left+e.width&&o>=e.top&&o<=e.top+e.height;return s(t.mascot)||s(t.tray)}refreshCursorAtCurrentMousePosition(e){"; + + if (!patchedSource.includes("codexLinuxApplyAvatarInputShape")) { + if (patchedSource.includes(interactivityNeedle)) { + patchedSource = patchedSource.replace(interactivityNeedle, interactivityPatch); + } else if (patchedSource.includes(previousInteractivityNeedle)) { + patchedSource = patchedSource.replace(previousInteractivityNeedle, interactivityPatch); + } else if (patchedSource.includes(previousSyncInteractivityNeedle)) { + patchedSource = patchedSource.replace(previousSyncInteractivityNeedle, interactivityPatch); + } else if ( + patchedSource.includes("avatar-overlay") && + patchedSource.includes("applyPointerInteractivityPolicy(){let e=this.window") + ) { + console.warn( + "WARN: Could not find avatar overlay mouse passthrough policy — skipping Linux avatar overlay passthrough recovery patch", + ); + return currentSource; + } + } + + const previousStartDragPatch = + "startDrag(e,{pointerWindowX:t,pointerWindowY:r}){let i=this.window;if(i==null||i.isDestroyed()||i.webContents.id!==e)return;this.pointerInteractive=!0,this.applyPointerInteractivityPolicy(),this.cancelMomentum();"; + const originalStartDragPrefix = + "startDrag(e,{pointerWindowX:t,pointerWindowY:r}){let i=this.window;if(i==null||i.isDestroyed()||i.webContents.id!==e)return;this.cancelMomentum();"; + const startDragNeedle = + "displayBounds:n.screen.getDisplayNearestPoint(n.screen.getCursorScreenPoint()).bounds}}moveDrag(e){"; + const startDragPatch = + "displayBounds:n.screen.getDisplayNearestPoint(n.screen.getCursorScreenPoint()).bounds},process.platform===`linux`&&(this.pointerInteractive=!0,this.applyPointerInteractivityPolicy())}moveDrag(e){"; + const previousStartDragAfterStatePatch = + "displayBounds:n.screen.getDisplayNearestPoint(n.screen.getCursorScreenPoint()).bounds},this.pointerInteractive=!0,this.applyPointerInteractivityPolicy()}moveDrag(e){"; + if (patchedSource.includes(previousStartDragPatch)) { + patchedSource = patchedSource.replace(previousStartDragPatch, originalStartDragPrefix); + } + if (patchedSource.includes(previousStartDragAfterStatePatch)) { + patchedSource = patchedSource.replace(previousStartDragAfterStatePatch, startDragPatch); + } else if (patchedSource.includes(startDragNeedle)) { + patchedSource = patchedSource.replace(startDragNeedle, startDragPatch); + } else if ( + patchedSource.includes("avatar-overlay") && + !patchedSource.includes(startDragPatch) + ) { + console.warn( + "WARN: Could not find avatar overlay drag start — skipping Linux avatar overlay drag interactivity patch", + ); + } + + const endDragNeedle = + "endDrag(e){let t=this.window;t==null||t.isDestroyed()||t.webContents.id!==e||(this.dragState?.hasMoved&&this.moveDragToCurrentCursor(t),this.dragState=null,this.reclampWindowToVisibleDisplay({shouldPersist:!0}))}"; + const endDragPatch = + "endDrag(e){let t=this.window;t==null||t.isDestroyed()||t.webContents.id!==e||(this.dragState?.hasMoved&&this.moveDragToCurrentCursor(t),this.dragState=null,this.reclampWindowToVisibleDisplay({shouldPersist:!0}),process.platform===`linux`&&this.applyPointerInteractivityPolicy())}"; + const previousEndDragPatch = + "endDrag(e){let t=this.window;t==null||t.isDestroyed()||t.webContents.id!==e||(this.dragState?.hasMoved&&this.moveDragToCurrentCursor(t),this.dragState=null,this.reclampWindowToVisibleDisplay({shouldPersist:!0}),this.codexLinuxSyncAvatarPointerInteractivity(t)&&this.applyPointerInteractivityPolicy())}"; + if (patchedSource.includes(previousEndDragPatch)) { + patchedSource = patchedSource.replace(previousEndDragPatch, endDragPatch); + } else if (patchedSource.includes(endDragNeedle)) { + patchedSource = patchedSource.replace(endDragNeedle, endDragPatch); + } else if ( + patchedSource.includes("avatar-overlay") && + !patchedSource.includes(endDragPatch) + ) { + console.warn( + "WARN: Could not find avatar overlay drag end — skipping Linux avatar overlay drag cleanup patch", + ); + } + + const setElementSizeNeedle = + "setElementSize(e,{mascot:t,tray:n}){let r=this.window;r==null||r.isDestroyed()||r.webContents.id!==e||(this.cancelMomentum(),this.anchor={...this.anchor,width:t.width,height:t.height},this.mascotSize=t,this.traySize=n,this.applyLayout(r))}"; + const setElementSizePatch = + "setElementSize(e,{mascot:t,tray:n}){let r=this.window;r==null||r.isDestroyed()||r.webContents.id!==e||(this.cancelMomentum(),this.anchor={...this.anchor,width:t.width,height:t.height},this.mascotSize=t,this.traySize=n,this.applyLayout(r),process.platform===`linux`&&this.applyPointerInteractivityPolicy())}"; + const previousSetElementSizePatch = + "setElementSize(e,{mascot:t,tray:n}){let r=this.window;r==null||r.isDestroyed()||r.webContents.id!==e||(this.cancelMomentum(),this.anchor={...this.anchor,width:t.width,height:t.height},this.mascotSize=t,this.traySize=n,this.applyLayout(r),this.codexLinuxSyncAvatarPointerInteractivity(r)&&this.applyPointerInteractivityPolicy())}"; + if (patchedSource.includes(previousSetElementSizePatch)) { + patchedSource = patchedSource.replace(previousSetElementSizePatch, setElementSizePatch); + } else if (patchedSource.includes(setElementSizeNeedle)) { + patchedSource = patchedSource.replace(setElementSizeNeedle, setElementSizePatch); + } else if ( + patchedSource.includes("avatar-overlay") && + !patchedSource.includes(setElementSizePatch) + ) { + console.warn( + "WARN: Could not find avatar overlay element size update — skipping Linux avatar overlay layout interactivity patch", + ); + } + + const applyLayoutNeedle = + "this.setWindowBounds(e,r.windowBounds),this.sendLayoutToRenderer(e)}getLayout(e){"; + const applyLayoutPatch = + "this.setWindowBounds(e,r.windowBounds),this.sendLayoutToRenderer(e),process.platform===`linux`&&this.applyPointerInteractivityPolicy()}getLayout(e){"; + const previousApplyLayoutPatch = + "this.setWindowBounds(e,r.windowBounds),this.sendLayoutToRenderer(e),this.codexLinuxSyncAvatarPointerInteractivity(e)&&this.applyPointerInteractivityPolicy()}getLayout(e){"; + if (patchedSource.includes(previousApplyLayoutPatch)) { + patchedSource = patchedSource.replace(previousApplyLayoutPatch, applyLayoutPatch); + } else if (patchedSource.includes(applyLayoutNeedle)) { + patchedSource = patchedSource.replace(applyLayoutNeedle, applyLayoutPatch); + } else if ( + patchedSource.includes("avatar-overlay") && + !patchedSource.includes(applyLayoutPatch) + ) { + console.warn( + "WARN: Could not find avatar overlay layout application — skipping Linux avatar overlay layout sync patch", + ); + } + + const showWindowNeedle = + "e.moveTop(),e.showInactive(),!t&&this.isOpen()&&this.broadcastOpenState()}broadcastOpenState(){"; + const showWindowPatch = + "e.moveTop(),e.showInactive(),process.platform===`linux`&&this.applyPointerInteractivityPolicy(),!t&&this.isOpen()&&this.broadcastOpenState()}broadcastOpenState(){"; + const previousShowWindowPatch = + "e.moveTop(),e.showInactive(),process.platform===`linux`&&this.codexLinuxStartAvatarPassthroughRecovery(),this.codexLinuxSyncAvatarPointerInteractivity(e)&&this.applyPointerInteractivityPolicy(),!t&&this.isOpen()&&this.broadcastOpenState()}broadcastOpenState(){"; + if (patchedSource.includes(previousShowWindowPatch)) { + patchedSource = patchedSource.replace(previousShowWindowPatch, showWindowPatch); + } else if (patchedSource.includes(showWindowNeedle)) { + patchedSource = patchedSource.replace(showWindowNeedle, showWindowPatch); + } else if ( + patchedSource.includes("avatar-overlay") && + !patchedSource.includes(showWindowPatch) + ) { + console.warn( + "WARN: Could not find avatar overlay show window — skipping Linux avatar overlay show sync patch", + ); + } + + const closedNeedle = "this.window===t&&(this.cancelMomentum(),this.window=null,"; + const closedPatch = + "this.window===t&&(this.codexLinuxStopAvatarPassthroughRecovery(),this.codexLinuxAvatarInputShapeKey=null,this.cancelMomentum(),this.window=null,"; + const previousClosedPatch = + "this.window===t&&(this.codexLinuxStopAvatarPassthroughRecovery(),this.cancelMomentum(),this.window=null,"; + if (patchedSource.includes(previousClosedPatch)) { + patchedSource = patchedSource.replace(previousClosedPatch, closedPatch); + } else if (patchedSource.includes(closedNeedle)) { + patchedSource = patchedSource.replace(closedNeedle, closedPatch); + } else if ( + patchedSource.includes("avatar-overlay") && + patchedSource.includes("codexLinuxStartAvatarPassthroughRecovery") && + !patchedSource.includes(closedPatch) + ) { + console.warn( + "WARN: Could not find avatar overlay close cleanup — skipping Linux avatar overlay passthrough cleanup patch", + ); + } + + return patchedSource; +} + +function applyLinuxOpaqueBackgroundPatch(currentSource) { + if (currentSource.includes("===`linux`&&!OM(")) { + return currentSource; + } + + const colorConstRegex = + /([A-Za-z_$][\w$]*)=`#00000000`,([A-Za-z_$][\w$]*)=`#000000`,([A-Za-z_$][\w$]*)=`#f9f9f9`/; + const colorMatch = currentSource.match(colorConstRegex); + + if (!colorMatch) { + console.warn( + "WARN: Could not find color constants (#00000000, #000000, #f9f9f9) — skipping background patch", + ); + return currentSource; + } + + const [, transparentVar, darkVar, lightVar] = colorMatch; + const funcParamRegex = + /function\s+[A-Za-z_$][\w$]*\(\{platform:([A-Za-z_$][\w$]*),appearance:([A-Za-z_$][\w$]*),opaqueWindowsEnabled:[A-Za-z_$][\w$]*,prefersDarkColors:([A-Za-z_$][\w$]*)\}\)\{return\s*\1===`win32`&&!([A-Za-z_$][\w$]*)\(\2\)/; + const funcMatch = currentSource.match(funcParamRegex); + + if (funcMatch == null) { + console.warn("WARN: Could not find BrowserWindow background function signature — skipping background patch"); + return currentSource; + } + + const [, platformParam, appearanceParam, darkColorsParam, transparentAppearancePredicate] = + funcMatch; + const bgNeedle = + `backgroundMaterial:\`mica\`}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`; + const oldLinuxBgPatch = + `backgroundMaterial:\`mica\`}:process.platform===\`linux\`?{backgroundColor:${darkColorsParam}?${darkVar}:${lightVar},backgroundMaterial:null}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`; + const oldLinuxBgPatchWithPredicate = + `backgroundMaterial:\`mica\`}:process.platform===\`linux\`&&![A-Za-z_$][\\w$]*\\(${appearanceParam}\\)\\?\\{backgroundColor:${darkColorsParam}\\?${darkVar}:${lightVar},backgroundMaterial:null\\}:\\{backgroundColor:${transparentVar},backgroundMaterial:null\\}\\}`; + const bgReplacement = + `backgroundMaterial:\`mica\`}:${platformParam}===\`linux\`&&!${transparentAppearancePredicate}(${appearanceParam})?{backgroundColor:${darkColorsParam}?${darkVar}:${lightVar},backgroundMaterial:null}:{backgroundColor:${transparentVar},backgroundMaterial:null}}`; + + if (currentSource.includes(bgReplacement)) { + return currentSource; + } + if (currentSource.includes(bgNeedle)) { + return currentSource.replace(bgNeedle, bgReplacement); + } + if (currentSource.includes(oldLinuxBgPatch)) { + return currentSource.replace(oldLinuxBgPatch, bgReplacement); + } + const oldLinuxBgPatchWithPredicateRegex = new RegExp(oldLinuxBgPatchWithPredicate); + if (oldLinuxBgPatchWithPredicateRegex.test(currentSource)) { + return currentSource.replace(oldLinuxBgPatchWithPredicateRegex, bgReplacement); + } + + console.warn("WARN: Could not find BrowserWindow background color needle — skipping background patch"); + return currentSource; +} + +function findNamedFunctionBody(source, functionName) { + const functionMatch = source.match( + new RegExp(`(?:async\\s+)?function\\s+${escapeRegExp(functionName)}\\([^)]*\\)\\{`), + ); + if (functionMatch == null) { + return null; + } + + const openIndex = functionMatch.index + functionMatch[0].length - 1; + const closeIndex = findMatchingBrace(source, openIndex); + return closeIndex === -1 ? null : source.slice(openIndex, closeIndex + 1); +} + +function isTrayFactoryFunction(source, functionName) { + const body = findNamedFunctionBody(source, functionName); + return body != null && /new [A-Za-z_$][\w$]*\.Tray\(/.test(body); +} + +function findDynamicTraySetup(source) { + const setupRegex = + /let ([A-Za-z_$][\w$]*)=async\(\)=>\{[A-Za-z_$][\w$]*=!0;try\{await ([A-Za-z_$][\w$]*)\(\{buildFlavor:/g; + let match; + while ((match = setupRegex.exec(source)) != null) { + const [, setupFn, factoryFn] = match; + if (isTrayFactoryFunction(source, factoryFn)) { + return { setupFn, index: match.index }; + } + } + return null; +} + +function findDynamicTrayStartupCall(source, setupFn, startIndex) { + const startupRegex = new RegExp(`([A-Za-z_$][\\w$]*)&&${escapeRegExp(setupFn)}\\(\\);`, "g"); + startupRegex.lastIndex = startIndex; + return startupRegex.exec(source); +} + +function applyLinuxQuitGuardPatch(currentSource) { + let patchedSource = currentSource; + + const quitGuardNeedle = "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`);"; + const legacyQuitGuardSuffix = + "let codexLinuxQuitInProgress=!1,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0;"; + const quitGuardSuffix = + "let codexLinuxQuitInProgress=!1,codexLinuxExplicitQuitApproved=!1,codexLinuxExplicitQuitDrainTimeoutMs=3e3,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxPrepareForExplicitQuit=()=>{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress()},codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0,codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0;"; + const quitGuardPatch = `${quitGuardNeedle}${quitGuardSuffix}`; + + if (patchedSource.includes("codexLinuxExplicitQuitApproved=!1")) { + return patchedSource; + } + + if (patchedSource.includes(legacyQuitGuardSuffix)) { + return patchedSource.replace(legacyQuitGuardSuffix, quitGuardSuffix); + } + + if (patchedSource.includes(quitGuardNeedle)) { + return patchedSource.replace(quitGuardNeedle, quitGuardPatch); + } + + const splitQuitGuardNeedle = + /let ([A-Za-z_$][\w$]*)=require\(`electron`\);(?:\1=[^;]+;)?let ([A-Za-z_$][\w$]*)=require\(`node:path`\);(?:\2=[^;]+;)?let ([A-Za-z_$][\w$]*)=require\(`node:fs`\);(?:\3=[^;]+;)?/; + const splitQuitGuardMatch = patchedSource.match(splitQuitGuardNeedle); + if (splitQuitGuardMatch != null) { + const matchedPrefix = splitQuitGuardMatch[0]; + return patchedSource.replace(matchedPrefix, `${matchedPrefix}${quitGuardSuffix}`); + } + + if (patchedSource.includes("require(`electron`)")) { + return `${quitGuardSuffix}${patchedSource}`; + } + + if (patchedSource.includes("require(`electron`)") && patchedSource.includes("require(`node:path`)")) { + console.warn("WARN: Could not find Linux quit guard insertion point — skipping explicit quit-state patch"); + } + + return patchedSource; +} + +function linuxExplicitQuitExpression() { + return "typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),"; +} + +function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { + let patchedSource = currentSource; + + const explicitQuitDrainGuard = + "process.platform===`linux`&&(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())"; + const originalDrainSnippet = + "Promise.all([...u.values()].map(e=>e.flush())).finally(()=>{d(),f.dispose(),n.app.quit()})"; + const patchedDrainSnippet = + "(()=>{let codexLinuxFinalizeQuit=()=>{d(),f.dispose(),n.app.quit()},codexLinuxDrainPromise=Promise.all([...u.values()].map(e=>e.flush()));" + + `if(${explicitQuitDrainGuard}){Promise.race([codexLinuxDrainPromise,new Promise(e=>setTimeout(e,typeof codexLinuxExplicitQuitDrainTimeoutMs===\`number\`?codexLinuxExplicitQuitDrainTimeoutMs:3e3))]).finally(codexLinuxFinalizeQuit);return}` + + "codexLinuxDrainPromise.finally(codexLinuxFinalizeQuit)})()"; + + if (patchedSource.includes("codexLinuxDrainPromise=Promise.all([...u.values()].map(e=>e.flush()))")) { + return patchedSource; + } + + if (patchedSource.includes(originalDrainSnippet)) { + return patchedSource.replace(originalDrainSnippet, patchedDrainSnippet); + } + + const drainRegex = + /Promise\.all\(\[\.\.\.([A-Za-z_$][\w$]*)\.values\(\)\]\.map\(e=>e\.flush\(\)\)\)\.finally\(\(\)=>\{([A-Za-z_$][\w$]*)\(\),([A-Za-z_$][\w$]*)\.dispose\(\),([A-Za-z_$][\w$]*)\.app\.quit\(\)\}\)/; + if (drainRegex.test(patchedSource)) { + patchedSource = patchedSource.replace( + drainRegex, + (_match, globalStatesVar, flushDisposeVar, disposablesVar, electronVar) => + `(()=>{let codexLinuxFinalizeQuit=()=>{${flushDisposeVar}(),${disposablesVar}.dispose(),${electronVar}.app.quit()},codexLinuxDrainPromise=Promise.all([...${globalStatesVar}.values()].map(e=>e.flush()));if(${explicitQuitDrainGuard}){Promise.race([codexLinuxDrainPromise,new Promise(e=>setTimeout(e,typeof codexLinuxExplicitQuitDrainTimeoutMs===\`number\`?codexLinuxExplicitQuitDrainTimeoutMs:3e3))]).finally(codexLinuxFinalizeQuit);return}codexLinuxDrainPromise.finally(codexLinuxFinalizeQuit)})()`, + ); + } else if ( + patchedSource.includes("n.app.on(`will-quit`,") && + patchedSource.includes(".map(e=>e.flush())") + ) { + console.warn("WARN: Could not find will-quit drain sequence — skipping Linux explicit quit drain timeout patch"); + } + + return patchedSource; +} + +function applyLinuxExplicitQuitPromptBypassPatch(currentSource) { + let patchedSource = currentSource; + + const promptBypassExpression = + "(typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt())||"; + const promptBypassGuard = `if(${promptBypassExpression}`; + const beforeQuitNeedle = + "if(e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}"; + const beforeQuitPatch = + `if(${promptBypassExpression}e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}`; + const beforeQuitRegex = + /if\(([A-Za-z_$][\w$]*)\|\|([A-Za-z_$][\w$]*)\.canQuitWithoutPrompt\(\)\|\|([A-Za-z_$][\w$]*)\|\|!([A-Za-z_$][\w$]*)&&!([A-Za-z_$][\w$]*)\)\{([A-Za-z_$][\w$]*)=!0,([A-Za-z_$][\w$]*)\.markAppQuitting\(\);return\}/; + + if (patchedSource.includes(promptBypassGuard)) { + return patchedSource; + } + + if (patchedSource.includes(beforeQuitNeedle)) { + return patchedSource.replace(beforeQuitNeedle, beforeQuitPatch); + } + + if (beforeQuitRegex.test(patchedSource)) { + patchedSource = patchedSource.replace( + beforeQuitRegex, + (_match, updateInstallVar, quitControllerVar, appQuittingVar, activeConversationVar, automationVar, quittingStateVar, appQuittingControllerVar) => + `if(${promptBypassExpression}${updateInstallVar}||${quitControllerVar}.canQuitWithoutPrompt()||${appQuittingVar}||!${activeConversationVar}&&!${automationVar}){${quittingStateVar}=!0,${appQuittingControllerVar}.markAppQuitting();return}`, + ); + } else if ( + patchedSource.includes("showMessageBoxSync({type:`warning`,buttons:[`Quit`,`Cancel`]") && + patchedSource.includes(".canQuitWithoutPrompt()") + ) { + console.warn("WARN: Could not find before-quit confirmation guard — skipping Linux explicit quit prompt bypass patch"); + } + + return patchedSource; +} + +function applyLinuxExplicitTrayQuitPatch(currentSource) { + let patchedSource = currentSource; + + const quitMarkerExpression = linuxExplicitQuitExpression(); + + const trayQuitNeedle = "{label:rB(this.appName),click:()=>{n.app.quit()}}"; + const trayQuitPatch = + `{label:rB(this.appName),click:()=>{${quitMarkerExpression}n.app.quit()}}`; + const trayQuitRegex = + /\{label:rB\(([^)]+)\),click:\(\)=>\{([A-Za-z_$][\w$]*)\.app\.quit\(\)\}\}/; + if (patchedSource.includes(trayQuitPatch)) { + // Already patched. + } else if (patchedSource.includes(trayQuitNeedle)) { + patchedSource = patchedSource.replace(trayQuitNeedle, trayQuitPatch); + } else if (trayQuitRegex.test(patchedSource)) { + patchedSource = patchedSource.replace( + trayQuitRegex, + (_match, appNameExpr, electronVar) => + `{label:rB(${appNameExpr}),click:()=>{${quitMarkerExpression}${electronVar}.app.quit()}}`, + ); + } else if ( + patchedSource.includes("getNativeTrayMenuItems(){") && + (patchedSource.includes("label:rB(") || patchedSource.includes("role:`quit`")) + ) { + console.warn("WARN: Could not find tray quit menu handler — skipping Linux explicit tray quit patch"); + } + + return patchedSource; +} + +function applyLinuxExplicitIpcQuitPatch(currentSource) { + let patchedSource = currentSource; + + const quitMarkerExpression = linuxExplicitQuitExpression(); + + const quitAppNeedle = "if(o.type===`quit-app`){n.app.quit();return}"; + const quitAppPatch = `if(o.type===\`quit-app\`){${quitMarkerExpression}n.app.quit();return}`; + const quitAppRegex = + /if\(([A-Za-z_$][\w$]*)\.type===`quit-app`\)\{([A-Za-z_$][\w$]*)\.app\.quit\(\);return\}/; + if (patchedSource.includes(quitAppPatch)) { + // Already patched. + } else if (patchedSource.includes(quitAppNeedle)) { + patchedSource = patchedSource.replace(quitAppNeedle, quitAppPatch); + } else if (quitAppRegex.test(patchedSource)) { + patchedSource = patchedSource.replace( + quitAppRegex, + (_match, messageVar, electronVar) => + `if(${messageVar}.type===\`quit-app\`){${quitMarkerExpression}${electronVar}.app.quit();return}`, + ); + } else if (patchedSource.includes("type===`quit-app`")) { + console.warn("WARN: Could not find quit-app IPC handler — skipping Linux explicit quit-app patch"); + } + + return patchedSource; +} + +function applyLinuxTrayPatch(currentSource, iconPathExpression) { + let patchedSource = currentSource; + + const trayGuardNeedle = + "process.platform!==`win32`&&process.platform!==`darwin`?null:"; + const trayGuardPatch = + "process.platform!==`win32`&&process.platform!==`darwin`&&process.platform!==`linux`?null:"; + const trayGuardIndex = patchedSource.indexOf(trayGuardNeedle); + if (patchedSource.includes(trayGuardPatch)) { + // Already patched. + } else if ( + trayGuardIndex !== -1 && + patchedSource.slice(trayGuardIndex, trayGuardIndex + TRAY_GUARD_LOOKAHEAD).includes("new n.Tray") + ) { + patchedSource = patchedSource.replace(trayGuardNeedle, trayGuardPatch); + } else { + console.warn("WARN: Could not find tray platform guard — skipping Linux tray guard patch"); + } + + if (iconPathExpression != null) { + const trayIconNeedle = + "for(let e of o){let t=n.nativeImage.createFromPath(e);if(!t.isEmpty())return{defaultIcon:t,chronicleRunningIcon:null}}return{defaultIcon:await n.app.getFileIcon(process.execPath,{size:process.platform===`win32`?`small`:`normal`}),chronicleRunningIcon:null}}"; + const trayIconPatch = + `for(let e of o){let t=n.nativeImage.createFromPath(e);if(!t.isEmpty())return{defaultIcon:t,chronicleRunningIcon:null}}if(process.platform===\`linux\`){let e=n.nativeImage.createFromPath(${iconPathExpression});if(!e.isEmpty())return{defaultIcon:e,chronicleRunningIcon:null}}return{defaultIcon:await n.app.getFileIcon(process.execPath,{size:process.platform===\`win32\`?\`small\`:\`normal\`}),chronicleRunningIcon:null}}`; + if (patchedSource.includes(`nativeImage.createFromPath(${iconPathExpression})`)) { + // Already patched. + } else if (patchedSource.includes(trayIconNeedle)) { + patchedSource = patchedSource.replace(trayIconNeedle, trayIconPatch); + } else { + console.warn("WARN: Could not find tray icon fallback — skipping Linux tray icon patch"); + } + } + + const closeToTrayNeedle = + "if(process.platform===`win32`&&f===`local`&&!this.isAppQuitting&&this.options.canHideLastLocalWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}"; + const closeToTrayExistingPatch = + "if((process.platform===`win32`||process.platform===`linux`)&&f===`local`&&!this.isAppQuitting&&this.options.canHideLastLocalWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}"; + const closeToTrayPatch = + "if((process.platform===`win32`||process.platform===`linux`)&&f===`local`&&!this.isAppQuitting&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.options.canHideLastLocalWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}"; + const patchedCloseToTrayRegex = + /if\(\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&[A-Za-z_$][\w$]*===`local`&&!this\.isAppQuitting&&!\(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress\(\)\)&&this\.options\.canHideLastLocalWindowToTray\?\.\(\)===!0&&![A-Za-z_$][\w$]*\)\{[A-Za-z_$][\w$]*\.preventDefault\(\),[A-Za-z_$][\w$]*\.hide\(\);return\}/; + if (patchedSource.includes(closeToTrayPatch)) { + // Already patched. + } else if (patchedSource.includes(closeToTrayExistingPatch)) { + patchedSource = patchedSource.replace(closeToTrayExistingPatch, closeToTrayPatch); + } else if (patchedSource.includes(closeToTrayNeedle)) { + patchedSource = patchedSource.replace(closeToTrayNeedle, closeToTrayPatch); + } else if (patchedCloseToTrayRegex.test(patchedSource)) { + // Already patched with a newer minifier's window variable. + } else { + const closeToTrayRegex = + /if\(process\.platform===`win32`&&([A-Za-z_$][\w$]*)===`local`&&!this\.isAppQuitting&&this\.options\.canHideLastLocalWindowToTray\?\.\(\)===!0&&!([A-Za-z_$][\w$]*)\)\{([A-Za-z_$][\w$]*)\.preventDefault\(\),([A-Za-z_$][\w$]*)\.hide\(\);return\}/; + const closeToTrayMatch = patchedSource.match(closeToTrayRegex); + if (closeToTrayMatch != null) { + const [, hostVar, hasOtherWindowVar, eventVar, windowVar] = closeToTrayMatch; + patchedSource = patchedSource.replace( + closeToTrayRegex, + `if((process.platform===\`win32\`||process.platform===\`linux\`)&&${hostVar}===\`local\`&&!this.isAppQuitting&&!(typeof codexLinuxIsQuitInProgress===\`function\`&&codexLinuxIsQuitInProgress())&&this.options.canHideLastLocalWindowToTray?.()===!0&&!${hasOtherWindowVar}){${eventVar}.preventDefault(),${windowVar}.hide();return}`, + ); + } else { + console.warn("WARN: Could not find close-to-tray condition — skipping Linux close-to-tray patch"); + } + } + + const trayContextMethodNeedle = + "trayMenuThreads={runningThreads:[],unreadThreads:[],pinnedThreads:[],recentThreads:[],usageLimits:[]};constructor("; + const trayContextMethodPatch = + "trayMenuThreads={runningThreads:[],unreadThreads:[],pinnedThreads:[],recentThreads:[],usageLimits:[]};setLinuxTrayContextMenu(){let e=n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());this.tray.setContextMenu?.(e);return e}constructor("; + if (patchedSource.includes("setLinuxTrayContextMenu(){")) { + // Already patched. + } else if (patchedSource.includes(trayContextMethodNeedle)) { + patchedSource = patchedSource.replace(trayContextMethodNeedle, trayContextMethodPatch); + } else { + console.warn("WARN: Could not find tray controller fields — skipping Linux tray context menu method patch"); + } + + const trayClickNeedle = + "this.tray.on(`click`,()=>{this.onTrayButtonClick()}),this.tray.on(`right-click`,()=>{this.openNativeTrayMenu()})}"; + const trayClickPatchWithoutContextSetup = + "this.tray.on(`click`,()=>{process.platform===`linux`?this.openNativeTrayMenu():this.onTrayButtonClick()}),this.tray.on(`right-click`,()=>{this.openNativeTrayMenu()})}"; + const trayClickPatch = + "process.platform===`linux`&&this.setLinuxTrayContextMenu(),this.tray.on(`click`,()=>{process.platform===`linux`?this.openNativeTrayMenu():this.onTrayButtonClick()}),this.tray.on(`right-click`,()=>{this.openNativeTrayMenu()})}"; + const canSetLinuxTrayContextMenu = patchedSource.includes("setLinuxTrayContextMenu(){"); + if (patchedSource.includes("process.platform===`linux`&&this.setLinuxTrayContextMenu(),this.tray.on(`click`")) { + // Already patched. + } else if (patchedSource.includes(trayClickNeedle)) { + patchedSource = patchedSource.replace( + trayClickNeedle, + canSetLinuxTrayContextMenu ? trayClickPatch : trayClickPatchWithoutContextSetup, + ); + } else if (canSetLinuxTrayContextMenu && patchedSource.includes(trayClickPatchWithoutContextSetup)) { + patchedSource = patchedSource.replace(trayClickPatchWithoutContextSetup, trayClickPatch); + } else { + console.warn("WARN: Could not find tray click handler — skipping Linux tray menu click patch"); + } + + const trayMenuBuildNeedle = + "openNativeTrayMenu(){this.updateChronicleTrayIcon();let e=n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());"; + const trayMenuBuildExistingPatch = + "openNativeTrayMenu(){this.updateChronicleTrayIcon();let e=process.platform===`linux`&&this.setLinuxTrayContextMenu?this.setLinuxTrayContextMenu():n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());"; + const trayMenuBuildPatch = + "openNativeTrayMenu(){if(process.platform===`linux`&&(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress()))return;this.updateChronicleTrayIcon();let e=process.platform===`linux`&&this.setLinuxTrayContextMenu?this.setLinuxTrayContextMenu():n.Menu.buildFromTemplate(this.getNativeTrayMenuItems());"; + if (patchedSource.includes("openNativeTrayMenu(){if(process.platform===`linux`&&(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress()))return;")) { + // Already patched. + } else if (patchedSource.includes(trayMenuBuildExistingPatch)) { + patchedSource = patchedSource.replace(trayMenuBuildExistingPatch, trayMenuBuildPatch); + } else if (patchedSource.includes(trayMenuBuildNeedle)) { + patchedSource = patchedSource.replace(trayMenuBuildNeedle, trayMenuBuildPatch); + } else { + console.warn("WARN: Could not find tray native menu builder — skipping Linux tray context menu builder patch"); + } + + const trayContextMenuNeedle = + "e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),this.tray.popUpContextMenu(e)}"; + const trayContextMenuPatch = + "if(process.platform===`linux`)return;e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),this.tray.popUpContextMenu(e)}"; + const oldLinuxPopupPatch = + "e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),process.platform===`linux`&&this.tray.setContextMenu?.(e),this.tray.popUpContextMenu(e)}"; + const badLinuxPopupPatch = + "e.once(`menu-will-show`,()=>{this.isNativeTrayMenuOpen=!0}),if(process.platform===`linux`)return;e.once(`menu-will-close`,()=>{this.isNativeTrayMenuOpen=!1,this.handleNativeTrayMenuClosed()}),this.tray.popUpContextMenu(e)}"; + if (patchedSource.includes("if(process.platform===`linux`)return;e.once(`menu-will-show`")) { + // Already patched. + } else if (patchedSource.includes(badLinuxPopupPatch)) { + patchedSource = patchedSource.replace(badLinuxPopupPatch, trayContextMenuPatch); + } else if (patchedSource.includes(oldLinuxPopupPatch)) { + patchedSource = patchedSource.replace(oldLinuxPopupPatch, trayContextMenuPatch); + } else if (patchedSource.includes(trayContextMenuNeedle)) { + patchedSource = patchedSource.replace(trayContextMenuNeedle, trayContextMenuPatch); + } else { + console.warn("WARN: Could not find tray native menu popup — skipping Linux tray popup guard patch"); + } + + const trayMenuThreadsNeedle = + "case`tray-menu-threads-changed`:this.trayMenuThreads=e.trayMenuThreads;return"; + const trayMenuThreadsExistingPatch = + "case`tray-menu-threads-changed`:this.trayMenuThreads=e.trayMenuThreads,process.platform===`linux`&&this.setLinuxTrayContextMenu?.();return"; + const trayMenuThreadsPatch = + "case`tray-menu-threads-changed`:this.trayMenuThreads=e.trayMenuThreads,process.platform===`linux`&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.setLinuxTrayContextMenu?.();return"; + if (patchedSource.includes("this.trayMenuThreads=e.trayMenuThreads,process.platform===`linux`&&!(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())&&this.setLinuxTrayContextMenu?.()")) { + // Already patched. + } else if (patchedSource.includes(trayMenuThreadsExistingPatch)) { + patchedSource = patchedSource.replace(trayMenuThreadsExistingPatch, trayMenuThreadsPatch); + } else if (patchedSource.includes(trayMenuThreadsNeedle)) { + patchedSource = patchedSource.replace(trayMenuThreadsNeedle, trayMenuThreadsPatch); + } else { + console.warn("WARN: Could not find tray menu thread update handler — skipping Linux tray context refresh patch"); + } + + const trayStartupNeedle = "E&&oe();"; + const previousTrayStartupPatch = "(E||process.platform===`linux`)&&oe();"; + const trayEnabledExpression = "process.platform===`linux`&&(typeof codexLinuxIsTrayEnabled!==`function`||codexLinuxIsTrayEnabled())"; + const trayStartupPatch = `(E||${trayEnabledExpression})&&oe();`; + patchedSource = patchedSource.replaceAll( + "process.platform===`linux`&&codexLinuxIsTrayEnabled())&&", + `${trayEnabledExpression})&&`, + ); + if (patchedSource.includes(trayStartupPatch)) { + // Already patched. + } else if (patchedSource.includes(previousTrayStartupPatch)) { + patchedSource = patchedSource.replace(previousTrayStartupPatch, trayStartupPatch); + } else if (patchedSource.includes(trayStartupNeedle)) { + patchedSource = patchedSource.replace(trayStartupNeedle, trayStartupPatch); + } else { + const traySetup = findDynamicTraySetup(patchedSource); + const dynamicTrayStartupMatch = traySetup == null + ? null + : findDynamicTrayStartupCall(patchedSource, traySetup.setupFn, traySetup.index); + if ( + traySetup != null && + patchedSource.includes(`${trayEnabledExpression})&&${traySetup.setupFn}();`) + ) { + // Already patched with a newer minifier's tray setup identifier. + } else if (dynamicTrayStartupMatch != null) { + const isWindowsVar = dynamicTrayStartupMatch[1]; + patchedSource = `${patchedSource.slice(0, dynamicTrayStartupMatch.index)}(${isWindowsVar}||${trayEnabledExpression})&&${traySetup.setupFn}();${patchedSource.slice(dynamicTrayStartupMatch.index + dynamicTrayStartupMatch[0].length)}`; + } else { + console.warn("WARN: Could not find tray startup call — skipping Linux tray startup patch"); + } + } + + return patchedSource; +} + +function applyLinuxSingleInstancePatch(currentSource) { + let patchedSource = currentSource; + + const singleInstanceLockNeedle = + "agentRunId:process.env.CODEX_ELECTRON_AGENT_RUN_ID?.trim()||null}});let A=Date.now();await n.app.whenReady()"; + const singleInstanceLockPatch = + "agentRunId:process.env.CODEX_ELECTRON_AGENT_RUN_ID?.trim()||null}});if(process.platform===`linux`&&!n.app.requestSingleInstanceLock()){n.app.quit();return}let A=Date.now();await n.app.whenReady()"; + if (patchedSource.includes("process.platform===`linux`&&!n.app.requestSingleInstanceLock()")) { + // Already patched. + } else if (patchedSource.includes(singleInstanceLockNeedle)) { + patchedSource = patchedSource.replace(singleInstanceLockNeedle, singleInstanceLockPatch); + } else if (patchedSource.includes("setSecondInstanceArgsHandler")) { + // Newer bundles take the single-instance lock in bootstrap.js and hand args into main here. + } else { + console.warn("WARN: Could not find startup handoff point — skipping Linux single-instance lock patch"); + } + + const secondInstanceHandlerNeedle = + "l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae="; + const secondInstanceHandlerExistingPatch = + "let codexLinuxSecondInstanceHandler=(e,t)=>{R.deepLinks.queueProcessArgs(t)||ie()};process.platform===`linux`&&(n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae="; + const secondInstanceHandlerPatch = + "let codexLinuxSecondInstanceHandler=(e,t)=>{(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(t)||ie()},codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()};process.platform===`linux`&&(n.app.on(`before-quit`,codexLinuxBeforeQuitHandler),k.add(()=>{n.app.off(`before-quit`,codexLinuxBeforeQuitHandler)}),n.app.on(`second-instance`,codexLinuxSecondInstanceHandler),k.add(()=>{n.app.off(`second-instance`,codexLinuxSecondInstanceHandler)})),l(e=>{R.deepLinks.queueProcessArgs(e)||ie()});let ae="; + if ( + patchedSource.includes("codexLinuxBeforeQuitHandler=()=>{typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()}") && + patchedSource.includes("(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())?void 0:R.deepLinks.queueProcessArgs(t)||ie()") + ) { + // Already patched. + } else if (patchedSource.includes(secondInstanceHandlerExistingPatch)) { + patchedSource = patchedSource.replace(secondInstanceHandlerExistingPatch, secondInstanceHandlerPatch); + } else if (patchedSource.includes(secondInstanceHandlerNeedle)) { + patchedSource = patchedSource.replace(secondInstanceHandlerNeedle, secondInstanceHandlerPatch); + } else if (patchedSource.includes("setSecondInstanceArgsHandler")) { + // bootstrap.js owns the Electron second-instance event and calls this bundle's handler. + } else { + console.warn("WARN: Could not find second-instance handler — skipping Linux second-instance focus patch"); + } + + return patchedSource; +} + +function applyBrowserUseNodeReplApprovalPatch(currentSource) { + const approvalPatch = + "startup_timeout_sec:120,tools:{js:{approval_mode:`approve`}},env:{"; + if (currentSource.includes(approvalPatch)) { + return currentSource; + } + + const needle = "startup_timeout_sec:120,env:{"; + if (!currentSource.includes(needle)) { + console.warn( + "WARN: Could not find Browser Use node_repl config insertion point — skipping node_repl approval patch", + ); + return currentSource; + } + + return currentSource.replace(needle, approvalPatch); +} + +function applyLinuxBrowserUseIabVisibleOnCreatePatch(currentSource) { + const marker = "codexLinuxBrowserUseAutoVisible"; + if (currentSource.includes(marker)) { + return currentSource; + } + + const visibilityExpr = (hostExpr, sessionExpr) => + `(()=>{try{${hostExpr}.setBrowserVisibleForBrowserUse(!0,${sessionExpr}.turnId)}catch(__codexLinuxErr){console.warn("${marker}",__codexLinuxErr?.message??__codexLinuxErr)}})()`; + const createTabRegex = + /if\(([A-Za-z_$][\w$]*)!=null\)return await this\.navigateTabToInitialPage\(\1\),this\.serializeTab\(\1\);let ([A-Za-z_$][\w$]*)=this\.getRequiredBrowserHost\(([A-Za-z_$][\w$]*)\);\2\.setBrowserUseActive\(!0,\3\.turnId\);let ([A-Za-z_$][\w$]*)=await \2\.openPageForBrowserUse\(\{startingUrl:this\.initialPageUrl,turnId:\3\.turnId\}\),([A-Za-z_$][\w$]*)=this\.updateTabForPage\(\4,\2\.routeKey\);return/; + const match = currentSource.match(createTabRegex); + if (match == null) { + if ( + currentSource.includes("createTabForBrowserUse") && + currentSource.includes("openPageForBrowserUse") + ) { + console.warn( + "WARN: Could not find Browser Use IAB tab creation point — skipping Linux IAB visibility patch", + ); + } + return currentSource; + } + + const [needle, tabVar, hostVar, sessionVar, pageVar, tabInfoVar] = match; + const activeTabVisibility = visibilityExpr( + `this.getRequiredBrowserHost(${sessionVar})`, + sessionVar, + ); + const newTabVisibility = visibilityExpr(hostVar, sessionVar); + const replacement = + `if(${tabVar}!=null)return await this.navigateTabToInitialPage(${tabVar}),${activeTabVisibility},this.serializeTab(${tabVar});` + + `let ${hostVar}=this.getRequiredBrowserHost(${sessionVar});${hostVar}.setBrowserUseActive(!0,${sessionVar}.turnId);` + + `let ${pageVar}=await ${hostVar}.openPageForBrowserUse({startingUrl:this.initialPageUrl,turnId:${sessionVar}.turnId}),${tabInfoVar}=this.updateTabForPage(${pageVar},${hostVar}.routeKey);` + + `return ${newTabVisibility},`; + + return currentSource.replace(needle, replacement); +} + +function applyLinuxChromeExtensionStatusPatch(currentSource) { + if (currentSource.includes("codexLinuxChromeProfileRoots")) { + return currentSource; + } + + const fsVar = requireName(currentSource, "node:fs"); + const osVar = requireName(currentSource, "node:os"); + const pathVar = requireName(currentSource, "node:path"); + if (fsVar == null || osVar == null || pathVar == null) { + console.warn( + "WARN: Could not find fs/os/path aliases — skipping Linux Chrome extension status patch", + ); + return currentSource; + } + + const unsupportedMessage = + "Opening Chrome extension settings is only supported on macOS and Windows"; + const unsupportedMessageIndex = currentSource.indexOf(unsupportedMessage); + const openFunctionStart = + unsupportedMessageIndex === -1 + ? -1 + : currentSource.lastIndexOf("async function ", unsupportedMessageIndex); + const blockStart = + openFunctionStart === -1 + ? -1 + : currentSource.lastIndexOf("function ", openFunctionStart - 1); + const blockEnd = + openFunctionStart === -1 + ? -1 + : currentSource.indexOf("function ", openFunctionStart + "async function ".length); + const originalBlock = blockEnd === -1 ? null : currentSource.slice(blockStart, blockEnd); + if ( + blockStart === -1 || + blockEnd === -1 || + !originalBlock.includes(unsupportedMessage) + ) { + console.warn( + "WARN: Could not find Chrome extension status functions — skipping Linux Chrome extension status patch", + ); + return currentSource; + } + + const statusFunctionName = /^function ([A-Za-z_$][\w$]*)\(\{extensionId:/.exec( + originalBlock, + )?.[1]; + const openFunctionName = /async function ([A-Za-z_$][\w$]*)\(\{extensionId:/.exec( + originalBlock, + )?.[1]; + const detectChromeFunctionName = + /detectChromeCommand:[A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)/.exec(originalBlock)?.[1]; + const runCommandFunctionName = + /runCommand:[A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)/.exec(originalBlock)?.[1]; + const extensionUrlFunctionName = /await [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*,\[([A-Za-z_$][\w$]*)\(e\)\]\)/.exec( + originalBlock, + )?.[1]; + const macOpenFunctionName = /await [A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*),\[`-b`,/.exec( + originalBlock, + )?.[1]; + const macBundleIdName = /await [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*,\[`-b`,([A-Za-z_$][\w$]*),/.exec( + originalBlock, + )?.[1]; + const extensionIdValidatorName = /let [A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\(e\),/.exec( + originalBlock, + )?.[1]; + const profileDirFunctionName = /[A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\(\{homeDir:/.exec( + originalBlock, + )?.[1]; + if ( + statusFunctionName == null || + openFunctionName == null || + detectChromeFunctionName == null || + runCommandFunctionName == null || + extensionUrlFunctionName == null || + macOpenFunctionName == null || + macBundleIdName == null || + extensionIdValidatorName == null || + profileDirFunctionName == null + ) { + console.warn( + "WARN: Could not identify Chrome extension status helper names — skipping Linux Chrome extension status patch", + ); + return currentSource; + } + + const replacement = [ + `function codexLinuxChromeProfileRoots({homeDir:e,platform:t}){return t===\`linux\`?[{root:(0,${pathVar}.join)(e,\`.config\`,\`BraveSoftware\`,\`Brave-Browser\`),commands:[\`brave-browser\`,\`brave\`]},{root:(0,${pathVar}.join)(e,\`.config\`,\`google-chrome\`),commands:[\`google-chrome\`,\`google-chrome-stable\`]},{root:(0,${pathVar}.join)(e,\`.config\`,\`google-chrome-beta\`),commands:[\`google-chrome-beta\`,\`google-chrome\`,\`google-chrome-stable\`]},{root:(0,${pathVar}.join)(e,\`.config\`,\`google-chrome-unstable\`),commands:[\`google-chrome-unstable\`,\`google-chrome\`,\`google-chrome-stable\`]},{root:(0,${pathVar}.join)(e,\`.config\`,\`chromium\`),commands:[\`chromium-browser\`,\`chromium\`]}]:[]}`, + `function codexLinuxChromeExtensionCommands({extensionId:e,homeDir:t,platform:n}){if(n!==\`linux\`)return null;let r=${extensionIdValidatorName}(e);for(let e of codexLinuxChromeProfileRoots({homeDir:t,platform:n})){if(!(0,${fsVar}.existsSync)(e.root))continue;for(let t of (0,${fsVar}.readdirSync)(e.root,{withFileTypes:!0}))if(t.isDirectory()&&(0,${fsVar}.existsSync)((0,${pathVar}.join)(e.root,t.name,\`Extensions\`,r)))return e.commands}return null}`, + `function codexLinuxChromeHasExtension(e){return codexLinuxChromeExtensionCommands(e)!=null}`, + `function codexLinuxChromeCommand(r){let e=(process.env.PATH??\`\`).split(\`:\`),s=r??[\`brave-browser\`,\`brave\`,\`google-chrome\`,\`google-chrome-stable\`,\`google-chrome-beta\`,\`google-chrome-unstable\`,\`chromium-browser\`,\`chromium\`];for(let t of s)for(let n of e){if(n.length===0)continue;let e=(0,${pathVar}.join)(n,t);try{if((0,${fsVar}.existsSync)(e)&&(0,${fsVar}.statSync)(e).isFile())return e}catch{}}return null}`, + `function ${statusFunctionName}({extensionId:e,homeDir:t=(0,${osVar}.homedir)(),localAppDataDir:n=process.env.LOCALAPPDATA,platform:a=process.platform}){if(a===\`linux\`)return codexLinuxChromeHasExtension({extensionId:e,homeDir:t,platform:a});let s=${extensionIdValidatorName}(e),c=${profileDirFunctionName}({homeDir:t,localAppDataDir:n,platform:a});return c==null||!(0,${fsVar}.existsSync)(c)?!1:(0,${fsVar}.readdirSync)(c,{withFileTypes:!0}).some(e=>e.isDirectory()&&(0,${fsVar}.existsSync)((0,${pathVar}.join)(c,e.name,\`Extensions\`,s)))}`, + `async function ${openFunctionName}({extensionId:e,platform:t=process.platform,detectChromeCommand:n=${detectChromeFunctionName},runCommand:r=${runCommandFunctionName}}){if(t===\`darwin\`){await r(${macOpenFunctionName},[\`-b\`,${macBundleIdName},${extensionUrlFunctionName}(e)]);return}if(t===\`win32\`){let t=n();if(t==null)throw Error(\`Google Chrome is not installed\`);await r(t,[${extensionUrlFunctionName}(e)]);return}if(t===\`linux\`){let o=codexLinuxChromeExtensionCommands({extensionId:e,homeDir:process.env.HOME??\`\`,platform:t}),t=codexLinuxChromeCommand(o)??n();if(t==null)throw Error(\`Google Chrome, Brave, or Chromium is not installed\`);await r(t,[${extensionUrlFunctionName}(e)]);return}throw Error(\`Opening Chrome extension settings is only supported on macOS, Windows, and Linux\`)}`, + ].join(""); + + return currentSource.slice(0, blockStart) + replacement + currentSource.slice(blockEnd); +} + +function applyLinuxGitOriginsSourceFallbackPatch(currentSource) { + const fallbackSource = "linux_git_origins_missing_source_fallback"; + if (currentSource.includes(`source:\`${fallbackSource}\`,requestKind:`)) { + return currentSource; + } + + const exactNeedle = + "if(o==null){if(e.qt(r))throw Error(`Missing git operation source for ${r}`);return l()}return t.Gt({source:o,requestKind:r},l)"; + const exactReplacement = + `if(o==null){if(e.qt(r)){if(r===\`git-origins\`)return t.Gt({source:\`${fallbackSource}\`,requestKind:r},l);throw Error(\`Missing git operation source for \${r}\`)}return l()}return t.Gt({source:o,requestKind:r},l)`; + if (currentSource.includes(exactNeedle)) { + return currentSource.replace(exactNeedle, exactReplacement); + } + const currentExactNeedle = + "if(o==null){if(e.Gt(r))throw Error(`Missing git operation source for ${r}`);return l()}return t.Gt({source:o,requestKind:r},l)"; + const currentExactReplacement = + `if(o==null){if(e.Gt(r)){if(r===\`git-origins\`)return t.Gt({source:\`${fallbackSource}\`,requestKind:r},l);throw Error(\`Missing git operation source for \${r}\`)}return l()}return t.Gt({source:o,requestKind:r},l)`; + if (currentSource.includes(currentExactNeedle)) { + return currentSource.replace(currentExactNeedle, currentExactReplacement); + } + + const dynamicRegex = + /if\(([A-Za-z_$][\w$]*)==null\)\{if\(([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\)throw Error\(`Missing git operation source for \$\{\4\}`\);return ([A-Za-z_$][\w$]*)\(\)\}return ([A-Za-z_$][\w$]*)\.Gt\(\{source:\1,requestKind:\4\},\5\)/; + const dynamicMatch = currentSource.match(dynamicRegex); + if (dynamicMatch != null) { + const [, sourceVar, gitGuardVar, guardFn, requestKindVar, callVar, operationContextVar] = dynamicMatch; + return currentSource.replace( + dynamicRegex, + `if(${sourceVar}==null){if(${gitGuardVar}.${guardFn}(${requestKindVar})){if(${requestKindVar}===\`git-origins\`)return ${operationContextVar}.Gt({source:\`${fallbackSource}\`,requestKind:${requestKindVar}},${callVar});throw Error(\`Missing git operation source for \${${requestKindVar}}\`)}return ${callVar}()}return ${operationContextVar}.Gt({source:${sourceVar},requestKind:${requestKindVar}},${callVar})`, + ); + } + + if ( + currentSource.includes("Missing git operation source for") && + currentSource.includes("\"git-origins\":") + ) { + console.warn("WARN: Could not find git operation source guard — skipping git-origins fallback patch"); + } + + return currentSource; +} + +module.exports = { + applyBrowserUseNodeReplApprovalPatch, + applyLinuxAvatarOverlayMousePassthroughPatch, + applyLinuxBrowserUseIabVisibleOnCreatePatch, + applyLinuxChromeExtensionStatusPatch, + applyLinuxExplicitIpcQuitPatch, + applyLinuxExplicitQuitPromptBypassPatch, + applyLinuxExplicitTrayQuitPatch, + applyLinuxFileManagerPatch, + applyLinuxGitOriginsSourceFallbackPatch, + applyLinuxMenuPatch, + applyLinuxOpaqueBackgroundPatch, + applyLinuxQuitGuardPatch, + applyLinuxSetIconPatch, + applyLinuxSingleInstancePatch, + applyLinuxTrayPatch, + applyLinuxWillQuitDrainTimeoutPatch, + applyLinuxWindowOptionsPatch, +}; diff --git a/scripts/patches/package-json.js b/scripts/patches/package-json.js new file mode 100644 index 00000000..f42be363 --- /dev/null +++ b/scripts/patches/package-json.js @@ -0,0 +1,34 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +// Package metadata patching is separate from ASAR bundle rewriting but shares +// the same patch report so rebuild inspection has one source of truth. +function patchPackageJson(extractedDir) { + const packageJsonPath = path.join(extractedDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const desktopName = resolveDesktopName(); + if (packageJson.desktopName !== desktopName) { + packageJson.desktopName = desktopName; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); + } + return packageJson.desktopName; +} + +function resolveDesktopName(env = process.env) { + const appId = env.CODEX_APP_ID || "codex-app"; + if (!/^[A-Za-z0-9._-]+$/.test(appId)) { + throw new Error("CODEX_APP_ID must contain only letters, numbers, dots, underscores, and hyphens"); + } + return `${appId}.desktop`; +} + +module.exports = { + patchPackageJson, + resolveDesktopName, +}; diff --git a/scripts/patches/registry.js b/scripts/patches/registry.js new file mode 100644 index 00000000..029f3732 --- /dev/null +++ b/scripts/patches/registry.js @@ -0,0 +1,493 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + captureWarnings, + patchStatusFromChange, + recordPatch, +} = require("../lib/patch-report.js"); +const { + loadLinuxFeatureMainBundlePatches, +} = require("../lib/linux-features.js"); +const { + applyLinuxAppUpdaterMenuPatch, + patchLinuxAppUpdaterBridge, +} = require("../lib/linux-update-bridge-patch.js"); +const { + findIconAsset, + findMainBundle, + patchAssetFiles, +} = require("./shared.js"); +const { + applyLinuxAvatarOverlayMousePassthroughPatch, + applyBrowserUseNodeReplApprovalPatch, + applyLinuxBrowserUseIabVisibleOnCreatePatch, + applyLinuxChromeExtensionStatusPatch, + applyLinuxExplicitIpcQuitPatch, + applyLinuxExplicitQuitPromptBypassPatch, + applyLinuxExplicitTrayQuitPatch, + applyLinuxFileManagerPatch, + applyLinuxGitOriginsSourceFallbackPatch, + applyLinuxMenuPatch, + applyLinuxOpaqueBackgroundPatch, + applyLinuxQuitGuardPatch, + applyLinuxSetIconPatch, + applyLinuxSingleInstancePatch, + applyLinuxTrayPatch, + applyLinuxWillQuitDrainTimeoutPatch, + applyLinuxWindowOptionsPatch, +} = require("./main-process.js"); +const { + applyLinuxChromePluginAutoInstallPatch, +} = require("./chrome-plugin.js"); +const { + applyLinuxComputerUseFeaturePatch, + applyLinuxComputerUseInstallFlowPatch, + applyLinuxComputerUsePluginGatePatch, + applyLinuxComputerUseRendererAvailabilityPatch, + isComputerUseUiEnabled, +} = require("./computer-use.js"); +const { + applyLinuxHotkeyWindowPrewarmPatch, + applyLinuxLaunchActionArgsPatch, + applyLinuxSettingsPersistencePatch, + applyLinuxTrayCloseSettingPatch, +} = require("./launch-actions.js"); +const { + applyBrowserAnnotationScreenshotPatch, + applyLinuxAppSunsetPatch, + applyLinuxOpaqueWindowsDefaultPatch, + patchCommentPreloadBundle, +} = require("./webview-assets.js"); +const { + patchKeybindsSettingsAssets, +} = require("./keybinds-settings.js"); +const { + patchPackageJson, +} = require("./package-json.js"); + +const REQUIRED_UPSTREAM = "required-upstream"; +const OPTIONAL = "optional"; +const OPT_IN = "opt-in"; + +// Main bundle patches run in this order because later patches can depend on +// helper functions inserted by earlier ones, especially the quit guard and +// Linux settings helpers. +const MAIN_BUNDLE_PATCHES = [ + { + name: "linux-quit-guard", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxQuitGuardPatch(source), + }, + { + name: "linux-explicit-quit-prompt-bypass", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxExplicitQuitPromptBypassPatch(source), + }, + { + name: "linux-explicit-quit-drain-timeout", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxWillQuitDrainTimeoutPatch(source), + }, + { + name: "linux-explicit-tray-quit", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxExplicitTrayQuitPatch(source), + }, + { + name: "linux-explicit-ipc-quit", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxExplicitIpcQuitPatch(source), + }, + { + name: "linux-window-options", + ciPolicy: OPTIONAL, + apply: (source, context) => applyLinuxWindowOptionsPatch(source, context.iconAsset), + }, + { + name: "linux-menu", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxMenuPatch(source), + }, + { + name: "linux-set-icon", + ciPolicy: OPTIONAL, + apply: (source, context) => applyLinuxSetIconPatch(source, context.iconAsset), + }, + { + name: "linux-opaque-background", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxOpaqueBackgroundPatch(source), + }, + { + name: "linux-avatar-overlay-mouse-passthrough", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxAvatarOverlayMousePassthroughPatch(source), + }, + { + name: "linux-file-manager", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxFileManagerPatch(source), + }, + { + name: "linux-tray", + ciPolicy: OPTIONAL, + apply: (source, context) => applyLinuxTrayPatch(source, context.iconPathExpression), + }, + { + name: "linux-single-instance", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxSingleInstancePatch(source), + }, + { + name: "linux-computer-use-ui-feature", + ciPolicy: OPT_IN, + enabled: (context) => context.enableComputerUseUi, + apply: (source) => applyLinuxComputerUseFeaturePatch(source), + }, + { + name: "linux-computer-use-plugin-gate", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxComputerUsePluginGatePatch(source), + }, + { + name: "linux-chrome-plugin-auto-install", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxChromePluginAutoInstallPatch(source), + }, + { + name: "browser-use-node-repl-approval", + ciPolicy: OPTIONAL, + apply: (source) => applyBrowserUseNodeReplApprovalPatch(source), + }, + { + name: "linux-browser-use-iab-visible-on-create", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxBrowserUseIabVisibleOnCreatePatch(source), + }, + { + name: "linux-chrome-extension-status", + ciPolicy: REQUIRED_UPSTREAM, + apply: (source) => applyLinuxChromeExtensionStatusPatch(source), + }, + { + name: "linux-app-updater-menu", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxAppUpdaterMenuPatch(source), + }, + { + name: "linux-tray-close-setting", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxTrayCloseSettingPatch(source), + }, + { + name: "linux-settings-persistence", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxSettingsPersistencePatch(source), + }, + { + name: "linux-launch-actions", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxLaunchActionArgsPatch(source), + }, + { + name: "linux-hotkey-window-prewarm", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxHotkeyWindowPrewarmPatch(source), + }, + { + name: "linux-git-origins-source-fallback", + ciPolicy: OPTIONAL, + apply: (source) => applyLinuxGitOriginsSourceFallbackPatch(source), + }, +]; + +// Asset patches are separate from main bundle patches because they scan hashed +// webview chunks by filename pattern after app.asar extraction. +const WEBVIEW_ASSET_PATCHES = [ + { + name: "linux-app-sunset-gate", + ciPolicy: REQUIRED_UPSTREAM, + pattern: /^index-.*\.js$/, + apply: applyLinuxAppSunsetPatch, + missingDescription: "webview index bundle", + skipDescription: "app sunset gate patch", + }, + { + name: "opaque-window-default-general-settings", + ciPolicy: OPTIONAL, + pattern: /^general-settings-.*\.js$/, + apply: applyLinuxOpaqueWindowsDefaultPatch, + missingDescription: "general settings bundle", + skipDescription: "translucent sidebar default patch", + }, + { + name: "opaque-window-default-webview-index", + ciPolicy: OPTIONAL, + pattern: /^index-.*\.js$/, + apply: applyLinuxOpaqueWindowsDefaultPatch, + missingDescription: "webview index bundle", + skipDescription: "translucent sidebar default patch", + }, + { + name: "opaque-window-default-resolved-theme", + ciPolicy: OPTIONAL, + pattern: /^use-resolved-theme-variant-.*\.js$/, + apply: applyLinuxOpaqueWindowsDefaultPatch, + missingDescription: "resolved theme bundle", + skipDescription: "translucent sidebar default patch", + }, +]; + +const COMPUTER_USE_UI_ASSET_PATCHES = [ + { + name: "linux-computer-use-ui-availability", + ciPolicy: OPT_IN, + pattern: /^(use-model-settings|apps|use-in-app-browser-use-availability)-.*\.js$/, + apply: applyLinuxComputerUseRendererAvailabilityPatch, + missingDescription: "Computer Use availability bundle", + skipDescription: "Linux Computer Use UI availability patch", + }, + { + name: "linux-computer-use-install-flow", + ciPolicy: OPT_IN, + pattern: /^(use-plugin-install-flow|plugins-availability)-.*\.js$/, + apply: applyLinuxComputerUseInstallFlowPatch, + missingDescription: "plugin install flow bundle", + skipDescription: "Linux Computer Use install flow patch", + }, +]; + +const CUSTOM_PATCH_POLICIES = [ + { name: "main-process-ui", ciPolicy: REQUIRED_UPSTREAM }, + { name: "linux-app-updater-bridge", ciPolicy: OPTIONAL }, + { name: "browser-annotation-screenshot", ciPolicy: OPTIONAL }, + { name: "keybinds-settings", ciPolicy: OPTIONAL }, + { name: "package-desktop-name", ciPolicy: REQUIRED_UPSTREAM }, +]; + +function webviewMissingWarning(extractedDir, patch) { + return `WARN: Could not find ${patch.missingDescription} in ${path.join(extractedDir, "webview", "assets")} — skipping ${patch.skipDescription}`; +} + +function createMainBundleContext(iconAsset) { + return { + enableComputerUseUi: isComputerUseUiEnabled(), + iconAsset, + iconPathExpression: + iconAsset == null ? null : `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``, + }; +} + +function recordAssetPatch(report, name, patchResult, warnings) { + const status = + patchResult.required && patchResult.changed === 0 && warnings.length > 0 + ? "failed-required" + : patchStatusFromChange(patchResult.changed > 0, warnings); + + if (patchResult.matched === 0) { + recordPatch( + report, + name, + patchResult.required ? "failed-required" : "skipped-optional", + warnings[0] ?? "no matching bundle found", + ); + return; + } + + recordPatch( + report, + name, + status, + warnings[0] ?? null, + ); +} + +function applyMainBundlePatches(source, context, report) { + let patched = source; + const warnings = []; + let requiredChanged = false; + let requiredFailureReason = null; + const patches = [ + ...MAIN_BUNDLE_PATCHES, + ...loadLinuxFeatureMainBundlePatches(), + ]; + + for (const patch of patches) { + if (patch.enabled != null && !patch.enabled(context)) { + continue; + } + + const before = patched; + const result = captureWarnings(() => patch.apply(patched, context)); + patched = result.value; + warnings.push(...result.warnings); + if (patch.ciPolicy === REQUIRED_UPSTREAM) { + requiredChanged ||= patched !== before; + if (patched === before && result.warnings.length > 0 && requiredFailureReason == null) { + requiredFailureReason = result.warnings[0]; + } + } + const status = + patch.ciPolicy === REQUIRED_UPSTREAM && patched === before && result.warnings.length > 0 + ? "failed-required" + : patchStatusFromChange(patched !== before, result.warnings); + recordPatch( + report, + patch.name, + status, + result.warnings[0] ?? null, + ); + } + + return { patchedSource: patched, warnings, requiredChanged, requiredFailureReason }; +} + +function patchMainBundleSource(source, iconAsset) { + return applyMainBundlePatches(source, createMainBundleContext(iconAsset), null).patchedSource; +} + +function patchExtractedApp(extractedDir, options = {}) { + const report = options.report ?? null; + const main = findMainBundle(extractedDir); + if (report != null) { + report.mainBundle = main?.mainBundle ?? null; + report.target = main == null ? null : path.join(main.buildDir, main.mainBundle); + } + if (main == null) { + const reason = `Could not find main bundle in ${path.join(extractedDir, ".vite", "build")}`; + console.warn(`WARN: ${reason} — skipping main-process UI patches`); + recordPatch(report, "main-process-ui", "failed-required", reason); + } + + const iconAsset = findIconAsset(extractedDir); + if (report != null) { + report.iconAsset = iconAsset; + } + if (iconAsset == null) { + console.warn( + `WARN: Could not find app icon asset in ${path.join(extractedDir, "webview", "assets")} — skipping icon patches`, + ); + } + + if (main != null) { + const target = path.join(main.buildDir, main.mainBundle); + const source = fs.readFileSync(target, "utf8"); + const context = createMainBundleContext(iconAsset); + const { patchedSource, requiredChanged, requiredFailureReason } = applyMainBundlePatches(source, context, report); + if (patchedSource !== source) { + fs.writeFileSync(target, patchedSource, "utf8"); + } + const mainProcessStatus = + requiredFailureReason != null + ? "failed-required" + : patchStatusFromChange(requiredChanged, []); + recordPatch( + report, + "main-process-ui", + mainProcessStatus, + requiredFailureReason, + ); + } + + { + const { value: result, warnings } = captureWarnings(() => patchLinuxAppUpdaterBridge(extractedDir)); + recordAssetPatch(report, "linux-app-updater-bridge", result, warnings); + } + + { + const { value: result, warnings } = captureWarnings(() => patchCommentPreloadBundle(extractedDir)); + recordPatch( + report, + "browser-annotation-screenshot", + patchStatusFromChange(result.changed, warnings), + warnings[0] ?? null, + ); + } + + for (const patch of WEBVIEW_ASSET_PATCHES) { + const { value: result, warnings } = captureWarnings(() => + patchAssetFiles(extractedDir, patch.pattern, patch.apply, webviewMissingWarning(extractedDir, patch)), + ); + recordAssetPatch(report, patch.name, { ...result, required: patch.ciPolicy === REQUIRED_UPSTREAM }, warnings); + } + + if (isComputerUseUiEnabled()) { + for (const patch of COMPUTER_USE_UI_ASSET_PATCHES) { + const { value: result, warnings } = captureWarnings(() => + patchAssetFiles(extractedDir, patch.pattern, patch.apply, webviewMissingWarning(extractedDir, patch)), + ); + recordAssetPatch(report, patch.name, { ...result, required: patch.ciPolicy === REQUIRED_UPSTREAM }, warnings); + } + } + + { + const { value: result, warnings } = captureWarnings(() => patchKeybindsSettingsAssets(extractedDir)); + recordPatch( + report, + "keybinds-settings", + result.changed > 0 ? "applied" : result.matched ? "already-applied" : "skipped-optional", + result.reason ?? warnings[0] ?? null, + ); + } + + const packageJsonPath = path.join(extractedDir, "package.json"); + const previousPackageJson = fs.existsSync(packageJsonPath) + ? fs.readFileSync(packageJsonPath, "utf8") + : null; + const desktopName = patchPackageJson(extractedDir); + const nextPackageJson = fs.existsSync(packageJsonPath) + ? fs.readFileSync(packageJsonPath, "utf8") + : null; + if (report != null) { + report.desktopName = desktopName; + } + recordPatch( + report, + "package-desktop-name", + desktopName == null + ? "failed-required" + : previousPackageJson !== nextPackageJson ? "applied" : "already-applied", + desktopName == null ? "package.json not found" : null, + ); + console.log("Patched Linux window, shell, and appearance behavior:", { + target: main == null ? null : path.join(main.buildDir, main.mainBundle), + mainBundle: main?.mainBundle ?? null, + iconAsset, + desktopName, + }); +} + +function allPatchPolicies() { + return [ + ...MAIN_BUNDLE_PATCHES.map(({ name, ciPolicy }) => ({ name, ciPolicy })), + ...loadLinuxFeatureMainBundlePatches().map(({ name, ciPolicy }) => ({ name, ciPolicy })), + ...WEBVIEW_ASSET_PATCHES.map(({ name, ciPolicy }) => ({ name, ciPolicy })), + ...COMPUTER_USE_UI_ASSET_PATCHES.map(({ name, ciPolicy }) => ({ name, ciPolicy })), + ...CUSTOM_PATCH_POLICIES, + ]; +} + +function requiredPatchNamesForProfile(profile) { + if (profile !== "upstream-build") { + return []; + } + return allPatchPolicies() + .filter((patch) => patch.ciPolicy === REQUIRED_UPSTREAM) + .map((patch) => patch.name); +} + +module.exports = { + COMPUTER_USE_UI_ASSET_PATCHES, + CUSTOM_PATCH_POLICIES, + MAIN_BUNDLE_PATCHES, + OPTIONAL, + OPT_IN, + REQUIRED_UPSTREAM, + WEBVIEW_ASSET_PATCHES, + allPatchPolicies, + patchExtractedApp, + patchMainBundleSource, + requiredPatchNamesForProfile, +}; diff --git a/scripts/patches/shared.js b/scripts/patches/shared.js new file mode 100644 index 00000000..b3a41420 --- /dev/null +++ b/scripts/patches/shared.js @@ -0,0 +1,292 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +// Shared bundle helpers. Keep these small and syntax-oriented; feature-specific +// policy belongs in the individual patch modules. +const TRAY_GUARD_LOOKAHEAD = 1200; +const CLOSE_GATE_PREFIX_LOOKBACK = 8000; +const HANDLER_PREFIX_LOOKBACK = 12000; +const DIRECT_HANDLER_PROXIMITY = 1200; + +const linuxSettingsKeys = { + promptWindow: "codex-linux-prompt-window-enabled", + systemTray: "codex-linux-system-tray-enabled", + warmStart: "codex-linux-warm-start-enabled", +}; + +function readDirectoryNames(dir) { + if (!fs.existsSync(dir)) { + return []; + } + return fs.readdirSync(dir); +} + +function findMainBundle(extractedDir) { + const buildDir = path.join(extractedDir, ".vite", "build"); + const mainBundle = readDirectoryNames(buildDir).find((name) => + /^main(?:-[^.]+)?\.js$/.test(name), + ); + + return mainBundle == null ? null : { buildDir, mainBundle }; +} + +function findIconAsset(extractedDir) { + const assetsDir = path.join(extractedDir, "webview", "assets"); + return readDirectoryNames(assetsDir).find((name) => /^app-.*\.png$/.test(name)) ?? null; +} + +const keybindsSettingsAsset = "keybinds-settings-linux.js"; +const linuxKeybindOverridesKey = "codex-linux-keybind-overrides"; + +const COMPUTER_USE_UI_ENV_VAR = "CODEX_LINUX_ENABLE_COMPUTER_USE_UI"; +const COMPUTER_USE_UI_SETTINGS_KEY = "codex-linux-computer-use-ui-enabled"; + +// Two opt-in surfaces, both checked at build time: +// +// 1. Env var `CODEX_LINUX_ENABLE_COMPUTER_USE_UI=1` — for ad-hoc builds +// (`make build-app`, manual `make package`). +// 2. Persisted flag `codex-linux-computer-use-ui-enabled: true` in +// `~/.config/codex-app/settings.json` — for the auto-updater path, +// where the systemd user service does not inherit interactive shell env. +// +// Either path enables the three Statsig-bypass-style Computer Use UI patches +// (`applyLinuxComputerUseFeaturePatch`, `applyLinuxComputerUseRendererAvailabilityPatch`, +// `applyLinuxComputerUseInstallFlowPatch`). The plugin manifest gate +// (`applyLinuxComputerUsePluginGatePatch`) is pure platform-port glue and +// stays unconditional — it is what we have shipped on by default since the +// project's first release. + +function patchAssetFiles(extractedDir, filenamePattern, patchFn, missingWarnMessage) { + const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); + if (!fs.existsSync(webviewAssetsDir)) { + console.warn( + `WARN: Could not find webview assets directory in ${webviewAssetsDir} — skipping asset patch`, + ); + return { matched: 0, changed: 0 }; + } + + const candidates = fs + .readdirSync(webviewAssetsDir) + .filter((name) => filenamePattern.test(name)) + .sort(); + + if (candidates.length === 0) { + console.warn(missingWarnMessage); + return { matched: 0, changed: 0 }; + } + + let changed = 0; + for (const candidate of candidates) { + const filePath = path.join(webviewAssetsDir, candidate); + const currentSource = fs.readFileSync(filePath, "utf8"); + const patchedSource = patchFn(currentSource); + if (patchedSource !== currentSource) { + fs.writeFileSync(filePath, patchedSource, "utf8"); + changed += 1; + } + } + + return { matched: candidates.length, changed }; +} + +function readWebviewAsset(webviewAssetsDir, assetName) { + return fs.readFileSync(path.join(webviewAssetsDir, assetName), "utf8"); +} + +function findRequiredWebviewAsset(webviewAssetsDir, filenamePattern, marker, description) { + if (!fs.existsSync(webviewAssetsDir)) { + throw new Error(`Required webview asset patch failed: missing webview assets directory ${webviewAssetsDir}`); + } + + const candidates = fs + .readdirSync(webviewAssetsDir) + .filter((name) => filenamePattern.test(name)) + .sort(); + const matches = marker == null + ? candidates + : candidates.filter((name) => readWebviewAsset(webviewAssetsDir, name).includes(marker)); + + if (matches.length === 0) { + throw new Error(`Required webview asset patch failed: could not find ${description}`); + } + + return matches[0]; +} + +function findImportedAsset(webviewAssetsDir, importerAsset, description) { + const importedAsset = readWebviewAsset(webviewAssetsDir, importerAsset).match(/from"\.\/([^"]+)"/)?.[1]; + if (!importedAsset || !fs.existsSync(path.join(webviewAssetsDir, importedAsset))) { + throw new Error(`Required Keybinds settings patch failed: could not find ${description}`); + } + return importedAsset; +} + +function requireName(source, moduleName) { + const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = source.match(new RegExp(`([A-Za-z_$][\\w$]*)=require\\((['"\`])${escaped}\\2\\)`)); + return match?.[1] ?? null; +} + +function inferModuleAlias(source, moduleName) { + const requiredName = requireName(source, moduleName); + if (requiredName != null) { + return requiredName; + } + + if (moduleName === "electron") { + return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{app:\{/u)?.[1] ?? null; + } + if (moduleName === "node:path") { + return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{default:\{dirname\(/u)?.[1] ?? null; + } + if (moduleName === "node:fs") { + return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{mkdirSync\(/u)?.[1] ?? null; + } + if (moduleName === "node:net") { + return source.match(/(?:let|,)\s*([A-Za-z_$][\w$]*)=\{default:\{createServer\(/u)?.[1] ?? null; + } + + return null; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function findCallBlock(source, marker) { + const markerStart = source.indexOf(marker); + if (markerStart === -1) { + return null; + } + + const blockStart = Math.max( + source.lastIndexOf("var ", markerStart), + source.lastIndexOf("let ", markerStart), + source.lastIndexOf("const ", markerStart), + ); + const blockEnd = source.indexOf("});", markerStart); + if (blockStart === -1 || blockEnd === -1) { + return null; + } + const end = blockEnd + "});".length; + + return { + start: blockStart, + end, + text: source.slice(blockStart, end), + }; +} + +function findMatchingBrace(source, openIndex) { + let depth = 0; + let quote = null; + let escaped = false; + + for (let i = openIndex; i < source.length; i += 1) { + const char = source[i]; + if (quote != null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + + return -1; +} + +function findLastRegexMatch(source, regex) { + regex.lastIndex = 0; + let lastMatch = null; + let match; + while ((match = regex.exec(source)) != null) { + lastMatch = match; + if (match[0].length === 0) { + regex.lastIndex += 1; + } + } + return lastMatch; +} + +function findLinuxGlobalStateExpression(prefix) { + const objectStateMatch = findLastRegexMatch(prefix, /(?:let|,)\s*([A-Za-z_$][\w$]*)=\{globalState:/g); + const propertyStateMatch = findLastRegexMatch(prefix, /globalState:([A-Za-z_$][\w$]*)\.globalState/g); + + if (objectStateMatch != null && (propertyStateMatch == null || objectStateMatch.index > propertyStateMatch.index)) { + return `${objectStateMatch[1]}.globalState`; + } + if (propertyStateMatch != null) { + return `${propertyStateMatch[1]}.globalState`; + } + + return null; +} + +function findDisposableVar(prefix) { + const explicitVar = findLastRegexMatch(prefix, /disposables:([A-Za-z_$][\w$]*)/g)?.[1]; + if (explicitVar != null) { + return explicitVar; + } + + const adjacentCtorVar = findLastRegexMatch( + prefix, + /([A-Za-z_$][\w$]*)=new [A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*;\1\.add\(/g, + )?.[1]; + if (adjacentCtorVar != null) { + return adjacentCtorVar; + } + + const constructedVar = findLastRegexMatch( + prefix, + /([A-Za-z_$][\w$]*)=new [A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*/g, + )?.[1]; + if (constructedVar != null && prefix.includes(`${constructedVar}.add(`)) { + return constructedVar; + } + + return null; +} + +module.exports = { + CLOSE_GATE_PREFIX_LOOKBACK, + COMPUTER_USE_UI_ENV_VAR, + COMPUTER_USE_UI_SETTINGS_KEY, + DIRECT_HANDLER_PROXIMITY, + HANDLER_PREFIX_LOOKBACK, + TRAY_GUARD_LOOKAHEAD, + escapeRegExp, + findCallBlock, + findDisposableVar, + findIconAsset, + findImportedAsset, + findLastRegexMatch, + findLinuxGlobalStateExpression, + findMainBundle, + findMatchingBrace, + findRequiredWebviewAsset, + inferModuleAlias, + keybindsSettingsAsset, + linuxKeybindOverridesKey, + linuxSettingsKeys, + patchAssetFiles, + readDirectoryNames, + readWebviewAsset, + requireName, +}; diff --git a/scripts/patches/webview-assets.js b/scripts/patches/webview-assets.js new file mode 100644 index 00000000..fed7c90f --- /dev/null +++ b/scripts/patches/webview-assets.js @@ -0,0 +1,164 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +// Webview asset patches target hashed browser chunks copied out of app.asar. +// They stay fail-soft because upstream chunk names and minified symbols drift. +function applyLinuxOpaqueWindowsDefaultPatch(currentSource) { + let patchedSource = currentSource; + + const mergeNeedle = "opaqueWindows:e?.opaqueWindows??n.opaqueWindows,semanticColors:"; + const mergePatch = + "opaqueWindows:e?.opaqueWindows??(typeof navigator<`u`&&((navigator.userAgentData?.platform??navigator.platform??navigator.userAgent).toLowerCase().includes(`linux`))?!0:n.opaqueWindows),semanticColors:"; + + if (patchedSource.includes("opaqueWindows:e?.opaqueWindows??(typeof navigator<`u`&&")) { + // Already patched. + } else if (patchedSource.includes(mergeNeedle)) { + patchedSource = patchedSource.replace(mergeNeedle, mergePatch); + } else if (patchedSource.includes("opaqueWindows") && patchedSource.includes("semanticColors")) { + console.warn( + "WARN: Could not find Linux opaque window default insertion point — skipping settings default patch", + ); + } + + const settingsNeedle = + "let d=ot(r,e),f=at(e),p={codeThemeId:tt(a,e).id,theme:d},"; + const settingsPatch = + "let d=ot(r,e);navigator.userAgent.includes(`Linux`)&&r?.opaqueWindows==null&&(d={...d,opaqueWindows:!0});let f=at(e),p={codeThemeId:tt(a,e).id,theme:d},"; + if (patchedSource.includes("navigator.userAgent.includes(`Linux`)&&r?.opaqueWindows==null")) { + // Already patched. + } else if (patchedSource.includes(settingsNeedle)) { + patchedSource = patchedSource.replace(settingsNeedle, settingsPatch); + } + + const currentSettingsNeedle = "setThemePatch:b,theme:x}=ne(t),S=$t(i,t),"; + const currentSettingsPatch = + "setThemePatch:b,theme:x}=ne(t);navigator.userAgent.includes(`Linux`)&&x?.opaqueWindows==null&&(x={...x,opaqueWindows:!0});let S=$t(i,t),"; + if (patchedSource.includes("navigator.userAgent.includes(`Linux`)&&x?.opaqueWindows==null")) { + // Already patched. + } else if (patchedSource.includes(currentSettingsNeedle)) { + patchedSource = patchedSource.replace(currentSettingsNeedle, currentSettingsPatch); + } + + const runtimeNeedle = + "let T=o===`light`?C:w,E;if(T.opaqueWindows&&!XZ()){"; + const runtimePatch = + "let T=o===`light`?C:w,E;document.documentElement.dataset.codexOs===`linux`&&((o===`light`?l:f)?.opaqueWindows==null&&(T={...T,opaqueWindows:!0}));if(T.opaqueWindows&&!XZ()){"; + if (patchedSource.includes("document.documentElement.dataset.codexOs===`linux`&&((o===`light`?l:f)?.opaqueWindows==null")) { + // Already patched. + } else if (patchedSource.includes(runtimeNeedle)) { + patchedSource = patchedSource.replace(runtimeNeedle, runtimePatch); + } + + const currentRuntimeNeedle = "let T=s===`light`?S:w,E;"; + const currentRuntimePatch = + "let T=s===`light`?S:w,E;document.documentElement.dataset.codexOs===`linux`&&((s===`light`?u:p)?.opaqueWindows==null&&(T={...T,opaqueWindows:!0}));"; + if (patchedSource.includes("document.documentElement.dataset.codexOs===`linux`&&((s===`light`?u:p)?.opaqueWindows==null")) { + // Already patched. + } else if (patchedSource.includes(currentRuntimeNeedle)) { + patchedSource = patchedSource.replace(currentRuntimeNeedle, currentRuntimePatch); + } + + return patchedSource; +} + +function applyLinuxAppSunsetPatch(currentSource) { + const statsigKey = "2929582856"; + const disabledGatePattern = /if\(!1&&([A-Za-z_$][\w$]*)\(`2929582856`\)\)\{/u; + const gatePattern = /if\(([A-Za-z_$][\w$]*)\(`2929582856`\)\)\{/u; + + if (disabledGatePattern.test(currentSource)) { + return currentSource; + } + + if (gatePattern.test(currentSource)) { + return currentSource.replace(gatePattern, "if(!1&&$1(`2929582856`)){"); + } + + if (currentSource.includes(statsigKey)) { + console.warn("WARN: Could not find app sunset gate needle — skipping Linux app sunset patch"); + } + + return currentSource; +} + +function applyBrowserAnnotationScreenshotPatch(currentSource) { + let patchedSource = currentSource; + + const liveElementScreenshotNeedle = + "if(M&&j?.anchor.kind===`element`){let e=qu(j,y.current)??null,t=e==null?null:rd(e);he=t?.rect??md(j.anchor),_e=t?.borderRadius}"; + const storedAnchorScreenshotPatch = + "if(M&&j?.anchor.kind===`element`){he=md(j.anchor),_e=void 0}"; + if (patchedSource.includes(storedAnchorScreenshotPatch)) { + // Already patched. + } else if ( + /if\([A-Za-z_$][\w$]*&&[A-Za-z_$][\w$]*\?\.anchor\.kind===`element`\)\{[A-Za-z_$][\w$]*=[A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\.anchor\),[A-Za-z_$][\w$]*=void 0\}/.test(patchedSource) + ) { + // Already patched with the current upstream symbol names. + } else if (patchedSource.includes(liveElementScreenshotNeedle)) { + patchedSource = patchedSource.replace(liveElementScreenshotNeedle, storedAnchorScreenshotPatch); + } else { + const currentElementScreenshotRegex = + /if\(([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\?\.anchor\.kind===`element`\)\{let e=[^;{}]+?\?\?null,t=e==null\?null:[A-Za-z_$][\w$]*\(e\);([A-Za-z_$][\w$]*)=t\?\.rect\?\?([A-Za-z_$][\w$]*)\(\2\.anchor\),([A-Za-z_$][\w$]*)=t\?\.borderRadius\}/; + const currentElementScreenshotMatch = patchedSource.match(currentElementScreenshotRegex); + if (currentElementScreenshotMatch != null) { + const [, screenshotModeVar, selectedCommentVar, rectVar, anchorRectFn, radiusVar] = currentElementScreenshotMatch; + patchedSource = patchedSource.replace( + currentElementScreenshotRegex, + `if(${screenshotModeVar}&&${selectedCommentVar}?.anchor.kind===\`element\`){${rectVar}=${anchorRectFn}(${selectedCommentVar}.anchor),${radiusVar}=void 0}`, + ); + } else { + console.warn("WARN: Could not find browser annotation screenshot element highlight — skipping screenshot anchor patch"); + } + } + + const allMarkersInScreenshotNeedle = + "de=u?.target.mode===`create`?ce.find(e=>Sd(e.anchor,u.anchor.value))??null:null,fe=!M&&de!=null?ce.filter(e=>e.id!==de.id):ce,"; + const selectedMarkerInScreenshotPatch = + "de=u?.target.mode===`create`?ce.find(e=>Sd(e.anchor,u.anchor.value))??null:null,fe=M?ue:!M&&de!=null?ce.filter(e=>e.id!==de.id):ce,"; + if (patchedSource.includes(selectedMarkerInScreenshotPatch)) { + // Already patched. + } else if (/=\([A-Za-z_$][\w$]*\?[A-Za-z_$][\w$]*:![A-Za-z_$][\w$]*&&[A-Za-z_$][\w$]*!=null\?[A-Za-z_$][\w$]*\.filter\(e=>e\.id!==[A-Za-z_$][\w$]*\.id\):[A-Za-z_$][\w$]*\)\.flatMap/.test(patchedSource)) { + // Already patched with the current upstream symbol names. + } else if (patchedSource.includes(allMarkersInScreenshotNeedle)) { + patchedSource = patchedSource.replace(allMarkersInScreenshotNeedle, selectedMarkerInScreenshotPatch); + } else { + const currentMarkersNeedle = "be=(!ge&&ye!=null?A.filter(e=>e.id!==ye.id):A).flatMap"; + const currentMarkersPatch = "be=(ge?he:!ge&&ye!=null?A.filter(e=>e.id!==ye.id):A).flatMap"; + if (patchedSource.includes(currentMarkersPatch)) { + // Already patched. + } else if (patchedSource.includes(currentMarkersNeedle)) { + patchedSource = patchedSource.replace(currentMarkersNeedle, currentMarkersPatch); + } else { + console.warn("WARN: Could not find browser annotation screenshot markers — skipping screenshot marker patch"); + } + } + + return patchedSource; +} + +function patchCommentPreloadBundle(extractedDir) { + const commentPreloadBundle = path.join(extractedDir, ".vite", "build", "comment-preload.js"); + if (!fs.existsSync(commentPreloadBundle)) { + console.warn( + `WARN: Could not find comment preload bundle in ${path.dirname(commentPreloadBundle)} — skipping annotation screenshot patch`, + ); + return { matched: false, changed: false }; + } + + const source = fs.readFileSync(commentPreloadBundle, "utf8"); + const patchedSource = applyBrowserAnnotationScreenshotPatch(source); + if (patchedSource !== source) { + fs.writeFileSync(commentPreloadBundle, patchedSource, "utf8"); + return { matched: true, changed: true }; + } + return { matched: true, changed: false }; +} + +module.exports = { + applyBrowserAnnotationScreenshotPatch, + applyLinuxAppSunsetPatch, + applyLinuxOpaqueWindowsDefaultPatch, + patchCommentPreloadBundle, +}; diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 5cbffd2f..e39c25dc 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -24,6 +24,11 @@ assert_file_exists() { [ -f "$path" ] || fail "Expected file to exist: $path" } +assert_file_not_exists() { + local path="$1" + [ ! -e "$path" ] || fail "Expected file not to exist: $path" +} + assert_contains() { local path="$1" local pattern="$2" @@ -69,7 +74,7 @@ JSON {"name":"browser-use","version":"0.1.0-alpha1"} JSON cat > "$resources_dir/plugins/openai-bundled/plugins/browser-use/scripts/browser-client.mjs" <<'JS' -class Wm{async fetchBlocked(t){let n=await MT(t.endpoint,{method:"GET"});if(!n.ok)throw new Error(Rt(`Browser Use cannot determine if ${t.displayUrl} is allowed. Please try again later or use another source.`));let r=await n.json();return R7(r)}}export function setupAtlasRuntime() {} +class Uf{async fetchBlocked(e){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`Browser Use cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)}}export function setupAtlasRuntime() {} JS } @@ -142,10 +147,18 @@ SCRIPT assert_file_exists "$pkg_root/DEBIAN/prerm" assert_file_exists "$pkg_root/DEBIAN/postrm" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/package-common.sh" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/patch-chrome-plugin.js" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/node-runtime.sh" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/linux-update-bridge-patch.js" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/patch-report.js" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/rebuild-report.sh" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/linux-features.js" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/lib/linux-features.sh" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/patches/registry.js" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/scripts/patches/shared.js" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/linux-features/README.md" + assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/linux-features/example-feature/feature.json" + assert_file_not_exists "$pkg_root/usr/lib/codex-app/update-builder/linux-features/features.json" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/node-runtime/bin/node" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/Cargo.toml" assert_file_exists "$pkg_root/usr/lib/codex-app/update-builder/computer-use-linux/Cargo.toml" @@ -334,6 +347,8 @@ test_upstream_build_app_workflow_tracks_dmg_metadata() { assert_contains "$workflow" 'Last-Modified' assert_contains "$workflow" 'tolower($0) ~ /^last-modified:/' assert_contains "$workflow" 'sha256sum' + assert_contains "$workflow" 'CODEX_PATCH_REPORT_JSON="$GITHUB_WORKSPACE/patch-report.json"' + assert_contains "$workflow" 'node scripts/ci/validate-patch-report.js patch-report.json --profile upstream-build' assert_contains "$workflow" 'make build-app DMG=/tmp/codex-upstream-ci/Codex.dmg' assert_contains "$workflow" 'DMG Last-Modified' assert_contains "$workflow" 'DMG SHA-256' @@ -620,6 +635,7 @@ test_launcher_template_sanity() { assert_contains "$REPO_DIR/launcher/start.sh.template" "owned_webview_server_pid" assert_contains "$REPO_DIR/launcher/start.sh.template" "discover_webview_server_pid" assert_contains "$REPO_DIR/launcher/start.sh.template" "Adopted existing webview server" + assert_contains "$REPO_DIR/launcher/start.sh.template" "reconcile_runtime_state" assert_contains "$REPO_DIR/launcher/start.sh.template" "detect_warm_start" assert_contains "$REPO_DIR/launcher/start.sh.template" "send_warm_start_launch_action" assert_contains "$REPO_DIR/launcher/start.sh.template" "CODEX_APP_LAUNCH_ACTION_SOCKET" @@ -645,6 +661,7 @@ adopt_body = source.split("adopt_existing_webview_server() {", 1)[1].split("ensu ensure_body = source.split("ensure_webview_server() {", 1)[1].split("wait_for_webview_server", 1)[0] gui_prompt_body = source.split("run_gui_cli_prompt() {", 1)[1].split("prompt_install_missing_cli() {", 1)[0] background_preflight_body = source.split("run_cli_preflight_background() {", 1)[1].split("is_interactive_terminal() {", 1)[0] +reconcile_body = source.split("reconcile_runtime_state() {", 1)[1].split("set_electron_defaults() {", 1)[0] if 'RUNNING_APP_PID="$(find_running_app_pid)"' not in detect_body: raise SystemExit("detect_warm_start must record a pid-file running app even when warm start is disabled") if '[ -S "$LAUNCH_ACTION_SOCKET" ] && RUNNING_APP_PID="$(discover_running_app_pid)"' not in detect_body: @@ -665,6 +682,8 @@ if 'if needs_cold_start && [ -z "${CODEX_CLI_PATH:-}" ]; then' not in runtime_bo raise SystemExit("second-instance handoff must skip CLI lookup") if 'if needs_cold_start && [ -z "$CODEX_CLI_PATH" ]; then' not in runtime_body: raise SystemExit("second-instance handoff must skip missing-CLI failure") +if '"$HOME/.bun/bin/codex"' not in source: + raise SystemExit("CLI lookup must include bun global install path") if "if needs_cold_start;" not in runtime_body: raise SystemExit("second-instance handoff must skip CLI preflight") if 'prompt_args+=(--cli-path "$CODEX_CLI_PATH")' not in gui_prompt_body: @@ -693,7 +712,99 @@ if "Keeping the live app untouched" not in ensure_body: raise SystemExit("ensure_webview_server must not stop a live app server when validation fails") if "webview bundle is missing or empty" not in ensure_body: raise SystemExit("ensure_webview_server must fail fast when the extracted webview bundle is missing") +if 'if live_app_pid="$(find_running_app_pid)" || { [ -S "$LAUNCH_ACTION_SOCKET" ] && live_app_pid="$(discover_running_app_pid)"; }; then' not in reconcile_body: + raise SystemExit("reconcile_runtime_state must preserve runtime markers when a live app still exists") +if 'rm -f "$LAUNCH_ACTION_SOCKET"' not in reconcile_body: + raise SystemExit("reconcile_runtime_state must clear a stale launch-action socket when no live app exists") +if 'clear_stale_pid_file' not in reconcile_body: + raise SystemExit("reconcile_runtime_state must still clear stale app.pid markers") +if 'if [ -z "$webview_pid" ] || { ! pid_is_webview_server "$webview_pid" && ! pid_is_stale_webview_server "$webview_pid"; }; then' not in reconcile_body: + raise SystemExit("reconcile_runtime_state must clear stale launcher webview ownership markers without touching valid orphaned servers") +PY + local launcher_probe + local output + launcher_probe="$TMP_DIR/launcher-rendering-probe.sh" + python3 - "$REPO_DIR/launcher/start.sh.template" "$launcher_probe" <<'PY' +import sys + +source_path, output_path = sys.argv[1:3] +source = open(source_path, encoding="utf-8").read() +start = source.index("is_wsl_environment() {") +end = source.index("configure_side_by_side_app_env() {") +probe = "#!/bin/bash\n" + source[start:end] + r''' +set -Eeuo pipefail + +CODEX_LINUX_APP_ID="${CODEX_LINUX_APP_ID:-codex-app}" +APP_STATE_DIR="${APP_STATE_DIR:-/tmp/codex-launcher-probe-state}" + +print_state() { + printf 'mode=%s wslg=%s ozone_platform=%s ozone_hint=%s gpu=%s gpu_arg=%s comp=%s gl_added=%s launch=' \ + "$ELECTRON_RENDERING_MODE" \ + "$ELECTRON_WSLG_DETECTED" \ + "${ELECTRON_OZONE_PLATFORM:-}" \ + "${ELECTRON_OZONE_HINT:-}" \ + "$ELECTRON_GPU_ENABLED" \ + "$ELECTRON_GPU_DISABLE_SWITCH_IN_ARGS" \ + "$ELECTRON_GPU_COMPOSITING_DISABLED" \ + "$ELECTRON_GL_SWITCH_ADDED" + for arg in "${ELECTRON_LAUNCH_ARGS[@]}"; do + printf '<%s>' "$arg" + done + printf ' electron=' + for arg in "${ELECTRON_ARGS[@]}"; do + printf '<%s>' "$arg" + done + printf '\n' +} + +case "${1:-}" in + probe) + shift + set_electron_defaults "$@" + build_electron_launch_args + print_state + ;; + *) + echo "Usage: $0 probe [launcher args...]" >&2 + exit 2 + ;; +esac +''' +open(output_path, "w", encoding="utf-8").write(probe) PY + chmod +x "$launcher_probe" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=default "$launcher_probe" probe --x11 -- --use-gl=angle)" + [[ "$output" == *"electron=<--use-gl=angle>"* ]] || fail "launcher must pass Electron args after -- without the separator: $output" + [[ "$output" != *"electron=<--><--use-gl=angle>"* ]] || fail "launcher must not pass the -- separator to Electron: $output" + [[ "$output" == *"<--ozone-platform=x11>"* ]] || fail "launcher --x11 must still set the Electron ozone platform: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=default "$launcher_probe" probe -- --ozone-platform=x11)" + [[ "$output" == *"electron=<--ozone-platform=x11>"* ]] || fail "pass-through ozone platform must reach Electron: $output" + [[ "$output" != *"<--ozone-platform-hint=auto>"* ]] || fail "launcher must not add ozone hint when pass-through supplies an ozone platform: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=wslg "$launcher_probe" probe)" + [[ "$output" == *"mode=wslg"* && "$output" == *"comp=0"* && "$output" == *"gl_added=1"* ]] || fail "forced WSLg profile must disable GPU compositing default and add ANGLE: $output" + [[ "$output" == *"<--ozone-platform=x11>"* && "$output" == *"electron=<--use-gl=angle>"* ]] || fail "forced WSLg profile must use X11 and ANGLE by default: $output" + [[ "$output" != *"<--disable-gpu-compositing>"* ]] || fail "forced WSLg profile must not add disable-gpu-compositing by default: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=wslg "$launcher_probe" probe --wayland --use-gl=desktop)" + [[ "$output" == *"<--ozone-platform=wayland>"* && "$output" == *"electron=<--use-gl=desktop>"* ]] || fail "explicit rendering args must override WSLg defaults: $output" + [[ "$output" == *"gl_added=0"* && "$output" != *"<--use-gl=angle>"* ]] || fail "WSLg profile must not add ANGLE when a GL switch was supplied: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=wslg "$launcher_probe" probe -- --disable-gpu)" + [[ "$output" == *"gpu=1"* && "$output" == *"gpu_arg=1"* && "$output" == *"gl_added=0"* ]] || fail "pass-through --disable-gpu must suppress WSLg ANGLE without becoming a launcher GPU toggle: $output" + [[ "$output" == *"electron=<--disable-gpu>"* && "$output" != *"<--disable-features=Vulkan>"* ]] || fail "pass-through --disable-gpu must not add launcher-only Vulkan flags: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=wslg CODEX_ELECTRON_DISABLE_GPU_COMPOSITING=1 "$launcher_probe" probe)" + [[ "$output" == *"comp=1"* && "$output" == *"<--disable-gpu-compositing>"* ]] || fail "CODEX_ELECTRON_DISABLE_GPU_COMPOSITING=1 must force the compositor flag: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" CODEX_LINUX_RENDERING_MODE=default CODEX_ELECTRON_DISABLE_GPU_COMPOSITING=0 "$launcher_probe" probe)" + [[ "$output" == *"comp=0"* && "$output" != *"<--disable-gpu-compositing>"* ]] || fail "CODEX_ELECTRON_DISABLE_GPU_COMPOSITING=0 must suppress the compositor flag: $output" + + output="$(env -i PATH="$PATH" HOME="$HOME" WSL_INTEROP=/tmp/codex-wsl WAYLAND_DISPLAY=wayland-0 "$launcher_probe" probe)" + [[ "$output" == *"mode=wslg"* && "$output" == *"wslg=1"* ]] || fail "auto rendering mode must detect WSLg from WSL and GUI markers: $output" + assert_contains "$REPO_DIR/launcher/start.sh.template" "warm_start_ipc_sent" assert_contains "$REPO_DIR/launcher/start.sh.template" "launcher_phase" assert_contains "$REPO_DIR/launcher/start.sh.template" 'date +%s%N' @@ -734,6 +845,19 @@ PY assert_not_contains "$REPO_DIR/Makefile" 'command -v rpmbuild' assert_contains "$REPO_DIR/Makefile" 'sudo apt install -y "$$deb_abs"' assert_contains "$REPO_DIR/Makefile" 'sudo apt-get -f install -y' + assert_contains "$REPO_DIR/launcher/start.sh.template" "sync_chrome_bundled_plugin_cache" + assert_contains "$REPO_DIR/launcher/start.sh.template" "extension-id.json" + assert_contains "$REPO_DIR/launcher/start.sh.template" ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts" + assert_contains "$REPO_DIR/launcher/start.sh.template" ".config/chromium/NativeMessagingHosts" + assert_contains "$REPO_DIR/launcher/start.sh.template" "diff -qr --exclude='*:com.apple.*'" + assert_not_contains "$REPO_DIR/launcher/start.sh.template" 'cmp -s "$source_client" "$cache_client"' + assert_contains "$REPO_DIR/launcher/start.sh.template" ".tmp/bundled-marketplaces/openai-bundled" + assert_contains "$REPO_DIR/launcher/start.sh.template" ".agents/plugins/marketplace.json" + assert_contains "$REPO_DIR/scripts/lib/bundled-plugins.sh" "stage_chrome_plugin_from_upstream" + assert_contains "$REPO_DIR/scripts/lib/patch-chrome-plugin.js" "Linux native host manifest location" + assert_contains "$REPO_DIR/computer-use-linux/src/bin/codex-chrome-extension-host.rs" "CODEX_BROWSER_USE_SOCKET_DIR" + assert_contains "$REPO_DIR/flake.nix" "Browser Use bundled marketplace metadata" + assert_contains "$REPO_DIR/flake.nix" ".tmp/bundled-marketplaces/openai-bundled" assert_contains "$REPO_DIR/launcher/start.sh.template" "is_interactive_terminal" assert_contains "$REPO_DIR/updater/src/app.rs" "kdialog" assert_contains "$REPO_DIR/updater/src/app.rs" "zenity" @@ -761,6 +885,8 @@ PY assert_contains "$REPO_DIR/scripts/lib/package-common.sh" "node-runtime" assert_contains "$REPO_DIR/tests/fixtures/create-packaged-app-fixture.sh" "resources/node-runtime/bin" assert_contains "$REPO_DIR/.github/workflows/ci.yml" "tests/fixtures/create-packaged-app-fixture.sh codex-app" + assert_contains "$REPO_DIR/.github/workflows/ci.yml" "for file in scripts/patches/" + assert_contains "$REPO_DIR/scripts/ci/container-entrypoint.sh" "for file in scripts/patches/" assert_contains "$REPO_DIR/launcher/start.sh.template" "MANAGED_NODE_BIN_DIR" assert_contains "$REPO_DIR/updater/src/builder.rs" "managed_node_bin_dirs" assert_contains "$REPO_DIR/scripts/build-rpm.sh" "stage_common_package_files" @@ -832,12 +958,12 @@ test_side_by_side_launcher_identity() { assert_contains "$app_dir/start.sh" '--user-data-dir="${CODEX_ELECTRON_USER_DATA_DIR:-$APP_STATE_DIR/electron-user-data}"' assert_contains "$app_dir/start.sh" "--force-renderer-accessibility" assert_contains "$app_dir/start.sh" 'LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/$CODEX_LINUX_APP_ID"' - XDG_CACHE_HOME="$workspace/cache" XDG_STATE_HOME="$workspace/state" "$app_dir/start.sh" --help >"$help_log" + XDG_CACHE_HOME="$workspace/cache" XDG_STATE_HOME="$workspace/state" XDG_RUNTIME_DIR="$workspace/runtime" "$app_dir/start.sh" --help >"$help_log" assert_contains "$help_log" "Launches the Codex CUA Lab app." assert_contains "$help_log" "codex-cua-lab/launcher.log" ln -s "$app_dir/start.sh" "$bin_dir/codex-cua-lab" - XDG_CACHE_HOME="$workspace/cache" XDG_STATE_HOME="$workspace/state" "$bin_dir/codex-cua-lab" --help >"$symlink_help_log" + XDG_CACHE_HOME="$workspace/cache" XDG_STATE_HOME="$workspace/state" XDG_RUNTIME_DIR="$workspace/runtime" "$bin_dir/codex-cua-lab" --help >"$symlink_help_log" assert_contains "$symlink_help_log" "Launches the Codex CUA Lab app." } @@ -887,6 +1013,12 @@ test_browser_use_node_repl_fallback_runtime() { # shellcheck disable=SC1091 source "$REPO_DIR/scripts/lib/bundled-plugins.sh" stage_linux_computer_use_plugin() { return 1; } + build_chrome_extension_host() { + local fake_host="$workspace/codex-chrome-extension-host" + printf '#!/bin/sh\n' > "$fake_host" + chmod +x "$fake_host" + printf '%s\n' "$fake_host" + } install_bundled_plugin_resources "$app_dir" ) >"$output_log" 2>&1 @@ -895,9 +1027,193 @@ test_browser_use_node_repl_fallback_runtime() { cmp -s /bin/true "$install_dir/resources/node_repl" || fail "Expected fallback node_repl to come from the runtime archive" assert_contains "$install_dir/resources/plugins/openai-bundled/plugins/browser-use/scripts/browser-client.mjs" "codexLinuxSiteStatusAllowlistFallback" assert_contains "$output_log" "Browser Use node_repl runtime is not a Linux executable for x86_64; skipping" + assert_not_matches "$output_log" "WARN.*Browser Use node_repl runtime is not a Linux executable" assert_contains "$output_log" "Downloading Browser Use node_repl fallback runtime" } +make_fake_chrome_upstream_app() { + local app_dir="$1" + local resources_dir="$app_dir/Contents/Resources" + local chrome_dir="$resources_dir/plugins/openai-bundled/plugins/chrome" + + mkdir -p \ + "$resources_dir/plugins/openai-bundled/.agents/plugins" \ + "$chrome_dir/.codex-plugin" \ + "$chrome_dir/scripts" + + cat > "$resources_dir/plugins/openai-bundled/.agents/plugins/marketplace.json" <<'JSON' +{"plugins":[{"name":"chrome","source":{"source":"local","path":"./plugins/chrome"},"policy":{"installation":"AVAILABLE"}}]} +JSON + cat > "$chrome_dir/.codex-plugin/plugin.json" <<'JSON' +{"name":"chrome","version":"0.1.7"} +JSON + cat > "$chrome_dir/scripts/installManifest.mjs" <<'JS' +var n={extensionId:"hehggadaopoacecdllhhajmbjkdcmajg",extensionHostName:"com.openai.codexextension"};var p=o=>{let t=`${o.extensionHostName}.json`,r={darwin:["Library/Application Support/Google/Chrome/NativeMessagingHosts"],linux:[".config/google-chrome/NativeMessagingHosts"],win32:["AppData/Local/OpenAI/extension"]}[m.platform()];return r.map(s=>l.resolve(m.homedir(),s,t))}; +JS + cat > "$chrome_dir/scripts/extension-id.json" <<'JSON' +{"extensionId":"hehggadaopoacecdllhhajmbjkdcmajg","extensionHostName":"com.openai.codexextension"} +JSON + cat > "$chrome_dir/scripts/browser-client.mjs" <<'JS' +import{resolve as GF}from"path";import{homedir as VF,platform as WF}from"os";var Tc=GF(VF(),WF()==="win32"?"AppData\\Local\\Google\\Chrome\\User Data":"Library/Application Support/Google/Chrome"); +async fetchBlocked(e){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`Browser Use cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)} +JS + cat > "$chrome_dir/scripts/check-native-host-manifest.js" <<'JS' +function getNativeHostManifestLocation() { + if (process.platform === "win32") { + const registryKey = `${WINDOWS_NATIVE_HOST_REGISTRY_KEY_PREFIX}\\${expectedHostName}`; + const registryManifestPath = readWindowsRegistryDefaultValue(registryKey); + + return { + manifestPath: registryManifestPath || getDefaultWindowsManifestPath(), + registryKey, + registryManifestPath, + registryKeyExists: registryManifestPath != null, + }; + } + + throw new Error( + `Unsupported platform for native host manifest check: ${process.platform}. This script supports macOS and Windows.`, + ); +} +JS + cat > "$chrome_dir/scripts/installed-browsers.js" <<'JS' +const KNOWN_BROWSERS = [ + { + name: "Google Chrome", + bundleIds: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + commands: ["google-chrome", "chrome"], + windowsExecutable: "chrome.exe", + }, +]; +JS + cat > "$chrome_dir/scripts/chrome-is-running.js" <<'JS' +const CHROME_PROCESS_NAMES_BY_PLATFORM = { + darwin: new Set(["Google Chrome", "Google Chrome Helper"]), + win32: new Set(["chrome.exe"]), +}; +JS + cat > "$chrome_dir/scripts/check-extension-installed.js" <<'JS' +function resolveChromeUserDataDirectory() { + return path.join(os.homedir(), ".config", "google-chrome"); +} +JS + cat > "$chrome_dir/scripts/open-chrome-window.js" <<'JS' +function resolveChromeUserDataDirectory() { + return path.join(os.homedir(), ".config", "google-chrome"); +} + +function getOpenChromeCommand(profileDirectory) { + const chromeArgs = [ + `--profile-directory=${profileDirectory}`, + "--new-window", + ABOUT_BLANK_URL, + ]; + + return { + command: "google-chrome", + args: chromeArgs, + }; +} +JS +} + +test_chrome_plugin_staging() { + info "Checking Chrome plugin staging" + local workspace="$TMP_DIR/chrome-plugin" + local app_dir="$workspace/Codex.app" + local install_dir="$workspace/install" + local output_log="$workspace/output.log" + local chrome_dir="$install_dir/resources/plugins/openai-bundled/plugins/chrome" + local host="$chrome_dir/extension-host/linux/x64/extension-host" + + mkdir -p "$workspace" "$install_dir/resources" + make_fake_chrome_upstream_app "$app_dir" + + ( + SCRIPT_DIR="$REPO_DIR" + INSTALL_DIR="$install_dir" + WORK_DIR="$workspace/work" + ARCH="x86_64" + ICON_SOURCE="$workspace/missing-icon.png" + CODEX_APP_ID="codex-app" + mkdir -p "$WORK_DIR" + warn() { echo "[WARN] $*" >&2; } + info() { echo "[INFO] $*" >&2; } + # shellcheck disable=SC1091 + source "$REPO_DIR/scripts/lib/bundled-plugins.sh" + stage_linux_computer_use_plugin() { return 1; } + install_bundled_plugin_resources "$app_dir" + ) >"$output_log" 2>&1 + + assert_file_exists "$host" + [ -x "$host" ] || fail "Expected Chrome extension host to be executable: $host" + assert_contains "$chrome_dir/scripts/installManifest.mjs" "BraveSoftware/Brave-Browser/NativeMessagingHosts" + assert_contains "$chrome_dir/scripts/installManifest.mjs" ".config/chromium/NativeMessagingHosts" + assert_contains "$chrome_dir/scripts/installed-browsers.js" "Brave Browser" + assert_contains "$chrome_dir/scripts/installed-browsers.js" "Chromium" + assert_contains "$chrome_dir/scripts/chrome-is-running.js" "brave-browser" + assert_contains "$chrome_dir/scripts/chrome-is-running.js" "chromium-browser" + assert_contains "$chrome_dir/scripts/check-native-host-manifest.js" 'process.platform === "linux"' + assert_contains "$chrome_dir/scripts/check-native-host-manifest.js" "BraveSoftware" + assert_contains "$chrome_dir/scripts/check-native-host-manifest.js" "chromium" + assert_contains "$chrome_dir/scripts/check-extension-installed.js" "linuxBraveUserDataDirectory" + assert_contains "$chrome_dir/scripts/check-extension-installed.js" "linuxChromiumUserDataDirectory" + assert_contains "$chrome_dir/scripts/check-extension-installed.js" "linuxCandidateWithInstalledExtension" + assert_contains "$chrome_dir/scripts/open-chrome-window.js" "brave-browser" + assert_contains "$chrome_dir/scripts/open-chrome-window.js" "chromium" + assert_contains "$chrome_dir/scripts/open-chrome-window.js" "defaultBrowser ===" + assert_contains "$chrome_dir/scripts/browser-client.mjs" ".config/google-chrome" + assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxSiteStatusAllowlistFallback" + assert_contains "$install_dir/resources/plugins/openai-bundled/.agents/plugins/marketplace.json" '"name": "chrome"' + assert_contains "$output_log" "Chrome plugin staged from upstream DMG" +} + +test_chrome_native_host_manifest_writer() { + info "Checking Chrome native host manifest writer" + local workspace="$TMP_DIR/chrome-native-host-manifest" + local plugin_dir="$workspace/plugin" + local home_dir="$workspace/home" + local host_path="$workspace/extension-host" + local manifest_path + + mkdir -p "$plugin_dir/scripts" "$home_dir" "$(dirname "$host_path")" + printf '#!/bin/sh\n' > "$host_path" + chmod +x "$host_path" + cat > "$plugin_dir/scripts/extension-id.json" <<'JSON' +{"extensionId":"abcdefghijklmnopabcdefghijklmnop","extensionHostName":"com.example.codextest"} +JSON + + python3 - "$REPO_DIR/launcher/start.sh.template" "$host_path" "$home_dir" "$plugin_dir" <<'PY' +import subprocess +import sys +from pathlib import Path + +source = Path(sys.argv[1]).read_text(encoding="utf-8") +marker = "python3 - \"$host_path\" \"$HOME\" \"$plugin_dir\" <<'PY'\n" +start = source.index(marker) + len(marker) +end = source.index("\nPY\n", start) +script = source[start:end] +subprocess.run( + ["python3", "-", sys.argv[2], sys.argv[3], sys.argv[4]], + input=script, + text=True, + check=True, +) +PY + + for relative in \ + ".config/google-chrome/NativeMessagingHosts" \ + ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts" \ + ".config/chromium/NativeMessagingHosts"; do + manifest_path="$home_dir/$relative/com.example.codextest.json" + assert_file_exists "$manifest_path" + assert_contains "$manifest_path" "com.example.codextest" + assert_contains "$manifest_path" "chrome-extension://abcdefghijklmnopabcdefghijklmnop/" + assert_contains "$manifest_path" "$host_path" + done +} + make_fake_extracted_asar() { local root="$1" local bundle_body="$2" @@ -1165,6 +1481,138 @@ NODE assert_occurrence_count "$extracted/.vite/build/main-test.js" 'process.platform===`linux`&&(typeof codexLinuxIsTrayEnabled!==`function`||codexLinuxIsTrayEnabled()))&&oe' '1' } +test_linux_explicit_quit_patch_smoke() { + info "Checking Linux explicit quit patch behavior" + local workspace="$TMP_DIR/explicit-quit-patch" + local extracted="$workspace/extracted" + local output_log="$workspace/output.log" + local bundle_body + + mkdir -p "$workspace" + bundle_body="$(cat <<'JS' +let n=require(`electron`),i=require(`node:path`),a=require(`node:fs`); +var pb=class{getNativeTrayMenuItems(){return[{label:rB(this.appName),click:()=>{n.app.quit()}}]}}; +function qB(r,o){if(o.type===`quit-app`){n.app.quit();return}return o} +n.app.on(`before-quit`,o=>{let s=BI(),c=t.sr().some(e=>e.status===`ACTIVE`);if(e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}let l=n.app.getName();if(n.dialog.showMessageBoxSync({type:`warning`,buttons:[`Quit`,`Cancel`],defaultId:0,cancelId:1,noLink:!0,title:`Quit ${l}?`,message:`Quit ${l}?`,detail:vB({hasInProgressLocalConversation:s,hasEnabledAutomations:c})})!==0){o.preventDefault();return}i.markQuitApproved(),g=!0,a.markAppQuitting()}); +n.app.on(`will-quit`,e=>{if(g=!0,!h){if(i.shouldSkipDrainBeforeQuit()){mB({hotkeyWindowLifecycleManager:c,globalDictationLifecycleManager:l,flushAndDisposeContexts:d,disposables:f});return}e.preventDefault(),h=!0,c.dispose(),l.dispose(),Promise.all([...u.values()].map(e=>e.flush())).finally(()=>{d(),f.dispose(),n.app.quit()})}}); +JS +)" + make_fake_extracted_asar "$extracted" "$bundle_body" + + node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 + assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxPrepareForExplicitQuit=()=>{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress()}' + assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0' + assert_contains "$extracted/.vite/build/main-test.js" '{label:rB(this.appName),click:()=>{typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),n.app.quit()}}' + assert_contains "$extracted/.vite/build/main-test.js" 'if(o.type===`quit-app`){typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),n.app.quit();return}' + assert_contains "$extracted/.vite/build/main-test.js" 'if((typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt())||e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}' + assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxFinalizeQuit=()=>{d(),f.dispose(),n.app.quit()},codexLinuxDrainPromise=Promise.all(' + assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxExplicitQuitDrainTimeoutMs' + assert_contains "$extracted/.vite/build/main-test.js" 'setTimeout(e,typeof codexLinuxExplicitQuitDrainTimeoutMs' + assert_not_contains "$extracted/.vite/build/main-test.js" '\`number\`' + assert_not_contains "$output_log" 'WARN: Could not find tray quit menu handler' + assert_not_contains "$output_log" 'WARN: Could not find quit-app IPC handler' + assert_not_contains "$output_log" 'WARN: Could not find before-quit confirmation guard' + assert_not_contains "$output_log" 'WARN: Could not find will-quit drain sequence' + + node - "$extracted/.vite/build/main-test.js" <<'NODE' +const fs = require("fs"); + +const source = fs.readFileSync(process.argv[2], "utf8"); +const helperSnippet = source.match(/let codexLinuxQuitInProgress=!1,[^;]*codexLinuxShouldBypassQuitPrompt=\(\)=>codexLinuxExplicitQuitApproved===!0,[^;]*codexLinuxIsQuitInProgress=\(\)=>codexLinuxQuitInProgress===!0;/)?.[0]; +const traySnippet = source.match(/\{label:rB\(this\.appName\),click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}/)?.[0]; +const quitAppSnippet = source.match(/if\(o\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/)?.[0]; +const beforeQuitSnippet = source.match(/if\(\(typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt\(\)\)\|\|e\|\|i\.canQuitWithoutPrompt\(\)\|\|r\|\|!s&&!c\)\{g=!0,a\.markAppQuitting\(\);return\}/)?.[0]; +if (!helperSnippet || !traySnippet || !quitAppSnippet || !beforeQuitSnippet) { + throw new Error("Could not extract explicit quit snippets"); +} + +function runTrayQuit({ withHelper = true } = {}) { + const state = { markCalls: 0, prepareCalls: 0, quitCalls: 0 }; + const app = { quit() { state.quitCalls += 1; } }; + const mark = () => { state.markCalls += 1; }; + const prepare = withHelper ? () => { state.prepareCalls += 1; mark(); } : undefined; + const factory = new Function( + "n", + "rB", + "codexLinuxPrepareForExplicitQuit", + "codexLinuxMarkQuitInProgress", + `return (${traySnippet}).click;`, + ); + const click = factory({ app }, () => "Quit", prepare, mark); + click(); + return state; +} + +function runQuitApp({ withHelper = true } = {}) { + const state = { markCalls: 0, prepareCalls: 0, quitCalls: 0 }; + const app = { quit() { state.quitCalls += 1; } }; + const mark = () => { state.markCalls += 1; }; + const prepare = withHelper ? () => { state.prepareCalls += 1; mark(); } : undefined; + const handler = new Function( + "n", + "codexLinuxPrepareForExplicitQuit", + "codexLinuxMarkQuitInProgress", + "o", + `${quitAppSnippet};return null;`, + ); + handler({ app }, prepare, mark, { type: "quit-app" }); + return state; +} + +function runBeforeQuitBypass() { + const state = { markCalls: 0 }; + const scope = new Function( + "BI", + "t", + `${helperSnippet}return {runBeforeQuitCheck(e,i,r,a){let s=BI(),c=t.sr().some(e=>e.status===\`ACTIVE\`);${beforeQuitSnippet}return \`prompt\`;},prepare:codexLinuxPrepareForExplicitQuit,bypass:codexLinuxShouldBypassQuitPrompt};`, + )( + () => true, + { sr: () => [{ status: "ACTIVE" }] }, + ); + const controller = { + canQuitWithoutPrompt() { return false; }, + markQuitApproved() {}, + }; + const appQuitting = { markAppQuitting() { state.markCalls += 1; } }; + scope.prepare(); + const bypassed = scope.runBeforeQuitCheck(false, controller, false, appQuitting); + return { state, bypassed, shouldBypass: scope.bypass() }; +} + +let state = runTrayQuit(); +if (state.prepareCalls !== 1 || state.markCalls !== 1 || state.quitCalls !== 1) { + throw new Error("tray quit should prepare explicit quit before quitting"); +} + +state = runQuitApp(); +if (state.prepareCalls !== 1 || state.markCalls !== 1 || state.quitCalls !== 1) { + throw new Error("quit-app IPC should prepare explicit quit before quitting"); +} + +state = runTrayQuit({ withHelper: false }); +if (state.prepareCalls !== 0 || state.markCalls !== 1 || state.quitCalls !== 1) { + throw new Error("tray quit should still fall back to the quit-in-progress marker"); +} + +state = runQuitApp({ withHelper: false }); +if (state.prepareCalls !== 0 || state.markCalls !== 1 || state.quitCalls !== 1) { + throw new Error("quit-app IPC should still fall back to the quit-in-progress marker"); +} + +state = runBeforeQuitBypass(); +if (!state.shouldBypass || state.bypassed !== undefined || state.state.markCalls !== 1) { + throw new Error("before-quit should bypass the Linux quit confirmation after an explicit quit"); +} +NODE + + node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxPrepareForExplicitQuit=()=>{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress()}' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()' '2' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt()' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxDrainPromise=Promise.all(' '1' +} + test_keybinds_settings_tab_patch_smoke() { info "Checking Keybinds settings tab patch behavior" local workspace="$TMP_DIR/keybinds-settings-patch" @@ -1840,7 +2288,7 @@ const repoDir = process.argv[2]; const baseExtracted = process.argv[3]; const workspace = process.argv[4]; const patcher = path.join(repoDir, "scripts", "patch-linux-window-ui.js"); -const patcherSource = fs.readFileSync(patcher, "utf8"); +const launchPatchSource = fs.readFileSync(path.join(repoDir, "scripts", "patches", "launch-actions.js"), "utf8"); const mainBundlePath = path.join(".vite", "build", "main-test.js"); const baseMainPath = path.join(baseExtracted, mainBundlePath); const currentSource = fs.readFileSync(baseMainPath, "utf8"); @@ -1852,7 +2300,7 @@ function assert(condition, message) { } function extractConst(name) { - const match = patcherSource.match(new RegExp(`const ${name} =\\n "((?:\\\\.|[^"])*)";`)); + const match = launchPatchSource.match(new RegExp(`const ${name} =\\n "((?:\\\\.|[^"])*)";`)); assert(match, `Could not extract ${name}`); return JSON.parse(`"${match[1]}"`); } @@ -2039,6 +2487,8 @@ main() { test_installer_keeps_electron_fallback_for_bad_metadata test_managed_node_runtime_source_install test_browser_use_node_repl_fallback_runtime + test_chrome_plugin_staging + test_chrome_native_host_manifest_writer test_launcher_template_sanity test_user_local_installer_uses_xdg_data_home test_side_by_side_launcher_identity @@ -2048,6 +2498,7 @@ main() { test_keybinds_settings_tab_patch_smoke test_keybinds_settings_patch_warns_on_bundle_shape_miss test_linux_tray_patch_smoke + test_linux_explicit_quit_patch_smoke test_browser_annotation_screenshot_patch_smoke test_linux_single_instance_patch_smoke test_linux_computer_use_gate_patch_smoke diff --git a/updater/Cargo.toml b/updater/Cargo.toml index afb01129..ffdaea81 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-app-updater" -version = "0.7.0" +version = "0.7.1" edition = "2021" [dependencies] diff --git a/updater/src/app.rs b/updater/src/app.rs index 5c4eb8f5..e32ef572 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -294,6 +294,7 @@ fn run_status( json: bool, ) -> Result<()> { codex_cli::refresh_status(config, state, paths)?; + complete_pending_install_if_already_installed(state, paths)?; if json { println!("{}", serde_json::to_string_pretty(state)?); @@ -764,7 +765,7 @@ async fn run_install_ready( if complete_pending_install_if_already_installed(state, paths)? { let _ = maybe_notify_installed(state, paths, config.notifications); - println!("Codex App update is already installed."); + println!("Codex App update is already installed or superseded."); return Ok(()); } @@ -842,18 +843,24 @@ fn complete_pending_install_if_already_installed( return Ok(false); } - if !state.candidate_version.as_deref().is_some_and(|candidate| { + let Some(candidate_version) = state.candidate_version.clone().filter(|candidate| { installed_version_satisfies_candidate(&state.installed_version, candidate) - }) { + }) else { return Ok(false); - } + }; + + let candidate_is_installed = + installed_version_matches_candidate(&state.installed_version, &candidate_version); state.status = UpdateStatus::Installed; state.candidate_version = None; + if !candidate_is_installed { + state.artifact_paths.package_path = None; + } state.error_message = None; state.notified_events.clear(); persist_state(paths, state)?; - info!("recovered pending install state because the candidate version is already installed"); + info!("recovered pending install state because the candidate version is already installed or superseded"); Ok(true) } @@ -862,11 +869,17 @@ fn recover_interrupted_install(state: &mut PersistedState, paths: &RuntimePaths) return Ok(()); } - if state.candidate_version.as_deref().is_some_and(|candidate| { + if let Some(candidate_version) = state.candidate_version.clone().filter(|candidate| { installed_version_satisfies_candidate(&state.installed_version, candidate) }) { + let candidate_is_installed = + installed_version_matches_candidate(&state.installed_version, &candidate_version); + state.status = UpdateStatus::Installed; state.candidate_version = None; + if !candidate_is_installed { + state.artifact_paths.package_path = None; + } state.error_message = None; state.notified_events.clear(); persist_state(paths, state)?; @@ -915,6 +928,14 @@ fn installed_version_satisfies_candidate(installed: &str, candidate: &str) -> bo } } +fn installed_version_matches_candidate(installed: &str, candidate: &str) -> bool { + match compare_generated_versions(installed, candidate) { + Some(std::cmp::Ordering::Equal) => true, + Some(_) => false, + None => installed == candidate, + } +} + fn compare_generated_versions(left: &str, right: &str) -> Option { let left = parse_generated_version(left)?; let right = parse_generated_version(right)?; @@ -1277,7 +1298,7 @@ mod tests { let mut state = PersistedState::new(false); state.status = UpdateStatus::Failed; - state.candidate_version = Some("2026.03.25.010203+deadbeef".to_string()); + state.candidate_version = Some("2999.03.25.010203+deadbeef".to_string()); state.error_message = Some("previous failure".to_string()); state.artifact_paths.package_path = Some(package_path); @@ -1369,21 +1390,21 @@ mod tests { }; paths.ensure_dirs()?; - { - let first_lock = try_acquire_check_lock(&paths)?; - let second_lock = try_acquire_check_lock(&paths)?; + let first_lock = + try_acquire_check_lock(&paths)?.expect("first lock acquisition should succeed"); + let second_lock = try_acquire_check_lock(&paths)?; - assert!(first_lock.is_some()); - assert!(second_lock.is_none()); - } + assert!(second_lock.is_none()); + drop(second_lock); + drop(first_lock); let mut reacquired_lock = None; - for _ in 0..10 { + for _ in 0..20 { reacquired_lock = try_acquire_check_lock(&paths)?; if reacquired_lock.is_some() { break; } - std::thread::sleep(Duration::from_millis(10)); + std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(reacquired_lock.is_some()); @@ -1418,7 +1439,7 @@ mod tests { let mut state = PersistedState::new(true); state.status = UpdateStatus::ReadyToInstall; - state.candidate_version = Some("2026.03.25.010203+deadbeef".to_string()); + state.candidate_version = Some("2999.03.25.010203+deadbeef".to_string()); state.artifact_paths.package_path = Some(temp.path().join("missing/codex.deb")); reconcile_pending_install(&config, &mut state, &paths).await?; @@ -1467,7 +1488,7 @@ mod tests { let mut state = PersistedState::new(true); state.status = UpdateStatus::ReadyToInstall; - state.candidate_version = Some("2026.03.25.010203+deadbeef".to_string()); + state.candidate_version = Some("2999.03.25.010203+deadbeef".to_string()); state.artifact_paths.package_path = Some(package_path); reconcile_pending_install(&config, &mut state, &paths).await?; @@ -1513,11 +1534,11 @@ mod tests { let mut state = PersistedState::new(false); state.status = UpdateStatus::ReadyToInstall; - state.candidate_version = Some("2026.03.25.010203+deadbeef".to_string()); + state.candidate_version = Some("2999.03.25.010203+deadbeef".to_string()); state.artifact_paths.package_path = Some(package_path); state .notified_events - .insert("install_auth_required:2026.03.25.010203+deadbeef".to_string()); + .insert("install_auth_required:2999.03.25.010203+deadbeef".to_string()); run_install_ready(&config, &mut state, &paths).await?; @@ -1554,7 +1575,7 @@ mod tests { let mut state = PersistedState::new(false); state.status = UpdateStatus::ReadyToInstall; - state.candidate_version = Some("2026.03.25.010203+deadbeef".to_string()); + state.candidate_version = Some("2999.03.25.010203+deadbeef".to_string()); state.artifact_paths.package_path = Some(temp.path().join("missing/codex.deb")); let result = run_install_ready(&config, &mut state, &paths).await; @@ -1703,21 +1724,25 @@ mod tests { }; paths.ensure_dirs()?; - let original_display = std::env::var_os("DISPLAY"); - let original_wayland_display = std::env::var_os("WAYLAND_DISPLAY"); - let original_dbus_session_bus_address = std::env::var_os("DBUS_SESSION_BUS_ADDRESS"); - let original_xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR"); - let original_path = std::env::var_os("PATH"); - let original_home = std::env::var_os("HOME"); - let original_nvm_dir = std::env::var_os("NVM_DIR"); - - std::env::remove_var("DISPLAY"); - std::env::remove_var("WAYLAND_DISPLAY"); - std::env::remove_var("DBUS_SESSION_BUS_ADDRESS"); - std::env::remove_var("XDG_RUNTIME_DIR"); - std::env::set_var("PATH", temp.path().join("missing-bin")); - std::env::set_var("HOME", temp.path()); - std::env::remove_var("NVM_DIR"); + let _display_guard = crate::test_util::EnvVarGuard::remove(&_env_guard, "DISPLAY"); + let _wayland_display_guard = + crate::test_util::EnvVarGuard::remove(&_env_guard, "WAYLAND_DISPLAY"); + let _dbus_session_bus_address_guard = + crate::test_util::EnvVarGuard::remove(&_env_guard, "DBUS_SESSION_BUS_ADDRESS"); + let _xdg_runtime_dir_guard = + crate::test_util::EnvVarGuard::remove(&_env_guard, "XDG_RUNTIME_DIR"); + let _path_guard = crate::test_util::EnvVarGuard::set( + &_env_guard, + "PATH", + temp.path().join("missing-bin"), + ); + let _home_guard = crate::test_util::EnvVarGuard::set(&_env_guard, "HOME", temp.path()); + let _nvm_dir_guard = crate::test_util::EnvVarGuard::remove(&_env_guard, "NVM_DIR"); + let _skip_system_cli_lookup_guard = crate::test_util::EnvVarGuard::set( + &_env_guard, + "CODEX_APP_UPDATER_TEST_SKIP_SYSTEM_CLI_LOOKUP", + "1", + ); let invalid_cli_path = temp.path().join("codex.txt"); std::fs::write(&invalid_cli_path, b"not executable")?; @@ -1739,42 +1764,6 @@ mod tests { let outcome = prompt_install_cli(&config, &mut state, &paths, None)?; - if let Some(value) = original_display { - std::env::set_var("DISPLAY", value); - } else { - std::env::remove_var("DISPLAY"); - } - if let Some(value) = original_wayland_display { - std::env::set_var("WAYLAND_DISPLAY", value); - } else { - std::env::remove_var("WAYLAND_DISPLAY"); - } - if let Some(value) = original_dbus_session_bus_address { - std::env::set_var("DBUS_SESSION_BUS_ADDRESS", value); - } else { - std::env::remove_var("DBUS_SESSION_BUS_ADDRESS"); - } - if let Some(value) = original_xdg_runtime_dir { - std::env::set_var("XDG_RUNTIME_DIR", value); - } else { - std::env::remove_var("XDG_RUNTIME_DIR"); - } - if let Some(value) = original_path { - std::env::set_var("PATH", value); - } else { - std::env::remove_var("PATH"); - } - if let Some(value) = original_home { - std::env::set_var("HOME", value); - } else { - std::env::remove_var("HOME"); - } - if let Some(value) = original_nvm_dir { - std::env::set_var("NVM_DIR", value); - } else { - std::env::remove_var("NVM_DIR"); - } - assert_eq!(outcome, PromptInstallCliOutcome::NoBackend); Ok(()) } @@ -1860,7 +1849,7 @@ mod tests { } #[test] - fn pending_install_becomes_installed_when_newer_version_is_present() -> Result<()> { + fn pending_install_is_cleared_when_installed_version_is_newer() -> Result<()> { let temp = tempfile::tempdir()?; let paths = RuntimePaths { config_file: temp.path().join("config/config.toml"), @@ -1873,9 +1862,13 @@ mod tests { paths.ensure_dirs()?; let mut state = PersistedState::new(true); - state.status = UpdateStatus::WaitingForAppExit; - state.installed_version = "2026.04.29.010203+abcdef12".to_string(); + state.status = UpdateStatus::ReadyToInstall; + state.installed_version = "2026.05.01.010203-99999999.fc43".to_string(); state.candidate_version = Some("2026.04.28.082247+abcdef12".to_string()); + state.error_message = Some("authentication was not obtained".to_string()); + let superseded_package_path = temp.path().join("superseded.deb"); + std::fs::write(&superseded_package_path, b"deb")?; + state.artifact_paths.package_path = Some(superseded_package_path); assert!(complete_pending_install_if_already_installed( &mut state, &paths @@ -1883,6 +1876,10 @@ mod tests { assert_eq!(state.status, UpdateStatus::Installed); assert_eq!(state.candidate_version, None); + assert_eq!(state.artifact_paths.package_path, None); + assert_eq!(state.error_message, None); + crate::rollback::record_current_package_as_known_good(&mut state); + assert_eq!(state.artifact_paths.rollback_package_path, None); Ok(()) } @@ -1974,6 +1971,7 @@ mod tests { assert_eq!(state.status, UpdateStatus::Installed); assert_eq!(state.candidate_version, None); + assert_eq!(state.artifact_paths.package_path, None); assert_eq!(state.error_message, None); Ok(()) } diff --git a/updater/src/builder.rs b/updater/src/builder.rs index 29375c19..aab3a193 100644 --- a/updater/src/builder.rs +++ b/updater/src/builder.rs @@ -15,7 +15,7 @@ use std::{ use tokio::process::Command; use tracing::info; -const REQUIRED_BUNDLE_FILES: [(&str, &str); 13] = [ +const REQUIRED_BUNDLE_FILES: [(&str, &str); 15] = [ ("Cargo.toml", "Cargo.toml"), ("Cargo.lock", "Cargo.lock"), ("computer-use-linux", "computer-use-linux"), @@ -31,10 +31,12 @@ const REQUIRED_BUNDLE_FILES: [(&str, &str); 13] = [ "scripts/patch-linux-window-ui.js", "scripts/patch-linux-window-ui.js", ), + ("scripts/patches", "scripts/patches"), ("scripts/lib", "scripts/lib"), ("node-runtime", "node-runtime"), ("packaging/linux", "packaging/linux"), ("assets/codex.png", "assets/codex.png"), + ("linux-features", "linux-features"), ]; const OPTIONAL_BUNDLE_FILES: [(&str, &str); 3] = [ ("scripts/build-rpm.sh", "scripts/build-rpm.sh"), @@ -339,6 +341,7 @@ fn read_app_package_version(app_dir: &Path) -> Result { fn build_command_path(builder_bundle_root: &Path) -> OsString { let mut entries = managed_node_bin_dirs(builder_bundle_root); entries.extend(preferred_node_bin_dirs()); + entries.extend(preferred_rust_bin_dirs()); entries.extend(std::env::split_paths( &std::env::var_os("PATH").unwrap_or_default(), )); @@ -381,6 +384,25 @@ fn preferred_node_bin_dirs() -> Vec { collect_nvm_bin_dirs(&nvm_root) } +fn preferred_rust_bin_dirs() -> Vec { + let Some(home) = std::env::var_os("HOME") else { + return Vec::new(); + }; + + let cargo_bin = PathBuf::from(home).join(".cargo/bin"); + if is_executable_file(&cargo_bin.join("cargo")) { + vec![cargo_bin] + } else { + Vec::new() + } +} + +fn is_executable_file(path: &Path) -> bool { + fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + fn collect_nvm_bin_dirs(nvm_root: &Path) -> Vec { let mut directories = Vec::new(); let mut seen = std::collections::BTreeSet::new(); @@ -520,6 +542,19 @@ touch "${DIST_DIR_OVERRIDE}/codex-app-${VER}-1-x86_64.pkg.tar.zst" Ok(()) } + fn write_fake_linux_features_bundle(root: &Path) -> Result<()> { + fs::create_dir_all(root.join("linux-features/example-feature"))?; + fs::write( + root.join("linux-features/features.example.json"), + b"{\"enabled\":[]}\n", + )?; + fs::write( + root.join("linux-features/example-feature/feature.json"), + b"{\"id\":\"example-feature\"}\n", + )?; + Ok(()) + } + #[tokio::test] async fn builds_update_with_fake_bundle() -> Result<()> { let temp = tempdir()?; @@ -527,11 +562,13 @@ touch "${DIST_DIR_OVERRIDE}/codex-app-${VER}-1-x86_64.pkg.tar.zst" let state_root = temp.path().join("state"); let cache_root = temp.path().join("cache"); fs::create_dir_all(bundle_root.join("scripts/lib"))?; + fs::create_dir_all(bundle_root.join("scripts/patches"))?; fs::create_dir_all(bundle_root.join("launcher"))?; fs::create_dir_all(bundle_root.join("packaging/linux"))?; fs::create_dir_all(bundle_root.join("assets"))?; fs::create_dir_all(bundle_root.join("node-runtime/bin"))?; write_fake_computer_use_bundle(&bundle_root)?; + write_fake_linux_features_bundle(&bundle_root)?; fs::write( bundle_root.join("launcher/start.sh.template"), b"# fake launcher template\n", @@ -620,6 +657,10 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve bundle_root.join("scripts/patch-linux-window-ui.js"), b"console.log('patched');\n", )?; + fs::write( + bundle_root.join("scripts/patches/registry.js"), + b"module.exports = {};\n", + )?; fs::write( bundle_root.join("scripts/lib/package-common.sh"), b"#!/bin/bash\n", @@ -676,6 +717,14 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve .workspace_dir .join("builder/scripts/lib/node-runtime.sh") .exists()); + assert!(artifacts + .workspace_dir + .join("builder/scripts/patches/registry.js") + .exists()); + assert!(artifacts + .workspace_dir + .join("builder/linux-features/features.example.json") + .exists()); assert!( is_native_package_file(&artifacts.package_path), "expected a native package (.deb, .rpm, or .pkg.tar.zst), got {}", @@ -691,11 +740,13 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve let destination_root = temp.path().join("destination"); fs::create_dir_all(source_root.join("scripts/lib"))?; + fs::create_dir_all(source_root.join("scripts/patches"))?; fs::create_dir_all(source_root.join("launcher"))?; fs::create_dir_all(source_root.join("packaging/linux"))?; fs::create_dir_all(source_root.join("assets"))?; fs::create_dir_all(source_root.join("node-runtime/bin"))?; write_fake_computer_use_bundle(&source_root)?; + write_fake_linux_features_bundle(&source_root)?; fs::write(source_root.join("install.sh"), b"#!/bin/bash\n")?; fs::write( source_root.join("launcher/start.sh.template"), @@ -706,6 +757,10 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve source_root.join("scripts/patch-linux-window-ui.js"), b"console.log('patched');\n", )?; + fs::write( + source_root.join("scripts/patches/registry.js"), + b"module.exports = {};\n", + )?; fs::write( source_root.join("scripts/lib/package-common.sh"), b"#!/bin/bash\n", @@ -731,6 +786,9 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve assert!(destination_root .join("scripts/patch-linux-window-ui.js") .exists()); + assert!(destination_root + .join("scripts/patches/registry.js") + .exists()); assert!(destination_root.join("computer-use-linux").exists()); assert!(destination_root.join("updater").exists()); assert!(destination_root @@ -740,6 +798,9 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve .join("scripts/lib/node-runtime.sh") .exists()); assert!(destination_root.join("node-runtime/bin/node").exists()); + assert!(destination_root + .join("linux-features/features.example.json") + .exists()); assert!(!destination_root.join("scripts/build-rpm.sh").exists()); assert!(!destination_root.join("scripts/build-pacman.sh").exists()); Ok(()) @@ -814,4 +875,42 @@ echo CODEX_APP_PACKAGE_VERSION=26.429.20946 > "${CODEX_INSTALL_DIR}/codex-app-ve assert_eq!(directories.first(), Some(&runtime_bin)); Ok(()) } + + #[test] + fn build_command_path_includes_cargo_bin_from_home() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let temp = tempdir()?; + let home_dir = temp.path().join("home"); + let cargo_bin = home_dir.join(".cargo/bin"); + fs::create_dir_all(&cargo_bin)?; + fs::write(cargo_bin.join("cargo"), b"bin")?; + fs::set_permissions(cargo_bin.join("cargo"), fs::Permissions::from_mode(0o755))?; + + let _home_guard = crate::test_util::EnvVarGuard::set(&_env_guard, "HOME", &home_dir); + + let path = build_command_path(Path::new("/tmp/missing-codex-builder")); + + let directories = std::env::split_paths(&path).collect::>(); + assert!(directories.iter().any(|dir| dir == &cargo_bin)); + Ok(()) + } + + #[test] + fn build_command_path_skips_non_executable_cargo_from_home() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let temp = tempdir()?; + let home_dir = temp.path().join("home"); + let cargo_bin = home_dir.join(".cargo/bin"); + fs::create_dir_all(&cargo_bin)?; + fs::write(cargo_bin.join("cargo"), b"bin")?; + fs::set_permissions(cargo_bin.join("cargo"), fs::Permissions::from_mode(0o644))?; + + let _home_guard = crate::test_util::EnvVarGuard::set(&_env_guard, "HOME", &home_dir); + + let path = build_command_path(Path::new("/tmp/missing-codex-builder")); + + let directories = std::env::split_paths(&path).collect::>(); + assert!(!directories.iter().any(|dir| dir == &cargo_bin)); + Ok(()) + } } diff --git a/updater/src/codex_cli.rs b/updater/src/codex_cli.rs index 333b6462..e6665208 100644 --- a/updater/src/codex_cli.rs +++ b/updater/src/codex_cli.rs @@ -429,8 +429,10 @@ fn known_cli_locations() -> Vec { candidates.push(home.join(".local/share/pnpm/codex")); candidates.push(home.join(".local/bin/codex")); } - candidates.push(PathBuf::from("/usr/local/bin/codex")); - candidates.push(PathBuf::from("/usr/bin/codex")); + if include_system_cli_locations() { + candidates.push(PathBuf::from("/usr/local/bin/codex")); + candidates.push(PathBuf::from("/usr/bin/codex")); + } candidates } @@ -449,6 +451,18 @@ fn parse_node_version_dir(name: &str) -> Option<(u64, u64, u64)> { Some((major, minor, patch)) } +fn include_system_cli_locations() -> bool { + #[cfg(test)] + { + std::env::var_os("CODEX_APP_UPDATER_TEST_SKIP_SYSTEM_CLI_LOOKUP").is_none() + } + + #[cfg(not(test))] + { + true + } +} + fn mark_cli_missing(state: &mut PersistedState) { state.cli_path = None; state.cli_installed_version = None; @@ -827,7 +841,7 @@ mod tests { use crate::{ config::{RuntimeConfig, RuntimePaths}, state::{CliStatus, PersistedState}, - test_util::env_lock, + test_util::{env_lock, EnvVarGuard}, }; use chrono::Utc; use std::{fs, os::unix::fs::PermissionsExt, path::Path}; @@ -946,8 +960,7 @@ mod tests { let temp = tempdir()?; let paths = test_runtime_paths(temp.path()); paths.ensure_dirs()?; - let original_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::remove_var("CODEX_CLI_PATH"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); let codex_path = temp.path().join("codex"); write_executable_script( @@ -968,11 +981,6 @@ mod tests { assert_eq!(state.cli_latest_version.as_deref(), Some("0.43.0")); assert_eq!(state.cli_status, CliStatus::UpdateRequired); assert_eq!(state.cli_error_message, None); - if let Some(value) = original_cli_path { - std::env::set_var("CODEX_CLI_PATH", value); - } else { - std::env::remove_var("CODEX_CLI_PATH"); - } Ok(()) } @@ -1014,8 +1022,7 @@ mod tests { let temp = tempdir()?; let paths = test_runtime_paths(temp.path()); paths.ensure_dirs()?; - let original_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::remove_var("CODEX_CLI_PATH"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); let configured_path = temp.path().join("configured-codex"); write_executable_script( @@ -1035,11 +1042,6 @@ mod tests { assert_eq!(outcome.cli_path, configured_path); assert_eq!(outcome.installed_version, "0.42.0"); assert_eq!(state.cli_status, CliStatus::UpToDate); - if let Some(value) = original_cli_path { - std::env::set_var("CODEX_CLI_PATH", value); - } else { - std::env::remove_var("CODEX_CLI_PATH"); - } Ok(()) } @@ -1049,8 +1051,7 @@ mod tests { let temp = tempdir()?; let paths = test_runtime_paths(temp.path()); paths.ensure_dirs()?; - let original_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::remove_var("CODEX_CLI_PATH"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); let codex_path = temp.path().join("codex"); write_executable_script( @@ -1072,11 +1073,6 @@ mod tests { assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.0")); assert_eq!(state.cli_status, CliStatus::UpdateRequired); assert_eq!(state.cli_error_message, None); - if let Some(value) = original_cli_path { - std::env::set_var("CODEX_CLI_PATH", value); - } else { - std::env::remove_var("CODEX_CLI_PATH"); - } Ok(()) } @@ -1087,14 +1083,15 @@ mod tests { let paths = test_runtime_paths(temp.path()); paths.ensure_dirs()?; - let original_home = std::env::var_os("HOME"); - let original_path = std::env::var_os("PATH"); - let original_nvm_dir = std::env::var_os("NVM_DIR"); - let original_codex_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::set_var("HOME", temp.path()); - std::env::set_var("PATH", temp.path().join("missing-bin")); - std::env::remove_var("NVM_DIR"); - std::env::remove_var("CODEX_CLI_PATH"); + let _home_guard = EnvVarGuard::set(&_env_guard, "HOME", temp.path()); + let _path_guard = EnvVarGuard::set(&_env_guard, "PATH", temp.path().join("missing-bin")); + let _nvm_dir_guard = EnvVarGuard::remove(&_env_guard, "NVM_DIR"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); + let _skip_system_cli_lookup_guard = EnvVarGuard::set( + &_env_guard, + "CODEX_APP_UPDATER_TEST_SKIP_SYSTEM_CLI_LOOKUP", + "1", + ); let missing_path = temp.path().join("missing-codex"); let mut state = PersistedState::new(true); @@ -1105,27 +1102,6 @@ mod tests { let config = test_runtime_config(&paths); refresh_cached_status(&config, &mut state, &paths)?; - if let Some(home) = original_home { - std::env::set_var("HOME", home); - } else { - std::env::remove_var("HOME"); - } - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - if let Some(nvm_dir) = original_nvm_dir { - std::env::set_var("NVM_DIR", nvm_dir); - } else { - std::env::remove_var("NVM_DIR"); - } - if let Some(cli_path) = original_codex_cli_path { - std::env::set_var("CODEX_CLI_PATH", cli_path); - } else { - std::env::remove_var("CODEX_CLI_PATH"); - } - assert_eq!(state.cli_path, None); assert_eq!(state.cli_installed_version, None); assert_eq!(state.cli_status, CliStatus::NotInstalled); @@ -1143,40 +1119,20 @@ mod tests { let paths = test_runtime_paths(temp.path()); paths.ensure_dirs()?; - let original_home = std::env::var_os("HOME"); - let original_path = std::env::var_os("PATH"); - let original_nvm_dir = std::env::var_os("NVM_DIR"); - let original_codex_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::set_var("HOME", temp.path()); - std::env::set_var("PATH", temp.path().join("missing-bin")); - std::env::remove_var("NVM_DIR"); - std::env::remove_var("CODEX_CLI_PATH"); + let _home_guard = EnvVarGuard::set(&_env_guard, "HOME", temp.path()); + let _path_guard = EnvVarGuard::set(&_env_guard, "PATH", temp.path().join("missing-bin")); + let _nvm_dir_guard = EnvVarGuard::remove(&_env_guard, "NVM_DIR"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); + let _skip_system_cli_lookup_guard = EnvVarGuard::set( + &_env_guard, + "CODEX_APP_UPDATER_TEST_SKIP_SYSTEM_CLI_LOOKUP", + "1", + ); let mut state = PersistedState::new(true); let config = test_runtime_config(&paths); refresh_status(&config, &mut state, &paths)?; - if let Some(home) = original_home { - std::env::set_var("HOME", home); - } else { - std::env::remove_var("HOME"); - } - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - if let Some(nvm_dir) = original_nvm_dir { - std::env::set_var("NVM_DIR", nvm_dir); - } else { - std::env::remove_var("NVM_DIR"); - } - if let Some(cli_path) = original_codex_cli_path { - std::env::set_var("CODEX_CLI_PATH", cli_path); - } else { - std::env::remove_var("CODEX_CLI_PATH"); - } - assert_eq!(state.cli_path, None); assert_eq!(state.cli_installed_version, None); assert_eq!(state.cli_status, CliStatus::NotInstalled); @@ -1209,15 +1165,15 @@ mod tests { "#!/bin/sh\nif [ \"$1\" = \"view\" ] && [ \"$2\" = \"@openai/codex\" ] && [ \"$3\" = \"version\" ]; then\n echo '0.42.1'\n exit 0\nfi\nif [ \"$1\" = \"install\" ] && [ \"$2\" = \"-g\" ]; then\n printf '%s\\n' '#!/bin/sh' 'if [ \"$1\" = \"--version\" ] || [ \"$1\" = \"version\" ]; then' \" echo 'codex-cli v0.42.1'\" ' exit 0' 'fi' 'exit 1' > \"$FAKE_CODEX_PATH\"\n exit 0\nfi\nexit 1\n", )?; - let original_home = std::env::var_os("HOME"); - let original_path = std::env::var_os("PATH"); - let original_nvm_dir = std::env::var_os("NVM_DIR"); - let original_codex_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::set_var("HOME", temp.path()); - std::env::set_var("PATH", std::env::join_paths([bin_dir.clone()])?); - std::env::remove_var("NVM_DIR"); - std::env::remove_var("CODEX_CLI_PATH"); - std::env::set_var("FAKE_CODEX_PATH", &codex_path); + let _home_guard = EnvVarGuard::set(&_env_guard, "HOME", temp.path()); + let _path_guard = EnvVarGuard::set( + &_env_guard, + "PATH", + std::env::join_paths([bin_dir.clone()])?, + ); + let _nvm_dir_guard = EnvVarGuard::remove(&_env_guard, "NVM_DIR"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); + let _fake_codex_path_guard = EnvVarGuard::set(&_env_guard, "FAKE_CODEX_PATH", &codex_path); assert_eq!(npm_program(), npm_path); @@ -1227,28 +1183,6 @@ mod tests { let config = test_runtime_config(&paths); let updated = reconcile_if_present(&config, &mut state, &paths)?; - if let Some(home) = original_home { - std::env::set_var("HOME", home); - } else { - std::env::remove_var("HOME"); - } - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - if let Some(nvm_dir) = original_nvm_dir { - std::env::set_var("NVM_DIR", nvm_dir); - } else { - std::env::remove_var("NVM_DIR"); - } - if let Some(cli_path) = original_codex_cli_path { - std::env::set_var("CODEX_CLI_PATH", cli_path); - } else { - std::env::remove_var("CODEX_CLI_PATH"); - } - std::env::remove_var("FAKE_CODEX_PATH"); - assert!(updated); assert_eq!(state.cli_path.as_deref(), Some(codex_path.as_path())); assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.1")); @@ -1267,8 +1201,7 @@ mod tests { let codex_path = temp.path().join("codex"); write_executable_script(&codex_path, "#!/bin/sh\nexit 42\n")?; - let original_codex_cli_path = std::env::var_os("CODEX_CLI_PATH"); - std::env::remove_var("CODEX_CLI_PATH"); + let _codex_cli_path_guard = EnvVarGuard::remove(&_env_guard, "CODEX_CLI_PATH"); let mut state = PersistedState::new(true); state.cli_path = Some(codex_path.clone()); @@ -1277,9 +1210,6 @@ mod tests { let config = test_runtime_config(&paths); let result = reconcile_if_present(&config, &mut state, &paths); - if let Some(cli_path) = original_codex_cli_path { - std::env::set_var("CODEX_CLI_PATH", cli_path); - } let error = result.unwrap_err(); assert!(error.to_string().contains("exited with exit status: 42")); diff --git a/updater/src/test_util.rs b/updater/src/test_util.rs index 814befc7..d2687631 100644 --- a/updater/src/test_util.rs +++ b/updater/src/test_util.rs @@ -9,10 +9,59 @@ //! pick up the real `~/.nvm/.../bin/npm` instead of the temp-dir fake. Each //! test that touches env vars must hold this lock for its entire body. -use std::sync::MutexGuard; +use std::{marker::PhantomData, sync::MutexGuard}; -pub(crate) fn env_lock() -> MutexGuard<'static, ()> { - crate::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|err| err.into_inner()) +pub(crate) struct EnvLock { + _guard: MutexGuard<'static, ()>, +} + +pub(crate) fn env_lock() -> EnvLock { + EnvLock { + _guard: crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()), + } +} + +#[must_use = "EnvVarGuard restores the environment variable when dropped"] +pub(crate) struct EnvVarGuard<'a> { + key: &'static str, + original: Option, + _lock: PhantomData<&'a EnvLock>, +} + +impl<'a> EnvVarGuard<'a> { + pub(crate) fn set>( + _lock: &'a EnvLock, + key: &'static str, + value: K, + ) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value.into()); + Self { + key, + original, + _lock: PhantomData, + } + } + + pub(crate) fn remove(_lock: &'a EnvLock, key: &'static str) -> Self { + let original = std::env::var_os(key); + std::env::remove_var(key); + Self { + key, + original, + _lock: PhantomData, + } + } +} + +impl Drop for EnvVarGuard<'_> { + fn drop(&mut self) { + if let Some(value) = &self.original { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } }