Compare commits

...

39 Commits

Author SHA1 Message Date
aaddrick
e38066efef fix(patches): anchor tray variable extraction on .Tray() literal
The old pattern `});let VAR=null;function TRAY_FUNC` relied on the
structural syntax immediately preceding the tray function declaration.
Upstream 1.9255.0 reshuffled declarations, inserting an intermediate
function between `let OE=null` and `function _5A`, breaking the match.

Replace with `VAR = new ELECTRON.Tray(` — anchored on the `.Tray(`
literal (a stable Electron API call) rather than minifier-dependent
syntax. Per docs/learnings/patching-minified-js.md § Anchor selection.

Fixes #656

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 02:03:36 -04:00
aaddrick
ec12c49092 docs(changelog): promote [Unreleased] to [v2.0.15] — 2026-05-27
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 01:55:10 -04:00
Aaddrick
73c9b8f6b2 fix: centralize StartupWMClass=Claude to match upstream productName (#655)
fix: centralize StartupWMClass=Claude to match upstream productName
2026-05-27 01:54:31 -04:00
aaddrick
7917ea4927 style: trim comments per simplifier review
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 01:44:42 -04:00
aaddrick
e7e647512a fix: centralize StartupWMClass=Claude to match upstream productName
Electron ignores --class= and derives WM_CLASS from productName in
package.json ("Claude"). The v2.0.14 release shipped --class=claude-desktop
and StartupWMClass=claude-desktop, but users confirmed via /proc cmdline +
xprop that the flag is silently ignored — WM_CLASS remains "claude","Claude"
regardless. This causes orphan windows and duplicate gear icons on GNOME/KDE.

Centralize the value to a single source of truth:
- build.sh: readonly WM_CLASS='Claude' (exported to packaging subprocesses)
- launcher-common.sh: @@WM_CLASS@@ placeholder, sed-replaced at build time
- frame-fix-wrapper.js: derived from result.app.name (zero hardcoding)
- app-asar.sh: build-time assertion that upstream productName matches

Down from 6 independent hardcoded values to 1 definition + 1 derivation.

Fixes #652
Ref #647, #561, discussion #653

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 01:41:20 -04:00
github-actions[bot]
4409f3f0d4 Update Claude Desktop download URLs to version 1.9255.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-27 01:46:43 +00:00
Aaddrick
4451694930 fix(patches): filter .asar paths from --add-dir dispatch and session restore (#650)
* fix(patches): filter .asar paths from --add-dir dispatch and session restore (#649)

PR #640 patched the directory-check helper and addTrustedFolder, but
.asar paths in corrupted pre-#640 sessions survive restore via
Electron's ASAR VFS shim and reach additionalDirectories, causing
Claude Code >=2.1.111 to fatally reject the non-directory --add-dir
argument.

Filter at two defense-in-depth sites:
1. The --add-dir CLI dispatch loop (single convergence point for ALL
   code paths that feed additionalDirectories)
2. Session restore (self-heals corrupted persisted state so the
   primary filter doesn't fire indefinitely)

Co-Authored-By: Claude <claude@anthropic.com>

* style(patches): simplify asar-additional-dirs patch diagnostics

Consolidate repetitive warning messages via warn() helper, shorten
FATAL error output, inline single-use variable, trim log prefixes.

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-26 07:24:33 -04:00
aaddrick
98232dbd81 docs(changelog): promote [Unreleased] to [v2.0.14] — 2026-05-25
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-25 21:54:19 -04:00
Aaddrick
76a5a21725 fix: align WM_CLASS and StartupWMClass to claude-desktop across all formats (#648)
* fix(ci): correct StartupWMClass in AUR PKGBUILD generation

The AUR PKGBUILD template has StartupWMClass=claude-desktop, but the
launcher passes --class=Claude and all other package formats use
StartupWMClass=Claude. Add a sed substitution during PKGBUILD
generation to align the AUR package with the rest.

Fixes #647

Co-Authored-By: Claude <claude@anthropic.com>

https://claude.ai/code/session_01PsPKbs2U5LTWukn8rm4dSY

* fix: align WM_CLASS and StartupWMClass to claude-desktop across all formats

Change --class=Claude to --class=claude-desktop and update
StartupWMClass in all .desktop generators (deb, rpm, AppImage,
autostart) to match. This aligns the X11 WM_CLASS with the Wayland
app_id (already derived from desktopName=claude-desktop.desktop),
the .desktop filename, and the AUR template — resolving the
mismatch reported in #647 and fulfilling the consistency
recommendation from #561.

Also reverts the now-unnecessary CI sed that would have forced the
AUR template to use the old value.

Fixes #647

Co-Authored-By: Claude <claude@anthropic.com>

https://claude.ai/code/session_01PsPKbs2U5LTWukn8rm4dSY

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-25 10:14:23 -04:00
Sum Abiut
e31ac3b4da test(appimage): readiness-marker poll + unified cleanup trap (#646)
* test(appimage): replace sleep 10 with readiness-marker poll

The headless smoke test in tests/test-artifact-appimage.sh waited a
flat 10s after launching the AppImage under Xvfb, then asserted the
main process was still alive. The 10s came from the floor needed to
give Electron startup enough wall-clock time to crash if it was going
to, but that approach has two failure modes:

- On a healthy startup the test always burned 10s even though the
  main process typically reaches its ready state in 1-2s.
- On a noisy or slow runner 10s isn't always enough buffer over real
  startup time, so a temporarily slow Electron pushes the assertion
  into flake territory.

Replace the flat sleep with a poll loop that watches launcher.log for
`[Frame Fix] Patches built successfully` — the last line emitted by
scripts/frame-fix-wrapper.js after every patch is installed and the
upgrade watcher is armed. Seeing that marker is a positive signal
that main-process startup finished without throwing.

The loop has a 30s ceiling and a 0.5s poll interval. Each tick checks
the marker first, then liveness via `kill -0`, so a marker emitted
right before exit still counts as success. On failure the assertion
now distinguishes two cases that the old code conflated:

- "AppImage did not reach ready state within Ns" — process is alive
  but never emitted the marker
- "AppImage exited before reaching ready state (exit: N)" — process
  died before the marker

The existing launcher.log and xvfb-run stderr tail-dumps are preserved
unchanged for both failure cases.

Coupling note: the test now reads a specific string from
scripts/frame-fix-wrapper.js. The wrapper is project-owned, so the
coupling is stable, but a future rename of the marker would need a
paired update in this test.

Follow-up to #592.

* test(appimage): consolidate cleanup into single script-level trap

Move the EXIT/INT/TERM trap from inside the smoke-test block to script
scope, and drop the now-redundant in-block trap. The in-block version
fired only after launch and never cleaned up $extract_dir, so Ctrl-C
during smoke-test still leaked ~200MB squashfs-root.

The script-level _cleanup covers everything the in-block trap did plus
extract_dir, with defensive [[ -n ... ]] guards so it can fire safely
at any point in the script's lifetime.

* test(appimage): shorten _cleanup comment per review

Aaddrick noted the 6-line block read like commit-message prose. The
incident detail (extract_dir leak under Ctrl-C) is already in the
commit message and PR description, so the inline comment only needs
to convey why the trap lives at script scope.
2026-05-25 15:10:46 +11:00
github-actions[bot]
3e1e508f69 chore: update flake.lock 2026-05-25 03:22:52 +00:00
aaddrick
5f67aa1ae4 docs(changelog): promote [Unreleased] to [v2.0.13] — 2026-05-24
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 21:39:41 -04:00
aaddrick
a894d41f76 docs(changelog): add 9 missing PRs to [Unreleased] for v2.0.13
Added: #638 (F11 fullscreen), #639 (org-plugins path)
Fixed: #643 (mcpServers preservation), #642 (Alt menu keyup),
#640 (.asar Cowork dispatch), #644 (identifier hardening),
#637 (exec signal forwarding), #636 (WM_CLASS --class)
Changed: #641 (CI/build/packaging hardening)

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 21:37:23 -04:00
Aaddrick
a470b30079 fix: add powerSaveBlocker logging shim and CLAUDE_KEEP_AWAKE=0 escape hatch (#645)
* fix: add powerSaveBlocker logging shim and CLAUDE_KEEP_AWAKE=0 escape hatch

Upstream's keepAwakeEnabled has no lifecycle management on Linux — the
darwin-only wake scheduler never runs, so powerSaveBlocker.start() fires
at init and never releases, preventing suspend and screensaver activation.

Intercept powerSaveBlocker via Proxy in frame-fix-wrapper.js:
- Always log start/stop/isStarted calls for diagnostic observability
- CLAUDE_KEEP_AWAKE=0 suppresses start() entirely (returns fake ID)
- --doctor reports the override when set

Fixes #605

Co-Authored-By: Claude <claude@anthropic.com>

* style: rename origPSB to originalPSB for naming consistency

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-24 21:27:16 -04:00
Aaddrick
b40441c66c fix(patches): harden regex patterns for minified JS identifiers (#644)
* fix(patches): harden regex patterns for minified JS identifiers

Audit all patch scripts against the documented patching guidelines in
CLAUDE.md and docs/learnings/patching-minified-js.md. Fix violations
across 5 files:

- Use [$\w]+ (PCRE/JS) or [[:alnum:]_$]+ (ERE/sed) instead of bare
  \w+ for capturing minified JS identifiers that may contain $
- Replace \$?\w+ with [$\w]+ in _common.sh (3 sites) — the old
  pattern silently truncated mid-$ names like i$A
- Add \s* whitespace tolerance to sed patterns in tray.sh (3 sites)
  and claude-code.sh (2 format paths)
- Fix broken idempotency guard in tray.sh (grep -q pipe produced
  empty input, always evaluated true)
- Add idempotency guards to cowork.sh patches 6, 9, and 10
- Add multi-site coordination check for tray.sh startup delay
- Remove dead code (first_const extraction in tray.sh)

Verified: clean AppImage build with all patches applying successfully.

Co-Authored-By: Claude <claude@anthropic.com>

* fix(patches): narrow ECONNREFUSED idempotency guard

The broad code.includes('"ECONNREFUSED"') guard matched 9 upstream
occurrences (retry logic, MCP error lists) and skipped our patch.
Use a regex matching the specific injected pattern instead:
process.platform==="linux"&&VAR.code==="ECONNREFUSED"

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-24 21:18:50 -04:00
Aaddrick
364147ecc6 fix(patches): preserve mcpServers across config writes (#643)
* fix(patches): preserve mcpServers across config writes (#400)

The upstream config writer caches parsed config in memory and never
re-reads from disk before writing. Every preference change (mode
switch, sidebar toggle, trusted folder addition) overwrites the file
with the stale cache, silently dropping externally-added mcpServers.

Two patches in new scripts/patches/config.sh:

1. patch_config_write_merge — injects a disk-merge step in nj()
   (the central config writer) that re-reads mcpServers from the
   file before each write. Disk servers form the base; in-memory
   servers override matching entries. Externally-added servers
   survive preference writes.

2. patch_asar_trusted_folder_guard — rejects .asar paths in
   addTrustedFolder, preventing Electron's ASAR VFS shim from
   misidentifying archives as folders and triggering spurious
   config writes that amplify the stale-cache bug.

Both patches use developer-string anchors ("Config file written",
"LocalAgentModeSessions.addTrustedFolder:"), dynamic variable
extraction with [$\w]+ for $-prefixed identifiers, and idempotency
guards.

Fixes #400

Co-Authored-By: Claude <claude@anthropic.com>

* style: consolidate local declarations in config.sh

Move write_fn_re and path_var_re into the initial local block,
matching the pattern used in tray.sh.

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-24 20:08:38 -04:00
Aaddrick
d6fc044490 fix: toggle menu bar on Alt keyup, not keydown (#642)
Chromium's autoHideMenuBar fires on keydown, which grabs menu bar
focus before Alt+Shift (language switch), Alt+F4, or any other
Alt-chord shortcut can complete. Replace with a manual keyup-only
toggle: suppress the Alt keydown via before-input-event, track
per-window state to detect bare Alt press-and-release, and toggle
menu bar visibility only on keyup when no intervening key was
pressed. Reset tracker on window blur to avoid stale state after
Alt-Tab.

Fixes #630

Co-authored-by: Claude <claude@anthropic.com>
2026-05-24 19:26:22 -04:00
Aaddrick
9f99e578da Merge pull request #641 from aaddrick/fix/quick-wins-batch-1
fix: harden CI, build pipeline, and packaging scriptlets
2026-05-24 19:05:14 -04:00
aaddrick
ee3d656715 fix: harden CI, build pipeline, and packaging scriptlets
- fix(ci): move ${{ steps.*.outputs.* }} from run: blocks to env:
  blocks in issue-triage-v2.yml, eliminating expression injection
  surface in the workflow most exposed to untrusted input (#554)

- fix(build): change process.exit(0) to process.exit(1) in
  quick-window.sh when patch anchors are not found, so CI correctly
  reports broken patches instead of masking failures (#429)

- fix(packaging): replace &> bashism with > /dev/null 2>&1 in
  deb postinst and rpm %post/%postun scriptlets, which run under
  /bin/sh (dash on Debian)

Fixes #554 (issue-triage-v2 component)
Refs #429

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 17:48:41 -04:00
Aaddrick
fed3b54bb5 Merge pull request #636 from aaddrick/fix/635-wm-class-hardening
fix(launcher): add --class=Claude for WM_CLASS consistency
2026-05-24 17:01:54 -04:00
Aaddrick
7151d77b8d Merge pull request #638 from aaddrick/feature/580-f11-fullscreen
feat(frame-fix): add F11 fullscreen toggle
2026-05-24 17:01:25 -04:00
Aaddrick
7b990c3aeb Merge pull request #639 from aaddrick/fix/607-org-plugins-linux-path
fix(patches): add Linux org-plugins path to platform switch
2026-05-24 17:01:00 -04:00
Aaddrick
3df24958a3 Merge pull request #640 from aaddrick/fix/383-asar-cowork-dispatch
fix(patches): reject .asar paths in directory check to prevent false Cowork dispatch
2026-05-24 17:00:38 -04:00
Alexis Williams
58eef6d865 Merge pull request #637 from aaddrick/fix/424-exec-launcher-signal
fix(launcher): exec Electron to fix Ctrl+C / signal forwarding
2026-05-24 13:44:25 -07:00
aaddrick
428777aca5 fix(patches): redirect org-plugins warnings to stderr
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:52:17 -04:00
aaddrick
880d21d51f fix(launcher): soften WM_CLASS comment wording per review
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:52:13 -04:00
aaddrick
6bfb296d5c fix(patches): reject .asar paths in directory check to prevent false Cowork dispatch
Fixes #383, #622, #632
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:42:03 -04:00
aaddrick
337e9a45b0 fix(patches): add Linux org-plugins path to platform switch
Fixes #607
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:40:04 -04:00
aaddrick
a32e1aa3c3 feat(frame-fix): add F11 fullscreen toggle for Linux parity
Fixes #580
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:36:38 -04:00
aaddrick
1e339aea93 fix(launcher): add --class=Claude to ensure consistent WM_CLASS
Ref #635
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:35:49 -04:00
aaddrick
e9b71cb567 fix(launcher): add exec before Electron invocation to fix signal forwarding
Fixes #424
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 12:35:45 -04:00
Aaddrick
016d8660c8 Merge pull request #589 from tkrag/fix/416-sloppy-focus-raise
fix(frame-fix): skip redundant webContents.focus() under sloppy WMs (#416)
2026-05-24 10:02:15 -04:00
aaddrick
d54efca7de fix(frame-fix): add restore event, document setTimeout gap (#589)
- Track `restore` event alongside `show` so minimize→restore on
  tiling WMs (i3/sway) resets the grace window timestamp
- Document the known deferred-setTimeout limitation inline
- Add @tkrag to README Acknowledgments
- Add CHANGELOG entry for #589 (fixes #416)

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:44:41 -04:00
tkrag
920c2be926 fix(frame-fix): skip redundant webContents.focus() under sloppy WMs
Under sloppy / focus-follows-mouse WMs (Cinnamon Muffin, Mutter,
i3 with focus_follows_mouse), every BrowserWindow 'focus' event
triggered an upstream webContents.focus() call, which on Electron/X11
routes through Chromium's X11Window::Activate() and sends a
_NET_ACTIVE_WINDOW client message. EWMH defines that as focus-and-
raise, so the WM raised the window on every mouse-enter, undoing the
user's "no auto-raise" config. Tracks electron/electron#38184.

Hooked at app.on('web-contents-created') rather than wrapping the
PatchedBrowserWindow's own webContents.focus, because the upstream
call site sits 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 on
wc.isFocused(), which returns false on a freshly-attached child even
when the window is focused, so guarding on it would never skip and
the raise loop would continue.

A pass-through grace window after every 'show' event handles
Electron's stale-isFocused() bug on Cinnamon/KDE/Wayland (same trap
already addressed for KDE by scripts/patches/quick-window.sh). After
hide(), isFocused() can keep returning true even though the WM never
re-activated the window; a naive guard would then SKIP the post-show
focus() call and leave a tray-restored window visible-but-inert until
clicked. Within SHOW_GRACE_MS (1000 ms) of a 'show' event we pass
through unconditionally, so tray-restore lands properly.

Verified on Cinnamon 6.0 (Muffin), Mint 22.3:

  Scenario                            Behaviour
  ----------------------------------  -----------------------------
  Sloppy + auto-raise off, hover      No raise. Window gets WM
                                      focus, frame highlights.
                                      Renderer focus not redirected
                                      until click — known trade-off
                                      (see below).
  Click-to-focus, tray right-click
    → "Show"                          Window restored, X11
                                      activated, keystrokes land
                                      immediately. Grace window
                                      prevents the stale-isFocused
                                      false-positive.
  Click-to-focus, app launch          Initial focus established
                                      (PASS, owner not yet focused).

Known trade-off in sloppy mode: hovering gives WM focus (frame
highlight) but renderer focus isn't directed until the user clicks,
because webContents.focus() conflates X11 activation and renderer
focus and the Electron API doesn't expose a renderer-only path on
X11. Net effect is one click per hover cycle vs the constant raise
that #416 reports; users self-selecting sloppy-focus presumably
prefer the former.

Known pre-existing (not addressed here): on Cinnamon, Quick Entry
submit fails to surface the main window on attempts after the first
hide → show cycle. Same root cause as the KDE-gated patch in
scripts/patches/quick-window.sh (stale isFocused() after hide
making upstream's focusCheck() || show() short-circuit skip the
show). Reproduces without this PR; worth a follow-up to widen the
KDE patch to Cinnamon.

Fixes: #416

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:42:50 -04:00
Aaddrick
dc00767cd8 Merge pull request #597 from JoshuaVlantis/fix/node-pty-windows-binaries-401
fix(node-pty): clean upstream Windows binaries before staging Linux build
2026-05-24 09:17:19 -04:00
aaddrick
fa42d4d05f docs(changelog): add #597 to unreleased; update JoshuaVlantis attribution
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:17:12 -04:00
JoshuaVlantis
de604e9445 fix(node-pty): clean upstream Windows binaries before staging Linux build (#401)
The Windows installer's app.asar ships node-pty with Windows binaries
(winpty.dll, winpty-agent.exe, Windows-format build/Release/*.node).
After upstream extraction, these sit in app.asar.contents/node_modules/
node-pty/. The existing cp -r $pty_src_dir/build only overwrites
same-named files, so orphan Windows binaries persist inside the asar,
surface as PE32+ when users inspect with asar list, and get extracted
to /tmp by Electron on any spurious require().

rm -rf the upstream-extracted node-pty directory before staging the
Linux build's lib/, package.json, and build/. The asar pack with
--unpack '**/*.node' then sees only the Linux pty.node, redirects
loads from inside the asar to app.asar.unpacked/, and unixTerminal.js
loads a Linux ELF instead of dlopen()-rejecting a Windows DLL.

Verified on both deb and rpm: built each in fedora:42 / ubuntu:24.04
containers, ran asar list app.asar — no .dll, .exe, or Windows .node
files remain. The shipped pty.node in app.asar.unpacked is now Linux
ELF (file output: ELF 64-bit LSB shared object, x86-64).

Note: app.asar.unpacked still contains cosmetic leftovers (winpty.dll,
winpty-agent.exe, Windows conpty*.node files) that came from the
upstream installer's own app.asar.unpacked — Linux runtime never loads
them, so they're harmless dead weight rather than a bug. Cleaning
those is a follow-up for finalize_app_asar in scripts/staging/electron.sh.
2026-05-24 09:15:50 -04:00
aaddrick
5b5c604723 docs(changelog): add #610, #611, #624, #631 to unreleased; credit new contributors
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:13:20 -04:00
Aaddrick
97531b2cdf Merge pull request #628 from aaddrick/docs/governance-refactor
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:10:24 -04:00
25 changed files with 1184 additions and 391 deletions

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -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": {

View File

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

View File

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

View File

@@ -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).

View File

@@ -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/...

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" \

View File

@@ -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
View 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 '##############################################################'
}

View File

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

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

View File

@@ -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) {

View File

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

View File

@@ -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.'

View File

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

View File

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

View File

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