mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-06-01 16:21:58 +03:00
Compare commits
39 Commits
docs/gover
...
fix/656-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e38066efef | ||
|
|
ec12c49092 | ||
|
|
73c9b8f6b2 | ||
|
|
7917ea4927 | ||
|
|
e7e647512a | ||
|
|
4409f3f0d4 | ||
|
|
4451694930 | ||
|
|
98232dbd81 | ||
|
|
76a5a21725 | ||
|
|
e31ac3b4da | ||
|
|
3e1e508f69 | ||
|
|
5f67aa1ae4 | ||
|
|
a894d41f76 | ||
|
|
a470b30079 | ||
|
|
b40441c66c | ||
|
|
364147ecc6 | ||
|
|
d6fc044490 | ||
|
|
9f99e578da | ||
|
|
ee3d656715 | ||
|
|
fed3b54bb5 | ||
|
|
7151d77b8d | ||
|
|
7b990c3aeb | ||
|
|
3df24958a3 | ||
|
|
58eef6d865 | ||
|
|
428777aca5 | ||
|
|
880d21d51f | ||
|
|
6bfb296d5c | ||
|
|
337e9a45b0 | ||
|
|
a32e1aa3c3 | ||
|
|
1e339aea93 | ||
|
|
e9b71cb567 | ||
|
|
016d8660c8 | ||
|
|
d54efca7de | ||
|
|
920c2be926 | ||
|
|
dc00767cd8 | ||
|
|
fa42d4d05f | ||
|
|
de604e9445 | ||
|
|
5b5c604723 | ||
|
|
97531b2cdf |
59
.github/workflows/issue-triage-v2.yml
vendored
59
.github/workflows/issue-triage-v2.yml
vendored
@@ -215,6 +215,7 @@ jobs:
|
||||
if: steps.classify.outputs.classification == 'bug' || steps.classify.outputs.classification == 'enhancement'
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
FIRST_PASS: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/classify-doublecheck-bug-vs-enhancement.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -249,7 +250,7 @@ jobs:
|
||||
printf '%s' "${structured}" \
|
||||
> /tmp/triage/classification-doublecheck.json
|
||||
|
||||
first_pass="${{ steps.classify.outputs.classification }}"
|
||||
first_pass="${FIRST_PASS}"
|
||||
verdict=$(jq -r '.verdict' \
|
||||
/tmp/triage/classification-doublecheck.json)
|
||||
|
||||
@@ -271,10 +272,14 @@ jobs:
|
||||
# classifier entirely.
|
||||
- name: Decide route
|
||||
id: route
|
||||
env:
|
||||
SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
DISAGREED: ${{ steps.doublecheck.outputs.disagreed }}
|
||||
run: |
|
||||
suspicious="${{ steps.suspicious.outputs.suspicious }}"
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
disagreed="${{ steps.doublecheck.outputs.disagreed }}"
|
||||
suspicious="${SUSPICIOUS}"
|
||||
classification="${CLASSIFICATION}"
|
||||
disagreed="${DISAGREED}"
|
||||
|
||||
if [[ "${suspicious}" == "true" ]]; then
|
||||
echo "route=deferral" >> "$GITHUB_OUTPUT"
|
||||
@@ -484,6 +489,7 @@ jobs:
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/investigate.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -530,7 +536,7 @@ jobs:
|
||||
# the PR. The reporter named a culprit; the diff is a
|
||||
# primary input for Stage 4 because the defect site is
|
||||
# almost always inside the named PR's changed files.
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
echo "## Regression context (PR named by reporter)"
|
||||
echo ""
|
||||
reg_title=$(jq -r '.title' /tmp/triage/regression-of.json)
|
||||
@@ -763,6 +769,7 @@ jobs:
|
||||
|| steps.dup_fetch.outputs.dup_fetched == 'true')
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/review.json)
|
||||
@@ -867,7 +874,7 @@ jobs:
|
||||
# regression_of diff block — only when Stage 3b validated.
|
||||
# Lets the reviewer check whether a finding's citation
|
||||
# actually lands inside the named PR's changed files.
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
echo "## regression_of PR diff (reporter-named culprit)"
|
||||
echo ""
|
||||
reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json)
|
||||
@@ -1027,25 +1034,37 @@ jobs:
|
||||
# low-confidence cause).
|
||||
- name: Decide comment variant
|
||||
id: decide
|
||||
env:
|
||||
ROUTE: ${{ steps.route.outputs.route }}
|
||||
DEFERRAL_REASON_ID: ${{ steps.route.outputs.deferral_reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
FETCH_OK: ${{ steps.fetch.outputs.fetch_ok }}
|
||||
INVEST_OK: ${{ steps.investigate.outputs.investigate_ok }}
|
||||
DRIFT: ${{ steps.drift.outputs.drift_detected }}
|
||||
REVIEW_OK: ${{ steps.review.outputs.review_ok }}
|
||||
FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }}
|
||||
KEPT: ${{ steps.filter.outputs.review_findings_kept }}
|
||||
AVG: ${{ steps.filter.outputs.review_avg_confidence }}
|
||||
DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }}
|
||||
run: |
|
||||
route="${{ steps.route.outputs.route }}"
|
||||
route="${ROUTE}"
|
||||
|
||||
if [[ "${route}" == "deferral" ]]; then
|
||||
echo "variant=8b" >> "$GITHUB_OUTPUT"
|
||||
echo "reason_id=${{ steps.route.outputs.deferral_reason_id }}" \
|
||||
echo "reason_id=${DEFERRAL_REASON_ID}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
fetch_ok="${{ steps.fetch.outputs.fetch_ok }}"
|
||||
invest_ok="${{ steps.investigate.outputs.investigate_ok }}"
|
||||
drift="${{ steps.drift.outputs.drift_detected }}"
|
||||
review_ok="${{ steps.review.outputs.review_ok }}"
|
||||
findings_passed="${{ steps.validate.outputs.findings_passed }}"
|
||||
kept="${{ steps.filter.outputs.review_findings_kept }}"
|
||||
avg="${{ steps.filter.outputs.review_avg_confidence }}"
|
||||
dup_rating="${{ steps.filter.outputs.duplicate_of_rating }}"
|
||||
classification="${CLASSIFICATION}"
|
||||
fetch_ok="${FETCH_OK}"
|
||||
invest_ok="${INVEST_OK}"
|
||||
drift="${DRIFT}"
|
||||
review_ok="${REVIEW_OK}"
|
||||
findings_passed="${FINDINGS_PASSED}"
|
||||
kept="${KEPT}"
|
||||
avg="${AVG}"
|
||||
dup_rating="${DUP_RATING}"
|
||||
|
||||
# Shared gates that apply to every investigate route.
|
||||
if [[ "${fetch_ok}" != "true" ]]; then
|
||||
@@ -1735,9 +1754,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REASON_ID: ${{ steps.decide.outputs.reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
VARIANT: ${{ steps.decide.outputs.variant }}
|
||||
run: |
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
variant="${{ steps.decide.outputs.variant }}"
|
||||
classification="${CLASSIFICATION}"
|
||||
variant="${VARIANT}"
|
||||
|
||||
if [[ "${variant}" == "8a" ]]; then
|
||||
triage_label="triage: investigated"
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -6,21 +6,60 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) —
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
<!-- Updated automatically by check-claude-version; will be current at release time. -->
|
||||
|
||||
### Added
|
||||
## [v2.0.15] — 2026-05-27
|
||||
|
||||
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
|
||||
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
|
||||
Tracks upstream Claude Desktop 1.9255.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
|
||||
- `StartupWMClass` aligned to `Claude` to match what Electron actually advertises via `productName`. The v2.0.14 value `claude-desktop` was silently ignored by Electron, causing orphan windows and duplicate gear icons on GNOME/KDE. Value centralized from 6 hardcoded locations to one source of truth in `build.sh`, with build-time substitution and a `productName` assertion guard. ([#655](https://github.com/aaddrick/claude-desktop-debian/pull/655), fixes [#652](https://github.com/aaddrick/claude-desktop-debian/issues/652))
|
||||
|
||||
## [v2.0.14] — 2026-05-25
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `WM_CLASS` and `StartupWMClass` aligned to `claude-desktop` across all formats (deb, RPM, AppImage, autostart). Resolves ambiguity with the Claude Code CLI (`claude`) and ensures consistent taskbar grouping on KDE/GNOME. ([#648](https://github.com/aaddrick/claude-desktop-debian/pull/648), fixes [#647](https://github.com/aaddrick/claude-desktop-debian/issues/647))
|
||||
|
||||
### Changed
|
||||
|
||||
- AppImage smoke test: replaced flat 10s sleep with readiness-marker poll (30s ceiling, 0.5s tick), unified cleanup trap to prevent 190MB `squashfs-root` leaks on interrupt. ([#646](https://github.com/aaddrick/claude-desktop-debian/pull/646))
|
||||
|
||||
## [v2.0.13] — 2026-05-24
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
|
||||
### Added
|
||||
|
||||
- `CLAUDE_KEEP_AWAKE=0` env var to suppress `powerSaveBlocker` sleep inhibitor that upstream holds indefinitely on Linux (no lifecycle management). Adds diagnostic logging for all `powerSaveBlocker` calls and `--doctor` visibility. ([#605](https://github.com/aaddrick/claude-desktop-debian/issues/605))
|
||||
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
|
||||
- F11 fullscreen toggle via hidden menu accelerator — Linux parity with macOS green button / Windows F11. ([#638](https://github.com/aaddrick/claude-desktop-debian/pull/638), fixes [#580](https://github.com/aaddrick/claude-desktop-debian/issues/580))
|
||||
- Linux org-plugins path (`/etc/claude/org-plugins`) added to platform switch, enabling MDM-managed plugin configuration. ([#639](https://github.com/aaddrick/claude-desktop-debian/pull/639), fixes [#607](https://github.com/aaddrick/claude-desktop-debian/issues/607))
|
||||
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
|
||||
- `--password-store` keyring detection: probes D-Bus for kwallet6 / gnome-libsecret at startup and injects the flag before the app path, fixing session persistence on KDE Plasma and other desktops where `safeStorage.isEncryptionAvailable()` returned false. Adds `CLAUDE_PASSWORD_STORE` env override and `--doctor` diagnostic. Thanks @dubreal. ([#611](https://github.com/aaddrick/claude-desktop-debian/pull/611), fixes [#593](https://github.com/aaddrick/claude-desktop-debian/issues/593))
|
||||
- Unzip fallback for Node 24: detects missing electron binary after `extract-zip` silently no-ops and recovers from the `@electron/get` cache using system `unzip`. Thanks @JustinJLeopard. ([#631](https://github.com/aaddrick/claude-desktop-debian/pull/631), fixes [#584](https://github.com/aaddrick/claude-desktop-debian/issues/584))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Config writes no longer drop externally-added `mcpServers`. The stale in-memory cache was overwriting disk on every preference change; now re-reads `mcpServers` from disk before each write. ([#643](https://github.com/aaddrick/claude-desktop-debian/pull/643), fixes [#400](https://github.com/aaddrick/claude-desktop-debian/issues/400))
|
||||
- Menu bar toggle fires on Alt keyup only, not keydown — fixes Alt+Shift (language switch) and Alt+F4 accidentally triggering the menu bar. `CLAUDE_MENU_BAR=hidden` disables the Alt toggle entirely. ([#642](https://github.com/aaddrick/claude-desktop-debian/pull/642), fixes [#630](https://github.com/aaddrick/claude-desktop-debian/issues/630))
|
||||
- `.asar` paths rejected in directory check, preventing Electron's ASAR VFS shim from dispatching `app.asar` to Cowork as a "folder drop". Fixes permission dialog on every launch, forced Cowork mode on reopen from tray, and "No conversation found" loop in Claude Code >=2.1.111. ([#640](https://github.com/aaddrick/claude-desktop-debian/pull/640), fixes [#383](https://github.com/aaddrick/claude-desktop-debian/issues/383), [#622](https://github.com/aaddrick/claude-desktop-debian/issues/622), [#632](https://github.com/aaddrick/claude-desktop-debian/issues/632))
|
||||
- Identifier captures across all patch scripts hardened from `\w+` to `[$\w]+` (PCRE) / `[[:alnum:]_$]+` (ERE). Fixes broken idempotency guard in `tray.sh`, adds missing guards to `cowork.sh` patches 6/9/10, adds `\s*` whitespace tolerance to multiple patterns. ([#644](https://github.com/aaddrick/claude-desktop-debian/pull/644))
|
||||
- `exec` before Electron invocation in deb, RPM, and Nix launchers so Ctrl+C and signals forward correctly to the Electron process. ([#637](https://github.com/aaddrick/claude-desktop-debian/pull/637), fixes [#424](https://github.com/aaddrick/claude-desktop-debian/issues/424))
|
||||
- `--class=Claude` added to launcher args ensuring WM_CLASS matches `StartupWMClass` in the .desktop file, preventing GNOME extension crashes from unexpected class values. ([#636](https://github.com/aaddrick/claude-desktop-debian/pull/636), ref [#635](https://github.com/aaddrick/claude-desktop-debian/issues/635))
|
||||
- Sloppy/focus-follows-mouse: suppress redundant `webContents.focus()` calls that trigger X11 `_NET_ACTIVE_WINDOW` raise-on-hover. Grace window handles stale `isFocused()` on tray-restore and minimize-restore. Thanks @tkrag. ([#589](https://github.com/aaddrick/claude-desktop-debian/pull/589), fixes [#416](https://github.com/aaddrick/claude-desktop-debian/issues/416))
|
||||
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
|
||||
- RPM: silence "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install` (replaces `%attr` in `%files`). Adds regression guard that fails the build if the warning reappears. Thanks @JoshuaVlantis. ([#610](https://github.com/aaddrick/claude-desktop-debian/pull/610), fixes [#609](https://github.com/aaddrick/claude-desktop-debian/issues/609))
|
||||
- Window close with `CLAUDE_QUIT_ON_CLOSE=1` now actively quits via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux. Rides upstream's own quit-in-progress guard. Thanks @phelps-matthew. ([#624](https://github.com/aaddrick/claude-desktop-debian/pull/624), fixes [#623](https://github.com/aaddrick/claude-desktop-debian/issues/623))
|
||||
- node-pty: wipe upstream Windows binaries (winpty.dll, winpty-agent.exe, Windows `.node` files) before staging the Linux build, preventing PE32+ orphans in the packaged asar. Thanks @JoshuaVlantis. ([#597](https://github.com/aaddrick/claude-desktop-debian/pull/597), addresses [#401](https://github.com/aaddrick/claude-desktop-debian/issues/401))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI injection hardening: moved `${{ steps.*.outputs.* }}` expressions from `run:` blocks to `env:` blocks in `issue-triage-v2.yml`. Build pipeline: `process.exit(0)` → `process.exit(1)` in `quick-window.sh` when patch anchors aren't found so CI fails instead of shipping broken patches. Packaging scriptlets: replaced `&> /dev/null` with `> /dev/null 2>&1` for dash compatibility in deb/RPM postinst. ([#641](https://github.com/aaddrick/claude-desktop-debian/pull/641))
|
||||
- Credit @lizthegrey, @sabiut, @typedrat, @RayCharlizard in README Acknowledgments. ([#626](https://github.com/aaddrick/claude-desktop-debian/pull/626))
|
||||
- Troubleshooting: new "Repeated Electron Crashes / GPU Process FATAL" section documenting `CLAUDE_DISABLE_GPU=1`. Adds tuning-rationale comments around the `--doctor` 3-in-7-days threshold and the `coredumpctl` `COMM=electron` assumption. Thanks @sabiut. ([#615](https://github.com/aaddrick/claude-desktop-debian/pull/615), addresses [#608](https://github.com/aaddrick/claude-desktop-debian/issues/608))
|
||||
- Docs filenames are now lowercase kebab-case (`docs/building.md`, `docs/configuration.md`, `docs/decisions.md`, `docs/troubleshooting.md`); `STYLEGUIDE.md` moved to [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md). Cross-references swept across README, CONTRIBUTING, CODEOWNERS, `.github/`, `.claude/`, `scripts/`, and `claude-desktop --doctor` user-facing output.
|
||||
@@ -225,7 +264,8 @@ First v2 wrapper release; tracks upstream Claude Desktop 1.3109.0, 1.3561.0.
|
||||
- **BREAKING**: Split `build.sh` into topical modules under `scripts/`; relocate packaging scripts into `scripts/packaging/`; extract `--doctor` into `scripts/doctor.sh`. Patch files now live in `scripts/patches/*.sh` (one per subsystem); `build.sh` is just an orchestrator. CI paths updated to `scripts/setup/detect-host.sh`.
|
||||
- Simplify cowork daemon recovery patch. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
|
||||
[Unreleased]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.12+claude1.8555.2...HEAD
|
||||
[Unreleased]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.13+claude1.8555.2...HEAD
|
||||
[v2.0.13]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.12+claude1.8555.2...v2.0.13+claude1.8555.2
|
||||
[v2.0.12]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.11+claude1.7196.1...v2.0.12+claude1.7196.3
|
||||
[v2.0.11]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.10+claude1.7196.0...v2.0.11+claude1.7196.1
|
||||
[v2.0.10]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.8+claude1.5354.0...v2.0.10+claude1.6259.0
|
||||
|
||||
@@ -273,9 +273,15 @@ Special thanks to:
|
||||
- RPM `chrome-sandbox` SUID via `%attr(4755, ...)` instead of a `%post` chmod scriptlet so the bit survives `--noscripts` and layered images (#539)
|
||||
- `autoUpdater` no-op Proxy on Linux that defends against future feed activation, with a thenable allowlist masking `then`/`catch`/`finally`/`Symbol.toPrimitive`/`Symbol.iterator` to `undefined` (#567)
|
||||
- Failing loudly on `npm install node-pty` failures instead of silently shipping the upstream Windows binaries, plus auto-installing `gcc`/`g++`/`make`/`python3` on minimal build environments (#401)
|
||||
- Silencing the RPM "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install`, with thorough investigation of four `%exclude`-based alternatives (#610)
|
||||
- Cleaning upstream Windows binaries from node-pty before staging the Linux build, preventing PE32+ orphans in the packaged asar (#597)
|
||||
- **[Hayao0819](https://github.com/Hayao0819)** for diagnosing the upstream `titleBarStyle:""` → `titleBarStyle:"hiddenInset"` migration that broke the About window render on GNOME/X11 and contributing the `isPopupWindow()` match extension (#481, #489)
|
||||
- **[michelsfun](https://github.com/michelsfun)** for reporting the cowork `ENAMETOOLONG` failure on eCryptfs-encrypted home directories with detailed `--doctor` output that pinpointed the short-NAME_MAX filesystem as the cause (#590)
|
||||
- **[proffalken](https://github.com/proffalken)** for the LUKS-volume + `pam_mount` workaround documented in `docs/troubleshooting.md`, restoring cowork support on legacy eCryptfs-encrypted home directories (#590)
|
||||
- **[phelps-matthew](https://github.com/phelps-matthew)** for fixing `CLAUDE_QUIT_ON_CLOSE=1` to actively quit via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux, with thorough root cause analysis and alternatives evaluation (#624, #623)
|
||||
- **[dubreal](https://github.com/dubreal)** for `--password-store` keyring detection that probes D-Bus for kwallet6 / gnome-libsecret at startup, fixing session persistence on KDE Plasma and other desktops where Electron's `safeStorage` was unavailable (#611, #593)
|
||||
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for detecting missing electron binaries after Node 24's `extract-zip` silently no-ops, with an `unzip` fallback that recovers from the `@electron/get` cache (#631, #584)
|
||||
- **[tkrag](https://github.com/tkrag)** for diagnosing and fixing the X11 window-raise-on-hover bug under sloppy/focus-follows-mouse WMs, tracing the upstream `webContents.focus()` → `_NET_ACTIVE_WINDOW` path through three iterations of review (#589, #416)
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
8
build.sh
8
build.sh
@@ -36,6 +36,8 @@ final_output_path=''
|
||||
|
||||
# Package metadata (constants)
|
||||
readonly PACKAGE_NAME='claude-desktop'
|
||||
readonly WM_CLASS='Claude'
|
||||
export WM_CLASS
|
||||
readonly MAINTAINER='Claude Desktop Linux Maintainers'
|
||||
readonly DESCRIPTION='Claude Desktop for Linux'
|
||||
|
||||
@@ -60,8 +62,12 @@ source "$script_dir/scripts/patches/quick-window.sh"
|
||||
source "$script_dir/scripts/patches/claude-code.sh"
|
||||
# shellcheck source=scripts/patches/cowork.sh
|
||||
source "$script_dir/scripts/patches/cowork.sh"
|
||||
# shellcheck source=scripts/patches/org-plugins.sh
|
||||
source "$script_dir/scripts/patches/org-plugins.sh"
|
||||
# shellcheck source=scripts/patches/wco-shim.sh
|
||||
source "$script_dir/scripts/patches/wco-shim.sh"
|
||||
# shellcheck source=scripts/patches/config.sh
|
||||
source "$script_dir/scripts/patches/config.sh"
|
||||
# shellcheck source=scripts/staging/electron.sh
|
||||
source "$script_dir/scripts/staging/electron.sh"
|
||||
# shellcheck source=scripts/staging/icons.sh
|
||||
@@ -155,7 +161,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;Network;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop (AppImage)
|
||||
EOF
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"lastModified": 1779536132,
|
||||
"narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
}:
|
||||
let
|
||||
pname = "claude-desktop";
|
||||
version = "1.8555.2";
|
||||
version = "1.9255.0";
|
||||
|
||||
srcs = {
|
||||
x86_64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-GrV+iMhkUc8ZnRVo11Hat/4p5L36Wj8DX9sVuHLHo1I=";
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe";
|
||||
hash = "sha256-QiRhl0sR08hwn5MDlhMss9AdJ+kX8yrGxLmgd7y3cEs=";
|
||||
};
|
||||
aarch64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-PDGaWaWbML/rhvcbbfgIkcXJg0BPEuRk9L4XVM1NLJQ=";
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe";
|
||||
hash = "sha256-HyCBdS793TGw9b7a43ZZs5w44zbRH4BBoaNpnqhhvbw=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -265,12 +265,10 @@ build_electron_args 'nix'
|
||||
# Add app path
|
||||
electron_args+=("$app_path")
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
log_message "Executing: $electron_exec ''${electron_args[*]} $*"
|
||||
"$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
exit_code=$?
|
||||
log_message "Electron exited with code: $exit_code"
|
||||
exit $exit_code
|
||||
exec "$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
LAUNCHER
|
||||
# Substitute placeholders — electron_exec points to our custom
|
||||
# wrapper (which sets GTK/GIO env then execs our merged binary)
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
# <name><TAB><pcre_pattern><TAB><sample>
|
||||
# Lines starting with '#' and blank lines are ignored.
|
||||
#
|
||||
# Each row names a post-patch fingerprint of patch_cowork_linux() in
|
||||
# scripts/patches/cowork.sh. Both verify-patches.sh and
|
||||
# tests/verify-patches.bats consume this file, so adding a marker
|
||||
# here adds it to the runtime check and the test matrix at the same
|
||||
# time.
|
||||
# Each row names a post-patch fingerprint from the patch suite in
|
||||
# scripts/patches/. Both verify-patches.sh and tests/verify-patches.bats
|
||||
# consume this file, so adding a marker here adds it to the runtime
|
||||
# check and the test matrix at the same time.
|
||||
#
|
||||
# Columns:
|
||||
# name — kebab-case id; surfaces in verify output and BATS names.
|
||||
@@ -16,8 +15,9 @@
|
||||
# sample — concrete string the pattern matches; BATS uses it to
|
||||
# build positive and per-marker negative fixtures.
|
||||
#
|
||||
# The 9 markers below correspond 1:1 with the smoke-test set defined
|
||||
# in issue #559 (PR #555 retrofit, deliverable D6).
|
||||
# The first 9 markers correspond to the smoke-test set defined in
|
||||
# issue #559 (PR #555 retrofit, deliverable D6). Additional markers
|
||||
# cover other critical patches (e.g., .asar guards).
|
||||
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
|
||||
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
|
||||
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
|
||||
@@ -27,3 +27,4 @@ econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED"
|
||||
cowork-daemon-pid global\.__coworkDaemonPid global.__coworkDaemonPid=_c.pid
|
||||
cowork-linux-daemon-shutdown cowork-linux-daemon-shutdown name:"cowork-linux-daemon-shutdown"
|
||||
sharedcwdpath-threadthrough sharedCwdPath:this\.sessions\.get\( sharedCwdPath:this.sessions.get(t)?.userSelectedFolders?.[0]
|
||||
asar-adddir-filter \.filter\(_d=>!_d\.endsWith\("\.asar"\)\).*"--add-dir" .filter(_d=>!_d.endsWith(".asar")))Y.push("--add-dir"
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 21 and column 39.
|
@@ -677,6 +677,14 @@ run_doctor() {
|
||||
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
|
||||
fi
|
||||
|
||||
# -- Keep awake override --
|
||||
local keep_awake="${CLAUDE_KEEP_AWAKE:-}"
|
||||
if [[ $keep_awake == '0' ]]; then
|
||||
_pass 'Keep awake: suppressed (CLAUDE_KEEP_AWAKE=0)'
|
||||
elif [[ -n $keep_awake ]]; then
|
||||
_info "Keep awake: CLAUDE_KEEP_AWAKE=$keep_awake (default behavior)"
|
||||
fi
|
||||
|
||||
# -- Electron binary --
|
||||
# Version is read from the file next to the binary rather than
|
||||
# launching Electron, which can hang (see #371).
|
||||
|
||||
@@ -81,6 +81,15 @@ const CLOSE_TO_TRAY = process.platform === 'linux'
|
||||
&& process.env.CLAUDE_QUIT_ON_CLOSE !== '1';
|
||||
console.log(`[Frame Fix] Close-to-tray: ${CLOSE_TO_TRAY ? 'on' : 'off'}`);
|
||||
|
||||
// Power save blocker behavior, controlled by CLAUDE_KEEP_AWAKE env var:
|
||||
// unset / '1' - pass through with diagnostic logging
|
||||
// '0' - suppress powerSaveBlocker.start() calls entirely
|
||||
// Upstream's keepAwakeEnabled has no lifecycle management on Linux (the
|
||||
// darwin-only wake scheduler never runs), so the inhibitor fires at init
|
||||
// and never releases — preventing suspend and screensaver. See #605.
|
||||
const KEEP_AWAKE = process.env.CLAUDE_KEEP_AWAKE !== '0';
|
||||
console.log(`[Frame Fix] Keep awake: ${KEEP_AWAKE ? 'on (default)' : 'suppressed (CLAUDE_KEEP_AWAKE=0)'}`);
|
||||
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About).
|
||||
// Window kinds — see build-reference/app-extracted/.vite/build/index.js:
|
||||
// Quick Entry: titleBarStyle:"hidden", frame:false (caught early)
|
||||
@@ -178,10 +187,7 @@ Module.prototype.require = function(id) {
|
||||
} else if (TITLEBAR_STYLE === 'native') {
|
||||
// Main window, native mode: force system frame.
|
||||
options.frame = true;
|
||||
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
|
||||
// 'auto' (default): hidden, Alt toggles
|
||||
// 'visible'/'hidden': no Alt toggle
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
options.autoHideMenuBar = false;
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
|
||||
@@ -211,7 +217,7 @@ Module.prototype.require = function(id) {
|
||||
// CSS rule still applying within the framed
|
||||
// window's content area.
|
||||
options.frame = true;
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
options.autoHideMenuBar = false;
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
|
||||
@@ -246,6 +252,22 @@ Module.prototype.require = function(id) {
|
||||
this.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
// Track the most recent 'show' event timestamp on the
|
||||
// window. Read by the webContents.focus() guard below to
|
||||
// distinguish a genuine post-show activation (which must
|
||||
// pass through to send _NET_ACTIVE_WINDOW and actually
|
||||
// give the window WM focus) from a sloppy-focus
|
||||
// reassertion (which is what we want to skip). Required
|
||||
// because Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland — a freshly-restored
|
||||
// window reports focused=true even though the WM never
|
||||
// activated it, and skipping the focus() call leaves the
|
||||
// window visible-but-inert until the user clicks it.
|
||||
// See #416 review notes.
|
||||
this._lastShownAt = 0;
|
||||
this.on('show', () => { this._lastShownAt = Date.now(); });
|
||||
this.on('restore', () => { this._lastShownAt = Date.now(); });
|
||||
|
||||
// Inject CSS for Linux scrollbar styling
|
||||
this.webContents.on('did-finish-load', () => {
|
||||
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
|
||||
@@ -316,8 +338,7 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
|
||||
// In 'hidden' mode, suppress Alt toggle by re-hiding
|
||||
// on every show event. In 'auto' mode, let
|
||||
// autoHideMenuBar handle the toggle natively.
|
||||
// on every show event.
|
||||
if (MENU_BAR_MODE === 'hidden') {
|
||||
this.on('show', () => {
|
||||
this.setMenuBarVisibility(false);
|
||||
@@ -367,6 +388,18 @@ Module.prototype.require = function(id) {
|
||||
this.on('close', () => { result.app.quit(); });
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle state (auto mode). Tracked
|
||||
// per-window so chords spanning multiple webContents
|
||||
// (main window + BrowserView) share one state machine.
|
||||
// Reset on blur to avoid stale state after Alt-Tab.
|
||||
if (MENU_BAR_MODE === 'auto') {
|
||||
this._altMenuTracker = { pressed: false, chorded: false };
|
||||
this.on('blur', () => {
|
||||
this._altMenuTracker.pressed = false;
|
||||
this._altMenuTracker.chorded = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Directly set child view bounds to match content size.
|
||||
// This bypasses Chromium's stale LayoutManagerBase cache
|
||||
// (only invalidated via _NET_WM_STATE atom changes, which
|
||||
@@ -530,11 +563,32 @@ Module.prototype.require = function(id) {
|
||||
|
||||
// Intercept Menu.setApplicationMenu to hide menu bar on Linux.
|
||||
// In 'hidden' mode, force-hide after every menu update.
|
||||
// In 'auto' mode, only hide initially (autoHideMenuBar handles
|
||||
// Alt toggle — re-hiding here would break that). Fixes: #321
|
||||
// In 'auto' mode, only hide initially (the before-input-event
|
||||
// Alt-keyup handler manages toggle). Fixes: #321
|
||||
const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu);
|
||||
patchedSetApplicationMenu = function(menu) {
|
||||
console.log('[Frame Fix] Intercepting setApplicationMenu');
|
||||
|
||||
// Append a hidden View submenu with F11 fullscreen toggle.
|
||||
// Upstream has fullscreenable:true and persists isFullScreen
|
||||
// across sessions; macOS provides the green traffic-light
|
||||
// button; Linux has no equivalent OS-level trigger, so we
|
||||
// register an accelerator here. visible:false keeps it out
|
||||
// of the menu bar — it only registers the keybinding.
|
||||
// Fixes: #580
|
||||
if (process.platform === 'linux' && menu) {
|
||||
const { MenuItem, Menu: MenuClass } = electronModule;
|
||||
menu.append(new MenuItem({
|
||||
label: 'View',
|
||||
visible: false,
|
||||
submenu: MenuClass.buildFromTemplate([{
|
||||
label: 'Toggle Full Screen',
|
||||
role: 'togglefullscreen',
|
||||
accelerator: 'F11',
|
||||
}]),
|
||||
}));
|
||||
}
|
||||
|
||||
originalSetAppMenu(menu);
|
||||
if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') {
|
||||
for (const win of PatchedBrowserWindow.getAllWindows()) {
|
||||
@@ -587,13 +641,105 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
}
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (!input.control) return;
|
||||
if (input.alt || input.shift || input.meta) return;
|
||||
if (input.key !== 'q' && input.key !== 'Q') return;
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
if (input.type === 'keyDown' && input.control
|
||||
&& !input.alt && !input.shift && !input.meta
|
||||
&& (input.key === 'q' || input.key === 'Q')) {
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle (auto mode). Chromium's
|
||||
// autoHideMenuBar fires on keydown, grabbing focus
|
||||
// before Alt+Shift (language switch) or Alt+F4 can
|
||||
// complete. We suppress the keydown and toggle on
|
||||
// keyup only when Alt was released without any
|
||||
// intervening key. Fixes: #630
|
||||
if (MENU_BAR_MODE !== 'auto') return;
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return;
|
||||
const tracker = owner._altMenuTracker;
|
||||
if (!tracker) return;
|
||||
|
||||
if (input.key === 'Alt') {
|
||||
if (input.type === 'keyDown') {
|
||||
tracker.pressed = true;
|
||||
tracker.chorded = false;
|
||||
event.preventDefault();
|
||||
} else if (input.type === 'keyUp') {
|
||||
if (tracker.pressed && !tracker.chorded) {
|
||||
owner.setMenuBarVisibility(!owner.isMenuBarVisible());
|
||||
}
|
||||
tracker.pressed = false;
|
||||
}
|
||||
} else if (tracker.pressed && input.type === 'keyDown') {
|
||||
tracker.chorded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress redundant webContents.focus() calls that would
|
||||
// re-trigger Chromium's X11Window::Activate() and send a
|
||||
// _NET_ACTIVE_WINDOW client message — EWMH defines that as
|
||||
// focus-AND-raise, so under sloppy / focus-follows-mouse
|
||||
// WMs (Cinnamon Muffin, Mutter, i3 with focus_follows_mouse)
|
||||
// every BrowserWindow 'focus' event causes a raise on
|
||||
// mouse-enter, undoing the user's "no auto-raise" config.
|
||||
// Tracks electron/electron#38184.
|
||||
//
|
||||
// Hooked at app.on('web-contents-created') so child views
|
||||
// are covered too — the BrowserWindow-class wrap only
|
||||
// touches the window's own webContents, but the upstream
|
||||
// call site lives on a child WebContentsView (the claude.ai
|
||||
// host view) whose webContents is a different object.
|
||||
//
|
||||
// Skip is gated on the *owning toplevel*'s isFocused(),
|
||||
// not the webContents'. wc.isFocused() returns false on a
|
||||
// freshly-attached child view even when the window is
|
||||
// focused — that's exactly the state on every sloppy hover,
|
||||
// so guarding on it would never skip and the raise loop
|
||||
// would continue.
|
||||
//
|
||||
// The post-'show' grace window is the second half of the
|
||||
// story. Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland (the same trap that
|
||||
// drives the KDE-only patches in scripts/patches/
|
||||
// quick-window.sh); a tray-restore hide → show then sees
|
||||
// ownerFocused=true and a naive guard would skip, leaving
|
||||
// the window visible-but-inert (no _NET_ACTIVE_WINDOW, no
|
||||
// keyboard focus until the user clicks). Within
|
||||
// SHOW_GRACE_MS of a 'show' event we pass through
|
||||
// unconditionally, so the post-restore activation actually
|
||||
// lands. 1000 ms covers the synchronous show → focus
|
||||
// sequence with margin for slow restores.
|
||||
//
|
||||
// Trade-off: in sloppy mode, hover-induced focus events
|
||||
// are SKIPped, which suppresses both the X11 raise (the
|
||||
// bug we're fixing) and the renderer-focus direction that
|
||||
// webContents.focus() would also do. Net effect: hover
|
||||
// gives WM focus (frame highlight) but renderer focus
|
||||
// doesn't follow until the user clicks. The Electron API
|
||||
// doesn't expose a renderer-focus-only path on X11, so
|
||||
// this is the best available trade against the constant-
|
||||
// raise UX. Genuine activations (no recent show + not
|
||||
// already focused) still go through end-to-end.
|
||||
//
|
||||
// Known: deferred setTimeout focus sites (e.g. find-bar
|
||||
// dismiss) outside the grace window may lose renderer-focus
|
||||
// direction on keyboard dismissal. See #416 review.
|
||||
//
|
||||
// Fixes: #416
|
||||
const SHOW_GRACE_MS = 1000;
|
||||
const origFocus = wc.focus.bind(wc);
|
||||
wc.focus = (...args) => {
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return origFocus(...args);
|
||||
if (!owner.isFocused()) return origFocus(...args);
|
||||
const shownAt = owner._lastShownAt || 0;
|
||||
if (Date.now() - shownAt < SHOW_GRACE_MS) {
|
||||
return origFocus(...args);
|
||||
}
|
||||
return;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -647,9 +793,8 @@ Module.prototype.require = function(id) {
|
||||
return { exec: 'claude-desktop', icon: 'claude-desktop' };
|
||||
};
|
||||
|
||||
// StartupWMClass matches the value set by scripts/packaging/{deb,rpm}.sh
|
||||
// so DEs group an autostarted window with user-launched instances
|
||||
// under the same taskbar / dock entry.
|
||||
// StartupWMClass derived from Electron's app.name (upstream
|
||||
// productName) so DEs group autostarted and launched instances.
|
||||
const buildAutostartContent = () => {
|
||||
const { exec, icon } = resolveAutostartTarget();
|
||||
return `[Desktop Entry]
|
||||
@@ -657,7 +802,7 @@ Type=Application
|
||||
Name=Claude
|
||||
Exec=${exec}
|
||||
Icon=${icon}
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=${result.app.name}
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`;
|
||||
@@ -793,6 +938,39 @@ X-GNOME-Autostart-enabled=true
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'powerSaveBlocker' && process.platform === 'linux') {
|
||||
// Wrap powerSaveBlocker with logging and optional suppression
|
||||
const originalPSB = target.powerSaveBlocker;
|
||||
return new Proxy(originalPSB, {
|
||||
get(psTarget, psProp) {
|
||||
if (psProp === 'start') {
|
||||
return function(type) {
|
||||
if (!KEEP_AWAKE) {
|
||||
console.log(`[Power] powerSaveBlocker.start('${type}') suppressed (CLAUDE_KEEP_AWAKE=0)`);
|
||||
return -1;
|
||||
}
|
||||
const id = psTarget.start(type);
|
||||
console.log(`[Power] powerSaveBlocker.start('${type}') -> id=${id}`);
|
||||
return id;
|
||||
};
|
||||
}
|
||||
if (psProp === 'stop') {
|
||||
return function(id) {
|
||||
if (id < 0) return;
|
||||
console.log(`[Power] powerSaveBlocker.stop(${id})`);
|
||||
return psTarget.stop(id);
|
||||
};
|
||||
}
|
||||
if (psProp === 'isStarted') {
|
||||
return function(id) {
|
||||
if (id < 0) return false;
|
||||
return psTarget.isStarted(id);
|
||||
};
|
||||
}
|
||||
return Reflect.get(psTarget, psProp);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'autoUpdater' && process.platform === 'linux') {
|
||||
// Force autoUpdater into a no-op on Linux. Upstream's bundled
|
||||
// app code sets a feed URL of api.anthropic.com/api/desktop/linux/...
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# Common launcher functions for Claude Desktop (AppImage and deb)
|
||||
# This file is sourced by both launchers to avoid code duplication
|
||||
|
||||
# WM_CLASS / StartupWMClass — must match upstream productName.
|
||||
# @@WM_CLASS@@ is replaced at build time; see build.sh.
|
||||
readonly WM_CLASS='@@WM_CLASS@@'
|
||||
|
||||
# Setup logging directory and file
|
||||
# Sets: log_dir, log_file
|
||||
setup_logging() {
|
||||
@@ -181,6 +185,10 @@ build_electron_args() {
|
||||
electron_args+=('--disable-features=CustomTitlebar')
|
||||
fi
|
||||
|
||||
# WM_CLASS must match the .desktop StartupWMClass and upstream's
|
||||
# productName. Ref: #647, #652
|
||||
electron_args+=("--class=$WM_CLASS")
|
||||
|
||||
# Chromium's safeStorage API and cookie encryption both require a
|
||||
# system keyring selected by --password-store. Without an explicit
|
||||
# value, Electron may silently report encryption unavailable even
|
||||
|
||||
@@ -48,6 +48,7 @@ echo 'Application files copied to Electron resources directory'
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "$appdir_path/usr/lib/claude-desktop" || exit 1
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$appdir_path/usr/lib/claude-desktop/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -133,7 +134,7 @@ Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Comment=Claude Desktop for Linux
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop
|
||||
EOF
|
||||
|
||||
@@ -70,6 +70,7 @@ echo 'Application files copied to Electron resources directory'
|
||||
# at runtime, so both must live in the same directory)
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$install_dir/lib/$package_name/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -84,7 +85,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
EOF
|
||||
echo 'Desktop entry created'
|
||||
|
||||
@@ -166,13 +167,10 @@ app_dir="/usr/lib/$package_name"
|
||||
log_message "Changing directory to \$app_dir"
|
||||
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
exec "\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
EOF
|
||||
chmod +x "$install_dir/bin/claude-desktop" || exit 1
|
||||
echo 'Launcher script created'
|
||||
@@ -206,7 +204,7 @@ set -e
|
||||
|
||||
# Update desktop database for MIME types
|
||||
echo "Updating desktop database..."
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox if electron is installed globally
|
||||
# or locally packaged
|
||||
|
||||
@@ -68,7 +68,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
EOF
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
@@ -149,13 +149,10 @@ app_dir="/usr/lib/$package_name"
|
||||
log_message "Changing directory to \$app_dir"
|
||||
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
exec "\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
EOF
|
||||
chmod +x "$staging_dir/claude-desktop"
|
||||
|
||||
@@ -221,6 +218,7 @@ cp -r $app_staging_dir/app.asar.unpacked %{buildroot}/usr/lib/$package_name/node
|
||||
# Copy shared launcher library (launcher-common.sh sources doctor.sh
|
||||
# at runtime, so both must live in the same directory)
|
||||
cp $(dirname "$script_dir")/launcher-common.sh %{buildroot}/usr/lib/$package_name/
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "%{buildroot}/usr/lib/$package_name/launcher-common.sh"
|
||||
cp $(dirname "$script_dir")/doctor.sh %{buildroot}/usr/lib/$package_name/
|
||||
|
||||
# Install desktop entry
|
||||
@@ -236,11 +234,11 @@ chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-
|
||||
|
||||
%post
|
||||
# Update desktop database for MIME types
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
%postun
|
||||
# Update desktop database after removal
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
%files
|
||||
%defattr(-, root, root, 0755)
|
||||
|
||||
@@ -11,10 +11,10 @@ extract_electron_variable() {
|
||||
echo 'Extracting electron module variable name...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
electron_var=$(grep -oP '\$?\w+(?=\s*=\s*require\("electron"\))' \
|
||||
electron_var=$(grep -oP '[$\w]+(?=\s*=\s*require\("electron"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $electron_var ]]; then
|
||||
electron_var=$(grep -oP '(?<=new )\$?\w+(?=\.Tray\b)' \
|
||||
electron_var=$(grep -oP '(?<=new )[$\w]+(?=\.Tray\b)' \
|
||||
"$index_js" | head -1)
|
||||
fi
|
||||
if [[ -z $electron_var ]]; then
|
||||
@@ -33,7 +33,7 @@ fix_native_theme_references() {
|
||||
|
||||
local wrong_refs
|
||||
mapfile -t wrong_refs < <(
|
||||
grep -oP '\$?\w+(?=\.nativeTheme)' "$index_js" \
|
||||
grep -oP '[$\w]+(?=\.nativeTheme)' "$index_js" \
|
||||
| sort -u \
|
||||
| grep -Fxv "$electron_var" || true
|
||||
)
|
||||
|
||||
@@ -53,6 +53,17 @@ fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2
|
||||
console.log('Updated package.json: main entry, desktopName, and node-pty dependency');
|
||||
" "$desktop_name"
|
||||
|
||||
# Fail fast if upstream changed productName — a mismatch silently
|
||||
# breaks StartupWMClass in every .desktop file we ship.
|
||||
local product_name
|
||||
product_name=$(node -e \
|
||||
"console.log(require('./app.asar.contents/package.json').productName)")
|
||||
if [[ $product_name != "$WM_CLASS" ]]; then
|
||||
echo "Error: upstream productName '$product_name' != WM_CLASS" \
|
||||
"'$WM_CLASS' — update WM_CLASS in build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create stub native module
|
||||
echo 'Creating stub native module...'
|
||||
mkdir -p app.asar.contents/node_modules/@ant/claude-native || exit 1
|
||||
@@ -92,9 +103,17 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
|
||||
# Add Linux Claude Code support
|
||||
patch_linux_claude_code
|
||||
|
||||
# Reject .asar paths in the directory-check helper so Electron's
|
||||
# ASAR VFS shim doesn't misidentify app.asar as a folder and
|
||||
# trigger false Cowork dispatch (#383, #622, #632).
|
||||
patch_asar_path_filter
|
||||
|
||||
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
|
||||
patch_cowork_linux
|
||||
|
||||
# Add Linux org-plugins path for MDM-managed plugin marketplace
|
||||
patch_org_plugins_path
|
||||
|
||||
# Inject WCO shim into the BrowserView preload so claude.ai's
|
||||
# desktop topbar renders on Linux. The shim spoofs the bundle's
|
||||
# isWindows() UA check (load-bearing) plus matchMedia and
|
||||
@@ -102,6 +121,17 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
|
||||
# docs/learnings/linux-topbar-shim.md.
|
||||
patch_wco_shim
|
||||
|
||||
# Preserve externally-added mcpServers across config writes (#400)
|
||||
patch_config_write_merge
|
||||
|
||||
# Reject .asar paths in addTrustedFolder to reduce spurious config
|
||||
# writes that amplify the stale-cache overwrite bug (#400)
|
||||
patch_asar_trusted_folder_guard
|
||||
|
||||
# Filter .asar paths from --add-dir dispatch and session restore
|
||||
# so corrupted pre-#640 sessions cannot crash local agent mode (#649)
|
||||
patch_asar_additional_dirs_guard
|
||||
|
||||
# Copy cowork VM service daemon for Linux Cowork mode
|
||||
echo 'Installing cowork VM service daemon...'
|
||||
cp "$source_dir/scripts/cowork-vm-service.js" \
|
||||
|
||||
@@ -16,12 +16,12 @@ patch_linux_claude_code() {
|
||||
|
||||
# New format (Claude >= 1.1.3541): getHostPlatform includes arch detection for win32
|
||||
# Pattern: if(process.platform==="win32")return e==="arm64"?"win32-arm64":"win32-x64";throw new Error(...)
|
||||
if grep -qP 'if\(process\.platform==="win32"\)return \w+==="arm64"\?"win32-arm64":"win32-x64";throw' "$index_js"; then
|
||||
sed -i -E 's/if\(process\.platform==="win32"\)return (\w+)==="arm64"\?"win32-arm64":"win32-x64";throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
if grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+[$\w]+\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+([[:alnum:]_$]+)\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
echo 'Added linux claude code support (new arch-aware format)'
|
||||
# Old format (Claude <= 1.1.3363): no arch detection for win32
|
||||
elif grep -q 'if(process.platform==="win32")return"win32-x64";' "$index_js"; then
|
||||
sed -i 's/if(process.platform==="win32")return"win32-x64";/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
elif grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
echo 'Added linux claude code support (legacy format)'
|
||||
else
|
||||
echo 'Warning: Could not find getHostPlatform pattern to patch for Linux claude code support'
|
||||
|
||||
297
scripts/patches/config.sh
Normal file
297
scripts/patches/config.sh
Normal file
@@ -0,0 +1,297 @@
|
||||
#===============================================================================
|
||||
# Config-related patches: preserve externally-added mcpServers across config
|
||||
# writes, guard addTrustedFolder against .asar paths, and filter .asar entries
|
||||
# from the --add-dir CLI dispatch and session restore.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: project_root
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_config_write_merge() {
|
||||
echo 'Patching config writer to preserve mcpServers from disk...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -q '_cdd_dc' "$index_js"; then
|
||||
echo ' mcpServers merge already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract variable names from the unique anchor:
|
||||
# await WRITE_FN(PATH_VAR, CONFIG_VAR), LOGGER.info("Config file written")
|
||||
local write_fn path_var config_var write_fn_re path_var_re
|
||||
|
||||
write_fn=$(grep -oP \
|
||||
'await \K[$\w]+(?=\([$\w]+,\s*[$\w]+\)\s*,\s*[$\w]+\.info\("Config file written"\))' \
|
||||
"$index_js")
|
||||
if [[ -z $write_fn ]]; then
|
||||
echo ' Could not extract write function name — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
write_fn_re="${write_fn//\$/\\$}"
|
||||
|
||||
path_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(\\K[\$\\w]+(?=,\\s*[\$\\w]+\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract path variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
path_var_re="${path_var//\$/\\$}"
|
||||
|
||||
config_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(${path_var_re},\\s*\\K[\$\\w]+(?=\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $config_var ]]; then
|
||||
echo ' Could not extract config variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
echo " Write fn: $write_fn, path: $path_var, config: $config_var"
|
||||
|
||||
if ! WRITE_FN="$write_fn" PATH_VAR="$path_var" CFG_VAR="$config_var" \
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const W = process.env.WRITE_FN;
|
||||
const P = process.env.PATH_VAR;
|
||||
const C = process.env.CFG_VAR;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const reEsc = (s) => s.replace(/[.*+?\${}()|[\\]\\\\]/g, '\\\\\$&');
|
||||
const anchor = new RegExp(
|
||||
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
|
||||
'\\\\)\\\\s*,\\\\s*\\\\w+\\\\.info\\\\(\"Config file written\"\\\\)'
|
||||
);
|
||||
if (!anchor.test(code)) {
|
||||
console.error(' [FAIL] Config-write anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const merge =
|
||||
'try{var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(' + P +
|
||||
',\"utf8\"));if(_cdd_dc.mcpServers){' + C +
|
||||
'.mcpServers=Object.assign({},_cdd_dc.mcpServers,' + C +
|
||||
'.mcpServers||{})}}catch(_cdd_ex){}';
|
||||
|
||||
code = code.replace(anchor, (m) => merge + ';' + m);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] mcpServers merge injected before config write');
|
||||
"; then
|
||||
echo 'Failed to inject config write merge' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_asar_trusted_folder_guard() {
|
||||
echo 'Patching addTrustedFolder to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -qF 'endsWith(".asar"))return' "$index_js"; then
|
||||
echo ' .asar guard already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
local folder_param
|
||||
folder_param=$(grep -oP \
|
||||
'LocalAgentModeSessions\.addTrustedFolder: \$\{\K[$\w]+(?=\})' \
|
||||
"$index_js")
|
||||
if [[ -z $folder_param ]]; then
|
||||
echo ' Could not extract folder parameter — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
echo " Found folder parameter: $folder_param"
|
||||
|
||||
if ! FOLDER_PARAM="$folder_param" node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const F = process.env.FOLDER_PARAM;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const anchor = 'LocalAgentModeSessions.addTrustedFolder: \${' + F + '}\`);';
|
||||
const idx = code.indexOf(anchor);
|
||||
if (idx === -1) {
|
||||
console.error(' [FAIL] addTrustedFolder anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const insertPoint = idx + anchor.length;
|
||||
const guard = 'if(' + F + '.endsWith(\".asar\"))return;';
|
||||
code = code.slice(0, insertPoint) + guard + code.slice(insertPoint);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] .asar guard injected in addTrustedFolder');
|
||||
"; then
|
||||
echo 'Failed to inject .asar trusted folder guard' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: filter .asar paths from --add-dir CLI dispatch and session restore
|
||||
#
|
||||
# PR #640 guards the directory-check helper and addTrustedFolder IPC
|
||||
# handler, but .asar paths in corrupted pre-#640 sessions survive
|
||||
# restore (existsSync passes via Electron's ASAR VFS shim) and reach
|
||||
# additionalDirectories -> --add-dir -> fatal Claude Code error.
|
||||
#
|
||||
# Fix: two sub-patches:
|
||||
# 1. Filter at the --add-dir CLI dispatch loop (the single convergence
|
||||
# point for ALL code paths that feed additionalDirectories).
|
||||
# 2. Filter at session restore to self-heal corrupted persisted state.
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_additional_dirs_guard() {
|
||||
echo 'Patching --add-dir dispatch to reject .asar paths (#649)...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency
|
||||
if grep -qF '.filter(_d=>!_d.endsWith(".asar"))' "$index_js"; then
|
||||
echo ' .asar --add-dir filter already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_ADDDIR_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
let patchCount = 0;
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 1: Filter .asar from --add-dir loop
|
||||
//
|
||||
// Target (unique, 1 occurrence):
|
||||
// for (let O of A) Y.push("--add-dir", O);
|
||||
// Fallback (if minifier uses .forEach):
|
||||
// A.forEach(O=>Y.push("--add-dir",O))
|
||||
// ================================================================
|
||||
{
|
||||
// Primary: for...of pattern
|
||||
const forOfRe = /for\s*\(\s*let\s+([\w$]+)\s+of\s+([\w$]+)\s*\)\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\1\s*\)/;
|
||||
// Fallback: .forEach pattern
|
||||
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/;
|
||||
|
||||
let match = code.match(forOfRe);
|
||||
let variant = 'for-of';
|
||||
if (!match) {
|
||||
match = code.match(forEachRe);
|
||||
variant = 'forEach';
|
||||
}
|
||||
if (!match) {
|
||||
console.error('FATAL: --add-dir dispatch loop not found.');
|
||||
console.error(' for(let X of Y) Z.push("--add-dir", X)');
|
||||
console.error(' Y.forEach(X=>Z.push("--add-dir", X))');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Count assertion: exactly 1 match expected
|
||||
const escaped = match[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allMatches = code.match(new RegExp(escaped, 'g'));
|
||||
if (allMatches && allMatches.length > 1) {
|
||||
console.error('FATAL: --add-dir pattern matches ' +
|
||||
allMatches.length + ' times (expected 1).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let filtered;
|
||||
if (variant === 'for-of') {
|
||||
const [, iterVar, arrVar, pushTarget] = match;
|
||||
filtered = 'for(let ' + iterVar + ' of ' + arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")))' +
|
||||
pushTarget + '.push("--add-dir",' + iterVar + ')';
|
||||
} else {
|
||||
const [, arrVar, iterVar, pushTarget] = match;
|
||||
filtered = arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
|
||||
iterVar + '=>' + pushTarget +
|
||||
'.push("--add-dir",' + iterVar + '))';
|
||||
}
|
||||
code = code.replace(match[0], filtered);
|
||||
console.log(' Filtered --add-dir dispatch (' +
|
||||
variant + ' variant)');
|
||||
patchCount++;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 2: Filter .asar from session restore
|
||||
//
|
||||
// Anchor: "Filtering out deleted folder from session" (unique)
|
||||
// Target: (VAR.userSelectedFolders||[]).filter(
|
||||
// Insert: .filter(l=>!l.endsWith(".asar")) before existing .filter(
|
||||
// ================================================================
|
||||
{
|
||||
const warn = (msg) => console.log(' WARNING: ' + msg +
|
||||
' (primary --add-dir filter still protects)');
|
||||
|
||||
const anchorIdx = code.indexOf(
|
||||
'Filtering out deleted folder from session');
|
||||
if (anchorIdx === -1) {
|
||||
warn('session restore anchor not found');
|
||||
} else {
|
||||
const searchStart = Math.max(0, anchorIdx - 500);
|
||||
const region = code.substring(searchStart, anchorIdx);
|
||||
const usIdx = region.lastIndexOf('userSelectedFolders');
|
||||
if (usIdx === -1) {
|
||||
warn('userSelectedFolders not found near anchor');
|
||||
} else {
|
||||
const absUsIdx = searchStart + usIdx;
|
||||
const afterUs = code.substring(absUsIdx, anchorIdx);
|
||||
const bracketMatch = afterUs.match(/\|\|\s*\[\s*\]\s*\)/);
|
||||
if (!bracketMatch) {
|
||||
warn('||[]) pattern not found');
|
||||
} else {
|
||||
const insertAt = absUsIdx + bracketMatch.index +
|
||||
bracketMatch[0].length;
|
||||
const peek = code.substring(insertAt, insertAt + 20);
|
||||
if (!peek.match(/^\s*\.filter\s*\(/)) {
|
||||
warn('.filter( not found after ||[])');
|
||||
} else if (code.substring(
|
||||
insertAt - 50, insertAt + 50
|
||||
).includes('!l.endsWith(".asar")')) {
|
||||
console.log(' Session restore filter ' +
|
||||
'already present');
|
||||
} else {
|
||||
code = code.substring(0, insertAt) +
|
||||
'.filter(l=>!l.endsWith(".asar"))' +
|
||||
code.substring(insertAt);
|
||||
console.log(' Injected .asar filter in ' +
|
||||
'session restore');
|
||||
patchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Applied ' + patchCount +
|
||||
' .asar additionalDirectories patch(es)');
|
||||
if (patchCount < 1) {
|
||||
console.error('FATAL: No patches applied — --add-dir filter ' +
|
||||
'must succeed (#649).');
|
||||
process.exit(1);
|
||||
}
|
||||
ASAR_ADDDIR_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar --add-dir filter patch failed' >&2
|
||||
echo 'Local agent mode will crash without this patch (#649).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
@@ -9,6 +9,95 @@
|
||||
# Modifies globals: node_pty_build_dir
|
||||
#===============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: reject .asar paths in the directory-check helper
|
||||
#
|
||||
# On Linux, app.asar is passed as an argv element to Electron. The
|
||||
# directory-check function (wFA in the current build) calls
|
||||
# fs.statSync(path).isDirectory(). Electron's ASAR virtual filesystem
|
||||
# shim makes .asar archives report isDirectory()===true, so app.asar
|
||||
# is dispatched to Cowork as a "folder drop". This causes:
|
||||
# - Permission dialog on every launch (#383)
|
||||
# - Forced Cowork mode (#622)
|
||||
# - Fatal --add-dir error in Claude Code >=2.1.111 (#632)
|
||||
#
|
||||
# Fix: inject !PARAM.endsWith(".asar")&& before the statSync call.
|
||||
# This runs independently of the Cowork-mode guard (the function
|
||||
# exists even if Cowork code is absent).
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_path_filter() {
|
||||
echo 'Patching directory check to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_FILTER_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
|
||||
// Find the directory-check helper function.
|
||||
// Beautified form:
|
||||
// function wFA(e) {
|
||||
// try { return ee.statSync(e).isDirectory(); }
|
||||
// catch { return !1; }
|
||||
// }
|
||||
// Minified form:
|
||||
// function wFA(e){try{return ee.statSync(e).isDirectory()}catch{return!1}}
|
||||
//
|
||||
// Stable anchors: .statSync( ).isDirectory() inside try/catch returning !1.
|
||||
// The function name, parameter, and fs variable are all minified.
|
||||
const dirCheckRe =
|
||||
/function\s+([\w$]+)\s*\(\s*([\w$]+)\s*\)\s*\{\s*try\s*\{\s*return\s+([\w$]+)\.statSync\(\s*\2\s*\)\.isDirectory\(\)/;
|
||||
const match = code.match(dirCheckRe);
|
||||
|
||||
if (!match) {
|
||||
console.error('FATAL: Could not find directory-check function' +
|
||||
' (statSync+isDirectory pattern).');
|
||||
console.error('This patch prevents .asar paths from triggering' +
|
||||
' false Cowork dispatch (#383, #622, #632).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [, funcName, paramName] = match;
|
||||
console.log(' Found directory-check function: ' + funcName +
|
||||
'(' + paramName + ')');
|
||||
|
||||
// Idempotency: check if already patched
|
||||
if (code.includes('.endsWith(".asar")')) {
|
||||
console.log(' .asar path filter already applied');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Insert the guard: !PARAM.endsWith(".asar")&&
|
||||
// Before: return FSVAR.statSync(PARAM).isDirectory()
|
||||
// After: return!PARAM.endsWith(".asar")&&FSVAR.statSync(PARAM).isDirectory()
|
||||
//
|
||||
// The replacement is scoped to the matched function via the full
|
||||
// regex match, so it cannot accidentally hit other statSync calls.
|
||||
code = code.replace(dirCheckRe, (whole, fn, param, fsVar) => {
|
||||
return 'function ' + fn + '(' + param + '){try{return!' +
|
||||
param + '.endsWith(".asar")&&' +
|
||||
fsVar + '.statSync(' + param + ').isDirectory()';
|
||||
});
|
||||
|
||||
// Verify the patch landed
|
||||
if (!code.includes('.endsWith(".asar")')) {
|
||||
console.error('FATAL: .asar path filter replacement failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Added .asar path rejection to ' + funcName + '()');
|
||||
ASAR_FILTER_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar path filter patch failed' >&2
|
||||
echo 'The app will show permission dialogs and may crash' \
|
||||
'without this patch (#383, #622, #632).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_cowork_linux() {
|
||||
echo 'Patching Cowork mode for Linux...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
@@ -51,7 +140,7 @@ function extractBlock(str, startIdx, open = '{') {
|
||||
// Pattern: VAR!=="darwin"&&VAR!=="win32" (unique in platform gate)
|
||||
// Anchor: appears near 'unsupported_platform' code value
|
||||
// ============================================================
|
||||
const platformGateRe = /(\w+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
|
||||
const platformGateRe = /([\w$]+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
|
||||
const origCode = code;
|
||||
code = code.replace(platformGateRe, (match, varName, mid, end) => {
|
||||
// Only patch the instance near the "unsupported_platform" code value
|
||||
@@ -67,10 +156,10 @@ if (code !== origCode) {
|
||||
patchCount++;
|
||||
} else {
|
||||
// Try without backreference (in case minifier uses different var names)
|
||||
const simpleRe = /(!=="darwin"\s*&&\s*\w+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
|
||||
const simpleRe = /(!=="darwin"\s*&&\s*[\w$]+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
|
||||
const simpleMatch = code.match(simpleRe);
|
||||
if (simpleMatch) {
|
||||
const varMatch = simpleMatch[0].match(/(\w+)\s*!==\s*"win32"/);
|
||||
const varMatch = simpleMatch[0].match(/([\w$]+)\s*!==\s*"win32"/);
|
||||
if (varMatch) {
|
||||
code = code.replace(simpleMatch[1],
|
||||
simpleMatch[1] + '&&' + varMatch[1] + '!=="linux"');
|
||||
@@ -91,7 +180,7 @@ if (code === origCode) {
|
||||
// Anchor: unique string "vmClient (TypeScript)"
|
||||
// Extracts the win32 platform variable, adds Linux OR condition
|
||||
// ============================================================
|
||||
const vmClientLogMatch = code.match(/(\w+)(\s*\?\s*"vmClient \(TypeScript\)")/);
|
||||
const vmClientLogMatch = code.match(/([\w$]+)(\s*\?\s*"vmClient \(TypeScript\)")/);
|
||||
if (vmClientLogMatch) {
|
||||
const win32Var = vmClientLogMatch[1];
|
||||
|
||||
@@ -147,7 +236,7 @@ if (vmClientLogMatch) {
|
||||
// Patch 3: Socket path - use Unix domain socket on Linux
|
||||
// Anchor: unique string "cowork-vm-service" in pipe path
|
||||
// ============================================================
|
||||
const pipeMatch = code.match(/(\w+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
||||
const pipeMatch = code.match(/([\w$]+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
||||
if (pipeMatch) {
|
||||
const pipeVar = pipeMatch[1];
|
||||
const assign = pipeMatch[2];
|
||||
@@ -226,7 +315,7 @@ if (!code.includes('"linux":{') && !code.includes("'linux':{") &&
|
||||
// calls download() which returns success immediately).
|
||||
// ============================================================
|
||||
{
|
||||
const statusRe = /getDownloadStatus\(\)\{return\s+(\w+\(\)\?(\w+)\.Downloading:\w+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
|
||||
const statusRe = /getDownloadStatus\(\)\{return\s+([\w$]+\(\)\?([\w$]+)\.Downloading:[\w$]+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
|
||||
const statusMatch = code.match(statusRe);
|
||||
if (statusMatch) {
|
||||
const [whole, origExpr, enumVar] = statusMatch;
|
||||
@@ -279,96 +368,104 @@ if (serviceErrorIdx !== -1) {
|
||||
// Step 1: Find the ENOENT check and expand it to include ECONNREFUSED
|
||||
// Pattern: VAR.code==="ENOENT"
|
||||
// Search backwards from the error string to find it
|
||||
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
||||
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
||||
const enoentRe = /(\w+)\.code\s*===\s*"ENOENT"/g;
|
||||
let enoentMatch;
|
||||
let lastEnoent = null;
|
||||
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
||||
lastEnoent = enoentMatch;
|
||||
}
|
||||
if (lastEnoent) {
|
||||
const enoentStr = lastEnoent[0];
|
||||
const errVar = lastEnoent[1];
|
||||
const enoentAbsIdx = searchStart + lastEnoent.index;
|
||||
// Replace: VAR.code==="ENOENT"
|
||||
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
||||
const expanded =
|
||||
'(' + enoentStr +
|
||||
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
||||
code = code.substring(0, enoentAbsIdx) +
|
||||
expanded +
|
||||
code.substring(enoentAbsIdx + enoentStr.length);
|
||||
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
||||
if (/process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED"/.test(code)) {
|
||||
console.log(' ENOENT/ECONNREFUSED expansion already applied');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
||||
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
||||
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
||||
const enoentRe = /([\w$]+)\.code\s*===\s*"ENOENT"/g;
|
||||
let enoentMatch;
|
||||
let lastEnoent = null;
|
||||
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
||||
lastEnoent = enoentMatch;
|
||||
}
|
||||
if (lastEnoent) {
|
||||
const enoentStr = lastEnoent[0];
|
||||
const errVar = lastEnoent[1];
|
||||
const enoentAbsIdx = searchStart + lastEnoent.index;
|
||||
// Replace: VAR.code==="ENOENT"
|
||||
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
||||
const expanded =
|
||||
'(' + enoentStr +
|
||||
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
||||
code = code.substring(0, enoentAbsIdx) +
|
||||
expanded +
|
||||
code.substring(enoentAbsIdx + enoentStr.length);
|
||||
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Inject auto-launch before the retry delay
|
||||
// Re-find serviceErrorStr since indices shifted after step 1
|
||||
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
|
||||
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
|
||||
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
|
||||
const retryMatch = searchRegion.match(
|
||||
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
|
||||
);
|
||||
if (retryMatch) {
|
||||
const retryStr = retryMatch[0];
|
||||
const retryOffset = searchRegion.indexOf(retryStr);
|
||||
const retryAbsIdx = newServiceErrorIdx + retryOffset;
|
||||
// Inject auto-launch before the retry delay
|
||||
// Service script is in app.asar.unpacked/ (not inside asar, since
|
||||
// child_process cannot execute scripts from inside an asar).
|
||||
// Uses fork() instead of spawn() because process.execPath in Electron
|
||||
// is the Electron binary - spawn would trigger "file open" handling
|
||||
// instead of executing the script as Node.js.
|
||||
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
|
||||
// Extract the enclosing function name (Ma or whatever it's
|
||||
// minified to) so the dedup guard attaches to it
|
||||
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
|
||||
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
|
||||
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
|
||||
const funcNameRe = /async function (\w+)\s*\(\s*\w+\s*,\s*\w+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
|
||||
let funcMatch;
|
||||
let retryFuncName = null;
|
||||
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
|
||||
retryFuncName = funcMatch[1];
|
||||
}
|
||||
const spawnGuard = retryFuncName
|
||||
? retryFuncName + '._lastSpawn'
|
||||
: '_globalLastSpawn';
|
||||
// Cooldown in ms — long enough to avoid fork storms, short enough
|
||||
// that the retry loop can re-spawn after a mid-session daemon death.
|
||||
const autoLaunch =
|
||||
'process.platform==="linux"&&' +
|
||||
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
|
||||
'&&(' + spawnGuard + '=Date.now(),' +
|
||||
'(()=>{try{' +
|
||||
'const _p=require("path"),_fs=require("fs");' +
|
||||
'const _d=_p.join(process.resourcesPath,' +
|
||||
'"app.asar.unpacked","' + svcPath + '");' +
|
||||
'if(_fs.existsSync(_d)){' +
|
||||
// Open daemon log for append; fall back to ignoring stdio.
|
||||
'let _stdio="ignore";' +
|
||||
'try{' +
|
||||
'const _ld=_p.join(process.env.HOME||"/tmp",' +
|
||||
'".config/Claude/logs");' +
|
||||
'_fs.mkdirSync(_ld,{recursive:true});' +
|
||||
'const _fd=_fs.openSync(' +
|
||||
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
|
||||
'_stdio=["ignore",_fd,_fd,"ipc"]' +
|
||||
'}catch(_){}' +
|
||||
'const _c=require("child_process").fork(_d,[],' +
|
||||
'{detached:true,stdio:_stdio,env:{...process.env,' +
|
||||
'ELECTRON_RUN_AS_NODE:"1"}});' +
|
||||
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
|
||||
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
|
||||
code = code.substring(0, retryAbsIdx) +
|
||||
autoLaunch + code.substring(retryAbsIdx);
|
||||
console.log(' Added service daemon auto-launch on Linux');
|
||||
patchCount++;
|
||||
if (code.includes('cowork-autolaunch')) {
|
||||
console.log(' Service daemon auto-launch already applied');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find retry delay for auto-launch patch');
|
||||
// Re-find serviceErrorStr since indices shifted after step 1
|
||||
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
|
||||
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
|
||||
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
|
||||
const retryMatch = searchRegion.match(
|
||||
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
|
||||
);
|
||||
if (retryMatch) {
|
||||
const retryStr = retryMatch[0];
|
||||
const retryOffset = searchRegion.indexOf(retryStr);
|
||||
const retryAbsIdx = newServiceErrorIdx + retryOffset;
|
||||
// Inject auto-launch before the retry delay
|
||||
// Service script is in app.asar.unpacked/ (not inside asar, since
|
||||
// child_process cannot execute scripts from inside an asar).
|
||||
// Uses fork() instead of spawn() because process.execPath in Electron
|
||||
// is the Electron binary - spawn would trigger "file open" handling
|
||||
// instead of executing the script as Node.js.
|
||||
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
|
||||
// Extract the enclosing function name (Ma or whatever it's
|
||||
// minified to) so the dedup guard attaches to it
|
||||
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
|
||||
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
|
||||
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
|
||||
const funcNameRe = /async function (\w+)\s*\(\s*\w+\s*,\s*\w+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
|
||||
let funcMatch;
|
||||
let retryFuncName = null;
|
||||
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
|
||||
retryFuncName = funcMatch[1];
|
||||
}
|
||||
const spawnGuard = retryFuncName
|
||||
? retryFuncName + '._lastSpawn'
|
||||
: '_globalLastSpawn';
|
||||
// Cooldown in ms — long enough to avoid fork storms, short enough
|
||||
// that the retry loop can re-spawn after a mid-session daemon death.
|
||||
const autoLaunch =
|
||||
'process.platform==="linux"&&' +
|
||||
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
|
||||
'&&(' + spawnGuard + '=Date.now(),' +
|
||||
'(()=>{try{' +
|
||||
'const _p=require("path"),_fs=require("fs");' +
|
||||
'const _d=_p.join(process.resourcesPath,' +
|
||||
'"app.asar.unpacked","' + svcPath + '");' +
|
||||
'if(_fs.existsSync(_d)){' +
|
||||
// Open daemon log for append; fall back to ignoring stdio.
|
||||
'let _stdio="ignore";' +
|
||||
'try{' +
|
||||
'const _ld=_p.join(process.env.HOME||"/tmp",' +
|
||||
'".config/Claude/logs");' +
|
||||
'_fs.mkdirSync(_ld,{recursive:true});' +
|
||||
'const _fd=_fs.openSync(' +
|
||||
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
|
||||
'_stdio=["ignore",_fd,_fd,"ipc"]' +
|
||||
'}catch(_){}' +
|
||||
'const _c=require("child_process").fork(_d,[],' +
|
||||
'{detached:true,stdio:_stdio,env:{...process.env,' +
|
||||
'ELECTRON_RUN_AS_NODE:"1"}});' +
|
||||
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
|
||||
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
|
||||
code = code.substring(0, retryAbsIdx) +
|
||||
autoLaunch + code.substring(retryAbsIdx);
|
||||
console.log(' Added service daemon auto-launch on Linux');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find retry delay for auto-launch patch');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find VM service error string for auto-launch');
|
||||
@@ -388,7 +485,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// toward recovery over re-download avoidance is correct.
|
||||
// ============================================================
|
||||
{
|
||||
const reinstallArrRe = /const (\w+)=\[("rootfs\.img"[^\]]*)\];/;
|
||||
const reinstallArrRe = /const ([\w$]+)=\[("rootfs\.img"[^\]]*)\];/;
|
||||
const arrMatch = code.match(reinstallArrRe);
|
||||
if (arrMatch) {
|
||||
const [whole, name, contents] = arrMatch;
|
||||
@@ -434,7 +531,7 @@ if (serviceErrorIdx !== -1) {
|
||||
{
|
||||
// Find: MKDTEMP(PATH.join(OS.tmpdir(), "wvm-"))
|
||||
// The bundle dir var is used in mkdir(VAR, ...) just before
|
||||
const mkdtempRe = /(\w+)\.mkdtemp\(\s*(\w+)\.join\(\s*(\w+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
|
||||
const mkdtempRe = /([\w$]+)\.mkdtemp\(\s*([\w$]+)\.join\(\s*([\w$]+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
|
||||
const mkdtempMatch = code.match(mkdtempRe);
|
||||
if (mkdtempMatch) {
|
||||
const [fullMatch, fsVar, pathVar, osVar] = mkdtempMatch;
|
||||
@@ -443,7 +540,7 @@ if (serviceErrorIdx !== -1) {
|
||||
const searchStart = Math.max(0, mkdtempIdx - 2000);
|
||||
const before = code.substring(searchStart, mkdtempIdx);
|
||||
// Look for: mkdir(VARNAME, { recursive
|
||||
const mkdirRe = /(\w+)\.mkdir\(\s*(\w+)\s*,\s*\{\s*recursive/g;
|
||||
const mkdirRe = /([\w$]+)\.mkdir\(\s*([\w$]+)\s*,\s*\{\s*recursive/g;
|
||||
let bundleVar = null;
|
||||
let lastMkdir;
|
||||
while ((lastMkdir = mkdirRe.exec(before)) !== null) {
|
||||
@@ -478,118 +575,122 @@ if (serviceErrorIdx !== -1) {
|
||||
// since minified names change between releases (#344).
|
||||
// ============================================================
|
||||
{
|
||||
const anchor = '"[VM:start] Windows VM service configured"';
|
||||
const anchorIdx = code.indexOf(anchor);
|
||||
if (anchorIdx !== -1) {
|
||||
// Find the "}" closing the win32 if-block after the anchor
|
||||
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
|
||||
if (closingBrace !== -1) {
|
||||
// Extract minified variable names from the win32 block
|
||||
// Search backwards from anchor to find the win32 block
|
||||
const regionStart = Math.max(0, anchorIdx - 1000);
|
||||
const region = code.substring(regionStart, anchorIdx);
|
||||
if (code.includes('[VM:start] Copying smol-bin') && code.includes('process.platform==="linux"')) {
|
||||
console.log(' Linux smol-bin copy block already present');
|
||||
} else {
|
||||
const anchor = '"[VM:start] Windows VM service configured"';
|
||||
const anchorIdx = code.indexOf(anchor);
|
||||
if (anchorIdx !== -1) {
|
||||
// Find the "}" closing the win32 if-block after the anchor
|
||||
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
|
||||
if (closingBrace !== -1) {
|
||||
// Extract minified variable names from the win32 block
|
||||
// Search backwards from anchor to find the win32 block
|
||||
const regionStart = Math.max(0, anchorIdx - 1000);
|
||||
const region = code.substring(regionStart, anchorIdx);
|
||||
|
||||
// JS identifier may start with $, _, or letter; \w doesn't
|
||||
// match $ so use [$\w]+ to capture vars like `$e` (Claude
|
||||
// >= 1.3109.0 uses $e for the fs module to avoid collision
|
||||
// with the parameter `e`). See issue #418.
|
||||
// path var: VAR.join(process.resourcesPath,
|
||||
const pathMatch = region.match(
|
||||
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
|
||||
);
|
||||
// fs var: VAR.existsSync(
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
// logger var: VAR.info("[VM:start]
|
||||
const logMatch = region.match(
|
||||
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
|
||||
);
|
||||
// stream/pipeline var: VAR.pipeline(
|
||||
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
|
||||
// arch function: const VAR=FUNC(), used in smol-bin
|
||||
const archMatch = region.match(
|
||||
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
|
||||
);
|
||||
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
|
||||
const bundleMatch = region.match(
|
||||
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
|
||||
);
|
||||
// JS identifier may start with $, _, or letter; \w doesn't
|
||||
// match $ so use [$\w]+ to capture vars like `$e` (Claude
|
||||
// >= 1.3109.0 uses $e for the fs module to avoid collision
|
||||
// with the parameter `e`). See issue #418.
|
||||
// path var: VAR.join(process.resourcesPath,
|
||||
const pathMatch = region.match(
|
||||
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
|
||||
);
|
||||
// fs var: VAR.existsSync(
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
// logger var: VAR.info("[VM:start]
|
||||
const logMatch = region.match(
|
||||
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
|
||||
);
|
||||
// stream/pipeline var: VAR.pipeline(
|
||||
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
|
||||
// arch function: const VAR=FUNC(), used in smol-bin
|
||||
const archMatch = region.match(
|
||||
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
|
||||
);
|
||||
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
|
||||
const bundleMatch = region.match(
|
||||
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
|
||||
);
|
||||
|
||||
if (pathMatch && fsMatch && logMatch &&
|
||||
streamMatch && archMatch && bundleMatch) {
|
||||
const pathVar = pathMatch[1];
|
||||
const fsVar = fsMatch[1];
|
||||
const logVar = logMatch[1];
|
||||
const streamVar = streamMatch[1];
|
||||
const archFunc = archMatch[2];
|
||||
const bundleVar = bundleMatch[1];
|
||||
if (pathMatch && fsMatch && logMatch &&
|
||||
streamMatch && archMatch && bundleMatch) {
|
||||
const pathVar = pathMatch[1];
|
||||
const fsVar = fsMatch[1];
|
||||
const logVar = logMatch[1];
|
||||
const streamVar = streamMatch[1];
|
||||
const archFunc = archMatch[2];
|
||||
const bundleVar = bundleMatch[1];
|
||||
|
||||
const linuxBlock =
|
||||
'if(process.platform==="linux"){' +
|
||||
'const _la=' + archFunc + '(),' +
|
||||
'_ls=' + pathVar + '.join(process.resourcesPath,' +
|
||||
'`smol-bin.${_la}.vhdx`),' +
|
||||
'_ld=' + pathVar + '.join(' + bundleVar +
|
||||
',"smol-bin.vhdx");' +
|
||||
fsVar + '.existsSync(_ls)?' +
|
||||
'(' + logVar + '.info(' +
|
||||
'`[VM:start] Copying smol-bin.${_la}' +
|
||||
'.vhdx to bundle (Linux)`),' +
|
||||
'await ' + streamVar + '.pipeline(' +
|
||||
fsVar + '.createReadStream(_ls),' +
|
||||
fsVar + '.createWriteStream(_ld)),' +
|
||||
logVar + '.info(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx copied successfully`))' +
|
||||
':' + logVar + '.warn(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx not found at ${_ls}`)' +
|
||||
'}';
|
||||
// Defensive: if a future upstream emits its own
|
||||
// if(process.platform==="linux"){...} block right
|
||||
// after the win32 close brace, strip it before
|
||||
// injecting our correctly-wired linuxBlock so we
|
||||
// don't end up with two competing blocks.
|
||||
const insertPos = closingBrace + 1;
|
||||
let stripUntil = insertPos;
|
||||
const afterWin32 = code.substring(insertPos);
|
||||
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
|
||||
const upstreamMatch = afterWin32.match(upstreamRe);
|
||||
if (upstreamMatch) {
|
||||
const matchEnd = insertPos + upstreamMatch[0].length;
|
||||
let depth = 1, pos = matchEnd;
|
||||
while (depth > 0 && pos < code.length) {
|
||||
if (code[pos] === '{') depth++;
|
||||
else if (code[pos] === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
if (depth === 0) {
|
||||
stripUntil = pos;
|
||||
console.log(' Stripped pre-existing upstream Linux block');
|
||||
} else {
|
||||
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
|
||||
const linuxBlock =
|
||||
'if(process.platform==="linux"){' +
|
||||
'const _la=' + archFunc + '(),' +
|
||||
'_ls=' + pathVar + '.join(process.resourcesPath,' +
|
||||
'`smol-bin.${_la}.vhdx`),' +
|
||||
'_ld=' + pathVar + '.join(' + bundleVar +
|
||||
',"smol-bin.vhdx");' +
|
||||
fsVar + '.existsSync(_ls)?' +
|
||||
'(' + logVar + '.info(' +
|
||||
'`[VM:start] Copying smol-bin.${_la}' +
|
||||
'.vhdx to bundle (Linux)`),' +
|
||||
'await ' + streamVar + '.pipeline(' +
|
||||
fsVar + '.createReadStream(_ls),' +
|
||||
fsVar + '.createWriteStream(_ld)),' +
|
||||
logVar + '.info(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx copied successfully`))' +
|
||||
':' + logVar + '.warn(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx not found at ${_ls}`)' +
|
||||
'}';
|
||||
// Defensive: if a future upstream emits its own
|
||||
// if(process.platform==="linux"){...} block right
|
||||
// after the win32 close brace, strip it before
|
||||
// injecting our correctly-wired linuxBlock so we
|
||||
// don't end up with two competing blocks.
|
||||
const insertPos = closingBrace + 1;
|
||||
let stripUntil = insertPos;
|
||||
const afterWin32 = code.substring(insertPos);
|
||||
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
|
||||
const upstreamMatch = afterWin32.match(upstreamRe);
|
||||
if (upstreamMatch) {
|
||||
const matchEnd = insertPos + upstreamMatch[0].length;
|
||||
let depth = 1, pos = matchEnd;
|
||||
while (depth > 0 && pos < code.length) {
|
||||
if (code[pos] === '{') depth++;
|
||||
else if (code[pos] === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
if (depth === 0) {
|
||||
stripUntil = pos;
|
||||
console.log(' Stripped pre-existing upstream Linux block');
|
||||
} else {
|
||||
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
|
||||
}
|
||||
}
|
||||
code = code.substring(0, insertPos) +
|
||||
linuxBlock +
|
||||
code.substring(stripUntil);
|
||||
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
|
||||
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
|
||||
patchCount++;
|
||||
} else {
|
||||
const missing = [];
|
||||
if (!pathMatch) missing.push('path');
|
||||
if (!fsMatch) missing.push('fs');
|
||||
if (!logMatch) missing.push('logger');
|
||||
if (!streamMatch) missing.push('stream');
|
||||
if (!archMatch) missing.push('arch');
|
||||
if (!bundleMatch) missing.push('bundlePath');
|
||||
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
|
||||
}
|
||||
code = code.substring(0, insertPos) +
|
||||
linuxBlock +
|
||||
code.substring(stripUntil);
|
||||
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
|
||||
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
|
||||
patchCount++;
|
||||
} else {
|
||||
const missing = [];
|
||||
if (!pathMatch) missing.push('path');
|
||||
if (!fsMatch) missing.push('fs');
|
||||
if (!logMatch) missing.push('logger');
|
||||
if (!streamMatch) missing.push('stream');
|
||||
if (!archMatch) missing.push('arch');
|
||||
if (!bundleMatch) missing.push('bundlePath');
|
||||
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
|
||||
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
|
||||
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,49 +700,53 @@ if (serviceErrorIdx !== -1) {
|
||||
// on Linux. Register our own to SIGTERM the daemon on app quit.
|
||||
// ============================================================
|
||||
{
|
||||
const quitFnRe = /registerQuitHandler:\s*(\w+)/;
|
||||
const quitFnMatch = code.match(quitFnRe);
|
||||
if (quitFnMatch) {
|
||||
const quitFn = quitFnMatch[1];
|
||||
console.log(' Found registerQuitHandler function: ' + quitFn);
|
||||
if (code.includes('cowork-linux-daemon-shutdown')) {
|
||||
console.log(' Linux cowork daemon quit handler already registered');
|
||||
} else {
|
||||
const quitFnRe = /registerQuitHandler:\s*([\w$]+)/;
|
||||
const quitFnMatch = code.match(quitFnRe);
|
||||
if (quitFnMatch) {
|
||||
const quitFn = quitFnMatch[1];
|
||||
console.log(' Found registerQuitHandler function: ' + quitFn);
|
||||
|
||||
const quitFnDef = 'function ' + quitFn + '(';
|
||||
const quitFnDefIdx = code.indexOf(quitFnDef);
|
||||
if (quitFnDefIdx !== -1) {
|
||||
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
|
||||
if (fnBlock) {
|
||||
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
|
||||
fnBlock.length;
|
||||
const shutdownHandler =
|
||||
'process.platform==="linux"&&' + quitFn + '({' +
|
||||
'name:"cowork-linux-daemon-shutdown",' +
|
||||
'fn:async()=>{' +
|
||||
'const _p=global.__coworkDaemonPid;' +
|
||||
'if(!_p)return;' +
|
||||
'try{const _cmd=require("fs").readFileSync(' +
|
||||
'"/proc/"+_p+"/cmdline","utf8");' +
|
||||
'if(!_cmd.includes("cowork-vm-service"))return' +
|
||||
'}catch(_e){return}' +
|
||||
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
|
||||
'for(let _i=0;_i<50;_i++){' +
|
||||
'await new Promise(_r=>setTimeout(_r,200));' +
|
||||
'try{process.kill(_p,0)}catch(_e){return}' +
|
||||
'}}});';
|
||||
code = code.substring(0, insertIdx) +
|
||||
shutdownHandler + code.substring(insertIdx);
|
||||
console.log(' Registered Linux cowork daemon quit handler');
|
||||
patchCount++;
|
||||
const quitFnDef = 'function ' + quitFn + '(';
|
||||
const quitFnDefIdx = code.indexOf(quitFnDef);
|
||||
if (quitFnDefIdx !== -1) {
|
||||
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
|
||||
if (fnBlock) {
|
||||
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
|
||||
fnBlock.length;
|
||||
const shutdownHandler =
|
||||
'process.platform==="linux"&&' + quitFn + '({' +
|
||||
'name:"cowork-linux-daemon-shutdown",' +
|
||||
'fn:async()=>{' +
|
||||
'const _p=global.__coworkDaemonPid;' +
|
||||
'if(!_p)return;' +
|
||||
'try{const _cmd=require("fs").readFileSync(' +
|
||||
'"/proc/"+_p+"/cmdline","utf8");' +
|
||||
'if(!_cmd.includes("cowork-vm-service"))return' +
|
||||
'}catch(_e){return}' +
|
||||
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
|
||||
'for(let _i=0;_i<50;_i++){' +
|
||||
'await new Promise(_r=>setTimeout(_r,200));' +
|
||||
'try{process.kill(_p,0)}catch(_e){return}' +
|
||||
'}}});';
|
||||
code = code.substring(0, insertIdx) +
|
||||
shutdownHandler + code.substring(insertIdx);
|
||||
console.log(' Registered Linux cowork daemon quit handler');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function body for quit handler');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function body for quit handler');
|
||||
' function definition');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function definition');
|
||||
console.log(' WARNING: Could not find registerQuitHandler' +
|
||||
' export for quit handler');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find registerQuitHandler' +
|
||||
' export for quit handler');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +782,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// 'sessionId:VAR' in the config itself — cheap, scoped, and
|
||||
// immune to unrelated *.userSelectedFolders references (e.g.
|
||||
// loop variables) that wander into the enclosing scope.
|
||||
const sidMatch = cfgBlock.match(/\{sessionId:(\w+)\b/);
|
||||
const sidMatch = cfgBlock.match(/\{sessionId:([\w$]+)\b/);
|
||||
if (!sidMatch) {
|
||||
console.log(' WARNING: #412 no sessionId field in config');
|
||||
} else {
|
||||
@@ -702,7 +807,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// --- 12c: accept a 13th param in spawn() method body ---
|
||||
let site3Done = false;
|
||||
const spawnIdempotent =
|
||||
/async spawn\([^)]+\)\{const \w+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
|
||||
/async spawn\([^)]+\)\{const [\w$]+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
|
||||
if (spawnIdempotent.test(code)) {
|
||||
console.log(' #412 spawn method already accepts sharedCwdPath');
|
||||
site3Done = true;
|
||||
@@ -710,7 +815,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// Match the spawn body with the trailing mountConda setter and the
|
||||
// IPC call. Captures: arg list, payload var, setter chain, IPC tail.
|
||||
const spawnRe =
|
||||
/async spawn\(([^)]+)\)\{const (\w+)=\{id:[^}]+\};([^{}]*?\w+&&\(\2\.mountConda=\w+\)),(await \w+\("spawn",\2\)\})/;
|
||||
/async spawn\(([^)]+)\)\{const ([\w$]+)=\{id:[^}]+\};([^{}]*?[\w$]+&&\(\2\.mountConda=[\w$]+\)),(await [\w$]+\("spawn",\2\)\})/;
|
||||
const spawnMatch = code.match(spawnRe);
|
||||
if (!spawnMatch) {
|
||||
console.log(' WARNING: #412 spawn method body regex did not match');
|
||||
@@ -747,11 +852,11 @@ if (serviceErrorIdx !== -1) {
|
||||
// the uniqueness so a second upstream caller wouldn't silently take
|
||||
// only the first hit.
|
||||
let site2Done = false;
|
||||
if (/,\w+\.mountConda,\w+\.sharedCwdPath\)/.test(code)) {
|
||||
if (/,[\w$]+\.mountConda,[\w$]+\.sharedCwdPath\)/.test(code)) {
|
||||
console.log(' #412 caller already forwards sharedCwdPath');
|
||||
site2Done = true;
|
||||
} else {
|
||||
const callMatches = [...code.matchAll(/,(\w+)\.mountConda\)/g)];
|
||||
const callMatches = [...code.matchAll(/,([\w$]+)\.mountConda\)/g)];
|
||||
if (callMatches.length === 0) {
|
||||
console.log(' WARNING: #412 no ",VAR.mountConda)" pattern found');
|
||||
} else if (callMatches.length > 1) {
|
||||
@@ -832,6 +937,15 @@ install_node_pty() {
|
||||
|
||||
if [[ -n $pty_src_dir && -d $pty_src_dir ]]; then
|
||||
echo 'Copying node-pty JavaScript files into app.asar.contents...'
|
||||
# Wipe the upstream-extracted node-pty before staging the Linux
|
||||
# build. The Windows installer's app.asar ships node-pty with
|
||||
# Windows binaries (winpty.dll, winpty-agent.exe, Windows
|
||||
# build/Release/*.node files). `cp -r $pty_src_dir/build` only
|
||||
# overwrites same-named files; orphan Windows binaries persist
|
||||
# inside the asar, surface as PE32+ when users inspect with
|
||||
# `asar list`, and pollute /tmp via Electron's lazy-extract on
|
||||
# any spurious require() (#401).
|
||||
rm -rf "$app_staging_dir/app.asar.contents/node_modules/node-pty"
|
||||
mkdir -p "$app_staging_dir/app.asar.contents/node_modules/node-pty" || exit 1
|
||||
# --no-preserve=mode so read-only bits from the Nix store
|
||||
# (--node-pty-dir) don't propagate into the staging tree.
|
||||
|
||||
57
scripts/patches/org-plugins.sh
Normal file
57
scripts/patches/org-plugins.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#===============================================================================
|
||||
# Linux org-plugins path: inject a case"linux" into the platform switch
|
||||
# that resolves the org-plugins source directory.
|
||||
#
|
||||
# Upstream only has cases for darwin and win32; the default returns null,
|
||||
# silently disabling the entire org-plugins marketplace feature on Linux.
|
||||
# This adds: case"linux":return"/etc/claude/org-plugins"
|
||||
#
|
||||
# /etc/claude/org-plugins is FHS-correct for MDM-managed configuration,
|
||||
# consistent with Claude Code's /etc/claude-code/ path.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: (none)
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_org_plugins_path() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency: skip if a Linux case already exists near the
|
||||
# org-plugins path resolver (upstream may add one in the future).
|
||||
if grep -q 'case"linux":return"/etc/claude/org-plugins"' \
|
||||
"$index_js"; then
|
||||
echo 'Linux org-plugins path already present'
|
||||
return
|
||||
fi
|
||||
|
||||
# Anchor: the darwin path string is unique in the entire bundle.
|
||||
# Verify it exists before attempting the patch.
|
||||
local anchor='Application Support/Claude/org-plugins'
|
||||
if ! grep -q "$anchor" "$index_js"; then
|
||||
echo 'Warning: org-plugins path resolver not found' \
|
||||
'in this version, skipping' >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# Pattern (minified):
|
||||
# ..."org-plugins");default:return null}
|
||||
#
|
||||
# The compound anchor — "org-plugins") immediately before
|
||||
# default:return null — is unique to this switch statement.
|
||||
# Insert case"linux":return"/etc/claude/org-plugins"; between
|
||||
# the end of the win32 case and the default case.
|
||||
#
|
||||
# \s* between tokens handles any future whitespace variation,
|
||||
# though the target file is always minified in practice.
|
||||
if grep -qP '"org-plugins"\)\s*;\s*default\s*:\s*return\s+null' \
|
||||
"$index_js"; then
|
||||
sed -i -E \
|
||||
's/("org-plugins"\)\s*;\s*)(default\s*:\s*return\s+null)/\1case"linux":return"\/etc\/claude\/org-plugins";\2/' \
|
||||
"$index_js"
|
||||
echo 'Added Linux org-plugins path (/etc/claude/org-plugins)'
|
||||
else
|
||||
echo 'Warning: org-plugins switch pattern not matched,' \
|
||||
'skipping' >&2
|
||||
fi
|
||||
}
|
||||
@@ -14,7 +14,7 @@ patch_quick_window() {
|
||||
# Extract the quick window variable name from the unique "pop-up-menu"
|
||||
# setAlwaysOnTop call, e.g.: Sa.setAlwaysOnTop(!0,"pop-up-menu")
|
||||
local quick_var
|
||||
quick_var=$(grep -oP '\w+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
quick_var=$(grep -oP '[$\w]+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $quick_var ]]; then
|
||||
echo 'WARNING: Could not extract quick window variable name'
|
||||
@@ -35,9 +35,9 @@ patch_quick_window() {
|
||||
de_check+='.toLowerCase().includes("kde")'
|
||||
if grep -qF "${quick_var}.blur(),${quick_var}.hide()" "$index_js"; then
|
||||
echo ' Quick window blur already patched'
|
||||
elif grep -qP "\|\|${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i \
|
||||
"s/||${quick_var_re}\.hide()/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
elif grep -qP "\|\|\s*${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/\|\|\s*${quick_var_re}\.hide\(\)/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
"$index_js"
|
||||
echo ' Added KDE-gated blur() before hide() on quick window'
|
||||
else
|
||||
@@ -57,11 +57,11 @@ let patchCount = 0;
|
||||
|
||||
// Find the minified isWindowFocused function via its named property
|
||||
// export: isWindowFocused: () => !!NAME()
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!(\w+)\(\)/;
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!([\w$]+)\(\)/;
|
||||
const focusedMatch = code.match(focusedPropRe);
|
||||
if (!focusedMatch) {
|
||||
console.log(' WARNING: Could not find isWindowFocused function');
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
const focusFn = focusedMatch[1];
|
||||
console.log(' Found focus check function: ' + focusFn);
|
||||
@@ -74,12 +74,12 @@ console.log(' Found focus check function: ' + focusFn);
|
||||
// group keeps the prefix optional in either case.
|
||||
const focusFnIdx = code.indexOf('function ' + focusFn + '(');
|
||||
const nearbyCode = code.substring(focusFnIdx, focusFnIdx + 500);
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var \w+(?:,\w+)*;)?return!\w+\|\|\w+\.isDestroyed\(\)\?!1:\w+\.isVisible\(\)/;
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var [\w$]+(?:,[\w$]+)*;)?return![\w$]+\|\|[\w$]+\.isDestroyed\(\)\?!1:[\w$]+\.isVisible\(\)/;
|
||||
const visMatch = nearbyCode.match(visFnRe);
|
||||
if (!visMatch) {
|
||||
console.log(' WARNING: Could not find visibility function near ' +
|
||||
focusFn);
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
const visFn = visMatch[1];
|
||||
console.log(' Found visibility check function: ' + visFn);
|
||||
@@ -106,7 +106,7 @@ for (const anchor of anchors) {
|
||||
}
|
||||
// matches: <focusFn>()||(someVar).show()
|
||||
const showRe = new RegExp(
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|(\w+)\.show\(\)`
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|([\w$]+)\.show\(\)`
|
||||
);
|
||||
const showMatch = region.match(showRe);
|
||||
if (showMatch) {
|
||||
|
||||
@@ -11,7 +11,7 @@ patch_tray_menu_handler() {
|
||||
echo 'Patching tray menu handler...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
local tray_func tray_func_re tray_var first_const
|
||||
local tray_func tray_func_re tray_var
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
@@ -26,8 +26,7 @@ patch_tray_menu_handler() {
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
|
||||
tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"$index_js")
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
if [[ -z $tray_var ]]; then
|
||||
echo 'Failed to extract tray variable name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -40,31 +39,21 @@ patch_tray_menu_handler() {
|
||||
# `async async function`, which then breaks downstream patches that
|
||||
# match `(?:async )?function NAME`.
|
||||
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
|
||||
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
|
||||
sed -i -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
fi
|
||||
|
||||
first_const=$(grep -oP \
|
||||
"async function ${tray_func_re}\(\)\{.*?const \K\w+(?==)" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $first_const ]]; then
|
||||
echo 'Failed to extract first const in function' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo " Found first const variable: $first_const"
|
||||
|
||||
# Add mutex guard to prevent concurrent tray rebuilds
|
||||
if ! grep -q "${tray_func}._running" "$index_js"; then
|
||||
sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
|
||||
sed -i -E "s/async\s+function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
|
||||
"$index_js"
|
||||
echo " Added mutex guard to ${tray_func}()"
|
||||
fi
|
||||
|
||||
# Add DBus cleanup delay after tray destroy
|
||||
if ! grep -q "await new Promise.*setTimeout" "$index_js" \
|
||||
| grep -q "$tray_var"; then
|
||||
sed -i "s/${tray_var}\&\&(${tray_var}\.destroy(),${tray_var}=null)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
tray_var_re="${tray_var//\$/\\$}"
|
||||
if ! grep -q "await new Promise.*setTimeout.*${tray_var_re}" "$index_js"; then
|
||||
sed -i -E "s/${tray_var_re}\s*\&\&\s*\(\s*${tray_var_re}\.destroy\(\)\s*,\s*${tray_var_re}\s*=\s*null\s*\)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
"$index_js"
|
||||
echo " Added DBus cleanup delay after $tray_var.destroy()"
|
||||
fi
|
||||
@@ -79,9 +68,12 @@ patch_tray_menu_handler() {
|
||||
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
|
||||
"$index_js"
|
||||
sed -i -E \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"s/\(([[:alnum:]_\$]+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"$index_js"
|
||||
echo ' Added startup delay check (3 second window)'
|
||||
if ! grep -q "Date.now()-_trayStartTime>3e3" "$index_js"; then
|
||||
echo 'WARNING: Startup delay conditional not injected' >&2
|
||||
fi
|
||||
fi
|
||||
echo '##############################################################'
|
||||
}
|
||||
@@ -91,9 +83,9 @@ patch_tray_icon_selection() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
local dark_check="${electron_var_re}.nativeTheme.shouldUseDarkColors"
|
||||
|
||||
if grep -qP ':\$?\w+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
if grep -qP ':[$\w]+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/:(\\\$?\w+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"s/:([[:alnum:]_\$]+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"$index_js"
|
||||
echo 'Patched tray icon selection for Linux theme support'
|
||||
else
|
||||
@@ -120,8 +112,7 @@ patch_tray_inplace_update() {
|
||||
# Escape `$` for PCRE patterns; matches the `tray_var_re` trick below.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
local_tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"$index_js")
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
if [[ -z $local_tray_var ]]; then
|
||||
echo ' Could not extract tray variable name — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -131,7 +122,7 @@ patch_tray_inplace_update() {
|
||||
|
||||
tray_var_re="${local_tray_var//\$/\\$}"
|
||||
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\(\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_func ]]; then
|
||||
echo ' Could not extract menu function name — skipping'
|
||||
@@ -146,7 +137,7 @@ patch_tray_inplace_update() {
|
||||
# suffix)` earlier in the function; minifier renames it between
|
||||
# releases, so it needs to be extracted (not hardcoded).
|
||||
path_var=$(grep -oP \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K\w+(?=\))" \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K[\$\w]+(?=\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract icon-path var — skipping'
|
||||
@@ -160,8 +151,8 @@ patch_tray_inplace_update() {
|
||||
# tests, so binding to the wrong site is silently broken. Bail if
|
||||
# upstream ever ships >1 declaration site instead of taking the
|
||||
# first one.
|
||||
enabled_count=$(grep -cE \
|
||||
'const \w+\s*=\s*\w+\("menuBarEnabled"\)' "$index_js")
|
||||
enabled_count=$(grep -cP \
|
||||
'const [$\w]+\s*=\s*[$\w]+\("menuBarEnabled"\)' "$index_js")
|
||||
if [[ $enabled_count -ne 1 ]]; then
|
||||
echo " Expected 1 menuBarEnabled declaration, found" \
|
||||
"${enabled_count} — skipping"
|
||||
@@ -169,7 +160,7 @@ patch_tray_inplace_update() {
|
||||
return
|
||||
fi
|
||||
enabled_var=$(grep -oP \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' "$index_js")
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' "$index_js")
|
||||
if [[ -z $enabled_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled var — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -248,7 +239,7 @@ patch_menu_bar_default() {
|
||||
|
||||
local menu_bar_var
|
||||
menu_bar_var=$(grep -oP \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' \
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_bar_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled variable name'
|
||||
|
||||
@@ -24,15 +24,15 @@ detect_architecture() {
|
||||
|
||||
case "$raw_arch" in
|
||||
x86_64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='1ab57e88c86451cf199d1568d751dab7fe29e4bdfa5a3f035fdb15b872c7a352'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe'
|
||||
claude_exe_sha256='422461974b11d3c8709f930396132cb3d01d27e917f32ac6c4b9a077bcb7704b'
|
||||
architecture='amd64'
|
||||
claude_exe_filename='Claude-Setup-x64.exe'
|
||||
echo 'Configured for amd64 (x86_64) build.'
|
||||
;;
|
||||
aarch64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='3c319a59a59b30bfeb86f71b6df80891c5c983404f12e464f4be1754cd4d2c94'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe'
|
||||
claude_exe_sha256='1f2081752efddd31b0f5bedae37659b39c38e336d11f8041a1a3699ea861bdbc'
|
||||
architecture='arm64'
|
||||
claude_exe_filename='Claude-Setup-arm64.exe'
|
||||
echo 'Configured for arm64 (aarch64) build.'
|
||||
|
||||
@@ -76,8 +76,13 @@ setup() {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
|
||||
# Copy to temp dir so we can substitute the build-time placeholder
|
||||
# and co-locate doctor.sh (sourced via BASH_SOURCE dirname).
|
||||
cp "$SCRIPT_DIR/../scripts/launcher-common.sh" "$TEST_TMP/launcher-common.sh"
|
||||
cp "$SCRIPT_DIR/../scripts/doctor.sh" "$TEST_TMP/doctor.sh"
|
||||
sed -i 's/@@WM_CLASS@@/Claude/' "$TEST_TMP/launcher-common.sh"
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
source "$TEST_TMP/launcher-common.sh"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
@@ -305,6 +310,13 @@ teardown() {
|
||||
# build_electron_args
|
||||
# =============================================================================
|
||||
|
||||
@test "build_electron_args: includes --class matching upstream productName" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--class=Claude'
|
||||
}
|
||||
|
||||
@test "build_electron_args: X11 deb - only CustomTitlebar disabled" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
|
||||
@@ -7,6 +7,19 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=tests/test-artifact-common.sh
|
||||
source "$script_dir/test-artifact-common.sh"
|
||||
|
||||
# Single point of cleanup, set at script scope so any interruption
|
||||
# between resource alloc and normal exit is covered.
|
||||
_cleanup() {
|
||||
if [[ -n ${launch_pid:-} ]]; then
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null
|
||||
fi
|
||||
[[ -n ${cache_root:-} ]] && rm -rf "$cache_root"
|
||||
[[ -n ${xvfb_log:-} ]] && rm -rf "$xvfb_log"
|
||||
[[ -n ${extract_dir:-} ]] && rm -rf "$extract_dir"
|
||||
}
|
||||
trap _cleanup EXIT INT TERM
|
||||
|
||||
component_id='io.github.aaddrick.claude-desktop-debian'
|
||||
|
||||
# Find the AppImage file (exclude .zsync)
|
||||
@@ -135,23 +148,39 @@ if command -v xvfb-run &>/dev/null \
|
||||
>"$xvfb_log" 2>&1 &
|
||||
launch_pid=$!
|
||||
|
||||
# Safety net: covers Ctrl-C, CI timeout, or any earlier `exit` so we
|
||||
# never leak Xvfb/electron between launch and the explicit kill below.
|
||||
trap '
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
' EXIT INT TERM
|
||||
# Wait up to 30s for the frame-fix readiness marker, or early
|
||||
# process death. The marker is the last log line emitted by
|
||||
# scripts/frame-fix-wrapper.js after all patches are installed,
|
||||
# so reaching it means main-process startup finished without
|
||||
# crashing. Replaces a flat 10s sleep that was both slow on
|
||||
# healthy startups and a flake risk on noisy runners.
|
||||
readiness_marker='[Frame Fix] Patches built successfully'
|
||||
readiness_timeout=30
|
||||
deadline=$((SECONDS + readiness_timeout))
|
||||
saw_marker=0
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -f $launcher_log ]] \
|
||||
&& grep -qF "$readiness_marker" \
|
||||
"$launcher_log"; then
|
||||
saw_marker=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$launch_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# CI is slow; 10s is the floor for Electron startup.
|
||||
sleep 10
|
||||
|
||||
if kill -0 "$launch_pid" 2>/dev/null; then
|
||||
pass "AppImage stays alive under Xvfb for 10s"
|
||||
if ((saw_marker == 1)); then
|
||||
pass "AppImage reached ready state under Xvfb"
|
||||
else
|
||||
wait "$launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
fail "AppImage exited within 10s (exit: $exit_code)"
|
||||
if kill -0 "$launch_pid" 2>/dev/null; then
|
||||
fail "AppImage did not reach ready state within ${readiness_timeout}s"
|
||||
else
|
||||
wait "$launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
fail "AppImage exited before reaching ready state (exit: $exit_code)"
|
||||
fi
|
||||
if [[ -f $launcher_log ]]; then
|
||||
echo '--- launcher.log (last 40 lines) ---' >&2
|
||||
tail -40 "$launcher_log" >&2
|
||||
|
||||
@@ -64,9 +64,9 @@ write_fixture() {
|
||||
done
|
||||
}
|
||||
|
||||
@test "markers file: at least 9 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 9 ]] || {
|
||||
echo "expected >= 9 markers, got ${#marker_names[@]}"
|
||||
@test "markers file: at least 10 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 10 ]] || {
|
||||
echo "expected >= 10 markers, got ${#marker_names[@]}"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user