Compare commits

..

231 Commits

Author SHA1 Message Date
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
aaddrick
230bc7a9e4 docs: fix anchor, stale path ref, align CHANGELOG ordering convention
- RELEASING.md: point #linting anchor at CLAUDE.md (bash_styleguide.md
  has no Linting heading)
- CHANGELOG.md: docs/CONFIGURATION.md → docs/configuration.md
- docs_styleguide.md: update documented section ordering to match
  actual CHANGELOG practice (Added, Fixed, Changed)
- Sync CLAUDE.md and AGENTS.md if affected

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:09:27 -04:00
aaddrick
66344770f8 docs: fix changelog omission, artifact count, stale links, sync comment
- Add PR #626 to [Unreleased] section
- Fix RELEASING.md artifact count (9 assets, not 6)
- Update [Unreleased] compare link to latest tag
- Align CLAUDE.md sync-boundary comment with AGENTS.md precision

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:09:27 -04:00
aaddrick
ffb85a80c1 docs: changelog this refactor; codeowners for new governance files
CHANGELOG.md [Unreleased]:
- Added entry summarizing the new top-level governance docs
  (CHANGELOG, RELEASING, SECURITY, docs/index, docs_styleguide),
  CLAUDE.md restructuring (Required reading / Anti-patterns /
  Docs sections), and the AGENTS.md mirror.
- Added entry for CONTRIBUTING.md "Before you start" triage section.
- Changed entry for the lowercase-kebab-case rename sweep
  (BUILDING/CONFIGURATION/DECISIONS/TROUBLESHOOTING and the
  STYLEGUIDE.md → docs/styleguides/bash_styleguide.md move).
- Changed entry for the codified [$\w]+ identifier-capture
  convention (CONTRIBUTING + patch-engineer agent examples),
  closing the docs-vs-code gap.

Omitted PR link — will add as a follow-up commit after the PR opens.

CODEOWNERS — Docs & style:
- /AGENTS.md, /CONTRIBUTING.md, /CHANGELOG.md, /RELEASING.md,
  /SECURITY.md added explicitly to the @aaddrick block. Per the
  file's own convention (lines 5-8): enumerate paths even when
  redundant with `*`, so intent is visible to future readers.

All five remain at @aaddrick: AGENTS mirrors CLAUDE; RELEASING is
the release procedure that aaddrick executes (sabiut's existing
tests/ + doctor.sh ownership already gates releases at CI, no
shared-review need at the doc level); SECURITY routes private
reports to aaddrick.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:09:27 -04:00
aaddrick
7a0fdb3e9a docs: codify [$\w]+ identifier-capture convention in CONTRIBUTING and patch-engineer
The convention already lived in docs/learnings/patching-minified-js.md
("Use [\$\w]+ (repo convention; [\w\$]+ is equivalent)") but the
CONTRIBUTING.md "Patch-script regexes" section and the
patch-engineer agent's worked examples were still showing bare \w+
for identifier captures — exactly the pattern PR #555 (v2.0.8) and
PR #627 (1.8089.1) had to fix in actual patches.

CONTRIBUTING.md "Patch-script regexes":
- Lead with the identifier-capture rule, then the intent-comment
  rule. Both apply to anything that matches against the minified
  upstream bundle.
- Updated the ENOENT example regex from (\w+) to ([\$\w]+).
- Cross-link patching-minified-js.md for the full background.

.claude/agents/patch-engineer.md:
- 6 worked grep -oP examples (lines 130, 134, 152, 155, 158, 209)
  updated from \w+ to [\$\w]+ where they capture JS identifiers
  from the minified upstream bundle. These are the patterns the
  patch-engineer agent reads as a template for new patches; if they
  show \w+, the agent reproduces the bug.
- Bash quoting respected: single-quoted regex strings keep [\$\w]+
  literal; double-quoted strings use [\\\$\w]+ so bash doesn't
  interpolate the dollar sign.

Out of scope (separate PR):
- scripts/patches/cowork.sh has ~11 unfixed identifier captures
  using \w+ (only the #555 and #627 sites are widened). Same for
  scripts/patches/_common.sh, which uses \$?\w+ — handles a leading
  $ but not mid-identifier $. These are latent until upstream's
  minifier emits a $ in one of those positions; the fix needs test
  surface and belongs on its own branch.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:09:27 -04:00
aaddrick
bdaff4acf4 docs: cross-reference sweep for renamed paths
Updates references to the renamed files in the previous commit. After
this, no tracked file outside test fixtures references the old paths.

CODEOWNERS, README, learnings/, scripts/
- /STYLEGUIDE.md            → /docs/styleguides/ glob
- /docs/TROUBLESHOOTING.md  → /docs/troubleshooting.md
- "Documenting" comment in CODEOWNERS updated to lowercase
- docs/learnings/linux-topbar-shim.md: docs/CONFIGURATION.md ref
- scripts/cowork-vm-service.js, scripts/doctor.sh (2x),
  scripts/setup/dependencies.sh: user-facing docs/ paths in error
  messages and --doctor output

.claude/ (agent definitions and orchestrator)
- 7 .claude/agents/*.md files: STYLEGUIDE.md mentions/links updated
  to docs/styleguides/bash_styleguide.md; directory-tree
  visualizations adjusted where shown
- .claude/scripts/implement-issue-orchestrator.sh: prompt
- .claude/skills/writing-agents/SKILL.md: example Read list

Kept as-is (intentional):
- .claude/agents/code-reviewer.md "(formerly STYLEGUIDE.md at root)"
  breadcrumb in the directory tree
- .claude/scripts/implement-issue-test/test-json-parsing.bats fake
  JSON fixture — string is historical AI output, not policy

Verified: all markdown links in updated files resolve; 29/29 bats
tests in tests/doctor.bats pass; CI-style shellcheck (only
shebanged scripts) clean; no broken upstream URLs.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:09:27 -04:00
aaddrick
d58a9188b9 docs: rename to lowercase kebab-case; move STYLEGUIDE under docs/styleguides
Pure git mv. No content changes — the next commit updates cross-refs.

- STYLEGUIDE.md           → docs/styleguides/bash_styleguide.md
- docs/BUILDING.md        → docs/building.md
- docs/CONFIGURATION.md   → docs/configuration.md
- docs/DECISIONS.md       → docs/decisions.md
- docs/TROUBLESHOOTING.md → docs/troubleshooting.md

Brings docs/ filenames in line with the conventions in
docs/styleguides/docs_styleguide.md: lowercase kebab-case throughout,
styleguides under docs/styleguides/.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:08:55 -04:00
aaddrick
808a9b739b docs: required reading, anti-patterns, docs rules; AGENTS mirror
Adds three sections to CLAUDE.md and mirrors the body byte-identically
into AGENTS.md (sync-policy comment is the one intentional difference
between the two files, perspective-flipped).

CLAUDE.md / AGENTS.md
- Required reading (after H1): explicit pointers to CONTRIBUTING,
  bash_styleguide, docs_styleguide, docs/index, SECURITY.
- Anti-patterns subsection under Code Style: no set -e, no eval,
  no [ ], no backticks, no hardcoded work_dir, no shellcheck-baseline
  appendage.
- Docs major section: one-sentence + code-block openers, lowercase
  kebab-case, real domain nouns, troubleshooting headings = literal
  symptoms, Keep a Changelog 1.1.0 format.

CONTRIBUTING.md
- New "Before you start" section: bug / fix / feature / security
  triage with right-channel guidance.
- Expanded "Where to find what" with refs to AGENTS, docs/index,
  both styleguides, CHANGELOG, RELEASING, SECURITY.

README.md
- One "Documentation:" line near the top linking docs/index,
  CHANGELOG, CONTRIBUTING, SECURITY.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:08:55 -04:00
aaddrick
0c74631f84 docs: add CHANGELOG, RELEASING, SECURITY, docs/index, docs styleguide
Five new top-level docs to round out the project's governance surface,
inspired by a survey of well-organized docs in laravel-commonplace.

- CHANGELOG.md (Keep a Changelog 1.1.0): v2.0.0 onward, grouped by
  REPO_VERSION; +claude upstream retags folded as one-liner inside the
  parent REPO_VERSION section. v2.0.3/.4/.9 reconstructed from git log.
- RELEASING.md: pre-release checklist, tag-and-push recipe, what CI does
  on tag push, mid-release recovery.
- SECURITY.md: private GHSA-based reporting; scope (in/out, what goes
  upstream); response expectations.
- docs/index.md: navigation hub linking BUILDING/CONFIGURATION/
  TROUBLESHOOTING (post-rename paths), decision log, learnings,
  testing, issue-triage, styleguides.
- docs/styleguides/docs_styleguide.md: page anatomy templates
  (setup/troubleshooting/learning/ADR), naming conventions,
  antipatterns, page-size honesty. Tailored to packaging/Electron
  domain; adapted from commonplace's docs_styleguide.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:08:55 -04:00
Aaddrick
bdb7bec749 Merge pull request #611 from dubreal/fix/593-password-store
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:07:27 -04:00
aaddrick
6a7c898e55 fix(launcher): add --print-reply + --reply-timeout to keyring probes
- gnome-libsecret probe: add --print-reply (without it, dbus-send
  exits 0 regardless of whether anything listens at
  org.freedesktop.secrets, making 'basic' fallback unreachable)
- Both probes: add --reply-timeout=1000 to prevent 25s stall from
  wedged keyring daemon
- tests/launcher-common.bats: set CLAUDE_PASSWORD_STORE='basic' in
  setup() to isolate build_electron_args tests from host D-Bus state

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:06:31 -04:00
dubreal
c81ca46179 fix(launcher): inject --password-store before app path, probe keyring at startup
- Add _detect_password_store() to probe D-Bus at startup:
  kwallet6 -> gnome-libsecret -> basic fallback
- Respect CLAUDE_PASSWORD_STORE env var for user override
- Inject --password-store=VALUE before app path in build_electron_args()
  so Chromium treats it as a browser flag, not a renderer arg
- Add CLAUDE_PASSWORD_STORE to log_session_env for diagnostic reports
- Add _doctor_check_password_store() to --doctor output between
  SingletonLock and MCP config checks; surfaces resolved backend
  and notes when CLAUDE_PASSWORD_STORE override is active
- Add bats tests for _detect_password_store fallback chain (4 cases)
  and _doctor_check_password_store output; update log_session_env
  tests for new key position

Fixes #593 (password-store half)

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:06:31 -04:00
Aaddrick
9f260316c8 Merge pull request #631 from JustinJLeopard/fix/584-extract-zip-silent-fail-node24
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:03:18 -04:00
Aaddrick
c48c438c68 Merge pull request #624 from phelps-matthew/fix/623-quit-on-close-bypass-bundled-handler
fix: actively quit on close when CLAUDE_QUIT_ON_CLOSE=1 (#623)
2026-05-24 09:03:15 -04:00
Aaddrick
a04ed9e6b4 Merge pull request #610 from JoshuaVlantis/fix/rpm-chrome-sandbox-listed-twice-609
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 09:03:12 -04:00
aaddrick
a2685b0f6f fix: correct arch mapping, cache path, and fallback diagnostics
- Map Debian arch names (amd64/arm64) to Electron names (x64/arm64)
  for cached zip lookup — fixes arm64 builds
- Hardcode cache dir to $HOME/.cache/electron (@electron/get does
  not read ELECTRON_CACHE)
- Differentiate download-failure from extract-failure before entering
  the unzip fallback path
- Update final validation to check for the binary, not just the
  directory

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-24 08:26:05 -04:00
github-actions[bot]
b8fe6b8502 Update Claude Desktop download URLs to version 1.8555.2
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-23 01:40:29 +00:00
Justin Leopard
e649a485a6 Fix #584: detect missing electron binary + add unzip fallback
The existing fallback chain for populating node_modules/electron/dist/
checks for the dist/ directory's existence. Under Node 24, both
electron's npm postinstall (when used) and the @electron/get fallback
in fetch-electron-binary.js can silently no-op via extract-zip —
creating an empty dist/locales/ subdirectory without throwing. That
passes a bare `-d` check while no electron binary actually landed,
which then fails downstream in stage_electron with a confusing
"Staged Electron binary not found" warning.

Two changes:

1. Check `-f $electron_dist_path/electron` (presence of the actual
   binary) instead of `-d $electron_dist_path`. Triggers the
   @electron/get fallback when extract-zip silently produced an
   empty dist.

2. After both @electron/get attempts, add a final system `unzip`
   fallback against the @electron/get cache. The zip downloaded
   successfully (visible in ~/.cache/electron/<sha256>/...) — the
   only failure mode is extract-zip itself. System unzip sidesteps
   the broken Node module entirely. Writes the version + path.txt
   marker files so subsequent install.js calls see the install as
   complete.

Tested on Ubuntu 26.04 amd64 with Node v24.16.0 (NVM). Reproduces
reliably without the patch; succeeds with it.
2026-05-22 13:34:59 -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
github-actions[bot]
7c226fbfc9 Update Claude Desktop download URLs to version 1.8555.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-22 01:45:17 +00:00
Sum Abiut
5c1d54920b docs: follow-ups from #585 review (#608) (#615)
Three small clarifications flagged in the #585 review:

1. docs/TROUBLESHOOTING.md gains a "Repeated Electron Crashes / GPU
   Process FATAL" entry documenting CLAUDE_DISABLE_GPU=1 — when to
   reach for it vs the in-app Settings toggle, what flags the
   launcher translates it to, and the #583 context.

2. scripts/doctor.sh: comment above the `count >= 3` check in
   _doctor_check_recent_crashes explaining the tuning basis (#583
   repro, ~10 crashes over 7 days, 3-in-7d floor).

3. scripts/doctor.sh: note that the awk total-count branch relies
   on `coredumpctl list electron`'s COMM=electron filter to exclude
   `-- Reboot --` separators; revisit if a future systemd version
   leaks them through.

Fixes #608.

Co-authored-by: Sum Abiut <sum.abiut@titanfx.com>
2026-05-20 21:59:31 -04:00
Aaddrick
bebf8f2c36 feat(doctor): flag eCryptfs short NAME_MAX + workaround doc (#590) (#614)
* feat(doctor): flag short NAME_MAX filesystems that break cowork (#590)

Claude Code's `.claude/projects/<sanitized-cwd>` directory reaches ~180
chars when the host CWD is a cowork session's deeply-nested outputs
dir. eCryptfs caps plaintext filenames at 143 due to encryption
overhead, so the mkdir fails with ENAMETOOLONG and the session never
starts — leaving the user with an opaque error.

Add `_doctor_check_filename_limit` to surface the precondition: probe
NAME_MAX on the first existing ancestor of `~/.claude/projects/`,
warn when < 200, and add an eCryptfs-specific LUKS-volume workaround
hint when df reports the filesystem type. Wired into the Cowork Mode
section of `run_doctor` so users hit the warning before their first
session attempt.

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

* docs(troubleshooting): document eCryptfs cowork workaround and credit reporters (#590)

Add a TROUBLESHOOTING.md section walking through the LUKS-volume +
pam_mount workaround for cowork sessions blocked by short NAME_MAX
on eCryptfs-encrypted homes, and update the eCryptfs hint in
_doctor_check_filename_limit to point at the new section by name.

Credit @michelsfun for the detailed --doctor bug report and
@proffalken for the LUKS workaround in the README Acknowledgments.

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

* docs(troubleshooting): symlink ~/.claude/ onto LUKS volume for cowork (#590)

The eCryptfs workaround as written relocated ~/.config/Claude and
~/.cache/claude-desktop-debian but left ~/.claude/ on the encrypted
home, which is where Claude Code creates the long-named per-session
project dirs that overflow NAME_MAX=143. Add a third mv+ln pair for
~/.claude/ (with a fresh-install mkdir fallback), and call out in the
prose that ~/.claude/ is the load-bearing path for cowork.

Per @sabiut's review on #614.

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

* test(doctor): cover df failure path for filename-limit check (#590)

Extend _install_df_shim with an empty-arg → exit-1 mode (matching the
_install_getconf_shim convention), then add a sixth case asserting
that when df --output=fstype fails, the NAME_MAX warn still fires but
the eCryptfs/LUKS-specific hint is silently skipped.

Per @sabiut's non-blocking ask on #614.

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

* docs(troubleshooting): tighten LUKS workaround framing (#590)

Per contrarian review on #614:

- Reframe the LUKS-passphrase-equals-login-password line as an
  informed tradeoff (single compromise unlocks both, equivalent to
  eCryptfs's existing threat surface) rather than a setup tip.
- Spell out the confidentiality posture vs eCryptfs explicitly so a
  privacy-conscious user evaluating the move doesn't downgrade
  silently. Note that pam_mount failure makes writes fail loudly
  (ENOENT through the symlink) rather than landing on plaintext.
- Add a CLAUDE_CONFIG_DIR escape hatch — users who've reconfigured
  Claude Code's home dir can't blindly follow the ~/.claude symlink
  step; the constraint is the underlying NAME_MAX, not the path.
- Add mountpoint and readlink verification commands alongside the
  existing getconf NAME_MAX check so the workaround surfaces its
  own success criteria.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 21:55:25 -04:00
Alexis Williams
6219d5a6a8 fix(tray): support $-containing identifiers in 1.8089.1 minified bundle (#627)
Three small fixes in scripts/patches/tray.sh that together unblock the
1.8089.1 build (issue #625) and restore the duplicate-icon fast-path
from #515 on the new bundle.

1. Allow $ in extracted JS identifiers (lines 16, 104).

   The minifier in 1.8089.1 emits the menu handler as 'i$A'.
   PCRE \w is [A-Za-z0-9_] and does not match $. Switching the
   capture to [\w$]+ matches the legal JS IdentifierPart, the
   same way _common.sh already does for electron_var with \$?\w+.

   This is the immediate cause of the 'Failed to extract tray menu
   function name' exit 1 in #625 — patch_tray_menu_handler aborts
   build.sh entirely, taking every downstream patch with it.

2. Escape $ when interpolating tray_func into PCRE / sed -E patterns
   (lines 25, 38, 72, 111).

   Once tray_func='i$A', the literal '$A' inside a PCRE pattern is
   an end-of-line anchor followed by literal 'A' and never matches.
   Mirror the existing tray_var_re trick at line 120:

       tray_func_re="${tray_func//\$/\\$}"

   Sed BRE substitutions (lines 34, 49, 57) are safe with raw $
   because BRE treats $ as an anchor only at end-of-pattern, but
   the sed -E at line 72 is a real PCRE and needs the escape.

3. Make the 'async function' rewrite idempotent.

   Anthropic now ships the menu handler as 'async function i$A()'
   in 1.8089.1. Re-applying the sed produces 'async async function',
   which then prevents patch_tray_inplace_update from matching its
   '(?:async )?function NAME' anchor and silently skipping — losing
   the KDE Plasma duplicate-icon fix from #515 on this version.

   Guard the sed with a grep -q so it only runs when the function
   isn't already async.

Verified end-to-end against a freshly-downloaded 1.8089.1 bundle
(Claude-b98a06c70e376344e3b6938d6980242e8cc20547.exe):

  - patch_tray_menu_handler:    tray_func=i$A, tray_var=Ju, first_const=e
  - patch_tray_icon_selection:  Dark.png variant patched
  - patch_tray_inplace_update:  fast-path injected (setImage / setContextMenu)
  - patch_menu_bar_default:     e!==false defaulting applied
  - node --check on the patched bundle: parses cleanly

The change is a strict superset of the old behavior: [\w$]+ matches
everything \w+ did, and tray_func_re is identical to tray_func when
no $ is present, so older bundles continue to patch the same way.

Fixes #625.

Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 21:38:56 -04:00
Aaddrick
1a7083d765 docs(readme): credit lizthegrey, sabiut, typedrat, RayCharlizard for additional contributions (#626)
- lizthegrey: in-place package upgrade detect + notify-restart (#564)
- sabiut: BATS CI runner (#520), test isolation fix (#534), AppImage smoke tests (#592)
- typedrat: NixOS electron binary executable fix (#581)
- RayCharlizard: AppArmor userns --doctor diag (#434), MCP double-spawn learnings (#527)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 08:32:53 -04:00
github-actions[bot]
decb512144 Update Claude Desktop download URLs to version 1.8089.1
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-20 01:46:18 +00:00
github-actions[bot]
ba2846c8b3 Update Claude Desktop download URLs to version 1.7196.3
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-19 01:44:55 +00:00
phelps-matthew
6eca4da798 fix: actively quit on close when CLAUDE_QUIT_ON_CLOSE=1
The wrapper's existing CLOSE_TO_TRAY=off path stopped installing the
hide-on-close listener but did nothing to counter the bundled
main-process close listener that Anthropic added in `.vite/build/
index.js` (offset ~12292119 in 1.7196.1):

    n.on("close", l => {
      if (lC()) return;
      if (Tn && !Ei("menuBarEnabled")) { yh(); return; }
      l.preventDefault();
      const Q = () => { bLA(); n.hide(); };
      n.isFullScreen() ? (n.once("leave-full-screen", Q),
                          n.setFullScreen(false)) : Q();
    });

`Tn = process.platform === "win32"` so the conditional-quit branch
can never fire on Linux. The handler unconditionally preventDefault +
hide()s the window. Result: setting CLAUDE_QUIT_ON_CLOSE=1 produces
`[Frame Fix] Close-to-tray: off` in the launcher log but X still
leaves the process alive.

When the env var is set, install an active close listener that runs
*first* and calls app.quit(). app.quit() emits 'before-quit'
synchronously, which sets ltA=true inside the bundled beforeQuit
handler (so lC() returns truthy). The bundled close listener then
runs second, hits its own `if (lC()) return;` guard, short-circuits
without calling preventDefault, and the window closes during the
quit flow.

We don't fight the bundled listener — we ride upstream's own quit-
in-progress contract, which they need for Ctrl+Q / tray Quit /
SIGTERM regardless. No string-matching against the minified bundle,
no removeListener splicing.

Fixes: #623
Refs: #448
2026-05-18 13:12:30 -10:00
github-actions[bot]
4a1bbc9e95 chore: update flake.lock 2026-05-18 03:22:09 +00:00
Sum Abiut
b676519c58 test: add headless launch + --doctor smoke tests for AppImage artifact (#592)
The AppImage artifact test only validated package structure (extraction,
AppDir layout, asar contents) — runtime regressions like frame-fix-wrapper
syntax errors, bad asar patches, or Electron startup crashes silently
passed CI. The .deb path already ran `--doctor` as a smoke check; the
AppImage path now has parity plus a 10s headless launch under Xvfb.

`setsid` + `kill -- -PGID` is load-bearing: xvfb-run's EXIT trap leaks
Xvfb on signal kill, so running the whole stack in its own process group
lets the teardown reap xvfb-run, Xvfb, dbus, AppRun, electron, and zygote
children together. `procps` (for pkill), `dbus-x11`, and `xvfb` added to
the CI apt line.

The headless probe catches main-process startup failures only — GPU /
renderer-process crashes like #583 leave the main process alive and pass
this check; that scope disclaimer is inlined at test-artifact-appimage.sh
lines 114-117 so future contributors don't try to claim #583 coverage by
switching Xvfb off.

Co-authored-by: Sum Abiut <sabiut@users.noreply.github.com>
2026-05-16 10:15:39 -04:00
Sum Abiut
4b2b1d3390 ci: add concurrency group to test-flags workflow (#606)
Prevents manual workflow_dispatch invocations from stacking on the
same ref. Uses cancel-in-progress: false to match ci.yml so a
reusable workflow_call invocation inside an in-flight CI run isn't
killed when a new push lands.

Co-authored-by: Sum Abiut <sum.abiut@titanfx.com>
2026-05-16 20:31:02 +11:00
github-actions[bot]
d50e5c366e Update Claude Desktop download URLs to version 1.7196.1
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-16 01:39:50 +00:00
aaddrick
b017c72e8f docs(readme): credit Hayao0819 for About window titleBarStyle diagnosis and fix (#481, #489)
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-15 18:53:42 -04:00
Aaddrick
25abb00e61 Merge pull request #489 from Hayao0819/fix/about-window-hiddenInset
fix: handle upstream titleBarStyle change for About window
2026-05-15 18:52:28 -04:00
JoshuaVlantis
8fedb6a77e fix(rpm): drop unnecessary ${} on PIPESTATUS in arithmetic context (SC2004)
Inside (( ... )) bash already expands variable names, so ${PIPESTATUS[0]}
is flagged by shellcheck as SC2004. Recovered-exit-code semantics are
identical — PIPESTATUS[0] evaluates the same way in arithmetic context.
2026-05-14 14:08:39 +02:00
JoshuaVlantis
58f7ba3263 refactor(rpm): drop dead status init, tighten guard comment
Inline ${PIPESTATUS[0]} into the rpmbuild exit-code check and remove
the unused initial assignment — the named intermediate variable was
only read once, immediately after being overwritten.

Rewrite the #609 guard comment to remove an awkward "reached for"
phrasing and state the failure mode more directly: the warning means
%files has overlapping listings, and on modern rpmbuild any %exclude
workaround silently strips the file from the payload.

Invariants preserved:
- chmod 4755 still runs in %install against the buildroot
- %files block unchanged (no per-file %attr on chrome-sandbox)
- rpmbuild output still captured to $rpmbuild_log
- "File listed twice" still fails the build
- PIPESTATUS[0] still recovers rpmbuild's exit through tee

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-14 13:55:56 +02:00
JoshuaVlantis
ba8ffa1637 fix(rpm): silence "File listed twice" warning on chrome-sandbox (#609)
PR #595 placed an explicit %attr(4755, ...) line on chrome-sandbox in
the rpm %files block while keeping the bare /usr/lib/$package_name
directory listing — so chrome-sandbox is claimed twice on every build,
producing:

  warning: File listed twice: /usr/lib/claude-desktop/.../chrome-sandbox

Restructuring the %files block does not help. Tested four shapes on
fedora:42 and rockylinux:9: bot-suggested %attr+%exclude, Slack/Spotify
ordering (dir + %exclude + %attr), %attr+dir+%exclude, all silence the
warning but strip chrome-sandbox from the payload entirely. On modern
rpmbuild, %exclude is absolute — no later %attr can resurrect a path
it removed.

Fix: set 4755 on chrome-sandbox in %install (on %{buildroot}) and drop
the explicit %files entry. The directory walk records the mode from
the buildroot, so the suid bit stays in the payload (preserves #539)
and the duplicate listing is gone.

Also: capture rpmbuild output and fail the build if "File listed twice"
recurs, so a future regression surfaces immediately instead of becoming
background noise again.

Verified in fedora:42 container:
- ./build.sh --build rpm: no "File listed twice" emitted
- rpm -qlvp shows -rwsr-xr-x root:root on chrome-sandbox
- rpm -ivh + ls -la confirms -rwsr-xr-x on disk after install
2026-05-14 13:52:46 +02:00
aaddrick
d632fdb253 fix: catch About window after upstream titleBarStyle change, guard Hardware Buddy (#481)
Upstream migrated the About window from titleBarStyle:"" to
titleBarStyle:"hiddenInset" (build-reference/.../index.js:524746).
isPopupWindow() stopped matching it, so About got frame:true and the
main-window resize handlers, breaking its render on GNOME/X11.

Picks up @Hayao0819's #489 with two adjustments on top of his fix:

- Refresh the stale window-kind comment block. All three of the
  documented titleBarStyle values were wrong post-migration; the block
  now reflects current upstream and the Hardware Buddy child modal at
  index.js:536666 that didn't exist when the original comment was
  written.
- Add a 'parent' in options early-return. Hardware Buddy declares
  `parent: Et ?? void 0` and shares the About window's titleBarStyle /
  no-minWidth shape -- without the parent check, the extended match
  would strip its frame and attach main-window handlers to it. Same
  failure mode as #481, just on a different window.

Hayao0819's diagnosis and one-line logic extension are preserved.

Fixes #481

Co-Authored-By: hayao <shun819.mail@gmail.com>
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-14 07:24:23 -04:00
aaddrick
04f9a18b69 docs(readme): credit JoshuaVlantis for RPM SUID, autoUpdater no-op, and node-pty install hardening (#539, #567, #401)
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-14 07:19:45 -04:00
Aaddrick
16f1bc8be1 Merge pull request #598 from JoshuaVlantis/fix/node-pty-install-fail-loudly
fix(node-pty): fail loudly on npm install failure; require gcc/make/python3
2026-05-14 07:14:10 -04:00
Aaddrick
56d22ca97a Merge pull request #596 from JoshuaVlantis/fix/linux-disable-autoupdater-567
fix(linux): no-op autoUpdater on Linux to defend against feed activation
2026-05-14 07:14:06 -04:00
Aaddrick
c9df9e2f2d Merge pull request #595 from JoshuaVlantis/fix/rpm-suid-attr-539
fix(rpm): set chrome-sandbox suid via %attr instead of %post chmod
2026-05-14 06:37:04 -04:00
Aaddrick
5a9c7eb00c Merge pull request #587 from aaddrick/claude/electron-get-durable-fix
fix(deps): fetch electron binary via @electron/get, drop ^41 pin
2026-05-14 06:33:31 -04:00
aaddrick
57cfab8c37 fix(deps): resolve @electron/get from work_dir, not script dir
The helper at scripts/setup/fetch-electron-binary.js was bare
`require('@electron/get')`. Node resolves that relative to the
script's directory and walks up — so it searches
$project_root/scripts/setup/node_modules, $project_root/scripts/
node_modules, $project_root/node_modules. None of those have
@electron/get; the package is installed in $work_dir/node_modules
by setup_electron_asar at install time.

On the current electron@41.5.0 pin this is dormant — the upstream
postinstall populates dist/, the helper short-circuits, and the
broken require() never runs. Caught by validating the helper path
against an electron@42 sandbox build, which is exactly the scenario
the helper exists to handle.

Fix: createRequire(path.join(cwd, 'package.json')) so module
resolution is anchored at work_dir. The temporary package.json that
setup_electron_asar writes is always present at this point.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-14 06:25:51 -04:00
JoshuaVlantis
8796aa2c82 fix(linux): mask thenable / coercion traps on autoUpdater no-op Proxy
The chainable-Proxy get-trap returned chainNoop for every property,
including `then`. V8's thenable check calls `then(resolve, reject)`
on anything with a function-typed `.then`, so `await someAutoUpdaterExpr`
or `Promise.resolve(autoUpdater).then(...)` invoked chainNoop with the
resolve/reject pair, got the Proxy back, and the await hung forever
with no error and no Sentry breadcrumb.

Verified against the actual Proxy in node:20-alpine: three await
scenarios (`await proxy`, `Promise.resolve(proxy).then(...)`,
`await proxy.checkForUpdates()`) all hung for the full 1500ms timeout.
After this change all three resolve to the Proxy itself, and existing
chain behavior (`.on().once().setFeedURL().checkForUpdates()`,
`emit.bind(proxy)`) keeps working.

Also masks `Symbol.toPrimitive` and `Symbol.iterator` so string
coercion / `for..of` / spread fail loudly rather than feeding the
Proxy back through V8's primitive- or iterator-protocol machinery.

Comment block updated to note the get-trap shadows reads but lets
writes land on the target.

Addresses review feedback on #567.
2026-05-14 12:18:34 +02:00
JoshuaVlantis
b0be17dd36 fix(deps): dedupe packages mapped from multiple commands
The dependency map sends gcc/g++/make all to build-essential and
wrestool/icotool both to icoutils. The previous loop appended once
per missing command, so a clean machine printed:

  System dependencies needed:  p7zip-full wget icoutils icoutils
    imagemagick build-essential build-essential build-essential python3

apt dedupes internally so the install worked, but the log line read
as if the script was broken. Skip the append when the package is
already in deps_to_install; the bordered substring match handles
both build-essential and icoutils with a single rule.

Verified in a clean debian:stable-slim container with no build tools
present: build-essential and icoutils now appear exactly once in
each invocation.

Addresses review feedback on #401.
2026-05-14 12:17:53 +02:00
aaddrick
3b86003a6b fix(deps): pin electron@41.5.0 to match upstream app.asar ABI
Re-add the exact Electron version pin that #586 had, hoisted to a
named local in scripts/setup/dependencies.sh so the next bump is
intentional. Upstream Claude Desktop's app.asar binds to a specific
V8/NAPI ABI, Chromium pairing, and node-pty native surface; running
a different Electron major against it is unsupported and was the
reproducibility hole left when this PR dropped the ^41 pin.

Also:
- Wrap node fetch-electron-binary.js in a 2-attempt retry so a
  transient 503 from electron's CDN doesn't red the whole build.
- Whitelist supported archs (x64, arm64, armv7l, ia32) with an
  actionable error in fetch-electron-binary.js, instead of 404'ing
  on electron's release server for exotic-arch hosts.
- Document ELECTRON_MIRROR / ELECTRON_CUSTOM_DIR in docs/BUILDING.md
  and clarify that ELECTRON_CACHE is NOT honored by @electron/get
  (the original PR body's claim was wrong).

Refs #584. Addresses self-review feedback on #587.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-14 06:04:42 -04:00
Aaddrick
b23e0aea12 Merge pull request #585 from aaddrick/feature/583-gpu-mitigations
launcher+doctor: GPU FATAL mitigations (mitigates #583)
2026-05-14 05:26:38 -04:00
github-actions[bot]
755f283431 Update Claude Desktop download URLs to version 1.7196.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-13 01:41:37 +00:00
JoshuaVlantis
cf085711f2 docs(test): broaden chrome-sandbox suid guard comment
Reframe the assert_setuid comment from "guards against the old %post
chmod pattern" to "guards against any regression that strips the suid
bit" — including but not limited to a %post chmod revert.

The assertion itself catches any loss of the setuid bit on
chrome-sandbox, not just the specific %post chmod regression path.
Per review feedback on #595.
2026-05-11 07:32:12 +02:00
github-actions[bot]
fa8f3441c0 chore: update flake.lock 2026-05-11 03:21:34 +00:00
JoshuaVlantis
3db7866e69 fix(node-pty): fail loudly on npm install failure; require gcc/make/python3
Two complementary changes that close the silent-failure path surfaced
during testing for #401.

1. install_node_pty: when `npm install node-pty` fails, abort the build
   with an explicit error message that names the likely cause (missing
   C/C++ toolchain or python3) and the per-distro fix command. Previously
   the function printed a one-line warning and continued, which left
   pty_src_dir empty, skipped the entire copy block, and shipped the
   upstream Windows node-pty binaries unchanged — exactly the failure
   mode in #401.

2. check_dependencies: add gcc, g++, make, python3 to the dependency
   check so the build environment is auto-provisioned before
   install_node_pty runs (build-essential on Debian/Ubuntu, gcc /
   gcc-c++ / make / python3 on Fedora). Skipped when --node-pty-dir
   is set (Nix and explicit overrides bring their own pre-built node-pty).

Together: the dep check makes the failure rare, and the install_node_pty
abort makes the rare failure obvious instead of silent.

Verified two ways:

- Isolated test: forced npm to a fake binary that exits 1, called
  install_node_pty, confirmed exit code 1, error message contains the
  Debian/Fedora install hints, and the staging copy block did not run.
- Full deb build: ran build.sh in an ubuntu:24.04 container with no
  pre-installed compilers (only ca-certificates, wget, sudo, nodejs,
  npm). check_dependencies ran `apt install ... build-essential`
  automatically; npm install node-pty subsequently compiled cleanly;
  shipped pty.node is ELF 64-bit LSB shared object, x86-64.
2026-05-09 17:02:04 +02:00
JoshuaVlantis
d5a4104684 fix(linux): no-op autoUpdater on Linux to defend against feed activation (#567)
Wrap require('electron').autoUpdater on Linux with a Proxy that no-ops
every method and returns chainable stubs for EventEmitter calls. The
bundled app's lii() gate sets a feed URL of api.anthropic.com/api/desktop/linux/
when app.isPackaged is true (which we set unconditionally via
ELECTRON_FORCE_IS_PACKAGED), and registers update-check listeners.
Today this is a happy accident: Electron's Linux autoUpdater is
unimplemented and the calls log "AutoUpdater is not supported on Linux"
and no-op. If a future Electron implements it, every install would
start hitting that feed and would either 404 or — worse — receive
content the install wasn't prepared for. .deb/.rpm/AppImage updates
flow through the OS package manager (or AppImageUpdate); the Anthropic
feed has no Linux artifacts.

The Proxy is installed via the existing Module.prototype.require
interceptor, so it covers gA = require("electron"); gA.autoUpdater.X()
and any equivalent destructure. getFeedURL returns '' so any code
that inspects the URL gets a well-typed empty string.

Verified: built deb in ubuntu:24.04 container, extracted shipped
app.asar, confirmed autoUpdaterNoop block + intercept present in
frame-fix-wrapper.js. Runtime test loaded the shipped wrapper with a
mock electron whose autoUpdater records every real call; replayed
the bundled app's setFeedURL/on/checkForUpdates/quitAndInstall/
chained .on() patterns — zero real calls were recorded, confirming
the Proxy intercepts every access path.
2026-05-09 14:07:13 +02:00
JoshuaVlantis
15813ca11f fix(rpm): set chrome-sandbox suid via %attr instead of %post chmod (#539)
Move the suid bit on chrome-sandbox into the rpm spec's %files section
via %attr(4755, root, root). The previous %post chmod 4755 only ran on
fresh installs and silently regressed when the scriptlet was skipped
(e.g., --noscripts), leaving a non-suid chrome-sandbox that breaks
sandboxing on every launch.

Also add an assert_setuid helper to tests/test-artifact-common.sh and
wire it up in test-artifact-rpm.sh so a future spec regression to the
old %post pattern fails CI rather than shipping silently.

Verified: built rpm in fedora:42 container, installed via dnf,
ls confirms -rwsr-xr-x on chrome-sandbox, %post no longer chmods.
2026-05-09 14:06:55 +02:00
github-actions[bot]
429d191f77 Update Claude Desktop download URLs to version 1.6608.2
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-09 01:39:39 +00:00
github-actions[bot]
ab5636ef29 Update Claude Desktop download URLs to version 1.6608.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-08 01:40:45 +00:00
github-actions[bot]
0d67646d21 Update Claude Desktop download URLs to version 1.6259.1
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-07 01:40:18 +00:00
Claude
cf64b78611 fix(deps): fetch electron binary via @electron/get, drop ^41 pin
The hotfix in #586 pinned electron to ^41 because electron@42.0.0
removed the postinstall that fetches the prebuilt binary into
node_modules/electron/dist/. That left us tethered to electron 41.x
and would re-break whenever 41 ages out of npm or the upstream
behavior shifts again.

This change does the binary fetch ourselves so we no longer depend on
electron's postinstall:

* New scripts/setup/fetch-electron-binary.js uses @electron/get to
  download the matching prebuilt binary for the host platform/arch
  and extract-zip to unpack it into dist/. Reads the version from
  the installed electron's package.json so it always matches what
  npm resolved.
* setup_electron_asar runs the helper only when npm install leaves
  dist/ missing. If electron later restores the postinstall, this
  is a no-op.
* Drops the 'electron@^41' pin so we follow latest electron again.
* @electron/get honors ELECTRON_MIRROR / ELECTRON_CACHE, so users
  behind proxies/mirrors keep working without further changes.

Refs #584

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-06 13:18:04 +00:00
Aaddrick
f7c4daeb89 fix(deps): pin electron to ^41 to restore postinstall binary fetch (#584) (#586)
electron@42.0.0 (published 2026-05-06) removed the postinstall script
that downloads the prebuilt binary into node_modules/electron/dist.
Without dist/ the existence check in setup_electron_asar fails and the
build aborts with "Failed to find Electron distribution directory".

Pin to electron@^41 as a hotfix to unblock source builds. A durable
fix using @electron/get to fetch the binary explicitly will land
separately so we no longer depend on electron's postinstall behavior.

Refs #584

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-06 09:15:12 -04:00
aaddrick
51e0bc7acd test(launcher-common): cover CLAUDE_DISABLE_GPU in log_session_env
CI fail on PR #585: the existing log_session_env block test did
exact-line matching on the env block contents, so adding
CLAUDE_DISABLE_GPU to log_session_env's key list (88676f4) shifted
the closing '}' index and broke both block tests.

Updates both tests in launcher-common.bats:
- "all required keys" — sets CLAUDE_DISABLE_GPU=1, asserts new line
  at index 11, '}' moves to index 12
- "unset/empty values render as KEY=" — asserts the new key emits
  empty form at index 11

70/70 launcher-common.bats pass locally.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-06 08:31:44 -04:00
aaddrick
368f83490e doctor: surface recent Electron crashes with #583 pointer
Adds _doctor_check_recent_crashes, called from run_doctor before the
log-file section. When systemd-coredump shows ≥3 Electron crashes in
the last 7 days, surfaces a [WARN] with two workarounds (Settings
toggle, CLAUDE_DISABLE_GPU=1) and a link to the tracking issue.

Filters by the caller-supplied electron_path when entries match, falls
back to all-electron entries with a footnote when they don't (covers
AppImage's transient mount paths and other Electron apps installed
side-by-side).

Silent when coredumpctl isn't on PATH (non-systemd hosts), when there
are zero matches, or when the count is below threshold.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-06 08:28:25 -04:00
aaddrick
88676f44a6 launcher: add CLAUDE_DISABLE_GPU=1 opt-in (#583)
Mitigation for the Chromium GPU process FATAL exhaustion tracked in
#583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
--disable-software-rasterizer to Electron's argv, providing the same
effect as the upstream Settings → disable hardware acceleration toggle
but persistable via the environment.

Co-occurrence with the existing XRDP block does not stack duplicate
flags: a single _disable_gpu sentinel gates the args+= push.

CLAUDE_DISABLE_GPU joins the other CLAUDE_* keys logged by
log_session_env so bug reports include the value.

This is a workaround, not a fix. The underlying Electron/Chromium GPU
process lifecycle issue remains tracked at #583.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-06 08:28:17 -04:00
github-actions[bot]
a2411b8928 Update Claude Desktop download URLs to version 1.6259.0
Updated download URLs resolved from official redirect endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-06 01:39:56 +00:00
Alexis Williams
59ec0c6918 fix(nix): make electron binary executable (#581) 2026-05-05 16:49:14 -04:00
Aaddrick
8882f0fe26 fix(cowork.sh): WARNING on Patch 2a/2b inner anchor miss (#576)
D5 of #559's followup. Patch 2a (vmClient log gate, line 107) and
Patch 2b (vm module assignment, line 123) had no else branch on the
inner anchor regex. A miss silently ships a half-patched asar — the
exact PR #555 failure mode that took hours to diagnose because the
build log printed "Applied 10 cowork patches" with no warning.

Three-branch pattern matches Patch 4b at line 227-234:
- regex matches: patch + log + count
- post-patch literal already in code: "already applied"
- otherwise: WARNING naming the patch site

Empirically validated against the pinned 1.5354.0 installer:
deliberately broke the 2a anchor (replaced "vmClient" with "XXMISSXX"
in the regex) → WARNING fires, verify-cowork-patches.sh from PR #575
catches missing vmclient-log-gate marker. Same for 2b. Baseline
unchanged: no new WARNINGs on a fresh upstream.

Refs #559. Builds on #575 (D6 verification scaffolding).

Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:32:46 -04:00
Aaddrick
9df8b88e3a verify(cowork): static-grep shipped asar for PR #555 markers (#559) (#575)
* verify(cowork): static-grep shipped asar for PR #555 markers

D6 of #559's followup plan: post-build check that greps the shipped
app.asar for 9 known cowork patch markers and exits non-zero if any
are missing. Catches the half-patched-asar failure mode from PR #555,
where two of three failed gates had no else branch and the build log
showed "Applied 10 cowork patches" instead of warning.

- scripts/cowork-patch-markers.tsv: single source of truth.
  Tab-separated name<TAB>pcre<TAB>sample. Both verify and BATS read it.
- scripts/verify-cowork-patches.sh: accepts a .js, an .asar (npx
  @electron/asar extract), or a directory containing
  app.asar.contents/.vite/build/index.js. Exits 0/1/2.
- tests/verify-cowork-patches.bats: regex-matches-sample integrity,
  positive full fixture, per-marker negative fixtures, input-shape
  coverage. 9 new BATS cases.
- .github/workflows/build-amd64.yml: runs verify against the deb
  build's asar. Pinned to deb because the patched JS is identical
  across formats.

Validated end-to-end against the pinned 1.5354.0 installer:
unpatched -> 9/9 miss; cowork.sh patched -> all 9 present.

Refs #559.

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

* verify(cowork): share TSV parser between verify.sh and BATS

Realises the library-mode plumbing the previous commit added but
didn't use: BATS now sources verify-cowork-patches.sh and calls
load_markers, so a TSV format change cannot desync the two consumers.
Drops the duplicate parser in tests/verify-cowork-patches.bats.

Also tightens main()'s loop (for over indexed while, drop redundant
missing counter) and the BATS index loops.

Behaviour-preserving; bats tests/verify-cowork-patches.bats still 9/9.

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

* rename: verify-cowork-patches → verify-patches (generic)

Rename the verify infra to make its generic intent explicit. Per
sabiut's review note on #575, the script + TSV are reusable for
non-cowork patch sets in principle — drop "cowork" from the script
and BATS filenames to reflect that, and accept an optional second
arg for the marker TSV path so other patch sets can plug their own
TSV in without forking the script.

The TSV itself stays cowork-specific (`cowork-patch-markers.tsv`)
because its contents are cowork markers; the script defaults to it
so existing CI keeps working without changes beyond the rename.

Routing implication noted by sabiut: filename now lives under
`/tests/` → @sabiut codeowner mapping (intentionally; the verify
infra is generic). Cowork-specific marker changes still touch the
TSV under `/scripts/`, which routes to @aaddrick/@RayCharlizard via
the cowork-* CODEOWNERS rule.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:25:22 -04:00
Aaddrick
ccce3eab37 docs(learnings): add patching-minified-js + CONTRIBUTING (#559) (#574)
PR 1 of 3 for issue #559 — docs and conventions, no behaviour change.

- New `docs/learnings/patching-minified-js.md` covering anchor
  selection, identifier capture (`\w` vs `$`), beautified
  false-negative trap, whitespace tolerance, replacement-string
  escaping, idempotency, multi-site coordination, lastIndexOf
  disambiguation, and the SHA-256-pinned hypothesis-verification
  recipe.
- New `CONTRIBUTING.md` as a navigation hub: scope policy
  (no net-new features outside Linux-environment parity), upstream
  routing, subsystem owners, PR checklist, AI-assisted contribution
  disclosure format, and the patch-script regex intent comment +
  markdown wrapping conventions.
- Fix CLAUDE.md:126 example regex `\w+` → `[$\w]+` (same class of
  bug the new learnings doc documents).
- CLAUDE.md learnings index entry for the new doc.

PRs 2 (`verify-cowork-patches.sh` + BATS) and 3 (silent-no-op
WARNING retrofits) follow.

Refs #559

Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:15:42 -04:00
Aaddrick
0efa67d417 doctor: detect IBus/GTK misconfigurations that break input (#572)
* doctor: detect IBus/GTK misconfigurations that break input (#550)

Adds _doctor_check_im_modules helper covering the four input-method
failure modes from #545:

  - ibus-gtk3 package missing while GTK_IM_MODULE=ibus
  - GTK immodules cache stale (active module not listed by
    gtk-query-immodules-3.0 --update-cache fixes it)
  - XWayland session routing IBus through XIM (lossy for some IMEs;
    informational note pointing at CLAUDE_USE_WAYLAND=1 for native
    Wayland IME)
  - CLAUDE_GTK_IM_MODULE override visibility (informational, so
    users can verify the resolved value)

Each check is gated so it only fires when relevant — e.g. the
package check is skipped when GTK_IM_MODULE isn't ibus, the cache
check is skipped when gtk-query-immodules-3.0 isn't installed, and
the package check returns silently on distros without dpkg/rpm/pacman
to avoid false negatives.

Adds tests/doctor.bats with 17 cases covering each gating branch and
the _cowork_pkg_hint mapping for ibus-gtk3 (Arch maps to plain ibus
since it bundles the GTK3 immodule).

Hoists _distro_id resolution to the top of run_doctor so the IM
check and the existing Cowork section share one /etc/os-release
read.

Closes #550. Refs #545, #549.

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

* doctor: simplify IM-check helper and DRY out doctor.bats setup

Mechanical clean-up of the #550 diff after self-review:

scripts/doctor.sh
  - tighten the _doctor_check_im_modules docblock: drop the "each
    check is gated" paragraph (self-evident in the code) and inline
    the XWayland/XIM rationale into the failure-mode bullet
  - drop the inline section comments that just restated the next
    block's purpose; keep the rc=1/rc=2 comment because the value
    distinction is the load-bearing detail
  - replace the `local _pkg_rc=0; ... || _pkg_rc=$?; if ((_pkg_rc == 1))`
    dance with a `case $?` on the direct call

tests/doctor.bats
  - hoist the `command -v gtk-query-immodules-3.0 → not-found` shim
    into a `_skip_gtk_query` helper (it was duplicated across 11 of
    the 17 cases)
  - default `_pkg_installed() { return 2; }` in setup so per-test
    stubs only appear when the test cares about rc=0 or rc=1
  - drop dead `_skip_gtk_query` calls from cases where the function
    returns earlier (no IM selected, package warn fires) so the
    shim is only present where it actually changes behaviour

No behaviour change — all 17 doctor.bats cases still pass, plus the
68 launcher-common.bats cases. Shellcheck is unchanged from baseline.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:08:36 -04:00
Aaddrick
023a736f1c launcher: add CLAUDE_GTK_IM_MODULE opt-in override (#571)
* launcher: add CLAUDE_GTK_IM_MODULE opt-in override (#549)

Some users hit broken IBus integration on Linux and have to wrap
every launch with `GTK_IM_MODULE=xim claude-desktop`. Forcing this
for everyone would break CJK input methods, compose keys, and
dead-key sequences (rationale in #545), so this lands as opt-in.

When `CLAUDE_GTK_IM_MODULE` is set non-empty, `setup_electron_env`
exports `GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE` before the Electron
exec and logs the override (with the prior value) to launcher.log.
Unset/empty leaves `GTK_IM_MODULE` alone — no behavior change for
users not affected by the IBus issue.

Adds a TROUBLESHOOTING.md section documenting symptoms, valid
values, the trade-off note for `xim`, and BATS coverage for the
set / unset / empty / unset-prior cases.

Closes #549. Refs #545.

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

* launcher: tighten CLAUDE_GTK_IM_MODULE comment and docs (#549)

Trim the in-source comment to what's not implied by the guard, drop
the underscore prefix on the local, and remove a redundant trailing
sentence + duplicated trade-off line from TROUBLESHOOTING.md. No
behavior change; all 68 BATS tests still pass.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 06:58:58 -04:00
Aaddrick
3ddfb7353c launcher: log session/IME env block at startup (#570)
* launcher: log session/IME env block at startup (#548)

Adds log_session_env, called once per launch from each packaging
target (deb, rpm, AppImage, Nix). Emits a single env={ ... } block
covering display (XDG_SESSION_TYPE, WAYLAND_DISPLAY, DISPLAY,
XDG_CURRENT_DESKTOP), IME (GTK_IM_MODULE, XMODIFIERS, QT_IM_MODULE),
and Claude-specific overrides (CLAUDE_USE_WAYLAND,
CLAUDE_TITLEBAR_STYLE, CLAUDE_GTK_IM_MODULE).

Empty/unset values are emitted as `KEY=` (rather than omitted) so
absence is unambiguous in bug reports. Pure observability — no
behavior change.

Closes #548

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

* test: consolidate log_session_env BATS coverage (#548)

Collapse the four log_session_env cases into two, and tighten the
assertions in both:

  - Old test 1 (substring match per key) + old test 4 (block braces
    on their own lines) → one test using exact-line equality on the
    `lines` array. Locks block structure and per-key formatting in
    a single pass; substring matching could not catch a regression
    that re-ordered keys, dropped indentation, or merged lines.
  - Old test 2 (unset values are KEY=) + old test 3 (empty-string
    is KEY=) → one test covering both code paths. Exact-line match
    proves the value after `=` is truly empty; the previous
    `*'KEY='*` substring would have matched `KEY=value` and the
    old test-3 regex was fragile (depended on trailing newline
    being literal `$'\n'` vs end-of-string `$`).

Net: 77 → 42 lines, 4 → 2 cases, stronger guarantees. No change to
the helper itself or the call sites — issue #548 acceptance criteria
still hold.

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 06:45:42 -04:00
Aaddrick
3506c14918 test(harness): add Linux compatibility test harness (#579)
Build out a Playwright-based regression-detection harness covering
the compat-matrix surfaces (KDE-W, KDE-X, GNOME, Sway, i3, Niri,
packaging formats). Adds:

- Planning + decision docs under docs/testing/ — README, matrix,
  runbook, automation, cases/ (11 case files), quick-entry-closeout
- Playwright scaffolding (config, tsconfig)
- 78 spec runners under tools/test-harness/src/runners/ — T## case-
  doc runners and S## distribution/smoke runners
- Substrate primitives in tools/test-harness/src/lib/: AX-tree
  loader (snapshotAx + waitForAxNode + axTreeToSnapshot), focus-
  shifter, eipc-registry, niri-native bridge, drag-drop bridge,
  electron-mocks, claudeai page-objects, inspector client

S03 (DEB Depends declared) and S04 (RPM Requires declared) ship
marked test.fail() — they're regression detectors for the case-doc
gap (deb.sh emits no Depends:, rpm.sh sets AutoReqProv: no), and
the expected-failure shape lets them report green on every host
until upstream packaging starts declaring runtime deps.

127 files, no runtime changes; harness is opt-in via
'cd tools/test-harness && npx playwright test'.

Co-authored-by: Claude <claude@anthropic.com>
2026-05-04 23:17:37 -04:00
Liz Fong-Jones
b8e1a1fc30 feat(lifecycle): notify and offer restart on in-place package upgrade (#564)
* feat(lifecycle): notify and offer restart on in-place package upgrade

dpkg/rpm replace app.asar via rename() while the main process keeps
its in-memory JS. Any window opened after the swap loads HTML/asset
files fresh from disk, where the hashed asset filenames now point at
v(N+1) bundles that the in-memory v(N) IPC and preload layers don't
match. Symptoms observed across recent reports: Quick Entry rendering
as raw JS text, About dialog showing minified source, and Ctrl+Q
intermittently failing — anything where a post-swap window load
crosses the version boundary.

macOS / Windows clients get this from Squirrel; Linux deb/RPM has no
equivalent, so we watch the file ourselves and surface a click-to-
restart notification. AppImage is unaffected (squashfs mount stays
pinned to the running file's contents); Nix store paths are immutable
until GC, so the running inode also stays valid until explicit
relaunch. The watcher noop-quiet on those targets is deliberate.

Implementation: stat-baseline app.asar at first require('electron'),
watch the parent dir (file-level fs.watch loses the inode across
rename-replace; inotify on the dir reports the new entry via
IN_MOVED_TO), filename-filter, debounce 5s past the last event to
clear dpkg's .dpkg-new → rename dance, compare ino+mtime to confirm
a real change, then show a Notification deferred behind whenReady.
Click → app.relaunch(); app.quit().

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

* refactor(lifecycle): flatten upgrade-watcher block

Hoist the in-place-upgrade detection block in frame-fix-wrapper.js
into an armUpgradeWatcher() helper so the missing-baseline path
becomes an early return instead of an outer if (baseline) wrap.
Collapse the isReady() ? show() : whenReady().then(show) ternary
to plain whenReady().then(show) — Electron's whenReady() resolves
immediately when the app is already ready, so the branch was
dead. Trim narrative comments that duplicate the previous commit
message; keep the why-comments that earn their keep (parent-dir
watch, ino+mtime, 5s debounce, watcher.unref).

Behaviour preserved: filename filter, 5s debounce, ino+mtime
double-check, watcher.unref(), best-effort try/catch, idempotent
notified guard. Net -22 lines.

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

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-05-04 19:15:44 -04:00
github-actions[bot]
0bbb550421 chore: update flake.lock 2026-05-04 03:19:54 +00:00
Aaddrick
b351d42a2d docs: archive upstream report draft for #546 (filed as anthropics/claude-code#55353) (#552)
* docs(upstream-reports): draft anthropics/claude-code report for #546

Adds a ready-to-file draft of the upstream MCP double-spawn report,
matched to the anthropics/claude-code bug_report.yml schema and
written in aaddrick's documented voice.

Includes a filing checklist (GitHub issue + in-app /bug + bidirectional
comment back on #546) and a note about the template mismatch since the
form is built for the Claude Code CLI rather than Claude Desktop.

Refs #546

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

* docs(upstream-reports): link claude-desktop-debian repo in draft body

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

* docs(upstream-reports): add download-rate context next to repo link

Adds an approximate "~2,300 package downloads/day across the last 3
releases" parenthetical so the upstream report leads with a sense of
how many users the bug affects.

Computed from GitHub release asset download counts: 13,823 installable
binary downloads (deb + rpm + AppImage, both arches) across 1.5354.0,
1.5220.0, and 1.4758.0 over a ~6-day window.

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

* docs(upstream-reports): voice pass on draft body and title

Refines the draft for the upstream `anthropics/claude-code` issue
through the aaddrick-voice profile. Changes are surgical:

- Title: em-dash separator → colon (matches voice's documented
  preference for colons; removes em-dash signal site-wide).
- "What's Wrong?": opens with personal-experience framing ("I was
  reading", "What I found"), splits compound sentences, swaps
  announcement-colons for periods.
- "What Should Happen?": same period-for-colon swap on the fix
  paragraph.

No content shifts. Symbol table, code blocks, log signals, links,
and form-field labels untouched.

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

* docs(upstream-reports): drop bogus /bug filing step

User confirmed `/bug` and `/feedback` are inert in both Claude
Desktop and Claude Code. Earlier web research suggesting they route
to engineering was wrong. Replaced step 4 of the filing checklist
with a note about what's actually in the Help menu (Get Support
goes to the support chat, wrong queue) and what the Troubleshooting
submenu IS useful for (attaching Installation ID / logs to a
GitHub issue).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-03 12:38:12 -04:00
Aaddrick
b404ebd5f1 docs(learnings): refine mcp-double-spawn root cause and routing (#546) (#547)
Per-coordinator-registry framing (CCD + LAM + SshMcpServerManager)
replaces the previous two-coordinator framing. Notes that each
coordinator dedups within its own scope, so the bug is strictly
cross-coordinator. Routing correction: the SDK does what it's told
- the bug is in Claude Desktop's coordinator wiring, so the SDK
repo is only a defensible secondary venue for advocacy of a
shared-transport/multiplex primitive. Symbol drift section points
at #546 for current minified symbols and extraction regexes.

Co-authored-by: Claude <claude@anthropic.com>
2026-05-03 12:37:48 -04:00
Aaddrick
14d04c2dab fix(dnf): set metadata_expire=1h on generated .repo (#551)
DNF defaults to a 48h metadata cache when metadata_expire is unset,
so users running `dnf install/reinstall claude-desktop` shortly after
a release see stale versions until either the cache expires or they
manually run `dnf clean expire-cache`.

Lower the cache TTL on the generated repo file so freshly published
releases propagate within an hour without user intervention.

Co-authored-by: Claude <claude@anthropic.com>
2026-05-03 12:37:06 -04:00
aaddrick
fd352f4390 docs(readme): credit jslatten for KDE Wayland desktopName fix (#562)
Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 08:22:32 -04:00
Justin Slatten
5a98854137 set desktopName for Wayland grouping (#562) 2026-05-03 08:20:09 -04:00
123 changed files with 8094 additions and 16445 deletions

View File

@@ -658,7 +658,7 @@ Bash scripts in this project are located in:
- `.claude/hooks/` - Session lifecycle hooks (build tool installation, linting, PR simplification)
When writing scripts for this project:
- Follow the style guide in `STYLEGUIDE.md` (enforced by shellcheck)
- Follow the style guide in `docs/styleguides/bash_styleguide.md` (enforced by shellcheck)
- Use existing modular scripts in `scripts/` as patterns for build logic
- Reference `build.sh` for architecture detection and package orchestration patterns
- Test scripts work on both amd64 and arm64 architectures where applicable

View File

@@ -6,7 +6,7 @@ model: opus
You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions.
**Reference**: Follow the [Bash Style Guide](../../STYLEGUIDE.md)
**Reference**: Follow the [Bash Style Guide](../../docs/styleguides/bash_styleguide.md)
You will analyze recently modified code and apply refinements that:

View File

@@ -47,7 +47,7 @@ Only launch delegates for domains that have changed files in the PR. All domain
| Changed Files | Agent | What to Ask |
|---|---|---|
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against STYLEGUIDE.md and CLAUDE.md conventions. Report issues with suggested fixes. |
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against `docs/styleguides/bash_styleguide.md` and CLAUDE.md conventions. Report issues with suggested fixes. |
| JS files in `scripts/` | `electron-linux-specialist` | Review for Electron API correctness, error handling, cross-DE robustness (GNOME, KDE, Xfce, Cinnamon). Note: frame-fix-entry.js is generated by build.sh. |
| sed patterns in `build.sh` | `patch-engineer` | Check whitespace tolerance, idempotency guards, dynamic extraction error checks, match specificity, `-E` flag usage. Minified names change between releases — must use regex. |
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Check format constraints (RPM version hyphens, AppImage --no-sandbox, deb permissions), cross-format consistency, desktop integration. |
@@ -202,12 +202,12 @@ claude-desktop-debian/
├── .github/workflows/ # CI/CD pipelines
├── resources/ # Desktop entries, icons
├── CLAUDE.md # Project conventions
└── STYLEGUIDE.md # Bash style guide
└── docs/styleguides/bash_styleguide.md # Bash style guide (formerly STYLEGUIDE.md at root)
# Note: frame-fix-entry.js is generated by build.sh, not a standalone file
```
### Key Conventions
- Shell: follows STYLEGUIDE.md strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
- Shell: follows `docs/styleguides/bash_styleguide.md` strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
- JS in scripts/: standalone files using Electron APIs (not minified)
- JS in build.sh: sed patterns against minified source (must use regex)
- Attribution: reviews end with `Written by Claude <model> via [Claude Code](...)`
@@ -222,7 +222,7 @@ claude-desktop-debian/
| File Type | Delegate To | Focus Area |
|-----------|------------|------------|
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | STYLEGUIDE.md compliance, clarity |
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | `docs/styleguides/bash_styleguide.md` compliance, clarity |
| JS files (`scripts/*.js`) | `electron-linux-specialist` | Electron APIs, cross-DE compatibility |
| sed patterns in `build.sh` | `patch-engineer` | Regex robustness, idempotency, extraction |
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Format constraints, cross-format consistency |

View File

@@ -24,7 +24,7 @@ You are a senior Electron and Linux desktop integration specialist with deep exp
- **Menu Bar Management**: Hiding/showing menu bars on Linux, `autoHideMenuBar`, `setMenuBarVisibility`, and `Menu.setApplicationMenu` interception.
**Not in scope** (defer to other agents):
- Shell script style and STYLEGUIDE.md compliance (defer to `cdd-code-simplifier`)
- Shell script style and `docs/styleguides/bash_styleguide.md` compliance (defer to `cdd-code-simplifier`)
- PR review orchestration (defer to `code-reviewer`)
- CI/CD workflow YAML and release automation
- Debian/RPM package metadata and control files
@@ -241,7 +241,7 @@ The `code-reviewer` agent delegates JavaScript file reviews (files in `scripts/`
This agent provides Electron domain expertise; `cdd-code-simplifier` handles shell style:
- This agent specifies WHAT Electron flags/env vars/APIs to use
- `cdd-code-simplifier` ensures the shell code implementing them follows STYLEGUIDE.md
- `cdd-code-simplifier` ensures the shell code implementing them follows `docs/styleguides/bash_styleguide.md`
### Providing Guidance on Patches

View File

@@ -274,7 +274,7 @@ claude-desktop-debian/
claude-native-stub.js # Native module replacement
.github/workflows/ # CI/CD (defer to ci-workflow-architect)
CLAUDE.md # Project conventions
STYLEGUIDE.md # Bash style guide
docs/styleguides/bash_styleguide.md # Bash style guide
```
### Version String Flow

View File

@@ -102,7 +102,7 @@ claude-desktop-debian/
│ ├── frame-fix-entry.js # Generated entry point (by build.sh)
│ └── claude-native-stub.js # Native module replacement
├── CLAUDE.md # Project conventions
└── STYLEGUIDE.md # Bash style guide
└── docs/styleguides/bash_styleguide.md # Bash style guide
```
### Key Files
@@ -127,11 +127,11 @@ The electron module variable name changes every release. This extraction finds i
```bash
# Primary: find the variable assigned from require("electron")
electron_var=$(grep -oP '\b\w+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
electron_var=$(grep -oP '\b[$\w]+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
# Fallback: find it from Tray usage if require pattern doesn't match
if [[ -z $electron_var ]]; then
electron_var=$(grep -oP '(?<=new )\w+(?=\.Tray\b)' "$index_js" | head -1)
electron_var=$(grep -oP '(?<=new )[$\w]+(?=\.Tray\b)' "$index_js" | head -1)
fi
# Always validate
@@ -149,13 +149,13 @@ Three connected extractions, each depending on the previous:
```bash
# Step 1: Find the tray rebuild function name from event handler
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' "$index_js")
# Step 2: Find the tray variable using the function name as anchor
tray_var=$(grep -oP "\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" "$index_js")
tray_var=$(grep -oP "\}\);let \K[\$\w]+(?==null;(?:async )?function ${tray_func})" "$index_js")
# Step 3: Find the first const inside the function for insertion point
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K\w+(?==)" "$index_js" | head -1)
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K[\$\w]+(?==)" "$index_js" | head -1)
```
Each uses a stable string literal as anchor and captures the adjacent minified name.
@@ -206,7 +206,7 @@ Note: `e.hide()` uses a minified variable name `e`, but this is safe because it'
```bash
# Find all variables used with .nativeTheme that aren't the correct electron var
mapfile -t wrong_refs < <(
grep -oP '\b\w+(?=\.nativeTheme)' "$index_js" \
grep -oP '\b[$\w]+(?=\.nativeTheme)' "$index_js" \
| sort -u \
| grep -v "^${electron_var}$" || true
)
@@ -288,7 +288,7 @@ When writing a new patch or modifying an existing one:
## SHELL STYLE NOTES
Follow the project's [Bash Style Guide](../../STYLEGUIDE.md) for all shell code:
Follow the project's [Bash Style Guide](../../docs/styleguides/bash_styleguide.md) for all shell code:
- Tabs for indentation
- Lines under 80 characters (exception: long regex patterns and URLs)

View File

@@ -14,7 +14,7 @@ You are NOT a code quality reviewer. You do not evaluate:
- Performance characteristics
- Best practices or design patterns
- Test coverage or test quality
- Shell script conventions (STYLEGUIDE.md compliance)
- Shell script conventions (`docs/styleguides/bash_styleguide.md` compliance)
- Minified JS regex pattern quality
Those concerns belong to the `code-reviewer` agent, which runs in parallel with you.
@@ -235,7 +235,7 @@ Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
Leave these concerns to the `code-reviewer` agent:
- Code quality, style, and formatting
- Shell script STYLEGUIDE.md compliance
- Shell script `docs/styleguides/bash_styleguide.md` compliance
- Regex pattern quality in sed commands
- Performance implications
- Security vulnerabilities

View File

@@ -759,7 +759,7 @@ IMPORTANT SCOPE CONSTRAINT: This is for issue #$ISSUE_NUMBER. Only simplify code
If no relevant files were modified as part of this issue's implementation, make no changes and report 'No changes to simplify'.
Simplify code for clarity and consistency without changing functionality. Follow STYLEGUIDE.md conventions for shell scripts.
Simplify code for clarity and consistency without changing functionality. Follow docs/styleguides/bash_styleguide.md conventions for shell scripts.
Output a summary of changes made."
local simplify_result

View File

@@ -107,7 +107,7 @@ Before writing the agent, gather domain knowledge and project context:
Glob: "scripts/*.sh"
Glob: ".github/workflows/*.yml"
Grep: "function.*\(\)" # in shell scripts
Read: "CLAUDE.md", "README.md", "STYLEGUIDE.md"
Read: "CLAUDE.md", "README.md", "docs/styleguides/bash_styleguide.md"
# Find existing agent patterns
Glob: ".claude/agents/*.md"

View File

@@ -2,5 +2,8 @@
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,.codespellrc
check-hidden = true
# ignore-regex =
# ignore-words-list =
# ignore-regex =
# openIn — substring of `openInEditor` IPC channel name (upstream).
# YHe — minified function identifier in build-reference anchor.
# hel — three-char literal in QE-13 example ("hel (3) submits").
ignore-words-list = openIn,YHe,hel

11
.github/CODEOWNERS vendored
View File

@@ -62,7 +62,12 @@
# ---- Docs & style ----
/README.md @aaddrick
/CLAUDE.md @aaddrick
/STYLEGUIDE.md @aaddrick
/AGENTS.md @aaddrick
/CONTRIBUTING.md @aaddrick
/CHANGELOG.md @aaddrick
/RELEASING.md @aaddrick
/SECURITY.md @aaddrick
/docs/styleguides/ @aaddrick
/docs/ @aaddrick
# ---- Testing & release quality ----
@@ -76,9 +81,9 @@
/.github/workflows/tests.yml @sabiut
# Shared review — either owner can approve.
# TROUBLESHOOTING is mostly the --doctor user-facing guide; lint
# troubleshooting.md is mostly the --doctor user-facing guide; lint
# touches everything, so either maintainer can sign off.
/docs/TROUBLESHOOTING.md @aaddrick @sabiut
/docs/troubleshooting.md @aaddrick @sabiut
/.github/workflows/shellcheck.yml @aaddrick @sabiut
#===============================================================================

View File

@@ -49,6 +49,29 @@ jobs:
fi
./build.sh ${{ inputs.build_flags }} $TAG_FLAG
# Static-grep the shipped asar for the cowork patch markers
# defined in scripts/cowork-patch-markers.tsv (issue #559 D6,
# PR #555). Pinned to amd64-deb because the patched JS is
# identical across formats, so one verification per CI run is
# sufficient — no need to duplicate across the matrix.
- name: Verify cowork patches in shipped asar
if: inputs.artifact_suffix == 'deb'
run: |
deb_file=$(find . -maxdepth 1 -name 'claude-desktop_*amd64.deb' \
-print -quit)
if [[ -z "$deb_file" ]]; then
echo "verify-patches: no .deb artifact found" >&2
exit 1
fi
extract_dir=$(mktemp -d)
dpkg-deb -x "$deb_file" "$extract_dir"
asar_path=$(find "$extract_dir" -name app.asar -print -quit)
if [[ -z "$asar_path" ]]; then
echo "verify-patches: app.asar not found in deb" >&2
exit 1
fi
./scripts/verify-patches.sh "$asar_path"
- name: Upload AMD64 Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:

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
@@ -674,6 +683,7 @@ jobs:
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
'metadata_expire=1h' \
> rpm/claude-desktop.repo
- name: Re-upload signed RPMs to GitHub Release

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,20 +38,52 @@ 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'
run: |
sudo apt-get update
sudo apt-get install -y file libfuse2 nodejs npm
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/

View File

@@ -4,6 +4,12 @@ on:
workflow_call: # Make this workflow reusable
workflow_dispatch: # Allows manual triggering for testing
concurrency:
group: test-flags-${{ github.ref }}
# Matches ci.yml: queue rather than cancel, so a reusable invocation
# from an in-flight CI run isn't killed mid-flight on the next push.
cancel-in-progress: false
jobs:
test-flags:
runs-on: ubuntu-latest

25
.gitignore vendored
View File

@@ -24,6 +24,13 @@ Thumbs.db
# Test build output
test-build/
# Playwright stray output — the harness writes to
# tools/test-harness/results/ per playwright.config.ts, but Playwright
# also drops a default `test-results/.last-run.json` next to the cwd
# it's invoked from. Ignore it at the repo root so an accidental run
# from here doesn't dirty the tree.
test-results/
# Reference files for source inspection
build-reference/
@@ -34,6 +41,18 @@ result-*
# Wrangler (Cloudflare Worker dev/deploy cache)
worker/.wrangler/
# UI snapshots — captured renderer state, intentionally ignored to avoid
# diff churn. See docs/testing/ui-snapshots/README.md.
docs/testing/ui-snapshots/*.json
# 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

493
AGENTS.md
View File

@@ -1,13 +1,492 @@
# AGENTS.md
All project instructions, conventions, and development guidelines are maintained in [CLAUDE.md](CLAUDE.md).
<!--
This file is read by AI tools that support the agents.md vendor-neutral
standard. The content below is duplicated in CLAUDE.md (read by Claude
Code) so that contributors using either receive the same instructions
without needing to cross-reference. Keep CLAUDE.md and AGENTS.md
byte-identical below the H1 title (the sync-policy comment above is the
one place they intentionally differ) — if you edit one, edit the other.
-->
Strictly follow the rules defined there.
## Required reading
## Project Tooling
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
Subagent definitions, skills, and orchestration scripts live in [`.claude/`](.claude/):
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
- `.claude/agents/` - Specialized subagent definitions for the Task tool
- `.claude/skills/` - User-invocable skills (slash commands)
- `.claude/scripts/` - Orchestration scripts that chain multiple Claude CLI calls
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
## Project Overview
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
## Learnings
The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical knowledge from debugging and fixing issues — things that aren't obvious from reading the code or docs alone. Consult these before working on related areas. Add new entries when you discover something non-obvious that would save future contributors (human or AI) significant time.
- [`nix.md`](docs/learnings/nix.md) — NixOS packaging, Electron resource path resolution, testing without NixOS
- [`cowork-vm-daemon.md`](docs/learnings/cowork-vm-daemon.md) — Cowork VM daemon lifecycle, respawn logic, crash diagnosis
- [`plugin-install.md`](docs/learnings/plugin-install.md) — Anthropic & Partners plugin install flow, gate logic, backend endpoints, and DevTools recipes
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
- [`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
- [`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
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
- Single quotes for literals, double quotes for expansions
- Lowercase variables; UPPERCASE only for constants/exports
- Use `local` in functions, avoid `set -e` and `eval`
### Anti-patterns
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
### Linting
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
1. **Fix the code** - Correct the underlying issue rather than suppressing the warning
2. **Disable directives are a last resort** - Only use `# shellcheck disable=SCXXXX` when:
- The warning is a false positive
- The pattern is intentional and unavoidable
- Always add a comment explaining why the disable is needed
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
## Docs
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
## GitHub Workflow
### General Approach
- Use `gh` CLI for all GitHub interactions
- Create branches based on issue numbers: `fix/123-description` or `feature/123-description`
- Reference issues in commits and PRs with `#123` or `Fixes #123`
- After creating a PR, add a comment to the related issue with a summary and link to the PR
### Investigating Issues
For older issues, review the state of the code when the issue was raised - it may have already been addressed:
```bash
# Get issue creation date
gh issue view 123 --json createdAt
# Find the commit just before the issue was created
git log --oneline --until="2025-08-23T08:48:35Z" -1
# View a file at that point in time
git show <commit>:path/to/file.sh
# Search for relevant changes since the issue was created
git log --oneline --after="2025-08-23" -- path/to/file.sh
# View a specific commit that may have fixed the issue
git show <commit>
```
This helps identify if the issue was already fixed, and allows referencing the specific commit in the response.
### Attribution
**For PR descriptions**, include full attribution:
```
---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
<XX>% AI / <YY>% Human
Claude: <what AI did>
Human: <what human did>
```
- Use the actual model name (e.g., `Claude Opus 4.5`, `Claude Sonnet 4`)
- The percentage split should honestly reflect the contribution balance for that specific work
- This provides a trackable record of AI-assisted development over time
**For issues and comments**, use simplified attribution:
```
---
Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
```
**For commits**, include a Co-Authored-By trailer:
```
Co-Authored-By: Claude <claude@anthropic.com>
```
### Contributor Credits
The README Acknowledgments section credits external contributors in chronological order (by merge date or fix date). Update it when:
1. **Merging an external PR** — Add the author to the Acknowledgments list with a link to their GitHub profile and a brief description of their contribution.
2. **Implementing a fix suggested in an issue** — If an issue author (or commenter) provided a concrete fix, workaround, code snippet, or detailed technical analysis that was directly used, credit them too.
Contributors are listed in chronological order: inspirational projects first (k3d3, emsi, leobuskin), then contributors ordered by when their contribution was merged or implemented.
## Working with Minified JavaScript
### Important Guidelines
1. **Always use regex patterns** when modifying the source JavaScript. Patches live in `scripts/patches/*.sh` (one file per subsystem: `tray.sh`, `cowork.sh`, `claude-code.sh`, etc.); `build.sh` is only an orchestrator that sources them. Variable and function names are minified and **change between releases**.
2. **The beautified code in `build-reference/` has different spacing** than the actual minified code in the app. Patterns must handle both:
- Minified: `oe.nativeTheme.on("updated",()=>{`
- Beautified: `oe.nativeTheme.on("updated", () => {`
3. **Use `-E` flag with sed** for extended regex support when patterns need grouping or alternation.
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
```bash
# Extract function name from a known pattern
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
```
5. **Handle optional whitespace** in regex patterns:
```bash
# Bad: assumes no spaces
sed -i 's/oe.nativeTheme.on("updated",()=>{/...'
# Good: handles optional whitespace
sed -i -E 's/(oe\.nativeTheme\.on\(\s*"updated"\s*,\s*\(\)\s*=>\s*\{)/...'
```
### Reference Files
- `build-reference/app-extracted/` - Extracted and beautified source for analysis
- `build-reference/tray-icons/` - Tray icon assets for reference
## Frame Fix Wrapper
The app uses a wrapper system to intercept and fix Electron behavior for Linux:
- **`frame-fix-wrapper.js`** - Intercepts `require('electron')` to patch BrowserWindow defaults (e.g., `frame: true` for proper window decorations on Linux)
- **`frame-fix-entry.js`** - Entry point that loads the wrapper before the main app
These are injected by `scripts/patches/app-asar.sh` (inside `patch_app_asar`) and referenced in `package.json`'s `main` field. The wrapper pattern allows fixing Electron behavior without modifying the minified app code directly.
## Setting Up build-reference
If `build-reference/` is missing or you need to inspect source for a new version, follow these steps to download, extract, and beautify the source code.
### Prerequisites
```bash
# Install required tools
sudo apt install p7zip-full wget nodejs npm
# Install asar and prettier globally (or use npx)
npm install -g @electron/asar prettier
```
### Step 1: Download the Windows Installer
The Windows installer contains the app.asar which has the full Electron app source.
```bash
# Create working directory
mkdir -p build-reference && cd build-reference
# Download URL pattern (update version as needed):
# x64: https://downloads.claude.ai/releases/win32/x64/VERSION/Claude-COMMIT.exe
# arm64: https://downloads.claude.ai/releases/win32/arm64/VERSION/Claude-COMMIT.exe
# Example for version 1.1.381:
wget -O Claude-Setup-x64.exe "https://downloads.claude.ai/releases/win32/x64/1.1.381/Claude-c2a39e9c82f5a4d51f511f53f532afd276312731.exe"
```
### Step 2: Extract the Installer
```bash
# Extract the exe (it's a 7z archive)
7z x -y Claude-Setup-x64.exe -o"exe-contents"
# Find and extract the nupkg
cd exe-contents
NUPKG=$(find . -name "AnthropicClaude-*.nupkg" | head -1)
7z x -y "$NUPKG" -o"nupkg-contents"
cd ..
# Copy out the important files
cp exe-contents/nupkg-contents/lib/net45/resources/app.asar .
cp -a exe-contents/nupkg-contents/lib/net45/resources/app.asar.unpacked .
# Optional: copy tray icons for reference
mkdir -p tray-icons
cp exe-contents/nupkg-contents/lib/net45/resources/*.png tray-icons/ 2>/dev/null || true
cp exe-contents/nupkg-contents/lib/net45/resources/*.ico tray-icons/ 2>/dev/null || true
```
### Step 3: Extract app.asar
```bash
# Extract the asar archive
asar extract app.asar app-extracted
```
### Step 4: Beautify the JavaScript Files
The extracted JS files are minified. Use prettier to make them readable:
```bash
# Beautify all JS files in the build directory
npx prettier --write "app-extracted/.vite/build/*.js"
# Or beautify specific files
npx prettier --write app-extracted/.vite/build/index.js
npx prettier --write app-extracted/.vite/build/mainWindow.js
```
### Step 5: Clean Up (Optional)
```bash
# Remove intermediate files, keep only what's needed for reference
rm -rf exe-contents
rm Claude-Setup-x64.exe
rm -rf app.asar app.asar.unpacked # Keep only app-extracted
```
### Final Structure
```
build-reference/
├── app-extracted/
│ ├── .vite/
│ │ ├── build/
│ │ │ ├── index.js # Main process (beautified)
│ │ │ ├── mainWindow.js # Main window preload
│ │ │ ├── mainView.js # Main view preload
│ │ │ └── ...
│ │ └── renderer/
│ │ └── ...
│ ├── node_modules/
│ │ └── @ant/claude-native/ # Native bindings (stubs)
│ └── package.json
├── tray-icons/
│ ├── TrayIconTemplate.png # Black icon (for light panels)
│ ├── TrayIconTemplate-Dark.png # White icon (for dark panels)
│ └── ...
└── nupkg-contents/ # Optional: full extracted nupkg
```
## Adding New Package Formats or Repositories
When adding support for new distribution formats (e.g., RPM, Flatpak, Snap) or package repositories, follow these guidelines to avoid iterative debugging in CI.
### Research Before Implementing
1. **Understand the target system's constraints** - Each package format has specific rules:
- Version string formats (e.g., RPM cannot have hyphens in Version field)
- Required metadata fields
- Signing requirements and tools
2. **Search for existing CI implementations** - Look for "GitHub Actions [format] signing" or similar. Existing workflows reveal required flags, environment setup, and common pitfalls.
3. **Check tool behavior in non-interactive environments** - CI has no TTY. Tools like GPG need flags like `--batch` and `--yes` to work without prompts.
### Consider Concurrency
1. **Multiple jobs writing to the same branch will race** - If APT and DNF repos both push to `gh-pages`, add:
- Job dependencies (`needs: [other-job]`), or
- Retry loops with `git pull --rebase` before push
2. **External processes may also modify branches** - GitHub Pages deployment runs automatically and can cause push conflicts.
### Test the Full Pipeline
1. **Test CI steps locally first** - Run the signing/packaging commands manually to catch errors before committing.
2. **Use a test tag for new infrastructure** - Create a non-release tag to validate the full CI pipeline before merging to main.
3. **Verify the end-user experience** - After CI succeeds, actually test the install commands from the README on a clean system.
### Common CI Pitfalls
| Issue | Solution |
|-------|----------|
| GPG "cannot open /dev/tty" | Add `--batch` flag |
| GPG "File exists" error | Add `--yes` flag to overwrite |
| Push rejected (ref changed) | Add `git pull --rebase` before push, with retry loop |
| Version format invalid | Research target format's version constraints upfront |
| Signing key not found | Ensure key is imported before signing step, check key ID output |
## CI/CD
### Triggering Builds
```bash
# Trigger CI on a branch
gh workflow run CI --ref branch-name
# Watch the run
gh run watch RUN_ID
# Download artifacts
gh run download RUN_ID -n artifact-name
```
### Build Artifacts
- `claude-desktop-VERSION-amd64.deb` - Debian package for x86_64
- `claude-desktop-VERSION-amd64.AppImage` - AppImage for x86_64
- `claude-desktop-VERSION-arm64.deb` - Debian package for ARM64
- `claude-desktop-VERSION-arm64.AppImage` - AppImage for ARM64
- `result/` - Nix build output (symlink, gitignored)
## Distribution
APT and DNF binaries are fronted by a Cloudflare Worker at `pkg.claude-desktop-debian.dev`. Metadata (`InRelease`, `Packages`, `KEY.gpg`, `repodata/*`) passes through to the `gh-pages` branch; binary requests (`/pool/.../*.deb`, `/rpm/*/*.rpm`) get 302'd to the corresponding GitHub Release asset. This keeps `.deb` / `.rpm` files out of `gh-pages` entirely, so they never hit GitHub's 100 MB per-file push cap.
Key files:
- `worker/src/worker.js` — Worker source
- `worker/wrangler.toml` — Worker config (route, `custom_domain = true`)
- `.github/workflows/deploy-worker.yml` — deploys on push to `main` when `worker/**` changes
- `.github/workflows/apt-repo-heartbeat.yml` — daily chain validation, auto-opens tracking issue on failure
- `update-apt-repo` and `update-dnf-repo` jobs in `.github/workflows/ci.yml` — gate a strip step on Worker liveness, so binaries are removed from the local pool tree before push
Repo secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`. Token scoped to the "Edit Cloudflare Workers" template.
Full details including the redirect chain, the http-scheme-downgrade gotcha, credential ownership, and heartbeat failure runbook: [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md).
## Testing
### Local Build
```bash
./build.sh --build appimage --clean no
```
### Nix Build
```bash
nix build .#claude-desktop
nix build .#claude-desktop-fhs
```
### Testing AppImage
```bash
# Run with logging
./test-build/claude-desktop-*.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log
```
## Debugging Workflow
### Inspecting the Running App's Code
```bash
# Find the mounted AppImage path
mount | grep claude
# Example: /tmp/.mount_claudeXXXXXX
# Extract the running app's asar for inspection
npx asar extract /tmp/.mount_claudeXXXXXX/usr/lib/node_modules/electron/dist/resources/app.asar /tmp/claude-inspect
# Search for patterns in the extracted code
grep -n "pattern" /tmp/claude-inspect/.vite/build/index.js
```
### Checking DBus/Tray Status
```bash
# List registered tray icons
gdbus call --session --dest=org.kde.StatusNotifierWatcher \
--object-path=/StatusNotifierWatcher \
--method=org.freedesktop.DBus.Properties.Get \
org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems
# Find which process owns a DBus connection
gdbus call --session --dest=org.freedesktop.DBus \
--object-path=/org/freedesktop/DBus \
--method=org.freedesktop.DBus.GetConnectionUnixProcessID ":1.XXXX"
```
### Log Locations
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
- App logs: `~/.config/Claude/logs/`
- Run with logging: `./app.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log`
## Useful Locations
- App data: `~/.config/Claude/`
- Logs: `~/.config/Claude/logs/`
- SingletonLock: `~/.config/Claude/SingletonLock`
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
## Versioning
Release versions are managed via two GitHub Actions repository variables (not files):
- **`REPO_VERSION`** - The project's own version (e.g., `1.3.23`). Bump this manually via `gh variable set REPO_VERSION --body "X.Y.Z"` when shipping project changes.
- **`CLAUDE_DESKTOP_VERSION`** - The upstream Claude Desktop version (e.g., `1.1.8629`). Updated automatically by the `check-claude-version` workflow when a new upstream release is detected.
### Tag format
Tags follow the pattern `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}`, e.g., `v1.3.23+claude1.1.7714`. Pushing a tag triggers the CI release build.
```bash
# Check current values
gh variable get REPO_VERSION
gh variable get CLAUDE_DESKTOP_VERSION
# Bump repo version and tag a release
gh variable set REPO_VERSION --body "1.3.24"
git tag "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
git push origin "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
```
When upstream Claude Desktop updates, the `check-claude-version` workflow automatically updates `CLAUDE_DESKTOP_VERSION`, patches the URLs in `scripts/setup/detect-host.sh`, and creates a new tag — no manual intervention needed.
## Common Gotchas
- **`.zsync` files** - Used for delta updates, can be ignored/deleted
- **AppImage mount points** - Running AppImages mount to `/tmp/.mount_claude*`; check with `mount | grep claude`
- **Killing the app** - Must kill all electron child processes, not just the main one:
```bash
pkill -9 -f "mount_claude"
```
- **SingletonLock** - If app won't start, check for stale lock: `~/.config/Claude/SingletonLock`
- **Node version** - Build requires Node.js; the script downloads its own if needed
- **Nix hashes** - When Claude Desktop version changes, both the URLs in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix` (version, URLs, SRI hashes) must be updated. The CI handles this automatically.
- **Claude Desktop version** - A GitHub Action automatically updates the `CLAUDE_DESKTOP_VERSION` repo variable and the URLs in `scripts/setup/detect-host.sh` on main when a new version is detected. Before committing `scripts/setup/detect-host.sh`, ensure your branch has the latest URLs:
```bash
# Check repo variable (source of truth)
gh variable get CLAUDE_DESKTOP_VERSION
# Check current version in the detect_architecture case statement
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
# If outdated, pull URLs from main branch
gh api repos/aaddrick/claude-desktop-debian/contents/scripts/setup/detect-host.sh?ref=main \
--jq '.content' | base64 -d | grep -E "claude_download_url="
```
Update both amd64 and arm64 URLs in `detect_architecture()` to match main

350
CHANGELOG.md Normal file
View File

@@ -0,0 +1,350 @@
# Changelog
All notable changes to `aaddrick/claude-desktop-debian` are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — semantic versioning applies to `REPO_VERSION`; upstream Claude Desktop bumps (the `+claude{X.Y.Z}` suffix on the tag) are tracked separately by the `check-claude-version` workflow.
## [Unreleased]
<!-- Updated automatically by check-claude-version; will be current at release time. -->
### Fixed
- `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.
- `[$\w]+` is the codified identifier-capture convention for patch-script regexes (CONTRIBUTING § Patch-script regexes; `patch-engineer` agent examples updated to match). Closes a docs-vs-code gap that left the rule only in [`docs/learnings/patching-minified-js.md`](docs/learnings/patching-minified-js.md) — the same `\w+` trap fixed in patches by [#555](https://github.com/aaddrick/claude-desktop-debian/pull/555) and [#627](https://github.com/aaddrick/claude-desktop-debian/pull/627).
## [v2.0.12] — 2026-05-19
Tracks upstream Claude Desktop 1.7196.3.
### Added
- Headless launch + `--doctor` smoke tests for the AppImage artifact. ([#592](https://github.com/aaddrick/claude-desktop-debian/pull/592))
### Changed
- CI: add concurrency group to `test-flags` workflow. ([#606](https://github.com/aaddrick/claude-desktop-debian/pull/606))
## [v2.0.11] — 2026-05-16
Tracks upstream Claude Desktop 1.7196.1.
### Fixed
- Catch About window after upstream `titleBarStyle` change; guard Hardware Buddy. ([#481](https://github.com/aaddrick/claude-desktop-debian/pull/481), [#489](https://github.com/aaddrick/claude-desktop-debian/pull/489))
- RPM `chrome-sandbox` SUID now set via `%attr` instead of `%post chmod`. ([#539](https://github.com/aaddrick/claude-desktop-debian/pull/539), [#595](https://github.com/aaddrick/claude-desktop-debian/pull/595))
- No-op `autoUpdater` on Linux to defend against feed activation; mask thenable/coercion traps on the Proxy. ([#567](https://github.com/aaddrick/claude-desktop-debian/pull/567), [#596](https://github.com/aaddrick/claude-desktop-debian/pull/596))
- `node-pty` install fails loudly on `npm install` failure; require `gcc`/`make`/`python3`. ([#401](https://github.com/aaddrick/claude-desktop-debian/pull/401), [#598](https://github.com/aaddrick/claude-desktop-debian/pull/598))
- Fetch electron binary via `@electron/get`, drop `^41` pin; resolve from `work_dir` not script dir. ([#587](https://github.com/aaddrick/claude-desktop-debian/pull/587))
- Dedupe packages mapped from multiple commands.
## [v2.0.10] — 2026-05-06
Tracks upstream Claude Desktop 1.6259.0, 1.6259.1, 1.6608.0, 1.6608.2, 1.7196.0.
### Added
- `--doctor` surfaces recent Electron crashes with a `#583` pointer; `CLAUDE_DISABLE_GPU=1` opt-in for GPU-process fatal crashes. ([#583](https://github.com/aaddrick/claude-desktop-debian/pull/583), [#585](https://github.com/aaddrick/claude-desktop-debian/pull/585))
- `--doctor` detects IBus/GTK misconfigurations that break input. ([#572](https://github.com/aaddrick/claude-desktop-debian/pull/572))
- Launcher: `CLAUDE_GTK_IM_MODULE` opt-in override. ([#571](https://github.com/aaddrick/claude-desktop-debian/pull/571))
- Launcher: log session/IME env block at startup. ([#570](https://github.com/aaddrick/claude-desktop-debian/pull/570))
- Linux compatibility test harness. ([#579](https://github.com/aaddrick/claude-desktop-debian/pull/579))
- Lifecycle: notify and offer restart on in-place package upgrade. ([#564](https://github.com/aaddrick/claude-desktop-debian/pull/564))
- `desktopName` set for Wayland window grouping. Thanks @jslatten. ([#562](https://github.com/aaddrick/claude-desktop-debian/pull/562))
### Fixed
- Pin electron to `^41` to restore postinstall binary fetch. ([#584](https://github.com/aaddrick/claude-desktop-debian/pull/584), [#586](https://github.com/aaddrick/claude-desktop-debian/pull/586))
- Nix: make electron binary executable. ([#581](https://github.com/aaddrick/claude-desktop-debian/pull/581))
- `cowork.sh`: emit WARNING on Patch 2a/2b inner anchor miss. ([#576](https://github.com/aaddrick/claude-desktop-debian/pull/576))
- CI: force primary GPG key for `repomd.xml` signing. Thanks @ProfFlow. ([#566](https://github.com/aaddrick/claude-desktop-debian/pull/566))
- DNF: set `metadata_expire=1h` on generated `.repo`. ([#551](https://github.com/aaddrick/claude-desktop-debian/pull/551))
- BATS: isolate `cleanup_stale_cowork_socket` from host `pgrep` state. ([#534](https://github.com/aaddrick/claude-desktop-debian/pull/534))
### Changed
- Static-grep shipped asar for PR #555 markers as a verification step. ([#559](https://github.com/aaddrick/claude-desktop-debian/pull/559), [#575](https://github.com/aaddrick/claude-desktop-debian/pull/575))
- New `patching-minified-js` learnings doc + `CONTRIBUTING`. ([#574](https://github.com/aaddrick/claude-desktop-debian/pull/574))
- Refine `mcp-double-spawn` root cause and routing in learnings. ([#546](https://github.com/aaddrick/claude-desktop-debian/pull/546), [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547))
- Archive upstream report draft for #546 (filed as `anthropics/claude-code#55353`). ([#552](https://github.com/aaddrick/claude-desktop-debian/pull/552))
## [v2.0.8] — 2026-05-02
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.7).
### Fixed
- Cowork starts again on Claude Desktop 1.5354.0. Upstream's minifier started emitting `$`-containing identifiers (`C$i`, `g$i`); two regex anchors in `scripts/patches/cowork.sh` used `\w+`, which doesn't match `$`. Patch 2b silently no-op'd, the Swift VM module assignment never landed, and you'd hit `Swift VM addon not available` at session init. Widens both anchors to `[\w$]+`. Patch 6 also moves from `indexOf` to `lastIndexOf` on the retry-delay anchor. Thanks @sirfaber, @HumboldtJoker, @zabka. ([#555](https://github.com/aaddrick/claude-desktop-debian/pull/555), fixes [#558](https://github.com/aaddrick/claude-desktop-debian/issues/558), likely fixes [#553](https://github.com/aaddrick/claude-desktop-debian/issues/553) and [#445](https://github.com/aaddrick/claude-desktop-debian/issues/445))
## [v2.0.7] — 2026-05-01
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.6).
### Added
- Linux in-app topbar works now. New `hybrid` titlebar mode is the default: native OS frame plus a BrowserView preload shim that satisfies claude.ai's UA gate, so the hamburger, sidebar, search, and nav buttons render and are clickable. Layout is stacked (DE titlebar above the in-app topbar) rather than combined like Windows. Set `CLAUDE_TITLEBAR_STYLE=native` to opt out and hide the in-app topbar. The upstream `frame:false` + WCO config is preserved as `hidden` for investigation but still has unclickable buttons on Linux; `--doctor` warns when it's active. Verified on KDE Plasma X11/Wayland and Hyprland; GNOME, Sway, Niri, and NixOS pending. ([#538](https://github.com/aaddrick/claude-desktop-debian/pull/538))
## [v2.0.6] — 2026-05-01
Tracks upstream Claude Desktop 1.5354.0. Absorbs three upstream bumps from v2.0.5: 1.4758.0, 1.5220.0, 1.5354.0.
### Added
- Cowork bwrap mounts accept a `{src, dst}` form, so you can map a host directory under `$HOME` onto a different path inside the sandbox. Unlocks persistent-`/tmp` so Bash tool calls don't wipe state between invocations. String form unchanged. Thanks @cbonnissent. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
- `--doctor` warns when `COWORK_VM_BACKEND` is set to an unknown value instead of silently falling through to auto-detect; adds a `COWORK_VM_BACKEND` row and a Cowork Backend section to `docs/configuration.md`. Thanks @CyPack. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
- `--doctor` warns when an additional bwrap mount destination shadows a default sandbox path like `/usr`, `/etc`, `/bin`, `/sbin`, `/lib`. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
- Troubleshooting entries for Cowork VM connection timeout, virtiofsd outside `$PATH` on Fedora/RHEL (`/usr/libexec/virtiofsd`), and Fedora tmpfs `EXDEV` errors. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
### Fixed
- Closing the window no longer kills the app on Linux. The X button hides to tray, matching Windows and macOS. Quit explicitly with Ctrl+Q, the tray menu, or your DE's quit shortcut. Set `CLAUDE_QUIT_ON_CLOSE=1` to restore the old behavior. Fixes scheduled tasks and `/schedule` firings getting silently dropped overnight. Thanks @lizthegrey. ([#451](https://github.com/aaddrick/claude-desktop-debian/pull/451))
- "Run on startup" toggle persists on Linux now. Electron's `setLoginItemSettings` isn't implemented on Linux; the wrapper backs the toggle with `~/.config/autostart/claude-desktop.desktop` per the XDG Autostart spec. Thanks @lizthegrey. ([#450](https://github.com/aaddrick/claude-desktop-debian/pull/450), fixes [#128](https://github.com/aaddrick/claude-desktop-debian/issues/128))
- Tray icon updates in place on OS theme change instead of briefly duplicating on KDE Plasma. Uses `setImage` + `setContextMenu` rather than destroy + recreate. Thanks @IliyaBrook. ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515))
- Window visibility check works again after an upstream minified-name change broke it. Thanks @Andrej730. ([#496](https://github.com/aaddrick/claude-desktop-debian/pull/496), fixes [#495](https://github.com/aaddrick/claude-desktop-debian/issues/495))
### Changed
- APT/DNF install instructions point at `pkg.claude-desktop-debian.dev` directly, bypassing the GitHub Pages 301. Pages serves the redirect over `http://` because it can't provision a cert for the `pkg.` subdomain (DNS belongs to the Cloudflare Worker), and `apt` refuses HTTPS→HTTP downgrades. DNF was unaffected. ([#510](https://github.com/aaddrick/claude-desktop-debian/pull/510), [#514](https://github.com/aaddrick/claude-desktop-debian/pull/514))
## [v2.0.5] — 2026-04-23
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
### Fixed
- CI: smoke test accepts release-assets CDN hostname. ([#509](https://github.com/aaddrick/claude-desktop-debian/pull/509))
- Strip CRLF from `cowork-plugin-shim.sh` during staging. ([#499](https://github.com/aaddrick/claude-desktop-debian/pull/499), [#505](https://github.com/aaddrick/claude-desktop-debian/pull/505))
## [v2.0.4] — 2026-04-23
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
### Fixed
- CI: smoke test accepts `http://` on Pages 301 hop. ([#506](https://github.com/aaddrick/claude-desktop-debian/pull/506))
- Worker: use `raw.githubusercontent.com` as origin to avoid Pages 301 loop. ([#504](https://github.com/aaddrick/claude-desktop-debian/pull/504))
### Changed
- Worker: flip route from staging to production for Phase 4a. ([#503](https://github.com/aaddrick/claude-desktop-debian/pull/503))
## [v2.0.3] — 2026-04-23
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
### Added
- APT/DNF Worker scaffolding. ([#498](https://github.com/aaddrick/claude-desktop-debian/pull/498))
### Fixed
- CI: resolve DNF Worker chain blockers. ([#500](https://github.com/aaddrick/claude-desktop-debian/issues/500), [#501](https://github.com/aaddrick/claude-desktop-debian/issues/501), [#502](https://github.com/aaddrick/claude-desktop-debian/pull/502))
### Changed
- Plan APT/DNF distribution via Cloudflare Worker. ([#493](https://github.com/aaddrick/claude-desktop-debian/pull/493), [#494](https://github.com/aaddrick/claude-desktop-debian/pull/494))
## [v2.0.2] — 2026-04-22
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
### Added
- BATS unit tests for `launcher-common.sh`. ([#395](https://github.com/aaddrick/claude-desktop-debian/pull/395))
### Fixed
- Copy `ion-dist` static assets for the `app://` protocol handler. ([#490](https://github.com/aaddrick/claude-desktop-debian/pull/490))
## [v2.0.1] — 2026-04-21
Wrapper/packaging update; tracks upstream Claude Desktop 1.3561.0, 1.3883.0.
### Added
- Triage Phase 4 sub-PRs: Stage 8c enhancement-design variant, suspicious-input tells, `regression_of` + edit-during-triage. ([#470](https://github.com/aaddrick/claude-desktop-debian/pull/470), [#471](https://github.com/aaddrick/claude-desktop-debian/pull/471), [#472](https://github.com/aaddrick/claude-desktop-debian/pull/472))
- Triage Phase 3: Stage 6 adversarial reviewer + duplicate gate. ([#465](https://github.com/aaddrick/claude-desktop-debian/pull/465))
- Decision log with D-001 (auto-update direction). ([#477](https://github.com/aaddrick/claude-desktop-debian/pull/477))
- `@sabiut` added to CODEOWNERS for testing & release quality. ([#468](https://github.com/aaddrick/claude-desktop-debian/pull/468))
### Fixed
- Export `GDK_BACKEND=wayland` in native Wayland mode. Thanks @aJV99. ([#397](https://github.com/aaddrick/claude-desktop-debian/pull/397))
- Scope Ctrl+Q to the focused window, not system-wide. ([#484](https://github.com/aaddrick/claude-desktop-debian/pull/484))
- Cowork: forward `CLAUDE_CODE_OAUTH_TOKEN` to VM spawn env. ([#482](https://github.com/aaddrick/claude-desktop-debian/pull/482), [#485](https://github.com/aaddrick/claude-desktop-debian/pull/485))
- Launcher: disable GPU compositing on XRDP sessions. ([#475](https://github.com/aaddrick/claude-desktop-debian/pull/475))
- Triage: normalize `claimed_version` before drift compare. ([#483](https://github.com/aaddrick/claude-desktop-debian/pull/483))
- Triage: drift-as-banner — demote drift from gate to modifier. ([#476](https://github.com/aaddrick/claude-desktop-debian/pull/476))
- Triage: pull broken-expectation rule up into first-pass classify. ([#469](https://github.com/aaddrick/claude-desktop-debian/pull/469))
- Triage: raise 8b comment word cap 150 → 300. ([#464](https://github.com/aaddrick/claude-desktop-debian/pull/464))
### Changed
- Triage v2 production cutover; README synced with shipped pipeline (drop plan + research). ([#478](https://github.com/aaddrick/claude-desktop-debian/pull/478), [#480](https://github.com/aaddrick/claude-desktop-debian/pull/480))
- Rename `feature` classification to `enhancement` in triage. ([#466](https://github.com/aaddrick/claude-desktop-debian/pull/466))
## [v2.0.0] — 2026-04-20
First v2 wrapper release; tracks upstream Claude Desktop 1.3109.0, 1.3561.0.
### Added
- Always-on lifecycle logging for `cowork-vm-service`. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
- `cowork-vm-daemon` learnings doc and Anthropic & Partners plugin install flow doc. ([#439](https://github.com/aaddrick/claude-desktop-debian/pull/439))
- `.github/CODEOWNERS` for per-subsystem review ownership.
- `shellcheck -x` to follow sourced modules in CI.
### Fixed
- Restore `cowork-vm-service` daemon recovery after crash. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
- Forward `userSelectedFolders[0]` as `sharedCwdPath` on cowork spawn. ([#412](https://github.com/aaddrick/claude-desktop-debian/pull/412), [#436](https://github.com/aaddrick/claude-desktop-debian/pull/436))
- Strip mode on `node-pty` cp at source; retire `chmod`. Chmod `node-pty` unpacked files before overwriting in Nix builds. ([#432](https://github.com/aaddrick/claude-desktop-debian/pull/432), [#438](https://github.com/aaddrick/claude-desktop-debian/pull/438))
- Diagnose AppArmor userns block on bwrap probe. ([#351](https://github.com/aaddrick/claude-desktop-debian/issues/351), [#434](https://github.com/aaddrick/claude-desktop-debian/pull/434))
- Suppress Cowork tab auto-select on every launch. ([#341](https://github.com/aaddrick/claude-desktop-debian/issues/341), [#433](https://github.com/aaddrick/claude-desktop-debian/pull/433))
- `home --dir` before SDK `--ro-bind` in bwrap sandbox. ([#426](https://github.com/aaddrick/claude-desktop-debian/pull/426))
- Only route `claude` commands through SDK binary in `cowork-vm-service`. ([#430](https://github.com/aaddrick/claude-desktop-debian/pull/430))
- `launcher-common.sh` self-match and stale socket cleanup. ([#407](https://github.com/aaddrick/claude-desktop-debian/pull/407), [#425](https://github.com/aaddrick/claude-desktop-debian/pull/425))
- Translate guest paths inside `--allowedTools` and `--disallowedTools`. ([#411](https://github.com/aaddrick/claude-desktop-debian/pull/411))
- Resolve working directory from primary mount on HostBackend. ([#392](https://github.com/aaddrick/claude-desktop-debian/pull/392))
### Changed
- **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.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
[v2.0.8]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.7+claude1.5354.0...v2.0.8+claude1.5354.0
[v2.0.7]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.6+claude1.5354.0...v2.0.7+claude1.5354.0
[v2.0.6]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.5+claude1.5354.0...v2.0.6+claude1.5354.0
[v2.0.5]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.4+claude1.3883.0...v2.0.5+claude1.3883.0
[v2.0.4]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.3+claude1.3883.0...v2.0.4+claude1.3883.0
[v2.0.3]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.2+claude1.3883.0...v2.0.3+claude1.3883.0
[v2.0.2]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.1+claude1.3883.0...v2.0.2+claude1.3883.0
[v2.0.1]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.0+claude1.3561.0...v2.0.1+claude1.3883.0
[v2.0.0]: https://github.com/aaddrick/claude-desktop-debian/releases/tag/v2.0.0+claude1.3109.0

View File

@@ -1,5 +1,26 @@
# Claude Desktop Debian - Development Notes
<!--
This file is read by Claude Code. The content below is duplicated in
AGENTS.md (read by other AI tools per the agents.md standard) so that
contributors using either receive the same instructions without needing
to cross-reference. Keep CLAUDE.md and AGENTS.md byte-identical below
the H1 title (the sync-policy comment above is the one place they
intentionally differ) — if you edit one, edit the other.
-->
## Required reading
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
## Project Overview
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
@@ -17,10 +38,12 @@ 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
All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.md). Key points:
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
@@ -28,6 +51,16 @@ All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.
- Lowercase variables; UPPERCASE only for constants/exports
- Use `local` in functions, avoid `set -e` and `eval`
### Anti-patterns
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
### Linting
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
@@ -39,6 +72,16 @@ Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `a
- Always add a comment explaining why the disable is needed
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
## Docs
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
## GitHub Workflow
### General Approach
@@ -125,7 +168,7 @@ Contributors are listed in chronological order: inspirational projects first (k3
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
```bash
# Extract function name from a known pattern
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
```
5. **Handle optional whitespace** in regex patterns:

161
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,161 @@
# Contributing
## Before you start
A few minutes here saves a round-trip later. Match your task to the right channel:
- **Found a bug?** File an [issue](https://github.com/aaddrick/claude-desktop-debian/issues/new/choose)
with the bug template. Paste full `claude-desktop --doctor` output;
include distro, DE, and session type (Wayland/X11). See
[Filing an issue](#filing-an-issue).
- **Have a fix in hand?** PRs that fix existing behaviour, restore parity
with Windows/macOS, or improve packaging are always welcome. Open the
PR; an issue isn't strictly required if the fix is small.
- **Want to add a new feature?** Open a [discussion](https://github.com/aaddrick/claude-desktop-debian/discussions)
or an issue first. We're a repackager; most net-new behaviour is
declined by default — see [What we accept](#what-we-accept).
- **Security concern?** Don't file a public issue. Use
[SECURITY.md](SECURITY.md) — GitHub Security Advisories route to
@aaddrick privately.
## Where to find what
- [CLAUDE.md](CLAUDE.md): conventions, build, patches, attribution.
- [AGENTS.md](AGENTS.md): vendor-neutral mirror of CLAUDE.md for non-Claude AI tools.
- [docs/index.md](docs/index.md): full docs entry point.
- [docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md):
bash style ([style.ysap.sh](https://style.ysap.sh)). Tabs, 80 cols, `[[ ]]`, no `set -e`.
- [docs/styleguides/docs_styleguide.md](docs/styleguides/docs_styleguide.md):
page anatomy and naming if you're adding a doc.
- [docs/learnings/](docs/learnings/): subsystem deep-dives. Read the
relevant entry first.
- [docs/building.md](docs/building.md): local build setup.
- [docs/decisions.md](docs/decisions.md): architectural choices (ADR format).
- [CHANGELOG.md](CHANGELOG.md): release-grouped history from v2.0.0 onward.
- [RELEASING.md](RELEASING.md): how a release ships (tag-driven CI).
- [SECURITY.md](SECURITY.md): private vulnerability reporting.
- [.github/CODEOWNERS](.github/CODEOWNERS): auto-review routing.
## What we accept
We're a repackager, not a fork. Net-new feature PRs default to no: we'd
own that behaviour across every re-minified upstream release.
Exception: parity patches for Windows features broken on Linux
(input methods, tray on Wayland/X11, frame defaults). Always welcome:
- Bug fixes against existing behaviour.
- Parity patches bringing Linux closer to the Windows build.
- Packaging, distribution, launcher fixes.
- Docs, tests, CI improvements.
## What goes upstream, not here
We patch the binary blob; we don't fix application logic inside it.
If the bug reproduces on Windows, file at
[anthropics/claude-code](https://github.com/anthropics/claude-code).
In-app `/bug` and `/feedback` are inert.
| File here | File upstream |
|----------------------------------------|-------------------------------------|
| `apt update` errors, install failures | Plugin install fails on all OSes |
| Tray icon missing on KDE Wayland | Conversation rendering glitch |
| AppImage won't launch on distro X | MCP server connection drops |
| `--doctor` reports wrong diagnosis | Account / login flow broken |
## Filing an issue
1. Use the issue template, not freeform.
2. Paste full `./build.sh --doctor` (or `claude-desktop --doctor`)
output. Most-skipped step.
3. Include distro, DE, session type (Wayland/X11). Most Linux-only
bugs trace to one of these.
4. Reproduce on a clean config: move `~/.config/Claude` aside, relaunch.
Stale config causes false positives.
## Patches against upstream
Patches live in `scripts/patches/*.sh`, one per subsystem; `build.sh`
sources them. Before writing or editing one, read [the
patching-minified-js learnings doc][pmj]: anchor selection, capture,
idempotency, beautified-vs-minified gap. Short form: CLAUDE.md §
Working with Minified JavaScript.
Priority rule: a broken-patch upstream release beats feature work.
## Subsystem owners
CODEOWNERS auto-requests reviews; this list is for human discoverability.
- **@aaddrick**: default. Build, non-Cowork patches, desktop, packaging, docs.
- **@sabiut**: `tests/`, `scripts/doctor.sh`, test workflows.
- **@RayCharlizard**: Cowork (`scripts/patches/cowork.sh`,
`scripts/cowork-vm-service.js`, `tests/cowork-*.bats`).
- **@typedrat**: Nix (`flake.nix`, `flake.lock`, `/nix/`).
## Before submitting a PR
- Run `/lint` (or `shellcheck` + `actionlint`). See CLAUDE.md § Linting.
- Local build: `./build.sh --build appimage --clean no`. Catches
patch failures unit tests miss.
- Branch: `fix/123-description` or `feature/123-description`.
- PR body links the issue: `Fixes #123` or `Refs #123`.
- AI-assisted? Add the attribution block (next section).
## AI-assisted contributions
AI-assisted PRs accepted with disclosure. PR descriptions:
```
---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
XX% AI / YY% Human
Claude: <what AI did>
Human: <what human did>
```
Real model name (e.g., "Claude Opus 4.7"). Honest split.
Breakdown lines make the ratio auditable against the diff.
Commits: `Co-Authored-By: Claude <claude@anthropic.com>`.
Issues/comments:
`Written by Claude <model-name> via [Claude Code](https://claude.ai/code)`.
## Conventions in this file
### Patch-script regexes
Two rules apply to regexes that target the minified upstream bundle.
**Identifier captures use `[$\w]+`, not `\w+`.** Upstream's minifier
emits `$` inside JS identifiers (`C$i`, `g$i`, `i$A`). `\w` is
`[A-Za-z0-9_]` and does not match `$`, so a `\w+` capture against
`$e` returns the suffix `e` instead of the whole identifier. PR #555
and PR #627 closed two cohorts of patches with this exact bug. The
learnings doc has the full background and the canonical character
class is `[$\w]+` (the equivalent `[\w$]+` is fine; either form
matches the same set, the order is convention only).
**Intent comments accompany whitespace-tolerant patterns.** When a
patch regex uses `\s*` or `[ \t]*` between tokens, add a one-line
intent comment with whitespace stripped so the matched shape stays
readable:
```js
// Intent: VAR.code==="ENOENT"
const enoentRe = /([$\w]+)\.code\s*===\s*"ENOENT"/g;
```
Apply both rules to new patches and to existing regexes when you're
editing them for other reasons. No churn PRs. Background:
[the patching-minified-js learnings doc][pmj].
[pmj]: docs/learnings/patching-minified-js.md
### Markdown prose wrapping
Wrap prose at ~80 chars, matching the bash column rule in
[docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md).
Tables, code blocks, URLs, alt text may exceed when breaking hurts
readability.

View File

@@ -4,6 +4,8 @@ This project provides build scripts to run Claude Desktop natively on Linux syst
**Note:** This is an unofficial build script. For official support, please visit [Anthropic's website](https://www.anthropic.com). For issues with the build script or Linux implementation, please [open an issue](https://github.com/aaddrick/claude-desktop-debian/issues) in this repository.
**Documentation:** Full docs at [`docs/index.md`](docs/index.md). Release history in [`CHANGELOG.md`](CHANGELOG.md). Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports: [`SECURITY.md`](SECURITY.md).
---
> **⚠️ APT migration notice (April 2026)**
@@ -135,7 +137,7 @@ Download the latest `.deb`, `.rpm`, or `.AppImage` from the [Releases page](http
### Building from Source
See [docs/BUILDING.md](docs/BUILDING.md) for detailed build instructions.
See [docs/building.md](docs/building.md) for detailed build instructions.
## Configuration
@@ -144,13 +146,13 @@ Model Context Protocol settings are stored in:
~/.config/Claude/claude_desktop_config.json
```
For additional configuration options including environment variables and Wayland support, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
For additional configuration options including environment variables and Wayland support, see [docs/configuration.md](docs/configuration.md).
## Troubleshooting
Run `claude-desktop --doctor` for built-in diagnostics that check common issues (display server, sandbox permissions, MCP config, stale locks, and more). It also reports cowork mode readiness — which isolation backend will be used, and which dependencies (KVM, QEMU, vsock, socat, virtiofsd, bubblewrap) are installed or missing.
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/troubleshooting.md](docs/troubleshooting.md).
## Acknowledgments
@@ -185,6 +187,7 @@ Special thanks to:
- Version update contributions
- Close-to-tray on Linux to keep in-app schedulers, MCP servers, and the tray icon alive across window close
- "Run on startup" persistence on Linux via XDG Autostart, fixing the toggle that would silently revert
- In-place package upgrade detection that watches `app.asar` for dpkg/rpm replacement and offers a click-to-restart notification, fixing the Quick Entry / About / Ctrl+Q symptom cluster from a running v(N) main process loading v(N+1) renderer assets (#564)
- **[mathys-lopinto](https://github.com/mathys-lopinto)**
- AUR package
- Automated deployment
@@ -198,6 +201,9 @@ Special thanks to:
- `--doctor` diagnostic command
- SHA-256 checksum validation for downloads
- Post-build integration tests for deb, rpm, and AppImage artifacts
- `tests.yml` CI workflow that runs the 186-test BATS suite on push and PR — the suite was inert in CI before this (#520)
- Isolating `cleanup_stale_cowork_socket` BATS from host `pgrep` state so the test passes on developer machines running Claude Desktop (#533, #534)
- Headless launch and `--doctor` smoke tests for the AppImage artifact, catching runtime regressions (frame-fix-wrapper syntax errors, asar patch breakage, `main` field mismatches) that the structural test missed (#592)
- **[milog1994](https://github.com/milog1994)**
- Popup detection
- Functional stubs
@@ -227,6 +233,7 @@ Special thanks to:
- node-pty derivation
- CI auto-update
- Fixing the flake package scoping regression
- Fixing the NixOS electron binary not being marked executable (#431, #581)
- **[cbonnissent](https://github.com/cbonnissent)**
- Reverse-engineering the Cowork VM guest RPC protocol
- Fixing the KVM startup blocker
@@ -242,6 +249,8 @@ Special thanks to:
- Detailed analysis of the self-referential `.mcpb-cache` symlink ELOOP bug
- Fixing auto-memory path translation on HostBackend
- Fixing the `ion-dist` static asset copy for the `app://` protocol handler
- `--doctor` diagnostic that detects the Ubuntu 24.04 AppArmor `apparmor_restrict_unprivileged_userns=1` block on bwrap, instead of letting it silently fall through to a hanging KVM probe (#351, #434)
- Documenting the upstream MCP double-spawn root-cause analysis in `docs/learnings/mcp-double-spawn.md` (#526, #527)
- **[reinthal](https://github.com/reinthal)** for fixing the NixOS build breakage caused by the nixpkgs `nodePackages` removal
- **[gianluca-peri](https://github.com/gianluca-peri)**
- Reporting the GNOME quit accessibility issue
@@ -259,6 +268,34 @@ Special thanks to:
- **[zabka](https://github.com/zabka)** for identifying that `cowork-vm-service.js` was never auto-spawned on Linux and contributing a systemd-unit workaround that scoped the daemon auto-launch fix (#445)
- **[sirfaber](https://github.com/sirfaber)** for fixing the `$`-in-minified-identifier breakage of cowork Patch 2b (vm module assignment) and Patch 6 step 2 (retry-delay auto-launch) on Claude Desktop 1.5354.0 (#555)
- **[ProfFlow](https://github.com/ProfFlow)** for re-fixing the RPM repodata signing regression by appending `!` to the keyid passed to `gpg --default-key`, forcing `repomd.xml` to be signed by the primary key instead of the auto-selected signing subkey (#566)
- **[jslatten](https://github.com/jslatten)** for fixing the KDE Plasma Wayland launcher-grouping bug by setting `pkg.desktopName` in the packaged `app.asar`'s `package.json`, format-conditional so deb/rpm get `claude-desktop.desktop` and AppImage gets `io.github.aaddrick.claude-desktop-debian.desktop` (#562)
- **[JoshuaVlantis](https://github.com/JoshuaVlantis)**
- 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)
## Sponsorship

80
RELEASING.md Normal file
View File

@@ -0,0 +1,80 @@
# Releasing
This project ships through tag-driven CI. A tag of the form `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}` on `main` triggers the release job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml), which builds for both architectures, attaches the artifacts to a GitHub Release, and updates the APT, DNF, and AUR repositories.
There are two flavors of release:
- **Upstream-tracking retag.** A `check-claude-version` workflow runs daily, detects new Claude Desktop releases, bumps the `CLAUDE_DESKTOP_VERSION` repo variable, patches URLs and SRI hashes in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix`, and pushes a new tag with the same `REPO_VERSION` and a new `+claude{X.Y.Z}` suffix. **No human action required.** These do not get CHANGELOG entries — they're tracked in the tag suffix.
- **Project release.** You bumped `REPO_VERSION` because you shipped project changes. Follow the checklist below.
## Pre-release checklist
1. **CI is green on `main`.** All required workflows (CI, tests, shellcheck, codespell) passed on the commit you're about to tag.
```bash
gh run list --branch main --limit 5
```
2. **`CHANGELOG.md` is updated.** The `[Unreleased]` section now reflects what you're about to ship. Move it under a new `[v{REPO_VERSION}]` heading with today's date.
3. **Local tests pass.**
```bash
bats tests/
shellcheck scripts/**/*.sh build.sh
```
See [`CLAUDE.md`](CLAUDE.md#linting) for the canonical lint command.
4. **AppImage artifact boots on a clean system.** The `test-artifacts.yml` reusable workflow already runs a `--doctor` smoke test against each format in CI (#592), but if you've touched the launcher or patch surface, build locally and confirm:
```bash
./build.sh --build appimage --clean no
./test-build/claude-desktop-*.AppImage --doctor
```
5. **The version variables are in sync.**
```bash
gh variable get REPO_VERSION
gh variable get CLAUDE_DESKTOP_VERSION
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
```
The grep value should match the `CLAUDE_DESKTOP_VERSION` variable. If not, pull the latest URLs from `main` — the `check-claude-version` workflow may have updated them on `main` without rebasing your branch ([`CLAUDE.md`](CLAUDE.md#common-gotchas) has the recipe).
## Bumping and tagging
```bash
# 1. Bump the project version (this is a GitHub Actions variable, not a file).
gh variable set REPO_VERSION --body "2.0.13"
# 2. Tag with both versions in the tag name.
git tag "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
# 3. Push the tag — this is what kicks off the release build.
git push origin "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
```
The `REPO_VERSION` variable bump can happen before or after the tag push; CI reads neither directly. The variable exists so future workflow runs know the current project version.
## What CI does on tag push
The [`release`](.github/workflows/ci.yml) job in `ci.yml` is gated on `startsWith(github.ref, 'refs/tags/v')`. After `test-flags`, `build-amd64`, `build-arm64`, and `test-artifacts` pass:
1. Downloads all nine assets (six packages -- amd64 + arm64, each in deb/rpm/AppImage -- plus two `.zsync` delta files and a `reference-source.tar.gz`).
2. Pulls release notes from the separate [`aaddrick/claude-desktop-versions`](https://github.com/aaddrick/claude-desktop-versions) repo if available; falls back to the autogenerated changelog otherwise.
3. Creates the GitHub Release and attaches the nine assets.
4. Hands off to `update-apt-repo`, `update-dnf-repo`, and `update-aur-repo`, which publish to the Cloudflare-fronted package repos ([`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) for the redirect chain).
## After the release lands
- **Verify the Release page.** Nine assets attached, sizes look right, release notes rendered.
- **Smoke-test one artifact.** Download the AppImage and run `--doctor` against it.
- **Watch `apt-repo-heartbeat`.** The next daily run validates the redirect chain end-to-end. If it opens a tracking issue, walk the chain in [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md#heartbeat-failure-runbook).
## If something goes wrong mid-release
- **Build fails.** Push the fix to `main`, then re-tag with a new `+claude` suffix (or a `+rebuild.N` suffix if upstream hasn't moved). The original tag stays — releases are append-only.
- **A bad release shipped.** Mark the GitHub Release as a pre-release / draft and ship a follow-up. Don't delete artifacts that may already be cached by the APT/DNF Worker.
- **The `check-claude-version` workflow conflicts with your local branch.** Pull URL changes from `main` before pushing your tag — the workflow autobumps `scripts/setup/detect-host.sh` between your work and your tag.

35
SECURITY.md Normal file
View File

@@ -0,0 +1,35 @@
# Security Policy
Report suspected vulnerabilities privately via [GitHub Security Advisories](https://github.com/aaddrick/claude-desktop-debian/security/advisories/new). Do not open a public issue or post details in Discussions.
## Scope
This project repackages an upstream Electron app. The boundary matters:
**In scope** — things this repo ships:
- Patches in `scripts/patches/*.sh`
- Packaging scripts in `scripts/packaging/`
- The launcher (`scripts/launcher-common.sh`) and the `claude-desktop --doctor` surface
- CI workflows under `.github/workflows/`
- The APT/DNF Cloudflare Worker under `worker/`
- The frame-fix wrapper and any other JS we inject into `app.asar`
**Out of scope** — file upstream:
- Vulnerabilities in the Claude Desktop application itself, the Anthropic API, or the claude.ai web app. Those go to Anthropic's support / disclosure channels — not here. This project can't fix them and shouldn't be the public record.
## What to include in a report
- Reproducer: commands, environment, distro / desktop / session type
- Output of `claude-desktop --doctor` if relevant
- Affected version(s) — `git describe --tags` or the release tag you installed from
- Any related upstream CVEs or advisories you found while investigating
## Response
GitHub Advisories notify @aaddrick. Acknowledgement is usually within a few days. Fix turnaround depends on the surface — packaging-layer bugs are usually fast; patches against minified upstream JS may need to wait for a tractable anchor in a future upstream release.
## Disclosure history
Past privacy-sensitive fixes (e.g., issue-triage bot scoping, log redaction in `--doctor` output) landed through the normal PR flow with public history; there have been no embargoed disclosures to date. If that changes, this section gets entries with the advisory ID, the affected versions, and the fix.

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

@@ -1,259 +0,0 @@
[< Back to README](../README.md)
# Troubleshooting
## Built-in Diagnostics
Run the `--doctor` flag to check your system for common issues:
```bash
# Deb install
claude-desktop --doctor
# AppImage
./claude-desktop-*.AppImage --doctor
```
This runs 10 checks and prints pass/fail results with suggested fixes:
| Check | What it verifies |
|-------|-----------------|
| Installed version | Package version via dpkg |
| Display server | Wayland/X11 detection and mode |
| Electron binary | Existence and version |
| Chrome sandbox | Correct permissions (4755/root) |
| SingletonLock | Stale lock file detection |
| MCP config | JSON validity and server count |
| Node.js | Version (v20+ recommended for MCP) |
| Desktop entry | `.desktop` file presence |
| Disk space | Free space on config partition |
| Log file | Log file size |
Example output:
```
Claude Desktop Diagnostics
================================
[PASS] Installed version: 1.1.4498-1.3.15
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
[PASS] Chrome sandbox: permissions OK
[PASS] SingletonLock: no lock file (OK)
[PASS] MCP config: valid JSON
[PASS] Node.js: v22.14.0
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
[PASS] Disk space: 632284MB free
[PASS] Log file: 1352KB
All checks passed.
```
When opening an issue, include the output of `--doctor` to help with diagnosis.
## Application Logs
Runtime logs are available at:
```
~/.cache/claude-desktop-debian/launcher.log
```
## Common Issues
### Window Scaling Issues
If the window doesn't scale correctly on first launch:
1. Right-click the Claude Desktop tray icon
2. Select "Quit" (do not force quit)
3. Restart the application
This allows the application to save display settings properly.
### Global Hotkey Not Working (Wayland)
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
See [CONFIGURATION.md](CONFIGURATION.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
### 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.
For enhanced security, consider:
- Using the .deb package instead
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
- Using Gear Lever's integrated AppImage management for better isolation
### 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:
- `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.
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
setup, requires sudo):
```bash
sudo tee /etc/apparmor.d/bwrap <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
include if exists <local/bwrap>
}
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.
**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.
Credit: this workaround was contributed by
[@hfyeh](https://github.com/hfyeh) in
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
### Cowork: "VM connection timeout after 60 seconds"
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
```bash
COWORK_VM_BACKEND=bwrap claude-desktop
```
See [CONFIGURATION.md](CONFIGURATION.md#cowork-backend) for how to make this permanent.
### Cowork: virtiofsd not found (Fedora/RHEL)
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
outside `$PATH`. The `--doctor` check detects it there automatically and will
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
resolves it through `$PATH` only.
**Fix:** Create a symlink so the KVM backend can find it at runtime:
```bash
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
```
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
### Cowork: cross-device link error on Fedora tmpfs /tmp
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
```bash
mkdir -p ~/.config/Claude/tmp
TMPDIR=~/.config/Claude/tmp claude-desktop
```
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
### Authentication Errors (401)
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
1. Close Claude Desktop completely
2. Edit `~/.config/Claude/config.json`
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
4. Save the file and restart Claude Desktop
5. Log in again when prompted
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
## Uninstallation
### For APT repository installations (Debian/Ubuntu)
```bash
# Remove package
sudo apt remove claude-desktop
# Remove the repository and GPG key
sudo rm /etc/apt/sources.list.d/claude-desktop.list
sudo rm /usr/share/keyrings/claude-desktop.gpg
```
### For DNF repository installations (Fedora/RHEL)
```bash
# Remove package
sudo dnf remove claude-desktop
# Remove the repository
sudo rm /etc/yum.repos.d/claude-desktop.repo
```
### For AUR installations (Arch Linux)
```bash
# Using yay
yay -R claude-desktop-appimage
# Or using paru
paru -R claude-desktop-appimage
# Or using pacman directly
sudo pacman -R claude-desktop-appimage
```
### For .deb packages (manual install)
```bash
# Remove package
sudo apt remove claude-desktop
# Or: sudo dpkg -r claude-desktop
# Remove package and configuration
sudo dpkg -P claude-desktop
```
### For .rpm packages
```bash
# Remove package
sudo dnf remove claude-desktop
# Or: sudo rpm -e claude-desktop
```
### For AppImages
1. Delete the `.AppImage` file
2. Remove the `.desktop` file from `~/.local/share/applications/`
3. If using Gear Lever, use its uninstall option
### Remove user configuration (all formats)
```bash
rm -rf ~/.config/Claude
```

View File

@@ -41,6 +41,17 @@ The build script automatically detects your distribution and selects the appropr
| Arch Linux | `.AppImage` (via AUR) | yay/paru |
| Other | `.AppImage` | - |
## Build Environment Variables
The build pulls the Electron prebuilt binary from `github.com/electron/electron/releases` via `@electron/get`. Two upstream environment variables let you redirect that fetch:
- `ELECTRON_MIRROR` — base URL to fetch Electron releases from instead of GitHub. Useful for mirrors or local proxies. Example: `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/`.
- `ELECTRON_CUSTOM_DIR` — overrides the path segment after the mirror. Defaults to `v{version}`.
The cache location is fixed at `~/.cache/electron/` (resolved by `@electron/get` via `envPaths`) and is reused across builds. `ELECTRON_CACHE` is **not** read by `@electron/get` — set `ELECTRON_MIRROR` if you need to avoid the public CDN.
The pinned Electron version lives in `scripts/setup/dependencies.sh` (`electron_version`) and must match `build-reference/app-extracted/package.json` — the upstream Claude Desktop `app.asar` is built against a specific Electron major and running a different one is unsupported.
## Installing the Built Package
### For .deb packages (Debian/Ubuntu)

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

65
docs/index.md Normal file
View File

@@ -0,0 +1,65 @@
# Documentation
Linux packaging, patching, and operations docs for the [Claude Desktop for Debian](../README.md) project. The README is the storefront; this is the manual.
```bash
# If you're here because something broke:
claude-desktop --doctor
# Then check troubleshooting.md below.
```
## Installation & building
- [**Building from source**](building.md) — `./build.sh`, format flags, the Electron mirror env vars
- [**Configuration**](configuration.md) — MCP config file locations, env vars, where state lives
- [**Troubleshooting**](troubleshooting.md) — symptom-keyed fixes, `--doctor` warning index
## Project direction
- [**Decision log**](decisions.md) — ADR-format record of what we ship and (more importantly) what we won't
- [**Releasing**](../RELEASING.md) — pre-release checklist, tag recipe, what CI does on tag push
- [**Changelog**](../CHANGELOG.md) — `v2.0.0` onward, grouped by REPO_VERSION
## How the patches work — subsystem deep-dives
Hard-won knowledge from debugging real bugs. Consult before working on the related subsystem; add a new entry when you discover something non-obvious that would save the next contributor (human or AI) significant time.
- [**Patching minified JavaScript**](learnings/patching-minified-js.md) — anchor selection, the `\w` vs `$` capture trap, beautified false-negatives, idempotency guards
- [**APT/DNF Worker architecture**](learnings/apt-worker-architecture.md) — Cloudflare Worker + GitHub Releases redirect chain, credential ownership, heartbeat runbook
- [**Nix packaging**](learnings/nix.md) — NixOS specifics, Electron resource path resolution, testing without NixOS
- [**Linux topbar shim**](learnings/linux-topbar-shim.md) — why the in-app topbar is missing on Linux and the four gates that hide it
- [**Tray rebuild race**](learnings/tray-rebuild-race.md) — KDE SNI re-registration race; the in-place `setImage`/`setContextMenu` fast path
- [**Plugin install flow**](learnings/plugin-install.md) — Anthropic & Partners plugin gate logic and DevTools recipes
- [**Cowork VM daemon**](learnings/cowork-vm-daemon.md) — lifecycle, respawn logic, crash diagnosis
- [**MCP double-spawn**](learnings/mcp-double-spawn.md) — why stdio MCPs spawn twice with chat + Code/Agent panels open
- [**Test harness — Electron hooks**](learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps get bypassed by the frame-fix Proxy
- [**Test harness — AX-tree walker**](learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker
## Testing
- [**Testing overview**](testing/README.md) — what we test and how it's organized
- [**Test runbook**](testing/runbook.md) — running tests locally
- [**Test matrix**](testing/matrix.md) — what runs on what distro / format
- [**Test automation**](testing/automation.md) — CI workflow shape
- [**Quick-entry closeout**](testing/quick-entry-closeout.md) — the Quick Entry test runner
## Operations
- [**Issue triage bot**](issue-triage/README.md) — how the GitHub Actions issue-triage workflow works
- [**Upstream bug reports**](upstream-reports/) — bugs we've filed against the upstream Electron app
## Style guides
- [**Bash style guide**](styleguides/bash_styleguide.md) — the project's shell-script conventions (forked from YSAP)
- [**Docs style guide**](styleguides/docs_styleguide.md) — how to write and organize docs (start here if you're adding a page)
## Contributing
- [**CONTRIBUTING.md**](../CONTRIBUTING.md) — what we accept, what goes upstream, AI-attribution policy
- [**CLAUDE.md**](../CLAUDE.md) — instructions for AI coding assistants (and a useful project archaeology read for humans)
- [**AGENTS.md**](../AGENTS.md) — vendor-neutral mirror of `CLAUDE.md` for non-Claude AI tools
- [**SECURITY.md**](../SECURITY.md) — private vulnerability reporting
## Cowork-Linux handover (historical)
- [**Cowork-Linux handover**](cowork-linux-handover.md) — record of the original cowork Linux work, kept for the historical context. Day-to-day cowork docs live in [`learnings/cowork-vm-daemon.md`](learnings/cowork-vm-daemon.md).

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

@@ -273,7 +273,7 @@ unusable on Linux today.
mode. Shim runtime behavior (className intercept, UA spoof) is
not unit-tested — verified empirically via the click test in
this doc
- `docs/CONFIGURATION.md` — user-facing env-var docs
- `docs/configuration.md` — user-facing env-var docs
## Diagnostic recipes

View File

@@ -37,46 +37,67 @@ external services with single-connection contracts, etc.
## Root Cause (Upstream)
Two parallel session managers live inside Electron main, each
holding an independent Claude Agent SDK `query`:
Multiple session managers live inside Electron main, each
holding its own MCP coordinator state with its own registry. The
two that spawn stdio MCPs from `claude_desktop_config.json` and
trigger this bug:
| Manager class | IPC namespace | Coordinator | Logs prefix |
|--------------------------|------------------------------------------|-----------------|-------------|
| `LocalSessions` | `claude.web_$_LocalSessions_$_*` | `n2t("ccd")` | `[CCD]` |
| `LocalAgentModeSessions` | `claude.web_$_LocalAgentModeSessions_$_*`| `n2t("cowork")` | `[LAM]` |
A third coordinator class — `SshMcpServerManager` — follows the
same per-coordinator-registry pattern but uses an SSH transport
and doesn't contribute to the local-node double-spawn. Its
existence does say something about the design intent: per-
coordinator isolated state appears to be a deliberate
architectural pattern, not a one-off oversight.
The logs prefixes are what to grep `~/.config/Claude/logs/` for to
confirm a session is hitting both coordinators (and therefore this
bug specifically).
Each `query` holds its own SDK transport. The transport's
`spawnLocalProcess` (`Du.spawn`) launches stdio MCPs **without
consulting the global registry** that *would* dedupe them
(`hZ` map, accessed via `oUt(serverName)` /
`launchMcpServer`). That registry is only used for the
"internal" cowork in-process MessageChannelMain path.
Each coordinator dedups **within its own scope**: CCD's launch
function serializes per server name through a promise queue and
shuts down any prior entry before respawn; LAM's
`getOrCreateConnection` reuses connected entries from its own
`connections` Map. The double-spawn is strictly **cross-
coordinator** — one process per coordinator that has the server
in its config.
In current versions (verified against `1.5354.0`) both
coordinators route their transport creation through a shared
Claude Desktop-side factory, but the factory itself doesn't
dedupe and the per-coordinator registries above it aren't
unified.
Net result: 2 coordinators × N configured MCPs = 2N processes.
Symbol names (`n2t`, `hZ`, `oUt`, `LocalSessions`,
`LocalAgentModeSessions`) are minified and **will rename across
upstream releases**.
### Symbol drift
Minified symbols rename across upstream releases. Issue
[#546](https://github.com/aaddrick/claude-desktop-debian/issues/546)
maintains the current symbol mappings (verified against
`1.5354.0`) plus extraction regexes that work against both
minified and beautified bundles.
## Status
**Upstream Claude Desktop bug. Not patchable in this repo.** A
fix would require either:
**Upstream Claude Desktop bug. Not patchable in this repo.** The
proximate cause is in Claude Desktop's session manager wiring. A
real fix needs either:
- Routing the SDK stdio transport through `oUt`/`hZ` (the
existing serialized-per-name registry), or
- Sharing one MCP-server registry between the `ccd` and
`cowork` coordinators.
- LAM proxying its MCP traffic through CCD's existing connection
(so only one coordinator owns the spawn), or
- A multiplexing wrapper transport that lets one spawned stdio
child serve multiple SDK clients via demuxing.
Both live inside the closed-source SDK transport / session
manager wiring. Regex-matching the minified symbols from
`scripts/patches/` would be fragile against release-to-release
renames and exceeds this repo's "minimal Linux-compat patches
only" charter.
Stdio MCP is 1:1 at the protocol layer — one stdin/stdout pair,
one transport, one SDK client. Sharing one process across
coordinators requires real engineering, not a sed patch on
minified code, and exceeds this repo's "minimal Linux-compat
patches only" charter.
## What's Already Verified Clean
@@ -118,13 +139,15 @@ The reporter's `baro-voyager` MCP shipped both in commit
- **Primary:** in-app feedback (Help → Send Feedback) or
`support@anthropic.com`. The duplication happens in
closed-source Desktop main.
- **Secondary:** an SDK-transport-flavored issue on
closed-source Desktop main, in the per-coordinator registry
wiring.
- **Secondary:** an issue on
[`anthropics/claude-agent-sdk-typescript`](https://github.com/anthropics/claude-agent-sdk-typescript)
is defensible — the spawn path goes through the **Claude Agent
SDK's** `query` transport (`spawnLocalProcess` / `Du.spawn`),
which is shared surface area. Reference the missing `hZ`
consultation explicitly.
is defensible only if it advocates for a shared-transport /
multiplex primitive that would make this kind of bug
structurally harder. The SDK's spawn implementation is doing
what it's told — the bug is one layer up, in Claude Desktop
calling spawn from two separate coordinators.
The embedded Claude Code CLI subprocess inside Claude Desktop is
**not** the cause — it receives `--mcp-config` only when the

View File

@@ -0,0 +1,298 @@
# Patching minified JavaScript
Hard-won lessons from maintaining a long-lived patch suite against an
actively re-minified upstream. Each section names a failure mode and
the fix.
The verification recipes below use claude-desktop-debian-specific
incantations (Claude-Setup.exe, nupkg extraction, `build.sh
--build appimage`); substitute your own project's fetch/extract/build
commands as needed.
## Capturing identifiers: `\w` doesn't match `$`
JS identifiers allow `$` and `_`; minifiers freely emit names like
`$e`, `C$i`, `g$x`. The character class `\w` is `[A-Za-z0-9_]` — it
does not match `$`. A `(\w+)` against `$e` captures the suffix `e`
and returns a name that doesn't exist in the file. The failure is
silent: regex matches, downstream sed runs against a truncated name,
asar ships broken JS. Three recurrences (PRs #253, #421, #555) before
the convention stuck.
Use `[$\w]+` (repo convention; `[\w$]+` is equivalent). Strict
superset of `\w+`, so pre-`$` versions still match. Live at
`cowork.sh:484-502`:
```bash
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
```
## The beautified false-negative trap
Testing a regex against `build-reference/` is not verification. The
beautified copy has whitespace the regex doesn't account for.
During PR #555, both `\w+` and `[\w$]+` tested false against the
beautified file. Shipped minified bytes:
```js
await new Promise(n=>setTimeout(n,g$x))
```
Beautified copy:
```js
await new Promise((n) => setTimeout(n, g$x))
```
`await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)` fails
the beautified version on the parens and spaces around `=>`. Always
close the loop against shipped bytes.
## Whitespace tolerance: `\s*` vs `[ \t]*`
`\s` matches newlines. A `\s*`-padded pattern is a license to span
across structural boundaries the original line layout meant to
keep apart — usually fine on minified bytes (no newlines to span),
much looser on beautified.
Use `[ \t]*` when the intent is "spaces but stay on this line."
Reserve `\s*` for crossing structural boundaries on purpose. The
existing `cowork.sh` patches mix both — `\s*` where the surrounding
context is bounded enough that newline-spanning is harmless, and
literal token sequences (`",b:` etc.) when stricter adjacency is
required.
## Replacement-string escaping: `\1`, `&`, `$1`
A regex can match correctly and still produce corrupted output
because the *replacement string* has its own metacharacters. Match
debugging shows green; the asar still ships broken bytes. Three
flavors:
**sed `&`** — the entire match. `sed 's/foo/&_suffix/'` is fine
(`foo_suffix`). `sed 's/foo/literal_&_dollar/'` accidentally
interpolates the match (`literal_foo_dollar`). Escape with `\&` if
you want a literal ampersand:
```bash
sed 's/foo/literal_\&_dollar/' # → literal_&_dollar
```
**sed `\1`** — backreferences in the replacement. These work as
expected in BRE/ERE. The footgun is the *pattern* side: in BRE, `$`
is the end-of-line anchor, so a literal `$` in the search pattern
needs `\$`. `_common.sh:25` does exactly this for `electron_var`,
which can be `$e` on newer upstream:
```bash
electron_var_re="${electron_var//\$/\\$}"
```
That escaping is for the sed *pattern*, not its replacement.
**JS `String.prototype.replace`: `$1`, `$&`, `$$`** — the JS
replacement DSL is its own thing. `$&` is the whole match; `$1..$9`
are capture groups; `$$` is a literal `$`. Plain `$` followed by an
unrelated char is left alone, but `$&` and `$N` get interpolated:
```js
code.replace(/foo/g, '$cost') // → '$cost' (safe, no special)
code.replace(/foo/g, '$&_x') // → 'foo_x' ($& = match)
code.replace(/foo/g, '$$cost') // → '$cost' (escaped)
```
If the replacement is an injected JS snippet that happens to
contain `$1` or `$&` (template literals, jQuery, regex source), JS
will eat them. Use `$$` to escape, or build the string with
concatenation so `$` never sits next to a digit or `&`.
## Idempotency: a re-run must be byte-identical
Without it, CI re-runs and partial builds layer mutations until
something breaks visibly. Three patterns:
**Re-key the guard to post-rename names.** `tray.sh:174-180` keys its
fast-path guard on the post-rename
`${tray_var}.setImage(${electron_var}.nativeImage.createFromPath(${path_var}))`
sequence, so the second run recognizes its own first-run output.
**Negative lookbehind, inline.** `cowork.sh:102-106` — the
`(?<!...)` prevents a second match against text the first run
already wrapped:
```js
const logRe = new RegExp(
'(?<!\\|\\|process\\.platform==="linux"\\))' +
win32Var.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
'(\\s*\\?\\s*"vmClient \\(TypeScript\\)")'
);
```
**Explicit `code.includes(...)` check.** `cowork.sh:227-230`
separates "anchor missing" from "already applied" in the build log:
```js
} else if (code.includes(
'getDownloadStatus(){return process.platform==="linux"?'
)) {
console.log(' Cowork auto-nav suppression already applied');
}
```
PR #436 verified by running the patch twice and diffing the output.
## Anchor selection: prefer literals over identifiers
The above sections cover making a patch work on first run. This one
covers keeping it working release after release. A patch can apply
cleanly today and silently no-op next month.
Minified identifiers churn every release. Developer strings —
property names, log messages, IPC channel names — survive
minification untouched (true for the upstream bundler used here; a
`--mangle-props` build would invalidate property-name anchors).
Anchor on those. A hardcoded minified name silently no-ops the next
release; the build log still says "patched."
Three patterns from the suite:
- **Quick-window (PR #390, fixing #144).** Original patch:
`s/e.hide()/e.blur(),e.hide()/`. When `e` became `Sa`, it no-oped.
The rewrite anchors on `"pop-up-menu"` (`quick-window.sh:17`), the
`isWindowFocused` property name (`quick-window.sh:60`), and the
`[QuickEntry]` log strings (`quick-window.sh:88-91`).
- **Cowork spawn (PR #436).** Anchored on `,VAR.mountConda)`
(`cowork.sh:741`) — unique to the 12-arg call path, absent from the
10-arg one-shot. Asserts match count is exactly 1 and bails
otherwise (`cowork.sh:744`), so a future second caller surfaces
immediately.
- **Tray (PR #515).** `tray.sh:16` uses the literal `"menuBarEnabled"`
as a *position anchor*, then captures the surrounding minified
identifier (`\K\w+(?=\(\)\})`) as the actual patch target. Two
stages: stable literal → derived identifier. Every other tray name
chains off that single dynamic extraction.
The lesson is about finding stable points to anchor on, not about
what gets patched. The patch target is usually a minified identifier;
the *anchor* should be a developer string nearby.
## Multi-site coordinated patches: surface partial application
Site 1 patches, site 2 misses, the asar ships half-wired. The
pattern: each sub-patch sets a per-site boolean flag on success,
then a single named WARNING fires if any flag is false:
```js
if (!siteADone || !siteBDone) {
console.log(' WARNING: <ticket> partial — siteA=' + siteADone +
' siteB=' + siteBDone + '; <fallback consequence>');
}
```
CI greps the build log for `WARNING:` and fails the build. That
catches the half-patched state even when individual sub-patches each
log "applied." See `cowork.sh:759-763` for a real instance —
three-site `sharedCwdPath` forwarding, daemon fallback if any site
misses.
## Disambiguating non-unique anchors: lastIndexOf over indexOf
A string anchor can appear in source maps, dead exports, or
chunk-merged duplicates alongside the live code. `indexOf` returns
the first; that may be wrong.
`cowork.sh:264` uses `lastIndexOf(serviceErrorStr)` to bias toward
appended code. On 1.5354.0 the string occurs once, so the change is
a no-op there — the defense is for a future upstream that
reintroduces the string in onboarding text or sample data far from
the live retry-loop site.
When neither side is reliable, narrow the search region first.
`cowork.sh:269-276` does this for the ENOENT check, scanning only a
300-character window before the error string.
## Verifying a hypothesis before shipping a fix
Pull the pinned URL and SHA from `scripts/setup/detect-host.sh`,
download, verify hash, extract without beautifying, and test the
regex against the minified bytes:
```bash
url=$(grep -oP "claude_download_url='\K[^']+" \
scripts/setup/detect-host.sh | head -1)
expected=$(grep -oP "claude_exe_sha256='\K[^']+" \
scripts/setup/detect-host.sh | head -1)
mkdir -p /tmp/verify && cd /tmp/verify
wget -q -O Claude-Setup.exe "$url"
echo "$expected Claude-Setup.exe" | sha256sum -c -
7z x -y Claude-Setup.exe -o exe
nupkg=$(find exe -name 'AnthropicClaude-*.nupkg' | head -1)
7z x -y "$nupkg" -o nupkg
npx asar extract nupkg/lib/net45/resources/app.asar app
node -e '
const fs = require("fs");
const code = fs.readFileSync(
"app/.vite/build/index.js", "utf8");
const re = /await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/;
const m = code.match(re);
console.log(m ? `MATCH: ${m[0]}` : "NO MATCH");
'
```
`NO MATCH` means the regex is wrong. Verifying the SHA defends against
stale URL pinning or server-side binary swap.
## End-to-end verification (post-build)
Four layers: build log, syntactic validity, asar markers, runtime.
1. Check the patch-count line:
```bash
./build.sh --build appimage --clean no 2>&1 | tee build.log
grep -E 'Applied [0-9]+ cowork patches' build.log
```
Healthy 1.5354.0 build: `Applied 12 cowork patches`. A lower
number, or any `WARNING:` in the cowork section, is a half-patched
asar.
2. `node --check` on the patched `index.js` — catches malformed
replacements that serialize but don't parse (PR #436 used this in
dry-run validation):
```bash
node --check test-build/.../app.asar.contents/.vite/build/index.js
```
3. Static-grep the shipped asar for the 9 cowork markers from PR
#555. `scripts/verify-patches.sh` automates this (issue #559 D6)
and runs in CI on every `amd64-deb` build via the
`Verify cowork patches in shipped asar` step in
`.github/workflows/build-amd64.yml`. Reusable for non-cowork patch
sets — pass any same-shape TSV as the second arg.
4. Launch the AppImage and check runtime state:
```bash
tail -20 ~/.config/Claude/logs/cowork_vm_daemon.log
ls -la "${XDG_RUNTIME_DIR}/cowork-vm-service.sock"
ss -lpx | grep cowork-vm-service.sock
```
Daemon log should have `lifecycle startup` and `lifecycle
listening`; socket should exist and be owned by the
`cowork-vm-service.js` process listed by `ss`.
## Cross-references
- `tray-rebuild-race.md` "Resilience to minifier churn" — prior art
for dynamic extraction across a six-variable patch site and the
post-rename idempotency-guard pattern.
- `plugin-install.md` "Getting the Minified Source for Any Shipped
Version" — the `reference-source.tar.gz` release asset gives
beautified asar contents of any prior version for diffing. Useful
for spotting when an identifier renamed and which version did it.

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

@@ -0,0 +1,144 @@
[< Back to docs index](../index.md)
# Docs Style Guide
How docs are organized and written in this repo. The patterns here come from a survey of well-organized open-source docs (Spatie, Filament, laravel-docs, earendil-works/pi) plus what's worked in this project's own `docs/` tree. If you're adding a page, read the **Page anatomy** section before you start.
## Structure
- **Flat `docs/`**, **lowercase kebab-case** filenames (`troubleshooting.md`, not `TROUBLESHOOTING.md`; `building.md`, not `BUILDING.md`). Order belongs in this index, not filenames.
- One entry point: **[`docs/index.md`](../index.md)**. It's the GitHub-browsable landing page and the link target from every other doc.
- **Subdirectories only when a topic grows past ~5 pages.** Current subdirs:
- [`docs/learnings/`](../learnings/) — subsystem deep-dives. Promoted out of the top level once there were >3.
- [`docs/testing/`](../testing/) — test harness docs.
- [`docs/issue-triage/`](../issue-triage/) — the issue-triage bot config and prompts.
- [`docs/upstream-reports/`](../upstream-reports/) — bug reports filed against upstream that we keep alongside the patch.
- `docs/styleguides/` — meta-docs about how to write docs and shell scripts.
- **`docs/images/`** for screenshots and diagrams. Never scatter `.png`s next to `.md`s.
- **Repo-root auxiliary files stay at the root** so GitHub auto-detects them: `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE-*`, `RELEASING.md`, `CLAUDE.md`, `AGENTS.md`. Don't move them under `docs/`.
## Page anatomy
Three skeletons recur across well-organized docs in this project. Pick one before starting a page.
### Setup / how-to page
Used for: `building.md`, `configuration.md`, `releasing.md` (in the root).
```
<one declarative sentence: what this page is for>
<one code block showing the minimum working command>
## Prerequisites -> short list; assume Linux + git unless stated
## <Step 1> -> one short paragraph + code block
## <Step 2>
## Common variations -> distro-specific or flag-specific quirks
## Troubleshooting -> link out to troubleshooting.md, don't duplicate
```
Open with the minimum command, not the prerequisites table. Readers skim to the code block first.
### Troubleshooting / FAQ page
Used for: `troubleshooting.md`.
```
<one declarative sentence: what kind of problem this page solves>
## <Symptom or error message verbatim> -> one ### Fix per symptom, with a code block
## <Next symptom>
```
The headings are **the symptom users type into search.** Don't editorialize ("Troubles with Wayland" is wrong — `## Black screen on Fedora KDE under Wayland` is right). One `### Fix` per `##`. If a symptom needs explanation, prose goes under the fix, not in the heading.
### Subsystem deep-dive (a "learning")
Used for: everything in `docs/learnings/`.
```
<one paragraph: what subsystem this covers, when it runs, why it's non-obvious>
**Source files:** bullet list of GitHub links to the relevant source
## Overview -> 23 paragraphs of context
## <Mechanic> -> for each non-trivial mechanic, prose + diagram only when state transitions need one
## <Failure mode> -> for each known failure, repro + diagnosis + fix path
## References -> issues, PRs, upstream bugs, useful commits
```
Deep-dives can be long — `apt-worker-architecture.md` and `patching-minified-js.md` are >10 kB and that's fine. They serve repeat readers (future you, future contributors) hunting for a specific fact, not first-timers.
### Decision record (ADR)
Used for: entries in `docs/decisions.md`.
```
## D-NNN — <short title>
- **Status:** Accepted / Superseded / Proposed
- **Decided:** YYYY-MM-DD
- **Owner:** @handle
- **Stakeholders:** ...
### Context -> what triggered the decision
### Decision -> the call in one or two sentences
### Rationale -> bullets
### Consequences -> what was accepted, what's now out of bounds
### Alternatives Considered
### References
```
See [`decisions.md`](../decisions.md) for the live record. Don't delete superseded decisions — mark them and link forward.
## Content rules
1. **Open every page with one declarative sentence, then a code block or list.** No "In this guide we will explore…" preamble. If the page is in the root (not behind `[< Back to ...]`), the first line under the H1 is that sentence.
2. **Imperative, second-person, present tense.** "Run the build." Not "users may wish to consider running the build."
3. **Domain nouns.** This is a packaging project — use `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`. Don't say `foo`/`bar` in end-to-end recipes. Placeholders are tolerable in basic-usage; in walkthroughs they kill comprehension.
4. **Real PR / issue / commit references over hand-waving.** "Fixed in [#475](https://github.com/aaddrick/claude-desktop-debian/pull/475)" beats "fixed in a recent PR." `git log --grep` works on links; not on adjectives.
5. **Defaults first, then the override.** "The build auto-detects your distro. To force a format, pass `--build appimage`."
6. **Warnings in alert blocks**, not paragraphs: `> [!NOTE]`, `> [!WARNING]`, `> [!TIP]`. GitHub renders them; reading them isn't optional.
7. **Source-file blocks on deep-dives.** Bulleted GitHub links to the actual files. Don't bury source references in prose.
8. **Cross-link liberally.** Every page should link to 24 others. `docs/index.md` should link to every page in `docs/`.
9. **One file per topic.** Don't paste the same config block into three pages. Show it once in `configuration.md`; excerpt subsections elsewhere with a link back.
10. **Rationale lives in `decisions.md` or a learning**, not sprinkled through feature docs. If you find yourself writing "we did this because…" in a how-to page, that paragraph belongs in `learnings/<topic>.md` or `decisions.md`.
## Patterns worth stealing
- **Comparison tables for near-synonyms.** When something has overlapping siblings (deb vs. rpm vs. AppImage vs. nix; Wayland vs. XWayland; SUID sandbox vs. user namespaces), a `| feature | A | B | C |` table beats three prose paragraphs.
- **"Source files" block at the top of deep-dives.** See [`docs/learnings/apt-worker-architecture.md`](../learnings/apt-worker-architecture.md) for the canonical example.
- **`[< Back to <parent>]` link at the top of subpages.** GitHub doesn't render breadcrumbs; this is the manual equivalent. Use it on pages inside subdirectories.
- **Verbatim error messages as `##` headings in `troubleshooting.md`.** Users land via search; search hits the heading.
## Antipatterns
- **Duplicating quickstart in three places.** README is pitch + install one-liner + link to docs. Real install lives in `building.md`, and only there.
- **`docs/` without an `index.md`.** GitHub renders an alphabetical file list and contributors get lost.
- **Uppercase / SHOUTY filenames** (`TROUBLESHOOTING.md`). Hard to type, looks dated, inconsistent with `docs/learnings/*.md`. Lowercase kebab-case throughout.
- **Numbered prefixes** (`01-introduction.md`). Order belongs in `index.md`. Renumbering rots cross-links.
- **Free-form FAQ prose** ("Q: How do I…? A: Well, you might…"). Use `## <error message>``### Fix` → code instead. Search ranks headings, not paragraphs.
- **One page past ~30 kB that isn't a reference/deep-dive.** Promote to a subdirectory or split. CLAUDE.md is the exception — it's an archaeology document, not a how-to.
- **Inline "this changed in v2.0.7" annotations** scattered through current docs. Version notes belong in `CHANGELOG.md`.
- **Code blocks without a "when to use this" sentence above them.** Turns docs into a man-page dump.
- **Hiding `CONTRIBUTING.md` or `SECURITY.md` under `docs/`.** GitHub stops auto-detecting them.
## Page-size honesty
Length should track topic depth, not editorial consistency.
| Size | When |
|---|---|
| <500 B | Single config snippet + 2 sentences. Stub pages and redirects. |
| 1.53 kB | Platform notes, single-flag install variants |
| 38 kB | Standard how-to and setup pages |
| 1017 kB | Major how-to pages, learnings |
| 1725 kB | Deep-dive learnings with diagrams |
| >30 kB | Smell. Either it's a reference page (rare in this repo), or it should split. |
Pages can be five sentences. **Don't pad short topics.**
## What stays in README vs. moves into `docs/`
| In README | In `docs/` |
|---|---|
| Elevator pitch (13 sentences) | Full prose docs |
| Installation one-liners per package format | Complete build / configuration walkthroughs |
| Link to `docs/index.md` | Everything else |
| Acknowledgments (contributor credits) | — |
| License + sponsor links | — |
The README is the project's storefront. `docs/` is the manual. Once a topic exists in `docs/`, the README links out — don't duplicate.

View File

@@ -11,7 +11,6 @@ This directory holds the manual test plan for the Linux fork of Claude Desktop.
| [`matrix.md`](./matrix.md) | **The dashboard.** Cross-environment results table + per-section env-specific status snapshots. Single source of truth for test status. |
| [`runbook.md`](./runbook.md) | How to run a sweep: VM setup, diagnostic capture, status update workflow, severity guidance. |
| [`cases/`](./cases/) | Functional test specs grouped by feature surface. Stable IDs: `T###` cross-env, `S###` env-specific. |
| [`ui/`](./ui/) | UI element inventory. Per-surface checklists — every interactive element with expected state. |
## Environment key

View File

@@ -16,11 +16,10 @@ tests, which anti-patterns to design against, and what to build first.
## Why this exists
The 67 tests in [`cases/`](./cases/) plus the 10 surfaces in [`ui/`](./ui/)
already have stable IDs, standardized bodies, and per-element checklists. That
structure is unusually friendly to automation — but only if the harness is
shaped to match the corpus, rather than the other way around. Three things
make that non-trivial:
The 67 tests in [`cases/`](./cases/) already have stable IDs and
standardized bodies. That structure is unusually friendly to
automation — but only if the harness is shaped to match the corpus,
rather than the other way around. Three things make that non-trivial:
1. The tests aren't homogeneous. Some are pure-renderer (Code tab), some are
native-OS-level (tray, autostart, URL handler), some are visual/UX checks
@@ -40,7 +39,7 @@ make that non-trivial:
| 1 | **Single language: TypeScript.** Every runner is `.ts`; OS tools are shelled out via `child_process` and wrapped as TS helpers. Python only as a last-resort escape hatch for AT-SPI cases that resist portal mocking. | Playwright Electron is JS-native (post-Spectron); `dbus-next` covers DBus end-to-end; portal mocking removes the dogtail dependency for most native-dialog tests. Three-language overhead doesn't pay back. |
| 2 | **Harness location: `tools/test-harness/`.** Sibling to `scripts/`. | Keeps `docs/testing/` documentation-only; matches the project's existing `tools/` / `scripts/` split. |
| 3 | **VM images: Packer for imperative distros + Nix flake for `Hypr-N`.** | Packer builds golden snapshots that boot fast and rebuild as code; Nix flake handles NixOS natively without a second wrapper. Vagrant's per-boot provisioning model is the wrong tradeoff for hermetic per-test snapshots. |
| 4 | **No CI infrastructure initially.** Harness is invokable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
| 4 | **No CI infrastructure initially.** Harness is invocable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
| 5 | **Selectors: semantic locators only (`getByRole`, `getByLabel`, `getByText`).** No CSS classes against minified renderer output. No proactive `data-testid` injection patch. Escalate per-test only when a specific test proves unstable: first ask upstream for a stable `data-testid`; only carry an `app-asar.sh` patch if upstream declines. | Building selector-injection infrastructure up front is a guess at where rot will happen. Modern React apps usually have enough ARIA roles and visible text for `getByRole`/`getByText` to be durable. Measure before patching. |
| 6 | **X11-default verification is Smoke. Wayland-native characterization is Should.** Add a Smoke test asserting the launcher log shows X11/XWayland selected on each row (the project's release-gate behavior). Add per-row Should tests characterizing what happens if Electron's default Wayland selection is allowed — these are informational, not release-gating. | The project chose X11 default because portal `GlobalShortcuts` coverage is patchy. The new Wayland-default tests exist to map that landscape, not to gate releases on it. |
| 7 | **Diagnostic retention: last 10 greens + all reds, on `main` only.** Captures `--doctor`, launcher log, screenshot every run. Reds retained indefinitely; greens rotate. | Cheap regression-bisect baseline; bounded storage; reds are the things you actually need to look at six weeks later. |
@@ -53,7 +52,7 @@ bucket maps to a different shape of TS code (not a different language):
| Layer | What it covers | Implementation |
|-------|----------------|----------------|
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat, most of `ui/code-tab-panes.md`, `prompt-area.md`, `settings.md` | `playwright-electron` (`_electron.launch()`) directly |
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat | `playwright-electron` (`_electron.launch()`) directly |
| **L2 — Native / OS** | Tray (DBus), window decorations, URL handler (`xdg-open`), autostart, `--doctor`, multi-instance, hide-to-tray, native file picker (T17) | TS + `dbus-next` for DBus; `child_process` shell-outs wrapped as TS helpers (`xprop`, `wlr-randr`, `swaymsg`, `niri msg`, `pgrep`, `ydotool`); `dbus-next`-driven portal mocking for native-dialog tests |
| **L3 — Manual** | "Icon is crisp on HiDPI", drag-and-drop feel, T28 catch-up after suspend (real wall-clock), subjective UX checks | Human eyes; capture in [`runbook.md`](./runbook.md) sweep loop |

View File

@@ -1,347 +0,0 @@
# docs/testing/cases grounding sweep — implementation prompt
This file is meant to be **copied verbatim into a fresh Claude Code
session** as the initial user message. Don't paraphrase it; the
orchestration depends on the exact directives below.
---
## Prompt to paste
You're picking up after the v7 walker, U01 wire-up, and the
`claudeai.ts` AX-tree migration all landed. The page-objects are
stable against the live renderer (T17_folder_picker passes on
KDE-W). The next workstream is **grounding the case docs in
`docs/testing/cases/` against actual upstream behavior**.
The cases were written from outside-in — observed user-visible
flows, expected outcomes, diagnostic captures. Many describe
behavior the test author *believed* exists in upstream Claude
Desktop, but no one has cross-checked each Step / Expected against
the actual extracted source. Your job is to spawn one subagent per
case file, have each one read the case + grep the build-reference
extract for the relevant feature, and report what's accurate, what's
stale, and what's missing — then make in-place adjustments to the
case files so each one is grounded in concrete code anchors before
the next sweep cycle.
### Authoritative reference
Read these in order. They're the substrate the subagents will pull
from.
- `docs/testing/cases/README.md` — the case-doc structure (severity,
surface, applies-to, steps, expected, diagnostics, references).
The "Standard test body" template at the bottom is the contract
every case currently follows.
- `docs/testing/matrix.md` — live Pass/Fail/Pending matrix per row.
Tells you which cases have a runner and which are still
human-execution-only.
- `build-reference/app-extracted/.vite/build/` — the extracted +
beautified Claude Desktop source. ~14 files; `index.js` is the
main process (~546k lines after beautification), `mainView.js` /
`mainWindow.js` / `quickWindow.js` are renderer preloads,
`coworkArtifact.js` is the cowork BrowserView preload,
`buddy.js` is the supervisor, etc. **This is the ground truth.**
- `tools/test-harness/src/runners/` — existing runners that *do*
have working selectors / event hooks. Sometimes the runner has
more accurate code anchors than the case doc.
- `CLAUDE.md` (project root) — project conventions, attribution
format, commit style. Don't violate.
### Case files in scope
Eleven files plus the README. One subagent per file:
| File | Tests covered |
|---|---|
| `code-tab-foundations.md` | T15-T20 |
| `code-tab-handoff.md` | T23-T25, T34, T38, T39 |
| `code-tab-workflow.md` | T21-T22, T29-T32 |
| `distribution.md` | S01-S05, S15, S16, S26 |
| `extensibility.md` | T11, T33, T35-T37, S27, S28 |
| `launch.md` | T01, T02, T13, T14 |
| `platform-integration.md` | T09, T10, T12, S17, S18, S22-S25 |
| `routines.md` | T26-T28, S19-S21 |
| `shortcuts-and-input.md` | T05, T06, S06-S14, S29-S37 |
| `tray-and-window-chrome.md` | T03, T04, T07, T08, S08, S13 |
### Why this iteration
Several cases have been silently bit-rotting against upstream
changes — a Step says "click the X menu" but X was renamed two
upstream versions ago, or an Expected references a behavior the
team shipped behind a feature flag that's now off by default. When
the sweep runs against a row that's stale, the failure looks like a
Linux compatibility issue but is actually a doc-vs-upstream drift.
Grounding the cases against the actual extracted source closes
that gap and makes future sweeps interpretable.
This isn't a one-time correctness pass — it's a cycle. After every
upstream version bump (`CLAUDE_DESKTOP_VERSION` rolls in
`scripts/setup/detect-host.sh`), the grounding can drift again.
Optimise for **leaving concrete code-anchor breadcrumbs** in each
case so the next grounding pass is fast.
### Repo conventions
- Tabs for indentation in code; markdown is space-indented as the
existing files do it.
- Markdown lines wrap at ~80 chars unless they're tables or links
that don't break naturally.
- Don't commit. The user reviews and commits.
- Don't run the host Claude Desktop. The user runs it. Read from
`build-reference/` instead — that's already extracted +
beautified specifically so you don't have to attach to a live
app to verify behavior.
### Code anchors
- `build-reference/app-extracted/.vite/build/index.js` — main
process. Every IPC channel registration, window-management
decision, app-lifecycle hook, tray-menu construction, autostart
toggle, dialog invocation, and protocol handler lives here.
- `build-reference/app-extracted/.vite/build/quickWindow.js`
Quick Entry preload + window setup.
- `build-reference/app-extracted/.vite/build/mainWindow.js`
main shell BrowserWindow preload (claude.ai is loaded into a
child BrowserView; this preload runs in the shell frame).
- `build-reference/app-extracted/.vite/build/mainView.js`
preload running inside the claude.ai BrowserView itself.
- `build-reference/app-extracted/.vite/build/coworkArtifact.js`
preload running inside cowork's iframe-shaped artifact view.
- `build-reference/app-extracted/.vite/build/buddy.js` — supervisor
process (the daemon that respawns the cowork worker; see
`docs/learnings/cowork-vm-daemon.md`).
- `build-reference/app-extracted/package.json` — declared main /
preloads, electron version, native deps. Quick reference for
whether a feature is wired up at all.
### Phases
#### Phase 0 — calibration
1. `cd tools/test-harness && npm run typecheck` — should pass; if
not, stop and report.
2. Read `docs/testing/cases/README.md` end-to-end and one full case
file (suggest `launch.md` — small, four tests, easy
surface-area). Confirm you understand the case-doc contract
before fanning out.
3. Pick T01 (App launch) as a calibration case. Manually grep
`build-reference/app-extracted/.vite/build/index.js` for the
launcher-log / backend-selection logic referenced in T01's
Expected. Confirm you can read the beautified source and locate
the relevant code. Report the anchor (`index.js:N-M`) so the
user knows the workflow is sound before you fan out.
If Phase 0 surfaces a problem (build-reference stale relative to
the case doc, calibration anchor not findable, README structure
unclear), stop and report. Don't fan out subagents against an
unverified workflow.
#### Phase 1 — fan-out
Spawn one subagent per case file (eleven total). Use
`subagent_type: 'general-purpose'`. Send them in **parallel**
they're independent. Keep the prompt to each subagent
self-contained; the subagent has no context from this conversation.
Per-subagent prompt template (fill in the case file path):
```
You're grounding ONE test-case file in
docs/testing/cases/<FILE>.md against the extracted Claude Desktop
source at build-reference/app-extracted/.vite/build/.
Read these first:
- docs/testing/cases/README.md (case-doc contract)
- docs/testing/cases/<FILE>.md (your case file)
- CLAUDE.md (project conventions)
For each test in the file:
1. Read the test's Steps + Expected.
2. Identify the load-bearing claim — the upstream behavior the
test depends on (an IPC channel, a tray-menu item, a
dialog.showOpenDialog call, a globalShortcut.register, a
nativeTheme listener, etc.).
3. Grep build-reference/app-extracted/.vite/build/ for that claim.
Use ripgrep / grep -E. The code is beautified but minified
variable names — anchor on string literals, IPC channel names,
menu labels, event names, not variable identifiers.
4. Classify the result:
- **Grounded** — claim verified, anchor found. Append a
`**Code anchors:** <file>:<line>` line to the test body
directly under the existing References field.
- **Drifted** — feature exists but the case's Steps or Expected
don't match what's actually shipping. Edit the case to
match upstream behavior. Note what changed.
- **Missing** — feature isn't in the build at all (deprecated,
never shipped, behind unset flag). Mark the test with a
prepended block:
`> **⚠ Missing in build 1.5354.0** — <one-line note>. Re-verify after next upstream bump.`
- **Ambiguous** — claim could be one of several upstream code
paths and you can't disambiguate from the case alone. Don't
edit; report under "Open questions".
Per-test, prefer concrete code anchors over wordy explanations.
The next person reading this case should see exactly where
upstream implements the feature.
Constraints:
- Don't fabricate anchors. If you can't find it, mark Missing or
Ambiguous — never invent a `index.js:12345` reference.
- Don't restructure the case files. Keep the existing template
(Severity / Surface / Applies to / Issues / Steps / Expected /
Diagnostics / References). Only add code anchors and edit
Steps/Expected for drift.
- Don't expand scope. If you notice an unrelated bug or missing
test, note it under "Open questions" — don't fix it inline.
- Don't run the host Claude Desktop. Read from build-reference/
only.
Report shape (~300-500 words):
## <FILE>.md grounding
- Tests reviewed: N
- Grounded: N
- Drifted (edited): N (one-line per: <test-id> — <what changed>)
- Missing (marked): N (one-line per: <test-id> — <what's gone>)
- Ambiguous (flagged): N (one-line per: <test-id> — <why>)
### Code anchor highlights
- <test-id>: <file>:<line> — <what the anchor proves>
### Open questions
- ...
### Files touched
- docs/testing/cases/<FILE>.md
```
Keep the report tight. The orchestrator reads eleven of these and
synthesizes.
#### Phase 2 — synthesis
Once all eleven subagents return:
1. Aggregate per-classification counts across all files. Big
numbers in any column are signals:
- Lots of **Drifted** → upstream had a recent feature shuffle;
the team should know.
- Lots of **Missing** → either the case doc was written
speculatively or upstream removed features without telling.
- Lots of **Ambiguous** → the case-doc template needs a
"Implementation hint" field so future grounding has a
starting point.
2. Cross-check: did any subagent edit the same anchor differently?
(Unlikely since each owns one file, but worth a sanity pass.)
3. Check that `git diff docs/testing/cases/` matches what the
subagents reported. If a subagent claimed Drifted but didn't
write to disk, surface it.
4. Build the user-facing summary (see "Final report format" below).
Don't make the user re-read the eleven subagent reports — give
them the synthesised view + the per-file links.
### Self-correction loop
After Phase 1 returns:
1. If any subagent failed (no report, error, hit token limit),
re-spawn just that one with a tighter scope (e.g. "process
tests T15-T17 only, not the full file").
2. If a subagent's report claims edits but `git diff` shows no
changes, the subagent silently dropped the writes — re-spawn
with explicit instruction to use the Edit tool.
3. If two subagents flag the same upstream code path with
contradictory claims (one says Grounded, one says Missing),
re-read the source yourself and adjudicate.
Cap re-spawns at **2 per file** — past that, mark the file as
"needs human review" in the final report and move on.
### Termination conditions
Stop and write a final report when one of:
1. **All eleven files grounded.** Per-file classification counts +
diff stat. Done.
2. **Hit the re-spawn cap on 3+ files.** Stop, write up which
files are blocked, what each blocker looks like.
3. **Build-reference is stale.** If multiple subagents report
"Missing" against features the user knows shipped, the
extract may be out of date — verify the version
(`build-reference/app-extracted/package.json` `version` field
vs `CLAUDE_DESKTOP_VERSION` repo variable) before continuing.
### What you should NOT do
- Don't commit. The user reviews everything.
- Don't restructure the case-doc template. Eleven files, one
shape — keep it that way.
- Don't add new tests. Grounding is a verify-and-anchor pass, not
a coverage expansion.
- Don't run the host Claude Desktop. The build-reference extract
exists specifically so you don't have to attach to a live app.
- Don't edit anything outside `docs/testing/cases/`. If you find
a runner discrepancy (case says "click X", runner clicks "Y"),
flag it under Open questions; don't edit the runner.
- Don't invent anchors. If the grep doesn't find the literal,
classify Missing or Ambiguous — never write a fictional
`index.js:12345` reference.
### Final report format
```markdown
## Cases grounding summary
- Files reviewed: 11 / 11
- Tests reviewed: N (sum across all files)
- Grounded: N (with code anchors added)
- Drifted (edited): N
- Missing (marked): N
- Ambiguous: N
- Files needing
human review: N
## Per-file breakdown
| File | Reviewed | Grounded | Drifted | Missing | Ambiguous |
|---|---|---|---|---|---|
| code-tab-foundations.md | ... | ... | ... | ... | ... |
| ... | | | | | |
## Notable findings
- <test-id>: <one-line significance>
- ...
## Open questions
- ...
## Files touched
git status output (only docs/testing/cases/*.md should appear)
## Diff summary
git diff --stat docs/testing/cases/
```
### Operational notes
- Subagents are launched in parallel via a single message with
multiple Agent tool calls. Don't serialize them — Phase 1 takes
~15 minutes serial, ~3 minutes parallel.
- Each subagent's Edit calls land directly in the working tree.
No merge conflicts because each owns one file.
- The build-reference `index.js` is 546k lines. Subagents should
use `grep -nE` with anchored string literals, not full reads.
Recommended grep pattern style:
`grep -nE 'globalShortcut\.register\([^)]*' build-reference/app-extracted/.vite/build/index.js`
- If a subagent needs to verify a renderer-side claim (DOM event
flow, React component shape), the relevant preload is in
`mainView.js` / `mainWindow.js`. Don't grep `index.js` for
renderer-only behavior.
Begin with Phase 0. Don't fan out until calibration succeeds.

View File

@@ -1,6 +1,6 @@
# Functional Test Cases
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md). For the UI element inventory, see [`../ui/`](../ui/).
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md).
## Files

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)
@@ -335,7 +333,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
**Diagnostics on failure:** `xrandr` (X11) / `wlr-randr` (wlroots) output before and after disconnect, captured popup coordinates, screenshot.
**Skip when:** Single-monitor VM or host. Not part of the [§ Mandatory matrix](../quick-entry-closeout.md#mandatory-matrix); skip with `-` in the dashboard.
**Skip when:** Single-monitor VM or host. Skip with `-` in the dashboard.
**References:** upstream `index.js:515502`
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515502 (`return cHn();` early-return when no saved position), 515523-515527 (`cHn()` centres popup on `screen.getPrimaryDisplay()` workArea), 515514-515515 (`label`-only match fallback before primary-display fallback).

View File

@@ -1,322 +0,0 @@
# lib/claudeai.ts AX-tree migration — implementation prompt
This file is meant to be **copied verbatim into a fresh Claude Code
session** as the initial user message. Don't paraphrase it; the
self-correction loop depends on the exact directives below.
---
## Prompt to paste
You're picking up after the v7 fingerprint walker + U01 wire-up
landed. Walker, resolver, and U01 are all on the AX-tree substrate.
The page-object library `tools/test-harness/src/lib/claudeai.ts` is
still on the old substrate — `document.querySelector` against
minified-tailwind class shapes (`button[aria-haspopup="menu"]` +
`span.truncate.max-w-[Npx]`) — and that's where every claude.ai UI
spec couples to upstream's React DOM. Your job is to migrate the
brittle CSS-shape walks in `claudeai.ts` to AX-tree resolution using
the v7 walker primitives, run the H/S spec families that consume
them, and iterate until those specs pass without DOM-shape coupling.
### Authoritative reference
Read these in order. They contain the design, the gotchas, and the
runtime contract — the prompt below assumes them as background.
- `docs/testing/fingerprint-v7-plan.md` — design contract for the v7
fingerprint, kind-strictness matrix, resolver fallback chain. Skim
the "Capture algorithm" and "Resolver / fallback chain" sections;
the migration consumes the same primitives.
- `docs/learnings/test-harness-ax-tree-walker.md` — the five
non-obvious AX-tree traps (AX-enable async lag, navigateTo no-op,
flat dialog>button[] lists, more-options shape, sidebar
virtualization). All apply here too — `lib/claudeai.ts` calls run
inside the same renderer the walker drives.
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
~340 lines, eight functions plus two classes (`CodeTab`,
`LocalEnvPill`). Every public function is a discovery walk against
`evalInRenderer` with `document.querySelectorAll`.
### Why this iteration
Per the v7 plan's design goal §2 "Resilient to cosmetic drift" —
upstream regenerates tailwind class signatures on rebuild
(`max-w-[Npx]`, `df-pill`-style atoms), so `claudeai.ts`'s CSS-shape
walks break on any minor UI rebuild even when the AX-computed role
and accessible name are stable. The U01 wire-up confirmed the AX
tree is a usable substrate end-to-end (~7s/test, 89/90 stable across
two consecutive sweeps). Pulling `claudeai.ts` onto the same
substrate eliminates the recurring "tailwind regen breaks H05/S31
again" failure mode.
Acceptance per the plan: H05 + S29-S37 + T-prefix specs that consume
`claudeai.ts` keep passing on the same account, with zero new
flakes. Migration is mechanical (replace the eval-string walks with
AX-tree queries) and the existing tests are the contract.
### Repo conventions
- Tabs for indentation, lines under 80 chars, single quotes for
literals, TypeScript strict mode (`tools/test-harness/tsconfig.json`
enforces it).
- Comments only when the WHY is non-obvious — write the `because:`
clause, not the `that:` clause.
- No backward-compatibility shims. If a function's signature needs
to change, change every caller. Don't keep both code paths.
- Don't commit. The user reviews and commits.
### Code anchors
- `tools/test-harness/explore/walker.ts` — exports the primitives
you'll consume:
- `findByFingerprint(inspector, fingerprint, kind)` — full
resolver with strictness gating + relaxed-scope fallback.
Overkill for one-shot lookups against the live renderer.
- `queryAccessibleTree(elements, query)` — pure filter, used at
capture and resolve time. Takes a `RawElement[]` snapshot and
an `AxQuery` (ariaPath + leaf criteria). What you'll likely
wrap.
- `axTreeToSnapshot(nodes)` — converts CDP `AxNode[]` to the
walker's `RawElement[]` shape. Drops ignored nodes.
- `walkLandmarkAncestors(raw)` — emits the AriaStep[] for an
element. Useful if a method needs to disambiguate by landmark.
- `waitForAxTreeStable(inspector, opts)` — gating primitive used
by walker + U01. Use `{ minNodes: 1, timeoutMs: 10000 }` for
post-click reads (matches `snapshotSurface`'s default).
- `tools/test-harness/src/lib/inspector.ts``getAccessibleTree`
fetches the raw CDP tree filtered to the claude.ai webContents.
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
Read the file-header comment first; it documents the discovery
strategy you're replacing.
- `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`,
`S31_quick_entry_submit_reaches_new_chat.spec.ts`,
`S32_quick_entry_submit_gnome_stale_isfocused.spec.ts` — primary
consumers of the methods being migrated.
### Phases
#### Phase A — spike on one method
1. `cd tools/test-harness && npm run typecheck` — must pass before
doing anything.
2. Pick `openPill(inspector, labelPattern, opts)` as the spike.
It's the most CSS-shape-coupled method and exercises the
menu-render polling pattern the rest of `claudeai.ts` reuses.
3. Replace its body with an AX-tree query:
- Fetch the AX tree (`inspector.getAccessibleTree('claude.ai')`),
convert via `axTreeToSnapshot`.
- Filter to elements with `computedRole === 'button'` and
accessibleName matching `labelPattern`.
- For each candidate, compute its parent landmark via
`walkLandmarkAncestors`. The compact-pill discriminator —
"has a `span.truncate.max-w-[Npx]` child" — needs an AX
analogue. Most likely: parent is `toolbar` / `group` and the
element has `aria-haspopup === 'menu'` (exposed in AX as
`hasPopup` property; check whether `RawElement` carries it
and extend if needed).
- Click via `inspector.clickByBackendNodeId(raw.backendDOMNodeId)`.
- Poll for menu items via AX role match (`menuitem`,
`menuitemradio`, `menuitemcheckbox`).
4. Run H05 against your branch (`./node_modules/.bin/playwright
test src/runners/H05_ui_drift_check.spec.ts`). H05 doesn't
directly call `openPill` but exercises the same renderer state;
if H05 regresses your AX walk is wrong.
5. Run S31 (`./node_modules/.bin/playwright test
src/runners/S31_quick_entry_submit_reaches_new_chat.spec.ts`).
This calls `openPill` indirectly via `CodeTab.activate` →
`findCompactPills`.
6. If both pass, the AX substrate works for at least one method.
Commit the shape mentally (don't `git commit` — the user does
that). If either fails, the spike is in trouble; re-read the
AX-tree learnings doc for traps you missed and fix the
primitive before expanding.
#### Phase B — migrate the rest
For each remaining function in `claudeai.ts`, port the discovery
walk to AX:
- `activateTab(inspector, name)` — `button` with
`accessibleName === name` under root or banner landmark. Existing
`aria-label="X"` selector → AX `name` literal match.
- `findCompactPills(inspector)` — list of buttons with
`hasPopup === 'menu'` AND inner `span.truncate.max-w-[…]` text
child. AX equivalent: button role + hasPopup + a child
`genericContainer` (or whatever AX exposes for `<span>`) carrying
the visible text. Returns `{text, maxW, expanded}` today —
`maxW` is a tailwind artifact and should be dropped from the AX
shape (callers don't use it for matching, just for diagnostics;
keep a placeholder or remove from the type).
- `clickMenuItem(inspector, textPattern, opts)` — element with
role in `{menuitem, menuitemradio, menuitemcheckbox}` and
accessibleName matching `textPattern`. The CSS attribute selector
has an AX direct equivalent.
- `pressEscape(inspector)` — keep as-is. It's a keydown dispatch,
not a discovery walk.
- `CodeTab.activate(opts)` — calls `activateTab` + polls
`findCompactPills`. Migrates by transitivity.
- `LocalEnvPill` — read its body to enumerate callers.
After each migration:
1. `npm run typecheck` — must pass.
2. `npx tsx explore/walker.ts` — selfTest must pass (you may have
touched walker.ts to expose new primitives).
3. Run the affected spec(s).
#### Phase C — full sweep
1. Run all H/S/T runners that consume `claudeai.ts`:
- H05 (UI drift)
- S31 (Code-tab submit)
- S32 (GNOME stale isFocused)
- any T-prefix that uses `installOpenDialogMock` or `pressEscape`
2. Tally pass/fail. The post-migration baseline must equal the
pre-migration baseline, modulo flakes characterized in
`docs/learnings/test-harness-ax-tree-walker.md`.
Cap iterations at **5 sweep cycles** total (spike + 4 fix-rerun
cycles) — past that, stop and report.
##### Failure classes
1. **AX-shape mismatch.** Element has the CSS shape the old code
relied on but a different AX role/name than expected. Fix:
probe the AX tree for the actual shape (use
`inspector.getAccessibleTree('claude.ai')` interactively from a
one-shot script), update the AX query.
2. **Missing AX property exposure.** `hasPopup`, `expanded`, etc.
may not be in `RawElement` today (the walker only reads role,
name, ancestors, sibling info). Extend `RawElement` and
`axTreeToSnapshot` to expose what the migration needs. Update
walker.ts selfTest if you change the snapshot shape.
3. **Race against menu render.** Old code polled
`document.querySelectorAll('[role=menuitem]')` every 50ms. AX
tree updates lag DOM by hundreds of ms; bake a
`waitForAxTreeStable({ minNodes: 1 })` between click and
menuitem fetch instead of a short DOM poll.
4. **Tailwind-class diagnostic loss.** `findCompactPills` returns
`maxW` which callers use only in error messages. If the
AX-only return shape drops `maxW`, error messages get less
informative — accept it, don't reintroduce DOM walks just for
diagnostics. Keep the `maxW` field optional/null in the type.
##### What "fix" means
A fix is one of:
- A code change in `claudeai.ts`, `walker.ts`, or `inspector.ts`.
- A targeted extension of `RawElement` / `axTreeToSnapshot` to
expose an AX property the migration needs.
Not a fix:
- `// eslint-disable-next-line` / `// @ts-ignore` / `as unknown as ...`.
- Keeping the old `document.querySelector` walk as a fallback.
- Adding an AX walk that wraps a CSS walk that wraps an AX walk.
### Self-correction loop (general protocol)
After each phase's specific loop:
1. If `npm run typecheck` reports errors, fix root causes — no
`// @ts-ignore`, no `any`, no `as unknown as ...`.
2. If `npx tsx explore/walker.ts` (selfTest) fails, the change broke
an algorithmic invariant. Don't relax the test; fix the change.
3. **Cap fix attempts per problem class at 3.** After 3 attempts
on the same class without progress, stop and report.
4. Mark Phase complete only when every step in that Phase passes
cleanly.
### Termination conditions
Stop and write a final report when one of:
1. **Migration is clean.** All `claudeai.ts` methods on AX
substrate, all consuming specs pass at the pre-migration
baseline. Report final pass tallies + diff stat.
2. **Hit the 5-sweep cap.** Report what's done, what's blocked,
and what each remaining failure looks like.
3. **Hit the 3-attempt cap on a non-trivial issue.** Report
attempts, why each failed, what's blocked.
4. **AX exposure gap.** A claude.ai surface uses a property the AX
tree doesn't expose (e.g., custom `data-state` attributes
without a corresponding ARIA reflection). Stop, document the
gap, ask the user before adding a hybrid AX+DOM walk.
### What you should NOT do
- Don't commit. The user reviews everything.
- Don't keep both substrates. The migration is atomic per method:
CSS walk out, AX walk in. No fallback chains.
- Don't add new abstractions in `claudeai.ts` that aren't required
by the migration. The file's shape (one function per UI verb) is
load-bearing for callers — don't introduce a `PageObject` base
class or a generic AX builder.
- Don't run the host Claude Desktop. The user runs it. The H/S
specs use `launchClaude` with `seedFromHost` or `null` isolation
per spec — confirm with the user before any sweep.
- Don't widen `RawElement` speculatively. Only add fields the
migration consumes. Each new field bloats every snapshot.
- Don't drill into a single-method workaround that other methods
would have to duplicate. If a fix wants to live in a helper,
put it next to `queryAccessibleTree` in `walker.ts`.
### Final report format
```markdown
## Migration summary
- Functions migrated: N / N
- Walker.ts changes: <one-line summary>
- Inspector.ts changes: <one-line summary or none>
- H/S/T specs run: N
- H/S/T specs passed: N
- New flakes introduced: N (description)
## Iteration log
### Spike — openPill
- Result: ...
- AX shape used: ...
- Issues hit: ...
### Phase B — remaining methods
- One block per method ...
### Phase C — full sweep
- Per-spec pass/fail tally
- Diff against pre-migration baseline
## Open issues
- ...
## Files touched
git status output
## Diff for review
git diff --stat output
```
### Operational notes
- Background runs: use `Bash run_in_background: true` for any
multi-spec sweep, and `Monitor` with a tight grep filter
(`✓|✘|Error|FAIL|EXIT=`) to stream events. Stop the monitor when
the run completes.
- Check for leftover Electron processes between runs
(`pgrep -af '/usr/lib/claude-desktop/node_modules/electron'`)
and stale tmpdirs (`ls /tmp/claude-test-*`) — clean both up if
the prior run errored before teardown.
- The U01 wire-up landed two `walker.ts` fixes that are part of
the substrate you're inheriting:
1. `findByFingerprint`: strictness gate also defers to
`fingerprint.classification === 'instance'` for degenerate
fingerprints.
2. `redrivePath`: navigates to startUrl when current URL drifted;
reloads only when already at startUrl.
Both are live in the working tree (or just-merged main,
depending on when this prompt fires).
Begin with Phase A. Read `claudeai.ts` end-to-end first — in
particular the file-header discovery comment (lines 1-31) and the
`openPill` body (lines 162-202) — so you understand what the
existing CSS-shape walks are anchoring on before you replace them.

View File

@@ -1,218 +0,0 @@
# claude.ai UI Map
*Last updated: 2026-05-02*
This file is the index from "UI surface" → "test-harness abstraction." It
answers: *which renderer surface does each Layer-2 helper cover, and where
are the gaps?* For human-readable behavior and visual specs of each surface
(what each button looks like, what each menu does), see [`ui/`](./ui/).
For the architectural rationale and growth strategy of the wrapper, see
[`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md).
A `✓` marker means the helper exists today, with a `file:line` reference
into [`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts).
A `TODO` marker is a planned helper — when a third test needs the same
shape, promote it from inline `evalInRenderer` to a top-level helper or
page-object method (see plan Phase 3).
## Top-level routes
- `/new` — chat composer page (default landing for signed-in users)
- `/chat/<uuid>` — open chat session
- `/epitaxy` — Code tab landing
- `/projects/<id>` — project view
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
The Code df-pill click does **not** change the URL — the router rerenders
the tab body inline. Helpers must poll for body-mount signals (e.g. a
compact pill rendering) rather than waiting on navigation.
## Surfaces by tab
### Chat (df-pill "Chat", route /new)
UI reference: [`ui/prompt-area.md`](./ui/prompt-area.md),
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
- Composer textarea — TODO `ChatTab.composer()`
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
— TODO `ChatTab.openAttachMenu()`
- Slash menu (triggered by typing `/`) — TODO `ChatTab.openSlashMenu()`
- Model picker — TODO `ChatTab.openModelPicker()`
- Permission mode picker — TODO `ChatTab.openPermissionPicker()`
- Effort picker — TODO
- Send button — TODO `ChatTab.send()`
- Stop button (replaces Send while responding) — TODO `ChatTab.stop()`
- Attachment chip / drag-drop overlay — TODO
- Usage ring — TODO
### Cowork (df-pill "Cowork")
UI reference: see ghost-icon row in
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md). No
dedicated surface doc yet — the ghost icon is the canonical "topbar shim
alive" indicator and the tab body itself is largely undocumented at the
time of writing.
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
- Workspace list — TODO `CoworkTab.listWorkspaces()`
- Environment switcher — TODO `CoworkTab.switchEnvironment()`
- Dispatch state indicator — TODO
### Code (df-pill "Code", route /epitaxy)
UI reference: [`ui/code-tab-panes.md`](./ui/code-tab-panes.md),
[`ui/sidebar.md`](./ui/sidebar.md),
[`ui/prompt-area.md`](./ui/prompt-area.md).
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
- Tab activation + body-mount wait — `lib/claudeai.ts:CodeTab.activate` (:285) ✓
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill` (:317) ✓
- Local env selection — `lib/claudeai.ts:CodeTab.selectLocal` (:350) ✓
- Select-folder pill (rendered after Local) — used internally by
`lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
- Folder picker dialog (full chain) — `lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
- Folder picker dialog mock + assertion — `lib/claudeai.ts:installOpenDialogMock`
(:70) ✓ + `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
- File tree (left panel) — TODO `CodeTab.fileTree()`
- Editor pane — TODO `CodeTab.editor()`
- Diff pane — TODO `CodeTab.openDiff()`
- Preview pane — TODO `CodeTab.openPreview()`
- Integrated terminal — TODO `CodeTab.openTerminal()`
- Tasks / subagent / plan panes — TODO
- Side-chat — TODO `CodeTab.openSideChat()`
- Recent-folder selection (radio in Select-folder menu) — TODO
## Surfaces independent of tab
### Sidebar
UI reference: [`ui/sidebar.md`](./ui/sidebar.md).
- Search overlay (topbar Search icon) — TODO `SidebarNav.search()`
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
- "More options" per row — TODO `SidebarNav.rowContextMenu(uuid)`
- "+ New session" button — TODO `SidebarNav.newSession()`
- Routines link — TODO `SidebarNav.openRoutines()`
- Customize link — TODO `SidebarNav.openCustomize()`
- Status / project / environment filters — TODO
- Group-by control — TODO
- Collapse toggle — TODO
### Window chrome / topbar (in-app hybrid)
UI reference: [`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
- Hamburger menu — TODO `Topbar.openHamburger()`
- Sidebar toggle — TODO `Topbar.toggleSidebar()`
- Back / forward arrows — TODO
- Cowork ghost icon (topbar-alive sentinel) — TODO `Topbar.coworkGhostPresent()`
### Native dialogs
- File / folder picker mock — `lib/claudeai.ts:installOpenDialogMock` (:70) ✓
- File / folder picker call inspection — `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
- Message box / confirm — TODO `installShowMessageBoxMock`
- Save dialog — TODO `installShowSaveDialogMock`
### Menus / popovers
- Compact-pill discovery — `lib/claudeai.ts:findCompactPills` (:130) ✓
- Compact-pill open + menu read — `lib/claudeai.ts:openPill` (:162) ✓
- Click any menuitem by text regex — `lib/claudeai.ts:clickMenuItem` (:210) ✓
- Dismiss popover via Escape — `lib/claudeai.ts:pressEscape` (:256) ✓
- Modal dismiss / confirm — TODO `Modal.dismiss()` / `Modal.confirm()`
- Toast / status — TODO `waitForToast(regex)`
- Right-click context menus (sidebar row, etc.) — TODO `openContextMenu(target)`
### Settings
UI reference: [`ui/settings.md`](./ui/settings.md).
- Open Settings — TODO `Settings.open()`
- Hotkey rebind — TODO `Settings.rebindHotkey(action, chord)`
- Theme toggle — TODO `Settings.setTheme('dark' | 'light' | 'auto')`
- Account / sign-out — TODO `Settings.signOut()`
- Computer-use toggle (absent on Linux per S22) — TODO
- Keep-computer-awake toggle (per S20) — TODO
### Routines page
UI reference: [`ui/routines-page.md`](./ui/routines-page.md).
- Routines list — TODO `RoutinesPage.list()`
- New-routine form — TODO `RoutinesPage.create(spec)`
- Routine detail page — TODO `RoutinesPage.open(id)`
### Connectors and plugins
UI reference: [`ui/connectors-and-plugins.md`](./ui/connectors-and-plugins.md).
- Connector picker — TODO `ConnectorPicker.open()`
- Connector list / status — TODO
- Plugin browser — TODO `PluginBrowser.open()`
- Plugin install (Anthropic & Partners flow) — TODO `PluginBrowser.install(slug)`
- Plugin manager (installed list) — TODO
### Quick Entry popup
UI reference: [`ui/quick-entry.md`](./ui/quick-entry.md). Note: the
Quick Entry harness lives in [`quickentry.ts`](../../tools/test-harness/src/lib/quickentry.ts),
not `claudeai.ts`. The `installOpenDialogMock` shape here intentionally
mirrors `QuickEntry.installInterceptor` (quickentry.ts:86) — keep them
aligned when extending either.
- Open Quick Entry (global shortcut) — covered by `lib/quickentry.ts`
- Compose + send — covered by `lib/quickentry.ts`
- Closeout cases (S29S37) — covered by `lib/quickentry.ts`
### Notifications
UI reference: [`ui/notifications.md`](./ui/notifications.md). libnotify
rendering is environmental — likely stays a manual checklist rather than
a renderer-side helper. No `claudeai.ts` coverage planned.
### Tray
UI reference: [`ui/tray.md`](./ui/tray.md). Tray is owned by the main
process / native bindings, not the renderer DOM — outside the scope of
`claudeai.ts`. Covered by separate tests (T03, S08).
## Atoms inventory
Stable structural patterns the lib already anchors on. See the
discovery comment at the top of
[`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts)
for why each is shape-matched rather than class-matched.
| Atom | Fingerprint | Helper |
|---|---|---|
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` (:44) |
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills` (:130), `openPill` (:162) |
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` (:210) |
| Escape dismiss | `document.dispatchEvent(KeyboardEvent('keydown', Escape))` | `pressEscape` (:256) |
| Electron `dialog.showOpenDialog` | main-process IPC | `installOpenDialogMock` (:70), `getOpenDialogCalls` (:113) |
Atoms not yet abstracted (when a third test needs the same shape,
promote to a top-level helper):
| Atom | Probable fingerprint | Status |
|---|---|---|
| modal | `[role=dialog]` | not seen yet |
| toast | `[role=status][aria-live]` | not seen yet |
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
| chat composer | textarea / contenteditable in composer container | not abstracted |
| right-click context menu | `[role=menu]` triggered by `contextmenu` event | not abstracted |
| Electron `dialog.showMessageBox` | main-process IPC | not abstracted |
| Electron `dialog.showSaveDialog` | main-process IPC | not abstracted |
| settings panel section | route-anchored container in Settings tab | not abstracted |
## See also
- [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) —
governing plan and phase rollout
- [`automation.md`](./automation.md) — harness architecture and the
SIGUSR1 / runtime-attach pattern
- [`ui/`](./ui/) — per-surface visual / behavior specs
- [`cases/`](./cases/) — functional test specs (T## / S##)

View File

@@ -1,415 +0,0 @@
# claude.ai UI Mapping Plan
This is an executable plan for systematically mapping claude.ai's
renderer UI into reusable test-harness abstractions. It can be picked
up by a fresh session — start at "Phase 1" and walk down.
## Where we are
The harness already has one worked example: `tools/test-harness/src/lib/claudeai.ts`
exports a `CodeTab` class plus atom helpers (`activateTab`,
`installOpenDialogMock`, `findCompactPills`, `openPill`, `clickMenuItem`,
`pressEscape`). `T17_folder_picker.spec.ts` is its only consumer
today — drives the chain `Code df-pill → env pill → Local → Select
folder → Open folder` and asserts `dialog.showOpenDialog` fires.
Discovery evidence captured by `tools/test-harness/probe.ts` (run
against a live debugger on port 9229):
- df-pill is a stable atom — exactly 3 instances on Code-tab page
(`Chat`, `Cowork`, `Code`), all with `class*="df-pill"` and
matching `aria-label`.
- compact-pill is a stable atom — `button[aria-haspopup=menu]` with
a `span.truncate.max-w-[Npx]` child. Env pill uses 200px,
Select-folder pill uses 160px. Same Tailwind class signature; we
anchor on structure, not classes.
- 80 `button[aria-haspopup=menu]` total on a Code-tab page; only the
2 with the truncate fingerprint are pills, the other 78 are sidebar
"More options" buttons.
Pattern proven: discovery-by-shape in the lib layer, page-object
classes per major UI surface, specs use the lib. This doc covers
how to extend that pattern across the rest of claude.ai.
## Strategy: three layers
**Layer 1 — atoms.** Generic helpers around stable structural
patterns. Live in `lib/claudeai.ts`. Built once, reused everywhere.
Examples already there: compact-pill, df-pill, menu, dialog mock.
**Layer 2 — page objects.** Domain classes per major UI surface
(CodeTab, ChatTab, Settings, etc.). Compose atoms. Built per test
demand — premature otherwise. CodeTab is the template.
**Layer 3 — discovery tooling.** Standalone scripts that connect to
a running debugger and let humans + agents explore the renderer.
`probe.ts` is the seed; this doc grows it into a small CLI.
The thing to avoid: comprehensively mapping the UI upfront. Even
with a recording tool, that burns time on surfaces no test will
exercise for months. Lazy + bookmark-the-shape wins.
## Phase 1 — Tooling foundation
**Goal:** turn `probe.ts` into a proper exploration CLI under
`tools/test-harness/explore/`, with snapshot + diff capability that
catches UI drift before tests do.
**Deliverables:**
- `tools/test-harness/explore/explore.ts` — entry point with
subcommands.
- `tools/test-harness/explore/snapshot.ts` — capture renderer state.
- `tools/test-harness/explore/diff.ts` — compare two snapshots.
- `tools/test-harness/explore/find.ts` — search for elements.
- `docs/testing/ui-snapshots/` — directory for captured snapshots
(gitignore the file contents but commit the directory + a README).
- `tools/test-harness/package.json` — add scripts:
`npm run explore`, `npm run explore:snapshot <name>`, etc.
**Subcommand spec:**
```
npx tsx explore/explore.ts # full snapshot to stdout
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
npx tsx explore/explore.ts menu # currently-open menu structure
npx tsx explore/explore.ts snapshot <name> # write to docs/testing/ui-snapshots/<name>.json
npx tsx explore/explore.ts diff <a> <b> # diff two snapshots — flags renamed/removed
npx tsx explore/explore.ts find <regex> # search renderer for matching text/aria-label
```
Snapshot shape (per file):
```json
{
"capturedAt": "2026-05-02T17:30:00Z",
"claudeAiUrl": "https://claude.ai/epitaxy",
"appVersion": "1.1.7714",
"dfPills": [...],
"compactPills": [...],
"ariaLabeledButtons": [...],
"openMenu": null,
"modals": [...]
}
```
`diff` should flag: removed elements (selector → no match), changed
text/aria-label, new elements (informational, not a failure). Output
human-readable + a `--json` flag for machine consumption.
**How to dispatch this work:**
Single agent, `general-purpose`. Brief:
> Build the explore CLI under `tools/test-harness/explore/`. Read
> `tools/test-harness/probe.ts` as the seed implementation. Match the
> existing project style (tabs, multi-line `//` why-blocks, terse).
> Reuse `src/lib/inspector.ts` (`InspectorClient.connect(9229)`) for
> the debugger connection. Subcommands as specified in
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 1. Do not delete
> probe.ts — leave it as a one-off; it can be removed in a follow-up.
> Typecheck with `npx tsc --noEmit` (no test runs). Add npm scripts
> to `package.json`. Add a thin README in
> `docs/testing/ui-snapshots/README.md` explaining how to capture +
> compare snapshots.
**Exit criteria:**
- `npx tsx explore/explore.ts pills` against a running debugger lists
the 3 df-pills and 2 compact-pills (or whatever's on screen).
- `explore/explore.ts snapshot baseline-code-tab` writes a JSON file.
- `explore/explore.ts diff baseline-code-tab baseline-code-tab`
reports zero diffs.
- Typecheck green.
## Phase 2 — UI map document
**Goal:** maintain a living markdown index of every reachable UI
surface, the navigation path to reach it, and which Layer-2 class
covers it (or `TODO` if none yet).
**Deliverable:** `docs/testing/claudeai-ui-map.md`.
**Initial content** (populate from what's known today, leave gaps
marked TODO):
```markdown
# claude.ai UI Map
Source of truth for "where does each UI surface live, and which
test-harness abstraction covers it." Update as new abstractions are
added.
## Top-level routes
- `/new` — chat composer page (default landing for signed-in users)
- `/chat/<uuid>` — open chat session
- `/epitaxy` — Code tab landing
- `/projects/<id>` — project view
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
## Surfaces by tab
### Chat (df-pill "Chat", route /new)
- Composer textarea — TODO `ChatTab.composer()`
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
— TODO `ChatTab.openAttachMenu()`
- Model selector — TODO
- Stop / regenerate — TODO
### Cowork (df-pill "Cowork")
- Workspace list — TODO
- Environment switcher — TODO
### Code (df-pill "Code", route /epitaxy)
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill()`
- Select folder pill — `lib/claudeai.ts:CodeTab` (used internally by
`openFolderPicker`) ✓
- Folder picker dialog — `lib/claudeai.ts:installOpenDialogMock`
- File tree (left panel) — TODO
- Editor pane — TODO
## Surfaces independent of tab
### Sidebar
- Search — TODO `SidebarNav.search()`
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
- "More options" per row — TODO
- New session button — TODO
### Native dialogs
- File / folder picker — `lib/claudeai.ts:installOpenDialogMock`
- Message box / confirm — TODO `installShowMessageBoxMock`
- Save dialog — TODO `installShowSaveDialogMock`
### Menus / popovers
- Generic menu open + click — `lib/claudeai.ts:openPill` /
`clickMenuItem`
- Modal — TODO `Modal.dismiss() / Modal.confirm()`
- Toast / status — TODO `waitForToast(regex)`
### Settings
- Hotkey rebind — TODO
- Theme toggle — TODO
- Account / sign-out — TODO
## Atoms inventory
Stable structural patterns the lib already anchors on:
| Atom | Fingerprint | Helper |
|---|---|---|
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` |
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills`, `openPill` |
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` |
Atoms not yet abstracted (when a third test needs the same shape,
promote to a top-level helper):
| Atom | Probable fingerprint | Status |
|---|---|---|
| modal | `[role=dialog]` | not seen yet |
| toast | `[role=status][aria-live]` | not seen yet |
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
| chat composer | textarea/contenteditable in composer container | not abstracted |
```
**How to dispatch this work:**
A claude-code-guide or general-purpose agent can write the initial
file. Single message:
> Create `docs/testing/claudeai-ui-map.md` matching the structure in
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 2. Pull TODO
> entries from the planned ChatTab/Settings/etc. surfaces. Mark
> existing helpers from `tools/test-harness/src/lib/claudeai.ts`
> with ✓ and the file:line. Don't run any tests.
**Exit criteria:**
- File exists with all top-level routes documented.
- Every existing `lib/claudeai.ts` export is referenced ✓.
- Every planned surface from this plan has a TODO entry.
## Phase 3 — Page objects per test demand
**Goal:** add new Layer-2 classes (ChatTab, Settings, etc.) when the
first test needs them. Don't speculate.
**Template:** `tools/test-harness/src/lib/claudeai.ts:CodeTab`. Match
its shape:
- Instance class taking `inspector: InspectorClient` in constructor.
- Public methods are either single-step (`openEnvPill`,
`selectLocal`) or multi-step convenience (`openFolderPicker`).
- Discovery by shape, not Tailwind classes.
- Multi-line `//` why-block at top of class explaining what UI
surface it covers and the discovery strategy.
- Failures throw with enough context for the spec to attach to
`testInfo.attach()`.
**Workflow per new page object:**
1. Identify which test motivates the new class. Don't build
speculatively.
2. Run `explore.ts snapshot <name>` against a live debugger on the
target UI surface. Commit the snapshot under
`docs/testing/ui-snapshots/`.
3. Inspect the snapshot — pick stable structural fingerprints, not
Tailwind classes.
4. Write the class in `lib/claudeai.ts`. If the file gets large
(>1500 lines), split per-tab into separate files
(`lib/claudeai/code-tab.ts`, `lib/claudeai/chat-tab.ts`, with
`lib/claudeai.ts` as the barrel).
5. Update `docs/testing/claudeai-ui-map.md` — replace the TODO with
the class name + ✓.
6. Add the spec that uses it.
7. Run typecheck. Don't run tests until everything's wired.
**Don't pull out yet:**
- Single-consumer methods. If only one spec calls
`Settings.toggleDarkMode()`, the inline implementation is fine.
Promote to its own method when a second consumer arrives.
- Generic primitives that haven't repeated three times. Three is
the threshold for "this is an atom" — two could still be
coincidence.
## Phase 4 — Atom promotion
**Goal:** keep the atom layer (Layer 1) growing in step with the
page-object layer (Layer 2).
**Rule:** when a discovery pattern (CSS selector + JS predicate)
appears in 3 different page objects, promote it to a top-level
helper in `lib/claudeai.ts`.
**Examples of likely promotions in the next 6 months:**
- `findModal()` / `dismissModal()` — every page object that opens a
confirmation modal will need this.
- `waitForToast(regex, timeout)` — error and success toasts are
pervasive.
- `installShowMessageBoxMock(inspector, response)` — for native
confirm dialogs.
- `clickNavRow(label)` — sidebar interactions.
**Process:**
1. Notice the third occurrence of the same pattern.
2. Move the inline implementation up to a top-level export.
3. Replace the three call sites with calls to the new export.
4. Add an entry to the atoms inventory in `claudeai-ui-map.md`.
## Phase 5 — Drift detection
**Goal:** catch UI changes that break selectors *before* a sweep
fails — fast, automatic, runs on every harness invocation.
**Deliverable:** `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`.
**Design:**
- Loads each `*.json` file from `docs/testing/ui-snapshots/`.
- Connects to a running app via the existing `launchClaude` +
`attachInspector` flow (NOT against an externally-running app —
the harness must be self-contained).
- For each snapshot, navigates to the captured URL (if not already
there), then asserts each captured selector still resolves to an
element with the same text/aria-label.
- Failures are *attachments*, not full failures — the spec passes
if ≥80% of snapshots match, surfaces the diffs as warnings. Hard
threshold can be tightened later. Goal is "tell me what drifted,"
not "block CI on every minor renderer change."
**How to dispatch:**
Single agent, after Phases 12 are done. Brief:
> Create `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`
> per the design in `docs/testing/claudeai-ui-mapping-plan.md`
> Phase 5. Read each `*.json` under `docs/testing/ui-snapshots/`,
> drive the renderer to the captured URL, assert each captured
> element selector still matches. Surface diffs via
> `testInfo.attach`. Pass if ≥80% match. Severity Should, surface
> "claude.ai UI drift detection". Typecheck only.
**Exit criteria:**
- Runs cleanly against current renderer state (all snapshots match).
- Returns ≤200ms per snapshot.
- Skip with a clear message when no signed-in host config available
(most snapshots will be of post-login surfaces).
## Recommended order
1. **Phase 1 (tooling)** — ~2 hours, single agent. Foundation for
everything else.
2. **Phase 2 (UI map doc)** — ~30 min, single agent. Cheap,
self-documenting.
3. **Phase 3 (page objects)** — incremental, per test need.
4. **Phase 4 (atom promotion)** — opportunistic, no scheduled work.
5. **Phase 5 (drift detection)** — once Phase 1 is done and a few
snapshots exist.
Phases 1 and 2 are independent and can run in parallel.
## Today's starting state (reference)
What's already in place as of session-end:
```
tools/test-harness/
├── probe.ts # one-off probe (Phase 1 seed)
├── src/
│ ├── lib/
│ │ ├── claudeai.ts # CodeTab + atoms (NEW today)
│ │ ├── electron.ts # SIGINT cleanup, lastExitInfo
│ │ ├── inspector.ts # idempotent close()
│ │ ├── quickentry.ts # disk-read getStoredPosition
│ │ └── ... (unchanged)
│ └── runners/
│ ├── H01_cdp_gate_canary.spec.ts # NEW
│ ├── H02_frame_fix_wrapper_present.spec.ts # NEW
│ ├── H03_patch_fingerprints.spec.ts # NEW
│ ├── H04_cowork_daemon_lifecycle.spec.ts # NEW
│ ├── T17_folder_picker.spec.ts # refactored to lib/claudeai.ts
│ ├── _investigate_t17_urls.spec.ts # one-off, can be deleted
│ └── ... (T01/T03/T04, S09/S12, S29-S37)
├── orchestrator/sweep.sh # multi-suite JUnit parser
└── playwright.config.ts # CI-gated retries + forbidOnly
```
**Pending cleanup** (covered in a final commit, not part of this plan):
- Delete `_investigate_t17_urls.spec.ts` — investigation served.
- Delete `probe.ts` once `explore/` lands and supersedes it.
- Update `tools/test-harness/README.md` Status table — T17 from
"selector-tuning pending" to passing on KDE-W.
**Useful commands for a fresh session:**
```sh
cd /home/aaddrick/source/claude-desktop-debian/tools/test-harness
# Typecheck (must pass after every edit)
npx tsc --noEmit
# Run a single spec
ROW=KDE-W CLAUDE_TEST_USE_HOST_CONFIG=1 npx playwright test \
src/runners/T17_folder_picker.spec.ts --reporter=list
# Full sweep
ROW=KDE-W CLAUDE_TEST_USE_HOST_CONFIG=1 ./orchestrator/sweep.sh
# Probe a running app (requires main process debugger enabled)
npx tsx probe.ts
# Kill stale instances before launch
pkill -9 -f claude-desktop; pkill -9 -f mount_claude
```
**Before starting Phase 1:** open Claude Desktop, enable
`Developer → Enable Main Process Debugger` from the menu, navigate
to a known UI state. Then run `npx tsx probe.ts` to confirm the
inspector is reachable on port 9229.

View File

@@ -1,490 +0,0 @@
# Fingerprint v7 Plan — Contextual, Account-Portable Identification
This is an executable plan for the v6 → v7 migration of the inventory
fingerprint shape used by `tools/test-harness/explore/walker.ts` and
`tools/test-harness/src/runners/U01_ui_visibility.spec.ts`. It can be
picked up by a fresh session — start at "Phase 1" and walk down.
## Where we are
`docs/testing/ui-inventory.json` v6 (captured 2026-05-03 against app
1.5354.0, 383 entries) records each interactive element with a
fingerprint of this shape:
```ts
fingerprint: {
selector: 'button[aria-label="Search"]',
ariaLabel: 'Search',
role: null,
tagName: 'BUTTON',
textContent: null,
}
```
`U01` resolves entries by handing the `selector` field to Playwright.
The current scheme has three load-bearing failure modes:
1. **Account-specific names baked into selectors and IDs.** Entries
like `root.button.awaaddrick-max` (the user's plan badge,
`button:has-text("AWAaddrick·Max")`) hardcode the walker-author's
username + plan tier. Any contributor running U01 against their
own auth fails this entry on selector match — the element is
structurally present, just labeled differently.
2. **Instance text in selectors of "stable" entries.** Search-result
options, recent-conversations buttons, and pinned conversations
carry titles like "Fine-tuning diffusion models with reinforcement
learning" in their selectors. These are inherently per-account; the
`kind: instance` taxonomy already exists to handle them, but the
selector still encodes the literal title, so the v6 capture
couldn't actually leverage `instance` semantics.
3. **Selector brittleness under cosmetic redesigns.** `button:has-text(...)`
selectors break under any label change. `button[aria-label="..."]`
selectors break under any aria-label rewrite (which the upstream
team does for accessibility audits without warning). Neither
strategy carries enough redundancy to recover when one signal drifts.
The reconciliation doc (`ui-inventory-reconciliation.md`) flags these
as "Walker coverage gap" and "Account-state-dependent" categories,
and the U01 brief lists per-user inventory regeneration as "a
separate workstream." This is that workstream.
## Design goals
In priority order:
1. **Account-portable.** A v7 inventory walked against User A's
account matches against User B's renderer for any entry whose
target element is structurally present in both accounts. Entries
that genuinely don't exist in B's account fall back to the existing
"skip if absent" semantics (`kind: instance` + ancestor-presence
check).
2. **Resilient to cosmetic drift.** Label changes, aria-label
rewrites, minified-class churn, and CSS rewrites must not
invalidate the fingerprint when the element's semantic role and
structural position survive.
3. **Surface drift before failure.** Soft drift (primary aria-path
missed, relaxed-scope match recovered) attaches a warning to the
test rather than passing silently. Hard drift (no strategy
resolves) fails as today. The sweep gains a third state:
`passed-with-drift`.
4. **Atomic cutover, not gradual migration.** v7 walker, v7 inventory
schema, and v7 resolver land together. The committed v6 inventory
gets invalidated the moment v7 walker ships; no parallel-emit
compatibility window, no `legacy` selector fallback in the
resolver. Two systems are worse than one.
Non-goals:
- Pixel-level visual diff. Separate concern; H05 is the right shape.
- AI / embedding-based matching. Out of scope for a Linux repackager.
- Behavioral fingerprints (click-and-verify-effect). Too expensive at
383 entries.
## v7 schema
```ts
interface FingerprintV7 {
// Primary: accessibility-tree path from nearest landmark down to
// the leaf. Each step carries (role, optional name).
ariaPath: AriaStep[];
// The element itself. Drops `name` entirely when role + ariaPath
// suffice for uniqueness on the captured surface.
leaf: {
role: string; // "button", "link", "menuitem", ...
name: NameMatcher | null;
siblingIndex: SiblingIndex | null;
};
// Stability classification — drives how strictly the resolver
// matches. See "Kind-strictness matrix" below. Distinct from the
// existing `kind` field (persistent / structural / menu / instance)
// which captures *lifecycle*, not *match strictness*.
classification: 'stable' | 'positional' | 'instance';
}
interface AriaStep {
role: string; // landmark / region / grouping role
name: NameMatcher | null; // optional — only included when needed
}
type NameMatcher =
| { kind: 'literal'; value: string } // "Search", "Cowork"
| { kind: 'pattern'; regex: string }; // "\\w+·(Free|Pro|Max|...)"
interface SiblingIndex {
role: string; // role of siblings being indexed
position: number; // 0-based
total: number; // total siblings of that role at capture
}
```
## Capture algorithm
Run during walker.ts's element emission, after the surface has settled.
```text
captureFingerprint(element, surface):
ariaPath = walkLandmarkAncestors(element)
// Stop at <body>; emit a step for each role in
// {banner, main, navigation, region, complementary,
// contentinfo, search, form, toolbar, menu, menubar,
// listbox, list, dialog, tablist, tabpanel, group}
// with grouping role plus optional accessible name.
role = element.role
name = element.accessibleName
// Step 1: try uniqueness without the name.
matches = surface.queryAccessibleTree({
ariaPath,
leaf: { role }
})
if matches.length == 1:
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
classification: 'stable' }
// Step 2: still too broad — try the name as a discriminator,
// shaping it if it looks instance-specific.
classification = classifyName(name, surface)
if classification != 'instance':
nameMatcher = (classification == 'positional')
? null
: (looksInstanceShaped(name)
? { kind: 'pattern', regex: shapeOfName(name) }
: { kind: 'literal', value: name })
matches = surface.queryAccessibleTree({
ariaPath, leaf: { role, name: nameMatcher }
})
if matches.length == 1:
return { ariaPath, leaf: { role, name: nameMatcher,
siblingIndex: null },
classification }
// Step 3: still ambiguous — fall through to sibling position.
siblings = element.parent.childrenWithRole(role)
if siblings.length > 1:
siblingIndex = {
role,
position: siblings.indexOf(element),
total: siblings.length
}
return { ariaPath, leaf: { role, name: null, siblingIndex },
classification: 'positional' }
// Step 4: instance — assert ≥1 match within ariaPath.
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
classification: 'instance' }
```
`queryAccessibleTree` should hit `Accessibility.getFullAXTree` over
CDP, not the DOM. The accessibility tree is what screen readers see
and what the platform APIs query — it's the substrate that aria
roles and accessible names actually live in.
## Name classifier
`classifyName(name, surface)` decides whether a name is `stable`,
`instance`, or `positional` (no usable name). Heuristics in priority
order:
```text
1. Empty / whitespace name → 'positional'
2. Element is a list-row child → 'instance' (handled by ancestor
role: option/listitem inside listbox/list)
3. Name matches a known
instance-shape regex → 'instance' (record as pattern)
4. Name is in the corpus of
"stable UI vocabulary" → 'stable'
5. Default → 'stable' but flag for review
```
### Known instance-shape regexes
| Regex | Example match | Shape recorded |
|---|---|---|
| `/^.+·(Free\|Pro\|Max\|Team\|Enterprise)$/` | `AWAaddrick·Max` | `\\w+·<PLAN>` |
| `/^Opus \d/` `/^Sonnet \d/` `/^Haiku \d/` | `Opus 4.7Adaptive` | model-name passthrough (stable across users, just versioned) |
| `/\d{1,3}%$/` | `Usage: plan 11%` | `Usage: plan \d+%` |
| `/Today\|Yesterday\|\d+ (day\|hour\|minute)s? ago/` | `Today+12` | `<RELATIVE-DATE>(\\+\d+)?` |
| `/^\d+\.\d+ \w+/` | `1.5 GB` | `\d+\.\d+ \w+` |
| `/@\w+/` | `@aaddrick` | `@\w+` (treat as user-handle) |
| `/[A-Z][a-z]+ [A-Z][a-z]+ [a-z]/` (3+ word title-case) | `Fine-tuning diffusion models...` | treat as `'instance'`, no pattern |
These regexes live in a registry that's part of the v7 capture
config. Adding a new shape is a one-file change; the registry should
be ordered (first match wins) so specific patterns take precedence
over general ones.
### Building the stable UI vocabulary
After the walker finishes the BFS, run a second pass:
1. Collect every `accessibleName` from every captured element.
2. Bucket by `kind` (existing taxonomy).
3. Names appearing in 3+ entries with `kind: persistent` or
`kind: structural`, across 2+ surfaces, are **stable**.
4. Names appearing in only 1 entry with `kind: persistent`/`structural`
are **suspect** — flag for human triage during reconciliation.
5. Names in `kind: instance` entries are excluded from the corpus
entirely.
Commit the resulting vocabulary list to
`docs/testing/ui-vocabulary.json` so future walks can use it without
re-deriving. Refresh the vocabulary on each major upstream release.
## Kind-strictness matrix
The existing `kind` field (`persistent` / `structural` / `menu` /
`instance`) tunes how strictly the resolver matches at runtime,
independently from the capture-time `classification`:
| kind | aria-path required | name required | siblingIndex strict | assertion |
|---|---|---|---|---|
| `persistent` | yes (deepest scope) | matcher must hit if present | yes | exactly 1 match |
| `structural` | yes (or 1 step shallower) | matcher OR position | flexible (±1 ok) | exactly 1 match |
| `menu` | yes, scoped to transient menu surface | literal text fallback ok | n/a | ≥1 match |
| `instance` | yes (closest list/listbox ancestor) | ignored | ignored | ≥1 match within scope |
Examples:
- `root.button.search``kind: persistent`, `classification: stable`,
`name: null` (unique by ariaPath alone). Strict 1-match assertion.
- `root.button.awaaddrick-max``kind: persistent`, `classification: stable`,
`name: { kind: 'pattern', regex: '\\w+·(Free|Pro|Max|...)' }`.
Plan-shape pattern; user-portable.
- `root.button.search.option.untitled-conversationtoday+12`
`kind: instance`, `classification: instance`, no name, scoped to
search-results listbox. Assert ≥1 option in listbox.
- `root.button.fine-tuning-diffusion-models-with-reinforcement-learning`
`kind: instance`, scoped to pinned-conversations list. Assert ≥1
button in pinned list.
## Resolver / fallback chain
In `findByFingerprint`:
```text
resolve(fp):
// Strategy 1 — primary: full aria-tree path
result = tryAriaTreeMatch(fp.ariaPath, fp.leaf, fp.kind)
if result.matched: return { found: true, strategy: 'aria-tree' }
// Strategy 2 — relaxed aria scope (drop deepest landmark step
// in the path; keep the rest). Catches the common case where the
// upstream team adds or removes one container layer.
if fp.ariaPath.length > 1:
result = tryAriaTreeMatch(fp.ariaPath.slice(0, -1), fp.leaf, fp.kind)
if result.matched: return {
found: true, strategy: 'aria-tree-relaxed', drift: 'scope-shifted'
}
return { found: false, strategy: null }
```
When `drift` is set, attach a soft warning to the Playwright test
without failing it:
```ts
testInfo.attach('drift-warning', {
body: JSON.stringify({
entryId: entry.id,
expected: fp.ariaPath,
matchedVia: result.strategy,
drift: result.drift,
note: 'primary aria-tree match failed; recovered via fallback. ' +
'Re-walk inventory before drift compounds.',
}, null, 2),
contentType: 'application/json',
});
```
CI exposes `drift-warning` as a separate counter alongside pass /
fail. Sweep summary becomes `383 passed, 12 with drift, 0 failed`.
## Migration plan
The cutover is atomic — no parallel-emit window. Walker, schema, and
resolver all flip from v6 to v7 in the same merge. The committed v6
inventory becomes invalid; first action after merge is a re-walk.
### Phase 1 — vocabulary scaffold (pre-walker)
The name classifier needs a stable-UI vocabulary corpus to
disambiguate suspect names from known-stable copy. Build it from the
existing v6 inventory before the walker rewrite:
1. Iterate `docs/testing/ui-inventory.json` v6.
2. Names appearing in 3+ entries with `kind: persistent` or
`kind: structural`, across 2+ surfaces, are **stable**.
3. Names matching any registry regex (plan badge, model version,
percentage, relative date, user handle) are **instance-shaped**.
4. Names appearing in only 1 entry, not matching a regex, not in
`kind: instance` — flag for human triage.
5. Commit the resulting corpus to `docs/testing/ui-vocabulary.json`.
The corpus survives the walker rewrite — it's keyed on names, not on
v6 schema specifics.
### Phase 2 — walker rewrite
1. Add `Accessibility.getFullAXTree` query to walker's surface-settle
step (or AX subtree at target node if full-tree latency is
unacceptable; see open questions).
2. Implement `walkLandmarkAncestors`, `queryAccessibleTree`,
`captureFingerprint` per the algorithm above.
3. Implement the name classifier consuming `ui-vocabulary.json` and
the instance-shape registry.
4. Replace v6 fingerprint emit with v7. Inventory schema header bumps
to `walkerVersion: 7`; v6 readers will fail loudly rather than
silently mis-resolve.
5. Walker passes that fail to compute a v7 fingerprint (AX query
error, accessible-name-computation failure) emit the entry with
`classification: 'positional'` and `name: null`, scoped to its
ariaPath. Uncaptured fingerprints are not silently dropped — they
become positional entries with explicit looseness.
Acceptance: a walk against the v6-author's account produces v7
fingerprints for ≥98% of the surfaces v6 captured. ≥80% have
`classification: 'stable'`; the rest split between `'positional'` and
`'instance'`.
#### Live-walk shakedown (post-Phase 2)
The first end-to-end walks against the running renderer surfaced five
real bugs the synthetic selfTest couldn't see. All landed in
`walker.ts` / `name-classifier.ts` / `inspector.ts`:
1. **AX-tree settle gate.** `Accessibility.enable` populates the tree
asynchronously; the existing `waitForStable` (1.5s ceiling on
DOM-mutation quiescence) returned long before claude.ai's React
tree mounted. Seed snapshots came back with 4 AX nodes (just the
`RootWebArea` + a generic shell) and the walker emitted zero
entries. Fix: `waitForAxTreeStable(inspector, { minNodes: 20 })`
polls `getFullAXTree` until two consecutive reads return the same
node count. Called once before the seed snapshot and once after
each `navigateTo` in `redrivePath`. Baked into every
`snapshotSurface` call too (with `minNodes: 1`) so post-click
reads don't race the React update.
2. **`reloadPage` in `redrivePath`.** `navigateTo(url)` short-circuits
when `currentUrl === url`, but every BFS pop re-navigates to
`startUrl`, so any state a prior drill left behind (open dialog,
expanded sidebar, scrolled focus) carried into the next redrive
and contaminated `clickById`'s snapshot. Replaced the redrive's
initial `navigateTo` with `location.reload()` to discard the
React tree.
3. **List-row sibling-count heuristic.** The plan's `isListRowChild`
check requires `option/listitem` inside `listbox/list`. claude.ai
exposes the marketplace dialog as `dialog > button[]` with no
list role at all (~80 cards) and the cowork sidebar as
`complementary > button[]` (72 sessions). Without a heuristic,
each row literal-matches by name and emits as a separate stable
entry. Extension: `LIST_ROW_ROLES` includes `button`,
`LIST_ANCESTOR_ROLES` includes `group`, AND `siblingTotal >= 15`
on its own qualifies regardless of ancestor role. Step 3
(positional fallback) also gates on `!isListRowChild` so list
rows fall through to step 4's `instance` collapse instead of
fragmenting into per-index positionals.
4. **Two new instance shapes** in `name-classifier.ts`:
`cowork-session` matches status-prefixed session titles
(`^(Idle|Ready|Working|Awaiting input|Pull request merged|Done|Failed|Cancelled)\s`)
and `row-more-options` matches per-row triggers
(`^More options for `). Both ordered before `long-title` so the
pattern wins over the no-pattern instance fallback.
5. **Lookup-failure threshold bump** 25 → 75. Sidebar virtualization
means the AX tree exposes a slightly different subset of cowork
sessions on each fresh load; redrives accumulate
"no element matches" misses in a row that aren't a real wedge.
The timeout counter (5 strikes) still gates against actual
renderer hangs.
Result on the AX migration's first clean walk
(`startUrl: claude.ai/epitaxy`, account: aaddrick, app 1.5354.0):
**90 entries** (37 persistent / 37 structural / 8 dialog / 8
instance), 6 denylisted, 23 non-fatal lookup misses. The marketplace
dialog folded to a single `button-instance+704`; the cowork sidebar
to `button-instance+72`; search history to `option-instance+25`.
Acceptance criteria from §Phase 2 met (≥98% structural overlap is
trivially true on a re-walk; ≥80% stable hit at 75/90 ≈ 83%).
### Phase 3 — resolver rewrite (U01 + walker.ts findByFingerprint)
1. Replace `findByFingerprint` body with the two-strategy chain
(primary aria-tree, relaxed-scope fallback). Drop the v6
selector code path entirely.
2. `gen-render-specs.ts` regenerates U01 from the v7 inventory; per-
entry test bodies consume `entry.fingerprint` (now v7-shaped)
directly.
3. Add the `drift-warning` attachment shape to U01's test runner.
4. Run U01 against the v7 inventory captured in Phase 2; baseline
drift counts.
Acceptance: U01 against a fresh walker pass produces 0 drift
warnings on the same account, fails 0 entries. Drift warnings only
appear when actually-drifted elements are encountered.
### Phase 4 — account-portability validation
1. A second contributor walks their own v7 inventory.
2. Diff against the v6-author's v7 inventory: structural overlap
should be ≥80% on `kind: persistent` and `kind: structural`
entries (the cross-user-stable subset).
3. Run the v6-author's inventory's U01 against the second
contributor's renderer (with `seedFromHost` lifting their auth).
4. Expect ≥80% pass on the cross-user-stable subset; `kind: instance`
entries pass via the ancestor-presence check.
This is the actual goal. If account-portability hits, the inventory
is no longer a "my-account snapshot" but a true render contract.
## Open questions
### Resolved
- **CDP `Accessibility.getFullAXTree` cost.** Not a bottleneck. The
signed-in `claude.ai/epitaxy` surface returns a 817-node tree;
`waitForAxTreeStable` settles in <1s once Chromium has populated
it. The cold-load gate dominates total latency, not per-call
overhead. Plan B (subtree queries at the target node) is unused.
- **Role overrides.** Confirmed working. `Skip to content` on
claude.ai is captured as `link` (its AX-computed role) regardless
of the underlying tag — a class of mismatch the v6 DOM walker
silently got wrong.
- **`account-bound` kind.** Not needed. The combination of
shape-patterned name matchers (plan badge, cowork session) +
the sibling-count list heuristic + persistent collapse handles
every account-shaped element observed in the first clean walk.
Re-evaluate if a future surface exposes account state without
one of those signals.
### Open
- **Accessible-name computation parity.** Chrome's AX-tree-computed
name should match what Playwright's `getByRole({ name })` matches
at resolution time, but they're independent implementations of
the ARIA name-computation spec. Validate at Phase 3 acceptance
with a sample of 50 entries — capture vs resolve should agree.
- **Stale vocabulary across releases.** When upstream renames
"Cowork" to "Workspaces" (hypothetical), the corpus needs to
update. Should vocabulary be re-derived automatically on each walk
(cheap, drift-following) or pinned to a committed version (stable,
manual updates)? Provisionally: re-derive on walk, commit the
derived corpus alongside the inventory so reconciliation can diff
vocabulary changes.
## Cross-references
- `tools/test-harness/explore/walker.ts` — capture site
- `tools/test-harness/explore/walk-isolated.ts` — driver that runs
the walk inside the test-harness `launchClaude` + `seedFromHost`
isolation path (use this rather than `explore walk` to avoid
mutating the host profile)
- `tools/test-harness/explore/gen-render-specs.ts` — emits U01 from
inventory; needs to consume v7 fingerprints
- `tools/test-harness/src/runners/U01_ui_visibility.spec.ts`
resolver consumer
- `tools/test-harness/src/lib/inspector.ts``getAccessibleTree`
+ `clickByBackendNodeId` for the AX-driven capture/click pair
- `docs/testing/ui-inventory-reconciliation.md` — current v6 reconciliation
- `docs/testing/claudeai-ui-mapping-plan.md` — broader UI mapping
strategy this fits inside

View File

@@ -50,14 +50,6 @@ Status legend: `✓` pass · `✗` fail · `🔧` mitigated · `?` untested · `
| [T38](./cases/code-tab-handoff.md#t38--continue-in-ide) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
| [T39](./cases/code-tab-handoff.md#t39--desktop-cli-handoff-graceful-na) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
## UI visibility (U-series)
Auto-generated render attestation: each entry in [`ui-inventory.json`](./ui-inventory.json) is asserted to mount with its recorded fingerprint on each platform. The single matrix cell aggregates every inventory entry — pass means every entry rendered, fail means at least one didn't (per-entry diagnostics in the JUnit attachments). Regenerate the spec with `npm run gen:render-specs` after re-walking. See [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) for the discovery + walker design.
| Test | KDE-W | KDE-X | GNOME | Ubu | Sway | i3 | Niri | Hypr-O | Hypr-N |
|------|-------|-------|-------|-----|------|----|------|--------|--------|
| [U01](../tools/test-harness/src/runners/U01_ui_visibility.spec.ts) — UI visibility | ? | ? | ? | ? | ? | ? | ? | ? | ? |
## Environment-specific status
### Ubuntu / DEB

View File

@@ -1,16 +1,11 @@
# Quick Entry Closeout Test Plan
# Quick Entry — Upstream Contract + Test Index
Focused sweep plan for closing the three open Quick Entry issues:
Reference doc for the Quick Entry surface. Two halves:
- [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393) — Submit doesn't open the main window (Ubuntu 24.04 GNOME and friends). Mitigated by [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)'s KDE-only gate; root cause is `BrowserWindow.isFocused()` returning stale-true on Linux Electron.
- [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — Shortcut doesn't fire from unfocused state on Fedora 43 GNOME. mutter no longer honours XWayland-side key grabs. Fix path: wire `--enable-features=GlobalShortcutsPortal` into the launcher on GNOME Wayland.
- [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370) — Opaque square frame behind the transparent Quick Entry popup on KDE Wayland. Bisected to Electron 41.0.4 (electron/electron#50213); upstream regression. Workarounds in `frame-fix-wrapper.js` not yet attempted.
- [§ Upstream design intent](#upstream-design-intent) documents what upstream Quick Entry promises vs. doesn't, with code anchors into `build-reference/app-extracted/.vite/build/index.js`. Treat as the authoritative answer when triaging whether a Quick Entry behavior is a Linux compat regression (our problem) or upstream-by-design (not our problem).
- [§ Test list](#test-list) enumerates the QE-N items as conceptual checks and maps each to the concrete S-N / T-N case that backs it. Spec headnotes (S09, S12, S31, S37) cite specific QE-N IDs by anchor; [§ Scaffold integration](#scaffold-integration) is the authoritative QE-N → S-N table.
This doc is a **sweep plan**, not a test catalog. Test bodies and diagnostics live in [`cases/`](./cases/); the live status dashboard lives in [`matrix.md`](./matrix.md). The 21 `QE-*` items below map to existing `T*` / `S*` IDs where possible, and call out gaps to add as new `S*` cases.
## Goal
Pass all `QE-*` items in [§ Test list](#test-list) on every row in [§ Mandatory matrix](#mandatory-matrix). When that holds, all three issues are closeable (or, for #370, demonstrably blocked on upstream Electron with reproducible evidence).
The QE-N items originated in the close-out sweep for [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), and [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370). The sweep has run; what remains is the upstream-contract reference + the test-index mapping.
## Upstream design intent
@@ -58,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
@@ -77,9 +72,9 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
| ID | Severity | Step | Expected | Existing |
|----|----------|------|----------|----------|
| QE-14 | Should | Inspect popup background | Transparent; no opaque square frame visible behind the rounded UI. **Note:** upstream already sets `transparent: true` and `backgroundColor: "#00000000"` (`:515380, :515383`), so the #370 triage-bot suggestion to "try setting backgroundColor to transparent" is moot — those are already in place. The Electron 41.0.4 regression is at the CSD/shadow rendering layer below those flags, not at the option-passing layer. | [S10](./cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | |
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | |
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | |
| QE-18 | Should | `electron --version` against the running app's bundled binary; record version in matrix | When > 41.0.4 ships and #370 still reproduces, the upstream-regression hypothesis is wrong | [S33](./cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version) |
### Patch-application sanity — regression prevention
@@ -92,7 +87,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
| ID | Severity | Step | Expected | Existing |
|----|----------|------|----------|----------|
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | [`ui/quick-entry.md`](./ui/quick-entry.md) |
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | |
### Popup placement & lifecycle — upstream contract sanity
@@ -104,91 +99,9 @@ These verify upstream-promised behaviors that aren't directly broken by #393/#40
| QE-23 | Smoke | **Multi-monitor required.** With an external monitor connected, invoke Quick Entry on the external monitor — let the position be saved (trigger QE-22's persistence path). Disconnect the external monitor (libvirt: `virsh detach-device` for the second display, or unplug the host monitor passing through). Invoke Quick Entry. | Popup falls back to the primary display via `cHn()` (`:515502`). Does **not** appear at off-screen coordinates. Skip this row in single-monitor VMs. | [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) |
| QE-24 | Should | Launch app, focus main window, then **destroy** the main window without quitting the app. On this project the X button hide-to-tray override means the standard close path won't destroy `ut`; force the destroy via a) DevTools console (`Cmd+Opt+I` / `Ctrl+Shift+I``require('electron').remote.getCurrentWindow().destroy()` if exposed), or b) accept that this case is unreachable on Linux without a code change and skip. After destroy, invoke Quick Entry, type, submit. | Popup remains functional (lazy-recreation on shortcut press; the `!ut \|\| ut.isDestroyed()` guard at `:515595` skips the show/focus block but does not crash). New chat creation may not have a window to surface in — if app remains running with no main window, this is the "popup outlives main" path upstream guarantees. **If unreachable on Linux, mark this row N/A and document why.** | [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) |
## Mandatory matrix
The five rows below are the must-pass set to close all three issues. Display server is the **session selected at login** — KDE and GNOME both let you choose Wayland vs Xorg from the greeter.
| Row | Distro | DE | Display server | Closes / verifies | Reporter |
|-----|--------|----|--------------:|-------------------|----------|
| **GNOME-W** | Fedora 43 Workstation | GNOME 49.x | Wayland | #404 (S11/S12), #393 (QE-11/QE-12) | @gianluca-peri (#404), @Andrej730 (#393 root cause) |
| **Ubu-W** | Ubuntu 24.04 LTS | GNOME (Ubuntu) | Wayland | #393 close-out (post-#406 gate). Also catches the `XDG_CURRENT_DESKTOP=ubuntu:GNOME` quirk (S02) | @Andrej730 |
| **KDE-W** | Fedora 43 KDE *or* Nobara 43 KDE | Plasma 6 | Wayland | #370 (S10), QE-19 patch sanity, daily-driver regression baseline | @noctuum (#370), aaddrick |
| **GNOME-X** | Ubuntu 24.04 (GNOME on Xorg session at greeter) | GNOME | Xorg | Differentiates whether #404 is mutter-as-compositor or mutter-XWayland-grabs specifically. **Note:** Fedora 43 GNOME may not ship an X11 session anymore (GNOME 49 deprecation); use Ubuntu's GNOME-on-Xorg session instead. | — |
| **KDE-X** | Fedora 43 KDE (Plasma X11 session at greeter) | Plasma 6 | Xorg | Catches kwin-X11 specifics; regression baseline for the historic working path | — |
## Strongly recommended
Catches generalization gaps but not blocking close-out.
| Row | Distro | DE | Display server | Why |
|-----|--------|----|--------------:|------|
| **COSMIC** | popOS 24.04 (COSMIC alpha) | COSMIC | Wayland | @davidsmorais reported #393 there; not covered by KDE or GNOME branches |
| **Ubu-X** | Ubuntu 24.04 (GNOME on Xorg) | GNOME | Xorg | Already counted under GNOME-X above. Listed here too because the Ubuntu install base is large — counts as its own row in the dashboard |
## Optional
Tracked under different bugs ([S06](./cases/shortcuts-and-input.md#s06--url-handler-doesnt-segfault-on-native-wayland), [S14](./cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) — skip unless closing those in the same sweep.
| Row | DE | Tracked under |
|-----|----|--------------:|
| Sway | wlroots | S06 |
| Niri | wlroots | S14 |
| Hypr-N (Omarchy) | wlroots | per @typedrat |
| Hypr-O | Hyprland Xorg | per @typedrat |
| i3 | Xorg | matrix |
## VM inventory
Existing host: `~/vms/` (libvirt, qcow2 images on a separate root-owned dir). Per-VM creation scripts in `~/vms/scripts/`. Per-VM test protocol in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md).
### Have
| Row | VM image | Status |
|-----|----------|--------|
| GNOME-W | `claude-fedora43-gnome.qcow2` | Ready |
| Ubu-W | `claude-ubuntu-2404.qcow2` | Ready |
| KDE-W | `claude-fedora43-kde.qcow2` | Ready (Nobara KDE on the bare-metal host is the alternative) |
| GNOME-X | `claude-ubuntu-2404.qcow2` | Ready (use the GNOME-on-Xorg session at the greeter — same VM as Ubu-W) |
| KDE-X | `claude-fedora43-kde.qcow2` | Ready (use the Plasma X11 session at the greeter — same VM as KDE-W) |
### Need to add for full mandatory + recommended coverage
| Row | What | Why |
|-----|------|-----|
| **COSMIC** | popOS 24.04 (COSMIC alpha) ISO + `~/vms/scripts/create-popos-cosmic.sh` | Davidsmorais's #393 environment; otherwise unrepresented |
### Need to add only if closing optional rows in the same sweep
| Row | What | Use existing | Why |
|-----|------|--------------|-----|
| Niri | Fedora-Niri-Live ISO + `~/vms/scripts/create-fedora-niri.sh` | — | S14 (`BindShortcuts` error 5) |
| Hypr-N | Possibly already covered by `claude-omarchy` | `claude-omarchy.qcow2` | Omarchy is a Hypr-N variant; may not exercise stock Hyprland |
| Sway | `claude-fedora43-sway.qcow2` | Existing | S06 URL handler segfault |
| i3 | `claude-fedora43-i3.qcow2` | Existing | Coverage only |
## Minimum viable kill-set
If the goal is the smallest pass that justifies closing all three issues:
- **GNOME-W** — must pass QE-2/3/4/6/7/8/9/11 → closes #404, half of #393.
- **Ubu-W** — must pass QE-7/8/9/11 → closes other half of #393.
- **KDE-W** — must pass QE-7/8/9 + QE-14 + QE-19 → closes #370 (or punts upstream with QE-18 evidence) and confirms the gated patch path still works.
(QE-20 has been folded into QE-19 — the patch ships in every build, so a single bundled-JS check covers both KDE and non-KDE rows.)
Three VMs, ~21 items per row, one full sweep ≈ 90 minutes if the visual checks are batched.
## Per-row pass criteria
| Issue | Closeable when |
|-------|----------------|
| #393 | QE-7 through QE-12 pass on **GNOME-W**, **Ubu-W**, and **KDE-W**. QE-19 confirms the patch was applied at build (KDE gate string present). If QE-11 fails on GNOME-W, the KDE-only gate is preserved as a permanent fix; otherwise the patch can be widened. |
| #404 | QE-2 and QE-3 pass on **GNOME-W**. QE-6 confirms the launcher actually appended `--enable-features=GlobalShortcutsPortal` on GNOME Wayland (S12). |
| #370 | QE-14 passes on **KDE-W**. **OR** QE-18 records an Electron version > 41.0.4 in the bundled binary and QE-14 still fails — at that point the upstream-regression hypothesis is wrong and we re-investigate. |
## Scaffold integration
This sweep is fully wired into the existing test scaffold. The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
| Case | Title | Backs |
|------|-------|-------|
@@ -202,24 +115,4 @@ This sweep is fully wired into the existing test scaffold. The `QE-*` items in [
| [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) | Popup falls back to primary display when saved monitor is gone | QE-23 |
| [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) | Popup remains functional after main window destroy | QE-24 |
UI-element-level checks for QE-14 through QE-17 and QE-21 live in [`ui/quick-entry.md`](./ui/quick-entry.md), which has been refined against the upstream evidence captured in [§ Upstream design intent](#upstream-design-intent).
(QE-13, QE-21 don't need their own S-IDs — they're documentation items / already covered by `ui/quick-entry.md`.)
## Sweep mechanics
Per-row procedure (one full pass):
1. Boot VM. Confirm session at greeter matches the row (Wayland vs Xorg, correct DE).
2. Install the latest build:
- DEB: `sudo apt install ./claude-desktop_*.deb`
- RPM: `sudo dnf install ./claude-desktop-*.rpm`
3. Capture environment baseline: `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP`, `gnome-shell --version` or `kwin --version`, `electron --version` (for QE-18).
4. Launch app. Wait for main window. Run QE-21 input smoke first to catch obvious breakage early.
5. Run shortcut tests (QE-1 → QE-6) in order. Each run, scrape `~/.cache/claude-desktop-debian/launcher.log` and `pgrep -af claude-desktop` argv.
6. Run submit tests (QE-7 → QE-13). For each window-state precondition, set the state, then trigger Quick Entry, then submit.
7. Run visual checks (QE-14 → QE-18). Screenshot QE-14 to attach to #370 if still failing.
8. Run patch sanity (QE-19 / QE-20).
9. Update [`matrix.md`](./matrix.md) status cells. Save logs under a row-tagged subdirectory: `~/vms/collected/<row>-<date>/`.
For the deeper #393 bisect (isolating which half of PR #390 regresses GNOME), see the two-variant build instructions in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md) — build a blur-only and a vis-only variant, run QE-7 through QE-11 on each on **Ubu-W** and **GNOME-W**, gate the offending half rather than the whole patch.
QE-13, QE-15, QE-16, QE-17, and QE-21 are visual / input checks with no formal S-ID — run them by eye against [§ Upstream design intent](#upstream-design-intent).

View File

@@ -2,7 +2,7 @@
*Last updated: 2026-05-03*
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/) and [`ui/`](./ui/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
## When to sweep
@@ -315,9 +315,6 @@ When a test drifts, edit Steps/Expected in place. When a feature is
gone from the build, prepend
`> **⚠ Missing in build X.Y.Z** — <note>. Re-verify after next
upstream bump.` under the test heading.
[`cases-grounding-prompt.md`](./cases-grounding-prompt.md) is the
fan-out prompt the last sweep used — paste verbatim into a fresh
session to repeat the workflow.
### Runtime pass

View File

@@ -1,238 +0,0 @@
# test-harness runner implementation — session 17 prompt
This file is meant to be **copied verbatim into a fresh Claude Code
session** as the initial user message. Don't paraphrase it; the
orchestration depends on the exact directives below.
> **ORCHESTRATION STOPPED AFTER SESSION 16.** This prompt is rotated
> for completeness only. **Session 17 will NOT run automatically** —
> the autonomous orchestration was halted at the end of session 16
> after coverage stalled at 74/76 (97%) for four consecutive sessions
> (13, 14, 15, 16). To resume, the user must manually trigger another
> orchestration run AND meet at least one of these preconditions:
>
> 1. **Real signed-in Claude Desktop running with `--inspect=9229`**
> on the dev box (debugger-attached, signed in, NOT a leaked test
> isolation). This unblocks Categories A (operon-mode probe) and
> B (Tier 3 read-only reframes that need auth-bearing renderer
> state).
> 2. **A real claude.ai account fixture for write-side state.** The
> remaining 2 specs (matrix coverage 74/76 → 76/76) need real
> write-side state (e.g. an installed plugin to exercise
> `LocalPlugins.listSkillFiles`, or a deep-linked deferred install
> intent for T11). The Tier 3 destructive constraint
> (`Don't run destructive Tier 3 write-side tests`) explicitly
> forbids the harness constructing this state itself.
> 3. **Renderer-drift event** that requires re-anchoring page-objects
> (e.g. claude.ai redesign breaks `findCompactPills`,
> `clickMenuItem`, etc.). Triggers a defensive-migration session.
> 4. **New IPC surface** added by upstream that the harness should
> cover (e.g. a new `claude.web` interface, a new eipc method
> that's case-doc-anchored).
>
> If none of those preconditions hold, the orchestration should NOT
> resume — further sessions will produce documentation-only or
> marginal output. The structural ceiling of the harness without
> real-account fixtures is 74/76 (97%); we're already there.
You're picking up after session 16 of the test-harness runner
implementation work. Session 16 was the final session of the
sessions-13-to-16 orchestration run and produced: T17 verification
(session-15 structural fix VERIFIED — bare 60s timeout gone, new
failure mode at `openFolderPicker` post-`selectLocal` classified as
renderer-state-dependent and deferred), schema-rev for
`listRemotePluginsPage` / `listSkillFiles` (both schemas resolved by
bundle inspection — neither shipped as a Tier 2 invocation because
`listRemotePluginsPage` is not anchored in any case doc, and
`listSkillFiles` needs Tier 3 destructive setup). NO coverage gain.
Plan-doc updated. Followup-prompt rotated with the STOP flag (this
document).
The plan doc at
[`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
captures the tier classification and execution-time reclassifications.
Its "Status (post-execution)" section is the source of truth for
what's done and what's deferred — read **session 16** first, then
**session 15**, **session 14**, **session 13**, **session 12**,
**session 11**, **session 10**, **session 9**, **session 8**,
**session 7**, **session 6**, **session 5**, **session 4**, **session
3**, **session 2**, then **session 1** sub-sections.
This session is a continuation, not a restart. Start by reading the
plan doc's status sections AND verifying at least one of the
preconditions above holds. If none hold, STOP and report; don't try
to fan out.
### Session 16 final findings (key context for any session-17 attempt)
1. **T17's session-15 structural fix VERIFIED.** Bare 60s timeout is
gone. `seedFromHost` clones the host's signed-in config,
`waitForReady('userLoaded')` resolves to a post-login URL
(`https://claude.ai/epitaxy` on the dev box), the dialog mock
installs, and `CodeTab.activate({ timeout: 15_000 })` (session 14
migration) succeeds first try.
2. **T17's NEW failure mode is renderer-state-dependent, not AX.**
After `selectLocal()` clicks the Local menuitem, the Select-folder
pill never appears within 4s. The URL during the run was
`/epitaxy` — the user's workspace route. The folder-picker UI
may only render on `/new` (or a fresh project), not on a workspace
already containing files. To unblock: navigate to `/new`
post-userLoaded BEFORE `openFolderPicker()`. NOT shipped session
16 — needs a careful navigation primitive that doesn't break
existing seedFromHost specs.
3. **`openPill` / `clickMenuItem` migration STILL parked.** Session
16's T17 trace confirmed the env-pill open + Local click both
succeeded, ruling out the AX-polling-loop hypothesis once and for
all. Don't migrate those speculatively.
4. **Schema-rev resolved both deferred validators.**
`CustomPlugins.listRemotePluginsPage(limit: number, offset:
number)`. `LocalPlugins.listSkillFiles(pluginId: string,
skillName: string, pluginContext?: opaque)`. Neither shipped as a
Tier 2 invocation: `listRemotePluginsPage` is not anchored in any
case doc; `listSkillFiles` needs Tier 3 destructive setup.
5. **Coverage stalled at 74/76 (97%) for 4 consecutive sessions.**
Sessions 13-16 net deliverables: 1 primitive, 1 AX migration, 1
structural fix, 1 verification + 1 schema-rev investigation.
Without real-account fixtures, the harness's structural ceiling
is 74/76. The remaining 2 specs need real-account write-side
state.
### What a future session 17 might attempt (only if preconditions hold)
If precondition 1 (real signed-in debugger-attached Claude) holds:
- **Operon-mode probe** (Category A from sessions 13-16). Run
`eipc-registry-probe.ts` against the user's Claude with operon mode
toggled on/off, capture the diff in registered channels. May
surface a new case-doc-coverable handler.
- **Schema-rev smoke-test** for the session-16-resolved schemas
against the live debugger. `listRemotePluginsPage(limit: 10,
offset: 0)` should return an array shape; `listSkillFiles('some-
installed-plugin', 'some-skill')` would test the LocalPlugins
handler's auth path.
If precondition 2 (real-account write-side fixture) holds:
- **T11 runtime invocation.** With an installed plugin in
`~/.claude/plugins/`, the post-install state can be probed via
`listSkillFiles` and the slash-menu skills would assert the
case-doc claim "skills appear in the slash menu" (T11 step 3).
- **T17 navigation fix.** Add a `/new` navigation primitive to
`claudeai.ts`'s `CodeTab` so `openFolderPicker` works on a fresh
project route. Verify T17 reaches the dialog mock fired assertion.
If precondition 3 or 4 holds:
- **Defensive page-object refactor.** Re-snapshot the AX tree at the
Customize panel and Plugin browser modal, refresh case-doc
inventory anchors, migrate any decayed selectors.
### Termination signal interpretation
If session 17 is triggered without any precondition met, the right
move is the same as session 16's STOP recommendation: write a one-
paragraph "preconditions not met, no work shipped" plan-doc update
and terminate. Don't burn a session on documentation-only output.
### Constraints to respect (unchanged from sessions 1-16)
- Use `seedFromHost: true` for any auth-required spec — never
`CLAUDE_TEST_USE_HOST_CONFIG=1` / `isolation: null` (legacy shape
removed in session 15).
- eipc handlers register on `webContents.ipc._invokeHandlers`, NOT
global `ipcMain._invokeHandlers`. Use `lib/eipc.ts`.
- For arg validator schema-rev: smoke-test first, fall back to
bundle-grep on the rejection literal.
- For AX-tree consumers: use `lib/ax.ts` (`snapshotAx` /
`waitForAxNode` / `waitForAxNodes`).
- For call-site migrations to `waitForAxNode`: keep per-spec retry
budgets matching existing tuning.
- `lib/input.ts` is X11-only. `lib/input-niri.ts` is Niri-only. CDP
auth gate is alive (runtime SIGUSR1 attach, never Playwright
`_electron.launch()`). BrowserWindow Proxy gotcha — use
`webContents.getAllWebContents()`. `skipUnlessRow()` always first.
- No fixed sleeps. `retryUntil` from `lib/retry.ts`, Playwright
auto-wait, or `waitForAxNode` from `lib/ax.ts`.
- Diagnostics on every run via `testInfo.attach()`. Tag with
`severity:` and `surface:` annotations.
- Tabs in TS, ~80-char wrap.
- Don't break existing runners. H01-H05 are the canaries.
- `npm run typecheck` must stay clean.
- Don't run destructive Tier 3 write-side tests.
### Authoritative reference
Read these in order before fanning out:
- [`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
— tier classification + status sections.
- [`tools/test-harness/README.md`](../../tools/test-harness/README.md)
— runner conventions, the 74-spec inventory, primitives in
`lib/`, isolation defaults.
- [`docs/testing/cases/README.md`](cases/README.md) — case-doc
structure and the four anchor scopes.
- [`tools/test-harness/src/lib/`](../../tools/test-harness/src/lib/)
— the existing primitives.
- [`tools/test-harness/src/runners/`](../../tools/test-harness/src/runners/)
— every existing spec is a template.
### Phase 0 — calibration (mandatory before fanning out)
1. `cd tools/test-harness && npm run typecheck` — should pass.
2. Check debugger ATTACHMENT QUALITY (not just port). `ss -tln |
grep ':9229'`. If port open, probe webContents via `evalInMain`:
```ts
import { InspectorClient } from './src/lib/inspector.js';
const client = await InspectorClient.connect(9229);
const wcs = await client.evalInMain<unknown>(`
const { webContents } = process.mainModule.require('electron');
return webContents.getAllWebContents().map((w) => ({
id: w.id, url: w.getURL(), title: w.getTitle(),
}));
`);
console.log(wcs); client.close();
```
If every URL is `/login` / `find_in_page` / `main_window`, treat
as soft-blocked for auth-required investigations.
3. Disambiguate running Claude processes. `pgrep -af
"ozone-platform=x11.*app.asar"`; for each, inspect cmdline for
`user-data-dir`. Real Claude has
`~/.config/Claude` (or no user-data-dir flag); leaked test
isolations have `/tmp/claude-test-*`.
4. **Verify at least one precondition for resuming the orchestration
holds.** If none hold, write a "no preconditions met" plan-doc
update and STOP. Don't fan out.
### Operational notes
- For the bundle-grep schema-rev pattern (sessions 9, 11, 12, 16
precedents):
```bash
cd tools/test-harness && node -e "
const {extractFile} = require('@electron/asar');
const buf = extractFile(
'/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar',
'.vite/build/index.js'
);
const s = buf.toString('utf8');
const idx = s.indexOf('<rejection-literal>');
console.log(s.slice(Math.max(0, idx - 1500), idx + 500));
"
```
- For seedFromHost specs: host MUST have a signed-in Claude.
`seedFromHost`'s host-claude-kill semantics will tear down any
running Claude process — flag clearly in the report before
invoking when the user's real Claude is running.
- For AX-tree polling: `lib/ax.ts`'s `waitForAxNode` /
`waitForAxNodes` for predicate-based polling.
- The eipc-registry probe (`tools/test-harness/eipc-registry-probe.ts`)
is the dedicated tool for inspecting per-wc IPC handler state.
Begin with Phase 0. Don't fan out until at least one of the
preconditions for resuming the orchestration is verified to hold.

File diff suppressed because it is too large Load Diff

View File

@@ -1,597 +0,0 @@
# claude.ai UI Inventory Reconciliation
*Generated against [`ui-inventory.json`](./ui-inventory.json) v6 (captured 2026-05-03, app version 1.5354.0, 383 entries).*
*Reconciled 2026-05-02.*
This file diffs the human-written claims in [`ui/`](./ui/) against the
machine-captured ground-truth in [`ui-inventory.json`](./ui-inventory.json).
It is one-shot output meant to drive human cleanup of `ui/*.md` — re-run
the reconciliation script (TODO: not yet built) after major walker passes.
## Reading this document
Three categories of finding per surface:
- **In docs but not in renderer** — the doc names an element that has no
corresponding inventory entry. Possible causes (don't read this as "doc
is wrong"; the walker covers a subset of reality):
- **OS / window-manager element** — title bar, close/min/max buttons,
drop shadow, resize edges. These are drawn by the compositor, not by
claude.ai's renderer; the walker can't see them.
- **Out of renderer scope** — tray menu, libnotify notifications, IME
composition popups, Quick Entry popup window. These are main-process
or DE-level surfaces that don't exist in the claude.ai DOM.
- **Walker coverage gap** — Settings overlay, dialogs, deep Code-tab
panes (terminal, file pane, diff). The walker drilled some surfaces
but not others; absence here is "not yet observed" not "not present."
- **Account-state-dependent** — features that don't appear on this
user's plan (e.g. SSH connections panel, managed-settings rows,
specific Code-tab pane types).
- **Speculative** — doc was written from upstream behavior, not from a
Linux build. May not actually render.
- **In renderer but not in docs** — inventory captured an element that no
doc row mentions. Either the doc is incomplete for that surface, or the
element is tangential (search-results recency rows, instance-suffix
duplicates with `#2`/`+5` markers).
- **Fingerprint potentially drifted** — doc and inventory agree on the
element but the doc's selector hint disagrees with the inventory's
`fingerprint.selector`. Most `ui/*.md` rows use prose ("Top-left of
topbar") rather than CSS selectors, so this category is small.
Human triage is what closes any of these. Don't auto-edit `ui/*.md`.
## Summary
| Metric | Count |
|--------|-------|
| Inventory entries (total) | 383 |
| Inventory entries by kind | persistent 65 / structural 276 / menu 33 / instance 9 |
| Inventory entries marked `denylisted: true` | 9 (Send×4, Install×4, Remove×1) |
| `ui/*.md` files reconciled | 11 (10 surface files + README) |
| `ui/*.md` rows reconciled (rough — multi-element rows complicate the count) | ~210 element rows across all 10 surface files |
| Rows with confirmed inventory match | ~70 (~33%) |
| Rows flagged "in docs but not in renderer" | ~140 (~67%) — heavily skewed by OS-frame, tray, notifications, deep Code panes, Settings, Quick Entry being out-of-renderer or under-walked |
| Inventory entries with no `ui/*.md` mention | ~190 (~50%) — heavily skewed by per-conversation/per-skill/per-prompt-card structural rows that the docs treat as categories rather than enumerating |
| Doc rows with explicit selectors that drift from inventory | 0 verified — `ui/*.md` rows almost never carry CSS selectors |
Match counts are approximate. `ui/*.md` rows often describe categories
("Recent conversations," "Per-history-entry hover") that map to many
inventory entries; the inventory in turn enumerates structural elements
the docs intentionally don't list (every project skill button, every
search result option). The reconciliation is a triage signal, not a
metric.
## Per-surface breakdown
### `ui/window-chrome-and-tabs.md`
**Inventory surfaces likely covered:** none directly — OS window frame is
drawn by the compositor; the in-app topbar elements live under `root` as
`root.button.menu`, `root.button.collapse-sidebar`, `root.button.search`,
`root.button.back`, `root.button.forward`. The "tab strip" maps to
`root.button.chat`, `root.button.cowork`, `root.button.code`.
**Doc rows reconciled:** ~22
#### In docs but not in renderer
| Doc element | Reason class |
|-------------|--------------|
| Title bar | OS / window-manager |
| Close button (X) | OS / window-manager |
| Minimize button | OS / window-manager |
| Maximize / restore button | OS / window-manager |
| Resize edges | OS / window-manager |
| Window menu (right-click titlebar) | OS / window-manager |
| Cowork ghost icon | Walker captures `root.button.cowork` (the tab) but not the ghost-icon visual within the topbar shim |
| Drag region (gaps between buttons) | Renders as empty space — not an actionable element |
| Active tab indicator | Visual styling, not an actionable element |
| Tab badges (unread / Dispatch) | None observed; user state at capture had no badges |
| About dialog | Walker did not surface a dialog; About is reachable only from app/tray menu, both out of renderer scope |
| App menu (macOS-style) | Doc itself notes this is N/A on Linux |
| Update prompt | Conditional, not present at capture |
| Crash report dialog | Conditional, not present at capture |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.menu` ("Menu", `aria-label="Menu"`) | This is the doc's "Hamburger menu" — renamed |
| `root.button.collapse-sidebar` ("Collapse sidebar") | Doc has "Sidebar toggle"; arguably the same |
| `root.button.search` ("Search") | Doc's "Search icon"; same |
| `root.button.back` / `root.button.forward` | Doc's back/forward arrows; same |
| `root.a.skip-to-content` ("Skip to content") | A11y skip link; not in doc |
| `root.button.new-chat-n` ("New chat⌘N") | Topbar new-chat button; not in doc |
| `root.button.pinned`, `root.button.recents`, `root.button.projects`, `root.button.artifacts`, `root.button.customize` | Sidebar nav buttons; doc covers some of these in `sidebar.md` not here |
| `root.button.awaaddrick-max` ("AWAaddrick·Max") | User/plan badge in topbar; not in doc |
| `root.button.get-apps-and-extensions` | Topbar shortcut to apps page; not in doc |
| `root.tab.write` / `root.tab.learn` / `root.tab.code` / `root.tab.from-calendar` / `root.tab.from-gmail` | Quick-prompt-template tabs in the prompt area; doc covers Write/Learn/Code as Chat/Cowork/Code tabs but the inventory's `root.tab.code` is distinct from `root.button.code` |
#### Fingerprint potentially drifted
None — doc rows for this surface use Location prose only.
#### Notable cross-cut
The doc's "Chat / Cowork / Code" tab strip maps cleanly to
`root.button.chat`, `root.button.cowork`, `root.button.code`. But the
inventory also has `root.tab.code` (a `[role="tab"]`, not a button) which
is a separate element — the prompt-area template strip — that the doc
conflates with the main Chat/Cowork/Code switcher. Worth a human note.
---
### `ui/tray.md`
**Inventory surfaces covered:** none — the tray is a main-process Electron
`Tray` object on the system SNI bus, not part of claude.ai's DOM.
**Doc rows reconciled:** ~17
#### In docs but not in renderer
Every row, by design. Categories:
- Tray icon (light / dark theme) — main-process `Tray.setImage()`
- Right-click menu items (Show/Hide, Quick Entry, Open at Login,
Settings, About, Quit) — main-process `Menu.buildFromTemplate()`
- Left-click / double-click / middle-click behaviors — main-process
event handlers
- Tooltip on hover, position, icon resolution, theme switch — SNI
daemon and DE behavior
This entire file is correctly out of renderer scope; the walker is doing
the right thing by not capturing any of it.
#### In renderer but not in docs
N/A — surface mismatch.
---
### `ui/sidebar.md`
**Inventory surfaces likely covered:** `root` (sidebar lives in the root
chrome on claude.ai). Note: the doc opens "Code Tab Sidebar" but the
sidebar in the captured renderer is the global claude.ai sidebar, not a
Code-tab-specific one. The Code-tab-specific session list is captured
separately under `root.button.code.button.new-session-n` (60 entries).
**Doc rows reconciled:** ~18
#### In docs but not in renderer
| Doc element | Reason class |
|-------------|--------------|
| Filter: status / project / environment | Walker did not drill the filter dropdown |
| Group-by control | Same — within Code-tab session list |
| Session status indicator (idle/running/...) | Visual decoration on row, not an actionable element |
| Project / branch label | Same |
| Diff stats badge `+12 -1` | Conditional — no session at capture had pending diffs |
| Dispatch badge | Conditional — no Dispatch-spawned session at capture |
| Scheduled badge | Conditional — same |
| Hover archive icon | Hover-revealed; walker captures static state |
| Right-click context menu (Rename / Archive / etc.) | Walker does not synthesise right-clicks |
| Sidebar resize handle | Visual / draggable, not an aria-labeled element |
| Sidebar collapse toggle | Inventory has `root.button.collapse-sidebar` but doc treats it as a Code-tab element rather than chrome |
| Scrollbar | OS / theme-rendered |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` cycling | Keyboard shortcut, not a UI element |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` | A pinned recent conversation — sidebar content |
| `root.button.more-options-for-fine-tuning-diffusion-models-with-reinforce` | Per-row menu trigger — doc mentions "right-click context menu" but inventory shows it's a discoverable button |
| `root.button.how-to-use-claude` + `root.button.more-options-for-how-to-use-claude` | Same pattern |
| `root.button.code.button.routines` | "Routines" link in Code-tab nav — doc's "Routines link" is here |
| `root.button.code.button.more-navigation-items` | Likely the doc's "Customize / Routines" expander — not enumerated |
| `root.button.code.button.filter` | The doc's "Filter: status" probably maps here |
| `root.button.code.button.appearance` | Not in doc |
| `root.button.code.button.show-5-more` | Pagination; not in doc |
| `root.button.code.button.open-session-*` (5 entries) | Each is a single session row in the Code-tab list — the doc's "Per-session row" category |
#### Fingerprint potentially drifted
None — doc rows for this surface use Location prose only.
---
### `ui/prompt-area.md`
**Inventory surfaces likely covered:** `root` (top-level prompt area
buttons), `root.button.add-files-connectors-and-more` (the `+` menu),
`root.button.model-opus-4-7-adaptive` (model picker), and several deep
sub-surfaces.
**Doc rows reconciled:** ~28
#### In docs but not in renderer
| Doc element | Reason class |
|-------------|--------------|
| Input field | The contenteditable / textarea itself isn't captured (no aria-label) |
| Placeholder text | Not an interactive element |
| Cursor caret / multi-line autosize / word wrap | Behavior, not element |
| Paste plain text / paste image | Behavior |
| `Enter` to send / `Shift+Enter` / `Esc` | Keyboard behavior |
| IME composition | Not a renderer element |
| Attachment button (left of input) | Not surfaced — possibly bundled into `root.button.add-files-connectors-and-more` |
| File-attached chip | Conditional — no attachment at capture |
| Multiple attachments / image preview / PDF preview | Conditional |
| Drag-drop overlay | Conditional, only renders during drag |
| `@filename` autocomplete | Conditional, only renders when typing `@` |
| `+` button | Likely IS the `root.button.add-files-connectors-and-more` button — see below |
| Slash menu (all rows: Built-in / Project skills / User skills / Plugin skills / filter / selection / `Esc`) | Walker did not type `/` to trigger the slash menu; no inventory entries |
| Effort picker (`Cmd+Shift+E`) | Possibly inside `root.button.code.button.opus-4-7-1m-extra-high` — uncertain |
| Stop button (replaces Send while responding) | Conditional — no in-flight response at capture |
| Usage ring | Possibly `root.button.code.button.usage-plan-11` ("Usage: plan 11%") |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.press-and-hold-to-record` ("Press and hold to record") | Voice / dictation button in prompt area — doc has no voice input row |
| `root.button.code.button.dictation-settings` | Dictation settings button |
| `root.button.code.button.transcript-view-mode` | Transcript view toggle in prompt area |
| `root.button.code.button.scroll-to-bottom` | Scroll-to-bottom affordance |
| `root.button.code.button.accept-edits` | Permission-mode-related quick action |
| `root.button.code.button.add` ("Add") | Likely the doc's `+` button, with a different label |
| `root.button.code.button.usage-plan-11` ("Usage: plan 11%") | Probably the doc's "Usage ring" |
| `root.button.code.button.opus-4-7-1m-extra-high` ("Opus 4.7 1M· Extra high") | Probably the doc's "Effort picker" |
| All `root.button.add-files-connectors-and-more.menuitem.*` entries (Add files or photos / Add to project / Skills / Connectors / Plugins / Research / Web search / Use style) | The `+` menu contents — doc has Slash commands / Skills / Connectors / Plugins / Add plugin; inventory surfaces additional items the doc misses (Add files or photos, Add to project, Web search, Use style) |
| `root.button.add-files-connectors-and-more.menuitem.use-style.*` (8 entries: Normal / Learning / Concise / Explanatory / Formal / Create & edit styles / Research mode) | Style picker is a whole sub-surface the doc doesn't mention |
| `root.button.model-opus-4-7-adaptive.menuitemradio.*` (Opus / Sonnet / Haiku / Adaptive thinking / More models) | Doc says "Sonnet, Opus, Haiku" — inventory adds Adaptive thinking + More models |
#### Fingerprint potentially drifted
| Doc claim | Inventory says |
|-----------|----------------|
| `+` button → opens menu of "Slash commands / Skills / Connectors / Plugins / Add plugin" | The corresponding inventory button is labeled "Add files, connectors, and more" with `aria-label="Add files, connectors, and more"`. Menu contents don't include "Slash commands" or "Add plugin" sub-entry — doc menu structure is partly speculative |
---
### `ui/code-tab-panes.md`
**Inventory surfaces likely covered:** `root.button.code` (23 entries),
`root.button.code.button.new-session-n` (60 entries) — but no per-pane
sub-surfaces (no diff pane, no terminal pane, no preview pane, no file
pane).
**Doc rows reconciled:** ~50
#### In docs but not in renderer
Almost every Code-tab pane row is missing from the inventory. The walker
landed in the Code-tab "New session" shell but did not open or drill any
of the panes. Categories:
| Pane | Doc rows missing | Reason |
|------|------------------|--------|
| Pane chrome (header, drag/resize handles, close button, Views menu) | 5 rows | Walker coverage gap — no pane was open |
| Diff pane | 9 rows (file list, diff content, line click, Cmd+Enter, Accept/Reject, Review code) | Walker coverage gap |
| Preview pane | 11 rows | Walker coverage gap |
| Terminal pane | 7 rows | Walker coverage gap (also: only renders for Local sessions) |
| File pane | 7 rows | Walker coverage gap |
| Tasks / subagent pane | 5 rows | Walker coverage gap |
| Side chat overlay | 3 rows (trigger / content / close) | `root.button.code.button.close-side-chat` IS captured — the close button — but content isn't drilled |
| CI status bar | 5 rows | Conditional — no PR open at capture |
| View modes (Normal/Verbose/Summary) | 3 rows | Possibly behind `root.button.code.button.transcript-view-mode` — single inventory entry vs. 3 doc rows |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.code.button.local` ("Local") | Environment switcher chip — not in doc |
| `root.button.code.button.select-folder` ("Select folder…") | Folder-picker entry — doc references this only via T17 cross-reference |
| `root.button.code.button.send` (and `#2`, both denylisted) | Send button — doc has it under prompt-area, not panes |
| `root.button.code.button.transcript-view-mode` | The doc's "Transcript view dropdown" — single inventory entry |
| `root.button.code.button.opus-4-7-1m-extra-high` | Model selector inside Code-tab session shell |
| `root.button.code.button.usage-plan-11` | Usage ring inside Code-tab session shell |
| `root.button.code.button.accept-edits` ("Accept edits") | Permission-mode quick action — not in doc |
| All 60 `root.button.code.button.new-session-n.button.open-session-*` and per-session entries | Doc covers the session list in `sidebar.md`, not here, so this isn't really a gap for `code-tab-panes.md` |
#### Fingerprint potentially drifted
None — doc is prose-only.
---
### `ui/settings.md`
**Inventory surfaces likely covered:** `root.button.settings` (only 1
entry — "Settings" button itself), `root.button.awaaddrick-max.menuitem.settingsctrl`
(the menu-item route to Settings, label "SettingsCtrl,").
**Doc rows reconciled:** ~28
#### In docs but not in renderer
The Settings page itself is essentially un-walked. Settings opens as an
overlay/modal which the walker treated as a single button rather than
drilling into. Every row in the doc beyond "Settings window opens" lacks
a matching inventory entry:
| Doc section | Rows missing | Reason |
|-------------|--------------|--------|
| Settings root (close button, sidebar nav) | 3 rows | Walker coverage gap |
| Desktop app → General (Computer use, Keep computer awake, Denied apps, Unhide apps, Theme picker) | 5 rows | Walker coverage gap; some rows account-state-dependent |
| Desktop app → Account (name/email, plan badge, Sign out) | 3 rows | Walker coverage gap |
| Claude Code (Worktree location, Branch prefix, Auto-archive toggle, Persist preview, Preview toggle, Bypass-permissions toggle, Auto mode availability) | 7 rows | Walker coverage gap |
| Connectors page (list, per-connector entry, Manage, Disconnect, Add connector) | 5 rows | Walker coverage gap; partially covered by the in-session connectors menu |
| SSH connections (list, Add SSH connection button, per-connection entry) | 3 rows | Walker coverage gap; account-state-dependent |
| Keyboard shortcuts (list, value, Reset, Quick Entry shortcut) | 4 rows | Walker coverage gap |
| Local environment editor (open, Add variable, Remove variable, Apply to dev servers) | 4 rows | Walker coverage gap; account-state-dependent |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.settings` ("Settings", `aria-label="Settings"`) | The button that opens Settings — confirmed in chrome |
| `root.button.awaaddrick-max.menuitem.settingsctrl` ("SettingsCtrl,") | Settings menu item under the user/plan menu — alternate path |
#### Fingerprint potentially drifted
None.
#### Walker coverage note
Settings is a known walker coverage gap (see preamble). This doc is
substantively un-reconciled until a Settings drill pass lands.
---
### `ui/routines-page.md`
**Inventory surfaces likely covered:** none directly. Routines are
reachable via `root.button.code.button.routines`, but the page itself
isn't drilled.
**Doc rows reconciled:** ~26
#### In docs but not in renderer
Every doc row except the "Routines page link" itself is unmatched — the
walker captured the entry point but did not open the Routines page.
| Doc section | Rows missing | Reason |
|-------------|--------------|--------|
| Routines list (header, New routine button, list, per-routine row, Run-now icon, Pause/resume, click row) | 7 rows | Walker coverage gap |
| New routine form Local (Name, Description, Instructions, permission-mode picker, model picker, Working folder, Worktree toggle, Schedule preset, Time picker, Day picker, Save, Cancel, Folder-trust prompt) | 13 rows | Walker coverage gap |
| New routine form Remote (Trigger type, Connectors picker, Network access controls) | 3 rows | Walker coverage gap; doc itself is partly speculative ("Per upstream docs") |
| Routine detail (Run now, Active/Paused toggle, Edit, Delete, Review history, hover tooltip, Show more, Always allowed, Revoke approval) | 9 rows | Walker coverage gap |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.code.button.routines` ("Routines") | The entry-point link — doc's "Routines page link" |
#### Fingerprint potentially drifted
None.
---
### `ui/connectors-and-plugins.md`
**Inventory surfaces likely covered:** `root.button.add-files-connectors-and-more.menuitem.connectors`
(the in-session connector picker, 5 entries), plus the deeper per-connector
sub-surfaces under `.connectors.menuitemcheckbox.gmail.*` (15 entries).
Plugin browser surfaces (`root.button.back.*`) cover Skills, Connectors,
Add plugin, Typescript lsp, Php lsp, Playwright, Connectors, etc.
**Doc rows reconciled:** ~24
#### In docs but not in renderer
| Doc element | Reason class |
|-------------|--------------|
| Connectors menu — "Per-connector row" with status indicator | Inventory has Gmail and Google Calendar but not status decorations |
| Empty state | Conditional — user has connectors configured |
| Connector catalog (modal body, per-connector tile with logo/description) | Walker coverage gap — the Add-connector flow opens a modal that wasn't drilled |
| OAuth in-app overlay | Conditional, not present at capture |
| Permission consent screen | External (provider's UI) |
| Callback completion | Behavior, not an element |
| Custom connector entry point | Walker coverage gap |
| Plugin browser modal (browser modal, marketplace selector, per-plugin tile, scope selector, install progress, success state, error state) | Walker captured plugin surfaces under `root.button.back.*` (Add plugin, Typescript lsp, Php lsp, Playwright) but not the modal anatomy |
| Manage plugins (installed list, per-plugin row, Enable toggle, Plugin skills sub-list) | Walker coverage gap — no Manage-plugins surface drilled |
#### In renderer but not in docs
| Inventory entry | Notes |
|-----------------|-------|
| `root.button.add-files-connectors-and-more.menuitem.connectors` ("Connectors", in-session menu) | Doc covers this — the in-session Connectors menu |
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail` ("Gmail") | Per-connector row — doc "Per-connector row" category |
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.google-calendar` ("Google Calendar") | Per-connector row — same |
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.manage-connectors` ("Manage connectors") | Doc's "Manage connectors entry" |
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.add-connector` ("Add connector") | Doc has "Add connector button" in Settings; inventory shows it also exists in the in-session menu |
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.tool-accessload-tools-when-needed` ("Tool accessLoad tools when needed") | Per-connector tool-access setting — not in doc |
| `root.button.back.a.skills` ("Skills") | Plugin browser — Skills tab |
| `root.button.back.a.connectors` / `root.button.back.a.connectors#2` (both "Connectors") | Plugin browser — Connectors tab (instance suffix `#2` indicates duplicate detection) |
| `root.button.back.button.add-plugin` ("Add plugin") | Plugin browser — Add plugin button |
| `root.button.back.a.typescript-lsp` / `root.button.back.a.php-lsp` / `root.button.back.a.playwright` | Installed plugins — doc treats this as "Manage plugins → Per-plugin row," walker captures the actual plugin names |
| `root.button.back.button.connect-your-appslet-claude-read-and-write-to-the-tools-you-` ("Connect your appsLet Claude read...") | Plugin browser landing pane CTA — not in doc |
| `root.button.back.a.create-new-skillsteach-claude-your-processes-team-norms-and-` ("Create new skillsTeach Claude your processes, team norms, and expertise.") | Skills-creation CTA — not in doc |
| `root.button.back.button.browse-pluginsadd-pre-built-knowledge-for-your-field` ("Browse pluginsAdd pre-built knowledge for your field.") | Browse-plugins CTA — not in doc |
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail.button.develop-storytelling-frameworks` and 9 similar `.option`/`.button` pairs | Connector-suggested prompt cards. Walker captured these as a side-effect of drilling Gmail — they aren't a doc-targeted UI element |
#### Fingerprint potentially drifted
| Doc claim | Inventory says |
|-----------|----------------|
| `+`**Connectors** opens "Connectors menu" | Inventory: button is "Add files, connectors, and more" not "+"; menu item is "Connectors". Functionally the same surface |
---
### `ui/quick-entry.md`
**Inventory surfaces covered:** none — Quick Entry is a separate
`BrowserWindow` constructed in the main process (`index.js:515375`), not
part of claude.ai's renderer. The walker started at `https://claude.ai/new`
which never reaches it.
**Doc rows reconciled:** ~17
#### In docs but not in renderer
Every row, by design. Categories:
- Window appearance (frame, background, rounded corners, drop shadow,
position, always-on-top, lifecycle, persistence after main destroy) —
main-process BrowserWindow construction
- Input area (text input, placeholder, multi-line, Enter/Shift+Enter,
Esc, click-outside, paste, IME) — popup renderer (separate from
claude.ai)
- Submit feedback (transition, loading, error) — popup renderer + IPC
bridge
This entire file is correctly out of renderer scope. Doc rows are
already heavily annotated with `index.js:515xxx` references to upstream
main-process source — that's the right substrate.
#### In renderer but not in docs
N/A — surface mismatch.
---
### `ui/notifications.md`
**Inventory surfaces covered:** none — notifications fire via libnotify
on the `org.freedesktop.Notifications` DBus path; they are not DOM
elements.
**Doc rows reconciled:** ~17
#### In docs but not in renderer
Every row, by design. Categories:
- Notification sources (Scheduled fires, Catch-up, CI status, PR merged,
Dispatch handoff, Permission prompt) — main-process emitters
- Per-notification anatomy (App identity, icon, title, body, actions,
click target) — DBus payload
- Per-DE rendering (KDE/GNOME/Mako/Dunst/swaync/Niri) — daemon behavior
- Notification persistence (history, DND) — daemon behavior
This entire file is correctly out of renderer scope.
#### In renderer but not in docs
N/A — surface mismatch.
---
## Top-level findings
### Coverage by source-of-truth axis
- **OS-level / window-manager elements** (window-chrome rows for
title bar, close/min/max, resize edges, drop shadow) — never going to
appear in the renderer inventory. ~10 doc rows.
- **Main-process Electron windows** (Quick Entry popup, About dialog,
crash dialog, file pickers) — never going to appear in the renderer
inventory. ~25 doc rows.
- **Tray menu** (Show/Hide, Quick Entry, Settings, About, Quit, Open
at Login) — main-process `Menu.buildFromTemplate()`. ~12 doc rows.
- **libnotify notifications** — DBus, not DOM. ~17 doc rows.
- **Walker coverage gaps** (Settings overlay, Routines page, plugin
browser modal, all Code-tab panes, dialogs, slash menu, drag-drop
overlay) — would appear if the walker drilled them. ~70 doc rows.
- **Account-state-dependent surfaces** (CI bar, Dispatch badges, file
attachments, SSH connections panel) — would appear in some sessions
but didn't at capture. ~15 doc rows.
- **Conditional / hover / behavior** (right-click context menus, hover
archive icons, drag-drop overlays, tooltips) — wouldn't appear in a
static walker pass even if the surface was visited. ~10 doc rows.
The combined explanation: roughly half of the "in docs but not in
renderer" mismatches are unfixable (different source of truth), and
roughly half are walker coverage gaps that future passes can close.
### Top 3 surfaces with the most "in docs but not in renderer" mismatches
These are likely candidates for speculative claims OR for un-walked
surfaces. Treat as triage queue:
1. **`ui/code-tab-panes.md`** — ~50 unmatched rows. Almost entirely
walker-coverage gap (the walker landed in the Code-tab shell but
opened no panes). Until the walker drills diff/preview/terminal/file/
tasks panes, this doc is un-reconcilable.
2. **`ui/settings.md`** — ~28 unmatched rows. Settings opens as an
overlay; walker captured only the Settings entry-point button. Needs
targeted drill.
3. **`ui/routines-page.md`** — ~26 unmatched rows. Same shape as
Settings — entry-point captured, page contents unwalked.
### Top 3 surfaces with the most "in renderer but not in docs" surplus
These docs are most-incomplete relative to ground truth:
1. **`ui/sidebar.md`** — Inventory has 60+ Code-tab session-list entries
under `root.button.code.button.new-session-n`. Doc treats sessions as
a single category row. This is intentional doc behavior, but it means
the doc doesn't help when reasoning about the actual structural
buttons (Filter, Appearance, Routines, More navigation items, Show 5
more, etc.) that the walker found.
2. **`ui/prompt-area.md`** — Inventory has the entire Use-style picker
sub-tree (Normal / Learning / Concise / Explanatory / Formal / Create
& edit styles + 5 preset cards), the Press-and-hold-to-record voice
button, dictation settings, transcript view mode, scroll-to-bottom,
and the model picker's "Adaptive thinking" / "More models" entries —
none of which the doc enumerates.
3. **`ui/connectors-and-plugins.md`** — Inventory has the entire plugin
browser sub-tree (`root.button.back.*` — 12 entries: Skills, Add
plugin, Typescript lsp, Php lsp, Playwright, Browse plugins, Create
new skills, Connect your apps, Connectors×2, Back to Claude, Select
a folder), and connector-suggested prompt cards (10 entries under
`.gmail.button.*`). Doc treats these surfaces at a higher level of
abstraction.
## Acknowledged gaps in inventory itself
Not all inventory absences are doc errors. Known walker gaps as of v6:
- **Settings page deep content** — only the entry-point button
(`root.button.settings`) and the menu shortcut
(`...menuitem.settingsctrl`) captured. Settings opens as an overlay
the walker did not drill.
- **Dialogs** — 0 captured. claude.ai may not use `[role=dialog]` for
most modals, or the walker's drill paths didn't reach them.
- **Code tab panes** — only the Code-tab session shell was drilled;
diff, preview, terminal, file, tasks, subagent, plan, side chat, CI
bar are uncaptured.
- **Routines page** — only the entry-point link was captured.
- **Plugin browser modal anatomy** — surrounding list captured, the
per-plugin install modal wasn't.
- **Slash menu** — walker did not type `/` to trigger.
- **Hover/right-click/drag-only affordances** — static walker; no
context menus or drag-drop overlays.
- **Quick Entry / Tray / Notifications** — out of renderer scope.
These are walker tickets, not bugs against the v6 capture.
## Triage suggestions for `ui/*.md` cleanup
Aimed at humans editing the docs. Ordered by impact:
1. **Mark out-of-renderer surfaces explicitly.** `ui/tray.md`,
`ui/quick-entry.md`, `ui/notifications.md`, and the OS-frame section
of `ui/window-chrome-and-tabs.md` already reference main-process
source and DE behavior — add a header note that this surface
intentionally doesn't appear in `ui-inventory.json`.
2. **Annotate walker-coverage-gap surfaces.** `ui/code-tab-panes.md`,
`ui/settings.md`, `ui/routines-page.md` — header note that the
inventory does not yet drill these surfaces; rows reflect upstream
behavior and are unverified in the renderer.
3. **Add missing topbar/prompt-area elements** to `ui/window-chrome-and-tabs.md`
and `ui/prompt-area.md` from the "In renderer but not in docs" lists.
4. **Decide the doc/inventory boundary for sidebar session lists.** Doc
treats sessions as a category; inventory enumerates each. Pick one
shape and document it.
5. **Flag speculative Linux-conditional rows**`ui/settings.md` SSH
connections, "Denied apps" / "Unhide apps when Claude finishes" for
Computer Use — mark as "may not render on Linux; verify before
assuming."

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
{
"capturedAt": "2026-05-03T07:13:20.024Z",
"appVersion": "1.5354.0",
"walkerVersion": "7",
"startUrl": "https://claude.ai/epitaxy",
"totalElements": 90,
"deniedActions": 6,
"partial": false,
"isolation": "launchClaude (test-harness path)",
"seededFromHost": true,
"allowlistEntries": []
}

View File

@@ -1,76 +0,0 @@
# UI snapshots
Captured renderer state for the `claude.ai` web view, taken via the
`explore` CLI in [`tools/test-harness/explore/`](../../../tools/test-harness/explore/).
Use these to detect upstream UI drift before it breaks the harness.
The snapshot JSON files themselves are gitignored
(`docs/testing/ui-snapshots/*.json`) — they're noisy diffs and
specific to the moment of capture. This directory is checked in so the
path exists; the README + `.gitkeep` are the only tracked files.
## Capture
Requires a running `claude-desktop` build with the main-process
debugger attached on port 9229 (Developer menu → Enable Main Process
Debugger). Then, from `tools/test-harness/`:
```sh
npx tsx explore/explore.ts snapshot baseline-code-tab
# → wrote /…/docs/testing/ui-snapshots/baseline-code-tab.json
```
Snapshot names are restricted to `[a-zA-Z0-9._-]`.
## Compare
```sh
npx tsx explore/explore.ts diff baseline-code-tab after-feature-x
```
Add `--json` for machine-readable output. Add `--exit-on-diff` to fail
the process (exit code 3) when there are any entries — useful inside a
CI guard.
`diff` arguments accept either a bare name (looked up in this dir,
`.json` appended) or an explicit path.
### What counts as a diff
| Kind | Meaning |
|-----------|---------------------------------------------------------|
| `removed` | Element keyed in A absent from B (drift signal). |
| `changed` | Same key, different visible text or structural detail. |
| `added` | New key in B (informational only — surface gained). |
## Snapshot shape
```jsonc
{
"capturedAt": "2026-05-02T17:30:00Z",
"claudeAiUrl": "https://claude.ai/…",
"appVersion": "1.1.7714", // from app.getVersion(), null on failure
"pageState": { "url", "title", "readyState" },
"dfPills": [ /* Chat / Cowork / Code top-level tabs */ ],
"compactPills": [ /* env pill, Select-folder pill, */ ],
"ariaLabeledButtons":[ /* every <button[aria-label]>, capped at 200 */ ],
"openMenu": { "ariaLabelledBy", "ariaLabel", "items": [...] },
"modals": [ /* role=dialog with heading + buttons */ ]
}
```
Discovery is by **structural shape**, never by minified Tailwind class
names. See the why-block at the top of
[`tools/test-harness/explore/snapshot.ts`](../../../tools/test-harness/explore/snapshot.ts)
for the rationale.
## Other subcommands
```sh
npx tsx explore/explore.ts # full snapshot to stdout
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
npx tsx explore/explore.ts menu # currently-open menu (or null)
npx tsx explore/explore.ts find <re> # regex search over text + aria-label
```
`find` regex is case-insensitive by default.

View File

@@ -1,360 +0,0 @@
{
"derivedAt": "2026-05-03T02:51:23.409Z",
"sourceInventory": {
"capturedAt": "2026-05-03T00:21:38.299Z",
"appVersion": "1.5354.0",
"walkerVersion": "6",
"totalElements": 383
},
"stable": [
"Accept edits",
"Add",
"Add connector",
"Add files",
"Add files or photosCtrl+U",
"Add files, connectors, and more",
"Add from GitHub",
"Add to project",
"All projects",
"Appearance",
"Ask",
"Back",
"Back to Claude",
"Chat",
"Clear active",
"Close",
"Close side chat",
"Close suggestions",
"Code",
"Completed: See Claude workTry a quick task — Claude does it, you watch",
"ConcisePreset",
"Connectors",
"Conversation ID reference",
"Copy invite",
"Cowork",
"Create custom style",
"Create engaging headlines",
"Create presentation scripts",
"Develop content templates",
"Develop storytelling frameworks",
"Dictation settings",
"Dismiss checklist",
"Dismiss guest pass",
"Draft PR visibility on GitHub",
"ELKO HRN-33 and HRN-31 manuals",
"Edit Instructions",
"Electron apps Linux users desperately want but can't have\nDespite Electron's cross-platform promise, several high-profil",
"Expand sidebar",
"ExplanatoryPreset",
"Feedback submission",
"Filter",
"Fine-tuning diffusion models with reinforcement learning",
"FormalPreset",
"Forward",
"From Calendar",
"From Gmail",
"Get apps and extensions",
"Gmail",
"Google Calendar",
"How to use ClaudeAaddrick Williams",
"Install",
"Invalid session description",
"Lamination plate position offsetsAaddrick Williams",
"Learn",
"Learn about styles",
"Learn how to use Cowork safely",
"Learn more about styles",
"Learning",
"LearningPreset",
"Local",
"Manage connectors",
"Menu",
"Model: Legacy Model",
"Model: Opus 4.7 Adaptive",
"Model: Sonnet 4.6 Adaptive",
"More navigation items",
"More options",
"More options for Fine-tuning diffusion models with reinforcement learning",
"More options for How to use Claude",
"New artifact",
"New project",
"Open session Audit for elementary-data supply chain vulnerability",
"Open session Find contact method for Claude Desktop issue",
"Open session Plan automated testing strategy for desktop app",
"Open session Test DNS query for Claude desktop package",
"Open session for PR #552",
"Pair your phoneSend tasks from your phone for Claude to run here",
"Pin project",
"Pinned",
"Plugins",
"Press and hold to record",
"Recents",
"Research",
"Research mode",
"Schedule a recurring taskGreat for reminders, reports, or regular check-ins",
"Scroll to bottom",
"Search",
"Search projects",
"Select folder…",
"Send",
"Settings",
"Show 5 more",
"Show more",
"Skills",
"Skip to content",
"Sort by",
"Start a task in Cowork",
"Style: Formal",
"Terms apply",
"Test",
"Testing and Quality Assurance",
"Tool accessLoad tools when needed",
"Transcript view mode",
"Untitled",
"Use style",
"View all",
"Web search",
"West Central Schools provincial takeover investigation",
"Work in a project",
"Write",
"Write something in the voice of my favorite historical figure",
"Your artifactsYour artifacts",
"about_tab.py, py, 60 lines",
"New chat⌘N",
"New session⌘N",
"New task⌘N",
"Artifacts",
"Live artifacts",
"Scheduled",
"DispatchBeta",
"Routines",
"How to use Claude",
"Projects",
"Customize"
],
"instanceShapes": [
{
"id": "plan-badge",
"regex": "^.+·(Free|Pro|Max|Team|Enterprise)[-\\s]*$",
"flags": "u",
"pattern": "\\w+·(Free|Pro|Max|Team|Enterprise)",
"matchedNames": [
"AWAaddrick·Max"
]
},
{
"id": "opus-version",
"regex": "^Opus \\d",
"flags": "",
"pattern": "^Opus \\d",
"matchedNames": [
"Opus 4.7 1M· Extra high",
"Opus 4.7Most capable for ambitious work"
]
},
{
"id": "sonnet-version",
"regex": "^Sonnet \\d",
"flags": "",
"pattern": "^Sonnet \\d",
"matchedNames": [
"Sonnet 4.6Most efficient for everyday tasks"
]
},
{
"id": "haiku-version",
"regex": "^Haiku \\d",
"flags": "",
"pattern": "^Haiku \\d",
"matchedNames": [
"Haiku 4.5Fastest for quick answers"
]
},
{
"id": "percentage",
"regex": "\\d{1,3}%$",
"flags": "",
"pattern": "\\d{1,3}%",
"matchedNames": [
"Usage: plan 11%"
]
},
{
"id": "relative-date",
"regex": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)",
"flags": "",
"pattern": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)(\\+\\d+)?",
"matchedNames": [
"Claude Desktop Debian1 year ago",
"Draft PR visibility on GitHubYesterday",
"ELKO HRN-33 and HRN-31 manualsYesterday",
"Feedback submissionYesterday",
"Find contact method for Claude Desktop issuePR #552 · Yesterday",
"Review PR 555 for issue 558 fixToday",
"Review and analyze issue 545Yesterday"
]
},
{
"id": "size-with-unit",
"regex": "^\\d+\\.\\d+\\s\\w+",
"flags": "",
"pattern": "^\\d+\\.\\d+\\s\\w+",
"matchedNames": []
},
{
"id": "user-handle",
"regex": "@\\w+",
"flags": "",
"pattern": "@\\w+",
"matchedNames": []
},
{
"id": "long-title",
"regex": "^[A-Z][a-z]+ [A-Z][a-z]+ [a-z]",
"flags": "",
"pattern": null,
"matchedNames": [
"Evaluate Terraform for infrastructure setup",
"Host Obsidian library in second database"
]
}
],
"suspect": [
"Adaptive thinkingThinks for more complex tasks",
"Add build instructions and patch toggle option",
"Add build instructions and quick menu patch toggle",
"Add plugin",
"Audit for elementary-data supply chain vulnerability",
"Automate",
"Browse pluginsAdd pre-built knowledge for your field.",
"Build adversarial resume review platform MVP",
"Change fonts to Lexend",
"Check Quad9 DNS resolution for package domain",
"Check flight map tile caching history",
"Check for Trivy supply chain vulnerability",
"Claude Desktop DebianAaddrick Williams",
"Claude Desktop DebianEnter",
"Claude is AI and can make mistakes. Please double-check responses.",
"Claude prompting guide.md, md, 413 lines",
"Clawdmartclawdmart.comClaudeCreate a shopping list, go on Chrome, and make an order",
"Collapse sidebar",
"Compare GPU options for gaming performance",
"Concise",
"Connect your appsLet Claude read and write to the tools you already use.",
"Copy",
"Create & edit styles",
"Create new skillsTeach Claude your processes, team norms, and expertise.",
"Create user documentation",
"Customer Email",
"Data",
"Develop editorial guidelines",
"Dispatch background conversation",
"Download",
"Draw",
"Edit",
"Educational Content",
"Evaluate productization viability of methodology",
"Explanatory",
"Find contact method for Claude Desktop issue",
"Fix Claude Desktop installation on Debian",
"Formal",
"Formulas",
"Give negative feedback",
"Give positive feedback",
"Help me develop a unique voice for an audience",
"Home",
"How to use ClaudeAn example project that also doubles as a how-to guide for using Claude. Chat with it to learn more abo",
"Identify tools for session start hook",
"Insert",
"Investigate GitHub Actions workflow failure",
"Investigate GitHub issue 394 comment",
"Investigate leaked crates.io API key",
"Investigate leaked crates.io token in repository",
"Lamination plate position offsetsAdjust existing code to just populate a table with original positions, new positions, a",
"Marketing Blog Post",
"More models",
"More options for Claude Desktop Debian",
"More options for Lamination plate position offsets",
"My downloads folder is a mess! Can you clean it up?",
"Normal",
"Open",
"Options",
"Page Layout",
"Php lsp",
"Plan automated testing strategy for desktop app",
"Playwright",
"Product Review",
"Read health data",
"Retry",
"Review",
"Review PR 555 for issue 558 fix",
"Review and address issue 88",
"Review and analyze issue 545",
"Review and close stale issues",
"Review and investigate GitHub issue 445",
"Review issue 156",
"Review issue 172 and document related history",
"Review issue 373",
"Review last three repository commits",
"Review path resolution issues and pull requests",
"Review project issues and pull requests",
"Review recent comments, issues, and pull requests",
"Select a folder",
"Share chat",
"Short Story",
"Start a new project",
"Start return",
"Style: Concise",
"Style: Explanatory",
"Style: Learning",
"Test DNS lookup with Quad9 resolver",
"Test DNS query for Claude desktop package",
"Test path resolution",
"Test startsession hook functionality",
"Troubleshoot modem downstream connection issue",
"Turn these receipts into an expense report",
"Typescript lsp",
"Unpin project",
"Untitled, rename chat",
"View",
"Write case studies",
"Write speech drafts",
"analyze_project.py, py, 220 lines",
"base_half_sheet.py, py, 32 lines",
"changelog_viewer_component.py, py, 113 lines",
"colors.py, py, 103 lines",
"compensation.py, py, 50 lines",
"components.py, py, 118 lines",
"components.py, py, 119 lines",
"config_reader.py, py, 120 lines",
"contraction_tab.py, py, 105 lines",
"contraction_tab.py, py, 82 lines",
"conversions.py, py, 28 lines",
"data_parser.py, py, 87 lines",
"dialogs.py, py, 34 lines",
"file_operations.py, py, 43 lines",
"log.py, py, 140 lines",
"log.py, py, 236 lines",
"machines.ini, ini, 2 lines",
"main.py, py, 203 lines",
"main.py, py, 264 lines",
"output_tab.py, py, 191 lines",
"output_tab.py, py, 246 lines",
"process_request.py, py, 632 lines",
"processing_format.ini, ini, 2 lines",
"setup_tab.py, py, 120 lines",
"setup_tab.py, py, 177 lines",
"sheet_dimensions.ini, ini, 3 lines",
"version 0.1.0.md, md, 42 lines",
"version 0.1.1.md, md, 31 lines",
"version 0.1.2.md, md, 18 lines",
"View all plans",
"Get apps and extensions",
"Gift Claude",
"Language",
"Get help",
"Learn more",
"Log out",
"SettingsCtrl,"
]
}

View File

@@ -1,78 +0,0 @@
# UI Element Inventory
This directory holds per-surface UI checklists. Where [`../cases/`](../cases/) tests verify *behavior end-to-end*, files here verify *every UI element renders and responds* on Linux.
## Why a separate directory
A functional test like [T17 — Folder picker opens](../cases/code-tab-foundations.md#t17--folder-picker-opens) verifies the folder picker works. A UI checklist asks the smaller, more granular questions:
- Is the **Select folder** button visually present?
- Does its hover state render?
- Is the icon next to it the correct shape on a HiDPI screen?
- Does it tab-focus correctly?
- Does it have an accessible name (a11y)?
Functional tests catch "the feature broke." UI checklists catch "the feature works but looks wrong." Both matter on Linux because Electron under different DEs / display servers / GTK theme combinations produces visual artifacts that aren't behavioral failures.
## Layout
| File | Surface | Notes |
|------|---------|-------|
| [`window-chrome-and-tabs.md`](./window-chrome-and-tabs.md) | OS window frame + hybrid in-app topbar + Chat/Cowork/Code tabs | Crosses with [T04](../cases/tray-and-window-chrome.md#t04--window-decorations-draw), [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
| [`tray.md`](./tray.md) | System tray icon + menu + theme variants | Crosses with [T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update) |
| [`sidebar.md`](./sidebar.md) | Session sidebar in Code tab | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
| [`prompt-area.md`](./prompt-area.md) | Code-tab prompt input area | Crosses with [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
| [`code-tab-panes.md`](./code-tab-panes.md) | Diff, preview, terminal, file, tasks, subagent, plan, side-chat | Crosses with [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens) |
| [`settings.md`](./settings.md) | All Settings pages | Crosses with [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
| [`routines-page.md`](./routines-page.md) | Routines list + new-routine form + detail page | Crosses with [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
| [`connectors-and-plugins.md`](./connectors-and-plugins.md) | Connector picker, connector list, plugin browser, plugin manager | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
| [`quick-entry.md`](./quick-entry.md) | Quick Entry popup window | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
| [`notifications.md`](./notifications.md) | libnotify rendering for all notification sources | Crosses with [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
## Standard checklist row
Each UI file uses tables of the form:
| Element | Selector / location | Expected | Notes |
|---------|---------------------|----------|-------|
| Close button | Top-right of titlebar | Renders, hover state visible, click hides to tray (see T08) | KDE-W: ✓ |
Columns:
- **Element** — human-readable name.
- **Selector / location** — DOM selector if known, otherwise plain-language pointer ("right-click menu, second item from top"). The selector column is what becomes a Playwright/CDP assertion when automation lands.
- **Expected** — what the user should see / what should happen on click. Concise.
- **Notes** — known issues, environment caveats, screenshot links.
## Sweep workflow
A UI sweep on a row:
1. Take a baseline screenshot of each surface (`scrot`, `gnome-screenshot`, `grim`, `flameshot`).
2. Walk each table top-to-bottom. For each row, look at the element, click/hover/tab to it, compare against Expected.
3. Mark anomalies in the **Notes** column or file an issue if the deviation is environment-specific.
4. Save screenshots of any failure to a dated folder; reference them inline.
UI rows don't have stable IDs (`T##` / `S##`) — they're append-only checkpoints. When something becomes a regression candidate worth tracking long-term, promote it to a functional test in [`../cases/`](../cases/).
## Automation roadmap
Each UI checklist row is a candidate Playwright (via [Electron driver](https://playwright.dev/docs/api/class-electron)) or `xdotool` assertion:
```typescript
// Playwright shape
await page.locator('[data-testid="close-button"]').click()
await expect(window).toBeHidden()
```
Or for pure visual diffing:
```bash
# scrot + perceptualdiff
scrot -u baseline.png
# ... interaction ...
scrot -u current.png
perceptualdiff baseline.png current.png
```
The structure here is intentionally diff-friendly: rows are stable, tables are append-only, selectors live in their own column.

View File

@@ -1,114 +0,0 @@
# UI — Code Tab Panes
Drag-and-drop panes inside a Code-tab session: diff, preview, terminal, file editor, tasks, subagent, plan, side chat. Related functional tests: [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens).
## Pane chrome (common)
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Pane header | Top of pane | Shows pane title, drag handle, close button | — |
| Drag handle | Pane header | Drag repositions the pane in the layout | — |
| Resize handle | Edge between panes | Drag resizes; double-click resets | — |
| Close pane button | Pane header right | `Cmd+\` or Ctrl+\\ shortcut equivalent | — |
| Views menu | Session toolbar | Lists all openable panes; click to add | — |
## Diff pane
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Diff stats indicator | Chat / sidebar (entry point) | Shows `+12 -1` style. Click opens diff pane | — |
| File list | Left side of pane | Lists changed files, click to navigate | — |
| Diff content | Right side | Side-by-side or unified diff renders cleanly | Theme-aware (dark/light) |
| Line click → comment box | Click any line | Opens inline comment input | — |
| Comment submit (`Cmd+Enter` / `Ctrl+Enter`) | Press the shortcut after writing | Submits all comments at once | — |
| Accept button | Per-file or per-hunk | Applies the change to disk | — |
| Reject button | Per-file or per-hunk | Discards the change | — |
| **Review code** button | Top-right of pane | Triggers Claude self-review of diff | — |
## Preview pane
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Preview dropdown | Session toolbar | Lists configured servers from `.claude/launch.json` | — |
| **Start** action | Per-server entry | Launches the dev server | — |
| **Stop** action | Per-server entry | Stops the dev server | — |
| **Stop all servers** | Dropdown bottom | Stops every running server | — |
| **Edit configuration** | Dropdown bottom | Opens `.claude/launch.json` in the file pane | — |
| **Persist sessions** toggle | Dropdown | Persists cookies / localStorage across server restarts | — |
| Embedded browser frame | Pane content | Renders the running app | Uses Electron `<webview>` or `BrowserView` |
| URL bar / address | Top of pane | Shows current URL; editable | — |
| Reload button | Top of pane | Reloads the embedded URL | — |
| DevTools toggle | Top of pane (right) | Opens Electron DevTools for the embedded view | — |
| Auto-verify screenshots | When Claude verifies a change | Brief overlay shows screenshot being captured | — |
## Terminal pane
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Terminal pane | Opened via `Ctrl+`` or Views menu | Bash/zsh/fish session in the working directory ([T19](../cases/code-tab-foundations.md#t19--integrated-terminal)) | Local sessions only |
| Cursor | Inside terminal | Blinks; cursor shape per shell | — |
| Resize | Drag pane edges | Terminal cols/rows update; `tput cols` reflects new width | SIGWINCH should fire |
| Scrollback | Type many lines | Scrollable history; mouse scroll wheel works | — |
| Color rendering | Run `ls --color=auto`, `tput colors` | 256-color or truecolor support; theme-aware | — |
| Copy / paste | Select + `Ctrl+Shift+C` / `Ctrl+Shift+V` | Standard terminal-emulator shortcuts | — |
| Working directory inheritance | Open pane in a session | Opens at the session's project folder | Confirm with `pwd` |
## File pane
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| File pane | Opened by clicking a file path | Shows file content, syntax-highlighted | — |
| Save button | Pane toolbar | Writes current content to disk | — |
| Path label | Pane header | Click copies absolute path | — |
| On-disk-changed warning | If file changed externally after open | Banner with Override / Discard options ([T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves)) | — |
| Discard button | When edits unsaved | Reverts to disk content | — |
| Cursor / selection | Inside content | Renders correctly; multi-cursor not supported | — |
| Find / replace | `Ctrl+F` | Opens find-in-file overlay | Verify scoped to current pane only |
## Tasks pane / subagent pane
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Tasks pane | Opened via Views menu | Lists subagents, background shell commands, workflows | — |
| Task entry click | Click any task | Opens the subagent pane with output | — |
| Stop task button | Per-task | Sends interrupt signal | — |
| Task status indicator | Per-task | Running / Completed / Failed | — |
| Output stream | Inside subagent pane | Live-updating stdout/stderr | — |
## Side chat overlay
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Side chat trigger | `Ctrl+;` or `/btw` in main prompt | Opens overlay attached to current session ([T31](../cases/code-tab-workflow.md#t31--side-chat-opens)) | — |
| Side chat content | Overlay body | Reads main thread context; replies stay in side chat | — |
| Close button | Overlay top-right | Closes side chat, returns focus to main session | — |
## CI status bar
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| CI status row | Below prompt area when PR open | Shows current check states | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
| **Auto-fix** toggle | Top of CI bar | Toggles automatic check-failure fixes | — |
| **Auto-merge** toggle | Top of CI bar | Toggles auto-merge on green | Requires GitHub repo setting |
| Per-check entries | Each CI check | Shows pass / fail / pending state | Click to see logs |
| CI completion notification | When all checks resolve | Desktop notification posted ([T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire)) | — |
## View modes
| Mode | Trigger | Expected | Notes |
|------|---------|----------|-------|
| Normal | Default; cycle via `Ctrl+O` | Tool calls collapsed into summaries, full text responses | — |
| Verbose | Cycle via `Ctrl+O` | Every tool call, file read, intermediate step | Use for debugging |
| Summary | Cycle via `Ctrl+O` | Only Claude's final responses + changes | Use when scanning many sessions |
| Transcript view dropdown | Next to send button | Same as `Ctrl+O` | — |
## Failure modes to watch for
| Symptom | Likely cause | Notes |
|---------|--------------|-------|
| Pane drag doesn't snap to layout zones | Layout engine state corruption; restart session | — |
| Terminal cursor doesn't blink | `xterm-256color` not propagated; `TERM` env wrong | `echo $TERM` inside the pane |
| File pane "Save" silently no-ops | Read-only filesystem ([S28](../cases/extensibility.md#s28--worktree-creation-surfaces-clear-error-on-read-only-mounts)); permissions wrong | `stat <file>` for ownership |
| Preview pane embedded browser blank | Dev server didn't bind expected port; `autoPort` config | Check launcher log; `lsof -i :<port>` |
| Auto-verify screenshots fail | Headless screenshot in embedded view broken on Wayland | Test on X11 row; report to upstream |
| CI bar shows stale state | `gh` polling interval; rate-limited | `gh api rate_limit`; manual `gh pr checks <num>` |

View File

@@ -1,70 +0,0 @@
# UI — Connectors & Plugins
Connector picker, connectors list, plugin browser, plugin manager. Related functional tests: [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths).
## Connector picker (in-session)
Triggered by `+`**Connectors** in the prompt area.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Connectors menu | Opened from `+` button | Lists configured connectors + "Manage connectors" entry | — |
| Per-connector row | Menu item | Name, status indicator (connected / not configured), action button | — |
| **Manage connectors** entry | Bottom of menu | Opens Settings → Connectors | Crosses with [`settings.md`](./settings.md#connectors) |
| Empty state | When no connectors configured | Helpful prompt with "Add connector" call to action | — |
## Connectors list (Settings → Connectors)
See [`settings.md`](./settings.md#connectors) for the surface.
## Add-connector flow
Triggered from the connector picker or Settings.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Connector catalog | Modal body | Searchable list (Slack, GitHub, Linear, Notion, Google Calendar, etc.) | — |
| Per-connector tile | Catalog entry | Logo, name, short description | — |
| **Connect** button | Per tile | Initiates OAuth flow ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | Click → `xdg-open` to provider |
| OAuth in-app overlay (if used) | Replaces system browser handoff in some flows | Embedded login pane | — |
| Permission consent screen | OAuth provider side | Provider's UI; not under our control | — |
| Callback completion | After OAuth completes | Returns to Claude Desktop, connector now in list | If the URL scheme handler is broken, user is stranded in browser |
| Custom connector entry point | Catalog bottom | "Add custom connector via remote MCP" link | — |
## Plugin browser
Triggered by `+`**Plugins****Add plugin**, or from sidebar **Customize****Plugins**.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Plugin browser modal | Opened from menu | Searchable marketplace catalog | — |
| Marketplace selector | Top of modal | Default: Anthropic official; user-configured marketplaces also visible | — |
| Per-plugin tile | Catalog body | Name, author, description, install count | — |
| **Install** button | Per tile | Click installs to `~/.claude/plugins/` ([T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths)) | — |
| Plugin scope selector | Per install | User / Project / Local-only | — |
| Install progress indicator | During install | Spinner + "Installing X..." text | — |
| Install success state | After install | Confirmation; plugin now in **Manage plugins** | — |
| Install error state | On failure | Error message identifying the cause (network, signature, conflict) | — |
## Manage plugins
Triggered by `+`**Plugins****Manage plugins**.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Installed plugins list | Modal body | One row per installed plugin | — |
| Per-plugin row | List item | Name, version, scope (User / Project / Local), enable toggle, uninstall button | — |
| Enable toggle | Per row | Toggles plugin on/off without uninstall | — |
| **Uninstall** button | Per row | Removes plugin files from `~/.claude/plugins/` | Confirmation expected |
| Plugin skills sub-list | Expand row | Lists skills, agents, hooks, MCP servers, LSP configs the plugin contributes | — |
## Failure modes to watch for
| Symptom | Likely cause | Notes |
|---------|--------------|-------|
| Connect OAuth doesn't return to app | Custom URI scheme not registered ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | `xdg-mime query default x-scheme-handler/claude` |
| Plugin browser empty | Marketplace fetch failed; offline | DevTools network panel |
| Install progress stalls | Network / signature verification | Launcher log; check `~/.claude/plugins/.partial/` for incomplete downloads |
| Plugin installed but skills don't appear | Slash menu cache stale; restart session | — |
| Uninstall leaves files | Filesystem permissions; some plugin files owned by root | `find ~/.claude/plugins/ -not -user $USER` |
| Connector "Connected" but tools fail | Token expired; backend refuses; needs reconnect | Disconnect → reconnect |

View File

@@ -1,59 +0,0 @@
# UI — Desktop Notifications
Notification rendering across DEs. The app dispatches notifications via `org.freedesktop.Notifications` (libnotify spec); each DE renders them differently. Related functional tests: [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
## Notification sources
The app posts notifications for the following events. Each should fire reliably on every supported DE.
| Source | Trigger | Expected text | Click action | Notes |
|--------|---------|---------------|--------------|-------|
| Scheduled task fires | When a routine starts a run | "Scheduled task `<name>` started" or similar | Focus the new session in sidebar | Crosses with [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
| Catch-up run | When a missed run starts after wake | "Catching up on `<name>`" + missed-time hint | Focus the catch-up session | Crosses with [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend) |
| CI status change | When PR's CI state resolves | "CI passed for `<branch>`" or "CI failed: `<check>`" | Focus the session with CI bar | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
| PR merged (auto-archive trigger) | When watched PR merges | "PR `<title>` merged. Session archived" | — | Crosses with [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
| Dispatch handoff | When a Dispatch task creates a Code session | "Dispatch session ready: `<task>`" | Focus the new Dispatch-badged session | Crosses with [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
| Permission prompt awaiting approval | When a session in Ask mode needs user approval | "Claude needs your approval" | Focus the awaiting session | Sessions in Ask mode stall until answered |
## Per-notification anatomy
Each notification should include:
| Element | Expected | Notes |
|---------|----------|-------|
| App identity | "Claude" or "Claude Desktop" as the source | DE-specific (Plasma shows the app name and icon prominently) |
| Notification icon | App icon (theme-aware) | Should match the same icon set as the tray |
| Title | Short event headline | One line, no truncation issues for typical lengths |
| Body | One or two short lines of context | Wrap correctly for the DE's notification width |
| Actions (if any) | Inline buttons (e.g. "Open", "Dismiss") | Some DEs show actions, some require expand |
| Click target | Activates the relevant session/window | — |
## Per-DE rendering
| DE / daemon | Expected render | Caveats |
|-------------|-----------------|---------|
| KDE Plasma | KDE notification daemon (KNotifications); appears top-right by default; inline action buttons supported | — |
| GNOME Shell | gnome-shell built-in; appears top-center; limited action support | — |
| Mako (wlroots) | Stacked notifications top-right by default; supports actions if config allows | — |
| Dunst | Lightweight; respects `~/.config/dunst/dunstrc`; actions via keybinds | — |
| swaync (Sway) | Notification center + popups | — |
| Niri | Compositor-provided; usually a portable daemon (mako, dunst) | — |
## Notification persistence
| Element | Expected | Notes |
|---------|----------|-------|
| Notification history | DE-dependent (KDE has notification panel; GNOME has Calendar drawer; mako/dunst can be configured) | Don't rely on persistence — assume fire-and-forget |
| Do-not-disturb mode | Respect DE's DND state | If user has DND on, notifications shouldn't fire — verify the daemon honors this |
## Failure modes to watch for
| Symptom | Likely cause | Diagnose with |
|---------|--------------|---------------|
| No notifications appear | No daemon running; service not registered | `gdbus call --session --dest=org.freedesktop.Notifications --object-path=/org/freedesktop/Notifications --method=org.freedesktop.DBus.Introspectable.Introspect`; `notify-send "test"` from terminal |
| Notification fires but no icon | Icon path resolution failed; theme strip | Inspect the dbus call body for `app_icon` value |
| Click does nothing | Action handler IPC missed; window already focused | Click while main window is hidden — does it appear? |
| Title/body cut off | DE truncation policy | Test with shorter strings to confirm content vs. layout |
| Notifications fire even in DND | Daemon ignoring DND, or our app sets `urgency=critical` inappropriately | Check `urgency` hint in the dbus call |
| Notification persists indefinitely | `expire_timeout=-1` (never) used inappropriately | Confirm timeout passed in the dbus call |
| Per-source duplicates | Multiple subscribers to the same event | Diagnose by isolating one source at a time |

View File

@@ -1,76 +0,0 @@
# UI — Code Tab Prompt Area
The prompt input area is where users type messages, attach files, pick model and permission mode, and trigger send/stop. Related functional tests: [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu).
## Text input
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Input field | Bottom center of session pane | Single-line on focus, expands to multi-line as user types | — |
| Placeholder text | Empty state | Helpful hint ("Type to message Claude...") | — |
| Cursor caret | Inside input | Blinks; visible against any background | — |
| Multi-line autosize | Type a long message | Input grows up to a max height, then scrolls | — |
| Word wrap | Long text | Wraps at field width without horizontal scroll | — |
| Paste plain text | `Ctrl+V` after copying text | Inserts at cursor | — |
| Paste image | `Ctrl+V` after copying an image | Attaches as file (see attachments below) | — |
| `Enter` to send | Press Enter | Submits prompt | — |
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | — |
| `Esc` | Press Esc when prompt has content | DE-dependent; typically does nothing in input | — |
| IME composition | Compose a CJK character | Composition UI renders correctly above the input | Fcitx5/IBus integration |
## Attachments
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Attachment button | Left of input (paperclip icon) | Click opens native file chooser | Wayland: portal-backed |
| File-attached chip | Above or inside input | Shows filename + remove (X) button | — |
| Multiple attachments | Attach 3+ files | Each shows as a separate chip; stacked if needed | — |
| Image preview thumbnail | Image attachments | Shows small thumbnail | — |
| PDF preview | PDF attachments | Shows generic PDF icon + filename | — |
| Drag-drop overlay | Drag a file from file manager into the prompt | Overlay highlight indicates drop zone; release attaches ([T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt)) | — |
| `@filename` autocomplete | Type `@` in prompt | Dropdown shows matching project files | Local and SSH only |
## `+` menu (skills, plugins, connectors)
| Element | Position in menu | Expected | Notes |
|---------|------------------|----------|-------|
| `+` button | Adjacent to attachment button | Click opens menu | — |
| **Slash commands** entry | Top of menu | Opens slash command picker (same as typing `/`) | Crosses with [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
| **Skills** entry | Mid-menu | Opens skill browser | — |
| **Connectors** entry | Mid-menu | Opens connector picker / status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
| **Plugins** entry | Mid-menu | Opens installed plugin list | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser) |
| **Add plugin** subentry | Under Plugins | Opens plugin browser | — |
## Slash menu (triggered by typing `/`)
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Menu container | Above prompt input | Modal-like overlay, scrollable | — |
| Built-in commands section | Top of list | Lists `/btw`, `/compact`, etc. | — |
| Project skills section | Mid-list | Lists skills from `.claude/skills/` | — |
| User skills section | Mid-list | Lists skills from `~/.claude/skills/` | — |
| Plugin skills section | Bottom-list | Lists skills from installed plugins | — |
| Filter by typing | Type after `/` | Narrows the list | — |
| Selected item insertion | `Enter` or click | Inserts highlighted token in prompt | — |
| `Esc` to dismiss | Press Esc | Closes menu, keeps `/` typed | — |
## Pickers next to send button
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Model picker | Right of input | Dropdown of Sonnet, Opus, Haiku (per current plan availability) | `Cmd+Shift+I` opens |
| Permission mode picker | Right of input | Dropdown of Ask, Auto accept, Plan, Auto, Bypass | `Cmd+Shift+M` opens |
| Effort picker (when applicable) | Right of input | Dropdown of effort levels for adaptive-reasoning models | `Cmd+Shift+E` opens |
| Send button | Far right | Click submits prompt | — |
| Stop button | Replaces Send while Claude responding | Click interrupts current response | `Esc` shortcut equivalent |
| Usage ring | Adjacent to model picker | Shows context window usage + plan usage | Click for details |
## Failure modes to watch for
| Symptom | Likely cause | Notes |
|---------|--------------|-------|
| Drag-drop overlay doesn't appear | Electron drag-drop event not firing on Wayland | Try X11 fallback to isolate |
| `@filename` autocomplete returns empty | Project-folder access not granted; folder picker [T17](../cases/code-tab-foundations.md#t17--folder-picker-opens) failed silently | Verify env pill shows the right folder |
| Slash menu shows wrong skills | Settings shared between desktop and CLI ([T36](../cases/extensibility.md#t36--hooks-fire), [T37](../cases/extensibility.md#t37--claudemd-memory-loads)) | Check `~/.claude/skills/` content vs what's listed |
| Send button greyed out unexpectedly | Permission mode or model not loaded | Refresh; check model dropdown |
| IME composition broken | Electron IME pipeline regression | Test with simpler Electron app |

View File

@@ -1,49 +0,0 @@
# UI — Quick Entry Popup
The Quick Entry popup is the global-shortcut-triggered prompt overlay. Related functional tests: [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S09](../cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame), [S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity), [S33](../cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version), [S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone), [S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy).
## Window appearance
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Window frame | None (frameless popup) | No OS-titlebar; no close/min/max buttons | Upstream sets `frame: false` on the BrowserWindow (`index.js:515381`) |
| Background | Behind prompt UI | Transparent (no opaque square frame visible) on KDE Plasma Wayland ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | Upstream already sets both `transparent: true` and `backgroundColor: "#00000000"` (`index.js:515380, 515383`). #370 regression is below the option-passing layer (Electron 41.0.4 CSD rework). KDE-W: pending; bug if opaque |
| Rounded corners | Outer edge of UI | Visible | Compositor must support corner rounding via shaders / clip mask |
| Drop shadow | Around popup | macOS-only at the Electron level; on Linux/Windows depends entirely on compositor | Upstream sets `hasShadow: Zr` where `Zr === process.platform === "darwin"` (`index.js:515384`). Linux is expected to render via compositor shadow support; wlroots without server-side decorations will not show one |
| Position | Last-saved position, keyed on monitor; falls back to primary display if monitor is gone | Popup remembers its position across invocations and across app restarts ([S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone)) | Upstream uses `an.get("quickWindowPosition")` (`index.js:515491-515526`) keyed on monitor label + resolution. Falls back to `cHn()` (`:515502`) when the saved monitor is gone. **Upstream does NOT place on cursor display or focused-window display** — it's last-position or primary, nothing else |
| Always-on-top | Window manager hint | Stays above other windows | Upstream sets `alwaysOnTop: true` with level `"pop-up-menu"` (`index.js:515399`). On macOS this is per-app; on Linux compositors the level hint is interpreted variably |
| Lifecycle | Lazy-created on first shortcut press | First shortcut press constructs the BrowserWindow; subsequent presses reuse it ([S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity)) | Upstream `if (!Ko \|\| ...) Ko = new BrowserWindow(...)` near `index.js:515375`. Means popup works in tray-only state with no main window mapped |
| Persistence after main window destroy | Popup survives `mainWindow.destroy()` | Popup remains functional; submit guards skip show/focus when `ut` is destroyed ([S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy)) | Upstream `!ut \|\| ut.isDestroyed()` guard at `index.js:515595`. Likely unreachable on this project due to hide-to-tray override of X button |
## Input area
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Text input field | Center of popup | Receives focus immediately on open; cursor blinks | — |
| Placeholder text | Empty input state | Shows guidance like "Ask Claude anything..." | — |
| Multi-line autosize | Type a long prompt | Input grows downward as text wraps; popup grows with it | — |
| `Enter` to submit | Press Enter | Sends prompt, closes popup. Prompt must be > 2 chars trimmed (`index.js:515530, 515533`); 1-2 char prompts are silently dropped | Renderer-side keymap; reaches main process via IPC `requestDismissWithPayload()` (`:515409`) |
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | Renderer-side |
| `Esc` to dismiss | Press Esc | Closes popup without submitting | Renderer-side; reaches main process via IPC `requestDismiss()` (`:515409`) |
| Click outside | Click outside the popup window | Closes popup without submitting | Wired in **main process** via the popup's `blur` handler (`Ko.on("blur", () => g3A(null))` at `index.js:515465`) |
| Paste behavior | Paste rich text | Text-only paste; no HTML residue | — |
| IME / dead-key composition | Type composed characters | Composition UI renders correctly above the input | Fcitx5/IBus integration is fragile under Electron |
## Submit feedback
| Element | Trigger | Expected | Notes |
|---------|---------|----------|-------|
| Submit transition | Press Enter | Popup closes; main window navigates to a **new** chat session ([S31](../cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state)). Quick Entry never appends to existing chats — `ynt(e)` at `index.js:515546` always creates new | Upstream calls `mainWin.show()` + `mainWin.focus()` only — no `restore()`, no workspace migration. Behavior on minimized / hidden / cross-workspace main is compositor-dependent |
| Loading indicator | While prompt is in flight | Brief spinner or fade-out — popup should not appear frozen | — |
| Error state | Submit when offline / API error | Inline error message; popup stays open so user can retry | — |
## Failure modes to watch for
| Symptom | Likely cause | Diagnose with |
|---------|--------------|---------------|
| Popup doesn't appear when shortcut pressed | Global shortcut not registered ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S11](../cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab), [S14](../cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) | Launcher log; portal `BindShortcuts` outcome |
| Opaque square frame visible behind UI | Transparent background not respected ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | KDE compositor settings; BrowserWindow `transparent: true` arg |
| Popup appears but input doesn't auto-focus | Focus stealing prevention by compositor; race in BrowserWindow `show()` + `focus()` | Wayland focus-request semantics; mutter is most strict |
| IME composition cursor renders in wrong place | Electron IME integration bug | Try with simpler GTK app to isolate; report upstream Electron issue if reproducible |
| Popup persists after submit | Close-on-submit IPC missed | Launcher log; DevTools console (if reachable on the popup window) |
| Popup appears on wrong monitor / wrong workspace | Compositor places frameless windows differently | Test with `xdotool getactivewindow` (X11) before/after |

View File

@@ -1,72 +0,0 @@
# UI — Routines Page
The Routines page hosts the list of scheduled tasks (local and remote), the new-routine form, and per-routine detail views. Related functional tests: [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend).
## Routines list
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Routines page link | Code-tab sidebar | Click opens the page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
| Page header | Top of page | Title "Routines" + description | — |
| **New routine** button | Top-right of page | Click shows Local / Remote selector | — |
| Routines list | Page body | Lists all configured routines | — |
| Per-routine row | List item | Name, schedule summary, last-run timestamp, status indicator | — |
| Run-now icon | Per row, hover-revealed | Click triggers immediate run ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | — |
| Pause / resume toggle | Per row | Pauses or resumes scheduled runs without deleting | — |
| Click row | Per row | Opens routine detail page | — |
## New routine form (Local)
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Routine type selector | Top of form | Local / Remote tabs or radio | — |
| **Name** field | Top of form | Required; converted to lowercase kebab-case for filesystem | — |
| **Description** field | Below name | Optional one-liner shown in list | — |
| **Instructions** textarea | Mid-form | Rich textarea for the prompt | — |
| Permission mode picker | Within Instructions area | Same options as session: Ask, Auto accept, Plan, Auto, Bypass | — |
| Model picker | Within Instructions area | Sonnet, Opus, Haiku per plan | — |
| **Working folder** picker | Below Instructions | Required; opens native file chooser | If folder not yet trusted, app prompts to trust |
| **Worktree** toggle | Below folder | When ON, each run gets its own isolated worktree | — |
| **Schedule** preset | Bottom of form | Manual / Hourly / Daily / Weekdays / Weekly | — |
| Time picker | Visible for Daily, Weekdays, Weekly | Defaults to 9:00 AM local | — |
| Day picker | Visible for Weekly only | Day-of-week selector | — |
| **Save** button | Bottom-right | Disabled until required fields filled | — |
| **Cancel** button | Bottom-left | Discards form, returns to list | — |
| Folder-trust prompt | Triggered when folder not trusted | Modal asking to trust the selected folder | Required before save |
## New routine form (Remote)
Per upstream docs, remote routines run on Anthropic-managed cloud infrastructure. The form has additional fields for connectors and trigger types (cron, API, GitHub event). On Linux, the Remote tab should function identically to other platforms.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Trigger type selector | Top of form | Schedule / API call / GitHub event | — |
| Connectors picker | Per-routine basis (remote) | Configures connectors at routine creation | — |
| Network access controls | If applicable | Tied to cloud environment config | — |
## Routine detail page
Per upstream docs.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| **Run now** button | Top of page | Starts the task immediately | — |
| Status toggle (Active / Paused) | Top of page | Pauses or resumes without deleting | — |
| **Edit** button | Top of page | Opens the same form populated with current values | — |
| **Delete** button | Top of page (or footer) | Removes routine; archives all sessions it created | Confirmation dialog expected |
| **Review history** section | Page body | Lists every past run with timestamp and status | — |
| Per-history-entry hover | Hover skipped runs | Tooltip explains why skipped (asleep, prior run still running, other concurrent task) | — |
| **Show more** button | Bottom of history | Loads older entries | — |
| **Always allowed** panel | Page body | Lists tools auto-approved for this routine | — |
| Revoke approval | Per-tool entry | Removes the auto-approval | — |
## Failure modes to watch for
| Symptom | Likely cause | Notes |
|---------|--------------|-------|
| Folder-trust modal doesn't appear | Trust state cached incorrectly | Clear `~/.claude/trusted-folders` (or equivalent) and retry |
| Save button never enables | Required fields validation regression | DevTools console |
| Time picker truncates / clips | Modal sizing on small viewports | Resize Settings window to reproduce |
| History tooltips don't render | Tooltip component regression | — |
| Run-now does nothing | Task runner thread not started | Launcher log; `pgrep -af claude` for runner subprocess |
| Routines page blank | Code-tab failure ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) cascading | Confirm Code tab itself loads first |

View File

@@ -1,87 +0,0 @@
# UI — Settings
The Settings window holds Desktop app preferences, Claude Code settings, connector management, and account controls. Related functional tests: [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge).
## Settings root
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Settings window | Opened via app menu, tray menu, or in-app shortcut | Window opens with sidebar nav and content area | — |
| Window close button | Top-right (or top-left on GNOME) | Closes settings; main app continues running | — |
| Sidebar nav | Left of window | Lists every settings page | — |
## Desktop app → General
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| **Computer use** toggle | Top of page | Either absent on Linux, or rendered disabled with a "not supported on Linux" hint ([S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux)) | Critical: must not appear functional |
| **Keep computer awake** toggle | Mid-page | Toggles `systemd-inhibit --what=idle:sleep` lock ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify with `systemd-inhibit --list` |
| **Denied apps** list | Computer-use related | Likely absent on Linux (computer use unsupported) | — |
| **Unhide apps when Claude finishes** toggle | Computer-use related | Likely absent on Linux | — |
| Theme picker (if exposed) | Mid-page | System / Light / Dark | Tray icon should respond ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) |
## Desktop app → Account
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Account name / email | Top of page | Reflects signed-in identity | — |
| Plan badge | Below name | Shows Pro / Max / Team / Enterprise | — |
| Sign out button | Bottom of page | Signs out cleanly; subsequent launches show sign-in screen | — |
## Claude Code
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| **Worktree location** | Top of page | Default: `<project-root>/.claude/worktrees/`. Editable to a custom directory | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation) |
| **Branch prefix** | Mid-page | Optional prefix prepended to every worktree branch | — |
| **Auto-archive after PR merge or close** toggle | Mid-page | When ON, sessions archive on PR resolution ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | — |
| **Persist preview sessions** toggle | Mid-page | Toggles cookies/localStorage persistence in Preview pane | Crosses with [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane) |
| **Preview** toggle | Mid-page | When OFF, preview pane and auto-verify are disabled | — |
| **Allow bypass permissions mode** toggle | Mid-page | When ON, exposes Bypass mode in mode picker | Enterprise admins can disable |
| **Auto** mode availability | Mid-page | Research preview; not on Pro plans | Per upstream docs |
## Connectors
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Connectors list | Page content | Lists connected services with status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
| Per-connector entry | List row | Name, last-connected timestamp, manage / disconnect buttons | — |
| **Manage** button | Per row | Opens connector-specific settings | — |
| **Disconnect** button | Per row | Revokes access; connector becomes unusable in subsequent sessions | — |
| **Add connector** button | Top of page | Opens the connector picker (same surface as `+ → Connectors`) | — |
## SSH connections
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| SSH connections list | Page content | Lists user-added + managed (read-only) connections | — |
| **Add SSH connection** button | Top of page | Opens dialog with Name / SSH Host / SSH Port / Identity File fields | — |
| Per-connection entry | List row | Edit / delete (user-added) or "Managed" badge (admin-distributed) | — |
## Keyboard shortcuts
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Shortcut list | Page content | Tabular list of all configurable shortcuts | — |
| Shortcut value | Per row | Click to rebind; shows current binding | — |
| Reset to default | Per row | Reverts to upstream default | — |
| Quick Entry shortcut | Specifically called out | Default `Ctrl+Alt+Space`; rebind here | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
## Local environment editor
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Env editor open | Environment dropdown → Local → gear icon | Opens encrypted env-var editor | Crosses with [S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot) |
| Add variable | In editor | Name + value fields; save | — |
| Remove variable | Per row | Deletes the variable | — |
| **Apply to dev servers** indicator | Near save | Confirms vars also reach preview servers | — |
## Failure modes to watch for
| Symptom | Likely cause | Notes |
|---------|--------------|-------|
| Computer-use toggle visible and toggleable on Linux | [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux) regression | File a bug; users will be misled |
| Keep-computer-awake toggle has no effect | `systemd-inhibit` integration not wired ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify lock list before/after |
| Worktree location field rejects valid paths | Path validation too strict; absolute vs `~`-prefixed | Check both forms |
| SSH connection list missing managed entries | Managed-settings file not loaded; admin distribution failed | Confirm file exists at expected path |
| Env editor not encrypting | Linux secret-store not wired ([S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot)) | `secret-tool search`; `kwallet5-query` |

View File

@@ -1,55 +0,0 @@
# UI — Code Tab Sidebar
The sidebar lists Code-tab sessions, lets you filter, group, archive, and rename. Related functional tests: [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
## Top controls
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| **+ New session** button | Top of sidebar | Click opens a new session against the currently selected env. `Ctrl+N` shortcut equivalent | — |
| **Routines** link | Top of sidebar | Click opens the Routines page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
| **Customize** link | Top of sidebar | Click opens connectors / skills / plugins manager | — |
| Filter: status | Top of session list | Dropdown / tabs filter by Active / Archived / All | — |
| Filter: project | Top of session list | Dropdown filters by project (multi-select) | — |
| Filter: environment | Top of session list | Dropdown filters by Local / Remote / SSH / All | — |
| Group-by control | Top of session list | Toggle between flat list and grouped-by-project | — |
## Session row
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Session title | Row content | Shows session name (auto-generated or user-renamed) | Click row → switches to that session |
| Session status indicator | Left of title or as colored dot | Reflects state: idle, running, awaiting-approval, errored, archived | — |
| Project / branch label | Below title | Shows project folder name + branch | — |
| Diff stats badge (e.g. `+12 -1`) | Right of title | Visible when session has uncommitted changes | Click → opens diff view |
| **Dispatch** badge | Top-right of row | Visible on Dispatch-spawned sessions ([S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification)) | — |
| **Scheduled** badge | Top-right of row | Visible on scheduled-task-spawned sessions ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | Sessions group under "Scheduled" header |
| Hover archive icon | Right side, on row hover | Click archives the session and removes its worktree | — |
| Right-click context menu | Right-click on row | Standard menu: Rename, Archive, Open in Files, Copy path | — |
| Active session highlight | Selected row | Visually distinct from inactive rows | — |
## Sidebar layout
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Sidebar resize handle | Right edge of sidebar | Drag to resize; double-click to reset width | — |
| Sidebar collapse toggle | Top of sidebar (hamburger or arrow) | Collapse to icons-only or hide entirely | Crosses with topbar hamburger |
| Scrollbar | Right edge when content exceeds height | Renders, drags work | Theme-aware |
## Cycling shortcuts
| Shortcut | Expected | Notes |
|----------|----------|-------|
| `Ctrl+Tab` | Cycle to next session | Per upstream docs |
| `Ctrl+Shift+Tab` | Cycle to previous session | Per upstream docs |
| `Cmd+Shift+]` / `Cmd+Shift+[` | Same as above on macOS | N/A on Linux unless rebound |
## Failure modes to watch for
| Symptom | Likely cause | Notes |
|---------|--------------|-------|
| Sidebar doesn't render | Code tab failed to load ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | Check DevTools console |
| Sessions appear but clicking does nothing | IPC between sidebar and session pane broken | Launcher log, DevTools console |
| Hover archive icon never appears | CSS hover state mis-applied; touch device might be assumed | Inspect element; check pointer events |
| Dispatch / Scheduled badges missing | Feature flag or state not reaching the renderer | Check session metadata in launcher log |
| Auto-archive doesn't fire | Session-archive logic bug ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | Confirm setting enabled; check PR state via `gh pr view` |

View File

@@ -1,44 +0,0 @@
# UI — System Tray
Tray icon, menu, and theme variants. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests ([T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)).
## Tray icon
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Tray icon (light theme) | System tray / status area | Black icon (the "Template" variant) renders cleanly on a light tray | — |
| Tray icon (dark theme) | System tray / status area | White icon (the "Template-Dark" variant) renders cleanly on a dark tray | — |
| Theme switch | Trigger system theme change | Icon updates in place — no duplicate icons spawned ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | KDE-W ✓ via in-place fast-path |
| Icon resolution / sharpness | Inspect at native scale | Icon is crisp, not pixelated. Check on HiDPI screens | — |
| Position | Tray area | Appears among other SNI/tray icons | KDE Plasma sorts alphabetically by ID; adjusting position requires user config |
| Tooltip on hover | Hover over icon | Shows "Claude" or app name | — |
## Right-click menu
| Element | Position in menu | Expected | Notes |
|---------|------------------|----------|-------|
| Show / Hide window | Top item | Toggles main window visibility | Label may change between "Show" and "Hide" based on state |
| Quick Entry | Mid-menu | Opens Quick Entry popup ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused)) | — |
| Open at Login (toggle) | Mid-menu | Reflects current XDG autostart state ([T09](../cases/platform-integration.md#t09--autostart-via-xdg)) | Toggle should write `~/.config/autostart/*.desktop` |
| Settings | Mid-menu | Opens Settings window | — |
| About | Bottom area | Opens About dialog | — |
| Quit | Bottom item | Fully exits the app (no hide-to-tray) | — |
| Menu separators | Between item groups | Render cleanly | — |
## Left-click behavior
| Element | Trigger | Expected | Notes |
|---------|---------|----------|-------|
| Single left-click | Click tray icon once | Toggles main window visibility | KDE-W ✓ |
| Double left-click | Click twice quickly | DE-dependent; should not spawn duplicate windows | — |
| Middle-click | Middle mouse button on tray icon | DE-dependent (no documented behavior); should not crash | — |
## Failure modes to watch for
| Symptom | Likely cause | Diagnose with |
|---------|--------------|---------------|
| Tray icon never appears | No SNI watcher (e.g. GNOME without AppIndicator extension); Electron fallback to legacy XEmbed not registered | `gdbus call ... org.kde.StatusNotifierWatcher` — see [runbook](../runbook.md#tray--dbus-state-kde) |
| Two tray icons after theme switch | Tray rebuild race ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | SNI watcher state before/after; [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md) |
| Icon renders as a generic placeholder | Icon path resolution failed; theme mismatch | Check Electron `Tray` constructor args; check `~/.cache/claude-desktop-debian/launcher.log` |
| Menu items don't respond | IPC bridge to tray menu broken; main process busy | Click main window — does the rest of the app respond? `pgrep -af claude`; main process state |
| Tray icon disappears after some time | Tray daemon restarted; Claude didn't re-register | KDE Plasma: restart `plasmashell`; observe whether icon comes back without restarting Claude |

View File

@@ -1,58 +0,0 @@
# UI — Window Chrome & Tabs
OS-level window frame plus the in-app tab strip and (PR #538) hybrid in-app topbar. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests.
## OS window frame
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Title bar | Top of window | Drawn by DE/compositor; shows app title; right-click opens window menu | KDE-W ✓; Hypr-N ✓ |
| Close button (X) | Top-right (or top-left on GNOME) | Renders, hover state visible, click hides-to-tray ([T08](../cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close)) | — |
| Minimize button | Adjacent to close | Renders, hover state visible, click minimizes | — |
| Maximize / restore button | Adjacent to minimize | Renders, hover state visible, click toggles maximize | — |
| Resize edges (left, right, top, bottom, corners) | Window perimeter | Cursor changes to resize affordance on hover; drag resizes | Wlroots compositors may not show cursor change |
| Window menu (right-click titlebar) | Right-click anywhere on titlebar | Standard window menu (Move, Resize, Close, Always on Top, etc.) | DE-dependent |
## Hybrid in-app topbar (PR #538 builds)
Sits below the OS frame in hybrid mode. Crosses with [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) and [S13](../cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports).
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| Hamburger menu | Top-left of topbar | Renders, click opens sidebar | — |
| Sidebar toggle | Adjacent to hamburger | Renders, click collapses/expands sidebar | — |
| Search icon | Center-left | Renders, click opens search overlay | — |
| Back arrow | Center | Renders, greyed out when no history; click navigates back | — |
| Forward arrow | Adjacent to back | Same as back, but for forward history | — |
| Cowork ghost icon | Right of nav arrows | Renders, click opens Cowork tab | The icon is the canonical "is the topbar shim alive" indicator |
| Drag region (gaps between buttons) | Empty space between elements | Drag region behaves correctly — buttons remain clickable, no implicit drag region capturing button clicks | Critical: this is the regression mode in [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
## Tab strip (Chat / Cowork / Code)
Sits in the topbar (hybrid) or in the OS-frame area (legacy). Top center.
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| **Chat** tab | Left tab | Renders, click switches to Chat | — |
| **Cowork** tab | Center tab | Renders, click switches to Cowork; ghost icon may indicate Dispatch state | — |
| **Code** tab | Right tab | Renders, click switches to Code; on Linux, may show 403 / sign-in upsell ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | — |
| Active tab indicator | Underline / fill on active tab | Visually distinct from inactive tabs | — |
| Tab badges (e.g. unread count, Dispatch badge) | Top-right of each tab | Render when applicable, dismiss when state clears | — |
## Other window-level UI
| Element | Location | Expected | Notes |
|---------|----------|----------|-------|
| About dialog | App menu → About | Modal opens with app version, Electron version, license info; close button works | — |
| App menu (macOS-style) | macOS only — N/A on Linux | Not present on Linux; menu items are in window menu instead | — |
| Update prompt | Triggered by upstream update detection | On DEB/RPM, auto-update path is suppressed ([S26](../cases/distribution.md#s26--auto-update-is-disabled-when-installed-via-apt--dnf)). On AppImage, may surface a prompt | — |
| Crash report dialog | Shown after a crash | Dialog explains what happened, offers to file an issue | Capture for Linux specifics — wording may reference macOS Console / Windows Event Viewer paths only |
## Display-server cross-cuts
| Concern | X11 | Wayland (mutter) | Wayland (KWin) | Wayland (wlroots) |
|---------|-----|-------------------|----------------|---------------------|
| HiDPI scaling | `--force-device-scale-factor=N` works | Auto via fractional scaling | Auto via fractional scaling | Auto where compositor supports it |
| Drag-to-snap (Aero-style) | Works under most WMs | mutter snaps | KWin snaps | Compositor-dependent |
| Always-on-top | Window menu | Window menu | Window menu | Compositor-dependent |
| Cursor theme | Inherits from `gtk-cursor-theme-name` | Same | Same | Same |

617
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,617 @@
[< Back to README](../README.md)
# Troubleshooting
## Built-in Diagnostics
Run the `--doctor` flag to check your system for common issues:
```bash
# Deb install
claude-desktop --doctor
# AppImage
./claude-desktop-*.AppImage --doctor
```
This runs a series of checks and prints pass/fail results with
suggested fixes:
| Check | What it verifies |
|-------|-----------------|
| Installed version | Package version via dpkg |
| Display server | Wayland/X11 detection and mode |
| 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) |
| Desktop entry | `.desktop` file presence |
| Disk space | Free space on config partition |
| Log file | Log file size |
Example output:
```
Claude Desktop Diagnostics
================================
[PASS] Installed version: 1.1.4498-1.3.15
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
[PASS] Chrome sandbox: permissions OK
[PASS] SingletonLock: no lock file (OK)
[PASS] MCP config: valid JSON
[PASS] Node.js: v22.14.0
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
[PASS] Disk space: 632284MB free
[PASS] Log file: 1352KB
All checks passed.
```
When opening an issue, include the output of `--doctor` to help with diagnosis.
## Application Logs
Runtime logs are available at:
```
~/.cache/claude-desktop-debian/launcher.log
```
## Common Issues
### Window Scaling Issues
If the window doesn't scale correctly on first launch:
1. Right-click the Claude Desktop tray icon
2. Select "Quit" (do not force quit)
3. Restart the application
This allows the application to save display settings properly.
### Global Hotkey Not Working (Wayland)
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
See [configuration.md](configuration.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
### Keyboard Input Doesn't Work (IBus / GTK Input Method)
If typing into the chat does nothing, characters get swallowed, or
dead-key sequences (e.g. ``` `e ``` → `è`) don't compose, your GTK
input module integration with the Electron-bundled GTK is broken.
Common symptoms:
- No characters appear when typing into any text field
- The first keystroke after focus is dropped, subsequent ones work
- CJK input methods (IBus, Fcitx) not engaging
- Compose key / dead-key sequences silently drop
**First step: run `claude-desktop --doctor`.** It checks for the
common misconfigurations and prints fix commands inline:
- `ibus-gtk3` package missing while `GTK_IM_MODULE=ibus`
- GTK immodules cache stale (the active module isn't listed by
`gtk-query-immodules-3.0`)
- XWayland session routing IBus through XIM (lossy for some IMEs —
set `CLAUDE_USE_WAYLAND=1` to use native Wayland IME)
- Active value of `CLAUDE_GTK_IM_MODULE` if you've set the override
If `--doctor` is clean but input still misbehaves, switch the
launcher to a different GTK input module. Set `CLAUDE_GTK_IM_MODULE`
and Claude Desktop will propagate it as `GTK_IM_MODULE` to Electron
at startup:
```bash
# Bypass IBus entirely — uses the X Input Method (XIM) protocol
CLAUDE_GTK_IM_MODULE=xim claude-desktop
# To make it persistent, export it from your shell profile:
# echo 'export CLAUDE_GTK_IM_MODULE=xim' >> ~/.profile
```
Valid values: anything your GTK installation supports (`xim`, `ibus`,
`fcitx`, `simple`, etc.). When the override is active, the launcher
logs a line to `~/.cache/claude-desktop-debian/launcher.log`:
```
GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)
```
**Trade-off:** `xim` is the lowest-common-denominator input module
and does not support advanced IME features like CJK candidate
windows or rich compose-key sequences. Only reach for it if your
real input method (IBus/Fcitx) is broken; if you depend on CJK or
compose, prefer fixing the IBus/Fcitx integration instead.
### Repeated Electron Crashes / GPU Process FATAL ([#583](https://github.com/aaddrick/claude-desktop-debian/issues/583))
If Claude Desktop crashes repeatedly on launch or shortly after,
the most common cause on Linux is the Chromium GPU process hitting
a FATAL exhaustion path. `claude-desktop --doctor` surfaces this
when `systemd-coredump` shows 3+ Electron crashes in the last 7
days, pointing at this issue.
Two ways to disable hardware acceleration as a workaround:
1. **In-app:** Settings → toggle hardware acceleration off →
restart Claude Desktop. Persists in the upstream config.
2. **Env var (headless / persists across reinstalls):** set
`CLAUDE_DISABLE_GPU=1` in the environment before launching.
```bash
# One-off:
CLAUDE_DISABLE_GPU=1 claude-desktop
# Persistent (shell profile):
echo 'export CLAUDE_DISABLE_GPU=1' >> ~/.profile
```
When `CLAUDE_DISABLE_GPU=1` is set, the launcher passes
`--disable-gpu --disable-software-rasterizer` to Electron (see
`scripts/launcher-common.sh`). This is the same pair of flags
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
Settings, when running in environments with no GPU available
(XRDP, headless CI smoke tests, some VMs), or when you want the
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.
For enhanced security, consider:
- Using the .deb package instead
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
- Using Gear Lever's integrated AppImage management for better isolation
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
**Cause:** Ubuntu 24.04+ sets `apparmor_restrict_unprivileged_userns=1`. This blocks the user namespaces Cowork's bubblewrap sandbox needs.
**Symptom:** `claude-desktop --doctor` shows `Cowork isolation: host-direct (bwrap probe failed)`.
**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'
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
include if exists <local/bwrap>
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap
```
**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.
**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.
**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"
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
```bash
COWORK_VM_BACKEND=bwrap claude-desktop
```
See [configuration.md](configuration.md#cowork-backend) for how to make this permanent.
### Cowork: virtiofsd not found (Fedora/RHEL)
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
outside `$PATH`. The `--doctor` check detects it there automatically and will
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
resolves it through `$PATH` only.
**Fix:** Create a symlink so the KVM backend can find it at runtime:
```bash
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
```
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
### Cowork: cross-device link error on Fedora tmpfs /tmp
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
```bash
mkdir -p ~/.config/Claude/tmp
TMPDIR=~/.config/Claude/tmp claude-desktop
```
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
### Cowork: ENAMETOOLONG on encrypted home (eCryptfs)
Cowork sessions can fail with an opaque `ENAMETOOLONG` error when
`$HOME` is on a filesystem with a short filename limit. The common
case is **eCryptfs** — the legacy "encrypted home" option on older
Ubuntu and Linux Mint installs, which caps individual filenames at
143 chars because of filename-encryption overhead. Standard
filesystems (ext4, btrfs, xfs, zfs) cap at 255 chars and are fine.
**Why it happens:** Claude Code creates one directory per session
under `~/.claude/projects/`, named after the sanitized host CWD. For
cowork sessions the host CWD is the deeply nested outputs dir under
`~/.config/Claude/local-agent-mode-sessions/<accountId>/<orgId>/local_<uuid>/outputs`,
which sanitizes to ~180 chars — fits ext4 but exceeds the eCryptfs
143-char ceiling.
**Diagnosis:** `claude-desktop --doctor` detects this automatically
and emits a `[WARN] Filename limit: NAME_MAX=143…` line, plus an
eCryptfs-specific hint when the filesystem type matches. You can
also check by hand:
```bash
df -T $HOME # look for type "ecryptfs"
getconf NAME_MAX $HOME # eCryptfs reports 143; ext4 reports 255
```
**Workaround:** move Claude's data onto a separate LUKS-encrypted
ext4 volume (NAME_MAX = 255) and symlink the original paths back.
`~/.claude/` is the critical one — that's where Claude Code creates
the long-named per-session dirs that overflow the limit — and
`~/.config/Claude/` plus `~/.cache/claude-desktop-debian/` are
relocated alongside it so all Claude state lives on the same volume.
This keeps the data encrypted at rest while sidestepping the
eCryptfs filename-length cap.
```bash
# 1. Create a 2 GB LUKS container
sudo dd if=/dev/urandom of=/opt/claude-secure.img bs=1M count=2048 \
status=progress
sudo cryptsetup luksFormat /opt/claude-secure.img
sudo cryptsetup open /opt/claude-secure.img claude-secure
sudo mkfs.ext4 /dev/mapper/claude-secure
# 2. Mount and move Claude's data in
sudo mkdir -p /mnt/claude-secure
sudo mount /dev/mapper/claude-secure /mnt/claude-secure
sudo chown "$USER:$USER" /mnt/claude-secure
mv ~/.config/Claude /mnt/claude-secure/Claude-config
mv ~/.cache/claude-desktop-debian /mnt/claude-secure/claude-cache
# ~/.claude may not exist yet on a fresh install — create the target
# either way so the symlink below resolves.
if [ -e ~/.claude ]; then
mv ~/.claude /mnt/claude-secure/claude-home
else
mkdir -p /mnt/claude-secure/claude-home
fi
ln -s /mnt/claude-secure/Claude-config ~/.config/Claude
ln -s /mnt/claude-secure/claude-cache ~/.cache/claude-desktop-debian
ln -s /mnt/claude-secure/claude-home ~/.claude
# 3. Verify the filename limit and the symlinks
getconf NAME_MAX /mnt/claude-secure # should print 255
mountpoint /mnt/claude-secure # confirms the volume is mounted
readlink ~/.claude # /mnt/claude-secure/claude-home
readlink ~/.config/Claude # /mnt/claude-secure/Claude-config
```
**If you've set `CLAUDE_CONFIG_DIR`** (or otherwise reconfigured
Claude Code to use a directory other than `~/.claude/`), the
`~/.claude` symlink above doesn't apply — adapt the path to wherever
your Claude Code config actually lives. The constraint is the same:
the directory tree where Claude Code creates per-session project
dirs must sit on a filesystem with `NAME_MAX` ≥ ~200.
**Auto-mount at login** with `pam_mount` so the volume unlocks
without a manual `cryptsetup open`:
```bash
sudo apt install libpam-mount
```
Add a `<volume>` entry to `/etc/security/pam_mount.conf.xml`
(replace `YOUR_USERNAME` with your login name):
```xml
<volume user="YOUR_USERNAME" fstype="crypt"
path="/opt/claude-secure.img"
mountpoint="/mnt/claude-secure"
options="" />
```
`libpam-mount` registers itself with `/etc/pam.d/common-auth` and
`/etc/pam.d/common-session` automatically on install.
**Notes:**
- Tested on Linux Mint with LightDM as the display manager.
- **LUKS passphrase tradeoff:** for `pam_mount` to unlock silently
at login the LUKS passphrase must match your login password. That
means one compromise unlocks both your session and the encrypted
volume — equivalent to the threat surface eCryptfs already had,
but worth a deliberate choice. Use a distinct LUKS passphrase if
you'd rather be prompted on each unlock.
- **Confidentiality posture vs eCryptfs.** The LUKS image lives at
`/opt/claude-secure.img`, outside `$HOME` and outside whatever
encryption envelope eCryptfs gives you. If `pam_mount` ever fails
silently — wrong passphrase, mount race at login, profile error —
Claude won't start (the symlink targets won't exist), so writes
fail loudly rather than landing on plaintext disk. Verify with
`mountpoint /mnt/claude-secure` after login if you're unsure.
- 2 GB is a conservative starting size; the Claude config
directory can exceed 500 MB once cowork session history
accumulates. Resize if needed.
- This is a system-wide change that affects login flow — review
the pam_mount config against your threat model before applying.
Credit: reported with detailed `--doctor` output by
[@michelsfun](https://github.com/michelsfun); LUKS-volume workaround
contributed by [@proffalken](https://github.com/proffalken) in
[#590](https://github.com/aaddrick/claude-desktop-debian/issues/590).
### Authentication Errors (401)
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
1. Close Claude Desktop completely
2. Edit `~/.config/Claude/config.json`
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
4. Save the file and restart Claude Desktop
5. Log in again when prompted
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
## Uninstallation
### For APT repository installations (Debian/Ubuntu)
```bash
# Remove package
sudo apt remove claude-desktop
# Remove the repository and GPG key
sudo rm /etc/apt/sources.list.d/claude-desktop.list
sudo rm /usr/share/keyrings/claude-desktop.gpg
```
### For DNF repository installations (Fedora/RHEL)
```bash
# Remove package
sudo dnf remove claude-desktop
# Remove the repository
sudo rm /etc/yum.repos.d/claude-desktop.repo
```
### For AUR installations (Arch Linux)
```bash
# Using yay
yay -R claude-desktop-appimage
# Or using paru
paru -R claude-desktop-appimage
# Or using pacman directly
sudo pacman -R claude-desktop-appimage
```
### For .deb packages (manual install)
```bash
# Remove package
sudo apt remove claude-desktop
# Or: sudo dpkg -r claude-desktop
# Remove package and configuration
sudo dpkg -P claude-desktop
```
### For .rpm packages
```bash
# Remove package
sudo dnf remove claude-desktop
# Or: sudo rpm -e claude-desktop
```
### For AppImages
1. Delete the `.AppImage` file
2. Remove the `.desktop` file from `~/.local/share/applications/`
3. If using Gear Lever, use its uninstall option
### Remove user configuration (all formats)
```bash
rm -rf ~/.config/Claude
```

View File

@@ -0,0 +1,164 @@
# Upstream report draft: MCP double-spawn (issue #546)
This is the draft for the upstream bug report covering [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Filing target is `anthropics/claude-code` GitHub Issues, with an in-app `/bug` from Claude Desktop as a complement so the report ties to build telemetry.
## Template mismatch note
The `anthropics/claude-code` bug template is built for the Claude Code CLI, not Claude Desktop. Required fields like "Claude Code Version" and "Terminal/Shell" don't apply cleanly. Other Claude Desktop bug reports in the same repo work around this by putting `N/A — Claude Desktop <version>` in the version field and selecting `Other` for terminal (see #43705, #36319, #14807).
## Title
```
[BUG] Claude Desktop 1.5354.0: stdio MCP servers double-spawn from independent CCD/LAM coordinator registries
```
## Form fields
### Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
### What's Wrong?
I maintain [claude-desktop-debian](https://github.com/aaddrick/claude-desktop-debian) (~2,300 package downloads/day across the last 3 releases), which repackages the Windows Electron build for Linux. I was reading the MCP spawn path in 1.5354.0 and found that stdio MCP servers configured in `claude_desktop_config.json` get spawned twice when both the chat panel and Code/Agent panel are active.
The user-visible symptom is two `node` processes per MCP, both children of the Electron main PID. Killing one disconnects one panel and the other keeps working. They're independent client/server pairs with no failover between them.
The original symptom report came from @communitytranslations against an earlier build (tracked in our repo as #526). I went back and read the bundle to confirm the cause. What I found was different from what we'd previously documented.
CCD wraps the spawn path in a per-key promise queue keyed by server name. It shuts down any prior entry in its global registry Map before respawning. That's correct dedup within CCD. But LAM (`LocalMcpServerManager`) has its own `this.connections` Map and its own `getOrCreateConnection` path. It never consults CCD's registry.
CCD and LAM each maintain independent spawn lifecycle management. They each spawn their own copy of the same MCP server. The double-spawn is structural in the current architecture. Each coordinator legitimately holds its own connection.
There's also a third coordinator class, `SshMcpServerManager`, that follows the same per-coordinator-registry pattern. It uses an SSH transport, so it doesn't contribute to local-node double-spawn directly. Its existence suggests per-coordinator isolated state is a deliberate pattern, not a one-off.
Secondary bug worth flagging while you're in this code. The `child_process.spawn` wrapper does proper signal escalation (end stdin, wait 2s, SIGTERM, wait 2s, SIGKILL). The `utilityProcess.fork` wrapper doesn't. It sends `process.kill()` (default SIGTERM), waits 5s, then calls `kill()` again with the same default signal. No SIGKILL escalation. A built-in-node MCP server that ignores SIGTERM could leak as an orphaned utility process.
### What Should Happen?
One process per stdio MCP server entry in `claude_desktop_config.json`, regardless of how many panels are open. Resource-side that means no more 2x memory and 2x stdin/stdout traffic per server. User-side that means `ps` shows one entry per declared server.
The fix is architectural. CCD and LAM share a registry, or the local-spawn factory dedups at the transport layer, or LAM proxies through CCD when running in-process. Any of those would collapse the duplication.
### Error Messages/Logs
The user-facing log prefixes are stable across releases. Grep `~/.config/Claude/logs/` for:
```
[CCD]
[LAM]
[LocalMcpServerManager]
[SshMcpServerManager]
```
For the spawn lifecycle specifically, look for:
```
"Launching MCP Server: <name>" (CCD spawn entry)
"Shutting down MCP Server: <name>" (CCD shutdown entry)
"local-mcp-server-cleanup" (LAM cleanup path)
```
Two of these per declared MCP server is the diagnostic signal.
### Steps to Reproduce
1. Linux host running Claude Desktop at or near 1.5354.0
2. Declare at least one stdio MCP server in `~/.config/Claude/claude_desktop_config.json`
3. Open Claude Desktop, start a session, open the Code/Agent panel and let it initialize fully (the original report waited about 5 minutes)
4. `ps -ef | grep <server-binary-name>`
Expected: 1 process per MCP. Actual: 2 processes per MCP, both children of the same Electron main PID.
### Claude Model
Not sure / Multiple models
### Is this a regression?
I don't know
### Last Working Version
(leave blank)
### Claude Code Version
```
N/A — this is a Claude Desktop issue. Bundle version: 1.5354.0
```
### Platform
Anthropic API
### Operating System
Ubuntu/Debian Linux
### Terminal/Shell
Other
### Additional Information
Bundle reference table for 1.5354.0. Symbols rename across releases, so each row has a stable string anchor for re-finding them.
| Role | Symbol in 1.5354.0 | Stable anchor |
|---|---|---|
| CCD spawn function | `BPt` | `"Launching MCP Server:"` |
| CCD shutdown function | `CPt` | `"Shutting down MCP Server:"` |
| CCD per-key promise queue | `dPt` | called by CCD spawn fn: `await dPt(e, async () => {...})` |
| CCD server registry Map | `xX` | `.get()` immediately preceding the CCD shutdown log line |
| Shared transport factory | `oPt` | `"built-in-node"` literal in factory body |
| LAM manager class | `p0A` | `"[LocalMcpServerManager]"` or `"local-mcp-server-cleanup"` |
| SSH manager class | `Rde` | `"[SshMcpServerManager]"` or `"ssh-mcp-server-cleanup"` |
| `utilityProcess.fork` wrapper | `mFr` | constructed in shared factory's `built-in-node` branch |
| `child_process.spawn` wrapper | `tFr` | constructed in shared factory's default branch |
Extraction commands (verified against 1.5354.0):
```bash
cd build-reference/app-extracted/.vite/build
# CCD spawn function name
grep -Pzo 'async function \K\w+(?=\(\w*\)\s*\{(?s).{0,800}?Launching MCP Server)' index.js | tr '\0' '\n'
# Shared transport factory (anchored on the unique 'built-in-node' string)
grep -Pzo 'async function \K\w+(?=\([^)]*\)\s*\{(?s).{0,400}?built-in-node)' index.js | tr '\0' '\n'
# All coordinator classes following the per-coordinator-registry pattern
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,300}?this\.connections\s*=\s*new Map)' index.js | tr '\0' '\n'
# LAM manager class specifically
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,500}?local-mcp-server-cleanup)' index.js | tr '\0' '\n'
```
Two questions where a one-line answer from the team would help us route this downstream:
1. Is per-coordinator isolated state intentional, or is it legacy drift from when each coordinator instantiated its transport inline?
2. Is the recent extraction of the shared transport factory (`oPt`) the start of a dedup refactor, or incidental cleanup?
If (1) is "intentional," we'll point users at the lockfile workaround as the supported path. If (2) is "in progress," this report saves you the duplicate analysis work.
Full provenance: [aaddrick/claude-desktop-debian#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Related learnings doc updates: [#527](https://github.com/aaddrick/claude-desktop-debian/pull/527) and [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547).
## Filing checklist
When you're ready to file:
1. Open https://github.com/anthropics/claude-code/issues/new?template=bug_report.yml
2. Paste each section above into the matching form field
3. Submit
4. Drop the GitHub issue URL as a comment on [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546) so the trail is bidirectional
Note: there is no in-app engineering bug-report path in Claude Desktop. `/bug` and `/feedback` are inert. The Help menu has "Get Support" (routes to the support chat, wrong queue for engineering) and "Troubleshooting" (self-diagnostic — useful for attaching `Copy Installation ID` or `Show Logs in File Manager` output to a GitHub issue, but not a reporting step on its own).
## Voice and authorship
Drafted using the [aaddrick-voice](https://github.com/aaddrick/written-voice-replication/blob/78f178dcf832943bcf1d5a65bf7627c3a20053a6/.claude/agents/aaddrick-voice.md) style profile against the form schema in `anthropics/claude-code/.github/ISSUE_TEMPLATE/bug_report.yml`.
---
Written by Claude Opus 4.7 via [Claude Code](https://claude.ai/code)

18
flake.lock generated
View File

@@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"lastModified": 1780747962,
"narHash": "sha256-IX7G1dlKrOqPOImfbo7ADDfV5yU1+j+MRChI3TL4tAA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"rev": "cbb5cf358f50aa6acc9efd6113b7bcfbc352cd73",
"type": "github"
},
"original": {
@@ -36,11 +36,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1774748309,
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github"
},
"original": {

View File

@@ -16,16 +16,16 @@
}:
let
pname = "claude-desktop";
version = "1.5354.0";
version = "1.11847.5";
srcs = {
x86_64-linux = fetchurl {
url = "https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
hash = "sha256-5hnHvTtnRqcwfr7+UJv+RHoUOu2X5sf2Zmd7Nqa2ulQ=";
url = "https://downloads.claude.ai/releases/win32/x64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe";
hash = "sha256-nm5i2wseSdmeQjuUNVbMQ8V4UeETZ9lop6wB+vfhQMs=";
};
aarch64-linux = fetchurl {
url = "https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
hash = "sha256-v33l1sASVC/q331cqnenLfzqGyRRLpptKOAEukrioR0=";
url = "https://downloads.claude.ai/releases/win32/arm64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe";
hash = "sha256-aCCeJS8TTOOgtvQstLCOxSAZYqFZw0vGKC//vZejDu8=";
};
};
@@ -124,6 +124,7 @@ stdenvNoCC.mkDerivation {
# Copy the ELF binary MUST be a real copy (not symlink) so that
# /proc/self/exe resolves to our tree
cp ${electronDir}/electron $electron_tree/electron
chmod +x $electron_tree/electron
# Symlink everything else from electron-unwrapped
for item in ${electronDir}/*; do
@@ -238,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
@@ -245,6 +247,7 @@ cleanup_stale_cowork_socket
log_message '--- Claude Desktop Launcher Start (NixOS) ---'
log_message "Timestamp: $(date)"
log_message "Arguments: $@"
log_session_env
# Check for display
if ! check_display; then
@@ -260,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

@@ -0,0 +1,31 @@
# Cowork patch markers — single source of truth.
#
# Format:
# <name><TAB><pcre_pattern><TAB><sample>
# Lines starting with '#' and blank lines are ignored.
#
# 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.
# pattern — PCRE matched against the shipped index.js by `grep -P`.
# sample — concrete string the pattern matches; BATS uses it to
# build positive and per-marker negative fixtures.
#
# The first 9 markers correspond to the smoke-test set defined in
# issue #559 (PR #555 retrofit, deliverable D6). Additional markers
# cover other critical patches (e.g., .asar guards).
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
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]
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

@@ -2420,7 +2420,7 @@ function detectBackend(emitEvent) {
+ 'AppArmor blocks unprivileged user namespaces by '
+ 'default (apparmor_restrict_unprivileged_userns=1). '
+ 'See the "Cowork on Ubuntu 24.04" section in '
+ 'docs/TROUBLESHOOTING.md for the AppArmor profile '
+ 'docs/troubleshooting.md for the AppArmor profile '
+ 'fix.');
} else {
logError(`bwrap probe failed: ${e.message || '(no message)'}`);

View File

@@ -1,3 +1,4 @@
# shellcheck shell=bash
#===============================================================================
# Doctor Diagnostics
#
@@ -5,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 /
@@ -71,12 +73,110 @@ _cowork_pkg_hint() {
arch) pkg='qemu-full' ;;
esac
;;
ibus-gtk3)
# Arch ships the GTK3 immodule as part of the main ibus
# package; Debian/Ubuntu and Fedora split it out.
case "$distro" in
arch) pkg='ibus' ;;
*) pkg='ibus-gtk3' ;;
esac
;;
*) pkg="$tool" ;;
esac
printf '%s' "$pkg_cmd $pkg"
}
# Return 0 if the named package is installed, 1 otherwise. Returns 2
# (treated as "unknown") when no recognized package manager is
# available — callers should not warn in that case to avoid false
# positives on unsupported distros.
_pkg_installed() {
local distro="$1"
local pkg="$2"
case "$distro" in
debian|ubuntu)
command -v dpkg-query &>/dev/null || return 2
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
| grep -q 'install ok installed'
;;
fedora)
command -v rpm &>/dev/null || return 2
rpm -q "$pkg" &>/dev/null
;;
arch)
command -v pacman &>/dev/null || return 2
pacman -Q "$pkg" &>/dev/null
;;
*) return 2 ;;
esac
}
# Diagnose IBus / GTK input-method misconfigurations that break
# keyboard input in the chat (#550). Surfaces:
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
# - XWayland-with-IBus routing note: on a Wayland session Electron
# defaults to XWayland (preserves global hotkeys), which forces
# the IBus path through XIM — a known weak link for some IMEs.
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
# - GTK immodules cache stale: active module not listed by
# gtk-query-immodules-3.0 (--update-cache fixes it)
#
# Usage: _doctor_check_im_modules <distro_id>
_doctor_check_im_modules() {
local distro="$1"
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
"(overrides GTK_IM_MODULE for Electron)"
fi
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
_info \
'IME note: Wayland session, Electron via XWayland —' \
'IBus path goes through XIM (lossy for some IMEs).'
_info \
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
'(loses global hotkeys).'
fi
# Nothing further to check without an active IM module.
[[ -n $active_im ]] || return 0
# ibus-gtk3 package check — only when the active module is ibus.
# rc=1 means definitely missing (warn); rc=2 means unsupported
# distro / no package manager (skip silently to avoid false
# negatives). On warn, return early — `apt install` refreshes
# the immodules cache, so the cache check below would be noise.
if [[ $active_im == 'ibus' ]]; then
_pkg_installed "$distro" ibus-gtk3
case $? in
1)
_warn \
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
return 0
;;
esac
fi
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
# means GTK 3 isn't in use — skip silently rather than warn.
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
if ! gtk-query-immodules-3.0 2>/dev/null \
| grep -q "\"$active_im\""; then
_warn \
"GTK immodules: '$active_im' not listed by" \
"gtk-query-immodules-3.0 (cache may be stale)"
_info \
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
fi
}
# Read the version string from the version file beside an Electron binary.
# Prints the raw version string, or nothing if unavailable.
_electron_version() {
@@ -338,6 +438,237 @@ JSEOF
fi
}
# Diagnose short-filename-limit filesystems that break cowork session
# initialization. Claude Code creates a per-session directory under
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
# sessions that flattens to ~180 chars (the host CWD is the deeply
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
# overhead — that mkdir fails with ENAMETOOLONG and the session never
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
# #590.
_doctor_check_filename_limit() {
# Walk up from ~/.claude/projects to the first dir that exists so
# getconf has something to query on a fresh install where the tree
# hasn't been created yet. $HOME is the floor — stop there rather
# than crossing into /.
local probe_dir="$HOME/.claude/projects"
while [[ ! -d $probe_dir ]]; do
probe_dir=$(dirname "$probe_dir")
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
done
[[ -d $probe_dir ]] || return 0
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
_warn "Filename limit: NAME_MAX=$name_max on $probe_dir (< 200)"
_info \
'Cowork sessions create project-dir names up to ~180 chars' \
'under ~/.claude/projects/; short limits cause ENAMETOOLONG'
_info 'when Claude Code initializes a session inside cowork (#590).'
local fs_type
fs_type=$(df --output=fstype "$probe_dir" 2>/dev/null \
| awk 'NR==2 {print $1}')
if [[ $fs_type == 'ecryptfs' ]]; then
_info \
'Detected eCryptfs (legacy Ubuntu/Mint encrypted home,' \
'NAME_MAX=143 due to filename-encryption overhead).'
_info \
'Workaround: move ~/.config/Claude onto a separate' \
'LUKS-encrypted ext4 volume (NAME_MAX=255) and symlink it'
_info \
'back. See docs/troubleshooting.md "Cowork: ENAMETOOLONG' \
'on encrypted home (eCryptfs)" for the worked steps.'
fi
}
# Surface a warning when systemd-coredump shows N+ recent Electron
# crashes. The most common cause on Linux is the GPU process FATAL
# exhaustion tracked in #583 — workaround for affected users is the
# upstream Settings → disable hardware acceleration toggle, or
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
#
# Arguments: $1 = electron path (e.g.,
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
# Used to filter results to claude-desktop's electron when possible;
# falls back to all-electron crashes when the path doesn't match
# (e.g., AppImage mount paths are transient).
_doctor_check_recent_crashes() {
local electron_path="${1:-}"
command -v coredumpctl &>/dev/null || return 0
# `coredumpctl list electron` filters by COMM=electron. If the
# exact electron_path matches any entry's EXE column, prefer that
# tighter count; otherwise fall back to all-electron entries.
local listing total_count path_count
listing=$(coredumpctl list electron \
--since='7 days ago' --no-pager 2>/dev/null) || return 0
[[ -n $listing ]] || return 0
# Drop the header line; count remaining entries.
# Assumes `coredumpctl list electron`'s COMM=electron filter
# excludes `-- Reboot --` separator rows from the listing (true
# on systemd as of writing). The path-matched branch below uses
# index($0, p) so it's unaffected even if that ever changes;
# revisit this total-count branch if a future systemd version
# starts leaking reboot markers into per-COMM listings.
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
((total_count == 0)) && return 0
if [[ -n $electron_path ]]; then
path_count=$(awk -v p="$electron_path" \
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
else
path_count=0
fi
# Use the path-matched count when available; else the unfiltered
# count with a footnote so the user knows it may include other
# Electron apps (Slack, VSCode, etc.).
local count footnote=''
if ((path_count > 0)); then
count=$path_count
else
count=$total_count
footnote=' (some entries may be from other Electron apps)'
fi
# Threshold tuned against the #583 repro (~10 crashes over 7 days
# on the affected laptop); a noisy session typically clears 3 in a
# week, so 3 is the floor for "worth surfacing the workaround".
if ((count >= 3)); then
_warn "Recent Electron crashes: $count in last 7 days$footnote"
_info \
'Most common cause: Chromium GPU process FATAL (#583).' \
'Try one of:'
_info ' Settings → toggle hardware acceleration off → restart'
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
_info \
'Tracking:' \
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
elif ((count > 0)); then
_info "Recent Electron crashes: $count in last 7 days$footnote"
fi
}
# Report the active Chromium password-store backend.
#
# Calls _detect_password_store() (defined in launcher-common.sh, which
# 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.
# 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 \
' → using fixed-key fallback;' \
'tokens are protected by filesystem permissions only'
fi
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
_info \
" → overridden by CLAUDE_PASSWORD_STORE=${CLAUDE_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() {
@@ -345,21 +676,17 @@ run_doctor() {
local _doctor_failures=0
_doctor_colors
# Distro ID is shared between the IM-module check (#550) and the
# Cowork Mode section further down. Resolve once.
local _distro_id
_distro_id=$(_cowork_distro_id)
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
echo '================================'
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
@@ -381,6 +708,9 @@ run_doctor() {
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
fi
# -- Input method (IBus / GTK) --
_doctor_check_im_modules "$_distro_id"
# -- Menu bar mode --
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
if [[ -n $menu_bar_mode ]]; then
@@ -429,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).
@@ -484,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"
@@ -502,6 +908,9 @@ run_doctor() {
_pass 'SingletonLock: no lock file (OK)'
fi
# -- Password store --
_doctor_check_password_store
# -- MCP config --
local mcp_config="$config_dir/claude_desktop_config.json"
if [[ -f $mcp_config ]]; then
@@ -569,30 +978,13 @@ 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
echo -e "${_bold}Cowork Mode${_reset}"
echo '----------------'
# Detect distro for package hints
local _distro_id
_distro_id=$(_cowork_distro_id)
# Determine whether bwrap is the active backend (for severity
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
# bwrap is active unless the user has overridden to KVM or host.
@@ -641,9 +1033,8 @@ print(len(servers))
' Common on Ubuntu 24.04+ where AppArmor sets' \
'apparmor_restrict_unprivileged_userns=1'
_info \
' by default. See docs/TROUBLESHOOTING.md' \
'"Cowork on Ubuntu 24.04"'
_info ' for the AppArmor profile fix.'
' by default. See docs/troubleshooting.md' \
'"Cowork on Ubuntu 24.04" for the AppArmor profile fix.'
fi
fi
else
@@ -785,34 +1176,26 @@ print(len(servers))
# Custom bwrap mount configuration
_doctor_check_bwrap_mounts
# Short NAME_MAX on the host's ~/.claude tree (eCryptfs etc.)
# blocks cowork session init with ENAMETOOLONG — see #590.
_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)'
@@ -821,6 +1204,11 @@ print(len(servers))
fi
fi
# -- Recent crashes --
# Surfaces the GPU process FATAL pattern (#583) before users
# notice the in-app "Claude crashed repeatedly" prompt.
_doctor_check_recent_crashes "$electron_path"
# -- Log file --
local log_path
log_path="${XDG_CACHE_HOME:-$HOME/.cache}"

View File

@@ -81,15 +81,28 @@ 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'}`);
// Detect if a window intends to be frameless (popup/Quick Entry/About)
// Quick Entry: titleBarStyle:"", skipTaskbar:true, transparent:true, resizable:false
// About: titleBarStyle:"", skipTaskbar:true, resizable:false
// Main: titleBarStyle:"", titleBarOverlay:false(linux), resizable (has minWidth)
// The main window has minWidth set; popups do not.
// 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)
// About: titleBarStyle:"hiddenInset", no minWidth, no parent
// Main: titleBarStyle:"hidden", minWidth:600
// Hardware Buddy: titleBarStyle:"hiddenInset", parent set (child modal — keep frame)
// minWidth excludes Main; the `parent` key excludes Hardware Buddy. About
// went from "" to "hiddenInset" upstream, so the test matches either.
function isPopupWindow(options) {
if (!options) return false;
if (options.frame === false) return true;
if (options.titleBarStyle === '' && !options.minWidth) return true;
if ('parent' in options) return false;
if ((options.titleBarStyle === '' || options.titleBarStyle === 'hiddenInset') && !options.minWidth) return true;
return false;
}
@@ -117,6 +130,28 @@ const LINUX_CSS = `
}
`;
// autoUpdater no-op: every property access returns a chainable function
// so `.on(...).once(...).setFeedURL(...).checkForUpdates()` is harmless.
// `getFeedURL` returns '' so any code that inspects the URL gets a
// well-typed empty string rather than undefined. `then`/`catch`/`finally`
// and `Symbol.toPrimitive`/`Symbol.iterator` resolve to `undefined` so the
// Proxy is not mistaken for a thenable (which would call chainNoop as
// `then(resolve, reject)` and never resolve — silent await hang) or
// asked to coerce to a primitive. Writes land on the target but are
// shadowed by the get-trap. Defined once and reused across all
// require('electron') calls. Linux-only; macOS/Windows still see the
// real autoUpdater. See #567.
const autoUpdaterNoop = new Proxy({}, {
get(_target, prop) {
if (prop === 'getFeedURL') return () => '';
if (prop === 'then' || prop === 'catch' || prop === 'finally'
|| prop === Symbol.toPrimitive || prop === Symbol.iterator) {
return undefined;
}
return function chainNoop() { return autoUpdaterNoop; };
},
});
// Build the patched BrowserWindow class and Menu interceptor once,
// on first require('electron'), then reuse via Proxy on every access.
let PatchedBrowserWindow = null;
@@ -152,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`);
@@ -185,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');
@@ -220,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(() => {});
@@ -290,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);
@@ -313,6 +360,44 @@ Module.prototype.require = function(id) {
this.hide();
}
});
} else {
// CLAUDE_QUIT_ON_CLOSE=1: the bundled main-process code
// (`.vite/build/index.js`) installs its own main-window
// close listener that hardcodes `preventDefault()` +
// `hide()` on every non-Windows platform, with no
// setting or env var to disable it. The wrapper's
// opt-out above only removes *this* file's hide handler;
// the bundled one still runs, so without this branch
// closing the window still leaves the app alive in the
// tray (in-app schedulers / single-instance lock /
// deleted-inode electron after dpkg upgrade-in-place).
//
// Approach: register a close listener that runs *first*
// and calls app.quit(). app.quit() emits 'before-quit'
// synchronously, which sets the bundled code's
// "quitting in progress" flag. The bundled close
// listener then runs second, sees that flag, and
// short-circuits via its own `if (lC()) return;` guard
// — so it never calls preventDefault, and the window
// closes normally during the quit flow. We ride the
// upstream's own quit-safety contract instead of trying
// to remove or splice their listener; robust to any
// refactor that preserves the quit-in-progress short-
// circuit (which they need for Ctrl+Q / tray Quit /
// SIGTERM anyway). Fixes: #623
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.
@@ -478,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()) {
@@ -535,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;
};
});
}
@@ -595,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]
@@ -605,7 +802,7 @@ Type=Application
Name=Claude
Exec=${exec}
Icon=${icon}
StartupWMClass=Claude
StartupWMClass=${result.app.name}
Terminal=false
X-GNOME-Autostart-enabled=true
`;
@@ -654,6 +851,74 @@ X-GNOME-Autostart-enabled=true
console.log('[Autostart] XDG Autostart shim installed');
}
// Detect in-place package upgrade (dpkg/rpm rename-replace of
// app.asar) and offer a restart, since post-swap window loads
// mix v(N+1) HTML/assets with the v(N) IPC/preload still in
// memory. AppImage and Nix are immune (immutable running file);
// the watcher just no-ops there. Fixes: see PR #564.
const armUpgradeWatcher = () => {
if (process.platform !== 'linux') return;
const fs = require('fs');
const asarPath = path.join(process.resourcesPath, 'app.asar');
let baseline;
try { baseline = fs.statSync(asarPath); } catch { return; }
let notified = false;
let debounceTimer = null;
const promptRestart = () => {
if (notified) return;
let cur;
try { cur = fs.statSync(asarPath); } catch { return; }
// ino catches rename-replace; mtime catches in-place
// rewrite. Either is sufficient on its own for dpkg/rpm,
// but checking both keeps us honest against odd packagers.
if (cur.ino === baseline.ino
&& cur.mtimeMs === baseline.mtimeMs) return;
notified = true;
console.log('[Frame Fix] app.asar replaced — prompting restart');
// whenReady() resolves immediately if already ready, so no
// isReady() branch needed. Linux libnotify ignores
// Notification.actions (macOS-only), so whole-notification
// click is the only restart affordance.
result.app.whenReady().then(() => {
try {
const n = new result.Notification({
title: 'Claude Desktop has been updated',
body: 'Click to restart and apply the update.',
});
n.on('click', () => {
result.app.relaunch();
result.app.quit();
});
n.show();
} catch (err) {
console.warn('[Frame Fix] Restart notification failed:',
err.message);
}
});
};
// Watch the parent dir, not the file: file-level fs.watch
// loses the inode across rename-replace. Filename filter
// ignores unrelated activity in the resources dir; 5s
// debounce covers dpkg's .dpkg-new → rename dance and
// similar multi-stage swaps in rpm/Nix.
const watcher = fs.watch(path.dirname(asarPath),
(_evt, filename) => {
if (filename !== 'app.asar') return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(promptRestart, 5000);
});
// App's other handles drive process lifetime; the watcher
// shouldn't keep the loop alive on its own.
watcher.unref();
console.log('[Frame Fix] Upgrade watcher armed:', asarPath);
};
try { armUpgradeWatcher(); } catch (err) {
console.warn('[Frame Fix] Upgrade watcher failed to arm:',
err.message);
}
console.log('[Frame Fix] Patches built successfully');
}
@@ -673,6 +938,56 @@ 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/...
// when app.isPackaged is true (we set ELECTRON_FORCE_IS_PACKAGED=true
// unconditionally). Today this is a happy accident: Electron's Linux
// autoUpdater is unimplemented and logs "AutoUpdater is not supported
// on Linux", so the calls no-op. If a future Electron implements it,
// every install would start hitting that feed and would either 404
// or — worse — receive content the install wasn't prepared for.
// .deb/.rpm/AppImage updates flow through the OS package manager
// (or AppImageUpdate); the Anthropic feed has no Linux artifacts.
// We replace the entire autoUpdater object with a Proxy that
// no-ops every method and returns chainable stubs for EventEmitter
// calls so listener registration in the bundled code is harmless.
// See #567.
return autoUpdaterNoop;
}
return Reflect.get(target, prop, receiver);
}
});

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() {
@@ -16,6 +20,41 @@ log_message() {
echo "$1" >> "$log_file"
}
# Log the session/IME environment vars that drive display and input
# decisions, so bug reports include enough context to reason about
# them without round-trip env-dump requests (#548).
#
# Emits one block:
# env={
# KEY=value
# ...
# }
#
# Empty or unset values are emitted as `KEY=` so absence is
# unambiguous (vs. silently omitted). Caller must run setup_logging
# first.
log_session_env() {
local key
log_message 'env={'
for key in \
XDG_SESSION_TYPE \
WAYLAND_DISPLAY \
DISPLAY \
XDG_CURRENT_DESKTOP \
GTK_IM_MODULE \
XMODIFIERS \
QT_IM_MODULE \
CLAUDE_USE_WAYLAND \
CLAUDE_TITLEBAR_STYLE \
CLAUDE_PASSWORD_STORE \
CLAUDE_GTK_IM_MODULE \
CLAUDE_DISABLE_GPU
do
log_message " $key=${!key:-}"
done
log_message '}'
}
# Detect display backend (Wayland vs X11)
# Sets: is_wayland, use_x11_on_wayland
detect_display_backend() {
@@ -23,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,,}"
@@ -67,6 +126,105 @@ _resolve_titlebar_style() {
esac
}
# Determine the best available Chromium password-store backend.
#
# Electron's safeStorage API and Chromium's cookie encryption both rely
# on the OS credential store selected by --password-store. Without a
# working store safeStorage.isEncryptionAvailable() returns false, OAuth
# tokens are silently discarded on exit, and users must re-authenticate
# on every launch (Cookies file stays 0 bytes). Fixes: #593
#
# Detection order (first match wins):
# CLAUDE_PASSWORD_STORE env var — explicit user override
# kwallet6 — KDE Plasma 6 keyring
# gnome-libsecret — GNOME Keyring / libsecret bridge
# basic — fixed internal key (always works)
#
# With 'basic' the stored data is encrypted with a fixed key. Tokens
# remain protected by Linux filesystem permissions on ~/.config/Claude/.
#
# Assumes a D-Bus session bus is available; this is true for any
# graphical login session.
_detect_password_store() {
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
echo "$CLAUDE_PASSWORD_STORE"
return
fi
# kwallet6: KDE Plasma 6 keyring
if dbus-send --session --print-reply --reply-timeout=1000 \
--dest=org.kde.kwalletd6 \
/modules/kwalletd6 \
org.kde.KWallet.isEnabled 2>/dev/null \
| grep -q 'boolean true'
then
echo 'kwallet6'
return
fi
# gnome-libsecret: GNOME Keyring, KWallet 5 compat bridge, etc.
if dbus-send --session --print-reply --reply-timeout=1000 \
--dest=org.freedesktop.secrets \
/org/freedesktop/secrets \
org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1
then
echo 'gnome-libsecret'
return
fi
# No keyring accessible — fall back to fixed-key provider.
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)
@@ -77,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')
@@ -84,18 +248,33 @@ 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. Fixes: #593
local pw_store
pw_store=$(_detect_password_store)
electron_args+=("--password-store=${pw_store}")
log_message "Password store: ${pw_store}"
# Remote XRDP sessions lack GPU acceleration and render a blank
# window when GPU compositing is enabled. Detect via XRDP_SESSION
# (set by xrdp's session init) and loginctl session Type. We do
@@ -107,37 +286,129 @@ build_electron_args() {
loginctl show-session "$XDG_SESSION_ID" \
-p Type --value 2>/dev/null
)
# Track GPU-disable decision so XRDP and CLAUDE_DISABLE_GPU don't
# stack duplicate flags. Either signal is sufficient.
local _disable_gpu=false
if [[ -n ${XRDP_SESSION:-} || $rdp_session_type == xrdp ]]; then
electron_args+=('--disable-gpu' '--disable-software-rasterizer')
_disable_gpu=true
log_message 'XRDP session detected - GPU compositing disabled'
fi
# CLAUDE_DISABLE_GPU=1: opt-in workaround for users hitting the
# Chromium GPU process FATAL exhaustion (#583). The same upstream
# 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 [[ -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 \
'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.
@@ -150,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
@@ -208,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
@@ -268,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
@@ -282,6 +647,15 @@ setup_electron_env() {
if [[ $(_resolve_titlebar_style) != 'hidden' ]]; then
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
fi
# CLAUDE_GTK_IM_MODULE: opt-in override for users hit by broken
# IBus integration on Linux (#549). Propagated to GTK_IM_MODULE
# so e.g. `xim` can be persisted without wrapping every launch.
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
local prev="${GTK_IM_MODULE:-<unset>}"
export GTK_IM_MODULE="$CLAUDE_GTK_IM_MODULE"
log_message \
"GTK_IM_MODULE override: $prev -> $GTK_IM_MODULE (via CLAUDE_GTK_IM_MODULE)"
fi
}
#===============================================================================

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
@@ -98,23 +105,28 @@ log_message '--- Claude Desktop AppImage Start ---'
log_message "Timestamp: $(date)"
log_message "Arguments: $@"
log_message "APPDIR: $appdir"
# 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"
log_session_env
# 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'
@@ -132,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
@@ -169,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>
@@ -267,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
@@ -114,6 +127,7 @@ cleanup_stale_cowork_socket
log_message '--- Claude Desktop Launcher Start ---'
log_message "Timestamp: \$(date)"
log_message "Arguments: \$@"
log_session_env
# Check for display
if ! check_display; then
@@ -131,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'
@@ -151,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'
@@ -180,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
@@ -188,6 +214,7 @@ Version: $version
Section: utils
Priority: optional
Architecture: $architecture
Recommends: bubblewrap
Maintainer: $maintainer
Description: $description
Claude is an AI assistant from Anthropic.
@@ -205,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
@@ -226,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"
@@ -242,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
@@ -97,6 +106,7 @@ cleanup_stale_cowork_socket
log_message '--- Claude Desktop Launcher Start ---'
log_message "Timestamp: \$(date)"
log_message "Arguments: \$@"
log_session_env
# Check for display
if ! check_display; then
@@ -114,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'
@@ -134,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"
@@ -220,35 +240,44 @@ 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).
chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox
%post
# Update desktop database for MIME types
update-desktop-database /usr/share/applications &> /dev/null || true
# Set correct permissions for chrome-sandbox
SANDBOX_PATH="/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox"
if [ -f "\$SANDBOX_PATH" ]; then
echo "Setting chrome-sandbox permissions..."
chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox"
chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox"
fi
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
@@ -257,14 +286,26 @@ echo 'RPM spec file created'
# --- Build RPM Package ---
echo 'Building RPM package...'
if ! rpmbuild --define "_topdir $rpmbuild_dir" \
rpmbuild_log="$work_dir/rpmbuild.log"
rpmbuild --define "_topdir $rpmbuild_dir" \
--define "_rpmdir $work_dir" \
--target "$rpm_arch" \
-bb "$rpmbuild_dir/SPECS/$package_name.spec"; then
-bb "$rpmbuild_dir/SPECS/$package_name.spec" 2>&1 |
tee "$rpmbuild_log"
if (( PIPESTATUS[0] != 0 )); then
echo 'Failed to build RPM package' >&2
exit 1
fi
# Guard against re-introducing #609. The "File listed twice" warning
# means %files has overlapping listings, and on modern rpmbuild any
# %exclude workaround silently strips the file from the payload.
if grep -qF 'File listed twice' "$rpmbuild_log"; then
echo 'rpmbuild emitted "File listed twice" — %files has overlapping listings (see #609)' >&2
grep -F 'File listed twice' "$rpmbuild_log" >&2
exit 1
fi
# Find and move the built RPM (it will be in a subdirectory)
rpm_file=$(find "$work_dir" -name "${package_name}-${rpm_version}*.rpm" -type f | head -n 1)
if [[ -z $rpm_file ]]; then

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

@@ -37,16 +37,32 @@ EOFENTRY
# Update package.json
echo 'Modifying package.json to load frame fix and add node-pty...'
local desktop_name='claude-desktop.desktop'
if [[ ${build_format:-} == 'appimage' ]]; then
desktop_name='io.github.aaddrick.claude-desktop-debian.desktop'
fi
node -e "
const fs = require('fs');
const pkg = require('./app.asar.contents/package.json');
pkg.originalMain = pkg.main;
pkg.main = 'frame-fix-entry.js';
pkg.desktopName = process.argv[1];
pkg.optionalDependencies = pkg.optionalDependencies || {};
pkg.optionalDependencies['node-pty'] = '^1.0.0';
fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2));
console.log('Updated package.json: main entry and node-pty dependency');
"
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...'
@@ -87,9 +103,22 @@ console.log('Updated package.json: main entry and node-pty dependency');
# 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
@@ -97,6 +126,17 @@ console.log('Updated package.json: main entry and node-pty dependency');
# 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'

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

@@ -0,0 +1,304 @@
#===============================================================================
# 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'
# Idempotency
if grep -qF '.filter(_d=>!_d.endsWith(".asar"))' "$index_js"; then
echo ' .asar --add-dir filter already present (idempotent)'
echo '##############################################################'
return
fi
if ! INDEX_JS="$index_js" node << 'ASAR_ADDDIR_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
let patchCount = 0;
// ================================================================
// Sub-patch 1: Filter .asar from --add-dir loop
//
// Target (unique, 1 occurrence):
// for (let O of A) Y.push("--add-dir", O);
// Fallback (if minifier uses .forEach):
// A.forEach(O=>Y.push("--add-dir",O))
// ================================================================
{
// Primary: for...of pattern
const forOfRe = /for\s*\(\s*let\s+([\w$]+)\s+of\s+([\w$]+)\s*\)\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\1\s*\)/;
// Fallback: .forEach pattern
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/;
let match = code.match(forOfRe);
let variant = 'for-of';
if (!match) {
match = code.match(forEachRe);
variant = 'forEach';
}
if (!match) {
console.error('FATAL: --add-dir dispatch loop not found.');
console.error(' for(let X of Y) Z.push("--add-dir", X)');
console.error(' Y.forEach(X=>Z.push("--add-dir", X))');
process.exit(1);
}
// Count assertion: exactly 1 match expected
const escaped = match[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const allMatches = code.match(new RegExp(escaped, 'g'));
if (allMatches && allMatches.length > 1) {
console.error('FATAL: --add-dir pattern matches ' +
allMatches.length + ' times (expected 1).');
process.exit(1);
}
let filtered;
if (variant === 'for-of') {
const [, iterVar, arrVar, pushTarget] = match;
filtered = 'for(let ' + iterVar + ' of ' + arrVar +
'.filter(_d=>!_d.endsWith(".asar")))' +
pushTarget + '.push("--add-dir",' + iterVar + ')';
} else {
const [, arrVar, iterVar, pushTarget] = match;
filtered = arrVar +
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
iterVar + '=>' + pushTarget +
'.push("--add-dir",' + iterVar + '))';
}
code = code.replace(match[0], filtered);
console.log(' Filtered --add-dir dispatch (' +
variant + ' variant)');
patchCount++;
}
// ================================================================
// Sub-patch 2: Filter .asar from session restore
//
// Anchor: "Filtering out deleted folder from session" (unique)
// Target: (VAR.userSelectedFolders||[]).filter(
// Insert: .filter(l=>!l.endsWith(".asar")) before existing .filter(
// ================================================================
{
const warn = (msg) => console.log(' WARNING: ' + msg +
' (primary --add-dir filter still protects)');
const anchorIdx = code.indexOf(
'Filtering out deleted folder from session');
if (anchorIdx === -1) {
warn('session restore anchor not found');
} else {
const searchStart = Math.max(0, anchorIdx - 500);
const region = code.substring(searchStart, anchorIdx);
const usIdx = region.lastIndexOf('userSelectedFolders');
if (usIdx === -1) {
warn('userSelectedFolders not found near anchor');
} else {
const absUsIdx = searchStart + usIdx;
const afterUs = code.substring(absUsIdx, anchorIdx);
const bracketMatch = afterUs.match(/\|\|\s*\[\s*\]\s*\)/);
if (!bracketMatch) {
warn('||[]) pattern not found');
} else {
const insertAt = absUsIdx + bracketMatch.index +
bracketMatch[0].length;
const peek = code.substring(insertAt, insertAt + 20);
if (!peek.match(/^\s*\.filter\s*\(/)) {
warn('.filter( not found after ||[])');
} else if (code.substring(
insertAt - 50, insertAt + 50
).includes('!l.endsWith(".asar")')) {
console.log(' Session restore filter ' +
'already present');
} else {
code = code.substring(0, insertAt) +
'.filter(l=>!l.endsWith(".asar"))' +
code.substring(insertAt);
console.log(' Injected .asar filter in ' +
'session restore');
patchCount++;
}
}
}
}
}
fs.writeFileSync(indexJs, code);
console.log(' Applied ' + patchCount +
' .asar additionalDirectories patch(es)');
if (patchCount < 1) {
console.error('FATAL: No patches applied — --add-dir filter ' +
'must succeed (#649).');
process.exit(1);
}
ASAR_ADDDIR_PATCH
then
echo 'FATAL: .asar --add-dir filter patch failed' >&2
echo 'Local agent mode will crash without this patch (#649).' >&2
exit 1
fi
echo '##############################################################'
}

View File

@@ -9,6 +9,211 @@
# Modifies globals: node_pty_build_dir
#===============================================================================
# ---------------------------------------------------------------------------
# Patch: reject .asar paths in the directory-check helper
#
# On Linux, app.asar is passed as an argv element to Electron. The
# directory-check function (wFA in the current build) calls
# fs.statSync(path).isDirectory(). Electron's ASAR virtual filesystem
# shim makes .asar archives report isDirectory()===true, so app.asar
# is dispatched to Cowork as a "folder drop". This causes:
# - Permission dialog on every launch (#383)
# - Forced Cowork mode (#622)
# - Fatal --add-dir error in Claude Code >=2.1.111 (#632)
#
# Fix: inject !PARAM.endsWith(".asar")&& before the statSync call.
# This runs independently of the Cowork-mode guard (the function
# exists even if Cowork code is absent).
# ---------------------------------------------------------------------------
patch_asar_path_filter() {
echo 'Patching directory check to reject .asar paths...'
local index_js='app.asar.contents/.vite/build/index.js'
if ! INDEX_JS="$index_js" node << 'ASAR_FILTER_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
// Find the directory-check helper function.
// Beautified form:
// function wFA(e) {
// try { return ee.statSync(e).isDirectory(); }
// catch { return !1; }
// }
// Minified form:
// function wFA(e){try{return ee.statSync(e).isDirectory()}catch{return!1}}
//
// Stable anchors: .statSync( ).isDirectory() inside try/catch returning !1.
// The function name, parameter, and fs variable are all minified.
const dirCheckRe =
/function\s+([\w$]+)\s*\(\s*([\w$]+)\s*\)\s*\{\s*try\s*\{\s*return\s+([\w$]+)\.statSync\(\s*\2\s*\)\.isDirectory\(\)/;
const match = code.match(dirCheckRe);
if (!match) {
console.error('FATAL: Could not find directory-check function' +
' (statSync+isDirectory pattern).');
console.error('This patch prevents .asar paths from triggering' +
' false Cowork dispatch (#383, #622, #632).');
process.exit(1);
}
const [, funcName, paramName] = match;
console.log(' Found directory-check function: ' + funcName +
'(' + paramName + ')');
// Idempotency: check if already patched
if (code.includes('.endsWith(".asar")')) {
console.log(' .asar path filter already applied');
process.exit(0);
}
// Insert the guard: !PARAM.endsWith(".asar")&&
// Before: return FSVAR.statSync(PARAM).isDirectory()
// After: return!PARAM.endsWith(".asar")&&FSVAR.statSync(PARAM).isDirectory()
//
// The replacement is scoped to the matched function via the full
// regex match, so it cannot accidentally hit other statSync calls.
code = code.replace(dirCheckRe, (whole, fn, param, fsVar) => {
return 'function ' + fn + '(' + param + '){try{return!' +
param + '.endsWith(".asar")&&' +
fsVar + '.statSync(' + param + ').isDirectory()';
});
// Verify the patch landed
if (!code.includes('.endsWith(".asar")')) {
console.error('FATAL: .asar path filter replacement failed.');
process.exit(1);
}
fs.writeFileSync(indexJs, code);
console.log(' Added .asar path rejection to ' + funcName + '()');
ASAR_FILTER_PATCH
then
echo 'FATAL: .asar path filter patch failed' >&2
echo 'The app will show permission dialogs and may crash' \
'without this patch (#383, #622, #632).' >&2
exit 1
fi
echo '##############################################################'
}
# ---------------------------------------------------------------------------
# Patch: reject .asar paths in the argv file-drop collector
#
# PR #640 patched the directory-check helper (isDirectory path) so
# app.asar is no longer dispatched as a "folder drop". However, the
# argv collector function (lKr in the current build) has a separate
# branch:
#
# if (!i.startsWith("-") && FSVAR.existsSync(i)) { A.push(i); }
#
# Electron's ASAR VFS shim makes existsSync return true for .asar
# paths, so app.asar passes this check and is dispatched to the
# "file drop" handler (cCA), triggering a permission prompt on every
# window close+reopen (#383, #622 regression in v2.0.16+).
#
# Fix: inject !PARAM.endsWith(".asar")&& before the existsSync call.
#
# Threat model: this argv path is reachable from user-launched
# invocations (TPr's only caller is the second-instance handler, and
# the desktop entries ship `Exec=... %u`), so it is not just the app's
# own relaunch. The exact-suffix, case-sensitive ".asar" match is still
# correct because the only sink here is attach-to-draft
# (dispatchOnCoworkFromMain -> selectedFiles) — identical to a manual
# drag, with no content read, privilege boundary, or traversal sink. So
# don't "harden" it with toLowerCase(): that would diverge from the
# sibling .asar guards for zero behavioral gain.
# ---------------------------------------------------------------------------
patch_asar_argv_file_drop_guard() {
echo 'Patching argv file-drop collector to reject .asar paths...'
local index_js='app.asar.contents/.vite/build/index.js'
# Idempotency: check for the guard in context — specifically
# !PARAM.startsWith("-")&&!PARAM.endsWith(".asar") — anchored to
# startsWith to avoid false-positive matches from other .asar guards
# (e.g. the statSync patch or the --add-dir filter).
if grep -qP '\.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\)' \
"$index_js"; then
echo ' .asar file-drop guard already present (idempotent)'
echo '##############################################################'
return
fi
if ! INDEX_JS="$index_js" node << 'ASAR_FILE_DROP_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
// Find the argv file-drop collector branch.
// Beautified form:
// if (!i.startsWith("-") && ee.existsSync(i)) {
// A.push(i);
// continue;
// }
// Minified form:
// if(!i.startsWith("-")&&ee.existsSync(i)){A.push(i);continue}
//
// Anchor: !PARAM.startsWith("-")&&FSVAR.existsSync(PARAM) — unique in
// the bundle (verified). The .push() suffix is intentionally omitted
// to avoid brittleness if the minifier reorders the if-body.
// The param variable and fs variable are both minified and captured.
const re =
/(![\w$]+\.startsWith\s*\(\s*"-"\s*\)\s*&&\s*)([\w$]+)\.existsSync\(\s*([\w$]+)\s*\)/;
const match = code.match(re);
if (!match) {
console.error('FATAL: argv file-drop collector branch not found.');
console.error(' Expected: !PARAM.startsWith("-")&&FSVAR.existsSync(PARAM)');
console.error(
' This patch prevents app.asar file-drop prompts (#383, #622).');
process.exit(1);
}
// Verify uniqueness — startsWith("-")&&existsSync must appear exactly
// once; multiple matches would mean we cannot safely target this site.
const escaped = match[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const allMatches = code.match(new RegExp(escaped, 'g'));
if (allMatches && allMatches.length > 1) {
console.error('FATAL: file-drop pattern matched ' +
allMatches.length + ' times (expected 1).');
process.exit(1);
}
const [, startsPart, fsVar, param] = match;
console.log(
' Found collector: param=' + param + ', fsVar=' + fsVar);
// Insert guard: !PARAM.endsWith(".asar")&&
// Before: !PARAM.startsWith("-")&&FSVAR.existsSync(PARAM)
// After: !PARAM.startsWith("-")&&!PARAM.endsWith(".asar")&&FSVAR.existsSync(PARAM)
//
// Replace the full outer match directly — no nested replace — to avoid
// any risk of $ in minified identifiers being misread as replacement
// pattern metacharacters.
const patched = startsPart + '!' + param + '.endsWith(".asar")&&' +
fsVar + '.existsSync(' + param + ')';
code = code.replace(match[0], patched);
// Verify the patch landed with the correct context
if (!code.match(/\.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\)/)) {
console.error('FATAL: .asar file-drop guard replacement failed.');
process.exit(1);
}
fs.writeFileSync(indexJs, code);
console.log(' Added .asar guard to argv file-drop collector');
ASAR_FILE_DROP_PATCH
then
echo 'FATAL: .asar argv file-drop guard patch failed' >&2
echo 'The app will show file-drop prompts on window reopen' \
'without this patch (#383, #622).' >&2
exit 1
fi
echo '##############################################################'
}
patch_cowork_linux() {
echo 'Patching Cowork mode for Linux...'
local index_js='app.asar.contents/.vite/build/index.js'
@@ -51,7 +256,7 @@ function extractBlock(str, startIdx, open = '{') {
// Pattern: VAR!=="darwin"&&VAR!=="win32" (unique in platform gate)
// Anchor: appears near 'unsupported_platform' code value
// ============================================================
const platformGateRe = /(\w+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
const platformGateRe = /([\w$]+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
const origCode = code;
code = code.replace(platformGateRe, (match, varName, mid, end) => {
// Only patch the instance near the "unsupported_platform" code value
@@ -67,10 +272,10 @@ if (code !== origCode) {
patchCount++;
} else {
// Try without backreference (in case minifier uses different var names)
const simpleRe = /(!=="darwin"\s*&&\s*\w+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
const simpleRe = /(!=="darwin"\s*&&\s*[\w$]+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
const simpleMatch = code.match(simpleRe);
if (simpleMatch) {
const varMatch = simpleMatch[0].match(/(\w+)\s*!==\s*"win32"/);
const varMatch = simpleMatch[0].match(/([\w$]+)\s*!==\s*"win32"/);
if (varMatch) {
code = code.replace(simpleMatch[1],
simpleMatch[1] + '&&' + varMatch[1] + '!=="linux"');
@@ -91,7 +296,7 @@ if (code === origCode) {
// Anchor: unique string "vmClient (TypeScript)"
// Extracts the win32 platform variable, adds Linux OR condition
// ============================================================
const vmClientLogMatch = code.match(/(\w+)(\s*\?\s*"vmClient \(TypeScript\)")/);
const vmClientLogMatch = code.match(/([\w$]+)(\s*\?\s*"vmClient \(TypeScript\)")/);
if (vmClientLogMatch) {
const win32Var = vmClientLogMatch[1];
@@ -109,6 +314,13 @@ if (vmClientLogMatch) {
'(' + win32Var + '||process.platform==="linux")$1');
console.log(' Patched VM client log check for Linux');
patchCount++;
} else if (code.includes(
'||process.platform==="linux")?"vmClient (TypeScript)"'
)) {
console.log(' VM client log gate already applied (Patch 2a)');
} else {
console.log(' WARNING: Could not find anchor for VM client log' +
' gate (Patch 2a) — half-patched asar will fail Cowork startup');
}
// 2b: Patch the actual module assignment
@@ -125,6 +337,12 @@ if (vmClientLogMatch) {
'(' + win32Var + '||process.platform==="linux")$1');
console.log(' Patched VM module assignment for Linux');
patchCount++;
} else if (/\|\|process\.platform==="linux"\)\??\(?[\w$]+=\{vm:[\w$]+\}/.test(code)) {
console.log(' VM module assignment already applied (Patch 2b)');
} else {
console.log(' WARNING: Could not find anchor for VM module' +
' assignment (Patch 2b) — half-patched asar will fail' +
' Cowork startup (PR #555 failure mode)');
}
} else {
console.log(' WARNING: Could not find vmClient variable for module loading patch');
@@ -134,7 +352,7 @@ if (vmClientLogMatch) {
// Patch 3: Socket path - use Unix domain socket on Linux
// Anchor: unique string "cowork-vm-service" in pipe path
// ============================================================
const pipeMatch = code.match(/(\w+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
const pipeMatch = code.match(/([\w$]+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
if (pipeMatch) {
const pipeVar = pipeMatch[1];
const assign = pipeMatch[2];
@@ -213,7 +431,7 @@ if (!code.includes('"linux":{') && !code.includes("'linux':{") &&
// calls download() which returns success immediately).
// ============================================================
{
const statusRe = /getDownloadStatus\(\)\{return\s+(\w+\(\)\?(\w+)\.Downloading:\w+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
const statusRe = /getDownloadStatus\(\)\{return\s+([\w$]+\(\)\?([\w$]+)\.Downloading:[\w$]+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
const statusMatch = code.match(statusRe);
if (statusMatch) {
const [whole, origExpr, enumVar] = statusMatch;
@@ -266,96 +484,104 @@ if (serviceErrorIdx !== -1) {
// Step 1: Find the ENOENT check and expand it to include ECONNREFUSED
// Pattern: VAR.code==="ENOENT"
// Search backwards from the error string to find it
const searchStart = Math.max(0, serviceErrorIdx - 300);
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
const enoentRe = /(\w+)\.code\s*===\s*"ENOENT"/g;
let enoentMatch;
let lastEnoent = null;
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
lastEnoent = enoentMatch;
}
if (lastEnoent) {
const enoentStr = lastEnoent[0];
const errVar = lastEnoent[1];
const enoentAbsIdx = searchStart + lastEnoent.index;
// Replace: VAR.code==="ENOENT"
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
const expanded =
'(' + enoentStr +
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
code = code.substring(0, enoentAbsIdx) +
expanded +
code.substring(enoentAbsIdx + enoentStr.length);
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
if (/process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED"/.test(code)) {
console.log(' ENOENT/ECONNREFUSED expansion already applied');
} else {
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
const searchStart = Math.max(0, serviceErrorIdx - 300);
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
const enoentRe = /([\w$]+)\.code\s*===\s*"ENOENT"/g;
let enoentMatch;
let lastEnoent = null;
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
lastEnoent = enoentMatch;
}
if (lastEnoent) {
const enoentStr = lastEnoent[0];
const errVar = lastEnoent[1];
const enoentAbsIdx = searchStart + lastEnoent.index;
// Replace: VAR.code==="ENOENT"
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
const expanded =
'(' + enoentStr +
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
code = code.substring(0, enoentAbsIdx) +
expanded +
code.substring(enoentAbsIdx + enoentStr.length);
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
} else {
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
}
}
// Step 2: Inject auto-launch before the retry delay
// Re-find serviceErrorStr since indices shifted after step 1
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
const retryMatch = searchRegion.match(
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
);
if (retryMatch) {
const retryStr = retryMatch[0];
const retryOffset = searchRegion.indexOf(retryStr);
const retryAbsIdx = newServiceErrorIdx + retryOffset;
// Inject auto-launch before the retry delay
// Service script is in app.asar.unpacked/ (not inside asar, since
// child_process cannot execute scripts from inside an asar).
// Uses fork() instead of spawn() because process.execPath in Electron
// is the Electron binary - spawn would trigger "file open" handling
// instead of executing the script as Node.js.
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
// Extract the enclosing function name (Ma or whatever it's
// minified to) so the dedup guard attaches to it
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
const funcNameRe = /async function (\w+)\s*\(\s*\w+\s*,\s*\w+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
let funcMatch;
let retryFuncName = null;
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
retryFuncName = funcMatch[1];
}
const spawnGuard = retryFuncName
? retryFuncName + '._lastSpawn'
: '_globalLastSpawn';
// Cooldown in ms — long enough to avoid fork storms, short enough
// that the retry loop can re-spawn after a mid-session daemon death.
const autoLaunch =
'process.platform==="linux"&&' +
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
'&&(' + spawnGuard + '=Date.now(),' +
'(()=>{try{' +
'const _p=require("path"),_fs=require("fs");' +
'const _d=_p.join(process.resourcesPath,' +
'"app.asar.unpacked","' + svcPath + '");' +
'if(_fs.existsSync(_d)){' +
// Open daemon log for append; fall back to ignoring stdio.
'let _stdio="ignore";' +
'try{' +
'const _ld=_p.join(process.env.HOME||"/tmp",' +
'".config/Claude/logs");' +
'_fs.mkdirSync(_ld,{recursive:true});' +
'const _fd=_fs.openSync(' +
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
'_stdio=["ignore",_fd,_fd,"ipc"]' +
'}catch(_){}' +
'const _c=require("child_process").fork(_d,[],' +
'{detached:true,stdio:_stdio,env:{...process.env,' +
'ELECTRON_RUN_AS_NODE:"1"}});' +
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
code = code.substring(0, retryAbsIdx) +
autoLaunch + code.substring(retryAbsIdx);
console.log(' Added service daemon auto-launch on Linux');
patchCount++;
if (code.includes('cowork-autolaunch')) {
console.log(' Service daemon auto-launch already applied');
} else {
console.log(' WARNING: Could not find retry delay for auto-launch patch');
// Re-find serviceErrorStr since indices shifted after step 1
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
const retryMatch = searchRegion.match(
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
);
if (retryMatch) {
const retryStr = retryMatch[0];
const retryOffset = searchRegion.indexOf(retryStr);
const retryAbsIdx = newServiceErrorIdx + retryOffset;
// Inject auto-launch before the retry delay
// Service script is in app.asar.unpacked/ (not inside asar, since
// child_process cannot execute scripts from inside an asar).
// Uses fork() instead of spawn() because process.execPath in Electron
// is the Electron binary - spawn would trigger "file open" handling
// instead of executing the script as Node.js.
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
// Extract the enclosing function name (Ma or whatever it's
// minified to) so the dedup guard attaches to it
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
const funcNameRe = /async function ([$\w]+)\s*\(\s*[$\w]+\s*,\s*[$\w]+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
let funcMatch;
let retryFuncName = null;
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
retryFuncName = funcMatch[1];
}
const spawnGuard = retryFuncName
? retryFuncName + '._lastSpawn'
: 'globalThis._lastSpawn';
// Cooldown in ms — long enough to avoid fork storms, short enough
// that the retry loop can re-spawn after a mid-session daemon death.
const autoLaunch =
'process.platform==="linux"&&' +
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
'&&(' + spawnGuard + '=Date.now(),' +
'(()=>{try{' +
'const _p=require("path"),_fs=require("fs");' +
'const _d=_p.join(process.resourcesPath,' +
'"app.asar.unpacked","' + svcPath + '");' +
'if(_fs.existsSync(_d)){' +
// Open daemon log for append; fall back to ignoring stdio.
'let _stdio="ignore";' +
'try{' +
'const _ld=_p.join(process.env.HOME||"/tmp",' +
'".config/Claude/logs");' +
'_fs.mkdirSync(_ld,{recursive:true});' +
'const _fd=_fs.openSync(' +
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
'_stdio=["ignore",_fd,_fd,"ipc"]' +
'}catch(_){}' +
'const _c=require("child_process").fork(_d,[],' +
'{detached:true,stdio:_stdio,env:{...process.env,' +
'ELECTRON_RUN_AS_NODE:"1"}});' +
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
code = code.substring(0, retryAbsIdx) +
autoLaunch + code.substring(retryAbsIdx);
console.log(' Added service daemon auto-launch on Linux');
patchCount++;
} else {
console.log(' WARNING: Could not find retry delay for auto-launch patch');
}
}
} else {
console.log(' WARNING: Could not find VM service error string for auto-launch');
@@ -375,7 +601,7 @@ if (serviceErrorIdx !== -1) {
// toward recovery over re-download avoidance is correct.
// ============================================================
{
const reinstallArrRe = /const (\w+)=\[("rootfs\.img"[^\]]*)\];/;
const reinstallArrRe = /const ([\w$]+)=\[("rootfs\.img"[^\]]*)\];/;
const arrMatch = code.match(reinstallArrRe);
if (arrMatch) {
const [whole, name, contents] = arrMatch;
@@ -421,7 +647,7 @@ if (serviceErrorIdx !== -1) {
{
// Find: MKDTEMP(PATH.join(OS.tmpdir(), "wvm-"))
// The bundle dir var is used in mkdir(VAR, ...) just before
const mkdtempRe = /(\w+)\.mkdtemp\(\s*(\w+)\.join\(\s*(\w+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
const mkdtempRe = /([\w$]+)\.mkdtemp\(\s*([\w$]+)\.join\(\s*([\w$]+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
const mkdtempMatch = code.match(mkdtempRe);
if (mkdtempMatch) {
const [fullMatch, fsVar, pathVar, osVar] = mkdtempMatch;
@@ -430,7 +656,7 @@ if (serviceErrorIdx !== -1) {
const searchStart = Math.max(0, mkdtempIdx - 2000);
const before = code.substring(searchStart, mkdtempIdx);
// Look for: mkdir(VARNAME, { recursive
const mkdirRe = /(\w+)\.mkdir\(\s*(\w+)\s*,\s*\{\s*recursive/g;
const mkdirRe = /([\w$]+)\.mkdir\(\s*([\w$]+)\s*,\s*\{\s*recursive/g;
let bundleVar = null;
let lastMkdir;
while ((lastMkdir = mkdirRe.exec(before)) !== null) {
@@ -465,118 +691,122 @@ if (serviceErrorIdx !== -1) {
// since minified names change between releases (#344).
// ============================================================
{
const anchor = '"[VM:start] Windows VM service configured"';
const anchorIdx = code.indexOf(anchor);
if (anchorIdx !== -1) {
// Find the "}" closing the win32 if-block after the anchor
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
if (closingBrace !== -1) {
// Extract minified variable names from the win32 block
// Search backwards from anchor to find the win32 block
const regionStart = Math.max(0, anchorIdx - 1000);
const region = code.substring(regionStart, anchorIdx);
if (code.includes('[VM:start] Copying smol-bin') && code.includes('process.platform==="linux"')) {
console.log(' Linux smol-bin copy block already present');
} else {
const anchor = '"[VM:start] Windows VM service configured"';
const anchorIdx = code.indexOf(anchor);
if (anchorIdx !== -1) {
// Find the "}" closing the win32 if-block after the anchor
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
if (closingBrace !== -1) {
// Extract minified variable names from the win32 block
// Search backwards from anchor to find the win32 block
const regionStart = Math.max(0, anchorIdx - 1000);
const region = code.substring(regionStart, anchorIdx);
// JS identifier may start with $, _, or letter; \w doesn't
// match $ so use [$\w]+ to capture vars like `$e` (Claude
// >= 1.3109.0 uses $e for the fs module to avoid collision
// with the parameter `e`). See issue #418.
// path var: VAR.join(process.resourcesPath,
const pathMatch = region.match(
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
);
// fs var: VAR.existsSync(
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
// logger var: VAR.info("[VM:start]
const logMatch = region.match(
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
);
// stream/pipeline var: VAR.pipeline(
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
// arch function: const VAR=FUNC(), used in smol-bin
const archMatch = region.match(
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
);
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
const bundleMatch = region.match(
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
);
// JS identifier may start with $, _, or letter; \w doesn't
// match $ so use [$\w]+ to capture vars like `$e` (Claude
// >= 1.3109.0 uses $e for the fs module to avoid collision
// with the parameter `e`). See issue #418.
// path var: VAR.join(process.resourcesPath,
const pathMatch = region.match(
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
);
// fs var: VAR.existsSync(
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
// logger var: VAR.info("[VM:start]
const logMatch = region.match(
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
);
// stream/pipeline var: VAR.pipeline(
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
// arch function: const VAR=FUNC(), used in smol-bin
const archMatch = region.match(
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
);
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
const bundleMatch = region.match(
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
);
if (pathMatch && fsMatch && logMatch &&
streamMatch && archMatch && bundleMatch) {
const pathVar = pathMatch[1];
const fsVar = fsMatch[1];
const logVar = logMatch[1];
const streamVar = streamMatch[1];
const archFunc = archMatch[2];
const bundleVar = bundleMatch[1];
if (pathMatch && fsMatch && logMatch &&
streamMatch && archMatch && bundleMatch) {
const pathVar = pathMatch[1];
const fsVar = fsMatch[1];
const logVar = logMatch[1];
const streamVar = streamMatch[1];
const archFunc = archMatch[2];
const bundleVar = bundleMatch[1];
const linuxBlock =
'if(process.platform==="linux"){' +
'const _la=' + archFunc + '(),' +
'_ls=' + pathVar + '.join(process.resourcesPath,' +
'`smol-bin.${_la}.vhdx`),' +
'_ld=' + pathVar + '.join(' + bundleVar +
',"smol-bin.vhdx");' +
fsVar + '.existsSync(_ls)?' +
'(' + logVar + '.info(' +
'`[VM:start] Copying smol-bin.${_la}' +
'.vhdx to bundle (Linux)`),' +
'await ' + streamVar + '.pipeline(' +
fsVar + '.createReadStream(_ls),' +
fsVar + '.createWriteStream(_ld)),' +
logVar + '.info(' +
'`[VM:start] smol-bin.${_la}' +
'.vhdx copied successfully`))' +
':' + logVar + '.warn(' +
'`[VM:start] smol-bin.${_la}' +
'.vhdx not found at ${_ls}`)' +
'}';
// Defensive: if a future upstream emits its own
// if(process.platform==="linux"){...} block right
// after the win32 close brace, strip it before
// injecting our correctly-wired linuxBlock so we
// don't end up with two competing blocks.
const insertPos = closingBrace + 1;
let stripUntil = insertPos;
const afterWin32 = code.substring(insertPos);
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
const upstreamMatch = afterWin32.match(upstreamRe);
if (upstreamMatch) {
const matchEnd = insertPos + upstreamMatch[0].length;
let depth = 1, pos = matchEnd;
while (depth > 0 && pos < code.length) {
if (code[pos] === '{') depth++;
else if (code[pos] === '}') depth--;
pos++;
}
if (depth === 0) {
stripUntil = pos;
console.log(' Stripped pre-existing upstream Linux block');
} else {
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
const linuxBlock =
'if(process.platform==="linux"){' +
'const _la=' + archFunc + '(),' +
'_ls=' + pathVar + '.join(process.resourcesPath,' +
'`smol-bin.${_la}.vhdx`),' +
'_ld=' + pathVar + '.join(' + bundleVar +
',"smol-bin.vhdx");' +
fsVar + '.existsSync(_ls)?' +
'(' + logVar + '.info(' +
'`[VM:start] Copying smol-bin.${_la}' +
'.vhdx to bundle (Linux)`),' +
'await ' + streamVar + '.pipeline(' +
fsVar + '.createReadStream(_ls),' +
fsVar + '.createWriteStream(_ld)),' +
logVar + '.info(' +
'`[VM:start] smol-bin.${_la}' +
'.vhdx copied successfully`))' +
':' + logVar + '.warn(' +
'`[VM:start] smol-bin.${_la}' +
'.vhdx not found at ${_ls}`)' +
'}';
// Defensive: if a future upstream emits its own
// if(process.platform==="linux"){...} block right
// after the win32 close brace, strip it before
// injecting our correctly-wired linuxBlock so we
// don't end up with two competing blocks.
const insertPos = closingBrace + 1;
let stripUntil = insertPos;
const afterWin32 = code.substring(insertPos);
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
const upstreamMatch = afterWin32.match(upstreamRe);
if (upstreamMatch) {
const matchEnd = insertPos + upstreamMatch[0].length;
let depth = 1, pos = matchEnd;
while (depth > 0 && pos < code.length) {
if (code[pos] === '{') depth++;
else if (code[pos] === '}') depth--;
pos++;
}
if (depth === 0) {
stripUntil = pos;
console.log(' Stripped pre-existing upstream Linux block');
} else {
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
}
}
code = code.substring(0, insertPos) +
linuxBlock +
code.substring(stripUntil);
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
patchCount++;
} else {
const missing = [];
if (!pathMatch) missing.push('path');
if (!fsMatch) missing.push('fs');
if (!logMatch) missing.push('logger');
if (!streamMatch) missing.push('stream');
if (!archMatch) missing.push('arch');
if (!bundleMatch) missing.push('bundlePath');
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
}
code = code.substring(0, insertPos) +
linuxBlock +
code.substring(stripUntil);
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
patchCount++;
} else {
const missing = [];
if (!pathMatch) missing.push('path');
if (!fsMatch) missing.push('fs');
if (!logMatch) missing.push('logger');
if (!streamMatch) missing.push('stream');
if (!archMatch) missing.push('arch');
if (!bundleMatch) missing.push('bundlePath');
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
}
} else {
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
}
} else {
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
}
}
@@ -586,49 +816,53 @@ if (serviceErrorIdx !== -1) {
// on Linux. Register our own to SIGTERM the daemon on app quit.
// ============================================================
{
const quitFnRe = /registerQuitHandler:\s*(\w+)/;
const quitFnMatch = code.match(quitFnRe);
if (quitFnMatch) {
const quitFn = quitFnMatch[1];
console.log(' Found registerQuitHandler function: ' + quitFn);
if (code.includes('cowork-linux-daemon-shutdown')) {
console.log(' Linux cowork daemon quit handler already registered');
} else {
const quitFnRe = /registerQuitHandler:\s*([\w$]+)/;
const quitFnMatch = code.match(quitFnRe);
if (quitFnMatch) {
const quitFn = quitFnMatch[1];
console.log(' Found registerQuitHandler function: ' + quitFn);
const quitFnDef = 'function ' + quitFn + '(';
const quitFnDefIdx = code.indexOf(quitFnDef);
if (quitFnDefIdx !== -1) {
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
if (fnBlock) {
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
fnBlock.length;
const shutdownHandler =
'process.platform==="linux"&&' + quitFn + '({' +
'name:"cowork-linux-daemon-shutdown",' +
'fn:async()=>{' +
'const _p=global.__coworkDaemonPid;' +
'if(!_p)return;' +
'try{const _cmd=require("fs").readFileSync(' +
'"/proc/"+_p+"/cmdline","utf8");' +
'if(!_cmd.includes("cowork-vm-service"))return' +
'}catch(_e){return}' +
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
'for(let _i=0;_i<50;_i++){' +
'await new Promise(_r=>setTimeout(_r,200));' +
'try{process.kill(_p,0)}catch(_e){return}' +
'}}});';
code = code.substring(0, insertIdx) +
shutdownHandler + code.substring(insertIdx);
console.log(' Registered Linux cowork daemon quit handler');
patchCount++;
const quitFnDef = 'function ' + quitFn + '(';
const quitFnDefIdx = code.indexOf(quitFnDef);
if (quitFnDefIdx !== -1) {
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
if (fnBlock) {
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
fnBlock.length;
const shutdownHandler =
'process.platform==="linux"&&' + quitFn + '({' +
'name:"cowork-linux-daemon-shutdown",' +
'fn:async()=>{' +
'const _p=global.__coworkDaemonPid;' +
'if(!_p)return;' +
'try{const _cmd=require("fs").readFileSync(' +
'"/proc/"+_p+"/cmdline","utf8");' +
'if(!_cmd.includes("cowork-vm-service"))return' +
'}catch(_e){return}' +
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
'for(let _i=0;_i<50;_i++){' +
'await new Promise(_r=>setTimeout(_r,200));' +
'try{process.kill(_p,0)}catch(_e){return}' +
'}}});';
code = code.substring(0, insertIdx) +
shutdownHandler + code.substring(insertIdx);
console.log(' Registered Linux cowork daemon quit handler');
patchCount++;
} else {
console.log(' WARNING: Could not find ' + quitFn +
' function body for quit handler');
}
} else {
console.log(' WARNING: Could not find ' + quitFn +
' function body for quit handler');
' function definition');
}
} else {
console.log(' WARNING: Could not find ' + quitFn +
' function definition');
console.log(' WARNING: Could not find registerQuitHandler' +
' export for quit handler');
}
} else {
console.log(' WARNING: Could not find registerQuitHandler' +
' export for quit handler');
}
}
@@ -664,7 +898,7 @@ if (serviceErrorIdx !== -1) {
// 'sessionId:VAR' in the config itself — cheap, scoped, and
// immune to unrelated *.userSelectedFolders references (e.g.
// loop variables) that wander into the enclosing scope.
const sidMatch = cfgBlock.match(/\{sessionId:(\w+)\b/);
const sidMatch = cfgBlock.match(/\{sessionId:([\w$]+)\b/);
if (!sidMatch) {
console.log(' WARNING: #412 no sessionId field in config');
} else {
@@ -689,7 +923,7 @@ if (serviceErrorIdx !== -1) {
// --- 12c: accept a 13th param in spawn() method body ---
let site3Done = false;
const spawnIdempotent =
/async spawn\([^)]+\)\{const \w+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
/async spawn\([^)]+\)\{const [\w$]+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
if (spawnIdempotent.test(code)) {
console.log(' #412 spawn method already accepts sharedCwdPath');
site3Done = true;
@@ -697,7 +931,7 @@ if (serviceErrorIdx !== -1) {
// Match the spawn body with the trailing mountConda setter and the
// IPC call. Captures: arg list, payload var, setter chain, IPC tail.
const spawnRe =
/async spawn\(([^)]+)\)\{const (\w+)=\{id:[^}]+\};([^{}]*?\w+&&\(\2\.mountConda=\w+\)),(await \w+\("spawn",\2\)\})/;
/async spawn\(([^)]+)\)\{const ([\w$]+)=\{id:[^}]+\};([^{}]*?[\w$]+&&\(\2\.mountConda=[\w$]+\)),(await [\w$]+\("spawn",\2\)\})/;
const spawnMatch = code.match(spawnRe);
if (!spawnMatch) {
console.log(' WARNING: #412 spawn method body regex did not match');
@@ -734,11 +968,11 @@ if (serviceErrorIdx !== -1) {
// the uniqueness so a second upstream caller wouldn't silently take
// only the first hit.
let site2Done = false;
if (/,\w+\.mountConda,\w+\.sharedCwdPath\)/.test(code)) {
if (/,[\w$]+\.mountConda,[\w$]+\.sharedCwdPath\)/.test(code)) {
console.log(' #412 caller already forwards sharedCwdPath');
site2Done = true;
} else {
const callMatches = [...code.matchAll(/,(\w+)\.mountConda\)/g)];
const callMatches = [...code.matchAll(/,([\w$]+)\.mountConda\)/g)];
if (callMatches.length === 0) {
console.log(' WARNING: #412 no ",VAR.mountConda)" pattern found');
} else if (callMatches.length > 1) {
@@ -794,16 +1028,40 @@ install_node_pty() {
echo '{"name":"node-pty-build","version":"1.0.0","private":true}' > package.json
echo 'Installing node-pty (this compiles native module)...'
if npm install node-pty 2>&1; then
echo 'node-pty installed successfully'
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
else
echo 'Failed to install node-pty - terminal features may not work'
# Fail loudly on npm install failure rather than warn-and-continue.
# The previous behavior silently dropped pty_src_dir, skipped the
# entire copy block, and shipped the upstream Windows node-pty
# binaries (the #401 failure mode). check_dependencies should now
# install gcc/g++/make/python3 before we get here, so this branch
# is the last line of defense for build-tool gaps that auto-install
# couldn't fix (unknown distro, broken package mirror, etc.).
if ! npm install node-pty 2>&1; then
echo "Error: 'npm install node-pty' failed." >&2
echo 'node-pty has a native module compiled via node-gyp;' >&2
echo 'this usually means the build environment lacks a C/C++' >&2
echo 'compiler, make, or python3.' >&2
echo '' >&2
echo 'Install build tools and re-run:' >&2
echo ' Debian/Ubuntu: sudo apt install build-essential python3' >&2
echo ' Fedora/RHEL: sudo dnf install gcc gcc-c++ make python3' >&2
cd "$project_root" || exit 1
exit 1
fi
echo 'node-pty installed successfully'
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
fi
if [[ -n $pty_src_dir && -d $pty_src_dir ]]; then
echo 'Copying node-pty JavaScript files into app.asar.contents...'
# Wipe the upstream-extracted node-pty before staging the Linux
# build. The Windows installer's app.asar ships node-pty with
# Windows binaries (winpty.dll, winpty-agent.exe, Windows
# build/Release/*.node files). `cp -r $pty_src_dir/build` only
# overwrites same-named files; orphan Windows binaries persist
# inside the asar, surface as PE32+ when users inspect with
# `asar list`, and pollute /tmp via Electron's lazy-extract on
# any spurious require() (#401).
rm -rf "$app_staging_dir/app.asar.contents/node_modules/node-pty"
mkdir -p "$app_staging_dir/app.asar.contents/node_modules/node-pty" || exit 1
# --no-preserve=mode so read-only bits from the Nix store
# (--node-pty-dir) don't propagate into the staging tree.

View File

@@ -0,0 +1,57 @@
#===============================================================================
# Linux org-plugins path: inject a case"linux" into the platform switch
# that resolves the org-plugins source directory.
#
# Upstream only has cases for darwin and win32; the default returns null,
# silently disabling the entire org-plugins marketplace feature on Linux.
# This adds: case"linux":return"/etc/claude/org-plugins"
#
# /etc/claude/org-plugins is FHS-correct for MDM-managed configuration,
# consistent with Claude Code's /etc/claude-code/ path.
#
# Sourced by: build.sh
# Sourced globals: (none)
# Modifies globals: (none)
#===============================================================================
patch_org_plugins_path() {
local index_js='app.asar.contents/.vite/build/index.js'
# Idempotency: skip if a Linux case already exists near the
# org-plugins path resolver (upstream may add one in the future).
if grep -q 'case"linux":return"/etc/claude/org-plugins"' \
"$index_js"; then
echo 'Linux org-plugins path already present'
return
fi
# Anchor: the darwin path string is unique in the entire bundle.
# Verify it exists before attempting the patch.
local anchor='Application Support/Claude/org-plugins'
if ! grep -q "$anchor" "$index_js"; then
echo 'Warning: org-plugins path resolver not found' \
'in this version, skipping' >&2
return
fi
# Pattern (minified):
# ..."org-plugins");default:return null}
#
# The compound anchor — "org-plugins") immediately before
# default:return null — is unique to this switch statement.
# Insert case"linux":return"/etc/claude/org-plugins"; between
# the end of the win32 case and the default case.
#
# \s* between tokens handles any future whitespace variation,
# though the target file is always minified in practice.
if grep -qP '"org-plugins"\)\s*;\s*default\s*:\s*return\s+null' \
"$index_js"; then
sed -i -E \
's/("org-plugins"\)\s*;\s*)(default\s*:\s*return\s+null)/\1case"linux":return"\/etc\/claude\/org-plugins";\2/' \
"$index_js"
echo 'Added Linux org-plugins path (/etc/claude/org-plugins)'
else
echo 'Warning: org-plugins switch pattern not matched,' \
'skipping' >&2
fi
}

View File

@@ -14,7 +14,7 @@ patch_quick_window() {
# Extract the quick window variable name from the unique "pop-up-menu"
# setAlwaysOnTop call, e.g.: Sa.setAlwaysOnTop(!0,"pop-up-menu")
local quick_var
quick_var=$(grep -oP '\w+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
quick_var=$(grep -oP '[$\w]+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
"$index_js" | head -1)
if [[ -z $quick_var ]]; then
echo 'WARNING: Could not extract quick window variable name'
@@ -35,9 +35,9 @@ patch_quick_window() {
de_check+='.toLowerCase().includes("kde")'
if grep -qF "${quick_var}.blur(),${quick_var}.hide()" "$index_js"; then
echo ' Quick window blur already patched'
elif grep -qP "\|\|${quick_var_re}\.hide\(\)" "$index_js"; then
sed -i \
"s/||${quick_var_re}\.hide()/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
elif grep -qP "\|\|\s*${quick_var_re}\.hide\(\)" "$index_js"; then
sed -i -E \
"s/\|\|\s*${quick_var_re}\.hide\(\)/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
"$index_js"
echo ' Added KDE-gated blur() before hide() on quick window'
else
@@ -57,11 +57,11 @@ let patchCount = 0;
// Find the minified isWindowFocused function via its named property
// export: isWindowFocused: () => !!NAME()
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!(\w+)\(\)/;
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!([\w$]+)\(\)/;
const focusedMatch = code.match(focusedPropRe);
if (!focusedMatch) {
console.log(' WARNING: Could not find isWindowFocused function');
process.exit(0);
process.exit(1);
}
const focusFn = focusedMatch[1];
console.log(' Found focus check function: ' + focusFn);
@@ -74,12 +74,12 @@ console.log(' Found focus check function: ' + focusFn);
// group keeps the prefix optional in either case.
const focusFnIdx = code.indexOf('function ' + focusFn + '(');
const nearbyCode = code.substring(focusFnIdx, focusFnIdx + 500);
const visFnRe = /function (\w+)\(\)\{(?:var \w+(?:,\w+)*;)?return!\w+\|\|\w+\.isDestroyed\(\)\?!1:\w+\.isVisible\(\)/;
const visFnRe = /function (\w+)\(\)\{(?:var [\w$]+(?:,[\w$]+)*;)?return![\w$]+\|\|[\w$]+\.isDestroyed\(\)\?!1:[\w$]+\.isVisible\(\)/;
const visMatch = nearbyCode.match(visFnRe);
if (!visMatch) {
console.log(' WARNING: Could not find visibility function near ' +
focusFn);
process.exit(0);
process.exit(1);
}
const visFn = visMatch[1];
console.log(' Found visibility check function: ' + visFn);
@@ -106,7 +106,7 @@ for (const anchor of anchors) {
}
// matches: <focusFn>()||(someVar).show()
const showRe = new RegExp(
escapeRegExp(focusFn) + String.raw`\(\)\|\|(\w+)\.show\(\)`
escapeRegExp(focusFn) + String.raw`\(\)\|\|([\w$]+)\.show\(\)`
);
const showMatch = region.match(showRe);
if (showMatch) {

View File

@@ -11,9 +11,9 @@ patch_tray_menu_handler() {
echo 'Patching tray menu handler...'
local index_js='app.asar.contents/.vite/build/index.js'
local tray_func tray_var first_const
local tray_func tray_func_re tray_var
tray_func=$(grep -oP \
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
if [[ -z $tray_func ]]; then
echo 'Failed to extract tray menu function name' >&2
cd "$project_root" || exit 1
@@ -21,9 +21,12 @@ patch_tray_menu_handler() {
fi
echo " Found tray function: $tray_func"
# Escape `$` for PCRE / sed -E patterns where it would otherwise act
# as an end-of-line anchor. Minifier emits identifiers like `i$A`.
tray_func_re="${tray_func//\$/\\$}"
tray_var=$(grep -oP \
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
"$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
@@ -31,49 +34,40 @@ patch_tray_menu_handler() {
fi
echo " Found tray variable: $tray_var"
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
"$index_js"
first_const=$(grep -oP \
"async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \
"$index_js" | head -1)
if [[ -z $first_const ]]; then
echo 'Failed to extract first const in function' >&2
cd "$project_root" || exit 1
exit 1
fi
echo " Found first const variable: $first_const"
# Add mutex guard to prevent concurrent tray rebuilds
if ! grep -q "${tray_func}._running" "$index_js"; then
sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
# Idempotent: upstream may already ship the function as `async`
# (1.8089.1 does). Re-applying the sed would produce
# `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 -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
"$index_js"
echo " Added mutex guard to ${tray_func}()"
fi
# 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 -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 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}\(\)\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() {
@@ -81,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
@@ -98,18 +92,19 @@ 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 local_tray_var tray_var_re
local menu_func path_var enabled_var enabled_count
local tray_func tray_func_re local_tray_var tray_var_re
local menu_func menu_var menu_var_re path_var enabled_var enabled_count
tray_func=$(grep -oP \
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
if [[ -z $tray_func ]]; then
echo ' Could not find tray function — skipping'
echo '##############################################################'
return
fi
# 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})" \
"$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 '##############################################################'
@@ -119,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
@@ -134,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'
@@ -148,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"
@@ -157,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 '##############################################################'
@@ -236,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

@@ -22,32 +22,51 @@ check_dependencies() {
rpm) all_deps="$all_deps rpmbuild" ;;
esac
# node-pty has a native C++ module compiled via node-gyp during
# `npm install`. Without gcc/g++/make/python3 the install silently
# emits a warning, leaves pty_src_dir empty, and the build ends up
# shipping the upstream Windows binaries (the #401 failure mode).
# Skip when --node-pty-dir is set (Nix and explicit overrides bring
# their own pre-built node-pty).
if [[ -z ${node_pty_dir:-} ]]; then
all_deps="$all_deps gcc g++ make python3"
fi
# Command-to-package mappings per distro family
declare -A debian_pkgs=(
[p7zip]='p7zip-full' [wget]='wget' [wrestool]='icoutils'
[icotool]='icoutils' [convert]='imagemagick'
[dpkg-deb]='dpkg-dev' [rpmbuild]='rpm'
[gcc]='build-essential' [g++]='build-essential'
[make]='build-essential' [python3]='python3'
)
declare -A rpm_pkgs=(
[p7zip]='p7zip p7zip-plugins' [wget]='wget' [wrestool]='icoutils'
[icotool]='icoutils' [convert]='ImageMagick'
[dpkg-deb]='dpkg' [rpmbuild]='rpm-build'
[gcc]='gcc' [g++]='gcc-c++'
[make]='make' [python3]='python3'
)
local cmd
local cmd pkg
for cmd in $all_deps; do
if ! check_command "$cmd"; then
case "$distro_family" in
debian)
deps_to_install="$deps_to_install ${debian_pkgs[$cmd]}"
;;
rpm)
deps_to_install="$deps_to_install ${rpm_pkgs[$cmd]}"
;;
debian) pkg="${debian_pkgs[$cmd]}" ;;
rpm) pkg="${rpm_pkgs[$cmd]}" ;;
*)
echo "Warning: Cannot auto-install '$cmd' on unknown distro. Please install manually." >&2
continue
;;
esac
# Several commands map to the same package (gcc/g++/make
# -> build-essential, wrestool/icotool -> icoutils). Skip
# if the package is already queued so the log line stays
# readable.
case " $deps_to_install " in
*" $pkg "*) ;;
*) deps_to_install="$deps_to_install $pkg" ;;
esac
fi
done
@@ -198,6 +217,13 @@ setup_nodejs() {
setup_electron_asar() {
section_header 'Electron & Asar Handling'
# Pin Electron to the exact version upstream Claude Desktop ships
# (build-reference/app-extracted/package.json). The shipped app.asar
# binds to specific V8/NAPI ABI, Chromium pairing, and node-pty
# native surface — running a different Electron major against this
# asar is unsupported. Bump when upstream bumps.
local electron_version='41.5.0'
echo "Ensuring local Electron and Asar installation in $work_dir..."
cd "$work_dir" || exit 1
@@ -214,19 +240,91 @@ setup_electron_asar() {
[[ ! -f $asar_bin_path ]] && echo 'Asar binary not found.' && install_needed=true
if [[ $install_needed == true ]]; then
echo "Installing Electron and Asar locally into $work_dir..."
if ! npm install --no-save electron @electron/asar; then
echo "Installing electron@${electron_version} and Asar locally into $work_dir..."
if ! npm install --no-save \
"electron@${electron_version}" @electron/asar @electron/get extract-zip; then
echo 'Failed to install Electron and/or Asar locally.' >&2
cd "$project_root" || exit 1
exit 1
fi
echo 'Electron and Asar installation command finished.'
# electron@42+ no longer ships a postinstall script that fetches
# the prebuilt binary into dist/. If npm didn't populate it,
# fetch the matching binary explicitly via @electron/get. See
# #584. Retry once on transient CDN failures (503, network drops).
#
# Check for the binary itself (not just the dist/ directory),
# because under Node 24 the extract-zip step in both the npm
# postinstall (electron <42 path) and @electron/get can silently
# no-op — leaving an empty dist/locales/ behind, which would pass
# a bare `-d` check while no electron binary actually landed.
if [[ ! -f $electron_dist_path/electron ]]; then
echo 'Electron dist/electron missing; fetching binary explicitly...'
local fetch_ok=false
local fetch_attempts=0
while ! node "$project_root/scripts/setup/fetch-electron-binary.js"; do
fetch_attempts=$((fetch_attempts + 1))
if (( fetch_attempts >= 2 )); then
echo 'Failed to fetch Electron binary via @electron/get after 2 attempts.' >&2
echo 'For air-gapped or mirrored builds set ELECTRON_MIRROR or ELECTRON_CUSTOM_DIR; see docs/building.md.' >&2
break
fi
echo "Retrying Electron binary fetch (attempt $((fetch_attempts + 1))/2)..."
sleep 2
done
if (( fetch_attempts < 2 )); then
fetch_ok=true
fi
# Final fallback: even when @electron/get reports success,
# extract-zip can leave dist/ empty under Node 24 (the
# unzip stream resolves without writing files). If we still
# have no binary, the cache zip was downloaded successfully
# — unpack it with system `unzip`.
if [[ ! -f $electron_dist_path/electron ]]; then
if [[ $fetch_ok == false ]]; then
echo 'Electron download failed; no cached zip to fall back on.' >&2
cd "$project_root" || exit 1
exit 1
fi
echo 'extract-zip path produced no binary; unpacking @electron/get cache with system unzip...'
local electron_cache_dir="$HOME/.cache/electron"
local electron_arch
case $architecture in
amd64) electron_arch='x64' ;;
arm64) electron_arch='arm64' ;;
*) electron_arch='x64' ;;
esac
local cached_zip
cached_zip=$(find "$electron_cache_dir" -name "electron-v${electron_version}-linux-${electron_arch}.zip" 2>/dev/null | head -1)
if [[ -z $cached_zip ]]; then
echo "No cached zip matching electron-v${electron_version}-linux-*.zip under $electron_cache_dir" >&2
cd "$project_root" || exit 1
exit 1
fi
if ! command -v unzip >/dev/null 2>&1; then
echo "unzip not installed; cannot apply final fallback. Install unzip and retry, or upgrade extract-zip upstream." >&2
cd "$project_root" || exit 1
exit 1
fi
mkdir -p "$electron_dist_path"
if ! unzip -oq "$cached_zip" -d "$electron_dist_path"; then
echo 'unzip fallback failed.' >&2
cd "$project_root" || exit 1
exit 1
fi
printf 'v%s\n' "$electron_version" > "$electron_dist_path/version"
printf 'electron\n' > "$work_dir/node_modules/electron/path.txt"
echo "unzip fallback populated $electron_dist_path ($(du -sh "$electron_dist_path" | awk '{print $1}'))"
fi
fi
else
echo 'Local Electron distribution and Asar binary already present.'
fi
if [[ -d $electron_dist_path ]]; then
echo "Found Electron distribution directory at $electron_dist_path."
if [[ -f $electron_dist_path/electron ]]; then
echo "Found Electron binary at $electron_dist_path."
chosen_electron_module_path="$(realpath "$work_dir/node_modules/electron")"
echo "Setting Electron module path for copying to $chosen_electron_module_path."
else

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.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
claude_exe_sha256='e619c7bd3b6746a7307ebefe509bfe447a143aed97e6c7f666677b36a6b6ba54'
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe'
claude_exe_sha256='9e6e62db0b1e49d99e423b943556cc43c57851e11367d968a7ac01faf7e140cb'
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.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
claude_exe_sha256='bf7de5d6c012542feadf7d5caa77a72dfcea1b24512e9a6d28e004ba4ae2a11d'
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe'
claude_exe_sha256='68209e252f134ce3a0b6f42cb4b08ec5201962a159c34bc6282fffbd97a30eef'
architecture='arm64'
claude_exe_filename='Claude-Setup-arm64.exe'
echo 'Configured for arm64 (aarch64) build.'

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
// Fetches the Electron prebuilt binary into node_modules/electron/dist/.
//
// electron@42.0.0 (2026-05-06) removed the postinstall script that
// historically populated dist/ during `npm install`. This helper restores
// that behavior using @electron/get + extract-zip, so the rest of the
// build pipeline (which depends on the dist/ layout) keeps working.
//
// Run from the directory containing node_modules/electron. Reads the
// installed electron version from its package.json and downloads the
// matching binary for the host platform/arch.
//
// See: https://github.com/aaddrick/claude-desktop-debian/issues/584
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
async function main() {
const cwd = process.cwd();
const electronModuleDir = path.join(cwd, 'node_modules', 'electron');
const distDir = path.join(electronModuleDir, 'dist');
if (!fs.existsSync(electronModuleDir)) {
throw new Error(
`Electron module not found at ${electronModuleDir}; ` +
"run 'npm install electron' first.",
);
}
const pkgPath = path.join(electronModuleDir, 'package.json');
const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (!version) {
throw new Error(`Could not read version from ${pkgPath}`);
}
const platform = 'linux';
// node's process.arch values map cleanly to electron release archs,
// except 'arm' which electron publishes as 'armv7l'.
const arch = process.arch === 'arm' ? 'armv7l' : process.arch;
const supportedArchs = ['x64', 'arm64', 'armv7l', 'ia32'];
if (!supportedArchs.includes(arch)) {
throw new Error(
`Unsupported architecture: ${arch}. ` +
`Electron publishes Linux binaries for ${supportedArchs.join(', ')}.`,
);
}
// Resolve @electron/get and extract-zip from the work-dir's
// node_modules. The script lives at scripts/setup/ so a plain
// require() walks up from there and never sees work_dir/.
const workDirRequire = createRequire(path.join(cwd, 'package.json'));
const { downloadArtifact } = workDirRequire('@electron/get');
const extractZip = workDirRequire('extract-zip');
console.log(`Fetching electron@${version} for ${platform}-${arch}...`);
const zipPath = await downloadArtifact({
version,
platform,
arch,
artifactName: 'electron',
});
console.log(`Extracting ${zipPath} into ${distDir}`);
fs.mkdirSync(distDir, { recursive: true });
await extractZip(zipPath, { dir: distDir });
const electronBin = path.join(distDir, 'electron');
if (fs.existsSync(electronBin)) {
fs.chmodSync(electronBin, 0o755);
}
console.log('Electron binary fetched and extracted successfully.');
}
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});

200
scripts/verify-patches.sh Executable file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env bash
#
# verify-patches.sh
#
# Static-greps a patched index.js for the patch markers defined in
# a TSV (defaults to scripts/cowork-patch-markers.tsv). Exits non-zero
# on any miss and names the missing markers in the output.
#
# Defends against silent half-patched asars (issue #559 D6, PR #555).
# Reusable for non-cowork patch sets — pass any TSV of the same shape
# via the second arg.
#
# Usage:
# verify-patches.sh <path> [markers-tsv]
#
# <path> may be:
# * a JavaScript file (the index.js itself)
# * an .asar archive (extracted on the fly via npx @electron/asar)
# * a directory containing app.asar.contents/.vite/build/index.js
#
# Exit codes:
# 0 — every marker present.
# 1 — usage error or input not found.
# 2 — one or more markers missing (named on stderr).
#
set -u
IFS=$'\n\t'
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
default_markers_tsv="$script_dir/cowork-patch-markers.tsv"
markers_tsv="$default_markers_tsv"
usage() {
cat <<-EOF >&2
Usage: $(basename "$0") <path> [markers-tsv]
<path> may be a .js file, an .asar archive, or a directory
containing app.asar.contents/.vite/build/index.js. The script
greps for patch markers (default: cowork, PR #555 / issue #559
D6) and exits non-zero if any are missing.
[markers-tsv] overrides the default TSV so the same script can
verify other patch sets.
EOF
}
# Parse the marker TSV into three parallel arrays. Skips comments
# and blank lines. Used by both the verify path here and by the
# BATS test, which sources this script (see _is_sourced below) to
# share parsing and avoid drift between the two consumers.
load_markers() {
marker_names=()
marker_patterns=()
marker_samples=()
if [[ ! -f $markers_tsv ]]; then
echo "verify-patches: marker file not found:" \
"$markers_tsv" >&2
return 1
fi
local name pattern sample
while IFS=$'\t' read -r name pattern sample; do
[[ -z $name || $name == '#'* ]] && continue
if [[ -z ${pattern:-} || -z ${sample:-} ]]; then
echo "verify-patches: malformed row '$name'" \
'in markers file' >&2
return 1
fi
marker_names+=("$name")
marker_patterns+=("$pattern")
marker_samples+=("$sample")
done < "$markers_tsv"
if [[ ${#marker_names[@]} -eq 0 ]]; then
echo 'verify-patches: no markers loaded' >&2
return 1
fi
}
# Resolve the input path to an actual index.js. For .asar inputs,
# extracts to a temp dir and echoes the inner index.js path. The
# caller cleans up via cleanup_tmp.
tmp_extract_dir=''
cleanup_tmp() {
if [[ -n $tmp_extract_dir && -d $tmp_extract_dir ]]; then
rm -rf "$tmp_extract_dir"
fi
}
trap cleanup_tmp EXIT
resolve_index_js() {
local input="$1"
if [[ ! -e $input ]]; then
echo "verify-patches: not found: $input" >&2
return 1
fi
if [[ -d $input ]]; then
local candidate="$input/app.asar.contents/.vite/build/index.js"
if [[ -f $candidate ]]; then
printf '%s\n' "$candidate"
return 0
fi
echo "verify-patches: directory does not contain" \
"app.asar.contents/.vite/build/index.js: $input" >&2
return 1
fi
if [[ $input == *.asar ]]; then
if ! command -v npx > /dev/null 2>&1; then
echo 'verify-patches: npx not found; install Node.js' \
'or pre-extract the asar' >&2
return 1
fi
tmp_extract_dir="$(mktemp -d)"
if ! npx --yes @electron/asar extract "$input" \
"$tmp_extract_dir" > /dev/null 2>&1; then
echo "verify-patches: asar extraction failed:" \
"$input" >&2
return 1
fi
local extracted="$tmp_extract_dir/.vite/build/index.js"
if [[ ! -f $extracted ]]; then
echo 'verify-patches: extracted asar lacks' \
'.vite/build/index.js' >&2
return 1
fi
printf '%s\n' "$extracted"
return 0
fi
# Treat as a JS file (.js or any other extension) — let grep
# decide whether the contents are sensible.
printf '%s\n' "$input"
}
main() {
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage
return 1
fi
case "$1" in
-h | --help)
usage
return 0
;;
esac
if [[ $# -eq 2 ]]; then
markers_tsv="$2"
fi
local index_js
if ! index_js="$(resolve_index_js "$1")"; then
return 1
fi
if ! load_markers; then
return 1
fi
echo "Verifying patch markers in: $index_js"
echo "Marker source: $markers_tsv"
local i missing_names=()
for i in "${!marker_names[@]}"; do
if grep -qP -- "${marker_patterns[$i]}" "$index_js"; then
printf ' OK %s\n' "${marker_names[$i]}"
else
printf ' MISS %s\n' "${marker_names[$i]}" >&2
missing_names+=("${marker_names[$i]}")
fi
done
if [[ ${#missing_names[@]} -gt 0 ]]; then
local joined
joined="$(IFS=','; printf '%s' "${missing_names[*]}")"
printf '\nverify-patches: %d/%d markers missing: %s\n' \
"${#missing_names[@]}" "${#marker_names[@]}" "$joined" >&2
return 2
fi
printf '\nAll %d patch markers present.\n' \
"${#marker_names[@]}"
return 0
}
# Library mode: when sourced (BATS test), expose load_markers and
# the markers_tsv path without running main.
_is_sourced() {
[[ ${BASH_SOURCE[0]} != "${0}" ]]
}
if ! _is_sourced; then
main "$@"
fi

614
tests/doctor.bats Normal file
View File

@@ -0,0 +1,614 @@
#!/usr/bin/env bats
#
# doctor.bats
# Tests for diagnostic helpers in scripts/doctor.sh
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
export HOME="$TEST_TMP/home"
export XDG_CACHE_HOME="$TEST_TMP/cache"
export XDG_CONFIG_HOME="$TEST_TMP/config"
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
# Clear all input/display vars to avoid host-state leakage
unset DISPLAY
unset WAYLAND_DISPLAY
unset XDG_SESSION_TYPE
unset CLAUDE_USE_WAYLAND
unset GTK_IM_MODULE
unset CLAUDE_GTK_IM_MODULE
unset CLAUDE_PASSWORD_STORE
# shellcheck source=scripts/doctor.sh
source "$SCRIPT_DIR/../scripts/doctor.sh"
_doctor_colors
_doctor_failures=0
# Default _pkg_installed to "unknown" (rc=2) so tests don't have
# to stub it unless they're exercising the package-check branch.
# Override in-test for rc=0 (installed) or rc=1 (missing).
_pkg_installed() { return 2; }
# Default stub for _detect_password_store (defined in
# launcher-common.sh, not sourced here). Tests that exercise
# _doctor_check_password_store override this in-test if needed.
_detect_password_store() { echo 'basic'; }
}
teardown() {
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
# Make `command -v gtk-query-immodules-3.0` report "not found" so the
# immodules cache check is skipped. Used by tests that aren't
# exercising the cache branch but reach it because no earlier gate
# fires. `command -v` finds bash functions too, so just unsetting a
# stub function isn't enough — we shadow `command` itself.
_skip_gtk_query() {
command() {
if [[ $1 == '-v' && $2 == 'gtk-query-immodules-3.0' ]]; then
return 1
fi
builtin command "$@"
}
}
# =============================================================================
# _cowork_pkg_hint: ibus-gtk3 mapping (#550)
# =============================================================================
@test "_cowork_pkg_hint: debian maps ibus-gtk3 to ibus-gtk3 via apt" {
local result
result=$(_cowork_pkg_hint debian ibus-gtk3)
[[ $result == "sudo apt install ibus-gtk3" ]]
}
@test "_cowork_pkg_hint: fedora maps ibus-gtk3 to ibus-gtk3 via dnf" {
local result
result=$(_cowork_pkg_hint fedora ibus-gtk3)
[[ $result == "sudo dnf install ibus-gtk3" ]]
}
@test "_cowork_pkg_hint: arch maps ibus-gtk3 to ibus (bundled)" {
local result
result=$(_cowork_pkg_hint arch ibus-gtk3)
[[ $result == "sudo pacman -S ibus" ]]
}
# =============================================================================
# _doctor_check_im_modules: CLAUDE_GTK_IM_MODULE override visibility
# =============================================================================
@test "_doctor_check_im_modules: emits override line when CLAUDE_GTK_IM_MODULE set" {
# CLAUDE_GTK_IM_MODULE makes active_im non-empty, so we'd reach
# the cache check — skip it to keep this test focused.
_skip_gtk_query
CLAUDE_GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output == *'CLAUDE_GTK_IM_MODULE=xim'* ]]
[[ $output == *'overrides GTK_IM_MODULE for Electron'* ]]
}
@test "_doctor_check_im_modules: no override line when CLAUDE_GTK_IM_MODULE unset" {
run _doctor_check_im_modules debian
[[ $output != *'CLAUDE_GTK_IM_MODULE'* ]]
}
# =============================================================================
# _doctor_check_im_modules: XWayland-with-IBus routing note
# =============================================================================
@test "_doctor_check_im_modules: emits XWayland note when wayland session and CLAUDE_USE_WAYLAND unset" {
XDG_SESSION_TYPE='wayland'
# CLAUDE_USE_WAYLAND deliberately unset
run _doctor_check_im_modules debian
[[ $output == *'XWayland'* ]]
[[ $output == *'CLAUDE_USE_WAYLAND=1'* ]]
}
@test "_doctor_check_im_modules: no XWayland note when CLAUDE_USE_WAYLAND=1" {
XDG_SESSION_TYPE='wayland'
CLAUDE_USE_WAYLAND='1'
run _doctor_check_im_modules debian
[[ $output != *'XWayland'* ]]
}
@test "_doctor_check_im_modules: no XWayland note on X11 session" {
XDG_SESSION_TYPE='x11'
run _doctor_check_im_modules debian
[[ $output != *'XWayland'* ]]
}
# =============================================================================
# _doctor_check_im_modules: ibus-gtk3 package check
# =============================================================================
@test "_doctor_check_im_modules: warns when ibus selected but ibus-gtk3 missing" {
# Package not installed (rc=1, definitive answer)
_pkg_installed() { return 1; }
GTK_IM_MODULE='ibus'
run _doctor_check_im_modules debian
[[ $output == *'[WARN]'* ]]
[[ $output == *'ibus-gtk3 is not installed'* ]]
[[ $output == *'sudo apt install ibus-gtk3'* ]]
}
@test "_doctor_check_im_modules: no warning when ibus selected and ibus-gtk3 present" {
# Package installed (rc=0); cache lists ibus.
_pkg_installed() { return 0; }
gtk-query-immodules-3.0() {
echo '"ibus" "IBus" "ibus" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='ibus'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_im_modules: no package warning when active module isn't ibus" {
# Even with rc=1 for ibus-gtk3, the package check should be
# skipped entirely when GTK_IM_MODULE isn't ibus.
_pkg_installed() { return 1; }
_skip_gtk_query
GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output != *'ibus-gtk3'* ]]
}
@test "_doctor_check_im_modules: no package warning on unsupported distro (rc=2)" {
# Default _pkg_installed (rc=2) — no warning even with ibus.
_skip_gtk_query
GTK_IM_MODULE='ibus'
run _doctor_check_im_modules unknown
[[ $output != *'[WARN]'* ]]
}
# =============================================================================
# _doctor_check_im_modules: immodules cache check
# =============================================================================
@test "_doctor_check_im_modules: warns when GTK_IM_MODULE not in immodules cache" {
# gtk-query-immodules-3.0 lists xim but not fcitx
gtk-query-immodules-3.0() {
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='fcitx'
run _doctor_check_im_modules debian
[[ $output == *'[WARN]'* ]]
[[ $output == *"'fcitx' not listed"* ]]
[[ $output == *'gtk-query-immodules-3.0 --update-cache'* ]]
}
@test "_doctor_check_im_modules: no warning when active module is in cache" {
gtk-query-immodules-3.0() {
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_im_modules: skips cache check when gtk-query-immodules-3.0 missing" {
_skip_gtk_query
GTK_IM_MODULE='fcitx'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
[[ $output != *'cache may be stale'* ]]
}
@test "_doctor_check_im_modules: CLAUDE_GTK_IM_MODULE takes precedence as active module" {
# Cache lists xim but not ibus. CLAUDE_GTK_IM_MODULE=xim should
# win over GTK_IM_MODULE=ibus, so no cache warning fires.
gtk-query-immodules-3.0() {
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='ibus'
CLAUDE_GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_im_modules: no checks fire when no IM module selected" {
# Neither GTK_IM_MODULE nor CLAUDE_GTK_IM_MODULE set — function
# should return early before the package or cache checks.
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
[[ $output != *'ibus-gtk3'* ]]
}
# =============================================================================
# _doctor_check_recent_crashes: GPU FATAL crash counter (#583)
# =============================================================================
# Install a coredumpctl shim. $1 is the coredumpctl-list-style
# multi-line output to emit (header + entry rows). The shim ignores
# its arguments — tests don't exercise the filter syntax.
_install_coredumpctl_shim() {
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/coredumpctl" <<SHIM
#!/usr/bin/env bash
cat <<'OUT'
$1
OUT
SHIM
chmod +x "$TEST_TMP/bin/coredumpctl"
export PATH="$TEST_TMP/bin:$PATH"
}
@test "_doctor_check_recent_crashes: no coredumpctl on PATH — silent" {
# Force coredumpctl off PATH so the helper short-circuits.
# Restore PATH before returning so teardown's rm works.
local saved_path="$PATH"
export PATH="/no-such-dir-for-test"
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
export PATH="$saved_path"
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_recent_crashes: zero crashes — silent" {
# Listing has the header line only, no entry rows.
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_recent_crashes: 1 crash — info line, no warn" {
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'Recent Electron crashes: 1'* ]]
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_recent_crashes: 3+ crashes — warn + #583 pointer" {
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M
Mon 2026-05-04 07:44:48 EDT 930532 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 22.8M
Sun 2026-05-03 14:34:10 EDT 567221 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 12.4M'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'Recent Electron crashes: 3'* ]]
[[ $output == *'CLAUDE_DISABLE_GPU=1'* ]]
[[ $output == *'/issues/583'* ]]
}
@test "_doctor_check_recent_crashes: path mismatch falls back with footnote" {
# Three crashes from a DIFFERENT electron binary (e.g., Slack).
# Caller passes claude-desktop's electron path, which doesn't
# match — helper falls back to total count and adds the footnote
# so the user knows the count may be cross-app.
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 09:00:00 EDT 200001 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
Wed 2026-05-05 09:00:00 EDT 200002 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
Wed 2026-05-04 09:00:00 EDT 200003 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'may be from other Electron apps'* ]]
}
@test "_doctor_check_recent_crashes: empty electron_path falls back" {
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
# Caller didn't pass an electron_path — helper still counts and
# emits the info line based on the unfiltered total.
run _doctor_check_recent_crashes ''
[[ $status -eq 0 ]]
[[ $output == *'Recent Electron crashes: 1'* ]]
[[ $output == *'may be from other Electron apps'* ]]
}
# =============================================================================
# _doctor_check_filename_limit: NAME_MAX probe + eCryptfs hint (#590)
# =============================================================================
# Install a getconf shim that emits $1 on stdout. Empty $1 → shim exits 1
# so callers can test the "getconf failed" path.
_install_getconf_shim() {
mkdir -p "$TEST_TMP/bin"
local value="$1"
if [[ -z $value ]]; then
cat > "$TEST_TMP/bin/getconf" <<'SHIM'
#!/usr/bin/env bash
exit 1
SHIM
else
cat > "$TEST_TMP/bin/getconf" <<SHIM
#!/usr/bin/env bash
echo ${value}
SHIM
fi
chmod +x "$TEST_TMP/bin/getconf"
export PATH="$TEST_TMP/bin:$PATH"
}
# Install a df shim that emits a single-column fstype listing matching
# the `df --output=fstype` shape the helper relies on. Empty $1 → shim
# exits 1 so callers can test the "df failed" path.
_install_df_shim() {
mkdir -p "$TEST_TMP/bin"
local fstype="$1"
if [[ -z $fstype ]]; then
cat > "$TEST_TMP/bin/df" <<'SHIM'
#!/usr/bin/env bash
exit 1
SHIM
else
cat > "$TEST_TMP/bin/df" <<SHIM
#!/usr/bin/env bash
cat <<'OUT'
Type
${fstype}
OUT
SHIM
fi
chmod +x "$TEST_TMP/bin/df"
export PATH="$TEST_TMP/bin:$PATH"
}
@test "_doctor_check_filename_limit: silent when NAME_MAX >= 200" {
_install_getconf_shim '255'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_filename_limit: warns when NAME_MAX < 200" {
_install_getconf_shim '143'
_install_df_shim 'ext4'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'NAME_MAX=143'* ]]
[[ $output == *'#590'* ]]
# Non-ecryptfs fs: no LUKS hint
[[ $output != *'eCryptfs'* ]]
[[ $output != *'LUKS'* ]]
}
@test "_doctor_check_filename_limit: eCryptfs adds LUKS workaround hint" {
_install_getconf_shim '143'
_install_df_shim 'ecryptfs'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'NAME_MAX=143'* ]]
[[ $output == *'eCryptfs'* ]]
[[ $output == *'LUKS'* ]]
}
@test "_doctor_check_filename_limit: silent on non-numeric getconf output" {
_install_getconf_shim 'undefined'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_filename_limit: silent when getconf fails" {
_install_getconf_shim ''
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_filename_limit: df failure suppresses eCryptfs hint, keeps warn" {
_install_getconf_shim '143'
_install_df_shim ''
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'NAME_MAX=143'* ]]
[[ $output != *'eCryptfs'* ]]
[[ $output != *'LUKS'* ]]
}
# =============================================================================
# _doctor_check_password_store
# =============================================================================
@test "_doctor_check_password_store: output contains 'Password store:' with a valid backend" {
# setup() already stubs _detect_password_store to return 'basic'.
run _doctor_check_password_store
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $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,46 @@ 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
# fail — always exits 1 with no output (no keyring accessible)
_stub_dbus_send() {
mkdir -p "$TEST_TMP/bin"
case "${1:-fail}" in
kwallet6)
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
#!/usr/bin/env bash
echo 'boolean true'
STUB
;;
secrets-ok)
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
#!/usr/bin/env bash
[[ "$*" == *kwalletd6* ]] && exit 1
exit 0
STUB
;;
*)
printf '#!/usr/bin/env bash\nexit 1\n' \
> "$TEST_TMP/bin/dbus-send"
;;
esac
chmod +x "$TEST_TMP/bin/dbus-send"
export PATH="$TEST_TMP/bin:$PATH"
}
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
@@ -35,13 +75,25 @@ setup() {
unset CLAUDE_USE_WAYLAND
unset NIRI_SOCKET
unset XDG_CURRENT_DESKTOP
unset XDG_SESSION_TYPE
unset CLAUDE_MENU_BAR
unset CLAUDE_TITLEBAR_STYLE
unset COWORK_VM_BACKEND
unset ELECTRON_USE_SYSTEM_TITLE_BAR
unset GTK_IM_MODULE
unset XMODIFIERS
unset QT_IM_MODULE
unset CLAUDE_GTK_IM_MODULE
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() {
@@ -86,6 +138,70 @@ teardown() {
[[ "${lines[1]}" == "test message two" ]]
}
# =============================================================================
# log_session_env
# =============================================================================
@test "log_session_env: emits env={ ... } block with all required keys" {
setup_logging
XDG_SESSION_TYPE='wayland'
WAYLAND_DISPLAY='wayland-0'
DISPLAY=':0'
XDG_CURRENT_DESKTOP='KDE'
GTK_IM_MODULE='ibus'
XMODIFIERS='@im=ibus'
QT_IM_MODULE='ibus'
CLAUDE_USE_WAYLAND='1'
CLAUDE_TITLEBAR_STYLE='hybrid'
CLAUDE_PASSWORD_STORE='basic'
CLAUDE_GTK_IM_MODULE='xim'
CLAUDE_DISABLE_GPU='1'
log_session_env
run cat "$log_file"
# Exact-line match locks block structure (open/close braces on
# their own lines) and per-key formatting in one pass.
[[ "${lines[0]}" == 'env={' ]]
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=wayland' ]]
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=wayland-0' ]]
[[ "${lines[3]}" == ' DISPLAY=:0' ]]
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=KDE' ]]
[[ "${lines[5]}" == ' GTK_IM_MODULE=ibus' ]]
[[ "${lines[6]}" == ' XMODIFIERS=@im=ibus' ]]
[[ "${lines[7]}" == ' QT_IM_MODULE=ibus' ]]
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=1' ]]
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=hybrid' ]]
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=basic' ]]
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=xim' ]]
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=1' ]]
[[ "${lines[13]}" == '}' ]]
}
@test "log_session_env: unset/empty values render as 'KEY=' (no value)" {
setup_logging
# All vars unset by setup() except this one, which exercises the
# empty-string branch (must be indistinguishable from unset).
GTK_IM_MODULE=''
unset CLAUDE_PASSWORD_STORE
log_session_env
run cat "$log_file"
# Exact-line match proves the line ends right after '=' — a
# substring like *'KEY='* would also match 'KEY=value'.
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=' ]]
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=' ]]
[[ "${lines[3]}" == ' DISPLAY=' ]]
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=' ]]
[[ "${lines[5]}" == ' GTK_IM_MODULE=' ]]
[[ "${lines[6]}" == ' XMODIFIERS=' ]]
[[ "${lines[7]}" == ' QT_IM_MODULE=' ]]
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=' ]]
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=' ]]
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=' ]]
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=' ]]
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=' ]]
}
# =============================================================================
# check_display
# =============================================================================
@@ -183,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
@@ -201,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
@@ -230,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
@@ -240,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
@@ -293,6 +517,48 @@ teardown() {
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set propagates to GTK_IM_MODULE" {
setup_logging
GTK_IM_MODULE='ibus'
CLAUDE_GTK_IM_MODULE='xim'
setup_electron_env
[[ $GTK_IM_MODULE == 'xim' ]]
# Override is logged so users can verify it took effect
run cat "$log_file"
[[ $output == *'GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set logs <unset> when GTK_IM_MODULE was unset" {
setup_logging
# GTK_IM_MODULE unset by setup()
CLAUDE_GTK_IM_MODULE='xim'
setup_electron_env
[[ $GTK_IM_MODULE == 'xim' ]]
run cat "$log_file"
[[ $output == *'GTK_IM_MODULE override: <unset> -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE unset leaves GTK_IM_MODULE alone" {
setup_logging
GTK_IM_MODULE='ibus'
# CLAUDE_GTK_IM_MODULE unset by setup()
setup_electron_env
[[ $GTK_IM_MODULE == 'ibus' ]]
# No override line should appear in the log
run cat "$log_file"
[[ $output != *'GTK_IM_MODULE override'* ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE empty leaves GTK_IM_MODULE alone" {
setup_logging
GTK_IM_MODULE='ibus'
CLAUDE_GTK_IM_MODULE=''
setup_electron_env
[[ $GTK_IM_MODULE == 'ibus' ]]
run cat "$log_file"
[[ $output != *'GTK_IM_MODULE override'* ]]
}
# =============================================================================
# _resolve_titlebar_style
# =============================================================================
@@ -465,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
# =============================================================================
@@ -587,3 +1066,40 @@ s.close()
result=$(_electron_version "$TEST_TMP/electron/electron") || true
[[ -z $result ]]
}
# =============================================================================
# _detect_password_store
# =============================================================================
@test "_detect_password_store: CLAUDE_PASSWORD_STORE env var wins without calling dbus-send" {
CLAUDE_PASSWORD_STORE='mystore'
# Stub dbus-send to fail — the early-return path must not reach it.
_stub_dbus_send fail
run _detect_password_store
[[ $status -eq 0 ]]
[[ $output == 'mystore' ]]
}
@test "_detect_password_store: falls back to kwallet6 when kwallet6 dbus-send call succeeds" {
unset CLAUDE_PASSWORD_STORE
_stub_dbus_send kwallet6
run _detect_password_store
[[ $status -eq 0 ]]
[[ $output == 'kwallet6' ]]
}
@test "_detect_password_store: falls back to gnome-libsecret when kwallet6 fails but secrets ping succeeds" {
unset CLAUDE_PASSWORD_STORE
_stub_dbus_send secrets-ok
run _detect_password_store
[[ $status -eq 0 ]]
[[ $output == 'gnome-libsecret' ]]
}
@test "_detect_password_store: falls back to basic when both dbus-send calls fail" {
unset CLAUDE_PASSWORD_STORE
_stub_dbus_send fail
run _detect_password_store
[[ $status -eq 0 ]]
[[ $output == 'basic' ]]
}

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env bats
#
# launcher-disable-gpu.bats
# Tests for the CLAUDE_DISABLE_GPU env var handling in
# build_electron_args (scripts/launcher-common.sh). The var is an
# opt-in workaround for the Chromium GPU process FATAL exhaustion
# tracked in #583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
# --disable-software-rasterizer; co-occurrence with XRDP must not
# stack duplicate flags.
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
LAUNCHER_COMMON="${SCRIPT_DIR}/../scripts/launcher-common.sh"
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
# loginctl shim — same pattern as launcher-xrdp-detection.bats.
# Defaults to a non-XRDP session so CLAUDE_DISABLE_GPU is the
# only signal in play unless a test overrides MOCK_LOGINCTL_TYPE.
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/loginctl" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "${MOCK_LOGINCTL_TYPE:-x11}"
SHIM
chmod +x "$TEST_TMP/bin/loginctl"
export PATH="$TEST_TMP/bin:$PATH"
log_file="$TEST_TMP/launcher.log"
: > "$log_file"
unset CLAUDE_DISABLE_GPU
unset XRDP_SESSION
unset XDG_SESSION_ID
unset MOCK_LOGINCTL_TYPE
# shellcheck disable=SC1090
source "$LAUNCHER_COMMON"
is_wayland=false
use_x11_on_wayland=true
}
teardown() {
if [[ -n ${TEST_TMP:-} && -d $TEST_TMP ]]; then
rm -rf "$TEST_TMP"
fi
}
args_contain() {
local needle="$1"
local arg
for arg in "${electron_args[@]}"; do
[[ $arg == "$needle" ]] && return 0
done
return 1
}
args_count() {
local needle="$1"
local arg count=0
for arg in "${electron_args[@]}"; do
[[ $arg == "$needle" ]] && ((count++))
done
printf '%d' "$count"
}
# =============================================================================
# CLAUDE_DISABLE_GPU=1 — flags must be added
# =============================================================================
@test "disable-gpu: CLAUDE_DISABLE_GPU=1 adds flags + logs message" {
export CLAUDE_DISABLE_GPU=1
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
}
# =============================================================================
# Co-occurrence with XRDP — no duplicate flags
# =============================================================================
@test "disable-gpu: with XRDP_SESSION, flags added exactly once (no dup)" {
export CLAUDE_DISABLE_GPU=1
export XRDP_SESSION=1
export XDG_SESSION_ID=5
export MOCK_LOGINCTL_TYPE=xrdp
build_electron_args deb
[[ "$(args_count '--disable-gpu')" -eq 1 ]]
[[ "$(args_count '--disable-software-rasterizer')" -eq 1 ]]
# Both signals should still log (independent diagnostic value),
# but only one set of flags should reach electron_args.
grep -q 'XRDP session detected' "$log_file"
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
}
# =============================================================================
# Off-states — flags must NOT be added
# =============================================================================
@test "disable-gpu: unset — flags NOT added" {
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
run args_contain '--disable-software-rasterizer'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: empty string — flags NOT added" {
export CLAUDE_DISABLE_GPU=''
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: =0 — flags NOT added (only literal '1' opts in)" {
export CLAUDE_DISABLE_GPU=0
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: =true — flags NOT added (no boolean aliases)" {
# Documents the strict equality check. If we ever add aliases,
# update this test to match. Strict-only matches the existing
# CLAUDE_USE_WAYLAND pattern.
export CLAUDE_DISABLE_GPU=true
build_electron_args deb
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 ]]
}

35
tests/test-artifact-appimage.sh Normal file → Executable file
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)
@@ -94,7 +104,30 @@ assert_contains "$appdir/AppRun" 'build_electron_args' \
# --- App contents (asar) ---
resources_dir="$appdir/usr/lib/node_modules/electron/dist/resources"
validate_app_contents "$resources_dir"
validate_app_contents "$resources_dir" "${component_id}.desktop"
# --- Doctor smoke test ---
# Some --doctor checks fail in CI (no display, etc.); we only care that
# the script itself didn't crash via signal or exec failure (>=127).
doctor_exit=0
"$appimage_file" --doctor >/dev/null 2>&1 || doctor_exit=$?
if [[ $doctor_exit -lt 127 ]]; then
pass "--doctor runs without crashing (exit: $doctor_exit)"
else
fail "--doctor crashed (exit: $doctor_exit)"
fi
# --- Headless launch smoke test ---
# 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

@@ -38,6 +38,14 @@ assert_executable() {
fi
}
assert_setuid() {
if [[ -u $1 ]]; then
pass "Setuid bit set: $1"
else
fail "Setuid bit not set: $1"
fi
}
assert_contains() {
local file="$1" pattern="$2" desc="${3:-}"
if grep -q "$pattern" "$file" 2>/dev/null; then
@@ -59,8 +67,10 @@ assert_command_succeeds() {
# Validate app contents inside an Electron resources directory.
# $1 = path to the resources/ dir containing app.asar
# $2 = expected desktopName in app/package.json
validate_app_contents() {
local resources_dir="$1"
local expected_desktop_name="${2:-claude-desktop.desktop}"
assert_file_exists "$resources_dir/app.asar"
assert_dir_exists "$resources_dir/app.asar.unpacked"
@@ -95,6 +105,11 @@ validate_app_contents() {
'frame-fix-entry.js' \
"package.json main field references frame-fix-entry.js"
# package.json desktopName matches the installed desktop file
assert_contains "$extract_dir/app/package.json" \
"\"desktopName\": \"$expected_desktop_name\"" \
"package.json desktopName matches $expected_desktop_name"
# .vite/build/index.js exists (main process code)
assert_file_exists "$extract_dir/app/.vite/build/index.js"
@@ -126,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 '================================'

Some files were not shown because too many files have changed in this diff Show More