Compare commits

...

176 Commits

Author SHA1 Message Date
Aaddrick
cdf934a061 Merge pull request #743 from aaddrick/fix/cowork-renderer-support-gate
fix(cowork): un-gray the Cowork tab on Linux without re-arming the VM download (#736 follow-up)
2026-06-25 00:38:26 -04:00
aaddrick
bf78ec6c51 docs(changelog): promote [Unreleased] to v2.0.22, add the cowork tab fix (#743)
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-25 00:34:15 -04:00
aaddrick
7327c95402 fix(cowork): report yukonSilver supported on Linux, keep VM download off
The yukonSilver refactor (1.13576+) made the renderer gate the Cowork
tab's visibility on the support evaluator ($oe/q4r), which returns
unsupportedCode:"msix_required" on Linux — graying out the tab with a
"requires a newer installation / Reinstall" prompt even though the
bwrap daemon is healthy.

Patch 1b injects an early Linux `{status:"supported"}` return at the top
of the q4r evaluator so the renderer un-grays the tab; the downstream
enterprise/user gates in $oe (secureVmEnabled, coworkSurface.enabled,
secureVmFeaturesEnabled) still apply.

The evaluator is also read by the VM-image download drivers (u8A and the
warm prefetch mzn), which gate on yukonSilver.status==="supported". With
1b alone they re-arm and pull the multi-GB rootfs.vhdx/vmlinuz/initrd VM
bundle that #337/a3190c3 deliberately disabled on Linux. Patch 1c adds a
process.platform==="linux" block to both so they behave as they did
under "unsupported"; startVM stays open (Patch 1) so the bwrap session
is unaffected.

Adds markers vm-supported-linux-evaluator, vm-download-blocked-linux,
warm-download-blocked-linux; a patch-application BATS test
(cowork-patches.bats) covering injection + node --check + idempotency;
backend-detection tests pinning the KVM-opt-in contract (detectBackend
now exported); and a patching-minified-js learning on the
one-gate-multiple-consumers trap.

Verified at runtime on 1.15200.0: tab + folder picker work,
download_and_sdk_prepare completes in 2ms with no rootfs.vhdx fetch, and
vm_bundles stays empty.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-25 00:31:21 -04:00
Aaddrick
d15309746b Merge pull request #739 from aaddrick/claude/review-open-prs-issues-chup0x
docs(readme): credit #729 and #736 contributors in Acknowledgments
2026-06-24 17:34:16 -04:00
Claude
96ffbb4640 docs(readme): credit #729 startup-hang and #736 cowork contributors
Add pjordanandrsn (#736 cowork yukonSilver re-derivation), chrisw1005
(#729 root-cause + stub fix), and colonelpanic8 (#730 stub test coverage)
to Acknowledgments.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-24 20:56:07 +00:00
Aaddrick
ad2635a4f3 Merge pull request #737 from aaddrick/claude/review-open-prs-issues-chup0x
fix(linux): stub Windows-only native policy methods to fix startup hang (#729)
2026-06-24 16:53:58 -04:00
Claude
81e6e176d1 Merge remote-tracking branch 'origin/main' into claude/review-open-prs-issues-chup0x
# Conflicts:
#	CHANGELOG.md
2026-06-24 20:07:37 +00:00
Aaddrick
a1fc200254 Merge pull request #736 from pjordanandrsn/upstream-fix/cowork-yukonsilver
fix(patches): re-derive cowork Linux patches for the yukonSilver VM refactor (1.13576+)
2026-06-24 16:06:48 -04:00
Claude
8b70d0b05c docs(changelog): note the #729 Linux startup-hang fix
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-24 18:24:17 +00:00
Claude
295d71beb0 fix(linux): stub Windows-only native policy methods to fix startup hang (#729)
Claude Desktop >= 1.13576.0 calls @ant/claude-native.readRegistryValues()
and getWindowsElevationType() unconditionally at startup for the
managed-config / enterprise-policy lookup, from the top level of
index.pre.js and index.js. The bundle guards only the native module being
null, not the method being absent, so the Linux stub (which lacked these
Windows-only methods) threw "<method> is not a function" during top-level
execution -- before the main window is created. index.pre.js installs an
empty uncaughtException handler early, so the throw is swallowed and the
app hangs with no window rather than crashing.

Add neutral no-op stubs for the Windows-only registry / MSIX / UAC
methods (no registry, no MSIX package, no UAC on Linux). Fixing the stub
covers every call site at the source and is robust against re-minification.

Adds tests/claude-native-stub.bats covering all five methods plus a
regression guard on the existing exports.

Consolidates the fix from #734 (complete stub) and #730 (test coverage),
crediting both authors.

Fixes #729

Co-Authored-By: Claude <claude@anthropic.com>
Co-Authored-By: chrisw1005 <26532805+chrisw1005@users.noreply.github.com>
Co-Authored-By: colonelpanic8 <1246619+colonelpanic8@users.noreply.github.com>
2026-06-24 18:23:50 +00:00
github-actions[bot]
0d76a85104 Update Claude Desktop download URLs to version 1.15200.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-24 01:42:41 +00:00
Jordan Anderson
83ea6372cd fix(patches): re-derive cowork Linux patches for yukonSilver VM refactor
Claude Desktop 1.13576+ partially re-architected the cowork/VM
subsystem ("yukonSilver"), staling Patch 1's platform-gate anchor.
Because Patch 1 process.exit(1)'d, the whole cowork node block bailed
and 9/11 cowork patch markers vanished from the shipped asar, failing
the "Verify cowork patches in shipped asar" build step. (main has been
red since the 1.13576.0 bump on 2026-06-17.)

- Patch 1: anchor on startVM's `yukonSilver.status !== "supported"`
  gate (via the "[startVM] VM not supported" log) and let Linux
  through, replacing the gone `darwin`/`win32` check.
- Patch 2: widen the isMsix gate in YBt() so the TS vmClient loads on
  Linux — covers the old 2a+2b in one site; gate fn captured
  dynamically. The isMsix detector itself is left untouched.
- Patch 6 step 2: re-anchor daemon auto-launch on the new
  `await <helper>(<delay>)` retry call (was inline setTimeout).
- Patch 9: fix the idempotency guard, which false-matched upstream's
  own "[VM:start] Copying smol-bin" log; now keys on the fork's
  injected "to bundle (Linux)" sentinel and applies correctly.
- Patch 12 (sharedCwdPath threading): retired — mountConda/
  sharedCwdPath are gone upstream; folder sharing now flows via
  first-class userSelectedFolders -> additionalMounts -> the daemon's
  mountMap, so the threading is moot.
- Patches 6b/8: anchors absent on this architecture; documented as
  safe no-ops (the Linux VM-download driver is unreachable — its
  downloadVM gate stays yukonSilver-gated, which Patch 1 leaves closed).
- markers tsv: drop the two Patch 2a/2b markers (collapsed into one)
  and the Patch 12 marker; add markers for the new Patch 1, Patch 2,
  and the previously-unfingerprinted Patch 9 smol-bin copy.

Verified against the real 1.14271.0 bundle: 8 cowork patches apply,
11/11 markers present, patched index.js parses, idempotent on re-run.
Also verified the shipped .deb's app.asar and the client<->daemon
contract (socket path, wire framing, autolaunch target) all agree.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-23 11:37:07 -05:00
github-actions[bot]
c5f8c0f4a7 chore: update flake.lock 2026-06-22 03:24:00 +00:00
github-actions[bot]
d7e0611091 Update Claude Desktop download URLs to version 1.14271.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-19 02:00:54 +00:00
github-actions[bot]
748b388548 Update Claude Desktop download URLs to version 1.13576.1
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-18 01:55:22 +00:00
github-actions[bot]
da341d733c Update Claude Desktop download URLs to version 1.13576.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-17 01:54:43 +00:00
Aaddrick
e8b9bfc27b Merge pull request #723 from aaddrick/fix/718-nix-add-dir-duplicate-dispatch
fix(patches): filter every --add-dir dispatch loop (#718)
2026-06-16 16:18:26 -04:00
Alexis Williams
5e4f26b28e fix(patches): filter every --add-dir dispatch loop (#718)
Upstream Claude Desktop 1.12603.1 ships two identical
`for(let O of A)Y.push("--add-dir",O)` dispatch loops. The .asar
filter patch from #650 asserted exactly one match and aborted the
build with `FATAL: --add-dir pattern matches 2 times (expected 1)`,
breaking every build format (deb, RPM, AppImage, nix) on the new
upstream version.

The correct invariant is not "exactly one dispatch loop" but "every
unfiltered --add-dir dispatch must filter .asar paths". Replace the
single-match-or-bail logic with a global replace over both the for-of
and forEach variants, counting how many loops were filtered. The patch
still fails loudly when no dispatch loop is found and no existing
filter is present (#649 protection intact), and remains idempotent on
re-runs.

Verified end-to-end against real upstream 1.12603.1: the patch reports
`Filtered 2 --add-dir dispatch loop(s) (for-of=2, forEach=0)` and
`nix build .#claude-desktop` now completes.

Adds tests/config-patches.bats covering the duplicate-loop case.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-16 10:37:58 -07:00
github-actions[bot]
2d1d0c59ff chore: update flake.lock 2026-06-15 03:24:09 +00:00
github-actions[bot]
d2ce046631 Update Claude Desktop download URLs to version 1.12603.1
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-12 01:51:57 +00:00
Aaddrick
e85450c90b Merge pull request #712 from aaddrick/fix/711-doctor-version-ownership
fix(doctor): report installed version from the package manager that owns the install
2026-06-09 20:54:48 -04:00
aaddrick
e0c41b4e52 docs(changelog): note the doctor version-ownership fix
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 20:53:50 -04:00
aaddrick
fa184216b3 fix(doctor): report installed version from the package manager that owns the install
The "Installed package version" block gated on `command -v dpkg-query`
alone and trusted whatever the dpkg database said. On dual-DB hosts
(e.g. Fedora with dpkg installed for deb work) a stale dpkg record
shadowed the live rpm install: doctor printed a confident PASS with a
version that doesn't describe the actual install, and rpm was never
consulted at all.

Decide ownership instead of trusting whichever DB answers first:
probe `rpm -qf` on the installed Electron binary — querying the path
is the discriminator, since a stale dpkg record can't own a file rpm
installed. If rpm owns it, report `%{VERSION}-%{RELEASE}` from the
owning package; otherwise fall back to dpkg-query as before. AppImage
and Nix installs keep the existing not-found warn; hosts with no
package tools stay silent.

The block is extracted into _doctor_check_pkg_version so it's unit
testable; output format (`Installed version: X`) is unchanged. New
bats cases cover the #711 repro (rpm owns path + stale dpkg record),
dpkg-only hosts, rpm-present-but-deb-owned fallback, the AppImage
warn, and the tool-less silent path; the repro test fails against the
old dpkg-first ordering.

Fixes #711

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 20:49:29 -04:00
aaddrick
dc2e0ecce2 docs: release v2.0.19 — promote changelog, add missing entries for #633, #666, #692, #691/#693
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 20:32:44 -04:00
github-actions[bot]
4a6a540bf1 Update Claude Desktop download URLs to version 1.11847.5
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-10 00:22:00 +00:00
Aaddrick
e1bdafd169 Merge pull request #682 from svankirk/fix/quit-cleans-desktop-helpers
Clean up Desktop helpers after explicit quit
2026-06-09 19:50:03 -04:00
aaddrick
c2ceb5e74f Merge origin/main into fix/quit-cleans-desktop-helpers
Reconciles #682 with the post-#700 live-UI fingerprint so one
detection concept survives the merge.

#682 introduced named helpers (_claude_desktop_ui_cmdline_matches /
_claude_desktop_ui_is_alive) keyed on the bundle's app.asar path;
#700 meanwhile removed app.asar from every launcher's argv (Electron
auto-loads it from resources/) and re-keyed main's inline detection
on the --class=$WM_CLASS flag from build_electron_args. This merge
keeps #682's helper structure but converges the matching on main's
fingerprint:

- _claude_desktop_ui_cmdline_matches now requires "--class=$WM_CLASS "
  (trailing-space anchored against look-alike classes) and still
  rejects --type= Chromium helpers and the cowork daemon; the dead
  claude_desktop_app_path keying — and its assignments in the deb/
  rpm/AppImage/nix launchers — are removed (app.asar never appears
  in any cmdline anymore, and nix never set the variable anyway).
- _claude_desktop_ui_is_alive pgreps on the --class fingerprint
  (keeping #682's -u uid scoping) and remains the single detector:
  cleanup_orphaned_cowork_daemon, cleanup_stale_desktop_helpers, and
  doctor's orphaned-cowork-daemon check (previously main's inline
  copy of the same loop) all call it.
- tests: the #693 reaper stand-in is renamed via exec -a to carry
  --class=Claude (replacing the app.asar rename), pgrep stubs key on
  the --class pattern per #700, and the matcher test asserts the
  unified fingerprint set (accept --class, reject foreign asar
  paths, look-alike classes, --type= helpers, the cowork daemon).
- CHANGELOG: union of both sides' [Unreleased] entries.

bats tests/: 303 passed, 0 failed. shellcheck -x clean on
launcher-common.sh, doctor.sh, deb.sh, rpm.sh, appimage.sh.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:42:02 -04:00
Aaddrick
63940b2684 Merge pull request #710 from aaddrick/docs/credits-2026-06-09
docs(readme): credit contributors from the 2026-06-09 PR triage
2026-06-09 19:37:00 -04:00
aaddrick
55347230b2 docs(readme): credit contributors from the 2026-06-09 PR triage
Credits @sabiut, @jerem, @caidejager, @JustinJLeopard, @DhanushSantosh,
@diarized, @emandel82, and @svankirk for #693, #692, #691, #690, #695,
#633, #666, #687, #694, #700, and #682.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:28:38 -04:00
Aaddrick
73b504834a Merge pull request #700 from emandel82/fix/696-launcher-app-asar-argv
fix: stop passing app.asar as an Electron arg (root cause of #696 attach prompt)
2026-06-09 19:26:29 -04:00
aaddrick
6f10b009ec ci: retrigger checks
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:26:19 -04:00
Aaddrick
50dd1f0366 Merge pull request #694 from diarized/feature/cowork-bwrap-apparmor-deb
fix(deb): auto-install AppArmor userns profile for Cowork bwrap backend
2026-06-09 19:25:41 -04:00
aaddrick
9790c7d77c Merge origin/main into fix/696-launcher-app-asar-argv
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:17:29 -04:00
aaddrick
9648416aab test(launcher): key the live-UI reaper stand-in on the bundle path
Resolve the behavioral half of the #693 test collision: the reaper
suite's "live UI present" case pinned the old exclusion-based detector
(any non-cowork, non---type process counts as a live UI), but the
_claude_desktop_ui_is_alive refactor kept from #682 narrows detection
to cmdlines referencing the Claude bundle's app.asar. The bare
'sleep 300' stand-in stopped qualifying, so the daemon was reaped and
the test failed. Rename the stand-in's argv[0] to the bundle path via
exec -a so the real-/proc design still exercises the live-UI branch.

(A plain extra argv on bash -c does not survive: with a single simple
command bash exec-optimizes and the wrapper argv vanishes.)

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:10:55 -04:00
aaddrick
26de2c2957 Merge origin/main into feature/cowork-bwrap-apparmor-deb
Resolves CHANGELOG union and keeps the two-profile postinst/postrm
(superset of #687's rebased lifecycle now on main).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:10:39 -04:00
Aaddrick
12cc726fc1 Merge pull request #687 from diarized/fix/apparmor-userns-deb-autoinstall
fix(deb): auto-install AppArmor userns profile to stop Ubuntu 24.04+ launch crash
2026-06-09 19:08:36 -04:00
Aaddrick
e50adf298a Merge pull request #666 from DhanushSantosh/fix/fedora-launch-recovery-pr
Fix Fedora launch regression and KDE tray quit handling
2026-06-09 19:07:55 -04:00
aaddrick
0aae168ab2 fix(deb): align AppArmor profile lifecycle with Debian Policy 10.7.3
The postinst unconditionally rewrote /etc/apparmor.d/claude-desktop on
every configure, and the postrm deleted it on remove. Policy 10.7.3
requires preserving local changes on upgrade and keeping config files
until purge.

- Write a marker header into the generated profile declaring it
  postinst-managed and pointing edits at local/claude-desktop.
- Only overwrite a profile that carries the marker; a hand-created or
  hand-edited profile (no marker) is preserved and best-effort reloaded.
- postrm keeps the apparmor_parser -R unload on remove (the confined
  binary is gone) but deletes the file only on purge — a profile for an
  absent binary is a harmless no-op, same as google-chrome.
- Clean up a truncated profile when the heredoc write fails:
  rm -f ... || true in the else branch, which is errexit-live; a bare
  rm would fail upgrades on a read-only /etc.

Addresses review findings 2 and 3 on PR #687.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:04:40 -04:00
aaddrick
b0ca0c811f fix(doctor): scope the userns WARN to deb installs and X11 sessions
The User namespaces check fired on the kernel knob alone, but doctor.sh
ships in every format and the knob is the Ubuntu 24.04+ distro default.
AppImage users (always --no-sandbox) and deb users on Wayland
(launcher-common.sh adds --no-sandbox there) got a false "crashes on
launch" WARN for a crash they cannot hit.

- Gate the block on the deb's installed Electron path, deliberately not
  $electron_path: the profile pins that exact path, and a user running
  the AppImage's doctor on a deb-installed machine should still see the
  installed deb's profile state.
- Scope the WARN text to X11 sessions and note Wayland runs with
  --no-sandbox.
- Branch the "re-run with sudo" hint on EUID so root with securityfs
  unmounted (containers) is not told to sudo.
- Reuse $_aa_profile in the load hint (fixes the one over-length line)
  and note the profile name must match deb.sh's $package_name.

Addresses review findings 1, 5, 6, 7 on PR #687.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:04:40 -04:00
Artur Kaminski
e18325dc25 fix(deb): harden AppArmor userns auto-install per PR #687 review
Address review feedback (aaddrick, sabiut) on the AppArmor userns
profile install, scoped to the existing change — no new functionality.

- postinst: abort-proof the profile write. Move mkdir + heredoc into an
  if-condition so a read-only/atypical /etc can no longer fail the
  install under set -e (every other op already uses || echo).
- postinst: gate on the apparmor_restrict_unprivileged_userns kernel
  knob, not just apparmor_parser. Non-Ubuntu systems that ship
  apparmor_parser but never impose the restriction now get no profile
  and no per-boot reload. Gate on knob existence (0 may flip to 1).
- prerm -> postrm: move profile cleanup so it also fires on purge and
  abort-install; upgrade still falls through (incoming postinst reloads).
- doctor: replace the file-existence PASS (false PASS on a staged-but-
  unloaded profile) with a real loaded-state check; stay silent unless
  the restriction is actually in force; drop .deb-specific advice in
  favour of hints pointing at docs/troubleshooting.md (also correct for
  AppImage/Nix users).
- doctor: judge the loaded set by an actual read, not [[ -r ]].
  securityfs marks the profiles file 0444 but denies the read without
  CAP_MAC_ADMIN, so -r passes for non-root yet the read returns empty —
  the mode-bit test would misreport a loaded profile as "not loaded".

Validated end-to-end on Ubuntu 24.04 (knob=1): build -> install
(profile loaded) -> doctor (all four branches, root and non-root) ->
remove/purge (profile unloaded + deleted) -> reinstall -> GUI launch
with no credentials.cc FATAL / exit-133 crash.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:04:40 -04:00
Artur Kaminski
98306b56b5 fix(deb): auto-install AppArmor userns profile to stop Ubuntu 24.04+ launch crash
Ubuntu 24.04+ ships apparmor_restrict_unprivileged_userns=1, which blocks
the unprivileged user namespaces Chromium's sandbox needs. On the .deb
(X11), where the launcher intentionally keeps the sandbox enabled, the app
aborts on launch with FATAL:.../credentials.cc Check failed: Permission
denied (Trace/breakpoint trap, exit 133). The SUID chrome-sandbox helper is
not the cause — Chromium still spins up an unprivileged user-namespace
zygote that AppArmor denies.

Fix it the way the google-chrome, code, and slack debs do: grant userns to
our Electron binary via a scoped AppArmor profile. The deb's postinst
generates /etc/apparmor.d/<pkg> and loads it, but only when AppArmor can
parse the userns rule (4.0+) — older AppArmor and non-AppArmor systems skip
it, which is harmless since they don't impose the restriction. A new prerm
unloads and removes the profile on package removal (it is generated, not a
dpkg-tracked file). The Chromium sandbox stays enabled; --no-sandbox is not
used.

Also adds a "User namespaces" check to `claude-desktop --doctor` that flags
a missing profile when the restriction is active, and a troubleshooting
entry documenting the automatic behavior plus a manual fallback.

Verified on Ubuntu 24.04.4: shellcheck clean (incl. generated sh), profile
parses, postinst load / prerm remove / postinst restore lifecycle, and both
doctor probe branches.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:03:56 -04:00
Aaddrick
79973e3422 Merge pull request #695 from caidejager/fix/cowork-unpacked-dir-perms
fix(packaging): normalize install perms so Cowork daemon can launch
2026-06-09 19:02:55 -04:00
Aaddrick
b35a1404ce Merge pull request #633 from JustinJLeopard/feat/appstream-metainfo
Add AppStream metainfo so the package shows up in App Center / GNOME Software / Discover
2026-06-09 19:02:45 -04:00
aaddrick
974f4d397b refactor(quit): carve MCP scope matching out to follow-up
Remove the Desktop-scoped MCP slice from the quit-cleanup path per the
review on #682: the systemd cgroup gate greps for a literal
/app-claude-desktop-*.scope token that systemd's \x2d escaping (and
KDE's app-*@uuid.service unit shape) can never produce, so the slice is
a silent no-op. It moves to a follow-up PR gated on real
/proc/<pid>/cgroup captures and a non-cowork surviving-PID repro.

Removed (to reland in the follow-up):
- _desktop_scoped_mcp_cmdline_matches and _pid_in_claude_desktop_scope
- the scope-gate fallback block in cleanup_stale_desktop_helpers
  (loop body collapses to a plain _desktop_helper_cmdline_matches gate)
- the three MCP arms of the _desktop_helper_candidate_pids pgrep
- the two BATS tests exercising the MCP matcher and candidate arms

Kept-half hardenings from the same review:
- scope both new pgrep call sites to the current user (-u "$(id -u)")
- anchor the --user-data-dir arm on the tr-joined trailing space so
  sibling dirs like ClaudeDev no longer match; pin both sides in BATS
- replace ((wait_count++)) with wait_count=$((wait_count + 1)) to drop
  the status-1-on-first-iteration footgun
- document that a live UI (any instance) suppresses all cleanup
- update the #693 live-UI pgrep stub to match on "$*" since the
  pattern is no longer at a fixed argument position

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:01:55 -04:00
aaddrick
bdd5b00f40 fix(launcher): preserve signal exit status while reaping Electron; fix self-kill guard (#682)
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:01:55 -04:00
svankirk
bcdc047dd5 Include Desktop-scoped MCPs in cleanup candidates
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-06-09 19:01:55 -04:00
svankirk
1c241ab443 Clean Desktop-scoped MCP helpers on quit
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-06-09 19:01:55 -04:00
svankirk
769036d55f Narrow live UI detection to Claude bundle
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-06-09 19:01:55 -04:00
svankirk
a87024c3e2 Fix explicit quit cleanup for desktop helpers
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-06-09 19:01:54 -04:00
aaddrick
8319407726 docs(troubleshooting): restore the manual bwrap profile for non-deb installs
"Fix: None needed" was only true for the .deb; the auto-install is
deliberately deb-only, leaving AppImage/Nix/rpm/manual installs on
Ubuntu 24.04+ with no documented fix. Restore @hfyeh's manual
/etc/apparmor.d/bwrap heredoc (#351) under a non-deb subhead, note
that the postinst defers to any profile already attaching to
/usr/bin/bwrap (including apparmor-profiles' bwrap-userns-restrict),
and document /etc/apparmor.d/local/<profile> as the upgrade-safe
customization point for both managed profiles.

Addresses review findings M3 and m3 (and M4's doc note) on PR #694
(M3 originally @sabiut's blocker 2).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:00:16 -04:00
aaddrick
f3647b044d fix(doctor): make the Cowork bwrap probe hint format-neutral
doctor.sh ships in deb, rpm, AppImage, and Nix, but the
bwrap-probe-failure hint told everyone to reinstall the .deb. Point
it at docs/troubleshooting.md "Cowork on Ubuntu 24.04" instead and
let the doc branch per format — same shape as the User namespaces
check and the #687 hints.

Addresses review finding M2 on PR #694 (originally @sabiut's
blocker 1).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:00:16 -04:00
aaddrick
a4b85115f0 fix(packaging): keep explicit app path in deb/rpm global-Electron fallback
The deb/rpm launchers fall back to a PATH-resolved `electron` when the
bundled binary is missing. A global Electron has no co-located
app.asar, so it boots default_app — the one mode where the first
positional argument IS the app path and the removed argv entry was
load-bearing. Without it that branch silently shows Electron's welcome
app, and a %u deep link forwarded via "$@" becomes a bogus positional
app path.

Append "$app_path" to electron_args only in the global-Electron
branch; the bundled-Electron path keeps the #696 root-cause omission.

Refs #700, #696
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:00:11 -04:00
aaddrick
de957183f1 fix(launcher): re-key live-UI detection on --class flag, not app.asar argv
cleanup_orphaned_cowork_daemon (launcher-common.sh) and the doctor's
orphaned-daemon check fingerprinted a live Claude UI via
pgrep -f 'app\.asar' — which only ever matched because the launchers
passed app.asar as an Electron argument. With that argument removed
(the #696 root-cause fix), an auto-loaded asar never appears in any
cmdline, so reopening the app while running would falsely classify the
live cowork daemon as orphaned and kill it, and --doctor alongside a
running app would false-report "orphaned".

Re-key both consumers on the --class=$WM_CLASS flag that every
launcher (deb/rpm/AppImage/nix) passes via build_electron_args.
Verified empirically against the bundled Electron: the exec'd argv
persists in /proc/PID/cmdline and --class is not propagated to
--type=... helper children. Both scenarios manually verified with real
processes: live UI + reopen (daemon survives, doctor passes) and
genuinely orphaned daemon (reaped, doctor warns).

The #693 reaper tests' pgrep stubs are updated to encode the new
fingerprint, and the stale --password-store comment (which reasoned
about flag position relative to the now-removed app path) is trimmed.

Refs #700, #696
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:00:11 -04:00
emandel82
06736b4cd7 docs: changelog entry for app.asar launcher fix (#700)
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:00:11 -04:00
aaddrick
c6cc037a30 fix(deb): knob-gate the bwrap AppArmor profile and align its lifecycle
Per review of #694 (M1, M4, m1, n1):

- Gate the bwrap block on the
  apparmor_restrict_unprivileged_userns kernel knob, same as the
  Electron block. Capability detection alone (apparmor_parser
  accepting the userns rule) installed a dead profile onto
  /usr/bin/bwrap — a binary this package does not own — on knobless
  AppArmor-4 systems (e.g. trixie). A kernel that can enforce the
  restriction exposes the knob; a parser that merely parses the rule
  is not enforcement. Strictly narrowing: Ubuntu behavior unchanged.
- Widen the clash guard from the literal filename
  /etc/apparmor.d/bwrap to any profile attaching to /usr/bin/bwrap
  (grep of /etc/apparmor.d excluding our own file). With identical
  attachment strings there is no specificity tiebreak, so our
  unconfined-mode profile could silently shadow a restrictive one
  like apparmor-profiles' bwrap-userns-restrict.
- Drop the [ -x /usr/bin/bwrap ] gate: Recommends gives dpkg no
  ordering edge, so the gate raced a same-transaction bubblewrap
  install; a profile attaching to a nonexistent binary is inert.
- Mirror the Policy 10.7.3 lifecycle from the Electron block:
  marker header pointing edits at local/claude-desktop-bwrap,
  preserve-and-reload an unmarked (admin-made) profile, and
  errexit-safe rm -f ... || true cleanup of truncated writes in the
  else branch. Purge-only deletion already lands via the merged
  postrm loop.
- Update the CHANGELOG bullet to match (knob-gated, attachment-wide
  deferral, local/ override path).

Verified with a stub-parser harness: fresh install, knob-absent
skip, unmarked-profile preservation, marked-profile rewrite on
upgrade, foreign-attachment skip, -Q-failure self-clean, and
remove/purge/upgrade postrm paths (25 checks).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 19:00:06 -04:00
aaddrick
d1eb24a7d4 fix(rpm): normalize buildroot file modes in %install
%defattr(-, root, root, 0755) forces directory modes in the payload but
its "-" first field preserves file modes verbatim from the buildroot,
which %install populates with plain cp -r — so a umask-077 build shipped
an unreadable app.asar and a non-executable electron binary. Add a find
chmod u=rwX,go=rX pass before the chrome-sandbox 4755 chmod so the suid
bit survives.

Verified with a umask-077 rpmbuild against fake ELF staging: payload now
records electron 755, chrome-sandbox 4755, app.asar 644, and
app.asar.unpacked 755 root:root; main's rpm.sh under the same umask
ships app.asar 600 and electron 700.

Also correct the "RPM was already safe" wording in the CHANGELOG entry
and docs/learnings/cowork-vm-daemon.md (RPM had the same exposure via
file-mode preservation), clarify the affected population (CI builds use
umask 022; local restrictive-umask builds were the ones hit), and assert
the new invariants in the deb/rpm artifact smoke tests (chrome-sandbox
setuid post-install, app.asar.unpacked 755 root:root).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:59:37 -04:00
chris
5c43ccd6e2 fix(packaging): normalize install perms so Cowork daemon can launch
The packaged app.asar.unpacked/ directory shipped as mode 0700 owned by
the build uid. The launcher runs as the desktop user (a different uid),
which cannot traverse that directory, so the app's daemon auto-launch
guard — if (fs.existsSync(.../app.asar.unpacked/cowork-vm-service.js)) —
silently returns false and the Cowork VM daemon never forks. The symptom
is an endless `connect ENOENT` on the VM-service socket with no
cowork_vm_daemon.log and no [cowork-autolaunch] line: nothing is even
attempted.

Root cause is packaging, not app code. A restrictive build umask creates
0700 directories, and dpkg-deb records the build uid verbatim because the
build does not run under fakeroot.

- deb.sh: normalize the installed tree to canonical modes (dirs and
  executables 755, other files 644) before building, and build with
  `dpkg-deb --root-owner-group` for root:root ownership.
- appimage.sh: apply the same normalization to the AppDir before
  mksquashfs (it copies with `cp -a`, preserving the bad modes).
- RPM was already safe: %defattr(-, root, root, 0755) forces dir perms.

Validated by building a mock .deb: app.asar.unpacked/ now ships as
drwxr-xr-x root/root and the bundled electron binary stays executable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:59:37 -04:00
emandel82
ab17b69a9c fix: stop passing app.asar as an Electron arg in all launchers
app.asar lives at Electron's default resources/ location next to the
binary, so Electron auto-loads it as the app. The launchers also
appended the same path as a command-line argument; because the app is
already loaded, Electron treats that path as a file-to-open and the app
forwards it to its file-drop handler (lKr -> cCA), surfacing a spurious
"Attach app.asar?" prompt.

This is the root cause behind the recurring prompt the renderer-side
.asar filters (#640, #650, #669) kept missing: the startup argv scan's
getAppPath() guard does not match in the auto-load layout, and the
second-instance handler (taskbar reopen) has no guard at all, so the
prompt returns on every reopen (#696).

Dropping the redundant argument removes the path at the source, so it
can never reach any renderer handler (folder-drop, file-drop, skill,
DXT) and the fix is immune to upstream re-minification. Applied to deb,
rpm, appimage, and nix launchers; app_path is retained and logged for
diagnostics.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:59:29 -04:00
Aaddrick
b78d3104e6 Merge pull request #707 from aaddrick/docs/599-softpipe-workaround
docs: black-screen softpipe workaround from #599
2026-06-09 18:57:11 -04:00
aaddrick
992dd34353 Merge branch 'fix/apparmor-userns-deb-autoinstall' (#687 head 133ffce) into feature/cowork-bwrap-apparmor-deb
Brings in #687's doctor userns gate (6b02fbb) and Debian Policy 10.7.3
profile lifecycle (133ffce). Conflict in the generated postrm resolved by
keeping #694's two-profile loop and adopting 133ffce's lifecycle inside
it: unload on remove/purge/abort-install, delete files only on purge,
errexit-safe rm.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:53:10 -04:00
Aaddrick
4002fd34f8 Merge pull request #691 from sabiut/fix/artifact-test-smoke-ci
ci(test-artifacts): test arm64 packages + fix AppImage launch-smoke pkill
2026-06-09 18:52:05 -04:00
Aaddrick
6ed49f5a3f Merge pull request #690 from jerem/fix/404-global-shortcuts-portal-wayland
Route GNOME Wayland global shortcuts through the XDG GlobalShortcuts portal (#404)
2026-06-09 18:51:55 -04:00
Aaddrick
9236476c10 Merge pull request #692 from sabiut/fix/doctor-false-greens
fix(doctor): stop false-green PASS on empty password store + bad disk read
2026-06-09 18:51:45 -04:00
aaddrick
29fa6daa36 docs(troubleshooting): describe sticky GPU auto-fallback, drop CRLF churn
Re-emit the auto-fallback paragraph against main's line endings so the
diff is just the added text instead of CRLF->LF churn across the whole
section. The paragraph now reflects the sticky recovery behaviour
(flags persist on subsequent launches) and notes that any explicitly
set CLAUDE_DISABLE_GPU value suppresses the auto-fallback — only the
literal 1 forces the flags on.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:51:22 -04:00
aaddrick
eefaad3c9d fix(launcher): make GPU auto-recovery sticky and match NixOS log headers
Two defects in the new _previous_launch_hit_gpu_fatal() helper:

1. Oscillation: a recovered launch runs with --disable-gpu and writes
   no GPU output, so launch N+2 saw a clean penultimate section,
   re-enabled GPU, and crashed again — crash/work/crash forever on the
   permanently broken hardware #583 describes. The awk now also accepts
   the launcher's own "Previous launch hit GPU process FATAL" marker as
   a trigger, so recovery stays tripped once tripped.
   CLAUDE_DISABLE_GPU=0 remains the escape hatch for retesting.

2. Nix no-op: the section-header regex only matched "Launcher Start"
   and "AppImage Start", but nix/claude-desktop.nix writes
   "Launcher Start (NixOS)" — sections never incremented and the
   helper silently exited 1. The alternation now covers it.

Adds a bats case for each: launch N+2 persistence and NixOS header
detection.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:51:15 -04:00
aaddrick
133ffcee86 fix(deb): align AppArmor profile lifecycle with Debian Policy 10.7.3
The postinst unconditionally rewrote /etc/apparmor.d/claude-desktop on
every configure, and the postrm deleted it on remove. Policy 10.7.3
requires preserving local changes on upgrade and keeping config files
until purge.

- Write a marker header into the generated profile declaring it
  postinst-managed and pointing edits at local/claude-desktop.
- Only overwrite a profile that carries the marker; a hand-created or
  hand-edited profile (no marker) is preserved and best-effort reloaded.
- postrm keeps the apparmor_parser -R unload on remove (the confined
  binary is gone) but deletes the file only on purge — a profile for an
  absent binary is a harmless no-op, same as google-chrome.
- Clean up a truncated profile when the heredoc write fails:
  rm -f ... || true in the else branch, which is errexit-live; a bare
  rm would fail upgrades on a read-only /etc.

Addresses review findings 2 and 3 on PR #687.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:49:17 -04:00
aaddrick
6b02fbbfa6 fix(doctor): scope the userns WARN to deb installs and X11 sessions
The User namespaces check fired on the kernel knob alone, but doctor.sh
ships in every format and the knob is the Ubuntu 24.04+ distro default.
AppImage users (always --no-sandbox) and deb users on Wayland
(launcher-common.sh adds --no-sandbox there) got a false "crashes on
launch" WARN for a crash they cannot hit.

- Gate the block on the deb's installed Electron path, deliberately not
  $electron_path: the profile pins that exact path, and a user running
  the AppImage's doctor on a deb-installed machine should still see the
  installed deb's profile state.
- Scope the WARN text to X11 sessions and note Wayland runs with
  --no-sandbox.
- Branch the "re-run with sudo" hint on EUID so root with securityfs
  unmounted (containers) is not told to sudo.
- Reuse $_aa_profile in the load hint (fixes the one over-length line)
  and note the profile name must match deb.sh's $package_name.

Addresses review findings 1, 5, 6, 7 on PR #687.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:49:08 -04:00
aaddrick
d854e67ed2 test(artifacts): assert AppStream metainfo ships in deb and rpm (#633)
The AppImage artifact test already asserts its metainfo exists; add the
matching assertion to the deb and rpm tests so a future refactor cannot
silently drop the install line.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:47:31 -04:00
aaddrick
c4fd450cee fix(appimage): report project_license as LicenseRef-proprietary (#633)
project_license describes the software the user launches -- the
proprietary Claude Desktop binary -- not the MIT packaging scripts.
GNOME Software renders this field as the app's license, so MIT was
user-facing misinformation. Matches the deb/rpm metainfo.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:47:31 -04:00
aaddrick
b7b444d13e fix(metainfo): use io.github.aaddrick component ID and unofficial branding (#633)
Rename the metainfo to io.github.aaddrick.claude-desktop-debian.metainfo.xml
to match the component ID the AppImage already ships, and stop presenting
the listing as Anthropic's official client:

- <id> and <developer id> move off the com.anthropic namespace
- name/summary/description align with the AppImage's unofficial wording
- homepage points at the packaging project, not claude.ai
- categories align with the deb/rpm .desktop files (Office;Utility)
- project_license stays LicenseRef-proprietary (the app the user
  launches is proprietary; the MIT license covers only the packaging)
- deb.sh uses install -Dm 644 instead of mkdir + cp; deb.sh and rpm.sh
  reference the renamed file in staging, %install, and %files

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:47:23 -04:00
aaddrick
a691909ef2 docs: add Fedora KDE Iris Xe black-screen softpipe workaround
Captures the MESA_LOADER_DRIVER_OVERRIDE=softpipe manual workaround
discovered by @dubreal in #593/#599 as a troubleshooting entry, with
the DRM_IOCTL_MODE_CREATE_DUMB log signature, the faster fallbacks to
try first (CLAUDE_DISABLE_GPU=1, LIBGL_ALWAYS_SOFTWARE=1), and the
slow-CPU-rendering tradeoff. Links tracking issue #706.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:46:48 -04:00
Dhanush
62fca415e1 Fix GPU fatal awk detection
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:45:55 -04:00
Dhanush
3cf58ca26d Ignore local build and inspection artifacts
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:45:55 -04:00
Dhanush
b2abc50a77 Ignore graphify artifacts 2026-06-09 18:45:55 -04:00
Dhanush
d8693baa6f Recover Fedora launch after GPU and config failures
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:45:55 -04:00
aaddrick
b511a8ba6f docs: align configuration table and S12 entry with portal descope
The descope commit (9762043) made the GNOME portal route opt-in but
missed a few doc surfaces that still described the auto-flip:

- configuration.md env-var table said GNOME and Niri both default to
  native Wayland; only Niri does
- S12's Expected described the abandoned auto-flip contract and its
  Diagnostics pointed at a launcher log line that no longer exists
  (the real line is "Using native Wayland backend (global shortcuts
  via XDG portal)")
- S11/S12 code anchors still said "GNOME -> native Wayland" /
  "GNOME branch"

Also two review-nit clarifications: the KDE note now flags that the
portal >=1.21 app-id requirement applies to all desktops, and the
learnings doc notes that a denied permission dialog persists in the
portal permission store (with a hedged reset path).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:45:07 -04:00
aaddrick
d1fe02185b fix(doctor): force base-10 df parsing + surface skipped disk reads
Close out the review residues on the false-green fixes:

- Normalize avail with $((10#$avail)) after the numeric guard in
  _doctor_check_disk_space: a leading zero ("0099") clears the
  ^[0-9]+$ regex but makes (( )) parse the value as octal, error
  out, and fall through to the PASS branch — the same trap the PR
  was written to kill, through a different door. Apply the same
  normalization to name_max in _doctor_check_filename_limit.
- Emit an _info line when the df read is unreadable or df is
  missing, so the summary never claims "All checks passed" over a
  check that silently never ran.
- Drop the duplicated 'basic' rationale sentence from the
  _doctor_check_password_store docstring.
- Pin the 100/500 tier boundaries and the leading-zero input in
  doctor.bats (+3 cases, 36 -> 39).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:44:40 -04:00
Aaddrick
f2aa627a51 Merge pull request #693 from sabiut/test/reaper-coverage
test(launcher): cover cleanup_orphaned_cowork_daemon
2026-06-09 18:44:28 -04:00
aaddrick
47e4011bd0 ci(test-artifacts): guard mount_claude sweep behind $CI
The pkill -KILL -f mount_claude sweep (and its EXIT/INT trap path)
matches any live local Claude Desktop AppImage session, so a
standalone test run — or a Ctrl-C mid-run — would kill a
developer's running app. Pass the sweep pattern only when $CI is
set; run_launch_smoke_test already no-ops an empty pattern at both
sweep sites, so local runs fall back to the process-group kill.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-09 18:43:16 -04:00
github-actions[bot]
3a01379792 chore: update flake.lock 2026-06-08 03:23:35 +00:00
github-actions[bot]
d99cdbd0e0 Update Claude Desktop download URLs to version 1.11187.4
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-06 01:43:41 +00:00
Aaddrick
1b31a41835 Merge pull request #698 from johnkferguson/fix/cowork-fhs-bubblewrap
fix(nix): add bubblewrap to FHS targetPkgs so cowork uses the bwrap sandbox
2026-06-05 14:58:15 -04:00
John Ferguson
b94aec10d1 fix(nix): add bubblewrap to FHS targetPkgs for cowork sandbox
Without bwrap in the FHS env, the cowork daemon's detectBackend cannot find it and falls through to KvmBackend, which fails with rootfs not found on Linux. Adding bubblewrap lets detection select the bwrap namespace sandbox.
2026-06-05 13:36:20 -04:00
Jeremy Bethmont
2b332b99c9 docs(learnings): pin Wayland global-shortcut root cause to source + crbug
Now that the upstream cause is located in source and a Chromium bug is
filed, record the precise location in the learnings doc:
components/dbus/xdg/portal.cc PortalRegistrar::OnServiceChecked() skips
Registry.Register on kUnitStarted, while xdg-desktop-portal 1.21
(src/global-shortcuts.c, commit 38dd2c03f2) hard-requires an app id.
Link both upstream trackers (electron#51875, crbug 520262204) and note
the Chrome 149 / Chromium 151 HEAD confirmation.

Refs: #404
2026-06-05 23:57:50 +07:00
Jeremy Bethmont
9762043c84 refactor(launcher): make GNOME native+portal opt-in, not auto-flip
Address PR #690 review (@aaddrick): keep the zero-regression parts
(single merged --enable-features, GlobalShortcutsPortal in the
native-Wayland feature set, tri-state CLAUDE_USE_WAYLAND) but stop
auto-flipping GNOME Wayland to native Wayland.

Flipping the default GNOME session off mature XWayland is a
rendering/IME/HiDPI risk for a large slice of users, verified only by
argv inspection, and on GNOME 50 the portal route is a no-op upstream
(electron/electron#51875) — so those users would take the risk for no
benefit. detect_display_backend now only auto-forces Niri; GNOME users
opt into the portal route with CLAUDE_USE_WAYLAND=1 (works on GNOME
<=49 after the one-time portal dialog). Auto-selecting native Wayland
on GNOME is deferred to a follow-up gated on a real render check.

Also per review:
- tests/launcher-common.bats: GNOME-Wayland-stays-XWayland-by-default
  and GNOME+CLAUDE_USE_WAYLAND=1-opts-in cases; split the ordered
  merged-features glob into two order-independent has_electron_arg
  checks (@sabiut nit).
- S12 spec launches with CLAUDE_USE_WAYLAND=1 to exercise the opt-in
  portal path.
- configuration.md: soften the GlobalShortcuts-backend allowlist
  (per-compositor and uneven; COSMIC also has none) and lead with the
  GNOME 50 reality.

Refs: #404
2026-06-05 22:58:45 +07:00
github-actions[bot]
4b6ff35236 Update Claude Desktop download URLs to version 1.11187.1
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-05 01:48:42 +00:00
Artur Kaminski
6c09ca26b5 fix(deb): abort-proof the Cowork bwrap AppArmor profile write
Match the Electron profile block (#687): move mkdir + heredoc into an
if-condition so a read-only or atypical /etc cannot abort the install
under set -e. Without this the bwrap block could still fail the postinst
even though the Electron block above it is already guarded; a failed
write now warns and the install continues.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 17:29:53 +02:00
Artur Kaminski
eb3fec9cd2 docs(changelog): backfill PR number #694 2026-06-04 17:24:14 +02:00
Artur Kaminski
3cfe1812fc docs(troubleshooting): make Cowork bwrap section concise 2026-06-04 17:23:24 +02:00
Artur Kaminski
49acfd1ea0 docs(changelog): note Cowork bwrap AppArmor profile auto-install 2026-06-04 17:23:24 +02:00
Artur Kaminski
3e18776965 docs(troubleshooting): note deb auto-installs the Cowork bwrap profile 2026-06-04 17:23:24 +02:00
Artur Kaminski
fd72940516 docs(doctor): note the deb auto-installs the Cowork bwrap AppArmor profile 2026-06-04 17:23:24 +02:00
Artur Kaminski
407ba29add feat(deb): prerm removes the Cowork bwrap AppArmor profile on uninstall 2026-06-04 17:23:24 +02:00
Artur Kaminski
514bba919f feat(deb): auto-install scoped AppArmor userns profile for Cowork bwrap backend 2026-06-04 17:22:17 +02:00
Artur Kaminski
77f7d8dafe feat(deb): Recommend bubblewrap for default Cowork isolation 2026-06-04 17:22:17 +02:00
Artur Kaminski
af05e3d1dc fix(deb): harden AppArmor userns auto-install per PR #687 review
Address review feedback (aaddrick, sabiut) on the AppArmor userns
profile install, scoped to the existing change — no new functionality.

- postinst: abort-proof the profile write. Move mkdir + heredoc into an
  if-condition so a read-only/atypical /etc can no longer fail the
  install under set -e (every other op already uses || echo).
- postinst: gate on the apparmor_restrict_unprivileged_userns kernel
  knob, not just apparmor_parser. Non-Ubuntu systems that ship
  apparmor_parser but never impose the restriction now get no profile
  and no per-boot reload. Gate on knob existence (0 may flip to 1).
- prerm -> postrm: move profile cleanup so it also fires on purge and
  abort-install; upgrade still falls through (incoming postinst reloads).
- doctor: replace the file-existence PASS (false PASS on a staged-but-
  unloaded profile) with a real loaded-state check; stay silent unless
  the restriction is actually in force; drop .deb-specific advice in
  favour of hints pointing at docs/troubleshooting.md (also correct for
  AppImage/Nix users).
- doctor: judge the loaded set by an actual read, not [[ -r ]].
  securityfs marks the profiles file 0444 but denies the read without
  CAP_MAC_ADMIN, so -r passes for non-root yet the read returns empty —
  the mode-bit test would misreport a loaded profile as "not loaded".

Validated end-to-end on Ubuntu 24.04 (knob=1): build -> install
(profile loaded) -> doctor (all four branches, root and non-root) ->
remove/purge (profile unloaded + deleted) -> reinstall -> GUI launch
with no credentials.cc FATAL / exit-133 crash.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 17:15:17 +02:00
Sum Abiut
e81f1a000d test(launcher): cover cleanup_orphaned_cowork_daemon
The orphan-reaper — which SIGTERMs, then SIGKILLs, a cowork-vm-service
daemon left behind by a crashed UI — had no test coverage despite
SIGKILLing processes. Add four cases pinning each branch:

- no daemon running    -> returns early, no kill, no log
- live UI present      -> daemon left alone (real bg process stands in
                          for the UI so the /proc cmdline/status reads
                          resolve without faking /proc)
- orphan dies on TERM  -> single SIGTERM, no SIGKILL, correct log line
- orphan survives TERM -> escalates to SIGKILL, logs the SIGKILL variant

Negative assertions use run + status (a bare '! grep' that isn't the
last command does not fail a bats test). Calls go through run so the
function's '((_wait++))' (returns 1 when _wait starts at 0) doesn't trip
bats' errexit — production has no set -e, so that's a harness concern,
not a code defect. 79/79 launcher-common.bats pass.

Note: _find_virtiofsd, flagged alongside this in review, is already
covered in tests/cowork-bwrap-config.bats — no work needed there.
2026-06-04 23:55:28 +11:00
Sum Abiut
bd5c699f5a fix(doctor): stop false-green PASS on empty password store + bad disk read
Two diagnostics could emit a green [PASS] on data they failed to read:

- _doctor_check_password_store printed 'Password store: ' (blank) when
  _detect_password_store returned empty — e.g. a sourcing-order
  regression — instead of flagging it. Now warns on an empty backend.
- The disk-space check guarded only against an empty df result, not a
  non-numeric one; a malformed avail field fell through to the else
  branch as a PASS. Extracted it into _doctor_check_disk_space() with a
  ^[0-9]+$ numeric guard (mirroring _doctor_check_filename_limit), which
  also makes it unit-testable via a df shim.

Adds 6 doctor.bats cases pinning both: empty-backend warn, the <100/<500/
ample disk tiers, and no-PASS on non-numeric / unavailable df. 36/36 pass.
2026-06-04 23:35:06 +11:00
Sum Abiut
2f7c3e7148 test(artifacts): make deb arch assertion arch-aware
The deb test hardcoded 'Architecture: amd64', so the new arm64 job failed
on a correctly-built arm64 package (Architecture: arm64). Assert against
the target arch instead: TARGET_ARCH from the per-arch CI matrix, falling
back to `dpkg --print-architecture` for standalone/local runs. A genuine
arch mismatch still fails, so the check keeps its value.
2026-06-04 23:18:44 +11:00
Sum Abiut
c56130b906 ci(test-artifacts): validate arm64 packages, not just amd64
test-artifacts only ran against amd64; the build-arm64 deb/rpm/appimage
artifacts shipped with zero structural, doctor, or launch-smoke coverage,
so an arm64-only regression could reach a release unnoticed.

Parametrize the reusable workflow by arch (default amd64) and run the
arm64 matrix on a native ubuntu-22.04-arm runner — matching build-arm64 —
so the launch smoke test executes the real arm64 binary instead of
failing on a foreign architecture. ci.yml now invokes test-artifacts
twice (amd64 + arm64); the release gate waits on both.
2026-06-04 23:01:38 +11:00
Sum Abiut
230819fa23 test(artifacts): fix AppImage launch-smoke pkill match
The AppImage execs Electron from its FUSE mount (/tmp/.mount_claudeXXXX),
so the escaped zygote/electron children live under that mount — not under
the .AppImage artifact path. The pkill sweep was passed the artifact path,
which matched only the already-reaped top-level launcher and never the
strays it exists to catch, leaking electron children on the runner.

Pass 'mount_claude' instead, per CLAUDE.md's pkill guidance. deb/rpm are
unaffected (they match the real /usr/lib/claude-desktop exec path).
2026-06-04 23:01:38 +11:00
Jeremy Bethmont
21fe23d434 feat(launcher): route GNOME Wayland global shortcuts through XDG portal
Quick Entry's Ctrl+Alt+Space is focus-bound on modern GNOME Wayland:
mutter (GNOME >= 49) no longer honours XWayland-side global key grabs,
so the upstream globalShortcut.register() X11 grab only fires when
Claude already has focus (#404).

detect_display_backend now auto-forces GNOME Wayland to native Wayland
(joining Niri), and build_electron_args adds GlobalShortcutsPortal to
the native-Wayland feature set so globalShortcut routes through the XDG
GlobalShortcuts portal instead. CLAUDE_USE_WAYLAND becomes tri-state
(1=native, 0=force XWayland escape hatch, unset=auto-detect).

Chromium honours only the last --enable-features switch, so all feature
requests are now merged into a single comma-joined flag (previously up
to two separate switches could clobber each other).

This fixes #404 on GNOME <= 49. On GNOME 50 / xdg-desktop-portal >= 1.20
it is not yet sufficient: Electron/Chromium never performs the portal's
new host Registry.Register app-id handshake, so register() fails and the
portal is never contacted. Proven via D-Bus capture + a Python portal
client (which works once Registry.Register is called); filed upstream as
electron/electron#51875. See docs/learnings/wayland-global-shortcuts-portal.md.

Refs: #404
2026-06-04 16:58:37 +07:00
Artur Kaminski
88eb10d1c2 docs(changelog): point AppArmor userns fix at upstream PR #687
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 10:23:17 +02:00
Artur Kaminski
95dc1840e1 docs(changelog): link the AppArmor userns fix to PR #2
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 10:16:57 +02:00
Artur Kaminski
d53474ba50 fix(deb): auto-install AppArmor userns profile to stop Ubuntu 24.04+ launch crash
Ubuntu 24.04+ ships apparmor_restrict_unprivileged_userns=1, which blocks
the unprivileged user namespaces Chromium's sandbox needs. On the .deb
(X11), where the launcher intentionally keeps the sandbox enabled, the app
aborts on launch with FATAL:.../credentials.cc Check failed: Permission
denied (Trace/breakpoint trap, exit 133). The SUID chrome-sandbox helper is
not the cause — Chromium still spins up an unprivileged user-namespace
zygote that AppArmor denies.

Fix it the way the google-chrome, code, and slack debs do: grant userns to
our Electron binary via a scoped AppArmor profile. The deb's postinst
generates /etc/apparmor.d/<pkg> and loads it, but only when AppArmor can
parse the userns rule (4.0+) — older AppArmor and non-AppArmor systems skip
it, which is harmless since they don't impose the restriction. A new prerm
unloads and removes the profile on package removal (it is generated, not a
dpkg-tracked file). The Chromium sandbox stays enabled; --no-sandbox is not
used.

Also adds a "User namespaces" check to `claude-desktop --doctor` that flags
a missing profile when the restriction is active, and a troubleshooting
entry documenting the automatic behavior plus a manual fallback.

Verified on Ubuntu 24.04.4: shellcheck clean (incl. generated sh), profile
parses, postinst load / prerm remove / postinst restore lifecycle, and both
doctor probe branches.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 10:14:59 +02:00
aaddrick
056fef077e docs: release v2.0.18 — promote changelog, credit @MitchSchwartz, @LiukScot, @sabiut
Promotes the [Unreleased] tray fixes (#680) and adds entries for the
app.asar file-drop guard (#669) and the deb/rpm launch smoke test (#671).

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 01:34:46 -04:00
Aaddrick
6da192a6c2 Merge pull request #671 from sabiut/feature/670-deb-rpm-launch-smoke-test
test(artifacts): headless launch smoke test for deb and rpm
2026-06-04 01:32:27 -04:00
aaddrick
09b883628d test(artifacts): skip rpm launch smoke test when container denies the sandbox (#671)
The dep fix landed so Electron now launches in the Fedora CI container,
but the GHA container's default seccomp/userns policy blocks Chromium's
namespace sandbox: the zygote aborts with "Failed to move to new
namespace" / zygote_host_impl_linux before the readiness marker
(exit 133). That's an environment limit, not an app defect — the deb and
appimage validate jobs exercise the same app code on the Ubuntu runner
where the sandbox is permitted and pass.

Add _smoke_sandbox_denied to scan the captured launcher.log and xvfb
stderr for that signature. In the pre-marker failure path, if (and only
if) the signature is present, emit a loud skip via pass() instead of
fail(), matching how the missing-tools path already skips. Every other
pre-marker exit (SyntaxError / #666-class crash, missing lib, generic
non-zero) keeps the existing hard-fail behavior. Logs are still dumped
before the skip for visibility.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 01:24:37 -04:00
Aaddrick
9ed1bf0f51 Merge pull request #680 from LiukScot/fix/679-tray-startup-mutex
fix(tray): startup icon stuck black — make rebuild mutex trailing-edge (#679)
2026-06-04 01:13:23 -04:00
aaddrick
9505574cc3 Merge origin/main into fix/679-tray-startup-mutex
Resolve CHANGELOG.md conflict: keep PR #680's tray startup-icon fixes
under [Unreleased] -> Fixed, alongside main's released [v2.0.17] section.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 01:12:32 -04:00
aaddrick
5b55a98a7b ci(test-artifacts): install Electron runtime libs for rpm launch smoke test (#671)
The rpm launch smoke test boots Electron in a bare fedora:42 container and
died with `libnspr4.so: cannot open shared object file` (exit 127). The rpm
is installed with `rpm -ivh --nodeps` and its spec sets `AutoReqProv: no`,
so the package declares no runtime Requires and nothing pulls Electron's
shared libraries into the container. Install the nss/nspr/gtk3/X11/etc. set
explicitly in the Fedora dependency-install step.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 01:10:39 -04:00
Aaddrick
aa08302d45 Merge pull request #669 from MitchSchwartz/fix/668-asar-file-drop-guard
fix(patches): reject .asar paths in argv file-drop collector (#668)
2026-06-04 01:07:06 -04:00
aaddrick
5772cc1eb9 fix(patches): whitespace-tolerant verify + correct threat-model comment for #668 guard
The .asar file-drop guard's match regex already tolerated whitespace
around &&, but the bash idempotency grep, the node post-verify regex,
and the TSV marker pattern did not — so on beautified input they would
falsely report "not patched" and the verify step could fail. Add \s*
around && in all three. Also drop a dead `cd "$project_root"` that sat
before an unconditional exit 1, and rewrite the threat-model note: the
argv path IS reachable from user/file-manager launches (Exec=... %u),
so the exact-suffix .asar match is justified by the sink (attach-to-
draft, same as a manual drag), not by "app's own relaunch argv".

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 00:58:20 -04:00
aaddrick
2ad33d36c2 fix(tray): warn loudly when menu-function resolution fails (#680)
When both the inline setContextMenu grep and the menu_var fallback come
up empty, patch_tray_inplace_update previously emitted an info-level
"skipping" message. A silent skip here is how the #515 duplicate-icon
race regressed before. Upgrade the both-paths-empty case to a loud
WARNING on stderr so the next silent regression shows up in CI logs.
Graceful skip/return is preserved — the build still completes.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 00:57:59 -04:00
aaddrick
9dc431404f ci(test-artifacts): guard smoke-test tool presence; document rpm reaper + marker scope (#671)
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 00:57:54 -04:00
aaddrick
53dfe4a9dd docs: release v2.0.17 — promote changelog, credit @maplefater
Promote [Unreleased] → [v2.0.17] (2026-06-04, tracks upstream 1.10628.2)
for the addTrustedFolder anchor fix (#685), and add @maplefater to the
README acknowledgments.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 00:35:48 -04:00
Aaddrick
32ce566080 Merge pull request #685: fix addTrustedFolder anchor for upstream 1.10628.x
fix(config): re-anchor addTrustedFolder .asar guard on method declaration
2026-06-04 00:28:29 -04:00
aaddrick
0b281eb954 docs(changelog): add addTrustedFolder anchor fix to [Unreleased]
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 00:28:13 -04:00
luosihao
2ede75d049 fix(config): anchor addTrustedFolder guard on method declaration
The patch_asar_trusted_folder_guard step hard-failed on Claude Desktop
1.10628.0, aborting the whole build with "addTrustedFolder anchor not
found".

Upstream re-minification folded the log statement into the comma
expression `if(D.info(`...${i}`),await ZOe(i)===null){...}`, so the old
anchor — which assumed the log line ended in `${i}`);` (with a
semicolon) — no longer exists; the `);` is now `),`.

Re-anchor on the method declaration `async addTrustedFolder(i){` instead:
the method name is not minified and is unique in the bundle, and the
guard is injected at the function-body head (reject .asar on entry),
which is both more robust against future re-minification and
semantically earlier.

Verified: full patch suite re-runs cleanly, `node --check` passes on the
patched index.js/mainView.js/mainWindow.js, and the resulting AppImage
cold-starts without errors.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-04 11:23:54 +08:00
github-actions[bot]
8b4aa53449 Update Claude Desktop download URLs to version 1.10628.2
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-04 01:57:01 +00:00
github-actions[bot]
a77cec7578 Update Claude Desktop download URLs to version 1.10628.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-03 01:57:51 +00:00
LiukScot
a5b54dd3c7 docs(changelog): link PR #680
Co-Authored-By: Claude <claude@anthropic.com>
2026-06-02 14:36:32 +02:00
LiukScot
55bc328574 review(tray): robustify menu_func fallback to declarator shapes
Address reviewer feedback: the object-form menu_func fallback anchored on
a fixed [,;(] class, so a `let M=BUILDER()` / `const M=BUILDER()`
declaration (space before the var) would return empty and silently skip
the in-place fast-path. Use a word-boundary lookbehind (?<![$\w]) so the
builder resolves after a separator OR a declarator. Verified across
,;({ separators and let/const/var forms; still rejects longer
identifiers ending in the var name.

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-02 14:34:32 +02:00
LiukScot
e13e331745 fix(tray): correct startup icon colour dropped by leading-edge mutex
On a dark desktop, nativeTheme.shouldUseDarkColors reads false for the
first ~50ms then flips true via a burst of "updated" events. The tray
is created with the transient false (black icon) and the leading-edge
rebuild mutex (1500ms) drops the corrective true events, leaving the
icon stuck black on the dark panel until a manual theme toggle.

- Make the rebuild mutex trailing-edge: remember a request that arrives
  while a rebuild is in flight and re-run once when the window clears,
  so the final nativeTheme value wins.
- Remove the obsolete _trayStartTime>3e3 startup-suppression window,
  which gated the very "updated" -> rebuild call the correction needs.
- Restore the in-place setImage fast-path (#515): its menu_func
  extraction assumed setContextMenu(BUILDER()), but upstream now does
  M=BUILDER(); setContextMenu(M) — resolve the builder in both shapes.

Fixes #679

Co-Authored-By: Claude <claude@anthropic.com>
2026-06-02 14:27:52 +02:00
github-actions[bot]
4fd8d8ce5d chore: update flake.lock 2026-06-01 03:24:07 +00:00
Sum Abiut
f803af9efc test(artifacts): fix SC2015 in launch-smoke pkill sweep
CI treats any shellcheck finding (incl. info) as failure via xargs
exit 123. The pkill child-sweep used the A && B || C form (SC2015);
rewrite as an explicit if so C can't run when A is true. Behavior
unchanged — pkill's no-match exit is still swallowed by || true.
2026-05-30 08:03:49 +11:00
Sum Abiut
dd8d6e1dd6 test(artifacts): dedupe skip message, collapse rpm launch branch
Code-simplifier pass: factor the shared 'Skipping launch smoke test'
prefix into a local (also brings the line under 80 cols), and drop the
redundant if/else around run_launch_smoke_test in the rpm test — the
helper already treats an empty run_as as run-as-is, so a single call
with $smoke_user covers both the root and non-root paths.
2026-05-30 07:53:52 +11:00
Sum Abiut
1ae489dece test(artifacts): headless launch smoke test for deb and rpm (#670)
The deb and rpm artifact tests only did static file checks — they
installed the package and asserted files exist, but never launched
/usr/bin/claude-desktop. A startup-only regression like #666's Fedora
SyntaxError was invisible to CI on the two formats where it bit.

Generalize the AppImage launch smoke test (the #646 readiness-marker
poll) into a shared run_launch_smoke_test helper and apply it to all
three formats:

- test-artifact-common.sh: add run_launch_smoke_test +
  _launch_smoke_cleanup (Xvfb + dbus boot, 30s readiness-marker poll,
  process-group teardown, tool-absence skip).
- appimage: refactor to call the helper, removing the inline copy so
  the implementations can't drift.
- deb: launch as the non-root ubuntu-latest runner.
- rpm: drop to a throwaway unprivileged user — Electron aborts as root
  without --no-sandbox, which the launcher only adds on Wayland/deb, so
  the root container must drop privileges to exercise the real
  setuid-sandbox path.
- test-artifacts.yml: install Xvfb/dbus/util-linux/procps on the Fedora
  job; the deb job already had the Ubuntu equivalents (now used, not
  wasted).

Refs #670
2026-05-30 07:46:42 +11:00
Mitch
623f1b0373 fix(patches): reject .asar paths in argv file-drop collector (#668)
PR #640 guarded the isDirectory() path in the directory-check helper
so app.asar is no longer dispatched as a folder drop. However, the
argv collector function (lKr) has a separate existsSync branch:

  if (!i.startsWith("-") && FSVAR.existsSync(i)) { A.push(i); }

Electron's ASAR VFS shim makes existsSync() return true for .asar
archive paths, so app.asar passes this check and is dispatched to
cCA() as a file drop, triggering a permission prompt on every window
close+reopen from the taskbar (#668, regression of #383, #622).

The startup code path already excludes the app bundle via a path
equality check (tA.resolve(n) !== appPath), but the second-instance
handler passes argv directly to lKr() with no equivalent guard.

Fix: inject !PARAM.endsWith(".asar")&& before the existsSync call
in lKr(), blocking app.asar before it can reach the file-drop handler.

- patch_asar_argv_file_drop_guard() added to cowork.sh
- wired into patch_app_asar() after patch_asar_path_filter
- asar-file-drop-guard marker added to cowork-patch-markers.tsv

Verified against extracted index.js from v2.0.16 (1.9255.2).
Anchor is unique (1 match), idempotency check is context-anchored
to startsWith("-") to avoid false-positives from other .asar guards.

Fixes #668
Related: #383, #622, #640

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-29 15:47:39 -04:00
github-actions[bot]
2ae2172a60 Update Claude Desktop download URLs to version 1.9659.2
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-29 01:44:58 +00:00
github-actions[bot]
5dd948e96d Update Claude Desktop download URLs to version 1.9255.2
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-28 01:42:51 +00:00
aaddrick
5513f1b867 docs(changelog): promote [Unreleased] to [v2.0.16] — 2026-05-27
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 16:37:07 -04:00
Aaddrick
7dc26eae9b Merge pull request #660 from aaddrick/claude/issue-659-EI5EH
fix(patches): capture $-prefixed minified names in cowork spawn guard
2026-05-27 16:22:39 -04:00
Claude
2ed019405f fix(patches): capture $-prefixed minified names in cowork spawn guard
The funcNameRe regex used \w+ which excludes $, so $-prefixed minified
function names like $Be silently failed to match. This left
retryFuncName null, falling back to the bare identifier
_globalLastSpawn — which throws ReferenceError on first read before
any assignment.

Two fixes:
- Widen regex character class to [$\w]+ so $-prefixed names match
- Change fallback from bare _globalLastSpawn to globalThis._lastSpawn,
  a safe property access that returns undefined instead of throwing

Fixes #659

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 15:22:17 +00:00
aaddrick
5b2fb4141b docs(changelog): add tray fix to v2.0.15
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-27 02:04:35 -04:00
Aaddrick
988a866310 fix(patches): anchor tray variable extraction on .Tray() literal (#657)
fix(patches): anchor tray variable extraction on .Tray() literal
2026-05-27 02:04:09 -04:00
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
Justin Leopard
bc3580c23e Add AppStream metainfo so the package appears in App Center / GNOME Software
GNOME Software, Ubuntu App Center, and KDE Discover all index installed
applications via /usr/share/metainfo/*.metainfo.xml. Without that file,
claude-desktop installs cleanly via apt/dnf but doesn't show up under the
"Installed" tab of any of these stores — users have to dig through the
app grid to find it.

This adds a minimal but correct metainfo XML (com.anthropic.Claude.metainfo.xml)
and wires the deb and rpm packagers to install it.

What's covered:
- Component id in proper reverse-DNS form (com.anthropic.Claude)
- Launchable links to the existing claude-desktop.desktop entry
- Name, summary, multi-paragraph description with feature bullets
- Homepage / bugtracker / help / vcs-browser URLs
- Developer attribution noting community packaging
- Categories (Office, Chat)
- OARS content rating
- Validated with `appstreamcli validate` — clean (2 pedantic notes only)

After install, `appstreamcli get com.anthropic.Claude` returns the expected
entry and App Center / GNOME Software / Discover show the app under
Installed with proper icon, name, and description.

Note: deb.sh installs to $install_dir/share/metainfo/ alongside the
existing share/applications/ desktop entry; rpm.sh stages into the
staging dir and the spec's %install + %files blocks were extended.
2026-05-22 13:32:39 -04:00
51 changed files with 4139 additions and 884 deletions

View File

@@ -54,9 +54,11 @@ jobs:
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
test-artifacts:
name: Test Build Artifacts
name: Test Build Artifacts (amd64)
needs: [build-amd64]
uses: ./.github/workflows/test-artifacts.yml
with:
arch: amd64
build-arm64:
name: Build Packages (arm64 - ${{ matrix.artifact_suffix }})
@@ -82,10 +84,17 @@ jobs:
artifact_suffix: ${{ matrix.artifact_suffix }}
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
test-artifacts-arm64:
name: Test Build Artifacts (arm64)
needs: [build-arm64]
uses: ./.github/workflows/test-artifacts.yml
with:
arch: arm64
release:
name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [test-flags, build-amd64, build-arm64, test-artifacts]
needs: [test-flags, build-amd64, build-arm64, test-artifacts, test-artifacts-arm64]
runs-on: ubuntu-latest
permissions:
contents: write

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

@@ -2,6 +2,11 @@ name: Test Build Artifacts (Reusable)
on:
workflow_call:
inputs:
arch:
description: Architecture of the artifacts under test (amd64/arm64)
type: string
default: amd64
permissions:
contents: read
@@ -13,17 +18,17 @@ jobs:
matrix:
include:
- format: deb
artifact: package-amd64-deb
container: ""
- format: rpm
artifact: package-amd64-rpm
container: "fedora:42"
- format: appimage
artifact: package-amd64-appimage
container: ""
name: Validate ${{ matrix.format }} package
runs-on: ubuntu-latest
name: Validate ${{ inputs.arch }} ${{ matrix.format }} package
# arm64 artifacts run on a native arm64 runner (matching build-arm64)
# so the launch smoke test actually executes the packaged binary
# rather than failing on a foreign architecture.
runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-latest' }}
container: ${{ matrix.container || '' }}
steps:
@@ -33,12 +38,24 @@ jobs:
- name: Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ${{ matrix.artifact }}
name: package-${{ inputs.arch }}-${{ matrix.format }}
path: artifacts/
- name: Install test dependencies (Fedora)
if: matrix.format == 'rpm'
run: dnf install -y findutils file nodejs npm
# Electron's shared libraries (nss/nspr/gtk3/X11/etc.) must be
# installed explicitly: the rpm is installed with `rpm -ivh --nodeps`
# and its spec sets `AutoReqProv: no`, so the package declares no
# runtime Requires and nothing pulls these in. Without them the
# launch smoke test dies with `libnspr4.so: cannot open shared
# object file` (exit 127). The Ubuntu runner already carries them.
run: |
dnf install -y findutils file nodejs npm \
xorg-x11-server-Xvfb dbus-daemon util-linux procps-ng \
nss nspr atk at-spi2-atk at-spi2-core cups-libs gtk3 \
libdrm mesa-libgbm alsa-lib libX11 libXcomposite libXdamage \
libXext libXfixes libXrandr libxcb libxkbcommon pango cairo \
libXScrnSaver libXtst libxshmfence
- name: Install test dependencies (Ubuntu)
if: matrix.format != 'rpm'
@@ -47,7 +64,26 @@ jobs:
sudo apt-get install -y file libfuse2 nodejs npm \
xvfb dbus-x11 procps
# Fail loud if a smoke-test tool is missing. Without this guard a
# missing/renamed tool turns run_launch_smoke_test into a silent
# green skip (it does `pass "$skip"; return`), masking the test.
- name: Verify smoke-test tools are present (Ubuntu)
if: matrix.format != 'rpm'
run: |
for t in xvfb-run dbus-run-session setsid; do
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
done
- name: Verify smoke-test tools are present (Fedora)
if: matrix.format == 'rpm'
run: |
for t in xvfb-run dbus-run-session setsid runuser; do
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
done
- name: Run artifact tests
env:
TARGET_ARCH: ${{ inputs.arch }}
run: |
chmod +x tests/test-artifact-${{ matrix.format }}.sh
tests/test-artifact-${{ matrix.format }}.sh artifacts/

16
.gitignore vendored
View File

@@ -40,3 +40,19 @@ result-*
# Wrangler (Cloudflare Worker dev/deploy cache)
worker/.wrangler/
# Graphify outputs and temporary files
graphify-out/
.graphify_*
# Local agent/editor state and helper bins
.agents/
.codex/
.tmpbin/
# Local package artifacts
*.rpm
# Root-level scratch extracts from app inspection
/frame-fix-wrapper.js
/index.js

View File

@@ -6,21 +6,138 @@ 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.22] — 2026-06-25
- `--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.15200.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))
- The Cowork tab is no longer grayed out on Linux with a *"Cowork requires a newer installation — Reinstall the desktop app"* tooltip. Upstream 1.13576+ gates the tab's visibility on the yukonSilver support *evaluator* (`$oe`/`q4r`, the Windows capability probe), which returns `msix_required` on Linux — a separate consumer from the `startVM` execution gate that [#736](https://github.com/aaddrick/claude-desktop-debian/pull/736) re-derived. The evaluator now reports `supported` on Linux so the renderer un-grays the tab (the bwrap daemon was already healthy underneath), while the VM-image download drivers it also feeds are re-blocked so they don't pull the multi-GB `rootfs.vhdx` bundle that is intentionally disabled on Linux — cowork runs through the bwrap sandbox, not a downloaded VM. ([#743](https://github.com/aaddrick/claude-desktop-debian/pull/743), #736 follow-up)
- Claude Desktop no longer hangs at startup on Linux with no window ever appearing (a regression introduced by upstream 1.13576+). The bundle calls the Windows-only `@ant/claude-native` methods `readRegistryValues()` and `getWindowsElevationType()` unconditionally during its enterprise-policy lookup, guarding only the native module being null and not the method being absent — so the Linux stub threw `"<method> is not a function"` at top-level execution, which the early empty `uncaughtException` handler swallowed, leaving the process alive but windowless. The Linux native stub now provides neutral no-ops for these Windows-only registry / MSIX / UAC methods. ([#729](https://github.com/aaddrick/claude-desktop-debian/issues/729))
- Cowork Linux patches apply again on Claude Desktop 1.13576+ — the build's "Verify cowork patches in shipped asar" step had started failing with 9/11 markers missing. Upstream re-architected the cowork/VM subsystem ("yukonSilver") between 1.12603.1 and 1.13576.0: the platform gate moved from a `darwin`/`win32` check into `startVM`'s `yukonSilver.status` feature-flag check, the vmClient module load moved behind the isMsix detector, and `sharedCwdPath`/`mountConda` were removed. Patch 1 (which anchored on the gone check) `process.exit(1)`'d, which killed the whole node block and dropped every subsequent cowork patch. Patches 1, 2 and the daemon auto-launch anchor were re-derived against the new bundle, the smol-bin idempotency guard was fixed (it false-matched upstream's own log), the obsolete `sharedCwdPath` threading (Patch 12) was retired in favor of the daemon's mountMap fallback, and the Linux smol-bin copy patch gained a verification marker. ([#736](https://github.com/aaddrick/claude-desktop-debian/pull/736))
- Builds (deb, RPM, AppImage, nix) no longer abort in the patch phase with `FATAL: --add-dir pattern matches 2 times (expected 1)`. Upstream Claude Desktop 1.12603.1 ships two identical `--add-dir` dispatch loops, but the `.asar` filter patch ([#650](https://github.com/aaddrick/claude-desktop-debian/pull/650)) asserted exactly one. The patch now filters every matching dispatch loop instead of bailing on a duplicate, and stays idempotent on re-runs. ([#718](https://github.com/aaddrick/claude-desktop-debian/issues/718))
- `claude-desktop --doctor` reports the installed version from the package manager that actually owns the install (probed via `rpm -qf` on the bundled Electron binary) instead of trusting `dpkg-query` alone — rpm installs on hosts that also carry a stale dpkg record (e.g. Fedora boxes with dpkg installed as a build tool) no longer show a months-old version with a PASS. ([#712](https://github.com/aaddrick/claude-desktop-debian/pull/712), fixes [#711](https://github.com/aaddrick/claude-desktop-debian/issues/711))
## [v2.0.19] — 2026-06-10
Tracks upstream Claude Desktop 1.11847.5.
### Added
- AppStream metainfo (`io.github.aaddrick.claude-desktop-debian.metainfo.xml`) installed by the deb, RPM, and AppImage builds, so the package appears in GNOME Software, KDE Discover, and App Center with correct unofficial-repackaging branding and a `LicenseRef-proprietary` project license. Store search for not-yet-installed users needs repo-side DEP-11/appstream metadata, tracked in [#708](https://github.com/aaddrick/claude-desktop-debian/issues/708). ([#633](https://github.com/aaddrick/claude-desktop-debian/pull/633))
- GPU crash auto-recovery in the launcher: when the previous launch died to a Chromium GPU-process FATAL (the [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583) SIGTRAP signature), the next launch automatically applies safe GPU flags — and stays recovered on subsequent launches instead of oscillating crash/work/crash. Detects NixOS launcher log headers too; set `CLAUDE_DISABLE_GPU=0` to override. ([#666](https://github.com/aaddrick/claude-desktop-debian/pull/666))
### Fixed
- `claude-desktop --doctor` no longer reports a false-green PASS when the password store reads back empty or when `df` returns a non-numeric disk reading — bad reads now fail or print a visible skip instead of falling through to the PASS branch, and leading-zero `df` output can no longer slip past as octal arithmetic. ([#692](https://github.com/aaddrick/claude-desktop-debian/pull/692))
- Explicit quit now keeps the launcher alive until Electron exits, then runs
stale-helper cleanup for Desktop-owned Cowork, Claude config, and extension
helpers. Close-to-tray still leaves the app and helpers running.
([#682](https://github.com/aaddrick/claude-desktop-debian/pull/682))
- All launchers (deb, RPM, AppImage, nix) no longer pass `app.asar` as an Electron
argument. Electron auto-loads `app.asar` from its default `resources/` dir next to the
binary, so the extra argv entry was redundant — and the app treated it as a
file-to-open, surfacing a spurious "Attach app.asar?" prompt on launch and on every
taskbar reopen. This removes the path at the source, complementing the renderer-side
`.asar` guards in [#669](https://github.com/aaddrick/claude-desktop-debian/pull/669)
and surviving upstream re-minification. Live-UI detection in the launcher and doctor,
which fingerprinted on the now-removed argv, was updated alongside.
([#700](https://github.com/aaddrick/claude-desktop-debian/pull/700),
fixes [#696](https://github.com/aaddrick/claude-desktop-debian/issues/696))
- Cowork's VM daemon never auto-launched on packages built under a restrictive umask (CI builds with umask `022`, so released artifacts were unaffected; local builds with e.g. `umask 077` were) because the bundled `app.asar.unpacked/` directory shipped as mode `0700` owned by the build uid, so the desktop user running the app couldn't traverse it and the auto-launch `fs.existsSync()` fork guard silently returned `false` (symptom: endless `connect ENOENT …/cowork-vm-service.sock`, no `cowork_vm_daemon.log`, no `[cowork-autolaunch]` line). `deb.sh` now normalizes the installed tree to canonical permissions (directories and executables `755`, other files `644`) and builds with `dpkg-deb --root-owner-group` for `root:root` ownership; `appimage.sh` applies the same normalization to the AppDir before `mksquashfs` (it copies with `cp -a`, which preserved the bad modes); and `rpm.sh` normalizes file modes in `%install``%defattr(-, root, root, 0755)` forces directory modes in the payload, but its `-` first field preserves file modes from the `cp -r`-populated buildroot, so a restrictive-umask RPM build shipped an unreadable `app.asar` and a non-executable electron binary.
- Claude Desktop no longer crashes on launch on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces Chromium's sandbox needs (`sandbox/linux/services/credentials.cc` FATAL, `Trace/breakpoint trap`, exit 133). The `.deb` `postinst` now installs a scoped AppArmor profile granting `userns` to the bundled Electron binary — mirroring the `google-chrome`/`code`/`slack` packages — and removes it again on uninstall. The Chromium sandbox stays enabled (no `--no-sandbox`). `claude-desktop --doctor` gained a **User namespaces** check that flags a missing profile. ([#687](https://github.com/aaddrick/claude-desktop-debian/pull/687))
- Cowork mode no longer silently falls back to host-direct (no isolation) on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces its bubblewrap sandbox needs. The `.deb` `postinst` now installs a second scoped AppArmor profile granting `userns` to `/usr/bin/bwrap` (distinct from the Electron profile above), automating the manual workaround from [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351) (contributed by [@hfyeh](https://github.com/hfyeh)). The profile is gated on the kernel's `apparmor_restrict_unprivileged_userns` knob and defers to any profile already attaching to `/usr/bin/bwrap` (a hand-made `/etc/apparmor.d/bwrap`, `apparmor-profiles`' `bwrap-userns-restrict`); put local overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` — they survive upgrades. `bubblewrap` is now a `Recommends`. ([#694](https://github.com/aaddrick/claude-desktop-debian/pull/694))
### Changed
- CI now validates the arm64 deb, RPM, and AppImage artifacts on native `ubuntu-22.04-arm` runners (previously only amd64 was tested), and the AppImage launch smoke test's process sweep is keyed to `mount_claude` and gated behind `$CI` so a local test run can't kill a developer's live Claude Desktop session. The launcher's orphaned-daemon reaper also gained mutation-tested BATS coverage. ([#691](https://github.com/aaddrick/claude-desktop-debian/pull/691), [#693](https://github.com/aaddrick/claude-desktop-debian/pull/693))
- The native-Wayland launch path now routes Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal: `GlobalShortcutsPortal` is added to the `--enable-features` set, and all Chromium feature requests are merged into a single `--enable-features=` switch (Chromium honours only the last one, so the previous code could silently clobber features). GNOME Wayland users can opt into the portal route with `CLAUDE_USE_WAYLAND=1`, which works on GNOME ≤ 49 after a one-time portal permission dialog and fixes the focus-bound hotkey from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404). The default GNOME session stays on XWayland (no rendering/IME regression risk); auto-selecting native Wayland on GNOME is deferred until it can be gated on a real render check. **On GNOME 50 / xdg-desktop-portal ≥ 1.20 the portal route is currently a no-op** — Electron/Chromium doesn't perform the portal's new host `Registry.Register` app-id handshake (filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875)). `CLAUDE_USE_WAYLAND` is now tri-state: `1` native Wayland, `0` force XWayland, unset auto-detects. ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404))
## [v2.0.18] — 2026-06-04
Tracks upstream Claude Desktop 1.10628.2.
### Fixed
- Tray icon no longer stuck black at startup on dark desktops. `nativeTheme.shouldUseDarkColors` reads `false` for the first ~50 ms then flips `true`, but the leading-edge rebuild mutex latched the transient `false` and dropped the corrective `"updated"` events; the mutex is now trailing-edge (re-applies the final value) and the obsolete 3 s startup-suppression window was removed. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680), fixes [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679))
- Restored the in-place tray `setImage` fast-path ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515)), which silently stopped applying after upstream changed the context-menu wiring from `setContextMenu(BUILDER())` to a prebuilt `setContextMenu(MENU)` object — `patch_tray_inplace_update` now resolves the builder in both shapes, so the duplicate-icon SNI race no longer regresses. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680))
- File-drop collector no longer re-attaches the app's own `app.asar` on every taskbar reopen. Electron's ASAR VFS shim returns `true` from `existsSync()` for `.asar` paths, so the second-instance argv collector dispatched `app.asar` to the file-drop handler and surfaced an attach prompt on each relaunch; it now rejects `.asar` paths, mirroring the existing `statSync` guard. ([#669](https://github.com/aaddrick/claude-desktop-debian/pull/669), fixes [#668](https://github.com/aaddrick/claude-desktop-debian/issues/668))
### Changed
- CI now runs a headless launch smoke test for the deb and rpm artifacts — previously only the AppImage actually booted, so a startup-only regression (e.g. the Fedora `SyntaxError`) could stay green on the formats it broke. A shared `run_launch_smoke_test` helper covers all three formats and gracefully skips when a container forbids Chromium's sandbox. ([#671](https://github.com/aaddrick/claude-desktop-debian/pull/671), closes [#670](https://github.com/aaddrick/claude-desktop-debian/issues/670))
## [v2.0.17] — 2026-06-04
Tracks upstream Claude Desktop 1.10628.2.
### Fixed
- `addTrustedFolder` `.asar` guard re-anchored on the `async addTrustedFolder(…)` method declaration. Upstream Claude Desktop 1.10628.x folded the `LocalAgentModeSessions.addTrustedFolder: ${i}` log call into a comma-expression inside an `if`, removing the trailing `` `); `` the old anchor matched — `./build.sh` aborted with `[FAIL] addTrustedFolder anchor not found`. Both the parameter extraction and the injection point now key off the unminified method name, so they can't drift apart if upstream drops the log line. ([#685](https://github.com/aaddrick/claude-desktop-debian/pull/685))
## [v2.0.16] — 2026-05-27
Tracks upstream Claude Desktop 1.9255.0.
### Fixed
- Cowork spawn guard now captures `$`-prefixed minified function names (e.g. `$Be`) and uses `globalThis._lastSpawn` instead of a bare `_globalLastSpawn` identifier, fixing `ReferenceError: _globalLastSpawn is not defined` that broke Cowork on all platforms with upstream 1.9255.0. ([#660](https://github.com/aaddrick/claude-desktop-debian/pull/660), fixes [#658](https://github.com/aaddrick/claude-desktop-debian/issues/658), [#659](https://github.com/aaddrick/claude-desktop-debian/issues/659), [#661](https://github.com/aaddrick/claude-desktop-debian/issues/661))
## [v2.0.15] — 2026-05-27
Tracks upstream Claude Desktop 1.9255.0.
### Fixed
- `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))
- Tray variable extraction re-anchored on `.Tray()` literal instead of minifier-dependent syntax that upstream 1.9255.0 reshuffled. ([#657](https://github.com/aaddrick/claude-desktop-debian/pull/657), fixes [#656](https://github.com/aaddrick/claude-desktop-debian/issues/656))
## [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 +342,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

@@ -38,6 +38,7 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
- [`wayland-global-shortcuts-portal.md`](docs/learnings/wayland-global-shortcuts-portal.md) — why Quick Entry's hotkey is focus-bound on GNOME Wayland (mutter dropped XWayland global key grabs), the native-Wayland + `GlobalShortcutsPortal` launcher change (opt-in via `CLAUDE_USE_WAYLAND=1`; fixes GNOME ≤49, default GNOME stays on XWayland), the "only the last `--enable-features` switch wins → merge into one flag" trap, the tri-state `CLAUDE_USE_WAYLAND` escape hatch, and the proof that GNOME 50 / xdg-desktop-portal ≥1.20 is still blocked upstream because Electron/Chromium never calls the host `Registry.Register` app-id handshake ([electron#51875](https://github.com/electron/electron/issues/51875)); wlroots (Niri/Sway/Hyprland) lack a portal GlobalShortcuts backend entirely
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
## Code Style

View File

@@ -273,9 +273,32 @@ 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)
- **[maplefater](https://github.com/maplefater)** for re-anchoring the `addTrustedFolder` `.asar` guard on the `async addTrustedFolder(…)` method declaration after upstream 1.10628.x folded the log call into a comma-expression, keying both the parameter extraction and the injection point off the unminified method name so they can't drift apart (#685)
- **[MitchSchwartz](https://github.com/MitchSchwartz)** for finding the second `app.asar` file-drop path — the `existsSync()` branch in the second-instance argv collector that #640 never guarded — and rejecting `.asar` paths there so the app no longer prompts to attach its own bundle on every taskbar reopen (#669, #668)
- **[LiukScot](https://github.com/LiukScot)** for making the tray rebuild mutex trailing-edge so the startup dark-theme icon no longer latches black, and restoring the in-place `setImage` fast-path after upstream changed the context-menu wiring to a prebuilt menu object (#680, #679)
- **[sabiut](https://github.com/sabiut)**
- BATS coverage for `cleanup_orphaned_cowork_daemon`, mutation-tested so the kill/escalation branches genuinely bite (#693)
- Fixing two false-green `--doctor` PASSes: an empty password store read as healthy, and a non-numeric `df` reading falling through to the PASS branch (#692)
- Extending the artifact launch smoke tests to arm64 on native `ubuntu-22.04-arm` runners, and re-keying the AppImage pkill sweep to `mount_claude` so escaped zygote/electron children stop leaking on the runner (#691)
- **[jerem](https://github.com/jerem)** for routing Quick Entry's global shortcut through the XDG GlobalShortcuts portal on native Wayland, and merging all Chromium feature requests into a single `--enable-features=` switch — the old code silently clobbered `WindowControlsOverlay` (#690, #404)
- **[caidejager](https://github.com/caidejager)** for diagnosing why Cowork's VM daemon never auto-launched on packages built under a restrictive umask — `app.asar.unpacked/` shipped mode `0700`, failing the auto-launch `existsSync()` guard — and normalizing install permissions across deb and AppImage, with `dpkg-deb --root-owner-group` closing a build-uid write exposure (#695)
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for the AppStream metainfo that surfaces the package in GNOME Software, KDE Discover, and App Center, wired into the deb, rpm, and AppImage builds (#633)
- **[DhanushSantosh](https://github.com/DhanushSantosh)** for the GPU crash auto-recovery in the launcher: detecting a previous GPU-process FATAL in the launcher log and re-launching with safe GPU flags automatically, instead of leaving users to discover `CLAUDE_DISABLE_GPU=1` by hand (#666)
- **[diarized](https://github.com/diarized)** for auto-installing scoped AppArmor userns profiles from the `.deb` postinst on Ubuntu 24.04+ — one for the bundled Electron binary (fixing the launch crash without `--no-sandbox`) and one for `/usr/bin/bwrap` (keeping Cowork's sandbox isolated instead of silently falling back to host-direct), automating the workaround from #351 (#687, #694)
- **[emandel82](https://github.com/emandel82)** for root-causing the "Attach app.asar?" prompt: every launcher passed `app.asar` as a redundant Electron argument, which the second-instance argv collector treated as a file to open — removed at the source across all four package formats (#700, #696)
- **[svankirk](https://github.com/svankirk)** for cleaning up Desktop helper processes after an explicit quit — a quit wrapper with signal forwarding and a bundle-keyed live-UI check, so closing the app no longer strands helper processes (#682)
- **[pjordanandrsn](https://github.com/pjordanandrsn)** for re-deriving the cowork Linux patch suite against the upstream "yukonSilver" VM refactor (1.13576+) — re-anchoring the platform gate on `startVM`'s `yukonSilver.status` check after Patch 1's removed `darwin`/`win32` anchor started `process.exit(1)`'ing and dropping every subsequent cowork patch, fixing the build's "Verify cowork patches in shipped asar" gate (#736)
- **[chrisw1005](https://github.com/chrisw1005)** for root-causing the Linux startup hang on Claude Desktop 1.13576+ — the unconditional `@ant/claude-native.readRegistryValues()` / `getWindowsElevationType()` enterprise-policy calls throwing a swallowed missing-method `TypeError` before window creation — via probe injection, and the complete Windows-only native stub fix (#729)
- **[colonelpanic8](https://github.com/colonelpanic8)** for independently reproducing the same 1.13576+ startup hang and contributing BATS coverage for the Linux native stub (#730)
## 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

View File

@@ -13,24 +13,39 @@ Model Context Protocol settings are stored in:
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
| `CLAUDE_USE_WAYLAND` | unset (auto) | Force the display backend on Wayland: `1` = native Wayland, `0` = XWayland. Unset auto-detects per compositor (only Niri defaults to native Wayland). See [Wayland Support](#wayland-support) below. |
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
| `CLAUDE_TITLEBAR_STYLE` | unset (`hybrid`) | Controls window decoration style: `hybrid` (system frame + in-app topbar), `native` (system frame, no in-app topbar), `hidden` (frameless WCO — broken on X11, kept for diagnostics). See [Titlebar Style](#titlebar-style) below. |
| `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. |
### Wayland Support
By default, Claude Desktop uses X11 mode (via XWayland) on Wayland sessions to ensure global hotkeys work. If you prefer native Wayland and don't need global hotkeys:
On Wayland sessions the launcher picks a display backend per compositor:
| Compositor | Backend | Why |
|------------|---------|-----|
| Niri | native Wayland (auto) | no XWayland support at all |
| Everything else (GNOME, KDE, Sway, Hyprland, COSMIC, …) | XWayland (auto) | XWayland global key grabs still work on most; mature path, broadest compatibility |
By default only Niri is auto-selected for native Wayland. GNOME Wayland stays on XWayland by default even though mutter no longer honours XWayland global key grabs ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)) — flipping the default GNOME session off XWayland is a rendering/IME/HiDPI risk, so it's left opt-in for now.
To route Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal on GNOME, opt into native Wayland with `CLAUDE_USE_WAYLAND=1`. On **GNOME ≤ 49** this works after a one-time portal permission dialog (accept it to bind the shortcut). On **GNOME 50 / xdg-desktop-portal ≥ 1.20 it does not work yet**: the newer portal requires apps to declare identity via `org.freedesktop.host.portal.Registry.Register`, which Electron/Chromium doesn't do, so `globalShortcut.register()` fails and the shortcut stays focus-bound. Tracked upstream at [electron/electron#51875](https://github.com/electron/electron/issues/51875).
Override the auto-detection with `CLAUDE_USE_WAYLAND`:
```bash
# One-time launch
# Force native Wayland (GNOME portal route, or Sway/Hyprland)
CLAUDE_USE_WAYLAND=1 claude-desktop
# Or add to your environment permanently
# Force XWayland (e.g. to override Niri's auto-native, or if native
# Wayland regresses rendering)
CLAUDE_USE_WAYLAND=0 claude-desktop
# Or persist either choice
export CLAUDE_USE_WAYLAND=1
```
**Important:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal. If global hotkeys (Ctrl+Alt+Space) are important to your workflow, keep the default X11 mode.
**Note:** portal-routed global shortcuts only work where the compositor's portal backend implements `org.freedesktop.portal.GlobalShortcuts`. Support is per-compositor and currently uneven — GNOME and KDE implement it (though the app-id requirement above — enforced for GlobalShortcuts since xdg-desktop-portal 1.21 — applies to all desktops, KDE included); wlroots compositors (Sway, Hyprland, Niri) and COSMIC currently ship no GlobalShortcuts backend, so the portal route is a no-op there until their portal gains one.
### Menu Bar

View File

@@ -119,6 +119,51 @@ Interpreting the log after a failure:
| `lifecycle uncaughtException ...` | JS-level crash, stack is in the log entry |
| `lifecycle SIGTERM received` + `lifecycle exit code=0` | Clean app-initiated shutdown |
| No `startup` entry at all | `fork()` didn't complete; check launcher.log for `[cowork-autolaunch]` errors |
| No `cowork_vm_daemon.log` file at all **and** no `[cowork-autolaunch]` line | The auto-launch `fs.existsSync()` guard returned false — `app.asar.unpacked/` isn't traversable by the running user. Packaging perms bug; see [below](#packaging--appasarunpacked-must-be-traversable-by-the-run-time-user). |
## Packaging — `app.asar.unpacked/` must be traversable by the run-time user
The auto-launch fork is guarded by an existence check:
```javascript
const _d = _p.join(process.resourcesPath, "app.asar.unpacked",
"cowork-vm-service.js");
if (_fs.existsSync(_d)) { /* fork daemon */ }
```
`fs.existsSync()` returns **false** when the directory can't be
traversed, not only when the file is genuinely absent — and there is no
`else`/`catch`, so the fork is skipped with zero log output. If the
packaged `app.asar.unpacked/` ships as mode `0700` owned by the build
uid (a restrictive build umask, plus `dpkg-deb` recording ownership
verbatim when not run under fakeroot or `--root-owner-group`), the
desktop user — a *different* uid — can't enter it. `existsSync` is
false, the daemon never forks, and the client loops forever on `connect
ENOENT`. The tell is that **both** the daemon log file and the
`[cowork-autolaunch]` error line are absent: nothing was even attempted.
Confirm what the run-time user actually sees, not what root sees:
```bash
svc=.../app.asar.unpacked/cowork-vm-service.js
test -r "$svc" && echo OK || echo BLOCKED # run as the desktop user
stat -c '%A %U:%G' "$(dirname "$svc")" # 0700 + foreign uid == broken
```
Fixed at the packaging boundary (not in the app code): `deb.sh` and
`appimage.sh` normalize the staged tree to canonical modes (directories
and executables `755`, other files `644`) before building, and the deb
is built with `dpkg-deb --root-owner-group` so ownership is `root:root`.
RPM has the same exposure through *file* modes: `%defattr(-, root,
root, 0755)` forces directory modes in the payload, but the `-` in its
first field preserves file modes verbatim from the buildroot, which
`%install` populates with plain `cp -r` — so a `umask 077` build ships
an unreadable `app.asar` and a non-executable electron binary (louder
symptom: EACCES, since the forced `0755` keeps directories
traversable). `rpm.sh` therefore normalizes file modes in `%install`
too. To unstick an already-installed package without rebuilding:
`sudo chmod -R o+rX /usr/lib/claude-desktop` (preserves the setuid
`chrome-sandbox`).
## Key Files

View File

@@ -287,6 +287,49 @@ Four layers: build log, syntactic validity, asar markers, runtime.
listening`; socket should exist and be owned by the
`cowork-vm-service.js` process listed by `ss`.
## One gate, multiple consumers: a marker can't catch a re-armed sibling
A single minified predicate is often read by several independent code
paths. Patching it at the source flips *all* of them — some you want,
some you don't — and a marker-based check won't catch the ones you
didn't, because nothing is *missing*; the regression is behavioral.
The yukonSilver cowork gate (1.13576+) is the case study. The support
evaluator `$oe()`/`q4r()` returns `{status:"supported"|"unsupported"}`,
and at least four call sites read it: `startVM` (execution gate), the
renderer (the Cowork tab's grayed-out / "reinstall" state), the
download driver `u8A`, and the warm prefetch `mzn`. The tab was grayed
out on Linux because the evaluator reported `unsupported` (the win32
`q4r` probe hits `msix_required`). Flipping it to `supported` for Linux
(`cowork.sh` Patch 1b) un-grayed the tab — and simultaneously re-armed
the multi-GB `rootfs.vhdx` VM download that #337/`a3190c3` had disabled,
because the two download consumers read the *same* evaluator.
`verify-patches.sh` was green throughout: Patch 1b's marker was present,
and there is no "download must stay off" marker to go red. The only
thing that surfaced it was launching the build and watching
`cowork_vm_node.log` (`rootfs.vhdx not found, downloading...`). The fix
was not to un-flip the evaluator but to re-block the now-reachable
consumers individually — Patch 1c adds `process.platform==="linux"||`
to `u8A` and `mzn` so they behave as they did under `unsupported`,
while the evaluator stays `supported` for the renderer.
Two rules fall out of this:
- **Before flipping a shared gate, grep every read of the predicate**
(here `\.status\)!=="supported"` / `status!=="supported"`). Enumerate
the consumers and decide per-site which should follow the flip. A
patch that "works" against the symptom you were chasing can arm a
sibling you weren't looking at.
- **Markers verify structure; only a runtime launch verifies
behavior.** When a patch changes a value that other code branches on,
the post-build click-through (and a log tail for unwanted side
effects) is not optional — the static layers (build log, `node
--check`, markers) are all blind to a re-armed consumer. Add a
positive marker for the *counter*-patch (Patch 1c ships
`vm-download-blocked-linux` + `warm-download-blocked-linux`) so the
invariant you just restored has a fingerprint that can go red.
## Cross-references
- `tray-rebuild-race.md` "Resilience to minifier churn" — prior art

View File

@@ -80,7 +80,7 @@ releases. All five are extracted dynamically in `tray.sh`:
| `tray_func` | `on("menuBarEnabled",()=>{ … })` |
| `tray_var` | `});let X=null;(async )?function ${tray_func}` |
| `electron_var` | already extracted earlier in `_common.sh` |
| `menu_func` | `${tray_var}.setContextMenu(X(` |
| `menu_func` | `${tray_var}.setContextMenu(X(` — or, when upstream prebuilds the menu (`M=X(); setContextMenu(M)`), resolved one hop back via `M=X(` |
| `path_var` | `${tray_var}=new ${electron_var}.Tray(${electron_var}.nativeImage.createFromPath(X))` |
| `enabled_var` | `const X = fn("menuBarEnabled")` |
@@ -110,13 +110,54 @@ cd claude-desktop-debian
After the patch: one SNI stays registered for the app's lifetime,
icon updates in place on every theme change.
## Startup icon-colour race (leading-edge mutex drop)
A subtler bug lives in the same rebuild function. On a *dark* desktop
(e.g. GNOME `color-scheme=prefer-dark`),
`nativeTheme.shouldUseDarkColors` reads **`false` for the first
~50 ms** of the process, then a burst of `nativeTheme "updated"`
events flips it to `true`. Measured with a standalone Electron probe:
```
[ready+0ms] shouldUseDarkColors=false <- tray created -> black icon
[UPDATED-EVENT] shouldUseDarkColors=true <- ~50-100 ms later
[ready+500ms] shouldUseDarkColors=true (stays true)
```
The tray is created with the transient `false` (black). The
correction never lands because the rebuild mutex was a *leading-edge*
throttle (`if(f._running)return;f._running=true;setTimeout(...,1500)`):
the first `"updated"` (false) takes the lock and renders black; the
follow-up `"updated"` (true) events all arrive inside the 1500 ms
window and are **dropped**. No further event fires on its own, so the
icon stays black until a manual theme change forces a new `"updated"`.
The fix makes the mutex *trailing-edge* — a request that arrives while
a rebuild is in flight is remembered and re-run once when the window
clears, so the final value wins:
```js
if (f._running) { f._pending = true; return; }
f._running = true;
setTimeout(() => {
f._running = false;
if (f._pending) { f._pending = false; f(); }
}, 1500);
```
The startup-suppression `_trayStartTime > 3e3` guard was removed at
the same time: it gated the very `"updated"` → rebuild call the
correction now depends on. Trade-off: a ~1.5 s black flash at startup
before the trailing re-run lands (vs. permanently black before).
See [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679).
## Pitfalls to watch for
- **Fast-path runs inside the 3 s startup window too.** The
existing `_trayStartTime > 3e3` guard only gates the
`nativeTheme.on('updated')``tray_func()` call; once
`tray_func()` is running for any reason, our fast-path executes.
Fine — it's cheaper than the slow path even at startup.
- **No startup window gates the rebuild any more.** An earlier
`_trayStartTime > 3e3` guard suppressed `tray_func()` for the first
3 s; it was removed because it also swallowed the startup colour
correction (see the section above). The trailing-edge mutex bounds
rebuild frequency instead.
- **macOS path is left untouched.** The condition
`process.platform !== 'darwin' && …setContextMenu` keeps the
Electron macOS tray model (right-click pops up a menu via

View File

@@ -0,0 +1,73 @@
[< Back to learnings](./)
# Wayland global shortcuts via the XDG GlobalShortcuts portal
Quick Entry's global hotkey (`Ctrl+Alt+Space`) is focus-bound on modern GNOME Wayland; the native-Wayland path now routes it through the XDG GlobalShortcuts portal (a merged `--enable-features=…,GlobalShortcutsPortal`), opt-in on GNOME via `CLAUDE_USE_WAYLAND=1` — which fixes GNOME ≤ 49, but GNOME 50 / xdg-desktop-portal ≥ 1.20 is still blocked by an upstream Electron gap ([electron/electron#51875](https://github.com/electron/electron/issues/51875)).
## The problem (#404)
Upstream registers Quick Entry's hotkey with a raw `globalShortcut.register()` (build-reference `index.js:499416`) and has no portal fallback. On X11 that becomes an X11 key grab. The launcher historically defaulted *every* Wayland session to XWayland (`--ozone-platform=x11`) precisely so that grab would keep working.
That stopped working on GNOME. mutter (GNOME ≥ 49) no longer honours XWayland-side global key grabs, so the grab only fires when the Claude window already has focus — the opposite of "open Claude from everywhere." The symptom is intermittent (a brief compositor state can make it appear to work, then it stops), which sent more than one reporter chasing ghosts.
## The launcher change (necessary, not sufficient)
Electron ≥ 35 (we bundle 41) exposes Chromium's `GlobalShortcutsPortal` feature: under the **native Wayland ozone platform** it is *supposed* to route `globalShortcut.register()` through the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface instead of an X11 grab. So `build_electron_args` adds `GlobalShortcutsPortal` to the native-Wayland feature set.
GNOME Wayland is **not** auto-flipped to native Wayland. `detect_display_backend` still only auto-forces Niri (no XWayland at all). The reason: GNOME Wayland is the default session for a large slice of users, and moving it off mature XWayland is a rendering / IME / HiDPI / fractional-scaling risk — shipped on argv-only verification, and on GNOME 50 the portal route is a no-op anyway (so those users would take the risk for zero benefit). GNOME users opt in with `CLAUDE_USE_WAYLAND=1`, which fully works on **GNOME ≤ 49** after the one-time portal dialog. Auto-selecting native Wayland on GNOME is deferred to a follow-up gated on a real "still renders correctly" check, not just "the flag reached argv."
KDE/Sway/Hyprland likewise stay on XWayland by default (opt in with `=1`).
## Two traps that bite
- **`GlobalShortcutsPortal` is inert under XWayland.** The feature lives in Chromium's ozone/wayland layer. Passing the flag while `--ozone-platform=x11` does nothing. The flag and `--ozone-platform=wayland` are a package deal — that's why the launcher flips the backend, not just appends a flag.
- **Chromium honours only the *last* `--enable-features=` switch.** Two separate `--enable-features=A` `--enable-features=B` on one command line silently drops `A`. `build_electron_args` previously emitted up to two (`WindowControlsOverlay` for hidden titlebars; `UseOzonePlatform,WaylandWindowDecorations` for native Wayland), so adding a third would have clobbered the others. The function now accumulates into one `enable_features` array and emits a single comma-joined `--enable-features=` at the end. The test-harness `argvHasFlag` (`tools/test-harness/src/lib/argv.ts`) already matches a subkey inside a comma-joined value, so `S12` passes against the merged form.
## Why GNOME 50 is still broken — and how it was proven
On Fedora 44 / GNOME 50.2 / xdg-desktop-portal **1.21.2**, `globalShortcut.register()` returns `false` and the portal is **never contacted** (no `CreateSession`, no `BindShortcuts`). The feature flag has zero observable effect:
| ozone backend | `GlobalShortcutsPortal` flag | `register()` | portal `CreateSession` |
|---|---|---|---|
| wayland | enabled | `false` | 0 |
| wayland | default (no flag) | `false` | 0 |
| wayland | disabled | `false` | 0 |
| x11 (XWayland) | enabled | `true` | 0 (X11 grab; mutter ignores it → focus-bound, the #404 symptom) |
Reproduced identically on Electron **40.6.1, 41.5.0, 41.7.1, and 42.3.3** (latest), with the relevant app-id fixes already present (electron#49988 → backported to `41-x-y` via #50051). So the Electron *version* is not the variable.
**Root cause (pinned to source on both sides):** xdg-desktop-portal grew a host-app identity step — non-sandboxed apps must call `org.freedesktop.host.portal.Registry.Register(app_id)` (added in **1.20**, commit `8fd5bdd5ec`), and GlobalShortcuts `CreateSession` now hard-rejects an empty app id (`src/global-shortcuts.c` `handle_create_session()``NOT_ALLOWED "An app id is required"`, added in **1.21.0**, commit `38dd2c03f2`). Chromium never makes that call in the normal case: `components/dbus/xdg/portal.cc` `PortalRegistrar::OnServiceChecked()` only calls `Register()` when starting its transient systemd scope *fails* — when the scope starts (`kUnitStarted`, the usual path; the browser creates `app-<id>-<pid>.scope`) it skips `Register()`, assuming the portal derives the app id from the scope. On portal 1.21 that derivation is gone, so the connection has an empty app id and `CreateSession` (issued from `ui/base/accelerators/global_accelerator_listener/global_accelerator_listener_linux.cc`) is rejected. Confirmed on plain Chromium 151 (HEAD) and Chrome 149, not just Electron.
**Proof the portal itself works** — a ~60-line Python client that performs the missing `Registry.Register` call (reverse-DNS app id backed by a `.desktop` file, launched in a matching `app-<id>.scope` via `systemd-run --user --scope`) drives the whole flow and receives `Activated` from an *unfocused* window:
```
Registry.Register('com.example.GsPortalProof') OK
CreateSession OK
BindShortcuts OK -> id='open-quick-entry' trigger='Press <Control><Alt>space'
*** ACTIVATED *** (press #1) *** ACTIVATED *** (press #2)
```
Secondary gate: GNOME's backend also rejects app ids that are not reverse-DNS and backed by an installed `.desktop` (`gnome-control-center-global-shortcuts-provider: Discarded shortcut bind request … invalid app_id >gsportalproof<`). Electron's default app id is the executable name (`claude-desktop`), which has no dot and would likely also fail this even once `Registry.Register` is wired up.
Why it works on GNOME ≤ 49: older xdg-desktop-portal derived the app id from the systemd scope automatically and did not require `Registry.Register`. GNOME 50 / portal 1.21 introduced the requirement Chromium hasn't adopted.
Filed upstream: [electron/electron#51875](https://github.com/electron/electron/issues/51875) (accepted, milestone `42-x-y`) and the underlying Chromium bug at [crbug 520262204](https://issues.chromium.org/issues/520262204) — fundamentally the `components/dbus/xdg/portal.cc` skip-`Register()`-on-`kUnitStarted` gap, surfacing through Electron.
## First-run UX and escape hatch
When the portal path *does* engage (GNOME ≤ 49), GNOME shows a **one-time permission dialog** the first time the shortcut is registered; the user must accept it to bind the shortcut. Expected portal behaviour, not a bug. A dismissed or denied dialog persists in the portal permission store and later `globalShortcut.register()` calls then fail silently; clearing the stored decision with `flatpak permission-reset <app-id>` (the store is shared with non-Flatpak apps) should re-trigger the dialog on the next launch — untested here.
`CLAUDE_USE_WAYLAND` is tri-state: `1` forces native Wayland, `0` forces XWayland (skipping auto-detect), unset auto-detects. The `0` value is the escape hatch for a GNOME user who hits a native-Wayland rendering regression and wants the old XWayland behaviour back (losing global-shortcut-from-unfocused in the process — which on GNOME 50 is not yet working anyway).
## wlroots caveat (Niri / Sway / Hyprland)
The portal flag is harmless where the compositor's portal has no GlobalShortcuts backend, but does nothing useful there. wlroots' `xdg-desktop-portal-wlr` ships no GlobalShortcuts implementation, so on Niri `BindShortcuts` fails with `error code 5`. That's the `S14` known-failing detector: the assertion encodes the contract and will start passing if/when the wlroots portal gains the interface — no spec edit needed.
## Tests / anchors
- `tests/launcher-common.bats``detect_display_backend` GNOME/`CLAUDE_USE_WAYLAND=0` cases; `build_electron_args` single-merged-flag + portal-present/absent cases.
- `tools/test-harness/src/runners/S12_global_shortcuts_portal_flag.spec.ts` — GNOME-W flag-in-argv detector (passes: the launcher delivers the flag).
- `tools/test-harness/src/runners/S14_quick_entry_from_other_focus_niri.spec.ts` — Niri portal `BindShortcuts` detector (known-failing by design).
- `docs/testing/cases/shortcuts-and-input.md` (S12/S14), `docs/testing/quick-entry-closeout.md` (QE-6).
- Upstream blockers: [electron/electron#51875](https://github.com/electron/electron/issues/51875), Chromium [crbug 520262204](https://issues.chromium.org/issues/520262204).

View File

@@ -130,10 +130,10 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
**Diagnostics on failure:** Launcher log (note `Using X11 backend via XWayland (for global hotkey support)`), `XDG_CURRENT_DESKTOP`, mutter version (`gnome-shell --version`), the active patch set.
**Currently:** Fedora 43 GNOME Wayland reproduces [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — mutter doesn't honour the XWayland-side key grab, so the shortcut is focus-bound. On Ubuntu 24.04 GNOME, the [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406) KDE-only gate prevents the regressing patch from running, leaving the older (working) code path active — hence `🔧` on Ubu. The unsolved fix path is [S12](#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland).
**Currently:** Fedora 43 GNOME Wayland reproduces [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) on the default (XWayland) path — mutter doesn't honour the XWayland-side key grab, so the shortcut is focus-bound. The fix is opt-in: launch with `CLAUDE_USE_WAYLAND=1` to use native Wayland + the XDG GlobalShortcuts portal (see [S12](#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland)), which mutter honours on **GNOME ≤ 49**. GNOME Wayland is not auto-flipped (rendering risk; GNOME 50 portal route is a no-op upstream). Re-verify on a GNOME Wayland host.
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)
**Code anchors:** project `scripts/launcher-common.sh:96-99` (XWayland-default `--ozone-platform=x11`); upstream `index.js:499416` (`globalShortcut.register`).
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (native Wayland opt-in via `CLAUDE_USE_WAYLAND=1`; only Niri auto-forced) and `build_electron_args` (native-Wayland `GlobalShortcutsPortal` feature); upstream `index.js:499416` (`globalShortcut.register`).
## S12 — `--enable-features=GlobalShortcutsPortal` launcher flag wired up for GNOME Wayland
@@ -143,20 +143,18 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
**Issues:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
**Steps:**
1. On GNOME Wayland, launch the app.
1. On GNOME Wayland, launch the app with `CLAUDE_USE_WAYLAND=1`.
2. Inspect the Electron command line via `pgrep -af claude-desktop` — look for `--enable-features=GlobalShortcutsPortal`.
3. Test Quick Entry shortcut from unfocused state (see [T06](#t06--quick-entry-global-shortcut-unfocused)).
**Expected:** Launcher detects GNOME Wayland and appends `--enable-features=GlobalShortcutsPortal` to Electron's argv, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs. Once wired, [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) is closeable.
**Expected:** With `CLAUDE_USE_WAYLAND=1`, the launcher uses native Wayland and emits `GlobalShortcutsPortal` inside a single merged `--enable-features=…` switch, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404); GNOME is not auto-flipped — the portal route is opt-in). Note the flag is comma-joined with `UseOzonePlatform,WaylandWindowDecorations`, so match the `GlobalShortcutsPortal` subkey, not an exact `--enable-features=GlobalShortcutsPortal` token.
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f electron)/cmdline | tr '\0' ' '`), launcher log, `XDG_CURRENT_DESKTOP`.
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f 'app\.asar')/cmdline | tr '\0' ' '`), launcher log (expect `Using native Wayland backend (global shortcuts via XDG portal)`), `XDG_CURRENT_DESKTOP`.
**Currently:** Not yet implemented. Tracking under [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404).
> **⚠ Missing in build 1.5354.0** — `--enable-features=GlobalShortcutsPortal` is not appended by `scripts/launcher-common.sh` for any GNOME Wayland variant. Re-verify after next upstream bump and after #404 lands.
**Currently:** Launcher side implemented — `build_electron_args` adds `GlobalShortcutsPortal` to the native-Wayland feature set (opt-in via `CLAUDE_USE_WAYLAND=1`; GNOME is not auto-flipped). The flag is verified present in argv on that opt-in path (this case launches with `CLAUDE_USE_WAYLAND=1` and passes). Functional global-from-unfocused works on **GNOME ≤ 49** (first registration shows a one-time portal permission dialog). On **GNOME 50 / xdg-desktop-portal ≥ 1.20** it does not yet fire: Electron/Chromium never performs the portal's host `Registry.Register` app-id handshake, so `globalShortcut.register()` returns `false` and the portal is never contacted. Proven via D-Bus capture + a Python portal client; filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875).
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
**Code anchors:** project `scripts/launcher-common.sh:59-112` (`build_electron_args` — no `GlobalShortcutsPortal` branch present).
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (tri-state `CLAUDE_USE_WAYLAND` override) + `build_electron_args` (merged `enable_features` array). See [`wayland-global-shortcuts-portal.md`](../../learnings/wayland-global-shortcuts-portal.md).
## S14 — Global shortcuts via XDG portal work on Niri
@@ -177,7 +175,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
**Currently:** `Failed to call BindShortcuts (error code 5)` — portal global shortcuts fail on Niri. Different root cause from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), same user-visible symptom (Quick Entry shortcut doesn't fire). Not yet filed.
**References:**
**Code anchors:** project `scripts/launcher-common.sh:41-44` (Niri force-native-Wayland branch); upstream `index.js:499416` (`globalShortcut.register`, which on native Wayland routes through Electron's `xdg-desktop-portal` `BindShortcuts` path inside Chromium).
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (Niri force-native-Wayland branch) + `build_electron_args` (native-Wayland `GlobalShortcutsPortal` feature, which Niri now also receives); upstream `index.js:499416` (`globalShortcut.register`, which on native Wayland routes through Electron's `xdg-desktop-portal` `BindShortcuts` path inside Chromium). wlroots' portal ships no GlobalShortcuts backend, so `BindShortcuts` still fails until that lands — this stays a known-failing detector.
## S29 — Quick Entry popup is created lazily on first shortcut press (closed-to-tray sanity)

View File

@@ -53,7 +53,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
| QE-3 | Critical | App on a different workspace, press shortcut | Popup appears on current workspace | [T06](./cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
| QE-4 | Critical | App closed-to-tray (no window mapped), press shortcut | Popup appears | [S29](./cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity) |
| QE-5 | Should | App quit entirely, press shortcut | No popup, no error, no zombie process | [S30](./cases/shortcuts-and-input.md#s30--quick-entry-shortcut-becomes-a-no-op-after-full-app-exit) |
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` vs `Using native Wayland backend (global hotkeys may not work)` (verbatim from `scripts/launcher-common.sh:98, 102`). | **Pre-S12 fix:** flag absent; shortcut fails on GNOME Wayland (this is the #404 repro). **Post-S12 fix:** `--enable-features=GlobalShortcutsPortal` present in argv on GNOME Wayland; QE-2 / QE-3 begin to pass. | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` (the GNOME default) vs `Using native Wayland backend (global shortcuts via XDG portal)` (after `CLAUDE_USE_WAYLAND=1`). | **Launcher implemented (S12).** GNOME defaults to XWayland (no portal flag); launching with `CLAUDE_USE_WAYLAND=1` adds `--ozone-platform=wayland` and a single `--enable-features=…,GlobalShortcutsPortal` (comma-joined with the ozone features, not a standalone token). On that opt-in path QE-2 / QE-3 pass on **GNOME ≤ 49** after the one-time portal dialog; on **GNOME 50 / xdg-desktop-portal ≥ 1.20** they don't yet — Electron skips the portal's `Registry.Register` handshake ([electron#51875](https://github.com/electron/electron/issues/51875)). | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
### Submit → main window — covers #393

View File

@@ -24,6 +24,7 @@ suggested fixes:
| Input method | IBus/GTK immodule sanity (ibus-gtk3 installed, cache fresh, XWayland routing note) |
| Electron binary | Existence and version |
| Chrome sandbox | Correct permissions (4755/root) |
| User namespaces | AppArmor userns restriction + Claude profile presence (Ubuntu 24.04+) |
| SingletonLock | Stale lock file detection |
| MCP config | JSON validity and server count |
| Node.js | Version (v20+ recommended for MCP) |
@@ -161,6 +162,13 @@ applied automatically inside XRDP sessions, where software
rendering is required regardless. Either signal is sufficient —
the launcher won't stack duplicate flags.
If the previous launch already died with the GPU-process FATAL
signature and `CLAUDE_DISABLE_GPU` is unset, the next launch
auto-applies the same flags and keeps them applied on subsequent
launches. Set `CLAUDE_DISABLE_GPU=0` to suppress the auto-fallback
when retesting hardware acceleration after a driver fix — any
explicitly set value suppresses it; only `1` forces the flags on.
**When to prefer which:** the in-app toggle is friendlier if you
can reach Settings without the app crashing. Reach for
`CLAUDE_DISABLE_GPU=1` when the app crashes before you can open
@@ -170,6 +178,49 @@ behavior to persist across reinstalls and config resets.
Tracking issue: [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583).
### Black screen on Fedora KDE with Intel Iris Xe ([#706](https://github.com/aaddrick/claude-desktop-debian/issues/706))
If the window opens but renders entirely black on Fedora KDE with
Intel Iris Xe graphics (TigerLake-LP GT2), force Mesa's reference
software rasterizer:
```bash
MESA_LOADER_DRIVER_OVERRIDE=softpipe claude-desktop
```
The failing launch logs this signature in
`~/.cache/claude-desktop-debian/launcher.log`:
```
KMS: DRM_IOCTL_MODE_CREATE_DUMB failed: Permission denied
```
**Try the faster fallbacks first.** softpipe renders everything on
the CPU with no acceleration of any kind and is noticeably slow.
Before reaching for it:
1. `CLAUDE_DISABLE_GPU=1 claude-desktop` — disables hardware
acceleration entirely (see the previous section).
2. `LIBGL_ALWAYS_SOFTWARE=1 claude-desktop` — selects llvmpipe,
Mesa's supported software fallback, several times faster than
softpipe.
Use `MESA_LOADER_DRIVER_OVERRIDE=softpipe` only if
`LIBGL_ALWAYS_SOFTWARE=1` also produces a black screen. To make it
persistent:
```bash
echo 'export MESA_LOADER_DRIVER_OVERRIDE=softpipe' >> ~/.profile
```
Tracking issue:
[#706](https://github.com/aaddrick/claude-desktop-debian/issues/706).
Credit: workaround discovered and confirmed by
[@dubreal](https://github.com/dubreal) while diagnosing
[#593](https://github.com/aaddrick/claude-desktop-debian/issues/593)
and
[#599](https://github.com/aaddrick/claude-desktop-debian/pull/599).
### AppImage Sandbox Warning
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
@@ -181,18 +232,13 @@ For enhanced security, consider:
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
Ubuntu 24.04 ships with `apparmor_restrict_unprivileged_userns=1`
by default, which blocks the unprivileged user namespaces that
Cowork's bubblewrap sandbox relies on. Symptoms:
**Cause:** Ubuntu 24.04+ sets `apparmor_restrict_unprivileged_userns=1`. This blocks the user namespaces Cowork's bubblewrap sandbox needs.
- `claude-desktop --doctor` reports `bubblewrap: sandbox probe failed`
with `Operation not permitted` in stderr.
- `~/.config/Claude/logs/cowork_vm_daemon.log` contains
`bwrap is installed but cannot create a user namespace`.
- Cowork sessions hang at "Starting VM..." or loop on reconnect.
**Symptom:** `claude-desktop --doctor` shows `Cowork isolation: host-direct (bwrap probe failed)`.
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
setup, requires sudo):
**Fix (`.deb` installs):** None needed. The `postinst` installs `/etc/apparmor.d/claude-desktop-bwrap`, granting `userns` to `/usr/bin/bwrap`. Still failing? Reinstall the package — the `postinst` recreates the profile.
**Fix (AppImage, Nix, rpm, and manual installs):** The auto-install is deb-only; install the profile by hand:
```bash
sudo tee /etc/apparmor.d/bwrap <<'EOF'
@@ -209,20 +255,120 @@ EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap
```
After applying the profile, run `claude-desktop --doctor` — the
bubblewrap probe should pass, and Cowork should start without
falling back to host-direct.
**Existing profiles win:** The `postinst` defers to any profile already attaching to `/usr/bin/bwrap` — the hand-made `/etc/apparmor.d/bwrap` above, or `bwrap-userns-restrict` from the `apparmor-profiles` package — rather than shadowing it with its unconfined-mode one. If such a profile blocks `userns`, resolve the conflict yourself before expecting Cowork isolation to work.
**Security note:** this grants `/usr/bin/bwrap` the unconfined
profile plus the `userns` capability. It matches the behavior
bwrap had on Ubuntu 22.04 and earlier, and on most other distros,
but is a system-wide change that affects every program invoking
`/usr/bin/bwrap` (not just Claude Desktop). Review the profile
against your threat model before applying.
**Customizing:** Put overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` they survive upgrades. Direct edits to the managed profile do not: the `postinst` rewrites any profile carrying its marker header on every upgrade, and removes it on purge.
Credit: this workaround was contributed by
[@hfyeh](https://github.com/hfyeh) in
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
**Security:** The profile grants `userns` to `/usr/bin/bwrap` host-wide. Bubblewrap's own sandbox does the confining. Review against your threat model.
**Credit:** [@hfyeh](https://github.com/hfyeh), [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
### Claude Desktop crashes immediately on launch (Ubuntu 24.04+, AppArmor blocks user namespaces)
The `.deb` handles this automatically — this section is for the rare case
where it doesn't. Ubuntu 24.04+ sets
`apparmor_restrict_unprivileged_userns=1`, blocking the user namespaces
Chromium's sandbox needs (same root cause as the Cowork case above, but it
kills the **main app** on startup before any window appears). The deb's
`postinst` installs a scoped AppArmor profile
(`/etc/apparmor.d/claude-desktop`) that grants `userns` to the bundled
Electron binary only — exactly as the `google-chrome`, `code`, and `slack`
packages do — so a normal install needs no action.
You only need to act if the app still crashes on launch with:
- `FATAL:sandbox/linux/services/credentials.cc:131] Check failed: . :
Permission denied (13)` in
`~/.cache/claude-desktop-debian/launcher.log` (the line number varies by
Electron version), and
- a `Trace/breakpoint trap` / core dump (exit code 133).
Run `sudo claude-desktop --doctor` first — the **User namespaces** check
reports whether the profile is actually loaded into the kernel (reading the
loaded set needs root; without `sudo` it can only confirm the profile is
present on disk). To (re)install it manually:
```bash
sudo tee /etc/apparmor.d/claude-desktop <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile claude-desktop /usr/lib/claude-desktop/node_modules/electron/dist/electron flags=(unconfined) {
userns,
include if exists <local/claude-desktop>
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/claude-desktop
```
To customize the profile on a `.deb` install, put overrides in
`/etc/apparmor.d/local/claude-desktop` — they survive upgrades; direct
edits to the managed profile are rewritten by the `postinst` on every
upgrade.
Don't use `--no-sandbox` as a permanent fix on the `.deb` — it disables the
Chromium sandbox entirely, which the package is built to keep. (AppImage
builds already launch with `--no-sandbox` because they can't ship a SUID
helper, so they never hit this crash.)
**Security note:** the profile grants the unconfined profile plus the
`userns` capability to the bundled Electron binary only, not system-wide —
narrower than relaxing `kernel.apparmor_restrict_unprivileged_userns`
globally, which would lift the restriction for every program on the host.
Review against your threat model before applying.
### Claude Desktop crashes immediately on launch (Ubuntu 24.04+, AppArmor blocks user namespaces)
The `.deb` handles this automatically — this section is for the rare case
where it doesn't. Ubuntu 24.04+ sets
`apparmor_restrict_unprivileged_userns=1`, blocking the user namespaces
Chromium's sandbox needs (same root cause as the Cowork case above, but it
kills the **main app** on startup before any window appears). The deb's
`postinst` installs a scoped AppArmor profile
(`/etc/apparmor.d/claude-desktop`) that grants `userns` to the bundled
Electron binary only — exactly as the `google-chrome`, `code`, and `slack`
packages do — so a normal install needs no action.
You only need to act if the app still crashes on launch with:
- `FATAL:sandbox/linux/services/credentials.cc:131] Check failed: . :
Permission denied (13)` in
`~/.cache/claude-desktop-debian/launcher.log` (the line number varies by
Electron version), and
- a `Trace/breakpoint trap` / core dump (exit code 133).
Run `sudo claude-desktop --doctor` first — the **User namespaces** check
reports whether the profile is actually loaded into the kernel (reading the
loaded set needs root; without `sudo` it can only confirm the profile is
present on disk). To (re)install it manually:
```bash
sudo tee /etc/apparmor.d/claude-desktop <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile claude-desktop /usr/lib/claude-desktop/node_modules/electron/dist/electron flags=(unconfined) {
userns,
include if exists <local/claude-desktop>
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/claude-desktop
```
Don't use `--no-sandbox` as a permanent fix on the `.deb` — it disables the
Chromium sandbox entirely, which the package is built to keep. (AppImage
builds already launch with `--no-sandbox` because they can't ship a SUID
helper, so they never hit this crash.)
**Security note:** the profile grants the unconfined profile plus the
`userns` capability to the bundled Electron binary only, not system-wide —
narrower than relaxing `kernel.apparmor_restrict_unprivileged_userns`
globally, which would lift the restriction for every program on the host.
Review against your threat model before applying.
### Cowork: "VM connection timeout after 60 seconds"

6
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"lastModified": 1781607440,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
"type": "github"
},
"original": {

View File

@@ -16,16 +16,16 @@
}:
let
pname = "claude-desktop";
version = "1.8555.2";
version = "1.15200.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.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe";
hash = "sha256-sxCC+1csPLYpUug3A94vy2SGjMNh9KXSaZbIEPMYzXU=";
};
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.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe";
hash = "sha256-lc870OG/6CsnYrqm9tWdNZrlrxBZ7RNcW+lhcZBnIoA=";
};
};
@@ -239,6 +239,7 @@ fi
setup_logging || exit 1
setup_electron_env
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
@@ -262,15 +263,20 @@ detect_display_backend
# Build Electron arguments
build_electron_args 'nix'
# Add app path
electron_args+=("$app_path")
# Intentionally NOT appended: app.asar sits in Electron's default
# resources/ dir next to the binary, so Electron auto-loads it. Passing
# the path again makes Electron treat it as a file-to-open, which the
# app forwards to its file-drop handler, producing a spurious
# "Attach app.asar?" prompt on launch and on every taskbar reopen
# (the second-instance argv path). Omitting it is the root-cause fix.
# See issue #696.
log_message "App (auto-loaded by Electron): $app_path"
# Execute Electron
# Execute Electron and keep the launcher alive so explicit quit can
# clean up Desktop-owned helpers that outlive the Electron main process.
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
run_electron_and_cleanup "$electron_exec" "''${electron_args[@]}" "$@"
exit $?
LAUNCHER
# Substitute placeholders electron_exec points to our custom
# wrapper (which sets GTK/GIO env then execs our merged binary)

View File

@@ -1,5 +1,6 @@
{
buildFHSEnv,
bubblewrap,
claude-desktop,
nodejs,
docker,
@@ -12,6 +13,7 @@ buildFHSEnv {
name = "claude-desktop";
targetPkgs = pkgs: [
bubblewrap
claude-desktop
docker
docker-compose

View File

@@ -42,6 +42,29 @@ class AuthRequest {
module.exports = {
getWindowsVersion: () => "10.0.0",
// Windows-only native methods with no Linux equivalent. Newer upstream
// (Claude Desktop >= 1.13576.0) calls readRegistryValues() and
// getWindowsElevationType() UNCONDITIONALLY at startup — the
// managed-config / enterprise-policy lookup — from the top level of
// index.pre.js and index.js. The bundle only guards the native module
// being null (e.g. `(o=g2())==null?void 0:o.readRegistryValues(r)`),
// not the method being absent, so a missing method throws
// "<method> is not a function" during top-level execution, before the
// logger and main window exist. index.pre.js installs an empty
// uncaughtException handler early, so the throw is swallowed: the
// process stays alive in the event loop but no window ever appears.
// Stub these as neutral no-ops (no registry, no MSIX package, no UAC
// on Linux) so the `?? []` / `?? "default"` consumers proceed. Fixing
// the stub covers every call site at the source and is robust against
// re-minification. Fixes the "hangs indefinitely, app window never
// shows up" regression (#729).
readRegistryValues: () => [],
writeRegistryValue: () => {},
writeRegistryDword: () => {},
getWindowsElevationType: () => "default",
getCurrentPackageFamilyName: () => null,
setWindowEffect: () => {},
removeWindowEffect: () => {},

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,14 +15,23 @@
# 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).
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}
# Marker set tracks the cowork patch suite in scripts/patches/cowork.sh.
# Re-derived for the yukonSilver VM architecture (Claude Desktop
# 1.13576+): the platform gate moved to startVM's feature-flag check
# and the vmClient module load moved behind the isMsix detector, so
# Patch 1 + Patch 2 fingerprints changed and Patch 12 (sharedCwdPath
# threading) was retired in favor of the daemon's mountMap fallback.
vm-supported-linux-gate process\.platform!=="linux"&&\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported" process.platform!=="linux"&&(r==null?void 0:r.status)!=="supported"
vm-supported-linux-evaluator if\(process\.platform==="linux"\)return\{status:"supported"\};const [\w$]+="win32" if(process.platform==="linux")return{status:"supported"};const A="win32"
vm-download-blocked-linux process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1: (process.platform==="linux"||(t==null?void 0:t.status)!=="supported")?!1:
warm-download-blocked-linux if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)\{await [\w$]+\(\[\]\);return\} if(process.platform==="linux"||!i||i.status!=="supported"){await YcA([]);return}
vmclient-linux-gate \([\w$]+\(\)\|\|process\.platform==="linux"\)\? (Rl()||process.platform==="linux")?
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"
empty-linux-bundle-manifest linux:\{x64:\[\],arm64:\[\]\} ,linux:{x64:[],arm64:[]}
getdownloadstatus-suppression getDownloadStatus\(\)\{return process\.platform==="linux"\?[\w$]+\.NotDownloaded getDownloadStatus(){return process.platform==="linux"?Z.NotDownloaded
econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED" (n.code==="ENOENT"||process.platform==="linux"&&n.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]
smol-bin-linux-copy Copying smol-bin\.\$\{_la\}\.vhdx to bundle \(Linux\) Copying smol-bin.${_la}.vhdx to bundle (Linux)
asar-adddir-filter \.filter\(_d=>!_d\.endsWith\("\.asar"\)\).*"--add-dir" .filter(_d=>!_d.endsWith(".asar")))Y.push("--add-dir"
asar-file-drop-guard \.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\) .startsWith("-")&&!i.endsWith(".asar")
Can't render this file because it contains an unexpected character in line 21 and column 39.

View File

@@ -2762,4 +2762,5 @@ module.exports = {
loadBwrapMountsConfig,
mergeBwrapArgs,
classifyBwrapProbeError,
detectBackend,
};

View File

@@ -6,8 +6,9 @@
# per-package launcher scripts — deb, rpm, AppImage, Nix).
#
# Provides: run_doctor (the `claude-desktop --doctor` entry point) plus its
# internal helpers. Self-contained — no dependencies on launcher-common.sh
# state or functions.
# internal helpers. Self-contained except for the WM_CLASS constant defined
# at the top of launcher-common.sh (substituted at build time), which the
# live-UI fingerprint in the orphaned-daemon check reads at runtime.
#
# To add a new check: define an internal function `_check_<name>`, call it
# from run_doctor in the appropriate section, use _pass / _fail / _warn /
@@ -462,6 +463,8 @@ _doctor_check_filename_limit() {
local name_max
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
[[ $name_max =~ ^[0-9]+$ ]] || return 0
# Force base 10 so a leading zero can't trip octal arithmetic.
name_max=$((10#$name_max))
((name_max >= 200)) && return 0
@@ -562,10 +565,16 @@ _doctor_check_recent_crashes() {
# sources this file) to surface what keyring Electron will use for
# safeStorage / cookie encryption. 'basic' is valid but means tokens
# rely on filesystem permissions alone, so we note it for visibility.
# Never fails — basic is an intentional fallback, not an error.
# An empty result means detection itself failed (e.g. a sourcing-order
# regression) and warns rather than emitting a green PASS with a blank
# value.
_doctor_check_password_store() {
local store
store=$(_detect_password_store)
if [[ -z $store ]]; then
_warn 'Password store: unable to detect backend'
return
fi
_pass "Password store: $store"
if [[ $store == 'basic' ]]; then
_info \
@@ -578,6 +587,88 @@ _doctor_check_password_store() {
fi
}
# Report free space on the partition holding the Claude config dir.
# Arguments: $1 = config directory to check.
#
# Skips when df is unavailable or yields a non-numeric value, leaving
# an _info line so the summary never claims a pass over an unrun
# check: better a visible skip than a green PASS reporting space we
# could not read.
_doctor_check_disk_space() {
local config_dir="$1"
local avail
avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
| tail -1 | tr -d ' M') || true
if [[ ! $avail =~ ^[0-9]+$ ]]; then
_info 'Disk space: unable to read (df)'
return 0
fi
# Force base 10: a leading zero ("0099") would otherwise make
# (( )) parse the value as octal and error out, falling through
# to the PASS branch.
avail=$((10#$avail))
if ((avail < 100)); then
_fail "Disk space: ${avail}MB free on config partition"
_info 'Fix: Free up disk space'
elif ((avail < 500)); then
_warn "Disk space: ${avail}MB free" \
"on config partition (low)"
else
_pass "Disk space: ${avail}MB free"
fi
}
# Report the installed claude-desktop version from the package manager
# that actually owns the install (#711). On dual-DB hosts (e.g. a
# Fedora box with dpkg installed for deb work) a stale dpkg record
# must not shadow the live rpm install, so rpm ownership of the real
# Electron binary is probed first: `rpm -qf <path>` succeeds only when
# rpm installed the file, which a stale dpkg record can never claim.
# dpkg is consulted only when rpm does not own the path.
#
# AppImage and Nix installs (no package owns the path) keep the
# existing not-found warn; hosts with no package tools stay silent.
#
# Usage: _doctor_check_pkg_version <electron_path>
_doctor_check_pkg_version() {
local electron_path="${1:-}"
local probe_path="$electron_path"
local pkg_version=''
if [[ -z $probe_path ]]; then
probe_path='/usr/lib/claude-desktop'
probe_path+='/node_modules/electron/dist/electron'
fi
# rpm branch: query the file, not the package name, so the answer
# comes from the database that owns the actual install.
if command -v rpm &>/dev/null; then
pkg_version=$(rpm -qf --qf '%{VERSION}-%{RELEASE}' \
"$probe_path" 2>/dev/null) || pkg_version=''
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
return 0
fi
fi
# dpkg branch: only consulted when rpm does not own the install.
if command -v dpkg-query &>/dev/null; then
pkg_version=$(dpkg-query -W -f='${Version}' \
claude-desktop 2>/dev/null) || pkg_version=''
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
return 0
fi
fi
# Neither manager knows the install — AppImage or Nix. Only warn
# when a package tool exists; with none there is nothing to say.
if command -v rpm &>/dev/null \
|| command -v dpkg-query &>/dev/null; then
_warn 'claude-desktop not found via dpkg/rpm (AppImage?)'
fi
}
# Run all diagnostic checks and print results
# Arguments: $1 = electron path (optional, for package-specific checks)
run_doctor() {
@@ -595,16 +686,7 @@ run_doctor() {
echo
# -- Installed package version --
if command -v dpkg-query &>/dev/null; then
local pkg_version
pkg_version=$(dpkg-query -W -f='${Version}' \
claude-desktop 2>/dev/null) || true
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
else
_warn 'claude-desktop not found via dpkg (AppImage?)'
fi
fi
_doctor_check_pkg_version "$electron_path"
# -- Display server --
if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
@@ -677,6 +759,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).
@@ -732,6 +822,74 @@ run_doctor() {
_warn 'Chrome sandbox not found (expected for AppImage)'
fi
# -- User-namespace sandbox (Ubuntu 24.04+ AppArmor) --
# Ubuntu 24.04+ sets apparmor_restrict_unprivileged_userns=1, which
# blocks the user namespaces Chromium's sandbox needs and crashes the
# app on launch (credentials.cc FATAL, exit 133). A scoped AppArmor
# profile permits them for Claude only. Only report when the
# restriction is actually in force — on other distros the knob is
# absent and this check stays silent.
local _userns_path='/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
local _userns_val=''
[[ -r $_userns_path ]] && _userns_val=$(<"$_userns_path")
# Gate on the deb's installed Electron, not $electron_path (the
# invoking build's binary): the profile pins this exact path, so only
# a deb install is confined by it. AppImage always runs --no-sandbox
# and Nix binaries live in the store — neither can hit the crash.
local _deb_electron='/usr/lib/claude-desktop'
_deb_electron+='/node_modules/electron/dist/electron'
if [[ $_userns_val == 1 && -e $_deb_electron ]]; then
# Profile name must match deb.sh's /etc/apparmor.d/$package_name
# (PACKAGE_NAME in build.sh).
local _aa_profile='/etc/apparmor.d/claude-desktop'
local _aa_loaded='/sys/kernel/security/apparmor/profiles'
# securityfs marks this file world-readable (0444), but the kernel
# still denies the actual read without CAP_MAC_ADMIN — so a -r test
# passes for non-root yet the read returns nothing. Attempt the read
# and judge by whether we actually got data, not by the mode bits.
local _loaded_set=''
_loaded_set=$(cat "$_aa_loaded" 2>/dev/null)
if [[ -n $_loaded_set ]]; then
# Authoritative: we actually read the kernel's loaded profile
# set (needs root), so report the real load state — not
# mere presence on disk.
if printf '%s\n' "$_loaded_set" | grep -q '^claude-desktop '; then
_pass 'User namespaces: restricted, AppArmor profile loaded'
else
_warn 'User namespaces: restricted by AppArmor,' \
'Claude profile not loaded'
if [[ -e $_aa_profile ]]; then
_info ' Profile is on disk but not loaded. Load it:'
_info " sudo apparmor_parser -r $_aa_profile"
else
_info ' No profile found. See docs/troubleshooting.md'
_info ' "Claude Desktop crashes immediately on launch".'
fi
fi
elif [[ -e $_aa_profile ]]; then
# The loaded set was unreadable: non-root (the kernel needs
# CAP_MAC_ADMIN despite the 0444 mode), or securityfs is
# unmounted (common in containers). Report presence on disk
# only — never a definitive PASS.
if (( EUID == 0 )); then
_info 'User namespaces: AppArmor profile present on disk' \
'(securityfs unavailable; cannot confirm it is loaded)'
else
_info 'User namespaces: AppArmor profile present on disk' \
'(re-run with sudo to confirm it is loaded)'
fi
else
_warn 'User namespaces: restricted by AppArmor,' \
'no Claude profile found'
_info ' Unprivileged user namespaces are blocked, which'
_info ' crashes the app on launch in X11 sessions'
_info ' (credentials.cc FATAL). Wayland sessions run with'
_info ' --no-sandbox and are unaffected.'
_info ' See docs/troubleshooting.md "Claude Desktop crashes'
_info ' immediately on launch" for the profile to install.'
fi
fi
# -- SingletonLock --
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
local lock_file="$config_dir/SingletonLock"
@@ -820,20 +978,7 @@ print(len(servers))
fi
# -- Disk space --
local config_disk_avail
config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
| tail -1 | tr -d ' M') || true
if [[ -n $config_disk_avail ]]; then
if ((config_disk_avail < 100)); then
_fail "Disk space: ${config_disk_avail}MB free on config partition"
_info 'Fix: Free up disk space'
elif ((config_disk_avail < 500)); then
_warn "Disk space: ${config_disk_avail}MB free" \
"on config partition (low)"
else
_pass "Disk space: ${config_disk_avail}MB free"
fi
fi
_doctor_check_disk_space "$config_dir"
# -- Cowork Mode --
echo
@@ -889,8 +1034,7 @@ print(len(servers))
'apparmor_restrict_unprivileged_userns=1'
_info \
' by default. See docs/troubleshooting.md' \
'"Cowork on Ubuntu 24.04"'
_info ' for the AppArmor profile fix.'
'"Cowork on Ubuntu 24.04" for the AppArmor profile fix.'
fi
fi
else
@@ -1037,33 +1181,21 @@ print(len(servers))
_doctor_check_filename_limit
# -- Orphaned cowork daemon --
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
# above: a live UI is an Electron main process on app.asar that is
# not a Chromium helper (--type=...), not the cowork daemon itself,
# and not stopped/zombie. Counting any `claude-desktop`-matching
# process (as the old check did) would include the launcher's own
# bash and stuck launcher bashes from previous crashes, producing
# false negatives where a real orphan is misreported as "parent
# alive".
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon:
# _claude_desktop_ui_is_alive in launcher-common.sh fingerprints on
# the --class=$WM_CLASS flag from build_electron_args (since #700
# the launchers no longer pass app.asar in argv — Electron
# auto-loads it), excluding Chromium helpers (--type=...), the
# cowork daemon itself, our own launcher bash, and stopped/zombie
# processes. Counting any `claude-desktop`-matching process (as
# the old check did) would include the launcher's own bash and
# stuck launcher bashes from previous crashes, producing false
# negatives where a real orphan is misreported as "parent alive".
local _cowork_pids
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|| true
if [[ -n $_cowork_pids ]]; then
local _daemon_orphaned=true _pid _cmdline _state
for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
[[ $_pid == "$$" || $_pid == "$PPID" ]] && continue
_cmdline=$(tr '\0' ' ' \
< "/proc/$_pid/cmdline" 2>/dev/null) || continue
[[ $_cmdline == *cowork-vm-service* ]] && continue
[[ $_cmdline == *--type=* ]] && continue
_state=$(awk '/^State:/ {print $2; exit}' \
"/proc/$_pid/status" 2>/dev/null) || continue
[[ $_state == T || $_state == t || $_state == Z ]] \
&& continue
_daemon_orphaned=false
break
done
if [[ $_daemon_orphaned == true ]]; then
if ! _claude_desktop_ui_is_alive; then
_warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)"
_info 'Fix: Restart Claude Desktop' \
'(daemon will be cleaned up automatically)'

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() {
@@ -58,18 +62,38 @@ detect_display_backend() {
is_wayland=false
[[ -n "${WAYLAND_DISPLAY:-}" ]] && is_wayland=true
# Default: Use X11/XWayland on Wayland for global hotkey support
# Set CLAUDE_USE_WAYLAND=1 to use native Wayland (global hotkeys disabled)
# Default: Use X11/XWayland on Wayland so upstream's globalShortcut
# (Quick Entry's Ctrl+Alt+Space) keeps working via an X11 key grab.
#
# CLAUDE_USE_WAYLAND is tri-state:
# 1 - force native Wayland (global shortcuts via XDG portal)
# 0 - force XWayland, skipping the auto-detect below
# unset - auto-detect per compositor
use_x11_on_wayland=true
[[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]] && use_x11_on_wayland=false
local wayland_override="${CLAUDE_USE_WAYLAND:-}"
[[ $wayland_override == '1' ]] && use_x11_on_wayland=false
# Fixes: #226 - Auto-detect compositors that require native Wayland
# Only Niri is auto-forced: it has no XWayland support.
# Sway and Hyprland have working XWayland, so users on those
# compositors who want native Wayland can set CLAUDE_USE_WAYLAND=1.
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME");
# glob matching with *niri* handles this correctly.
if [[ $is_wayland == true && $use_x11_on_wayland == true ]]; then
# Fixes: #226 - Only Niri is auto-forced to native Wayland: it has
# no XWayland at all, so the X11 backend can't even start.
#
# GNOME Wayland is NOT auto-forced. mutter no longer honours
# XWayland global key grabs (#404), and native Wayland would route
# Quick Entry's globalShortcut through the XDG GlobalShortcuts portal
# instead -- but flipping the default session off mature XWayland is
# a rendering / IME / HiDPI risk, and on GNOME 50 the portal path is
# a no-op anyway (electron/electron#51875). GNOME users who want the
# portal route opt in with CLAUDE_USE_WAYLAND=1 (works on GNOME <=49
# after the one-time portal permission dialog).
#
# Sway and Hyprland keep working XWayland grabs and their wlroots
# portal has no GlobalShortcuts backend, so they also stay on the
# XWayland default; opt in with CLAUDE_USE_WAYLAND=1 if desired. An
# explicit CLAUDE_USE_WAYLAND=0 opts out of this auto-detect entirely.
#
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME"); the
# *glob* substring match handles this.
if [[ $is_wayland == true && $use_x11_on_wayland == true \
&& $wayland_override != '0' ]]; then
local desktop="${XDG_CURRENT_DESKTOP:-}"
desktop="${desktop,,}"
@@ -152,6 +176,55 @@ _detect_password_store() {
echo 'basic'
}
# Detect whether the previous launch ended in Chromium's
# "GPU process isn't usable" crash signature (#583).
#
# setup_logging() must have run first so $log_file is available. The
# launcher writes the current session header before build_electron_args()
# runs, so the previous launch lives in the penultimate log section.
#
# A recovered launch (running with --disable-gpu) produces no GPU
# output, so the crash signature alone would re-enable GPU on launch
# N+2 and oscillate crash/work/crash on permanently broken hardware.
# The launcher's own "disabling GPU" marker therefore also counts as
# a trigger, making recovery sticky once tripped. CLAUDE_DISABLE_GPU=0
# remains the escape hatch for retesting hardware acceleration.
#
# Section headers vary by package format: deb/rpm write "Launcher
# Start", AppImage writes "AppImage Start", and Nix writes "Launcher
# Start (NixOS)" (nix/claude-desktop.nix).
_previous_launch_hit_gpu_fatal() {
[[ -f ${log_file:-} ]] || return 1
awk '
/^--- Claude Desktop (Launcher|AppImage) Start( \(NixOS\))? ---$/ {
section++
next
}
{
sections[section] = sections[section] $0 "\n"
}
END {
target = section > 1 ? section - 1 : section
if (target < 1) {
exit 1
}
text = sections[target]
if (index(text,
"GPU process launch failed: error_code=") &&
index(text,
"GPU process isn'\''t usable. Goodbye.")) {
exit 0
}
if (index(text,
"Previous launch hit GPU process FATAL")) {
exit 0
}
exit 1
}
' "$log_file"
}
# Build Electron arguments array based on display backend
# Requires: is_wayland, use_x11_on_wayland to be set
# (call detect_display_backend first)
@@ -162,6 +235,12 @@ build_electron_args() {
electron_args=()
# Chromium ignores all but the LAST --enable-features switch on a
# command line, so every feature we want must end up in ONE
# comma-joined flag. Accumulate them here and emit a single
# --enable-features=... at the end of the function.
local enable_features=()
# AppImage always needs --no-sandbox due to FUSE constraints
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
@@ -169,26 +248,28 @@ build_electron_args() {
# hybrid (default) / native: --disable-features=CustomTitlebar
# so Chromium's drawn CSD titlebar doesn't compete with
# the DE-drawn one. Both modes use frame:true.
# hidden: --enable-features=WindowControlsOverlay because WCO
# is off by default on Linux Chromium (Win/macOS have
# it on by default). Without this flag, titleBarOverlay
# is silently ignored at the page level.
# hidden: WindowControlsOverlay because WCO is off by default on
# Linux Chromium (Win/macOS have it on by default).
# Without it, titleBarOverlay is silently ignored at the
# page level.
local _tb
_tb=$(_resolve_titlebar_style)
if [[ $_tb == 'hidden' ]]; then
electron_args+=('--enable-features=WindowControlsOverlay')
enable_features+=('WindowControlsOverlay')
else
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
# when a keyring daemon is running, discarding OAuth tokens on exit
# and forcing re-authentication on every launch. We probe for the
# best available store at startup and pass it before the app path
# so Chromium treats it as a Chromium flag (args after the app
# path go to the renderer, not Chromium). Fixes: #593
# best available store at startup. Fixes: #593
local pw_store
pw_store=$(_detect_password_store)
electron_args+=("--password-store=${pw_store}")
@@ -217,39 +298,117 @@ build_electron_args() {
# behaviour is reachable via Settings → disable hardware
# acceleration; this lets users persist it via the env without
# having to reach the Settings UI through repeated crashes.
if [[ ${CLAUDE_DISABLE_GPU:-} == '1' ]]; then
if [[ -v CLAUDE_DISABLE_GPU ]]; then
if [[ ${CLAUDE_DISABLE_GPU} == '1' ]]; then
_disable_gpu=true
log_message \
'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
fi
elif _previous_launch_hit_gpu_fatal; then
_disable_gpu=true
log_message 'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
log_message \
'Previous launch hit GPU process FATAL - disabling GPU'
fi
[[ $_disable_gpu == true ]] \
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
# X11 session - no special flags needed
# X11 session - no display-backend flags needed.
if [[ $is_wayland != true ]]; then
log_message 'X11 session detected'
return
fi
# Wayland: deb/nix packages need --no-sandbox in both modes
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
&& electron_args+=('--no-sandbox')
if [[ $use_x11_on_wayland == true ]]; then
# Default: Use X11 via XWayland for global hotkey support
log_message 'Using X11 backend via XWayland (for global hotkey support)'
electron_args+=('--ozone-platform=x11')
else
# Native Wayland mode (user opted in via CLAUDE_USE_WAYLAND=1)
log_message 'Using native Wayland backend (global hotkeys may not work)'
electron_args+=('--enable-features=UseOzonePlatform,WaylandWindowDecorations')
electron_args+=('--ozone-platform=wayland')
electron_args+=('--enable-wayland-ime')
electron_args+=('--wayland-text-input-version=3')
# Override any system-wide GDK_BACKEND=x11 that would silently
# prevent GTK from connecting to the Wayland compositor, causing
# blurry rendering or launch failures on HiDPI displays.
export GDK_BACKEND=wayland
# Wayland: deb/nix packages need --no-sandbox in both modes
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
&& electron_args+=('--no-sandbox')
if [[ $use_x11_on_wayland == true ]]; then
# Use X11 via XWayland; globalShortcut uses an X11 key grab.
log_message 'Using X11 backend via XWayland (for global hotkey support)'
electron_args+=('--ozone-platform=x11')
else
# Native Wayland: route globalShortcut through the XDG
# GlobalShortcutsPortal instead of an X11 key grab. Needs
# the wayland ozone platform (the feature is inert under
# XWayland) and Electron >= 35. Fixes #404 on GNOME, where
# mutter no longer honours XWayland grabs. On compositors
# whose portal lacks a GlobalShortcuts backend (e.g.
# wlroots) the feature is a harmless no-op.
log_message 'Using native Wayland backend (global shortcuts via XDG portal)'
enable_features+=(
'UseOzonePlatform'
'WaylandWindowDecorations'
'GlobalShortcutsPortal'
)
electron_args+=('--ozone-platform=wayland')
electron_args+=('--enable-wayland-ime')
electron_args+=('--wayland-text-input-version=3')
# Override any system-wide GDK_BACKEND=x11 that would silently
# prevent GTK from connecting to the Wayland compositor, causing
# blurry rendering or launch failures on HiDPI displays.
export GDK_BACKEND=wayland
fi
fi
# Emit all accumulated Chromium features as a single switch (see the
# enable_features declaration above for why a single switch matters).
if [[ ${#enable_features[@]} -gt 0 ]]; then
local IFS=','
electron_args+=("--enable-features=${enable_features[*]}")
fi
}
# Does a /proc/PID/cmdline (joined with spaces) belong to the Claude
# Desktop Electron UI main process?
#
# We can NOT fingerprint on `app.asar`: since #700 the launchers no
# longer pass it as an argument (Electron auto-loads it from
# resources/), so it never appears in any cmdline. The stable
# signature across deb/rpm/AppImage/nix is the `--class=$WM_CLASS`
# flag every launcher passes via build_electron_args; Chromium keeps
# the exec'd argv in /proc/PID/cmdline and does not propagate --class
# to its --type=... helper children (verified empirically).
#
# Callers join /proc/PID/cmdline with `tr '\0' ' '`, which leaves
# every argument space-terminated, so anchoring on the trailing space
# rejects look-alike classes (e.g. ClaudeDev).
_claude_desktop_ui_cmdline_matches() {
local cmdline="$1"
# Never the cowork daemon (defensive; it carries no --class) and
# never a Chromium helper: zygote, renderer, gpu, utility, etc.
[[ $cmdline == *cowork-vm-service* ]] && return 1
[[ $cmdline == *--type=* ]] && return 1
[[ $cmdline == *"--class=$WM_CLASS "* ]]
}
# Is a live Claude Desktop UI running for this user?
#
# We can NOT use `pgrep -f 'claude-desktop'` on its own for this: it
# matches the launcher's own bash process (this script's cmdline
# contains "/usr/bin/claude-desktop"), any stale launcher bash left
# stopped/zombie after a previous crash, and the cowork daemon
# itself. Counting any of those as "the UI is alive" causes false
# negatives in the cleanup functions below. The reliable definition
# is: a process whose cmdline carries our --class fingerprint (see
# _claude_desktop_ui_cmdline_matches) and is actually runnable (not
# stopped/zombie), excluding our own launcher bash and its parent.
_claude_desktop_ui_is_alive() {
local pid cmdline state
for pid in \
$(pgrep -u "$(id -u)" -f -- "--class=$WM_CLASS" 2>/dev/null); do
# Skip our own launcher bash and its parent.
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
cmdline=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") \
|| continue
_claude_desktop_ui_cmdline_matches "$cmdline" || continue
# Skip stopped (T/t) and zombie (Z) processes — not a live UI.
state=$(awk '/^State:/ {print $2; exit}' \
"/proc/$pid/status" 2>/dev/null) || continue
[[ $state == T || $state == t || $state == Z ]] && continue
# Found a genuine live Electron UI.
return 0
done
return 1
}
# Kill orphaned cowork-vm-service daemon processes.
@@ -262,40 +421,16 @@ build_electron_args() {
# Must run BEFORE cleanup_stale_lock / cleanup_stale_cowork_socket
# so that stale files left behind by the daemon can be cleaned up.
cleanup_orphaned_cowork_daemon() {
local cowork_pids
local cowork_pids pid
cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|| return 0
# Check if a live Claude Desktop UI process is also running.
#
# We can NOT use `pgrep -f 'claude-desktop'` on its own for this:
# it matches the launcher's own bash process (this script's
# cmdline contains "/usr/bin/claude-desktop"), any stale launcher
# bash left stopped/zombie after a previous crash, and the cowork
# daemon itself. Counting any of those as "the UI is alive"
# causes a false negative and the orphan survives.
#
# The reliable definition of "UI is alive" is: an Electron main
# process whose cmdline references app.asar and is NOT a Chromium
# helper (--type=...) and NOT the cowork daemon, and is actually
# runnable (not stopped/zombie).
local pid cmdline state
for pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
# Skip our own launcher bash and its parent.
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) \
|| continue
# Skip the cowork daemon (matches app.asar.unpacked path).
[[ $cmdline == *cowork-vm-service* ]] && continue
# Skip Chromium helpers: zygote, renderer, gpu, utility, etc.
[[ $cmdline == *--type=* ]] && continue
# Skip stopped (T/t) and zombie (Z) processes — not a live UI.
state=$(awk '/^State:/ {print $2; exit}' \
"/proc/$pid/status" 2>/dev/null) || continue
[[ $state == T || $state == t || $state == Z ]] && continue
# Found a genuine live Electron UI — daemon is expected
# A live Claude Desktop UI process means the daemon is expected;
# leave it alone. See _claude_desktop_ui_is_alive for why neither
# `pgrep -f 'claude-desktop'` nor an app.asar fingerprint works.
if _claude_desktop_ui_is_alive; then
return 0
done
fi
# No UI process found — daemon is orphaned, terminate it.
# Escalate to SIGKILL if a daemon is stuck and does not exit
@@ -320,6 +455,83 @@ cleanup_orphaned_cowork_daemon() {
fi
}
_desktop_helper_cmdline_matches() {
local cmdline="$1"
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
case "$cmdline" in
*cowork-vm-service.js*)
return 0
;;
*"--user-data-dir=$config_dir "*)
return 0
;;
*"$config_dir/Claude Extensions/"*)
return 0
;;
*/usr/lib/claude-desktop/*--type=*)
return 0
;;
esac
return 1
}
_desktop_helper_candidate_pids() {
pgrep -u "$(id -u)" -f 'cowork-vm-service\.js|--user-data-dir=.*[/]Claude|Claude Extensions|/usr/lib/claude-desktop/' 2>/dev/null
}
cleanup_stale_desktop_helpers() {
# A live UI (any instance) suppresses all cleanup. We don't scope
# helpers per-instance. Safe, not complete.
if _claude_desktop_ui_is_alive; then
return 0
fi
local pids pid cmdline
pids=$(_desktop_helper_candidate_pids) || return 0
local matched=()
for pid in $pids; do
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
[[ ${_electron_child_pid:-} == "$pid" ]] && continue
cmdline=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") \
|| continue
_desktop_helper_cmdline_matches "$cmdline" || continue
matched+=("$pid")
done
[[ ${#matched[@]} -gt 0 ]] || return 0
for pid in "${matched[@]}"; do
kill "$pid" 2>/dev/null || true
done
local wait_count=0 alive
while ((wait_count < 20)); do
alive=false
for pid in "${matched[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
alive=true
break
fi
done
[[ $alive == false ]] && break
sleep 0.1
wait_count=$((wait_count + 1))
done
if [[ $alive == true ]]; then
for pid in "${matched[@]}"; do
kill -KILL "$pid" 2>/dev/null || true
done
log_message \
"Killed stale Claude Desktop helpers (SIGKILL, PIDs: ${matched[*]})"
else
log_message "Killed stale Claude Desktop helpers (PIDs: ${matched[*]})"
fi
}
# Clean up stale SingletonLock if the owning process is no longer running.
# Electron uses requestSingleInstanceLock() which silently quits if the lock
# is held. A stale lock (from a crash or unclean update) blocks all launches
@@ -380,6 +592,47 @@ cleanup_stale_cowork_socket() {
log_message "Removed stale cowork-vm-service socket (no daemon running)"
}
cleanup_after_electron_exit() {
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
}
_electron_launcher_forward_signal() {
local signal="$1"
if [[ -n ${_electron_child_pid:-} ]]; then
kill "-$signal" "$_electron_child_pid" 2>/dev/null || true
fi
}
run_electron_and_cleanup() {
local status
"$@" >> "$log_file" 2>&1 &
_electron_child_pid=$!
trap '_electron_launcher_forward_signal TERM' TERM
trap '_electron_launcher_forward_signal INT' INT
trap '_electron_launcher_forward_signal HUP' HUP
wait "$_electron_child_pid"
status=$?
while kill -0 "$_electron_child_pid" 2>/dev/null; do
wait "$_electron_child_pid" # reap only; keep status
done
trap - TERM INT HUP
log_message "Electron exited with code: $status"
cleanup_after_electron_exit
_electron_child_pid=''
log_message '--- Claude Desktop Launcher End ---'
return "$status"
}
# Set common environment variables
setup_electron_env() {
# ELECTRON_FORCE_IS_PACKAGED makes app.isPackaged return true, which

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'
@@ -86,7 +87,13 @@ fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
# Path to the bundled Electron executable and app
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
app_path="$appdir/usr/lib/node_modules/electron/dist/resources/app.asar"
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
@@ -100,22 +107,26 @@ log_message "Arguments: $@"
log_message "APPDIR: $appdir"
log_session_env
# Path to the bundled Electron executable and app
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
app_path="$appdir/usr/lib/node_modules/electron/dist/resources/app.asar"
# Build electron args (appimage mode adds --no-sandbox)
build_electron_args 'appimage'
# Add app path LAST - Chromium flags must come before this
electron_args+=("$app_path")
# Intentionally NOT appended: app.asar sits in Electron's default
# resources/ dir next to the binary, so Electron auto-loads it. Passing
# the path again makes Electron treat it as a file-to-open, which the
# app forwards to its file-drop handler, producing a spurious
# "Attach app.asar?" prompt on launch and on every taskbar reopen
# (the second-instance argv path). Omitting it is the root-cause fix.
# See issue #696.
log_message "App (auto-loaded by Electron): $app_path"
# Change to HOME directory before exec'ing Electron to avoid CWD permission issues
cd "$HOME" || exit 1
# Execute Electron
# Execute Electron and keep AppRun alive so explicit quit can clean up
# Desktop-owned helpers that outlive the Electron main process.
log_message "Executing: $electron_exec ${electron_args[*]} $*"
exec "$electron_exec" "${electron_args[@]}" "$@" >> "$log_file" 2>&1
run_electron_and_cleanup "$electron_exec" "${electron_args[@]}" "$@"
exit $?
EOF
chmod +x "$appdir_path/AppRun" || exit 1
echo 'AppRun script created'
@@ -133,7 +144,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
@@ -170,14 +181,15 @@ mkdir -p "$metadata_dir" || exit 1
appdata_file="$metadata_dir/${component_id}.appdata.xml"
# Generate the AppStream XML file
# Use MIT license based on LICENSE-MIT file in repo
# project_license describes the app the user launches (the proprietary
# Claude binary), not the MIT packaging scripts
# ID follows reverse DNS convention
cat > "$appdata_file" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>$component_id</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<project_license>LicenseRef-proprietary</project_license>
<developer id="io.github.aaddrick">
<name>aaddrick</name>
</developer>
@@ -268,6 +280,17 @@ if [[ -z $appimagetool_path ]]; then
fi
fi
# Normalize AppDir permissions before squashing. The staging copy above
# uses `cp -a`, which preserves source modes, and a restrictive build
# umask can leave directories at 0700. mksquashfs records those verbatim,
# so a user who later runs the AppImage can't traverse into
# app.asar.unpacked/ — silently breaking Cowork's daemon auto-launch (the
# fork is guarded by fs.existsSync(), false on a directory it can't read).
# Canonical modes: dirs and already-executable files 755, the rest 644.
echo 'Normalizing AppDir permissions...'
find "$appdir_path" -type d -exec chmod 755 {} + || exit 1
find "$appdir_path" -type f -exec chmod u=rwX,go=rX {} + || exit 1
# --- Build AppImage ---
echo 'Building AppImage...'
output_filename="${package_name}-${version}-${architecture}.AppImage"

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,10 +85,17 @@ Type=Application
Terminal=false
Categories=Office;Utility;
MimeType=x-scheme-handler/claude;
StartupWMClass=Claude
StartupWMClass=$WM_CLASS
EOF
echo 'Desktop entry created'
# --- Install AppStream metainfo (App Center / GNOME Software / KDE Discover) ---
echo 'Installing AppStream metainfo...'
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
install -Dm 644 "$script_dir/$metainfo_name" \
"$install_dir/share/metainfo/$metainfo_name" || exit 1
echo 'AppStream metainfo installed'
# --- Create Launcher Script ---
echo 'Creating launcher script...'
cat > "$install_dir/bin/claude-desktop" << EOF
@@ -106,7 +114,12 @@ fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
# App path
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
@@ -132,12 +145,14 @@ fi
# Determine Electron executable path
electron_exec='electron'
using_global_electron=false
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
if [[ -f \$local_electron_path ]]; then
electron_exec="\$local_electron_path"
log_message "Using local Electron: \$electron_exec"
else
if command -v electron &> /dev/null; then
using_global_electron=true
log_message "Using global Electron: \$electron_exec"
else
log_message 'Error: Electron executable not found'
@@ -152,27 +167,35 @@ else
fi
fi
# App path
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
# Build electron args
build_electron_args 'deb'
# Add app path LAST
electron_args+=("\$app_path")
# Bundled Electron: app.asar sits in its default resources/ dir next
# to the binary, so Electron auto-loads it. Passing the path again
# makes Electron treat it as a file-to-open, which the app forwards
# to its file-drop handler, producing a spurious "Attach app.asar?"
# prompt on launch and on every taskbar reopen (the second-instance
# argv path). Omitting it is the root-cause fix. See issue #696.
# Global (PATH) Electron has no co-located app.asar and would boot
# its default_app welcome screen instead — only there the explicit
# app path is load-bearing and must stay.
if [[ \$using_global_electron == true ]]; then
electron_args+=("\$app_path")
log_message "App (explicit arg, global Electron): \$app_path"
else
log_message "App (auto-loaded by Electron): \$app_path"
fi
# Change to application directory
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 and keep the launcher alive so explicit quit can
# clean up Desktop-owned helpers that outlive the Electron main process.
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
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
exit \$?
EOF
chmod +x "$install_dir/bin/claude-desktop" || exit 1
echo 'Launcher script created'
@@ -181,7 +204,9 @@ echo 'Launcher script created'
echo 'Creating control file...'
# Electron is bundled with its own Node.js runtime, so nodejs/npm are not
# runtime dependencies. p7zip is only used at build time to extract the
# installer. No external dependencies are required at runtime.
# installer. bubblewrap is Recommended (not required): it provides the
# default namespace-sandbox isolation for Cowork mode; the app runs without
# it (Cowork falls back to host-direct). apt installs Recommends by default.
cat > "$package_root/DEBIAN/control" << EOF
Package: $package_name
@@ -189,6 +214,7 @@ Version: $version
Section: utils
Priority: optional
Architecture: $architecture
Recommends: bubblewrap
Maintainer: $maintainer
Description: $description
Claude is an AI assistant from Anthropic.
@@ -206,7 +232,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
@@ -227,11 +253,177 @@ else
echo "Warning: chrome-sandbox binary not found in local package at \$LOCAL_SANDBOX_PATH. Sandbox may not function correctly."
fi
# --- AppArmor profile for Chromium's user-namespace sandbox ---
# Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1, which
# blocks the unprivileged user namespaces Chromium's sandbox relies on,
# crashing the app on launch with a sandbox/.../credentials.cc FATAL.
# Grant userns to our Electron binary via a scoped AppArmor profile, exactly
# as the google-chrome, code, and slack packages do. Gate on the kernel knob
# (not just apparmor_parser): only Ubuntu-family systems impose the
# restriction, so on stock Debian/others the knob is absent and we skip the
# profile entirely rather than installing one they never need. The knob may
# read 0 now and flip to 1 later, so existence — not value — is the gate.
APPARMOR_PROFILE="/etc/apparmor.d/$package_name"
if command -v apparmor_parser >/dev/null 2>&1 \
&& [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
echo "Configuring AppArmor profile for Chromium sandbox..."
# Writing the profile is best-effort: a read-only or atypical /etc must
# never abort the install (this postinst runs under set -e). Keeping the
# grep / mkdir + heredoc in the if/elif conditions exempts them from
# errexit. Debian Policy 10.7.3: a profile without our marker header was
# hand-created or hand-edited by the admin — preserve it, never overwrite.
if [ -e "\$APPARMOR_PROFILE" ] \
&& ! grep -qF "managed by the $package_name package" \
"\$APPARMOR_PROFILE" 2>/dev/null; then
echo "Preserving locally modified \$APPARMOR_PROFILE (no marker header)"
apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || true
elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$APPARMOR_PROFILE" <<'APPARMOR_EOF'
# This profile is managed by the $package_name package (postinst); direct
# edits will be overwritten on upgrade. Put local changes in
# /etc/apparmor.d/local/$package_name instead.
abi <abi/4.0>,
include <tunables/global>
profile $package_name /usr/lib/$package_name/node_modules/electron/dist/electron flags=(unconfined) {
userns,
include if exists <local/$package_name>
}
APPARMOR_EOF
then
if apparmor_parser -Q "\$APPARMOR_PROFILE" >/dev/null 2>&1; then
apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || echo "Note: AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot."
echo "AppArmor profile installed at \$APPARMOR_PROFILE"
else
rm -f "\$APPARMOR_PROFILE"
echo "AppArmor on this system does not support the userns rule; skipping profile (not required here)."
fi
else
# A failed write may leave a truncated profile behind; clear it.
# The || true is mandatory: this branch is errexit-live, and a bare
# rm fails the upgrade on a read-only /etc.
rm -f "\$APPARMOR_PROFILE" 2>/dev/null || true
echo "Warning: could not write \$APPARMOR_PROFILE; skipping AppArmor profile."
fi
fi
# --- AppArmor profile for the Cowork bwrap sandbox helper ---
# Cowork's "bwrap backend" runs the agent's Claude Code process inside a
# bubblewrap sandbox, which itself needs unprivileged user namespaces — the
# same thing Ubuntu 24.04+ blocks (apparmor_restrict_unprivileged_userns=1).
# bwrap is a SEPARATE binary from the Electron app, so the claude-desktop
# profile above (which scopes the Electron binary) does not cover it; it
# needs its own profile on /usr/bin/bwrap. Without this, Cowork silently
# falls back to host-direct (no isolation).
#
# Gate on the kernel knob, exactly like the Electron block above: only a
# kernel that can enforce the restriction exposes the knob, and a userspace
# parser that merely accepts the userns rule (AppArmor 4) is not
# enforcement — without the knob the profile is dead weight on a binary
# this package does not own. There is deliberately no [ -x /usr/bin/bwrap ]
# gate: a profile attaching to a nonexistent binary is inert, and dpkg
# gives Recommends no ordering edge, so gating on the binary races a
# same-transaction bubblewrap install. Static checks only: postinst runs as
# root, which is exempt from the unprivileged-userns restriction, so a
# behavioral bwrap probe here would falsely pass — the behavioral probe
# lives in 'claude-desktop --doctor' instead (runs as the user).
BWRAP_PROFILE="/etc/apparmor.d/${package_name}-bwrap"
if command -v apparmor_parser >/dev/null 2>&1 \
&& [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
echo "Configuring AppArmor profile for the Cowork bwrap sandbox..."
# Writing the profile is best-effort: a read-only or atypical /etc must
# never abort the install (this postinst runs under set -e). Keeping the
# grep / mkdir + heredoc in the if/elif conditions exempts them from
# errexit. Debian Policy 10.7.3: a profile without our marker header was
# hand-created or hand-edited by the admin — preserve it, never overwrite.
if [ -e "\$BWRAP_PROFILE" ] \
&& ! grep -qF "managed by the $package_name package" \
"\$BWRAP_PROFILE" 2>/dev/null; then
echo "Preserving locally modified \$BWRAP_PROFILE (no marker header)"
apparmor_parser -r "\$BWRAP_PROFILE" >/dev/null 2>&1 || true
elif grep -rl '/usr/bin/bwrap' /etc/apparmor.d/ 2>/dev/null \
| grep -vxF "\$BWRAP_PROFILE" | grep -q .; then
# Another profile already attaches to /usr/bin/bwrap — a hand-made
# /etc/apparmor.d/bwrap, apparmor-profiles' bwrap-userns-restrict,
# or any other filename. Identical attachment strings have no
# specificity tiebreak, and shadowing a restrictive profile with our
# unconfined-mode one would silently undo distro hardening, so defer
# to the existing profile. (A false grep hit in a comment fails
# safe: we merely skip our profile.)
echo "An existing AppArmor profile already covers /usr/bin/bwrap; leaving it in charge."
elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$BWRAP_PROFILE" <<'BWRAP_APPARMOR_EOF'
# This profile is managed by the $package_name package (postinst); direct
# edits will be overwritten on upgrade. Put local changes in
# /etc/apparmor.d/local/${package_name}-bwrap instead.
abi <abi/4.0>,
include <tunables/global>
profile ${package_name}-bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
include if exists <local/${package_name}-bwrap>
}
BWRAP_APPARMOR_EOF
then
if apparmor_parser -Q "\$BWRAP_PROFILE" >/dev/null 2>&1; then
apparmor_parser -r "\$BWRAP_PROFILE" >/dev/null 2>&1 || echo "Note: bwrap AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot."
echo "Cowork bwrap AppArmor profile installed at \$BWRAP_PROFILE"
else
rm -f "\$BWRAP_PROFILE"
echo "AppArmor on this system does not support the userns rule; skipping bwrap profile (not required here)."
fi
else
# A failed write may leave a truncated profile behind; clear it.
# The || true is mandatory: this branch is errexit-live, and a bare
# rm fails the upgrade on a read-only /etc.
rm -f "\$BWRAP_PROFILE" 2>/dev/null || true
echo "Warning: could not write \$BWRAP_PROFILE; skipping bwrap AppArmor profile."
fi
fi
exit 0
EOF
chmod +x "$package_root/DEBIAN/postinst" || exit 1
echo 'Postinst script created'
# --- Create Postrm Script ---
echo 'Creating postrm script...'
# The AppArmor profiles are generated by postinst, not tracked by dpkg, so we
# unload and delete them ourselves. Cleanup lives in postrm (not prerm) so it
# also fires on purge and abort-install. Skip on upgrade — the incoming
# postinst rewrites and reloads them. 'disappear' is deliberately not handled:
# matching it would also clean during the overwrite-by-another-package flow.
# Two profiles: the Electron one (Chromium sandbox, #687) and the bwrap one
# (Cowork sandbox helper, #694).
# Per Debian Policy 10.7.3 the profiles are configuration: unload them
# whenever the confined binaries go away, but delete the files only on
# purge — a profile for an absent binary is a harmless no-op (google-chrome
# leaves its profile behind the same way).
cat > "$package_root/DEBIAN/postrm" << EOF
#!/bin/sh
set -e
case "\$1" in
remove|purge|abort-install)
for _profile in "/etc/apparmor.d/$package_name" \
"/etc/apparmor.d/${package_name}-bwrap"; do
if [ -e "\$_profile" ] \
&& command -v apparmor_parser >/dev/null 2>&1; then
apparmor_parser -R "\$_profile" >/dev/null 2>&1 || true
fi
# Policy 10.7.3: config survives remove; delete on purge only.
if [ "\$1" = purge ]; then
rm -f "\$_profile" 2>/dev/null || true
fi
done
;;
esac
exit 0
EOF
chmod +x "$package_root/DEBIAN/postrm" || exit 1
echo 'Postrm script created'
# --- Build .deb Package ---
echo 'Building .deb package...'
deb_file="$work_dir/${package_name}_${version}_${architecture}.deb"
@@ -243,8 +435,27 @@ chmod 755 "$package_root/DEBIAN" || exit 1
# Fix script permissions in DEBIAN directory
echo 'Setting script permissions...'
chmod 755 "$package_root/DEBIAN/postinst" || exit 1
chmod 755 "$package_root/DEBIAN/postrm" || exit 1
if ! dpkg-deb --build "$package_root" "$deb_file"; then
# Normalize the installed tree before building. A restrictive build umask
# can leave directories at 0700, and dpkg-deb records file ownership
# verbatim unless told otherwise. Both bite at runtime: the launcher runs
# as the desktop user, who then can't traverse into app.asar.unpacked/ —
# silently breaking Cowork's daemon auto-launch (the fork is guarded by
# fs.existsSync(), which returns false on a directory it can't read, so
# the symptom is an endless connect ENOENT on the VM-service socket with
# no daemon log and no [cowork-autolaunch] line). Canonical modes: dirs
# and already-executable files 755, every other file 644. The blanket
# pass clears chrome-sandbox's setuid bit, but postinst re-asserts 4755
# after install, so the net result is unchanged.
echo 'Normalizing installed tree permissions...'
find "$install_dir" -type d -exec chmod 755 {} + || exit 1
find "$install_dir" -type f -exec chmod u=rwX,go=rX {} + || exit 1
# --root-owner-group forces root:root in the archive so a leaked build
# uid can't deny access on the installed system (the build does not run
# under fakeroot).
if ! dpkg-deb --root-owner-group --build "$package_root" "$deb_file"; then
echo 'Failed to build .deb package' >&2
exit 1
fi

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AppStream metainfo for the claude-desktop package.
Indexed by GNOME Software / Ubuntu App Center / KDE Discover under the
Installed tab so users can see the package with name, summary, icon, and
release history rather than as an unidentified entry.
See: https://www.freedesktop.org/software/appstream/docs/
-->
<component type="desktop-application">
<id>io.github.aaddrick.claude-desktop-debian</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<name>Claude Desktop</name>
<summary>Unofficial desktop client for Claude AI</summary>
<description>
<p>
Claude Desktop is an unofficial community repackaging of Anthropic's
Claude Desktop client for Debian and Ubuntu. The upstream Windows
binary is repacked and patched for Linux compatibility (frame, tray,
Cowork mode, MCP stdio, Quick Entry, etc.).
</p>
<p>Features:</p>
<ul>
<li>Conversations with the Claude model family (Sonnet, Opus, Haiku)</li>
<li>Projects with persistent context and file uploads</li>
<li>Cowork mode — local agent VM for sandboxed code tasks</li>
<li>MCP (Model Context Protocol) stdio servers for tool integration</li>
<li>System tray, Quick Entry hotkey, and tab system</li>
</ul>
<p>
This packaging is community-maintained and is not affiliated with or
endorsed by Anthropic. See the packaging source and issue tracker
linked below.
</p>
</description>
<launchable type="desktop-id">claude-desktop.desktop</launchable>
<url type="homepage">https://github.com/aaddrick/claude-desktop-debian</url>
<url type="bugtracker">https://github.com/aaddrick/claude-desktop-debian/issues</url>
<url type="vcs-browser">https://github.com/aaddrick/claude-desktop-debian</url>
<developer id="io.github.aaddrick">
<name>aaddrick</name>
</developer>
<categories>
<category>Office</category>
<category>Utility</category>
</categories>
<content_rating type="oars-1.1" />
<provides>
<binary>claude-desktop</binary>
</provides>
</component>

View File

@@ -68,9 +68,13 @@ Type=Application
Terminal=false
Categories=Office;Utility;
MimeType=x-scheme-handler/claude;
StartupWMClass=Claude
StartupWMClass=$WM_CLASS
EOF
# --- Stage AppStream metainfo (installed via %files block below) ---
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
cp "$script_dir/$metainfo_name" "$staging_dir/$metainfo_name" || exit 1
# --- Create Launcher Script ---
echo 'Creating launcher script...'
cat > "$staging_dir/claude-desktop" << EOF
@@ -89,7 +93,12 @@ fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
# App path
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
@@ -115,12 +124,14 @@ fi
# Determine Electron executable path
electron_exec='electron'
using_global_electron=false
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
if [[ -f \$local_electron_path ]]; then
electron_exec="\$local_electron_path"
log_message "Using local Electron: \$electron_exec"
else
if command -v electron &> /dev/null; then
using_global_electron=true
log_message "Using global Electron: \$electron_exec"
else
log_message 'Error: Electron executable not found'
@@ -135,27 +146,35 @@ else
fi
fi
# App path
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
# Build electron args - use 'deb' type (same sandbox behavior)
build_electron_args 'deb'
# Add app path LAST
electron_args+=("\$app_path")
# Bundled Electron: app.asar sits in its default resources/ dir next
# to the binary, so Electron auto-loads it. Passing the path again
# makes Electron treat it as a file-to-open, which the app forwards
# to its file-drop handler, producing a spurious "Attach app.asar?"
# prompt on launch and on every taskbar reopen (the second-instance
# argv path). Omitting it is the root-cause fix. See issue #696.
# Global (PATH) Electron has no co-located app.asar and would boot
# its default_app welcome screen instead — only there the explicit
# app path is load-bearing and must stay.
if [[ \$using_global_electron == true ]]; then
electron_args+=("\$app_path")
log_message "App (explicit arg, global Electron): \$app_path"
else
log_message "App (auto-loaded by Electron): \$app_path"
fi
# Change to application directory
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 and keep the launcher alive so explicit quit can
# clean up Desktop-owned helpers that outlive the Electron main process.
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
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
exit \$?
EOF
chmod +x "$staging_dir/claude-desktop"
@@ -221,14 +240,25 @@ 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
install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/applications/claude-desktop.desktop
# Install AppStream metainfo (GNOME Software / KDE Discover)
install -Dm 644 $staging_dir/$metainfo_name %{buildroot}/usr/share/metainfo/$metainfo_name
# Install launcher script
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
# Normalize file modes — the cp -r above honors the build umask, and
# the "-" first field of %defattr ships buildroot *file* modes verbatim
# (only directory modes are forced to 0755), so a umask-077 build would
# package an unreadable app.asar and a non-executable electron binary.
# Must run before the chrome-sandbox chmod below so 4755 survives.
find %{buildroot}/usr/lib/$package_name -type f -exec chmod u=rwX,go=rX {} +
# Set the chrome-sandbox suid bit in the buildroot so the /usr/lib
# directory walk in %files records 4755 in the payload (preserves #539
# without the "File listed twice" warning #609 — see %files block).
@@ -236,17 +266,18 @@ 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)
%attr(755, root, root) /usr/bin/claude-desktop
/usr/lib/$package_name
/usr/share/applications/claude-desktop.desktop
/usr/share/metainfo/$metainfo_name
/usr/share/icons/hicolor/*/apps/claude-desktop.png
SPECEOF

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,22 @@ 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
# Reject .asar paths in the argv file-drop collector so the
# existsSync branch doesn't dispatch app.asar as a file drop,
# triggering a permission prompt on every window reopen (#383, #622).
patch_asar_argv_file_drop_guard
# 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 +126,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'

296
scripts/patches/config.sh Normal file
View File

@@ -0,0 +1,296 @@
#===============================================================================
# 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
# Anchor on the method declaration itself — the method name
# `addTrustedFolder` is not minified and is unique in the bundle.
# Earlier releases let us anchor on the trailing `${param}`);` of the
# log line, but upstream now folds that log call into the comma
# expression `if(D.info(`…${i}`),await ZOe(i)===null){…}`, so the
# `);` no longer exists. Injecting at the function body head is both
# more robust and semantically earlier (reject .asar on entry).
local folder_param
folder_param=$(grep -oP \
'async 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 = 'async 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'
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;
let dispatchPatchCount = 0;
let dispatchAlreadyPresent = code.includes(
'.filter(_d=>!_d.endsWith(".asar"))'
);
// ================================================================
// Sub-patch 1: Filter .asar from --add-dir loop
//
// Targets (one or more occurrences):
// 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*\)/g;
// Fallback: .forEach pattern
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/g;
let forOfCount = 0;
let forEachCount = 0;
code = code.replace(forOfRe, (match, iterVar, arrVar, pushTarget) => {
forOfCount++;
dispatchPatchCount++;
patchCount++;
return 'for(let ' + iterVar + ' of ' + arrVar +
'.filter(_d=>!_d.endsWith(".asar")))' +
pushTarget + '.push("--add-dir",' + iterVar + ')';
});
code = code.replace(forEachRe, (match, arrVar, iterVar, pushTarget) => {
forEachCount++;
dispatchPatchCount++;
patchCount++;
return arrVar +
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
iterVar + '=>' + pushTarget +
'.push("--add-dir",' + iterVar + '))';
});
if (dispatchPatchCount === 0 && !dispatchAlreadyPresent) {
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);
}
if (dispatchPatchCount > 0) {
console.log(' Filtered ' + dispatchPatchCount +
' --add-dir dispatch loop(s) (for-of=' + forOfCount +
', forEach=' + forEachCount + ')');
} else {
console.log(' .asar --add-dir filter already present ' +
'(idempotent)');
}
}
// ================================================================
// 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 (dispatchPatchCount < 1 && !dispatchAlreadyPresent) {
console.error('FATAL: --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 '##############################################################'
}

File diff suppressed because it is too large Load Diff

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,50 +39,35 @@ 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
# Trailing-edge mutex guard. Still prevents concurrent/reentrant
# rebuilds (the slow path's 250ms DBus await can interleave), but —
# unlike a plain leading-edge drop — it remembers a request that
# arrives while a rebuild is in flight and re-runs once when the
# window clears, so the FINAL nativeTheme value wins. At startup
# shouldUseDarkColors reads false for ~50ms, then a burst of
# "updated" events flips it true; a dropping mutex latches the
# initial (wrong) value and leaves the tray icon stuck black on a
# dark panel. See docs/learnings/tray-rebuild-race.md.
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){${tray_func}._pending=true;return}${tray_func}._running=true;setTimeout(()=>{${tray_func}._running=false;if(${tray_func}._pending){${tray_func}._pending=false;${tray_func}()}},1500);/g" \
"$index_js"
echo " Added mutex guard to ${tray_func}()"
echo " Added trailing-edge 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
echo 'Tray menu handler patched'
echo '##############################################################'
# Skip tray updates during startup (3 second window)
echo 'Patching nativeTheme handler for startup delay...'
if ! grep -q '_trayStartTime' "$index_js"; then
sed -i -E \
"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" \
"$index_js"
echo ' Added startup delay check (3 second window)'
fi
echo '##############################################################'
}
patch_tray_icon_selection() {
@@ -91,9 +75,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
@@ -109,7 +93,7 @@ patch_tray_inplace_update() {
# Re-extract the tray variable name — `patch_tray_menu_handler`
# declares it `local` so it's not visible here. Same grep pattern.
local tray_func tray_func_re local_tray_var tray_var_re
local menu_func path_var enabled_var enabled_count
local menu_func menu_var menu_var_re path_var enabled_var enabled_count
tray_func=$(grep -oP \
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
if [[ -z $tray_func ]]; then
@@ -120,8 +104,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,10 +114,38 @@ patch_tray_inplace_update() {
tray_var_re="${local_tray_var//\$/\\$}"
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
# Two upstream shapes wire the context menu differently:
# old: ${tray_var}.setContextMenu(BUILDER()) — builder called inline
# new: M=BUILDER(); ${tray_var}.setContextMenu(M) — prebuilt menu object
# Resolve the BUILDER name in both. The injected fast-path emits
# setContextMenu(BUILDER()), so landing on the menu *object* (M) instead
# of its builder would emit setContextMenu(M()) and throw at runtime —
# M is a Menu instance, not a function.
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'
menu_var=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\))" \
"$index_js" | head -1)
if [[ -n $menu_var ]]; then
menu_var_re="${menu_var//\$/\\$}"
# Word-boundary lookbehind, not a fixed [,;({] class, so the
# assignment resolves whether it follows a separator or a
# declarator (`let `/`const ` leaves a space before the var).
# First assignment site wins, matching the inline-form grep.
menu_func=$(grep -oP "(?<![\$\w])${menu_var_re}=\K[\$\w]+(?=\(\))" \
"$index_js" | head -1)
fi
fi
if [[ -z $menu_func ]]; then
# Both the inline grep and the menu_var fallback came up empty.
# A silent skip here is how the #515 duplicate-icon race
# regressed before — make it loud on stderr so the next silent
# regression surfaces in CI logs. Still skip gracefully so the
# build completes.
echo "WARNING: could not resolve tray menu function" \
"(inline + fallback both failed) — in-place" \
"fast-path NOT applied; duplicate-icon race" \
"(#515) may regress" >&2
echo '##############################################################'
return
fi
@@ -146,7 +157,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 +171,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 +180,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 +259,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.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe'
claude_exe_sha256='b31082fb572c3cb62952e83703de2fcb64868cc361f4a5d26996c810f318cd75'
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.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe'
claude_exe_sha256='95cf3bd0e1bfe82b2762baa6f6d59d359ae5af1059ed135c5be9617190672280'
architecture='arm64'
claude_exe_filename='Claude-Setup-arm64.exe'
echo 'Configured for arm64 (aarch64) build.'

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bats
#
# claude-native-stub.bats
# Tests for the Linux @ant/claude-native stub (scripts/claude-native-stub.js)
# copied into app.asar and app.asar.unpacked during packaging.
#
# The Windows-only registry / MSIX / UAC methods are the load-bearing
# part here: upstream (>= 1.13576.0) calls readRegistryValues() and
# getWindowsElevationType() unconditionally at startup, so a missing
# method throws before any window is created and the app hangs (#729).
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
STUB_JS="${SCRIPT_DIR}/../scripts/claude-native-stub.js"
# Evaluate a snippet of JS with the stub loaded as `stub`. The snippet
# must `process.exit(1)` (via thrown error) on failure; a clean exit is
# a pass. Keeps each @test to a single Node spawn.
run_stub_js() {
run node -e "
const stub = require('${STUB_JS}');
$1
"
[[ "$status" -eq 0 ]] || {
echo "$output"
return 1
}
}
@test "claude-native stub: readRegistryValues returns an empty array" {
run_stub_js '
const v = stub.readRegistryValues(["HKCU\\\\Software\\\\Anthropic"]);
if (!Array.isArray(v) || v.length !== 0) {
throw new Error("expected [], got " + JSON.stringify(v));
}
'
}
@test "claude-native stub: getWindowsElevationType returns \"default\"" {
run_stub_js '
if (stub.getWindowsElevationType() !== "default") {
throw new Error("expected default");
}
'
}
@test "claude-native stub: getCurrentPackageFamilyName returns null" {
run_stub_js '
if (stub.getCurrentPackageFamilyName() !== null) {
throw new Error("expected null");
}
'
}
@test "claude-native stub: registry writers are callable no-ops" {
run_stub_js '
stub.writeRegistryValue("k", "v");
stub.writeRegistryDword("k", 1);
'
}
@test "claude-native stub: all Windows-only policy methods are functions" {
run_stub_js '
const required = [
"readRegistryValues",
"writeRegistryValue",
"writeRegistryDword",
"getWindowsElevationType",
"getCurrentPackageFamilyName",
];
for (const name of required) {
if (typeof stub[name] !== "function") {
throw new Error(name + " is not a function");
}
}
'
}
@test "claude-native stub: existing exports are preserved" {
run_stub_js '
if (stub.getWindowsVersion() !== "10.0.0") {
throw new Error("getWindowsVersion regressed");
}
if (typeof stub.flashFrame !== "function") {
throw new Error("flashFrame missing");
}
if (!stub.KeyboardKey || stub.KeyboardKey.Enter !== 261) {
throw new Error("KeyboardKey regressed");
}
'
}

67
tests/config-patches.bats Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bats
#
# config-patches.bats
# Tests for scripts/patches/config.sh patch helpers.
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
PATCH_SH="$SCRIPT_DIR/../scripts/patches/config.sh"
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
project_root="$TEST_TMP"
export project_root
mkdir -p "$TEST_TMP/app.asar.contents/.vite/build"
cd "$TEST_TMP" || return 1
# shellcheck source=scripts/patches/config.sh
source "$PATCH_SH"
}
teardown() {
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
write_index_js() {
local fixture='app.asar.contents/.vite/build/index.js'
{
printf '%s' \
'function a(A,Y){for(let O of A)Y.push("--add-dir",O)}'
printf '%s' \
'function b(A,Y){for(let O of A)Y.push("--add-dir",O)}'
printf '%s' \
'function c(S){(S.userSelectedFolders||[]).filter(p=>true);'
printf '%s' \
'console.log("Filtering out deleted folder from session")}'
} > "$fixture"
}
@test "additional dirs guard filters every --add-dir dispatch loop" {
write_index_js
run patch_asar_additional_dirs_guard
[[ "$status" -eq 0 ]] || {
echo "$output"
return 1
}
local patched='app.asar.contents/.vite/build/index.js'
run grep -oF '.filter(_d=>!_d.endsWith(".asar"))' "$patched"
[[ "$status" -eq 0 ]] || {
echo 'expected .asar filters to be injected'
return 1
}
[[ "${#lines[@]}" -eq 2 ]] || {
echo "expected 2 dispatch filters, got ${#lines[@]}"
return 1
}
run grep -qF 'for(let O of A)Y.push("--add-dir",O)' "$patched"
[[ "$status" -eq 1 ]] || {
echo 'unfiltered --add-dir dispatch remained'
return 1
}
}

View File

@@ -124,3 +124,60 @@ assertEqual(r.kind, 'unknown', 'null error does not crash');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# detectBackend — COWORK_VM_BACKEND override contract
#
# KVM uses a downloaded VM image; on Linux cowork normally runs through
# the bwrap daemon, and the renderer-gate fix (cowork.sh Patch 1b) is
# paired with a download block (Patch 1c) so the multi-GB VM bundle is
# never pulled. The daemon half of that policy is here: KVM is reachable
# only via an explicit COWORK_VM_BACKEND=kvm opt-in — auto-detect never
# selects it while bwrap works (#351). These pin the override contract;
# COWORK_VM_BACKEND is read at module load, so each case is a fresh
# process with the env preset.
# =============================================================================
# Resolve the backend class name for a given COWORK_VM_BACKEND value.
# detectBackend's log()/logError() chatter can land on stdout/stderr, so
# emit a sentinel and parse only that — robust against any log noise.
backend_name() {
COWORK_VM_BACKEND="$1" node -e '
const { detectBackend } = require("'"${SCRIPT_DIR}"'/../scripts/cowork-vm-service.js");
const b = detectBackend(() => {});
process.stdout.write("\n__BACKEND__:" +
(b && b.constructor ? b.constructor.name : "null") + "\n");
' 2>/dev/null | grep -oE '__BACKEND__:[A-Za-z]+' | cut -d: -f2
}
@test "detectBackend: COWORK_VM_BACKEND=kvm opts into KvmBackend" {
[[ "$(backend_name kvm)" == "KvmBackend" ]] || {
echo "expected KvmBackend, got: $(backend_name kvm)"
return 1
}
}
@test "detectBackend: COWORK_VM_BACKEND=bwrap selects BwrapBackend" {
[[ "$(backend_name bwrap)" == "BwrapBackend" ]] || {
echo "expected BwrapBackend, got: $(backend_name bwrap)"
return 1
}
}
@test "detectBackend: COWORK_VM_BACKEND=host selects HostBackend" {
[[ "$(backend_name host)" == "HostBackend" ]] || {
echo "expected HostBackend, got: $(backend_name host)"
return 1
}
}
@test "detectBackend: an unknown override never silently lands on KVM" {
# Garbage override falls through to auto-detect, which prefers bwrap
# and stops at host on probe failure — it must not become KVM (#351).
local got
got="$(backend_name not-a-backend)"
[[ "$got" == "BwrapBackend" || "$got" == "HostBackend" ]] || {
echo "unknown override resolved to unexpected backend: $got"
return 1
}
}

126
tests/cowork-patches.bats Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bats
#
# cowork-patches.bats
# Application tests for the Cowork index.js patches in
# scripts/patches/cowork.sh — specifically the yukonSilver
# renderer-gate fix (Patch 1b) and the paired VM-download block
# (Patch 1c). verify-patches.bats proves each marker regex matches its
# sample; this proves patch_cowork_linux() actually PRODUCES those
# markers from an unpatched bundle and is idempotent on re-run.
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
PATCH_SH="$SCRIPT_DIR/../scripts/patches/cowork.sh"
INDEX='app.asar.contents/.vite/build/index.js'
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
mkdir -p "$TEST_TMP/app.asar.contents/.vite/build"
cd "$TEST_TMP" || return 1
# cowork-vm-service.js path is read by SVC_PATH-aware patches; a
# bare placeholder is enough for the index.js transforms.
: > "$TEST_TMP/cowork-vm-service.js"
# shellcheck source=scripts/patches/cowork.sh
source "$PATCH_SH"
}
teardown() {
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
# Minimal minified fixture carrying the anchors patch_cowork_linux()
# needs: the "vmClient (TypeScript)" guard, the FATAL startVM gate
# (Patch 1), the q4r support evaluator (Patch 1b), and the two
# download gates u8A / mzn (Patch 1c-A / 1c-B). Other patches warn
# harmlessly on this fixture; only Patch 1 is fatal-on-miss.
write_cowork_fixture() {
{
printf '%s' \
'function VF(A,e,t){const{yukonSilver:r}=D_();if((r==null?void 0:r.status)!=="supported"){Ve.warn("[startVM] VM not supported ("+(r==null?void 0:r.status)+")");return}return ov()}'
printf '%s' \
'function q4r(){var i;const A="win32",e=process.arch;if(e!=="x64"&&e!=="arm64")return{status:"unsupported",unsupportedCode:"unsupported_architecture"};if(!bl())return{status:"unsupported",unsupportedCode:"msix_required"};return{status:"supported"}}'
printf '%s' \
'function u8A(A,e){const{yukonSilver:t}=z_();return(t==null?void 0:t.status)!=="supported"?!1:(ul(x,y).catch(()=>{}),TP?(Ve.info("[downloadVM] Download already in progress, waiting..."),TP):f6()?!1:P8r(A,e))}'
printf '%s' \
'async function mzn(A,e,t){const{yukonSilver:i}=z_();if(!i||i.status!=="supported"){await YcA([]);return}if(!nOt()){await YcA([ao.sha]);return}}'
printf '%s' \
'async function YBt(){return bl()?(QL||(Ve.info("vmClient (TypeScript)"),QL={vm:hji}),QL):null}'
} > "$INDEX"
}
@test "patch_cowork_linux injects the evaluator + download-block markers" {
write_cowork_fixture
run patch_cowork_linux
[[ "$status" -eq 0 ]] || {
echo "patch_cowork_linux exited $status"
echo "$output"
return 1
}
# Patch 1b: evaluator reports supported on Linux at q4r's top.
run grep -cP 'if\(process\.platform==="linux"\)return\{status:"supported"\};const [\w$]+="win32"' "$INDEX"
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
echo "evaluator marker count: $output"
return 1
}
# Patch 1c-A: VM-download driver short-circuits on Linux.
run grep -cP 'process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1:' "$INDEX"
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
echo "vm-download-block marker count: $output"
return 1
}
# Patch 1c-B: warm prefetch early-returns on Linux.
run grep -cP 'if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)\{await [\w$]+\(\[\]\);return\}' "$INDEX"
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
echo "warm-download-block marker count: $output"
return 1
}
}
@test "patch_cowork_linux still parses as valid JS after patching" {
write_cowork_fixture
run patch_cowork_linux
[[ "$status" -eq 0 ]] || { echo "$output"; return 1; }
run node --check "$INDEX"
[[ "$status" -eq 0 ]] || {
echo "patched fixture failed node --check"
echo "$output"
return 1
}
}
@test "patch_cowork_linux is idempotent for the new markers" {
write_cowork_fixture
run patch_cowork_linux
[[ "$status" -eq 0 ]] || { echo "$output"; return 1; }
cp "$INDEX" first.js
# Second run must not double-inject and must be byte-identical.
run patch_cowork_linux
[[ "$status" -eq 0 ]] || { echo "$output"; return 1; }
run diff first.js "$INDEX"
[[ "$status" -eq 0 ]] || {
echo "re-run changed the bundle (not idempotent):"
echo "$output"
return 1
}
for marker in \
'if\(process\.platform==="linux"\)return\{status:"supported"\}' \
'process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1:' \
'if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)'; do
run grep -cP "$marker" "$INDEX"
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
echo "marker not unique after re-run: $marker (count $output)"
return 1
}
done
}

View File

@@ -443,3 +443,172 @@ SHIM
[[ $output == *'Password store:'* ]]
[[ $output == *'basic'* ]]
}
@test "_doctor_check_password_store: warns, not PASS, when detection returns empty" {
# An empty backend means detection failed (e.g. sourcing-order
# regression) — it must not surface as a green PASS with a blank value.
_detect_password_store() { echo ''; }
run _doctor_check_password_store
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output != *'[PASS]'* ]]
}
# =============================================================================
# _doctor_check_disk_space
# =============================================================================
@test "_doctor_check_disk_space: fails when under 100MB free" {
df() { printf 'Avail\n50M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[FAIL]'* ]]
[[ $output == *'50MB free'* ]]
}
@test "_doctor_check_disk_space: warns when under 500MB free" {
df() { printf 'Avail\n300M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[WARN]'* ]]
[[ $output == *'300MB free'* ]]
}
@test "_doctor_check_disk_space: warns at exactly 100MB (tier boundary)" {
# 100 is not < 100, so the FAIL tier must not fire; < 500 → WARN.
df() { printf 'Avail\n100M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[WARN]'* ]]
[[ $output != *'[FAIL]'* ]]
[[ $output == *'100MB free'* ]]
}
@test "_doctor_check_disk_space: passes at exactly 500MB (tier boundary)" {
# 500 is not < 500, so the WARN tier must not fire → PASS.
df() { printf 'Avail\n500M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[PASS]'* ]]
[[ $output != *'[WARN]'* ]]
[[ $output == *'500MB free'* ]]
}
@test "_doctor_check_disk_space: no false PASS on leading-zero df output" {
# '0099' clears the numeric regex but would make (( )) parse the
# value as octal and error out, falling through to the PASS
# branch. The 10# normalization must read it as 99 → FAIL tier.
df() { printf 'Avail\n0099M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[FAIL]'* ]]
[[ $output != *'[PASS]'* ]]
[[ $output == *'99MB free'* ]]
}
@test "_doctor_check_disk_space: passes with ample free space" {
df() { printf 'Avail\n2048M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[PASS]'* ]]
[[ $output == *'2048MB free'* ]]
}
@test "_doctor_check_disk_space: no false PASS on non-numeric df output" {
# A malformed/empty avail field must not slip through as a PASS,
# and the skip must be visible rather than hiding behind a clean
# summary.
df() { printf 'Avail\nN/A\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $status -eq 0 ]]
[[ $output != *'[PASS]'* ]]
[[ $output != *'[FAIL]'* ]]
[[ $output != *'[WARN]'* ]]
[[ $output == *'Disk space: unable to read (df)'* ]]
}
@test "_doctor_check_disk_space: visible skip when df is unavailable" {
df() { return 127; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $status -eq 0 ]]
[[ $output == *'Disk space: unable to read (df)'* ]]
[[ $output != *'[PASS]'* ]]
[[ $output != *'[FAIL]'* ]]
[[ $output != *'[WARN]'* ]]
}
# =============================================================================
# _doctor_check_pkg_version: package-manager ownership (#711)
# =============================================================================
# Make `command -v` report the named package tools (rpm, dpkg-query)
# as missing so tests can simulate single-manager or tool-less hosts
# regardless of what the CI/dev box really has installed. Same shadow
# trick as _skip_gtk_query: `command -v` finds functions too, so
# shadowing `command` itself is the only reliable way.
_hide_pkg_tools() {
_hidden_pkg_tools=" $* "
command() {
if [[ $1 == '-v' \
&& $_hidden_pkg_tools == *" $2 "* ]]; then
return 1
fi
builtin command "$@"
}
}
@test "_doctor_check_pkg_version: rpm owns the path — rpm version wins over stale dpkg record (#711)" {
# The #711 repro: Fedora host, rpm owns the install, but a stale
# dpkg record from an old deb experiment still answers. The rpm
# answer must win; the stale dpkg version must not appear at all.
rpm() { printf '1.11847.5-2.0.19'; }
dpkg-query() { printf '1.5354.0'; }
run _doctor_check_pkg_version \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Installed version: 1.11847.5-2.0.19'* ]]
[[ $output != *'1.5354.0'* ]]
}
@test "_doctor_check_pkg_version: dpkg-only host reports dpkg version" {
_hide_pkg_tools rpm
dpkg-query() { printf '1.11847.5'; }
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Installed version: 1.11847.5'* ]]
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_pkg_version: dual-DB host where rpm does not own the path falls back to dpkg" {
# rpm exists but the install is a real deb: `rpm -qf` says "not
# owned" (rc=1, message on stdout) and dpkg must be consulted.
rpm() {
# $4 = probe path ($1=-qf $2=--qf $3=<format>)
printf 'file %s is not owned by any package\n' "$4"
return 1
}
dpkg-query() { printf '1.11847.5'; }
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Installed version: 1.11847.5'* ]]
[[ $output != *'not owned'* ]]
}
@test "_doctor_check_pkg_version: neither manager owns the install — warn (AppImage/Nix)" {
rpm() { return 1; }
dpkg-query() { return 1; }
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'AppImage'* ]]
[[ $output != *'[PASS]'* ]]
}
@test "_doctor_check_pkg_version: silent when no package tools exist" {
_hide_pkg_tools rpm dpkg-query
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ -z $output ]]
}

View File

@@ -18,6 +18,17 @@ has_electron_arg() {
return 1
}
# Count how many electron_args entries start with --enable-features=.
# Chromium honours only the last such switch, so the launcher must emit
# exactly one; this lets tests assert that invariant.
count_enable_features() {
local n=0 arg
for arg in "${electron_args[@]}"; do
[[ $arg == --enable-features=* ]] && ((n++))
done
echo "$n"
}
# Install a dbus-send stub at the front of PATH.
# kwallet6 — echoes 'boolean true', exits 0 (kwallet6 detectable)
# secrets-ok — fails for kwalletd6 dest, succeeds for all other dests
@@ -76,8 +87,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() {
@@ -283,7 +299,7 @@ teardown() {
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: non-Niri Wayland keeps XWayland default" {
@test "detect_display_backend: non-Niri non-GNOME Wayland keeps XWayland default" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="sway"
setup_logging
@@ -301,10 +317,68 @@ teardown() {
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: GNOME Wayland keeps XWayland default (not auto-flipped)" {
# GNOME native+portal is opt-in only; the default session stays on
# mature XWayland to avoid rendering/IME regressions (#404 portal
# route is opt-in via CLAUDE_USE_WAYLAND=1).
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="GNOME"
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: GNOME Wayland + CLAUDE_USE_WAYLAND=1 opts into native" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="ubuntu:GNOME"
CLAUDE_USE_WAYLAND=1
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: GNOME on X11 (not Wayland) stays X11" {
DISPLAY=":0"
XDG_CURRENT_DESKTOP="GNOME"
setup_logging
detect_display_backend
[[ $is_wayland == false ]]
# use_x11_on_wayland is the default true; the auto-detect block is
# guarded by is_wayland so it never flips it on an X11 session.
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on GNOME" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="GNOME"
CLAUDE_USE_WAYLAND=0
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on Niri" {
WAYLAND_DISPLAY="wayland-0"
NIRI_SOCKET="/tmp/niri.sock"
CLAUDE_USE_WAYLAND=0
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == true ]]
}
# =============================================================================
# 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
@@ -330,6 +404,17 @@ teardown() {
has_electron_arg '--no-sandbox'
}
@test "build_electron_args: Wayland XWayland deb - no GlobalShortcutsPortal feature" {
# The portal feature is inert under XWayland, so it must not be
# emitted on the X11-via-XWayland path.
is_wayland=true
use_x11_on_wayland=true
setup_logging
build_electron_args deb
# shellcheck disable=SC2314 # last command in test, ! works correctly
! has_electron_arg '*GlobalShortcutsPortal*'
}
@test "build_electron_args: Wayland native deb - includes wayland platform flags" {
is_wayland=true
use_x11_on_wayland=false
@@ -340,6 +425,45 @@ teardown() {
has_electron_arg '*WaylandWindowDecorations*'
}
@test "build_electron_args: Wayland native deb - enables GlobalShortcutsPortal (#404)" {
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '*GlobalShortcutsPortal*'
}
@test "build_electron_args: Wayland native deb - portal + ozone share one --enable-features" {
# Chromium honours only the last --enable-features switch, so the
# portal feature, UseOzonePlatform and WaylandWindowDecorations must
# all live in a single comma-joined flag — not separate switches.
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
# Exactly one --enable-features switch (Chromium honours only the
# last), carrying both features. Order inside the value is irrelevant
# to Chromium, so assert each subkey independently rather than with an
# ordered glob.
[[ $(count_enable_features) -eq 1 ]]
has_electron_arg '--enable-features=*UseOzonePlatform*'
has_electron_arg '--enable-features=*GlobalShortcutsPortal*'
}
@test "build_electron_args: hidden titlebar + native Wayland - one merged --enable-features" {
# WindowControlsOverlay (hidden titlebar) and the wayland/portal
# features must coexist in a single flag rather than clobber.
CLAUDE_TITLEBAR_STYLE=hidden
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
[[ $(count_enable_features) -eq 1 ]]
has_electron_arg '*WindowControlsOverlay*'
has_electron_arg '*GlobalShortcutsPortal*'
has_electron_arg '*WaylandWindowDecorations*'
}
@test "build_electron_args: Wayland appimage - always includes --no-sandbox" {
is_wayland=true
use_x11_on_wayland=true
@@ -607,6 +731,219 @@ s.close()
[[ ! -S "$sock" ]]
}
# =============================================================================
# cleanup_orphaned_cowork_daemon
#
# Reaps a cowork-vm-service daemon left behind by a crashed UI, but only
# when no live Claude UI is running. pgrep/kill/sleep are stubbed; the
# "live UI" case uses a real background process so the /proc cmdline and
# status reads resolve naturally without faking /proc.
# =============================================================================
@test "cleanup_orphaned_cowork_daemon: no daemon running — no action, no log" {
# Daemon pgrep finds nothing, so the function returns before any
# UI scan or kill.
pgrep() { return 1; }
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
setup_logging
run cleanup_orphaned_cowork_daemon
[[ $status -eq 0 ]]
[[ ! -f "$TEST_TMP/kills" ]]
[[ ! -f $log_file ]]
}
@test "cleanup_orphaned_cowork_daemon: live UI present — daemon left running" {
# A real background process stands in for the live Electron UI so
# the /proc cmdline and status reads resolve naturally. The UI
# scan fingerprints on the launcher-passed --class flag (since
# #700 app.asar no longer appears in any cmdline), so the
# stand-in's argv[0] is renamed to carry it via exec -a. Its state
# is sleeping (not T/t/Z), so the function treats it as a live UI
# and must NOT kill the daemon.
bash -c 'exec -a "--class=Claude" sleep 300' &
ui_pid=$!
# Match on "$*", not "$2": the UI scan passes -u <uid> and a `--`
# end-of-options separator before the pattern, so the pattern is
# not at a fixed argument position.
pgrep() {
if [[ $* == *cowork-vm-service* ]]; then
echo 4242
elif [[ $* == *--class=Claude* ]]; then
echo "$ui_pid"
fi
}
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
setup_logging
cleanup_orphaned_cowork_daemon
local rc=$?
builtin kill "$ui_pid" 2>/dev/null
[[ $rc -eq 0 ]]
# Daemon kill must never have been attempted.
[[ ! -f "$TEST_TMP/kills" ]]
}
@test "cleanup_orphaned_cowork_daemon: orphan exits on SIGTERM — no SIGKILL" {
# Daemon present, no live UI. The daemon disappears once SIGTERM is
# sent, so the escalation to SIGKILL must not fire.
local term_sent="$TEST_TMP/term_sent"
pgrep() {
if [[ $* == *cowork-vm-service* ]]; then
[[ -f $term_sent ]] && return 1
echo 4242
else
# UI scan (--class fingerprint): no live UI.
return 1
fi
}
kill() {
echo "kill $*" >> "$TEST_TMP/kills"
# A plain SIGTERM ($1 is the PID, not -KILL) reaps the daemon.
[[ $1 == -KILL ]] || : > "$term_sent"
}
sleep() { :; }
setup_logging
# Via `run` so the function's internal `((_wait++))` (which returns 1
# when _wait starts at 0) doesn't trip bats' errexit. Production has
# no set -e, so this is a harness concern, not a code defect.
run cleanup_orphaned_cowork_daemon
grep -q 'Killed orphaned cowork-vm-service daemon (PIDs: 4242)' \
"$log_file"
# Negative assertions via `run` + status: a bare `! grep` that isn't
# the last command does not fail a bats test (SC2314), so it would be
# a hollow check.
run grep -q 'SIGKILL' "$log_file"
[[ $status -ne 0 ]]
grep -q '^kill 4242$' "$TEST_TMP/kills"
run grep -qF -- '-KILL' "$TEST_TMP/kills"
[[ $status -ne 0 ]]
}
@test "cleanup_orphaned_cowork_daemon: orphan survives SIGTERM — escalates to SIGKILL" {
# Daemon never dies, so after the SIGTERM grace window the function
# escalates to SIGKILL and logs the SIGKILL variant.
pgrep() {
if [[ $* == *cowork-vm-service* ]]; then
echo 4242
else
# UI scan (--class fingerprint): no live UI.
return 1
fi
}
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
sleep() { :; }
setup_logging
# `run` for the same errexit reason as the SIGTERM test above.
run cleanup_orphaned_cowork_daemon
grep -q 'Killed orphaned cowork-vm-service daemon (SIGKILL, PIDs: 4242)' \
"$log_file"
grep -q '^kill 4242$' "$TEST_TMP/kills"
grep -q '^kill -KILL 4242$' "$TEST_TMP/kills"
}
# =============================================================================
# cleanup_stale_desktop_helpers
# =============================================================================
@test "_desktop_helper_cmdline_matches: matches known Desktop helpers only" {
local config_dir="$XDG_CONFIG_HOME/Claude"
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --type=utility --user-data-dir=$config_dir"
[[ $status -eq 0 ]]
# tr '\0' ' ' joins cmdline args with a trailing space, so the
# --user-data-dir arm anchors on "$config_dir " — exact dir only.
run _desktop_helper_cmdline_matches \
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=$config_dir "
[[ $status -eq 0 ]]
run _desktop_helper_cmdline_matches \
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=${config_dir}Dev "
[[ $status -ne 0 ]]
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar.unpacked/cowork-vm-service.js"
[[ $status -eq 0 ]]
run _desktop_helper_cmdline_matches \
"node $config_dir/Claude Extensions/ant.dir.example/server.js"
[[ $status -eq 0 ]]
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/node_modules/electron/dist/electron /usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar"
[[ $status -ne 0 ]]
run _desktop_helper_cmdline_matches \
"claude --dangerously-skip-permissions"
[[ $status -ne 0 ]]
run _desktop_helper_cmdline_matches \
"/home/scott/dev/dude/core/agent-dude/dist/index.js mcp"
[[ $status -ne 0 ]]
}
@test "_claude_desktop_ui_cmdline_matches: keys on the --class fingerprint" {
# Live UI: launcher argv carries --class=$WM_CLASS (tr '\0' ' '
# leaves every argument space-terminated). Since #700 app.asar no
# longer appears in any cmdline, so the --class flag from
# build_electron_args is the only stable UI signature.
run _claude_desktop_ui_cmdline_matches \
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --class=Claude --enable-features=WaylandWindowDecorations "
[[ $status -eq 0 ]]
# Another Electron app's asar path must not match.
run _claude_desktop_ui_cmdline_matches \
"/opt/other-electron-app/resources/app.asar "
[[ $status -ne 0 ]]
# Look-alike WM class is rejected by the trailing-space anchor.
run _claude_desktop_ui_cmdline_matches \
"/opt/claude-dev/electron --class=ClaudeDev "
[[ $status -ne 0 ]]
# Chromium helpers (--type=) never count as the UI, even if a
# --class flag leaked into their argv.
run _claude_desktop_ui_cmdline_matches \
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --type=utility --user-data-dir=$XDG_CONFIG_HOME/Claude --class=Claude "
[[ $status -ne 0 ]]
# The cowork daemon never counts as the UI.
run _claude_desktop_ui_cmdline_matches \
"/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar.unpacked/cowork-vm-service.js --class=Claude "
[[ $status -ne 0 ]]
}
@test "run_electron_and_cleanup: runs cleanup after Electron exits and preserves status" {
local marker="$TEST_TMP/cleanup-ran"
local electron="$TEST_TMP/electron"
cat > "$electron" <<'STUB'
#!/usr/bin/env bash
echo "electron argv: $*"
exit 7
STUB
chmod +x "$electron"
cleanup_after_electron_exit() {
touch "$marker"
}
setup_logging
run run_electron_and_cleanup "$electron" '--flag' 'value'
[[ $status -eq 7 ]]
[[ -f $marker ]]
run cat "$log_file"
[[ $output == *'electron argv: --flag value'* ]]
}
# =============================================================================
# Doctor helper functions
# =============================================================================

View File

@@ -142,3 +142,72 @@ args_count() {
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: prior GPU fatal auto-disables on next launch" {
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start ---
LOG
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
}
@test "disable-gpu: recovery stays sticky on launch N+2 (no oscillation)" {
# A recovered launch runs with --disable-gpu and writes no GPU
# output, so the crash signature alone would re-enable GPU on
# launch N+2 (crash/work/crash forever). The launcher's own
# "disabling GPU" marker in the penultimate section must keep
# recovery tripped.
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start ---
Previous launch hit GPU process FATAL - disabling GPU
--- Claude Desktop Launcher Start ---
LOG
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
}
@test "disable-gpu: NixOS launcher header sections are detected" {
# nix/claude-desktop.nix writes "Launcher Start (NixOS)" headers;
# the section regex must match them or recovery silently no-ops
# on Nix.
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start (NixOS) ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start (NixOS) ---
LOG
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
}
@test "disable-gpu: CLAUDE_DISABLE_GPU=0 suppresses auto fallback" {
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start ---
LOG
export CLAUDE_DISABLE_GPU=0
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}

View File

@@ -7,6 +7,16 @@ 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. _launch_smoke_cleanup
# (test-artifact-common.sh) reaps an interrupted launch and its temp dirs;
# extract_dir is AppImage-specific so it's torn down here.
_cleanup() {
_launch_smoke_cleanup
[[ -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)
@@ -108,78 +118,16 @@ else
fi
# --- Headless launch smoke test ---
# Catches startup-only regressions (asar/frame-fix-wrapper syntax errors)
# that pure structure checks miss.
#
# Scope: main-process startup failures only. GPU/renderer-process
# crashes (e.g. #583-class) leave the main process alive and pass
# this check — Xvfb has no GPU, so Electron falls back to SwiftShader
# and the GPU-crash path isn't exercised here.
if command -v xvfb-run &>/dev/null \
&& command -v dbus-run-session &>/dev/null \
&& command -v setsid &>/dev/null; then
# XDG_CACHE_HOME redirect so the test owns the launcher log.
cache_root=$(mktemp -d)
export XDG_CACHE_HOME="$cache_root"
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
# setsid puts xvfb-run + Xvfb + dbus + AppRun + electron in a fresh
# process group; xvfb-run's EXIT trap alone leaves Xvfb behind on
# TERM, so we need kill -- -PGID below.
# AppRun redirects electron's stdout/stderr into launcher_log;
# xvfb_log captures xvfb-run's own stderr.
xvfb_log=$(mktemp)
setsid xvfb-run -a -s '-screen 0 1280x720x24' \
dbus-run-session -- "$appimage_file" \
>"$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
# 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"
else
wait "$launch_pid" 2>/dev/null
exit_code=$?
fail "AppImage exited within 10s (exit: $exit_code)"
if [[ -f $launcher_log ]]; then
echo '--- launcher.log (last 40 lines) ---' >&2
tail -40 "$launcher_log" >&2
echo '------------------------------------' >&2
fi
if [[ -s $xvfb_log ]]; then
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
tail -20 "$xvfb_log" >&2
echo '---------------------------------------' >&2
fi
fi
# Negative PID targets the process group.
kill -TERM -- "-$launch_pid" 2>/dev/null || true
sleep 1
kill -KILL -- "-$launch_pid" 2>/dev/null || true
wait "$launch_pid" 2>/dev/null || true
# Sweep any electron child that escaped the group (e.g. zygote).
pkill -KILL -f "$appimage_file" 2>/dev/null || true
rm -rf "$cache_root" "$xvfb_log"
unset XDG_CACHE_HOME
else
# Match the codebase convention (test-artifact-common.sh
# validate_app_contents): tool absence is a skip, not a failure.
# Loud failure on missing tools belongs at the workflow layer.
pass "Skipping launch smoke test (xvfb-run/dbus-run-session/setsid missing)"
fi
# The AppImage runs as the (non-root) CI user, so no privilege drop.
# The pkill sweep matches 'mount_claude', not the .AppImage path: a running
# AppImage execs Electron from its FUSE mount (/tmp/.mount_claudeXXXX), so
# the escaped zygote/electron children live there. Matching the artifact
# path would sweep nothing. See CLAUDE.md (`pkill -9 -f "mount_claude"`).
# Sweep escaped children only in CI: locally, 'mount_claude' also
# matches a developer's live Claude Desktop AppImage session.
smoke_sweep=''
[[ -n ${CI:-} ]] && smoke_sweep='mount_claude'
run_launch_smoke_test 'AppImage' "$smoke_sweep" '' "$appimage_file"
# --- Cleanup ---
rm -rf "$extract_dir"

View File

@@ -141,6 +141,187 @@ validate_app_contents() {
rm -rf "$extract_dir"
}
# Headless launch smoke test. Boots the packaged app under Xvfb + dbus
# and waits for the frame-fix readiness marker
# ('[Frame Fix] Patches built successfully'), which scripts/frame-fix-
# wrapper.js emits on the FIRST require('electron') — i.e. before
# app.whenReady(), not after full startup. Reaching it proves the asar
# loaded and the wrapper's electron interception ran without a
# SyntaxError (the #666 class) — note a hang after this point would
# still pass. Catches startup-only regressions (asar/wrapper syntax
# errors, bad patch anchors that yield a SyntaxError) that pure
# structure checks miss. Ref: #670 (deb/rpm),
# #646 (AppImage readiness-poll pattern this generalizes).
#
# Scope: main-process startup only. GPU/renderer crashes (#583-class)
# leave the main process alive and pass — Xvfb has no GPU, so Electron
# falls back to SwiftShader and that path isn't exercised here.
#
# Usage:
# run_launch_smoke_test <label> <pkill_match> <run_as> <cmd> [args...]
# label human name for pass/fail messages
# pkill_match pattern for the pkill -f child sweep (may be empty)
# run_as unprivileged user to drop to, or '' to run as-is.
# Electron aborts as root without --no-sandbox, and the
# launcher only adds that on Wayland/deb, so a root
# container (rpm) must drop privileges to exercise the
# real setuid-sandbox path.
# cmd [args] the launch command
#
# Tool absence (xvfb-run/dbus-run-session/setsid, or runuser when a
# run_as user is requested) is a skip, not a failure — matching
# validate_app_contents. Loud failure on missing tools belongs at the
# workflow layer.
# Module-scope state so the caller's trap can reap an interrupted launch.
_smoke_launch_pid=''
_smoke_cache_root=''
_smoke_xvfb_log=''
_smoke_pkill_match=''
_launch_smoke_cleanup() {
if [[ -n $_smoke_launch_pid ]]; then
# Negative PID targets the whole process group.
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null
[[ -n $_smoke_pkill_match ]] \
&& pkill -KILL -f "$_smoke_pkill_match" 2>/dev/null
fi
[[ -n $_smoke_cache_root ]] && rm -rf "$_smoke_cache_root"
[[ -n $_smoke_xvfb_log ]] && rm -rf "$_smoke_xvfb_log"
}
# True when any passed log file carries the sandbox-namespace-denied
# signature: the CI container forbidding Chromium's user/PID namespace
# sandbox. Matches `Failed to move to new namespace`,
# `zygote_host_impl_linux`, or `Operation not permitted` co-occurring
# with `namespace`. Missing files are skipped silently.
_smoke_sandbox_denied() {
local log
for log in "$@"; do
[[ -f $log ]] || continue
grep -qE 'Failed to move to new namespace|zygote_host_impl_linux' \
"$log" && return 0
grep -q 'Operation not permitted' "$log" \
&& grep -q 'namespace' "$log" && return 0
done
return 1
}
run_launch_smoke_test() {
local label="$1" pkill_match="$2" run_as="$3"
shift 3
local skip="Skipping launch smoke test for $label"
if ! { command -v xvfb-run && command -v dbus-run-session \
&& command -v setsid; } &>/dev/null; then
pass "$skip (xvfb-run/dbus-run-session/setsid missing)"
return
fi
if [[ -n $run_as ]] && ! command -v runuser &>/dev/null; then
pass "$skip (runuser missing)"
return
fi
local cache_root xvfb_log launcher_log
cache_root=$(mktemp -d)
xvfb_log=$(mktemp)
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
_smoke_cache_root="$cache_root"
_smoke_xvfb_log="$xvfb_log"
_smoke_pkill_match="$pkill_match"
# setsid puts xvfb-run + Xvfb + dbus + launcher + electron in a fresh
# process group; xvfb-run's own EXIT trap leaves Xvfb behind on TERM,
# so we reap via kill -- -PGID below. XDG_CACHE_HOME is redirected so
# the test owns the launcher log the readiness marker is written to
# (the launcher execs electron with stdout/stderr >> "$log_file").
local -a runner=(setsid)
if [[ -n $run_as ]]; then
# The unprivileged user must be able to write the redirected
# cache (and read the world-readable install + setuid sandbox).
chmod 0777 "$cache_root"
runner+=(runuser -u "$run_as" --)
fi
runner+=(env "XDG_CACHE_HOME=$cache_root"
xvfb-run -a -s '-screen 0 1280x720x24'
dbus-run-session -- "$@")
"${runner[@]}" >"$xvfb_log" 2>&1 &
_smoke_launch_pid=$!
# Poll for the readiness marker or early process death, up to 30s.
# Replaces a flat sleep: faster on healthy startups, less flaky on
# noisy runners.
local readiness_marker='[Frame Fix] Patches built successfully'
local readiness_timeout=30 deadline saw_marker=0
deadline=$((SECONDS + readiness_timeout))
while ((SECONDS < deadline)); do
if [[ -f $launcher_log ]] \
&& grep -qF "$readiness_marker" "$launcher_log"; then
saw_marker=1
break
fi
kill -0 "$_smoke_launch_pid" 2>/dev/null || break
sleep 0.5
done
if ((saw_marker == 1)); then
pass "$label reached ready state under Xvfb"
else
# Build the failure detail message, but defer the fail/skip
# verdict until after we've dumped and scanned the logs below.
local detail exit_code
if kill -0 "$_smoke_launch_pid" 2>/dev/null; then
detail="$label did not reach ready state within"
detail+=" ${readiness_timeout}s"
else
wait "$_smoke_launch_pid" 2>/dev/null
exit_code=$?
detail="$label exited before reaching ready state"
detail+=" (exit: $exit_code)"
fi
if [[ -f $launcher_log ]]; then
echo '--- launcher.log (last 40 lines) ---' >&2
tail -40 "$launcher_log" >&2
echo '------------------------------------' >&2
fi
if [[ -s $xvfb_log ]]; then
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
tail -20 "$xvfb_log" >&2
echo '---------------------------------------' >&2
fi
# Narrow skip: the GHA container's default seccomp/userns policy
# blocks Chromium's namespace sandbox, so the zygote aborts before
# the readiness marker. That's an environment limit, not an app
# defect (deb/appimage jobs prove the same code boots where the
# sandbox is allowed). Treat ONLY this signature as a skip; every
# other pre-marker exit stays a hard failure.
if _smoke_sandbox_denied "$launcher_log" "$xvfb_log"; then
pass "$label: SKIP — Chromium sandbox cannot initialize in this container (namespace creation denied by seccomp/userns policy); launch not exercised here. App boots where the sandbox is permitted (see deb/appimage jobs)."
else
fail "$detail"
fi
fi
kill -TERM -- "-$_smoke_launch_pid" 2>/dev/null || true
sleep 1
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null || true
wait "$_smoke_launch_pid" 2>/dev/null || true
# Sweep any electron child that escaped the group (e.g. zygote).
# Under the rpm runuser path PAM re-setsid()s the child into its own
# session/process group, so the negative-PID group kills above miss
# it entirely — this pkill -f sweep is the ACTUAL reaper there, not a
# belt-and-suspenders extra. Don't drop it.
if [[ -n $pkill_match ]]; then
pkill -KILL -f "$pkill_match" 2>/dev/null || true
fi
rm -rf "$cache_root" "$xvfb_log"
_smoke_launch_pid=''
_smoke_cache_root=''
_smoke_xvfb_log=''
}
print_summary() {
echo
echo '================================'

View File

@@ -6,6 +6,9 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=tests/test-artifact-common.sh
source "$script_dir/test-artifact-common.sh"
# Reap an interrupted launch smoke test (see test-artifact-common.sh).
trap _launch_smoke_cleanup EXIT INT TERM
# Find the .deb file
deb_file=$(find "$artifact_dir" -name '*.deb' -type f | head -1)
if [[ -z $deb_file ]]; then
@@ -23,10 +26,16 @@ else
fail "Package name is not claude-desktop"
fi
if [[ $pkg_info == *'Architecture: amd64'* ]]; then
pass "Architecture is amd64"
# Architecture must match the target we built for. TARGET_ARCH is set by
# the CI workflow's per-arch matrix; fall back to the host's dpkg
# architecture for standalone/local runs (each CI arch runs on a native
# runner, so the host arch matches the package arch there too).
expected_arch="${TARGET_ARCH:-$(dpkg --print-architecture 2>/dev/null)}"
if [[ -n $expected_arch ]] \
&& [[ $pkg_info == *"Architecture: $expected_arch"* ]]; then
pass "Architecture is $expected_arch"
else
fail "Architecture is not amd64"
fail "Architecture is not ${expected_arch:-<undetermined>}"
fi
if [[ $pkg_info == *'Version:'* ]]; then
@@ -46,6 +55,8 @@ fi
# --- File existence checks ---
assert_executable '/usr/bin/claude-desktop'
assert_file_exists '/usr/share/applications/claude-desktop.desktop'
assert_file_exists \
'/usr/share/metainfo/io.github.aaddrick.claude-desktop-debian.metainfo.xml'
assert_dir_exists '/usr/lib/claude-desktop'
assert_file_exists '/usr/lib/claude-desktop/launcher-common.sh'
@@ -58,6 +69,11 @@ assert_executable "$electron_path"
assert_file_exists \
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
# The build's permission normalization clears the setuid bit; postinst
# must re-assert 4755 or the Electron sandbox breaks silently (#695).
assert_setuid \
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
# --- Desktop entry validation ---
desktop_file='/usr/share/applications/claude-desktop.desktop'
assert_contains "$desktop_file" 'Exec=/usr/bin/claude-desktop' \
@@ -99,6 +115,15 @@ assert_contains '/usr/bin/claude-desktop' 'build_electron_args' \
resources_dir='/usr/lib/claude-desktop/node_modules/electron/dist/resources'
validate_app_contents "$resources_dir"
# app.asar.unpacked must be world-traversable and root-owned, or
# Cowork's auto-launch fs.existsSync() guard silently fails (#695).
unpacked_stat=$(stat -c '%a %U:%G' "$resources_dir/app.asar.unpacked")
if [[ $unpacked_stat == '755 root:root' ]]; then
pass 'app.asar.unpacked is 755 root:root'
else
fail "app.asar.unpacked is $unpacked_stat (want 755 root:root)"
fi
# --- Doctor smoke test ---
# --doctor checks system state; some checks will fail in CI (no display,
# etc.) but the script itself should not crash with signal or 127.
@@ -110,4 +135,9 @@ else
fail "--doctor crashed (exit: $doctor_exit)"
fi
# --- Headless launch smoke test ---
# ubuntu-latest runs as a non-root user, so no privilege drop needed.
run_launch_smoke_test 'deb package' '/usr/lib/claude-desktop' '' \
/usr/bin/claude-desktop
print_summary

View File

@@ -6,6 +6,16 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=tests/test-artifact-common.sh
source "$script_dir/test-artifact-common.sh"
# Reap an interrupted launch smoke test, then remove the throwaway
# unprivileged user the launch drops to (see below / test-artifact-
# common.sh).
_rpm_cleanup() {
_launch_smoke_cleanup
[[ -n ${smoke_user:-} ]] \
&& userdel -r "$smoke_user" 2>/dev/null
}
trap _rpm_cleanup EXIT INT TERM
# Find the .rpm file
rpm_file=$(find "$artifact_dir" -name '*.rpm' -type f | head -1)
if [[ -z $rpm_file ]]; then
@@ -33,6 +43,8 @@ fi
# --- File existence checks ---
assert_executable '/usr/bin/claude-desktop'
assert_file_exists '/usr/share/applications/claude-desktop.desktop'
assert_file_exists \
'/usr/share/metainfo/io.github.aaddrick.claude-desktop-debian.metainfo.xml'
assert_dir_exists '/usr/lib/claude-desktop'
assert_file_exists '/usr/lib/claude-desktop/launcher-common.sh'
@@ -85,6 +97,15 @@ assert_contains '/usr/bin/claude-desktop' 'build_electron_args' \
resources_dir='/usr/lib/claude-desktop/node_modules/electron/dist/resources'
validate_app_contents "$resources_dir"
# app.asar.unpacked must be world-traversable and root-owned, or
# Cowork's auto-launch fs.existsSync() guard silently fails (#695).
unpacked_stat=$(stat -c '%a %U:%G' "$resources_dir/app.asar.unpacked")
if [[ $unpacked_stat == '755 root:root' ]]; then
pass 'app.asar.unpacked is 755 root:root'
else
fail "app.asar.unpacked is $unpacked_stat (want 755 root:root)"
fi
# --- Doctor smoke test ---
doctor_exit=0
/usr/bin/claude-desktop --doctor >/dev/null 2>&1 || doctor_exit=$?
@@ -94,4 +115,22 @@ else
fail "--doctor crashed (exit: $doctor_exit)"
fi
# --- Headless launch smoke test ---
# The container runs as root; Electron aborts as root without
# --no-sandbox (which the launcher only adds on Wayland/deb), so drop to
# a throwaway unprivileged user. The install is world-readable and
# chrome-sandbox is setuid root, so this exercises the real sandbox path
# a Fedora user hits. The user is removed by the EXIT trap.
# In a non-root env or without useradd, smoke_user stays empty and the
# helper runs the launch as-is rather than dropping privileges.
smoke_user=''
if [[ $(id -u) -eq 0 ]] && command -v useradd &>/dev/null; then
smoke_user='claude-smoke'
useradd -m "$smoke_user" 2>/dev/null \
|| smoke_user=''
fi
run_launch_smoke_test 'rpm package' '/usr/lib/claude-desktop' \
"$smoke_user" /usr/bin/claude-desktop
print_summary

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

View File

@@ -5,21 +5,23 @@ import { readPidArgv, argvHasFlag } from '../lib/argv.js';
import { readLauncherLog, captureSessionEnv } from '../lib/diagnostics.js';
// S12 — `--enable-features=GlobalShortcutsPortal` launcher flag
// wired up for GNOME Wayland. Backs QE-6 in
// wired up for the native-Wayland path. Backs QE-6 in
// docs/testing/quick-entry-closeout.md.
//
// On GNOME Wayland, mutter no longer honors XWayland-side key grabs,
// so the Quick Entry global shortcut fails from unfocused state
// (#404). The fix is to route global shortcuts through XDG Desktop
// Portal: pass `--enable-features=GlobalShortcutsPortal` to Electron
// from the launcher when XDG_CURRENT_DESKTOP includes GNOME and
// XDG_SESSION_TYPE is wayland.
// (#404). The launcher routes global shortcuts through XDG Desktop
// Portal by adding `GlobalShortcutsPortal` to the native-Wayland
// `--enable-features` set.
//
// As of writing, this fix is NOT implemented. The test asserts the
// fix's signature (the flag is in the spawned Electron's argv) and
// will therefore FAIL on GNOME-W until the launcher patch lands.
// That's intentional — it's the regression detector, not a smoke
// test. Once the patch is in, this becomes a Critical green cell.
// GNOME native Wayland is opt-in (CLAUDE_USE_WAYLAND=1), NOT the
// default — flipping the default GNOME session off XWayland is a
// rendering/IME risk, and on GNOME 50 the portal route is a no-op
// upstream (electron/electron#51875). So this test launches with
// CLAUDE_USE_WAYLAND=1 and asserts the flag is present on that
// opt-in path. The portal feature is comma-joined with the ozone
// features (Chromium honors only the last `--enable-features`), so we
// match the subkey, not an exact token.
//
// Row gate: GNOME Wayland only. KDE rows skip with `-`.
@@ -41,6 +43,8 @@ test('S12 — --enable-features=GlobalShortcutsPortal launcher flag wired up for
const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === '1';
const app = await launchClaude({
isolation: useHostConfig ? null : undefined,
// GNOME native+portal is opt-in; exercise that path explicitly.
extraEnv: { CLAUDE_USE_WAYLAND: '1' },
});
try {