Compare commits

..

75 Commits

Author SHA1 Message Date
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
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
109 changed files with 4700 additions and 15900 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

@@ -674,6 +674,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

@@ -44,7 +44,8 @@ jobs:
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
- name: Run artifact tests
run: |

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

11
.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/
@@ -33,7 +40,3 @@ 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

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

240
CHANGELOG.md Normal file
View File

@@ -0,0 +1,240 @@
# 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]
Tracks upstream Claude Desktop 1.8555.2.
<!-- Updated automatically by check-claude-version; will be current at release time. -->
### Added
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
### Fixed
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
### Changed
- 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.12+claude1.8555.2...HEAD
[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,11 @@ 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
- [`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 +50,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 +71,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 +167,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,14 @@ 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)
- **[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)
## 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

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

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

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

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

@@ -335,7 +335,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
@@ -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 |

471
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,471 @@
[< 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) |
| 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.
**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).
### 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.
### 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": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"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.8555.2";
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.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
hash = "sha256-GrV+iMhkUc8ZnRVo11Hat/4p5L36Wj8DX9sVuHLHo1I=";
};
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.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
hash = "sha256-PDGaWaWbML/rhvcbbfgIkcXJg0BPEuRk9L4XVM1NLJQ=";
};
};
@@ -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
@@ -245,6 +246,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

View File

@@ -0,0 +1,29 @@
# 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 of patch_cowork_linux() in
# scripts/patches/cowork.sh. Both verify-patches.sh and
# tests/verify-patches.bats consume this file, so adding a marker
# here adds it to the runtime check and the test matrix at the same
# time.
#
# 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 9 markers below correspond 1:1 with the smoke-test set defined
# in issue #559 (PR #555 retrofit, deliverable D6).
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
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]
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
#
@@ -71,12 +72,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 +437,147 @@ 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
((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.
# Never fails — basic is an intentional fallback, not an error.
_doctor_check_password_store() {
local store
store=$(_detect_password_store)
_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
}
# Run all diagnostic checks and print results
# Arguments: $1 = electron path (optional, for package-specific checks)
run_doctor() {
@@ -345,6 +585,11 @@ 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
@@ -381,6 +626,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
@@ -502,6 +750,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
@@ -589,10 +840,6 @@ print(len(servers))
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,7 +888,7 @@ print(len(servers))
' Common on Ubuntu 24.04+ where AppArmor sets' \
'apparmor_restrict_unprivileged_userns=1'
_info \
' by default. See docs/TROUBLESHOOTING.md' \
' by default. See docs/troubleshooting.md' \
'"Cowork on Ubuntu 24.04"'
_info ' for the AppArmor profile fix.'
fi
@@ -785,6 +1032,10 @@ 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
@@ -821,6 +1072,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,19 @@ 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.
// 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 +121,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;
@@ -313,6 +339,32 @@ 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(); });
}
// Directly set child view bounds to match content size.
@@ -654,6 +706,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 +793,23 @@ X-GNOME-Autostart-enabled=true
}
});
}
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

@@ -16,6 +16,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() {
@@ -67,6 +102,56 @@ _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'
}
# Build Electron arguments array based on display backend
# Requires: is_wayland, use_x11_on_wayland to be set
# (call detect_display_backend first)
@@ -96,6 +181,19 @@ build_electron_args() {
electron_args+=('--disable-features=CustomTitlebar')
fi
# Chromium's safeStorage API and cookie encryption both require a
# system keyring selected by --password-store. Without an explicit
# value, Electron may silently report encryption unavailable even
# when a keyring daemon is running, discarding OAuth tokens on exit
# and forcing re-authentication on every launch. We probe for the
# best available store at startup and pass it before the app path
# so Chromium treats it as a Chromium flag (args after the app
# path go to the renderer, not Chromium). Fixes: #593
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,10 +205,24 @@ 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 [[ ${CLAUDE_DISABLE_GPU:-} == '1' ]]; then
_disable_gpu=true
log_message 'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
fi
[[ $_disable_gpu == true ]] \
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
# X11 session - no special flags needed
if [[ $is_wayland != true ]]; then
@@ -282,6 +394,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

@@ -98,6 +98,7 @@ log_message '--- Claude Desktop AppImage Start ---'
log_message "Timestamp: $(date)"
log_message "Arguments: $@"
log_message "APPDIR: $appdir"
log_session_env
# Path to the bundled Electron executable and app
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"

View File

@@ -114,6 +114,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

View File

@@ -97,6 +97,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
@@ -228,18 +229,15 @@ install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/appli
# Install launcher script
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
# 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
%postun
# Update desktop database after removal
update-desktop-database /usr/share/applications &> /dev/null || true
@@ -257,14 +255,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

@@ -37,16 +37,21 @@ 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"
# Create stub native module
echo 'Creating stub native module...'

View File

@@ -109,6 +109,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 +132,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');
@@ -794,12 +807,27 @@ 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

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 first_const
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,8 +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})" \
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
"$index_js")
if [[ -z $tray_var ]]; then
echo 'Failed to extract tray variable name' >&2
@@ -31,11 +35,17 @@ patch_tray_menu_handler() {
fi
echo " Found tray variable: $tray_var"
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
"$index_js"
# 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 "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
"$index_js"
fi
first_const=$(grep -oP \
"async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \
"async function ${tray_func_re}\(\)\{.*?const \K\w+(?==)" \
"$index_js" | head -1)
if [[ -z $first_const ]]; then
echo 'Failed to extract first const in function' >&2
@@ -69,7 +79,7 @@ patch_tray_menu_handler() {
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
"$index_js"
sed -i -E \
"s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
"s/\((\w+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
"$index_js"
echo ' Added startup delay check (3 second window)'
fi
@@ -98,17 +108,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 tray_func tray_func_re local_tray_var tray_var_re
local menu_func 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})" \
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
"$index_js")
if [[ -z $local_tray_var ]]; then
echo ' Could not extract tray variable name — skipping'

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.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
claude_exe_sha256='1ab57e88c86451cf199d1568d751dab7fe29e4bdfa5a3f035fdb15b872c7a352'
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.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
claude_exe_sha256='3c319a59a59b30bfeb86f71b6df80891c5c983404f12e464f4be1754cd4d2c94'
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

445
tests/doctor.bats Normal file
View File

@@ -0,0 +1,445 @@
#!/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'* ]]
}

View File

@@ -18,6 +18,35 @@ has_electron_arg() {
return 1
}
# 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,10 +64,17 @@ 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'
# shellcheck source=scripts/launcher-common.sh
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
@@ -86,6 +122,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
# =============================================================================
@@ -293,6 +393,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
# =============================================================================
@@ -587,3 +729,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,144 @@
#!/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 ]]
}

87
tests/test-artifact-appimage.sh Normal file → Executable file
View File

@@ -94,7 +94,92 @@ 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 ---
# Catches startup-only regressions (asar/frame-fix-wrapper syntax errors)
# that pure structure checks miss.
#
# Scope: main-process startup failures only. GPU/renderer-process
# crashes (e.g. #583-class) leave the main process alive and pass
# this check — Xvfb has no GPU, so Electron falls back to SwiftShader
# and the GPU-crash path isn't exercised here.
if command -v xvfb-run &>/dev/null \
&& command -v dbus-run-session &>/dev/null \
&& command -v setsid &>/dev/null; then
# XDG_CACHE_HOME redirect so the test owns the launcher log.
cache_root=$(mktemp -d)
export XDG_CACHE_HOME="$cache_root"
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
# setsid puts xvfb-run + Xvfb + dbus + AppRun + electron in a fresh
# process group; xvfb-run's EXIT trap alone leaves Xvfb behind on
# TERM, so we need kill -- -PGID below.
# AppRun redirects electron's stdout/stderr into launcher_log;
# xvfb_log captures xvfb-run's own stderr.
xvfb_log=$(mktemp)
setsid xvfb-run -a -s '-screen 0 1280x720x24' \
dbus-run-session -- "$appimage_file" \
>"$xvfb_log" 2>&1 &
launch_pid=$!
# Safety net: covers Ctrl-C, CI timeout, or any earlier `exit` so we
# never leak Xvfb/electron between launch and the explicit kill below.
trap '
kill -KILL -- "-$launch_pid" 2>/dev/null
pkill -KILL -f "$appimage_file" 2>/dev/null
rm -rf "$cache_root" "$xvfb_log"
' EXIT INT TERM
# CI is slow; 10s is the floor for Electron startup.
sleep 10
if kill -0 "$launch_pid" 2>/dev/null; then
pass "AppImage stays alive under Xvfb for 10s"
else
wait "$launch_pid" 2>/dev/null
exit_code=$?
fail "AppImage exited within 10s (exit: $exit_code)"
if [[ -f $launcher_log ]]; then
echo '--- launcher.log (last 40 lines) ---' >&2
tail -40 "$launcher_log" >&2
echo '------------------------------------' >&2
fi
if [[ -s $xvfb_log ]]; then
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
tail -20 "$xvfb_log" >&2
echo '---------------------------------------' >&2
fi
fi
# Negative PID targets the process group.
kill -TERM -- "-$launch_pid" 2>/dev/null || true
sleep 1
kill -KILL -- "-$launch_pid" 2>/dev/null || true
wait "$launch_pid" 2>/dev/null || true
# Sweep any electron child that escaped the group (e.g. zygote).
pkill -KILL -f "$appimage_file" 2>/dev/null || true
rm -rf "$cache_root" "$xvfb_log"
unset XDG_CACHE_HOME
else
# Match the codebase convention (test-artifact-common.sh
# validate_app_contents): tool absence is a skip, not a failure.
# Loud failure on missing tools belongs at the workflow layer.
pass "Skipping launch smoke test (xvfb-run/dbus-run-session/setsid missing)"
fi
# --- 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"

View File

@@ -41,9 +41,14 @@ electron_path='/usr/lib/claude-desktop/node_modules/electron/dist/electron'
assert_file_exists "$electron_path"
assert_executable "$electron_path"
# chrome-sandbox
assert_file_exists \
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
# chrome-sandbox: setuid bit must be set by the rpm spec's %files
# %attr(4755, ...) entry, not by a %post chmod (#539). The check
# guards against any regression that strips the suid bit — including
# (but not limited to) reverting to a %post chmod, which silently
# no-ops if the scriptlet is skipped (--noscripts, layered images).
chrome_sandbox='/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
assert_file_exists "$chrome_sandbox"
assert_setuid "$chrome_sandbox"
# --- Desktop entry validation ---
desktop_file='/usr/share/applications/claude-desktop.desktop'

163
tests/verify-patches.bats Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bats
#
# verify-patches.bats
# Tests for scripts/verify-patches.sh — the post-build static grep
# that confirms patch markers (default: cowork, issue #559 D6 / PR
# #555) are present in the shipped index.js.
#
# Both these tests and the verify script consume the marker list from
# scripts/cowork-patch-markers.tsv, so adding a marker there
# automatically expands the test matrix below.
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
VERIFY_SH="$SCRIPT_DIR/../scripts/verify-patches.sh"
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
# Source the verify script in library mode and reuse its
# parser, so a TSV format change can't desync the two consumers.
# shellcheck source-path=SCRIPTDIR/.. source=scripts/verify-patches.sh
source "$VERIFY_SH"
load_markers
}
teardown() {
if [[ -n "${TEST_TMP:-}" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
# Build a fixture index.js containing every sample. If $1 is given,
# the marker with that name is omitted (used to drive the missing-
# marker negative tests).
write_fixture() {
local omit="${1:-}"
local fixture="$TEST_TMP/index.js"
: > "$fixture"
local i
for i in "${!marker_names[@]}"; do
if [[ ${marker_names[$i]} != "$omit" ]]; then
printf '%s\n' "${marker_samples[$i]}" >> "$fixture"
fi
done
printf '%s\n' "$fixture"
}
# =============================================================================
# Marker file integrity
# =============================================================================
@test "markers file: every regex matches its sample" {
local i
for i in "${!marker_names[@]}"; do
run grep -qP -- "${marker_patterns[$i]}" \
<(printf '%s\n' "${marker_samples[$i]}")
[[ "$status" -eq 0 ]] || {
echo "regex did not match own sample: ${marker_names[$i]}"
echo "pattern: ${marker_patterns[$i]}"
echo "sample: ${marker_samples[$i]}"
return 1
}
done
}
@test "markers file: at least 9 markers loaded" {
[[ "${#marker_names[@]}" -ge 9 ]] || {
echo "expected >= 9 markers, got ${#marker_names[@]}"
return 1
}
}
# =============================================================================
# Positive path: full fixture passes
# =============================================================================
@test "verify: exits 0 when every marker present" {
local fixture
fixture="$(write_fixture)"
run "$VERIFY_SH" "$fixture"
[[ "$status" -eq 0 ]] || {
echo 'verify rejected a fully-marked fixture'
echo "$output"
return 1
}
run grep -c 'OK ' <<< "$output"
[[ "$output" -eq "${#marker_names[@]}" ]] || {
echo "expected ${#marker_names[@]} OK lines, got: $output"
return 1
}
}
# =============================================================================
# Negative path: per-marker missing fixture
# =============================================================================
@test "verify: exits 2 and names the missing marker (each)" {
local name fixture failures=0
for name in "${marker_names[@]}"; do
fixture="$(write_fixture "$name")"
run "$VERIFY_SH" "$fixture"
if [[ "$status" -ne 2 ]]; then
echo "missing $name should exit 2, got $status"
echo "$output"
failures=$((failures + 1))
fi
if ! grep -q "$name" <<< "$output"; then
echo "missing $name not named in output"
echo "$output"
failures=$((failures + 1))
fi
done
[[ "$failures" -eq 0 ]]
}
# =============================================================================
# Input shapes
# =============================================================================
@test "verify: accepts a directory containing the asar layout" {
local layout="$TEST_TMP/staging/app.asar.contents/.vite/build"
mkdir -p "$layout"
: > "$layout/index.js"
local sample
for sample in "${marker_samples[@]}"; do
printf '%s\n' "$sample" >> "$layout/index.js"
done
run "$VERIFY_SH" "$TEST_TMP/staging"
[[ "$status" -eq 0 ]] || {
echo 'verify rejected directory-shaped input'
echo "$output"
return 1
}
}
@test "verify: rejects missing path with exit 1" {
run "$VERIFY_SH" "$TEST_TMP/does-not-exist.js"
[[ "$status" -eq 1 ]]
[[ "$output" == *'not found'* ]]
}
@test "verify: rejects directory without expected layout" {
mkdir -p "$TEST_TMP/empty"
run "$VERIFY_SH" "$TEST_TMP/empty"
[[ "$status" -eq 1 ]]
}
@test "verify: prints usage on no args and exits 1" {
run "$VERIFY_SH"
[[ "$status" -eq 1 ]]
[[ "$output" == *'Usage:'* ]]
}
@test "verify: --help prints usage and exits 0" {
run "$VERIFY_SH" --help
[[ "$status" -eq 0 ]]
[[ "$output" == *'Usage:'* ]]
}

View File

@@ -8,10 +8,7 @@ architecture, decisions, and rationale.
## Status
Seventy-four specs wired (36 cross-env T-tests, 33 env-specific S-tests,
5 H-prefix harness self-tests). See
[`docs/testing/runner-implementation-plan.md`](../../docs/testing/runner-implementation-plan.md)
for the tiered triage of remaining tests and the per-spec rationale
behind tier classification.
5 H-prefix harness self-tests).
| Test | What it checks | Layer |
|------|----------------|-------|
@@ -193,15 +190,12 @@ demonstrates the schema-rev path: when invocation rejects with
the verbatim rejection string is the cheapest grep target back to
the inline hand-rolled validator block (bundle bytes 5013601 /
5018821 for the two CustomPlugins methods). See `lib/eipc.ts` for
both surfaces, and
[`runner-implementation-plan.md`](../../docs/testing/runner-implementation-plan.md)
session 7 / 8 / 9 / 10 status sections for the findings.
both surfaces.
Per-row pass/skip counts depend on which sweep runs against the row;
see `runner-implementation-plan.md` for tier classification and
matrix-regen for the most-recent per-row outcomes. The Quick Entry
runners (S29-S35) all share the same primitive set (`installInterceptor()`
+ `openAndWaitReady()` + scenario-specific state setup).
Per-row pass/skip counts depend on which sweep runs against the row.
The Quick Entry runners (S29-S35) all share the same primitive set
(`installInterceptor()` + `openAndWaitReady()` + scenario-specific
state setup).
## Prerequisites
@@ -441,7 +435,7 @@ is a snapshot of what's currently on screen.
- **T04** uses `xprop` (no `xdotool` dependency — walks `_NET_CLIENT_LIST` + `_NET_WM_PID`). Works on X11 native and KDE Wayland (XWayland), **not** on native-Wayland sessions where the app is running through Ozone-Wayland directly. Per Decision 6, project default is X11; native-Wayland window-state queries are deferred until those tests get added.
- **T17** is shallow — it intercepts `dialog.showOpenDialog` at the Electron main process level. The integration question "does Claude make the right *portal* call?" is a v2 concern; portal-level mocking via `dbus-next` is sketched in [`docs/testing/automation.md`](../../docs/testing/automation.md) but requires displacing the running portal service or running under `dbus-run-session`.
- **`render-matrix.sh`** isn't here yet. `sweep.sh` prints a summary; the `matrix.md` regen step from JUnit is the next addition.
- **No CI wrapper.** Decision 4: the harness is invokable from CI but sweeps run from the dev box for the first ~20 tests.
- **No CI wrapper.** Decision 4: the harness is invocable from CI but sweeps run from the dev box for the first ~20 tests.
## Adding a test

View File

@@ -1,280 +0,0 @@
// Derives the stable-UI vocabulary corpus from an existing inventory.
// Output is committed at docs/testing/ui-vocabulary.json and consumed
// by the v7 walker (Phase 2) when classifying captured accessible-
// names. Re-run on each major upstream release.
//
// Rules (adapted from the v7 plan to the v6-collapsed inventory shape):
// - Persistent entries collapse to one inventory entry with a
// `surfaces[]` array recording every surface the element was
// observed on. Any persistent label whose surfaces[] has length
// >= 2 is stable by definition.
// - Structural / menu entries: stable if the label is shared by 3+
// entries OR appears on 2+ distinct surfaces. Either signal is
// enough — the plan's strict 3-and-2 conjunction over-rejects
// against a v6-collapsed inventory where most chrome already
// deduped to one entry.
// - Names matching any INSTANCE_SHAPES regex go to instanceShapes
// and are excluded from stable / suspect even if they would have
// qualified — the instance-shape pattern is the canonical
// representation for those at resolve time.
// - kind: instance entries are excluded from the stable corpus
// entirely — those labels by definition vary per session. (A
// label that appears in BOTH instance and structural entries
// follows the structural / menu rule.)
// - Everything else falls through to `suspect`, queued for human
// reconciliation.
import {
existsSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { INSTANCE_SHAPES } from '../src/lib/name-classifier.js';
import type { Inventory, InventoryEntry } from './walker.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const TESTING_DIR = resolve(HERE, '..', '..', '..', 'docs', 'testing');
const DEFAULT_INVENTORY = resolve(TESTING_DIR, 'ui-inventory.json');
const DEFAULT_OUTPUT = resolve(TESTING_DIR, 'ui-vocabulary.json');
interface CliOpts {
inventory: string;
output: string;
help: boolean;
}
interface InstanceShapeOutput {
id: string;
regex: string;
flags: string;
pattern: string | null;
matchedNames: string[];
}
interface VocabularyOutput {
derivedAt: string;
sourceInventory: {
capturedAt: string;
appVersion: string;
walkerVersion: string;
totalElements: number;
};
stable: string[];
instanceShapes: InstanceShapeOutput[];
suspect: string[];
}
function parseCli(argv: string[]): CliOpts {
const opts: CliOpts = {
inventory: DEFAULT_INVENTORY,
output: DEFAULT_OUTPUT,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
switch (a) {
case '-h':
case '--help':
opts.help = true;
break;
case '--inventory': {
const v = argv[++i];
if (!v) {
process.stderr.write('--inventory requires a path\n');
process.exit(1);
}
opts.inventory = resolve(v);
break;
}
case '--output': {
const v = argv[++i];
if (!v) {
process.stderr.write('--output requires a path\n');
process.exit(1);
}
opts.output = resolve(v);
break;
}
default:
process.stderr.write(
`derive-vocabulary: unknown argument: ${a}\n`,
);
printUsage();
process.exit(1);
}
}
return opts;
}
function printUsage(): void {
process.stdout.write(
'Usage: tsx explore/derive-vocabulary.ts [options]\n' +
'\n' +
'Derives docs/testing/ui-vocabulary.json from an existing\n' +
'inventory walk. Output records the stable-UI corpus, the\n' +
'instance-shape registry hits, and any names flagged for\n' +
'human triage.\n' +
'\n' +
'Options:\n' +
' --inventory <path> Override default inventory path\n' +
' (default: docs/testing/ui-inventory.json)\n' +
' --output <path> Override default vocabulary output path\n' +
' (default: docs/testing/ui-vocabulary.json)\n' +
' -h, --help Print this help and exit\n',
);
}
function loadInventory(path: string): Inventory {
if (!existsSync(path)) {
process.stderr.write(
`derive-vocabulary: inventory not found: ${path}\n`,
);
process.exit(1);
}
try {
return JSON.parse(readFileSync(path, 'utf8')) as Inventory;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`derive-vocabulary: failed to parse inventory: ${msg}\n`,
);
process.exit(1);
}
}
interface LabelStats {
kinds: Set<InventoryEntry['kind']>;
surfaces: Set<string>;
entryCount: number;
maxPersistentSpan: number;
}
function aggregate(inv: Inventory): Map<string, LabelStats> {
const stats = new Map<string, LabelStats>();
for (const e of inv.entries) {
const lbl = e.label;
if (!lbl) continue;
let s = stats.get(lbl);
if (!s) {
s = {
kinds: new Set(),
surfaces: new Set(),
entryCount: 0,
maxPersistentSpan: 0,
};
stats.set(lbl, s);
}
s.kinds.add(e.kind);
s.surfaces.add(e.surface);
s.entryCount += 1;
if (e.kind === 'persistent' && e.surfaces) {
s.maxPersistentSpan = Math.max(
s.maxPersistentSpan,
e.surfaces.length,
);
}
}
return stats;
}
function classify(inv: Inventory): VocabularyOutput {
const stats = aggregate(inv);
const stable = new Set<string>();
const suspect = new Set<string>();
const instanceHits = new Map<string, Set<string>>();
for (const shape of INSTANCE_SHAPES) {
instanceHits.set(shape.id, new Set());
}
for (const [lbl, s] of stats) {
// Pure-instance label — exclude entirely.
if (s.kinds.size === 1 && s.kinds.has('instance')) {
continue;
}
// Instance-shape regex match — record + skip stable/suspect.
let shapeMatched = false;
for (const shape of INSTANCE_SHAPES) {
if (shape.regex.test(lbl)) {
instanceHits.get(shape.id)!.add(lbl);
shapeMatched = true;
break;
}
}
if (shapeMatched) continue;
// Persistent: surfaces[] >= 2 carries the proof that the chrome
// element actually spans surfaces.
if (s.maxPersistentSpan >= 2) {
stable.add(lbl);
continue;
}
// Structural / menu: 3+ entries OR 2+ distinct surfaces.
if (s.entryCount >= 3 || s.surfaces.size >= 2) {
stable.add(lbl);
continue;
}
suspect.add(lbl);
}
const instanceShapesOut: InstanceShapeOutput[] = INSTANCE_SHAPES.map(
(shape) => ({
id: shape.id,
regex: shape.regex.source,
flags: shape.regex.flags,
pattern: shape.pattern,
matchedNames: [...instanceHits.get(shape.id)!].sort(),
}),
);
return {
derivedAt: new Date().toISOString(),
sourceInventory: {
capturedAt: inv.capturedAt,
appVersion: inv.appVersion,
walkerVersion: inv.walkerVersion,
totalElements: inv.totalElements,
},
stable: [...stable].sort(),
instanceShapes: instanceShapesOut,
suspect: [...suspect].sort(),
};
}
function atomicWrite(path: string, body: string): void {
const tmp = `${path}.tmp`;
writeFileSync(tmp, body, 'utf8');
renameSync(tmp, path);
}
function main(): void {
const opts = parseCli(process.argv.slice(2));
if (opts.help) {
printUsage();
return;
}
const inv = loadInventory(opts.inventory);
const out = classify(inv);
const body = `${JSON.stringify(out, null, 2)}\n`;
atomicWrite(opts.output, body);
const shapeHitTotal = out.instanceShapes.reduce(
(n, s) => n + s.matchedNames.length,
0,
);
process.stdout.write(
`derive-vocabulary: wrote ${opts.output}\n` +
` source: ${opts.inventory} (${inv.totalElements} entries)\n` +
` stable: ${out.stable.length}, ` +
`instance-shaped: ${shapeHitTotal} (${out.instanceShapes.filter((s) => s.matchedNames.length > 0).length} shapes hit), ` +
`suspect: ${out.suspect.length}\n`,
);
}
main();

View File

@@ -1,313 +0,0 @@
// Snapshot comparator.
//
// Diff semantics, in priority order:
// - removed: an element keyed in A is absent from B → drift signal.
// - changed: same key, different visible text or aria-label → drift.
// - added: new key in B → informational only (UI gained surface).
//
// Keys are stable identity tokens chosen per element class:
// - df-pill: aria-label (Chat / Cowork / Code)
// - compactPill: inner text (env value, "Select folder…", …)
// - ariaButton: aria-label (sidebar "more" buttons share labels;
// we de-dup by counting; see compareCounts below)
// - modal: headingText ?? aria-label ?? aria-labelledby
// - openMenu: items diffed by `${role}::${text}`
//
// Pure module — no I/O, no process.exit. The dispatcher reads files
// and prints; this file just produces a Diff value.
import type {
AriaButton,
CompactPillSnap,
DfPill,
MenuItem,
ModalSnap,
OpenMenu,
Snapshot,
} from './snapshot.js';
export interface DiffEntry {
kind: 'removed' | 'changed' | 'added';
category: string;
key: string;
before?: string;
after?: string;
}
export interface DiffResult {
a: { capturedAt: string; url: string; appVersion: string | null };
b: { capturedAt: string; url: string; appVersion: string | null };
entries: DiffEntry[];
summary: { removed: number; changed: number; added: number };
}
export function diff(a: Snapshot, b: Snapshot): DiffResult {
const entries: DiffEntry[] = [];
entries.push(...diffDfPills(a.dfPills, b.dfPills));
entries.push(...diffCompactPills(a.compactPills, b.compactPills));
entries.push(...diffAriaButtons(a.ariaLabeledButtons, b.ariaLabeledButtons));
entries.push(...diffModals(a.modals, b.modals));
entries.push(...diffOpenMenu(a.openMenu, b.openMenu));
const summary = entries.reduce(
(acc, e) => {
acc[e.kind] += 1;
return acc;
},
{ removed: 0, changed: 0, added: 0 },
);
return {
a: {
capturedAt: a.capturedAt,
url: a.claudeAiUrl,
appVersion: a.appVersion,
},
b: {
capturedAt: b.capturedAt,
url: b.claudeAiUrl,
appVersion: b.appVersion,
},
entries,
summary,
};
}
// Human-readable formatter. Removed/changed first (they're failures
// in spirit), added last (informational). Empty diff prints a single
// line so CI logs stay tidy.
export function formatDiff(d: DiffResult): string {
const lines: string[] = [];
lines.push(`A: ${d.a.capturedAt} (${d.a.url}) app=${d.a.appVersion}`);
lines.push(`B: ${d.b.capturedAt} (${d.b.url}) app=${d.b.appVersion}`);
lines.push('');
if (d.entries.length === 0) {
lines.push('No differences.');
return lines.join('\n');
}
const order: DiffEntry['kind'][] = ['removed', 'changed', 'added'];
for (const kind of order) {
const group = d.entries.filter((e) => e.kind === kind);
if (group.length === 0) continue;
lines.push(`# ${kind.toUpperCase()} (${group.length})`);
for (const e of group) {
if (e.kind === 'changed') {
lines.push(
` [${e.category}] ${e.key}: ${e.before ?? ''}${e.after ?? ''}`,
);
} else if (e.kind === 'removed') {
lines.push(` [${e.category}] ${e.key}: ${e.before ?? ''}`);
} else {
lines.push(` [${e.category}] ${e.key}: ${e.after ?? ''}`);
}
}
lines.push('');
}
lines.push(
`Summary: ${d.summary.removed} removed, ` +
`${d.summary.changed} changed, ${d.summary.added} added`,
);
return lines.join('\n');
}
function diffDfPills(a: DfPill[], b: DfPill[]): DiffEntry[] {
const aMap = byKey(a, (p) => p.ariaLabel ?? p.text);
const bMap = byKey(b, (p) => p.ariaLabel ?? p.text);
return compareMaps(aMap, bMap, 'dfPill', (p) => p.text);
}
function diffCompactPills(
a: CompactPillSnap[],
b: CompactPillSnap[],
): DiffEntry[] {
// Compact pills can repeat by text in pathological cases, so we
// disambiguate by appending an ordinal when needed. The ordinal is
// stable as long as DOM order is — same approach `findCompactPills`
// callers rely on.
const aMap = byKeyOrdinal(a, (p) => p.text);
const bMap = byKeyOrdinal(b, (p) => p.text);
return compareMaps(aMap, bMap, 'compactPill', (p) => `maxW=${p.maxW}`);
}
// Aria-labeled buttons frequently repeat (sidebar's ~80 conversation-row
// "more" buttons all share a label). We compare by *count per label*
// instead of per-instance: a delta in count surfaces as a single
// changed entry, which is far more readable than 80 added/removed
// rows. Per-label text is omitted since duplicate labels mean text is
// not a stable identity.
function diffAriaButtons(a: AriaButton[], b: AriaButton[]): DiffEntry[] {
return compareCounts(
countBy(a, (x) => x.ariaLabel),
countBy(b, (x) => x.ariaLabel),
'ariaButton',
);
}
function diffModals(a: ModalSnap[], b: ModalSnap[]): DiffEntry[] {
const key = (m: ModalSnap) =>
m.headingText ?? m.ariaLabel ?? m.ariaLabelledBy ?? '<unlabeled-modal>';
const aMap = byKeyOrdinal(a, key);
const bMap = byKeyOrdinal(b, key);
return compareMaps(aMap, bMap, 'modal', (m) =>
`buttons=${m.buttonLabels.join('|')}`,
);
}
// Menu diff is special: the "key" is the menu identity, but a menu
// diff is really an item-set diff. We compare item lists, scoped under
// the menu's labelledBy/ariaLabel for context.
function diffOpenMenu(
a: OpenMenu | null,
b: OpenMenu | null,
): DiffEntry[] {
if (!a && !b) return [];
const scope =
(a?.ariaLabel ?? b?.ariaLabel) ||
(a?.ariaLabelledBy ?? b?.ariaLabelledBy) ||
'<menu>';
if (a && !b) {
return [
{
kind: 'removed',
category: 'openMenu',
key: scope,
before: a.items.map(itemKey).join(' | '),
},
];
}
if (!a && b) {
return [
{
kind: 'added',
category: 'openMenu',
key: scope,
after: b.items.map(itemKey).join(' | '),
},
];
}
if (!a || !b) return [];
const aMap = byKeyOrdinal(a.items, itemKey);
const bMap = byKeyOrdinal(b.items, itemKey);
return compareMaps(
aMap,
bMap,
`openMenu[${scope}]`,
(it) =>
`disabled=${it.disabled}` +
(it.ariaChecked !== null ? ` checked=${it.ariaChecked}` : ''),
);
}
function itemKey(it: MenuItem): string {
return `${it.role}::${it.text}`;
}
function byKey<T>(arr: T[], k: (t: T) => string): Map<string, T> {
const m = new Map<string, T>();
for (const it of arr) m.set(k(it), it);
return m;
}
// When keys collide, append `#2`, `#3`, … so the comparator can still
// detect "we used to have 3, now we have 2" (one #N drops out as
// removed). Ordinals are local to this snapshot — they don't cross
// snapshot boundaries.
function byKeyOrdinal<T>(arr: T[], k: (t: T) => string): Map<string, T> {
const m = new Map<string, T>();
const counts = new Map<string, number>();
for (const it of arr) {
const base = k(it);
const n = (counts.get(base) ?? 0) + 1;
counts.set(base, n);
m.set(n === 1 ? base : `${base}#${n}`, it);
}
return m;
}
function countBy<T>(arr: T[], k: (t: T) => string): Map<string, number> {
const m = new Map<string, number>();
for (const it of arr) {
const key = k(it);
m.set(key, (m.get(key) ?? 0) + 1);
}
return m;
}
function compareMaps<T>(
a: Map<string, T>,
b: Map<string, T>,
category: string,
describe: (t: T) => string,
): DiffEntry[] {
const out: DiffEntry[] = [];
for (const [k, v] of a) {
const bv = b.get(k);
if (bv === undefined) {
out.push({
kind: 'removed',
category,
key: k,
before: describe(v),
});
continue;
}
const before = describe(v);
const after = describe(bv);
if (before !== after) {
out.push({
kind: 'changed',
category,
key: k,
before,
after,
});
}
}
for (const [k, v] of b) {
if (!a.has(k)) {
out.push({
kind: 'added',
category,
key: k,
after: describe(v),
});
}
}
return out;
}
function compareCounts(
a: Map<string, number>,
b: Map<string, number>,
category: string,
): DiffEntry[] {
const out: DiffEntry[] = [];
for (const [k, n] of a) {
const m = b.get(k);
if (m === undefined) {
out.push({
kind: 'removed',
category,
key: k,
before: `count=${n}`,
});
} else if (m !== n) {
out.push({
kind: 'changed',
category,
key: k,
before: `count=${n}`,
after: `count=${m}`,
});
}
}
for (const [k, m] of b) {
if (!a.has(k)) {
out.push({
kind: 'added',
category,
key: k,
after: `count=${m}`,
});
}
}
return out;
}

View File

@@ -1,640 +0,0 @@
// Entry point for the explore CLI.
//
// Subcommand surface (matches docs/testing/claudeai-ui-mapping-plan.md
// Phase 1):
//
// explore full snapshot to stdout
// explore pills df-pills + compact-pills + state
// explore menu currently-open menu structure
// explore snapshot <name> write to docs/testing/ui-snapshots/<name>.json
// explore diff <a> <b> diff two snapshots
// explore find <regex> search renderer for matching text/aria-label
//
// Why a hand-rolled dispatcher: the surface is six cases. A flag parser
// adds a dependency and obscures which command takes which positional.
// Keep the routing visible.
//
// Exit codes:
// 0 success (including a clean diff)
// 1 caller error (bad args, missing file)
// 2 runtime error (no debugger, no claude.ai webContents)
// 3 diff non-empty AND `--exit-on-diff` was set — opt-in, off by
// default so `explore diff` from a script can read entries
// without conflating "drift" with "tool blew up".
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { InspectorClient } from '../src/lib/inspector.js';
import { capture, capturePills, captureOpenMenu } from './snapshot.js';
import type { Snapshot } from './snapshot.js';
import { diff, formatDiff } from './diff.js';
import { findInRenderer, formatHits } from './find.js';
import {
collapsePersistentEntries,
walkRenderer,
WALKER_VERSION,
} from './walker.js';
import type { Inventory } from './walker.js';
const INSPECTOR_PORT = 9229;
// Resolve relative to this source file so the CLI works regardless of
// cwd (npm script vs. ad-hoc tsx invocation from elsewhere).
const TESTING_DIR = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'docs',
'testing',
);
const SNAPSHOT_DIR = resolve(TESTING_DIR, 'ui-snapshots');
const INVENTORY_PATH = resolve(TESTING_DIR, 'ui-inventory.json');
const INVENTORY_META_PATH = resolve(TESTING_DIR, 'ui-inventory.meta.json');
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const cmd = argv[0];
const rest = argv.slice(1);
try {
switch (cmd) {
case undefined:
await runFullSnapshot();
return;
case 'pills':
await runPills();
return;
case 'menu':
await runMenu();
return;
case 'snapshot':
await runSnapshot(rest);
return;
case 'diff':
await runDiff(rest);
return;
case 'find':
await runFind(rest);
return;
case 'walk':
await runWalk(rest);
return;
case 'collapse':
await runCollapse(rest);
return;
case '-h':
case '--help':
case 'help':
printUsage();
return;
default:
console.error(`unknown subcommand: ${cmd}`);
printUsage();
process.exit(1);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`explore: ${msg}`);
process.exit(2);
}
}
async function runFullSnapshot(): Promise<void> {
const client = await connect();
try {
const snap = await capture(client);
console.log(JSON.stringify(snap, null, 2));
} finally {
client.close();
}
}
async function runPills(): Promise<void> {
const client = await connect();
try {
const pills = await capturePills(client);
console.log(JSON.stringify(pills, null, 2));
} finally {
client.close();
}
}
async function runMenu(): Promise<void> {
const client = await connect();
try {
const menu = await captureOpenMenu(client);
if (!menu) {
console.log('null');
return;
}
console.log(JSON.stringify(menu, null, 2));
} finally {
client.close();
}
}
async function runSnapshot(args: string[]): Promise<void> {
const name = args[0];
if (!name) {
console.error('snapshot: missing <name> argument');
console.error('usage: explore snapshot <name>');
process.exit(1);
}
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
console.error(
`snapshot: name ${JSON.stringify(name)} contains characters ` +
`outside [a-zA-Z0-9._-] — choose a slug-safe name`,
);
process.exit(1);
}
const client = await connect();
let snap: Snapshot;
try {
snap = await capture(client);
} finally {
client.close();
}
if (!existsSync(SNAPSHOT_DIR)) {
mkdirSync(SNAPSHOT_DIR, { recursive: true });
}
const outPath = resolve(SNAPSHOT_DIR, `${name}.json`);
writeFileSync(outPath, JSON.stringify(snap, null, 2) + '\n', 'utf8');
console.log(`wrote ${outPath}`);
}
async function runDiff(args: string[]): Promise<void> {
const opts = { json: false, exitOnDiff: false };
const positional: string[] = [];
for (const a of args) {
if (a === '--json') opts.json = true;
else if (a === '--exit-on-diff') opts.exitOnDiff = true;
else positional.push(a);
}
if (positional.length !== 2) {
console.error('diff: expected exactly two snapshot names or paths');
console.error('usage: explore diff <a> <b> [--json] [--exit-on-diff]');
process.exit(1);
}
const a = readSnapshot(positional[0]!);
const b = readSnapshot(positional[1]!);
const result = diff(a, b);
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatDiff(result));
}
if (opts.exitOnDiff && result.entries.length > 0) {
process.exit(3);
}
}
// `walk` parses its own flags; --max-elements 0 prints usage and exits
// (a cheap dry-run for "is the CLI loadable" without touching CDP).
async function runWalk(args: string[]): Promise<void> {
const opts: {
maxElements: number;
maxDrillsPerSurface: number;
checkpointEvery: number;
allowlist: string | null;
output: string;
verbose: boolean;
help: boolean;
} = {
maxElements: 1000,
maxDrillsPerSurface: 50,
checkpointEvery: 100,
allowlist: null,
output: INVENTORY_PATH,
verbose: false,
help: false,
};
for (let i = 0; i < args.length; i += 1) {
const a = args[i]!;
if (a === '-h' || a === '--help') {
opts.help = true;
} else if (a === '--max-elements') {
const n = Number(args[i + 1]);
if (!Number.isFinite(n) || n < 0) {
console.error('walk: --max-elements requires a non-negative integer');
process.exit(1);
}
opts.maxElements = n;
i += 1;
} else if (a === '--checkpoint-every') {
const n = Number(args[i + 1]);
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
console.error(
'walk: --checkpoint-every requires a non-negative integer (0 disables)',
);
process.exit(1);
}
opts.checkpointEvery = n;
i += 1;
} else if (
a === '--max-drills-per-surface' ||
a === '--max-elements-per-surface'
) {
// v4 renamed the flag from --max-elements-per-surface (which
// truncated emissions) to --max-drills-per-surface (which only
// caps queue pushes; all entries are still emitted). Keep the
// old name as a deprecated alias.
if (a === '--max-elements-per-surface') {
process.stderr.write(
'walk: --max-elements-per-surface is deprecated; ' +
'use --max-drills-per-surface (semantics changed: now ' +
'caps drilling fan-out, not emission count)\n',
);
}
const n = Number(args[i + 1]);
if (!Number.isFinite(n) || n < 0) {
console.error(`walk: ${a} requires a non-negative integer`);
process.exit(1);
}
opts.maxDrillsPerSurface = n;
i += 1;
} else if (a === '--allowlist') {
const p = args[i + 1];
if (!p) {
console.error('walk: --allowlist requires a path');
process.exit(1);
}
opts.allowlist = p;
i += 1;
} else if (a === '--output') {
const p = args[i + 1];
if (!p) {
console.error('walk: --output requires a path');
process.exit(1);
}
opts.output = resolve(p);
i += 1;
} else if (a === '--verbose') {
opts.verbose = true;
} else {
console.error(`walk: unknown argument: ${a}`);
printWalkUsage();
process.exit(1);
}
}
if (opts.help || opts.maxElements === 0) {
printWalkUsage();
return;
}
let allowlist: string[] = [];
if (opts.allowlist) {
const raw = readFileSync(opts.allowlist, 'utf8');
try {
const parsed = JSON.parse(raw) as { exemptions?: string[] };
allowlist = parsed.exemptions ?? [];
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`walk: allowlist ${opts.allowlist}: invalid JSON — ${msg}`);
process.exit(1);
}
}
const outDir = dirname(opts.output);
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
const metaPath =
opts.output === INVENTORY_PATH
? INVENTORY_META_PATH
: opts.output.replace(/\.json$/, '.meta.json');
// Atomic writer: write to <path>.tmp, then rename. Survives a kill
// between writes — readers always see either the prior complete file
// or the new one, never a half-written buffer. Used for both the
// in-flight checkpoint writes and the final write. `partial` is
// recorded in meta.json (true on intermediate writes, false on the
// final write) so downstream readers can tell whether the inventory
// is complete; the inventory file itself stays shape-compatible.
const writeCheckpoint = (
inventory: Inventory,
isPartial: boolean,
): void => {
const invTmp = `${opts.output}${INVENTORY_TMP_SUFFIX}`;
writeFileSync(
invTmp,
JSON.stringify(inventory, null, 2) + '\n',
'utf8',
);
renameSync(invTmp, opts.output);
const meta = {
capturedAt: inventory.capturedAt,
appVersion: inventory.appVersion,
walkerVersion: WALKER_VERSION,
startUrl: inventory.startUrl,
totalElements: inventory.totalElements,
deniedActions: inventory.deniedActions,
partial: isPartial,
denylistDescription:
'Default destructive-action labels (see DEFAULT_DENYLIST in walker.ts) ' +
'plus optional allowlist exemptions.',
allowlistEntries: allowlist,
};
const metaTmp = `${metaPath}${INVENTORY_TMP_SUFFIX}`;
writeFileSync(metaTmp, JSON.stringify(meta, null, 2) + '\n', 'utf8');
renameSync(metaTmp, metaPath);
};
const client = await connect();
let inventory: Inventory;
try {
inventory = await walkRenderer(client, {
maxElements: opts.maxElements,
maxDrillsPerSurface: opts.maxDrillsPerSurface,
allowlist,
verbose: opts.verbose,
checkpointEvery: opts.checkpointEvery,
checkpointWriter:
opts.checkpointEvery > 0
? (inv) => writeCheckpoint(inv, true)
: undefined,
});
} finally {
client.close();
}
writeCheckpoint(inventory, false);
console.log(
`wrote ${opts.output} (${inventory.totalElements} entries, ` +
`${inventory.deniedActions} denylisted)`,
);
console.log(`wrote ${metaPath}`);
}
// Suffix used by the atomic-write helper. Kept module-level so any
// future readers know which dotfile to ignore in tooling/gitignore.
const INVENTORY_TMP_SUFFIX = '.tmp';
// `collapse [<path>]` re-runs the post-walk persistent-element
// collapse against an existing inventory file. Use case: a partial
// checkpoint (walker aborted mid-walk) skipped the in-loop collapse
// and so has 0 persistent entries — this command salvages it without
// re-running the walker. Also useful if collapse heuristics change
// and we want to refresh an existing inventory.
async function runCollapse(args: string[]): Promise<void> {
let path = INVENTORY_PATH;
let help = false;
for (let i = 0; i < args.length; i += 1) {
const a = args[i]!;
if (a === '-h' || a === '--help') help = true;
else if (!a.startsWith('-')) path = resolve(a);
else {
console.error(`collapse: unknown argument: ${a}`);
printCollapseUsage();
process.exit(1);
}
}
if (help) {
printCollapseUsage();
return;
}
if (!existsSync(path)) {
console.error(`collapse: inventory not found: ${path}`);
process.exit(1);
}
let inventory: Inventory;
try {
inventory = JSON.parse(readFileSync(path, 'utf8')) as Inventory;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`collapse: invalid JSON in ${path}${msg}`);
process.exit(1);
}
// v7-only gate. The v6 → v7 fingerprint cutover invalidated all
// older inventory shapes; re-running the persistent collapse on a
// v6 inventory would mint v7-key collisions against v6 selectors
// and drop unrelated entries. Re-walk first.
const wv = inventory.walkerVersion;
if (wv !== '7') {
console.error(
`collapse: walkerVersion ${wv} is not supported (need v7; ` +
`re-walk after the v6 → v7 fingerprint cutover)`,
);
process.exit(1);
}
const before = inventory.entries.length;
const result = collapsePersistentEntries(inventory.entries);
const after = result.entries.length;
const dropped = before - after;
const collapsedAt = new Date().toISOString();
const updated: Inventory = {
...inventory,
walkerVersion: WALKER_VERSION,
totalElements: after,
entries: result.entries,
capturedAt: inventory.capturedAt,
};
// Atomic write inventory + meta. Mirror the walk subcommand: write
// to .tmp, rename. Meta gets `partial: false` (collapse closes out
// a partial checkpoint) and `collapsedAt`; everything else carries
// through from the existing meta where present.
const invTmp = `${path}${INVENTORY_TMP_SUFFIX}`;
writeFileSync(invTmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
renameSync(invTmp, path);
const metaPath =
path === INVENTORY_PATH
? INVENTORY_META_PATH
: path.replace(/\.json$/, '.meta.json');
let existingMeta: Record<string, unknown> = {};
if (existsSync(metaPath)) {
try {
existingMeta = JSON.parse(readFileSync(metaPath, 'utf8')) as Record<
string,
unknown
>;
} catch {
// Carry the inventory through even if meta is malformed; meta
// is recoverable, the entries are not.
}
}
const meta = {
...existingMeta,
capturedAt: updated.capturedAt,
appVersion: updated.appVersion,
walkerVersion: WALKER_VERSION,
startUrl: updated.startUrl,
totalElements: updated.totalElements,
deniedActions: updated.deniedActions,
partial: false,
collapsedAt,
};
const metaTmp = `${metaPath}${INVENTORY_TMP_SUFFIX}`;
writeFileSync(metaTmp, JSON.stringify(meta, null, 2) + '\n', 'utf8');
renameSync(metaTmp, metaPath);
console.log(
`collapse: read ${before} entries → wrote ${after} entries ` +
`(${dropped} dropped via persistent collapse, ` +
`${result.persistentSurvivors} shells emitted)`,
);
console.log(`wrote ${path}`);
console.log(`wrote ${metaPath}`);
}
function printCollapseUsage(): void {
console.log(
[
'usage: explore collapse [<path>]',
'',
'Re-run the post-walk persistent-element collapse against an',
'existing inventory file. Useful for salvaging a partial',
'checkpoint that aborted before the in-loop collapse step.',
'',
' <path> inventory file to collapse in place (default:',
' docs/testing/ui-inventory.json). Must be v5+.',
' -h, --help print this help',
'',
'Writes the collapsed inventory and updated meta.json',
'atomically (.tmp + rename). Meta gains `collapsedAt` and',
'clears `partial` to false.',
].join('\n'),
);
}
function printWalkUsage(): void {
console.log(
[
'usage: explore walk [options]',
'',
'options:',
' --max-elements N safety cap on total entries',
' (default 1000; 0 prints this help',
' and exits)',
' --max-drills-per-surface N max number of children to drill into',
' from one surface (default 50). All',
' children are still emitted to the',
' inventory; this only bounds the BFS',
' queue fan-out per surface.',
' (Alias: --max-elements-per-surface,',
' deprecated — v3 truncated emissions,',
' v4 only caps drilling.)',
' --checkpoint-every N atomically write the inventory every N',
' newly-emitted entries (default 100;',
' 0 disables). Intermediate writes set',
' meta.json `partial: true`; the final',
' write clears it to false.',
' --allowlist PATH JSON file:',
' {"exemptions": ["entry.id", ...]} to',
' remove from the default denylist',
' --output PATH write inventory to PATH (default',
' docs/testing/ui-inventory.json)',
' --verbose log every click + surface to stderr',
' -h, --help print this help',
].join('\n'),
);
}
async function runFind(args: string[]): Promise<void> {
const opts = { json: false, limit: 100 };
const positional: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const a = args[i]!;
if (a === '--json') opts.json = true;
else if (a === '--limit') {
const n = Number(args[i + 1]);
if (!Number.isFinite(n) || n <= 0) {
console.error('find: --limit requires a positive integer');
process.exit(1);
}
opts.limit = n;
i += 1;
} else positional.push(a);
}
const pat = positional[0];
if (!pat) {
console.error('find: missing <regex> argument');
console.error('usage: explore find <regex> [--json] [--limit N]');
process.exit(1);
}
let re: RegExp;
try {
re = new RegExp(pat, 'i');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`find: invalid regex: ${msg}`);
process.exit(1);
}
const client = await connect();
try {
const hits = await findInRenderer(client, re, { limit: opts.limit });
if (opts.json) {
console.log(JSON.stringify(hits, null, 2));
} else {
console.log(formatHits(hits));
}
} finally {
client.close();
}
}
// Snapshot resolver: accept either a bare name (looked up in the
// snapshot dir, .json appended) or an explicit path. Bare names are
// the common case from CI / the README; explicit paths help when
// diffing a snapshot against an out-of-tree fixture.
function readSnapshot(nameOrPath: string): Snapshot {
const candidates = [
nameOrPath,
resolve(SNAPSHOT_DIR, nameOrPath),
resolve(SNAPSHOT_DIR, `${nameOrPath}.json`),
];
const found = candidates.find((p) => existsSync(p));
if (!found) {
console.error(`snapshot not found: tried ${candidates.join(', ')}`);
process.exit(1);
}
const raw = readFileSync(found, 'utf8');
try {
return JSON.parse(raw) as Snapshot;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`snapshot ${found}: invalid JSON — ${msg}`);
process.exit(1);
}
}
async function connect(): Promise<InspectorClient> {
try {
return await InspectorClient.connect(INSPECTOR_PORT);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`could not attach to debugger on :${INSPECTOR_PORT}${msg}. ` +
`Enable the main-process debugger via the in-app menu first.`,
);
}
}
function printUsage(): void {
console.log(
[
'usage:',
' explore full snapshot to stdout',
' explore pills df-pills + compact-pills + state',
' explore menu currently-open menu structure',
' explore snapshot <name> write snapshot to ui-snapshots/<name>.json',
' explore diff <a> <b> [--json] [--exit-on-diff]',
' compare two snapshots',
' explore find <regex> [--json] [--limit N]',
' search renderer text + aria-label',
' explore walk [options] BFS walker → docs/testing/ui-inventory.json',
' (see `explore walk --help` for options)',
' explore collapse [<path>] re-run persistent-element collapse against',
' an existing inventory (salvages partial',
' checkpoints; see `explore collapse --help`)',
].join('\n'),
);
}
main().catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(`explore: ${msg}`);
process.exit(2);
});

View File

@@ -1,86 +0,0 @@
// Renderer search by regex over text content + aria-label.
//
// Why text+aria together: a "Send" button might have aria-label="Send"
// but textContent="" (icon child); a heading might be the inverse.
// Searching both lets the human ask "where does the word X appear?"
// without first guessing which surface labels it.
//
// We restrict the candidate set to interactive + landmark elements
// (button, [role], a, h1-h6, [aria-label]) rather than walking the
// entire document — claude.ai's chat history dumps thousands of
// <span>/<p> nodes that swamp signal. If a future need wants the
// broader sweep, add a `--all` flag here rather than expanding the
// default.
import type { InspectorClient } from '../src/lib/inspector.js';
export interface FindHit {
tag: string;
role: string | null;
ariaLabel: string | null;
text: string;
matchedField: 'text' | 'ariaLabel' | 'both';
visible: boolean;
}
// Regex source + flags travel as JSON strings into the renderer eval —
// same encoding pattern as openPill / clickMenuItem in lib/claudeai.ts.
export async function findInRenderer(
client: InspectorClient,
pattern: RegExp,
opts: { limit?: number } = {},
): Promise<FindHit[]> {
const limit = opts.limit ?? 100;
const reSrc = JSON.stringify(pattern.source);
const reFlags = JSON.stringify(pattern.flags);
return await client.evalInRenderer<FindHit[]>(
'claude.ai',
`(() => {
const re = new RegExp(${reSrc}, ${reFlags});
const sel = 'button, a, h1, h2, h3, h4, h5, h6, ' +
'[role], [aria-label]';
const nodes = Array.from(document.querySelectorAll(sel));
const hits = [];
for (const el of nodes) {
const text = (el.textContent || '').trim().slice(0, 200);
const aria = el.getAttribute('aria-label');
const textHit = text.length > 0 && re.test(text);
const ariaHit = aria !== null && re.test(aria);
if (!textHit && !ariaHit) continue;
hits.push({
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role'),
ariaLabel: aria,
text,
matchedField: textHit && ariaHit
? 'both'
: (textHit ? 'text' : 'ariaLabel'),
visible: !!el.getClientRects().length,
});
if (hits.length >= ${limit}) break;
}
return hits;
})()`,
);
}
export function formatHits(hits: FindHit[]): string {
if (hits.length === 0) return 'No matches.';
const lines: string[] = [];
for (const h of hits) {
const vis = h.visible ? '' : ' [hidden]';
const role = h.role ? ` role=${h.role}` : '';
const aria = h.ariaLabel !== null ? ` aria-label=${q(h.ariaLabel)}` : '';
lines.push(
`${h.tag}${role}${aria} (${h.matchedField})${vis}` +
(h.text ? `\n text: ${h.text}` : ''),
);
}
lines.push('');
lines.push(`${hits.length} match(es).`);
return lines.join('\n');
}
function q(s: string): string {
return JSON.stringify(s);
}

View File

@@ -1,523 +0,0 @@
// Generate the U01 UI-visibility Playwright spec from the captured
// inventory at docs/testing/ui-inventory.json. Reads the inventory +
// its meta sidecar offline (no live app needed), groups entries by
// canonical surface, and emits a single .spec.ts file with one
// `test()` per inventory entry under one `test.describe()` per
// surface.
//
// The generated spec asserts each entry's recorded fingerprint still
// resolves to a visible element on the live signed-in renderer. It's
// the inventory's "do these things still render" sibling — H05
// detects shape drift across snapshots, U01 detects per-entry render
// failures across the whole inventory.
//
// Pure file in/out: no network, no inspector. The spec it emits is
// where the live app gets touched. Run via `npm run gen:render-specs`.
//
// Refuses to operate on a stale walker version or a partial inventory
// — generating a passing spec from a half-walked DOM would silently
// shrink the assertion surface to whatever the walker happened to
// reach before crashing.
import {
existsSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { WALKER_VERSION } from './walker.js';
import type { Inventory, InventoryEntry, NavStep } from './walker.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const TESTING_DIR = resolve(HERE, '..', '..', '..', 'docs', 'testing');
const DEFAULT_INVENTORY = resolve(TESTING_DIR, 'ui-inventory.json');
const DEFAULT_META = resolve(TESTING_DIR, 'ui-inventory.meta.json');
const DEFAULT_OUTPUT = resolve(
HERE,
'..',
'src',
'runners',
'U01_ui_visibility.spec.ts',
);
interface MetaSidecar {
walkerVersion: string;
partial: boolean;
capturedAt: string;
appVersion: string;
}
interface CliOpts {
inventory: string;
output: string;
help: boolean;
}
function parseCli(argv: string[]): CliOpts {
const opts: CliOpts = {
inventory: DEFAULT_INVENTORY,
output: DEFAULT_OUTPUT,
help: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
switch (a) {
case '-h':
case '--help':
opts.help = true;
break;
case '--inventory': {
const v = argv[++i];
if (!v) {
process.stderr.write('--inventory requires a path\n');
process.exit(1);
}
opts.inventory = resolve(v);
break;
}
case '--output': {
const v = argv[++i];
if (!v) {
process.stderr.write('--output requires a path\n');
process.exit(1);
}
opts.output = resolve(v);
break;
}
default:
process.stderr.write(`gen-render-specs: unknown argument: ${a}\n`);
printUsage();
process.exit(1);
}
}
return opts;
}
function printUsage(): void {
process.stdout.write(
'Usage: tsx explore/gen-render-specs.ts [options]\n' +
'\n' +
'Generates src/runners/U01_ui_visibility.spec.ts from\n' +
'docs/testing/ui-inventory.json. Refuses to run if the inventory\n' +
'is partial or was produced by a walker older than v' +
WALKER_VERSION +
'.\n' +
'\n' +
'Options:\n' +
' --inventory <path> Override default inventory path\n' +
' (default: docs/testing/ui-inventory.json)\n' +
' --output <path> Override default spec output path\n' +
' (default: src/runners/U01_ui_visibility.spec.ts)\n' +
' -h, --help Print this help and exit\n',
);
}
function loadInventory(path: string): Inventory {
if (!existsSync(path)) {
process.stderr.write(`gen-render-specs: inventory not found: ${path}\n`);
process.exit(1);
}
try {
return JSON.parse(readFileSync(path, 'utf8')) as Inventory;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gen-render-specs: failed to parse inventory: ${msg}\n`);
process.exit(1);
}
}
function loadMeta(invPath: string): MetaSidecar {
const metaPath = invPath.replace(/\.json$/, '.meta.json');
const fallbackPath =
invPath === DEFAULT_INVENTORY ? DEFAULT_META : metaPath;
const path = existsSync(metaPath) ? metaPath : fallbackPath;
if (!existsSync(path)) {
process.stderr.write(
`gen-render-specs: meta sidecar not found at ${metaPath} ` +
'(needed for partial/walkerVersion gating)\n',
);
process.exit(1);
}
try {
return JSON.parse(readFileSync(path, 'utf8')) as MetaSidecar;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gen-render-specs: failed to parse meta: ${msg}\n`);
process.exit(1);
}
}
// Refuse on stale walker versions or partial inventories. The point of
// this generator is to emit a spec that asserts the FULL inventory
// renders; gating on these two flags is what stops a half-walked
// checkpoint from quietly shrinking the assertion set.
function validate(inv: Inventory, meta: MetaSidecar): void {
const seen = Number.parseInt(inv.walkerVersion, 10);
const required = Number.parseInt(WALKER_VERSION, 10);
if (Number.isNaN(seen) || seen < required) {
process.stderr.write(
`gen-render-specs: walkerVersion ${inv.walkerVersion} < ${WALKER_VERSION}; ` +
'inventory shape may be incompatible. Re-walk with the current ' +
'explore CLI before regenerating the spec.\n',
);
process.exit(1);
}
if (meta.partial === true) {
process.stderr.write(
'gen-render-specs: inventory meta reports partial=true (walk did ' +
'not finish). Refusing to generate a spec from a half-walked DOM ' +
'— complete the walk first or pass --inventory to a known-good file.\n',
);
process.exit(1);
}
}
// Deterministic surface→entries grouping. Sort surfaces alphabetically
// and entries within each surface by id, so a re-run produces an
// identical spec file when the inventory hasn't changed (the file is
// checked in; no-op regeneration shouldn't mint diffs).
function groupBySurface(
entries: InventoryEntry[],
): { surface: string; entries: InventoryEntry[] }[] {
const buckets = new Map<string, InventoryEntry[]>();
for (const e of entries) {
const list = buckets.get(e.surface) ?? [];
list.push(e);
buckets.set(e.surface, list);
}
const surfaces = [...buckets.keys()].sort();
return surfaces.map((surface) => {
const list = buckets.get(surface)!.slice();
list.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return { surface, entries: list };
});
}
// Strip any navigationPath step that would CLICK the entry under
// test, when that entry is denylisted. Per the spec brief: never click
// denylisted controls, just assert they exist. In practice the
// recorded path's last click is the surface-opener (entry's own id is
// `surface.role.label`, distinct from any path step), so this filter
// usually no-ops — but it's the safety net the brief calls for.
function safeNavigationPath(entry: InventoryEntry): NavStep[] {
if (!entry.denylisted) return entry.navigationPath;
return entry.navigationPath.filter(
(s) => !(s.action === 'click' && s.id === entry.id),
);
}
// JS string literal for embedding in generated source. Use JSON.stringify
// — handles all the escapes (backslash, quotes, newlines, unicode) that
// hand-rolling would miss on entries with weird labels.
function js(value: unknown): string {
return JSON.stringify(value);
}
// Sanitize a surface name into a `test.describe()` block label that
// reads cleanly. Surfaces are dot-separated paths like
// `root.button.search.option.x`; the raw form is fine for grouping
// but we annotate the count so the report shows scope at a glance.
function describeLabel(surface: string, count: number): string {
return `surface: ${surface} (${count} ${count === 1 ? 'entry' : 'entries'})`;
}
function testTitle(entry: InventoryEntry): string {
const tags: string[] = [entry.kind];
if (entry.denylisted) tags.push('denylist');
const tagStr = tags.length ? ` [${tags.join(',')}]` : '';
return `${entry.id}${tagStr}${entry.role}: ${entry.label}`;
}
function generateSpec(
inv: Inventory,
meta: MetaSidecar,
groups: { surface: string; entries: InventoryEntry[] }[],
): string {
const out: string[] = [];
out.push(
'// AUTO-GENERATED FROM docs/testing/ui-inventory.json',
'// DO NOT EDIT — regenerate with `npm run gen:render-specs`',
`// Source inventory: walker v${inv.walkerVersion} (account-portable ariaPath ` +
`fingerprints), captured ${inv.capturedAt}, app ${inv.appVersion}`,
`// Entries: ${inv.totalElements} ` +
`(${inv.deniedActions} denylisted), ` +
`${groups.length} surfaces`,
`// Meta: partial=${meta.partial}`,
'',
"import { test, expect } from '@playwright/test';",
'',
"import { launchClaude } from '../lib/electron.js';",
"import type { ClaudeApp } from '../lib/electron.js';",
"import { createIsolation } from '../lib/isolation.js';",
"import { InspectorClient } from '../lib/inspector.js';",
"import { captureSessionEnv } from '../lib/diagnostics.js';",
'import {',
'\tcurrentUrl,',
'\tfindByFingerprint,',
'\tredrivePath,',
'\twaitForStable,',
"} from '../../explore/walker.js';",
"import type { InventoryEntry } from '../../explore/walker.js';",
'',
'// U01 — UI visibility sweep.',
'//',
'// One Playwright test per inventory entry. Each test re-drives the',
"// entry's recorded navigationPath against the live signed-in",
"// renderer, then asserts the entry's fingerprint resolves to a",
'// visible element. The full inventory acts as a render contract:',
'// any entry that no longer renders (selector drift, route change,',
'// permission change) shows up as exactly one failed test, with the',
'// triage payload (entry JSON + observed DOM neighbourhood)',
'// attached to that test only.',
'//',
'// Skip semantics mirror H05: the suite skips cleanly if the host',
"// isn't signed in (claude.ai webContents never reaches the",
"// userLoaded level). Default path: kill any running host Claude,",
"// copy the auth-relevant subset of ~/.config/Claude into a",
"// hermetic tmpdir, and launch against that copy. Host config is",
"// left untouched after the kill+seed. CLAUDE_TEST_USE_HOST_CONFIG=1",
"// opts out and shares the host's actual config directory (no",
"// kill+seed) — use only when you've manually closed the host first.",
'//',
"// Denylisted entries: we still assert they render, but the",
"// generator strips any navigationPath step that would CLICK the",
'// denylisted entry itself. Per the spec brief: never trigger',
'// destructive controls from a render check.',
'//',
'// Persistent entries: each persistent entry is asserted on its',
'// canonical surface only (the `surface` field). The cross-surface',
'// `surfaces[]` list is intentionally unused here — a strict',
'// "renders on every surface it was observed" mode is a future',
'// follow-up.',
'//',
'// Instance entries: assert that AT LEAST ONE element matching the',
"// fingerprint exists. We don't assert the recorded instanceCount",
'// — list lengths legitimately fluctuate across sessions.',
'',
"// Per-test budget covers a path redrive (~1 nav + ~N clicks * 1.5s)",
'// plus a fingerprint resolve. Generous to ride out a slow first',
'// route load; later tests in the same suite reuse the warmed app.',
'test.setTimeout(120_000);',
'',
'const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === \'1\';',
'',
"// Single shared launch + inspector across the whole suite. N",
'// tests at one launch each would burn 30+ minutes on cold-start',
'// alone. We pay for setup once, then each test re-drives from the',
'// recorded startUrl so prior-test side effects (open menus, route',
'// changes) get reset before the next assertion runs.',
'let app: ClaudeApp | null = null;',
'let sharedInspector: InspectorClient | null = null;',
'let sharedStartUrl: string | null = null;',
'let suiteSkipReason: string | null = null;',
'',
"test.describe('U01 — UI visibility sweep (auto-generated)', () => {",
'\ttest.beforeAll(async () => {',
'\t\t// Default path: kill any host Claude, copy auth-relevant',
"\t\t// subset of ~/.config/Claude into a hermetic tmpdir, launch",
"\t\t// against that copy. Host config is left untouched after the",
"\t\t// kill+seed. CLAUDE_TEST_USE_HOST_CONFIG=1 opts out — shares",
"\t\t// the host's actual config directory (no kill+seed); use only",
"\t\t// when you've manually closed the host first.",
'\t\tif (useHostConfig) {',
'\t\t\tapp = await launchClaude({ isolation: null });',
'\t\t} else {',
'\t\t\tconst seeded = await createIsolation({ seedFromHost: true });',
'\t\t\tapp = await launchClaude({ isolation: seeded });',
'\t\t}',
"\t\tconst ready = await app.waitForReady('userLoaded');",
'\t\tif (!ready.postLoginUrl) {',
"\t\t\tsuiteSkipReason = 'claude.ai never reached a post-login URL — host ' +",
"\t\t\t\t'profile is not signed in. Sign in via the host app first.';",
'\t\t\treturn;',
'\t\t}',
'\t\tsharedInspector = ready.inspector;',
'\t\tsharedStartUrl = await currentUrl(sharedInspector);',
'\t\tawait waitForStable(sharedInspector);',
'\t});',
'',
'\ttest.afterAll(async () => {',
'\t\tif (sharedInspector) {',
'\t\t\ttry {',
'\t\t\t\tsharedInspector.close();',
'\t\t\t} catch {',
'\t\t\t\t// inspector may already be closed by app.close()',
'\t\t\t}',
'\t\t\tsharedInspector = null;',
'\t\t}',
'\t\tif (app) {',
'\t\t\tawait app.close();',
'\t\t\tapp = null;',
'\t\t}',
'\t});',
'',
'\t// why: shared per-test runner. Each generated `test()` packs the',
'\t// entry as a literal and calls this — keeps the file scannable',
'\t// (one block per entry) without duplicating the assertion logic',
"\t// 383 times. Throws on its own when the suite was skipped so",
"\t// each test's status reflects the actual render check, not a",
'\t// mis-attributed setup failure.',
'\tasync function runEntry(',
'\t\tentry: InventoryEntry,',
"\t\ttestInfo: import('@playwright/test').TestInfo,",
'\t): Promise<void> {',
'\t\tif (suiteSkipReason) {',
'\t\t\ttestInfo.skip(true, suiteSkipReason);',
'\t\t\treturn;',
'\t\t}',
'\t\tif (!sharedInspector || !sharedStartUrl) {',
'\t\t\tthrow new Error(',
"\t\t\t\t'U01: beforeAll did not initialize the inspector — check the ' +",
"\t\t\t\t\t'session-env attachment for the launch failure.',",
'\t\t\t);',
'\t\t}',
"\t\ttestInfo.annotations.push({ type: 'severity', description: 'Should' });",
'\t\ttestInfo.annotations.push({',
"\t\t\ttype: 'surface',",
'\t\t\tdescription: entry.surface,',
'\t\t});',
'\t\ttestInfo.annotations.push({',
"\t\t\ttype: 'kind',",
'\t\t\tdescription: entry.kind,',
'\t\t});',
'',
'\t\ttry {',
'\t\t\tawait redrivePath(sharedInspector, sharedStartUrl, entry.navigationPath);',
'\t\t} catch (err) {',
'\t\t\tconst msg = err instanceof Error ? err.message : String(err);',
"\t\t\tawait testInfo.attach('redrive-failure', {",
'\t\t\t\tbody: JSON.stringify(',
'\t\t\t\t\t{',
'\t\t\t\t\t\tentry,',
'\t\t\t\t\t\terror: msg,',
'\t\t\t\t\t\tnote:',
"\t\t\t\t\t\t\t'redrivePath threw before we could assert visibility — ' +",
"\t\t\t\t\t\t\t'usually a stale fingerprint along the path. Re-walk the ' +",
"\t\t\t\t\t\t\t'inventory and regenerate.',",
'\t\t\t\t\t},',
'\t\t\t\t\tnull,',
'\t\t\t\t\t2,',
'\t\t\t\t),',
"\t\t\t\tcontentType: 'application/json',",
'\t\t\t});',
'\t\t\tthrow err;',
'\t\t}',
'\t\tawait waitForStable(sharedInspector);',
'',
'\t\tconst result = await findByFingerprint(',
'\t\t\tsharedInspector,',
'\t\t\tentry.fingerprint,',
'\t\t\tentry.kind,',
'\t\t);',
'\t\tif (!result.found) {',
"\t\t\tawait testInfo.attach('fingerprint-miss', {",
'\t\t\t\tbody: JSON.stringify(',
'\t\t\t\t\t{',
'\t\t\t\t\t\tentry,',
'\t\t\t\t\t\treason: result.reason,',
'\t\t\t\t\t\tobservedOuterHTML: result.outerHTMLSnippet,',
'\t\t\t\t\t},',
'\t\t\t\t\tnull,',
'\t\t\t\t\t2,',
'\t\t\t\t),',
"\t\t\t\tcontentType: 'application/json',",
'\t\t\t});',
'\t\t}',
"\t\t// Soft drift: primary aria-tree match failed but a relaxed-",
"\t\t// scope fallback recovered. Test still passes — but a",
"\t\t// drift-warning attachment surfaces it so the sweep summary",
"\t\t// can flag re-walk before drift compounds.",
'\t\tif (result.found && result.drift) {',
"\t\t\tawait testInfo.attach('drift-warning', {",
'\t\t\t\tbody: JSON.stringify(',
'\t\t\t\t\t{',
'\t\t\t\t\t\tentryId: entry.id,',
'\t\t\t\t\t\texpected: entry.fingerprint.ariaPath,',
'\t\t\t\t\t\tmatchedVia: result.strategy,',
'\t\t\t\t\t\tdrift: result.drift,',
"\t\t\t\t\t\tnote:",
"\t\t\t\t\t\t\t'primary aria-tree match failed; recovered via fallback. ' +",
"\t\t\t\t\t\t\t'Re-walk inventory before drift compounds.',",
'\t\t\t\t\t},',
'\t\t\t\t\tnull,',
'\t\t\t\t\t2,',
'\t\t\t\t),',
"\t\t\t\tcontentType: 'application/json',",
'\t\t\t});',
"\t\t\ttestInfo.annotations.push({",
"\t\t\t\ttype: 'drift',",
'\t\t\t\tdescription: result.strategy ?? \'unknown\',',
'\t\t\t});',
'\t\t}',
'\t\texpect(',
'\t\t\tresult.found,',
'\t\t\t`fingerprint did not resolve: ${result.reason ?? \'unknown\'}`,',
'\t\t).toBe(true);',
'\t}',
'',
'\ttest.beforeAll(async ({}, testInfo) => {',
"\t\tawait testInfo.attach('session-env', {",
'\t\t\tbody: JSON.stringify(captureSessionEnv(), null, 2),',
"\t\t\tcontentType: 'application/json',",
'\t\t});',
'\t});',
'',
);
// One describe per surface, one test per entry. Strings are
// JSON-encoded so labels with quotes/backticks/unicode survive.
for (const group of groups) {
out.push(
`\ttest.describe(${js(describeLabel(group.surface, group.entries.length))}, () => {`,
);
for (const entry of group.entries) {
const safe: InventoryEntry = {
...entry,
navigationPath: safeNavigationPath(entry),
};
out.push(
`\t\ttest(${js(testTitle(entry))}, async ({}, testInfo) => {`,
`\t\t\tconst entry: InventoryEntry = ${js(safe)};`,
'\t\t\tawait runEntry(entry, testInfo);',
'\t\t});',
);
}
out.push('\t});', '');
}
out.push('});', '');
return out.join('\n');
}
function atomicWrite(path: string, body: string): void {
const tmp = `${path}.tmp`;
writeFileSync(tmp, body, 'utf8');
renameSync(tmp, path);
}
function main(): void {
const opts = parseCli(process.argv.slice(2));
if (opts.help) {
printUsage();
return;
}
const inv = loadInventory(opts.inventory);
const meta = loadMeta(opts.inventory);
validate(inv, meta);
const groups = groupBySurface(inv.entries);
const body = generateSpec(inv, meta, groups);
atomicWrite(opts.output, body);
const testCount = inv.entries.length;
process.stdout.write(
`gen-render-specs: wrote ${opts.output}\n` +
` ${testCount} test() across ${groups.length} test.describe() ` +
`(${inv.deniedActions} denylisted)\n`,
);
}
main();

View File

@@ -1,202 +0,0 @@
// Live AX-tree probe for the claudeai.ts migration. Connects to the
// host's main-process Node inspector on :9229 (must be enabled via
// "Developer → Enable Main Process Debugger"), pulls the claude.ai
// AX tree, and reports what the page-object discrimination shapes
// will actually see.
//
// Read-only — no clicks, no state mutation.
//
// Run: cd tools/test-harness && npx tsx explore/probe-claudeai-ax.ts
import { InspectorClient } from '../src/lib/inspector.js';
import { axTreeToSnapshot, type RawElement } from './walker.js';
const INSPECTOR_PORT = 9229;
const ROW_MORE_OPTIONS_RE = /^More options for /;
const MENU_ITEM_ROLES = new Set([
'menuitem',
'menuitemradio',
'menuitemcheckbox',
]);
function landmarkTrail(el: RawElement): string {
const trail = el.ancestors
.filter((a) => a.role !== null)
.map((a) => (a.name ? `${a.role}[${a.name}]` : (a.role as string)));
return trail.join(' ') || '<no ancestors>';
}
function fmtElement(el: RawElement): string {
const name = el.accessibleName ?? '<no-name>';
const popup = el.hasPopup ?? '-';
return (
` • role=${el.computedRole} hasPopup=${popup} ` +
`name=${JSON.stringify(name).slice(0, 90)}\n` +
` landmarks: ${landmarkTrail(el)}`
);
}
async function main(): Promise<void> {
const inspector = await InspectorClient.connect(INSPECTOR_PORT);
try {
// What URL is the renderer on right now?
const url = await inspector.evalInRenderer<string>(
'claude.ai',
'(() => location.href)()',
);
process.stdout.write(`renderer URL: ${url}\n\n`);
const nodes = await inspector.getAccessibleTree('claude.ai');
process.stdout.write(`raw AX nodes: ${nodes.length}\n`);
const elements = axTreeToSnapshot(nodes);
process.stdout.write(
`interactive elements (post-filter): ${elements.length}\n\n`,
);
// Bucket by role for a quick overall shape.
const byRole = new Map<string, number>();
for (const el of elements) {
byRole.set(el.computedRole, (byRole.get(el.computedRole) ?? 0) + 1);
}
process.stdout.write('role histogram:\n');
for (const [role, n] of [...byRole.entries()].sort()) {
process.stdout.write(` ${role}: ${n}\n`);
}
process.stdout.write('\n');
// THE KEY QUESTION: do any buttons report hasPopup === 'menu'?
// If yes, the migration's discrimination shape is sound. If no,
// claude.ai exposes the popover trigger via a different AX
// signal and we need a different filter.
const buttonsWithPopup = elements.filter(
(el) => el.computedRole === 'button' && el.hasPopup !== null,
);
process.stdout.write(
`buttons with hasPopup set (any value): ${buttonsWithPopup.length}\n`,
);
const popupValues = new Map<string, number>();
for (const b of buttonsWithPopup) {
const v = b.hasPopup ?? '<null>';
popupValues.set(v, (popupValues.get(v) ?? 0) + 1);
}
for (const [v, n] of [...popupValues.entries()].sort()) {
process.stdout.write(` hasPopup="${v}": ${n}\n`);
}
process.stdout.write('\n');
// What findCompactPills() would return.
const compactPills = elements.filter(
(el) =>
el.computedRole === 'button' &&
el.hasPopup === 'menu' &&
el.accessibleName !== null &&
el.accessibleName.length > 0 &&
!ROW_MORE_OPTIONS_RE.test(el.accessibleName),
);
process.stdout.write(
`findCompactPills() would return ${compactPills.length} candidate(s):\n`,
);
for (const el of compactPills) process.stdout.write(`${fmtElement(el)}\n`);
process.stdout.write('\n');
// What the row-more-options filter is dropping.
const rowMore = elements.filter(
(el) =>
el.computedRole === 'button' &&
el.hasPopup === 'menu' &&
el.accessibleName !== null &&
ROW_MORE_OPTIONS_RE.test(el.accessibleName),
);
process.stdout.write(
`row-more-options filter dropped ${rowMore.length} button(s) ` +
`(showing first 5):\n`,
);
for (const el of rowMore.slice(0, 5)) {
process.stdout.write(`${fmtElement(el)}\n`);
}
process.stdout.write('\n');
// Top-level tabs: activateTab() looks for `role: 'button'` with
// accessibleName === 'Chat' | 'Cowork' | 'Code'. Probe each one.
process.stdout.write('top-level tab probe:\n');
for (const name of ['Chat', 'Cowork', 'Code']) {
const matches = elements.filter(
(el) =>
el.computedRole === 'button' && el.accessibleName === name,
);
process.stdout.write(` "${name}": ${matches.length} match(es)\n`);
for (const el of matches) {
process.stdout.write(
` landmarks: ${landmarkTrail(el)} hasPopup=${el.hasPopup ?? '-'}\n`,
);
}
}
process.stdout.write('\n');
// Open menu? Anything in MENU_ITEM_ROLES right now would mean a
// menu happens to be open at probe time — useful context for
// callers reading the output.
const items = elements.filter((el) =>
MENU_ITEM_ROLES.has(el.computedRole),
);
process.stdout.write(
`menuitem* elements currently in tree: ${items.length}` +
(items.length > 0 ? ' (a menu is open — surprise context)' : '') +
'\n\n',
);
// Diagnostic: is `properties[]` even being returned? Dump the
// raw shape of the first button node and any node that has a
// non-empty properties array, so we can tell whether
// (a) Chromium isn't surfacing aria-haspopup, or
// (b) properties[] is just absent from the response.
const firstButton = nodes.find((n) => n.role?.value === 'button');
if (firstButton) {
process.stdout.write('first raw button AxNode (full JSON):\n');
process.stdout.write(`${JSON.stringify(firstButton, null, 2)}\n\n`);
}
const nodesWithProps = nodes.filter(
(n) => Array.isArray(n.properties) && n.properties.length > 0,
);
process.stdout.write(
`raw nodes with non-empty properties[]: ${nodesWithProps.length}\n`,
);
// Histogram of property names actually present.
const propNames = new Map<string, number>();
for (const n of nodesWithProps) {
const props = n.properties as { name?: string }[];
for (const p of props) {
if (typeof p.name === 'string') {
propNames.set(p.name, (propNames.get(p.name) ?? 0) + 1);
}
}
}
for (const [name, n] of [...propNames.entries()].sort()) {
process.stdout.write(` property "${name}": ${n}\n`);
}
process.stdout.write('\n');
// Spot-check the model picker if visible — it should be the
// canonical "menu trigger" on every surface.
const modelLikely = elements.filter(
(el) =>
el.accessibleName !== null &&
/^(Opus|Sonnet|Haiku|Claude)\b/i.test(el.accessibleName),
);
process.stdout.write(
`model-picker-like elements (name starts with Opus/Sonnet/Haiku/Claude): ` +
`${modelLikely.length}\n`,
);
for (const el of modelLikely.slice(0, 5)) {
process.stdout.write(`${fmtElement(el)}\n`);
}
} finally {
inspector.close();
}
}
main().catch((err) => {
process.stderr.write(`probe failed: ${err}\n`);
process.exit(1);
});

View File

@@ -1,276 +0,0 @@
// Renderer-state capture for the explore CLI.
//
// Why a separate module: the snapshot shape is the contract diff.ts
// reads against. Keeping the capture here (rather than inline in the
// dispatcher) means a future format bump only touches two files and
// the schema lives next to its sole producer.
//
// All discovery is by structural shape — never by minified Tailwind
// class names. We anchor on:
// - df-pills: button.df-pill[aria-label] (3 expected: Chat/Cowork/Code)
// - compact pills: button[aria-haspopup="menu"] containing
// span.truncate.max-w-[Npx] (env pill, Select-folder pill, …)
// - aria-labeled buttons: any <button[aria-label]> for general drift
// visibility (sidebar "more" buttons, header actions, modals).
// - open menu: the role=menu currently in the DOM, plus its items.
// - modals: role=dialog elements with aria-label/aria-labelledby.
//
// All renderer evals run in a single round-trip to keep snapshots
// deterministic — async work between probes can shift the DOM.
import type { InspectorClient } from '../src/lib/inspector.js';
export interface DfPill {
ariaLabel: string | null;
text: string;
visible: boolean;
}
export interface CompactPillSnap {
ariaLabel: string | null;
text: string;
maxW: string;
expanded: boolean;
}
export interface AriaButton {
ariaLabel: string;
text: string;
expanded: boolean | null;
hasPopup: string | null;
visible: boolean;
}
export interface MenuItem {
role: string;
text: string;
ariaChecked: string | null;
disabled: boolean;
}
export interface OpenMenu {
ariaLabelledBy: string | null;
ariaLabel: string | null;
items: MenuItem[];
}
export interface ModalSnap {
ariaLabel: string | null;
ariaLabelledBy: string | null;
headingText: string | null;
buttonLabels: string[];
}
export interface PageState {
url: string;
title: string;
readyState: string;
}
export interface Snapshot {
capturedAt: string;
claudeAiUrl: string;
appVersion: string | null;
pageState: PageState;
dfPills: DfPill[];
compactPills: CompactPillSnap[];
ariaLabeledButtons: AriaButton[];
openMenu: OpenMenu | null;
modals: ModalSnap[];
}
// Capture the renderer DOM into the canonical snapshot shape.
// `claudeAiUrl` is recorded separately from pageState.url because the
// pageState reflects the moment of capture and is useful for diff
// triage; the top-level url anchors which webContents we hit.
export async function capture(client: InspectorClient): Promise<Snapshot> {
const target = await pickClaudeAiWebContents(client);
const appVersion = await readAppVersion(client);
const dom = await client.evalInRenderer<{
pageState: PageState;
dfPills: DfPill[];
compactPills: CompactPillSnap[];
ariaLabeledButtons: AriaButton[];
openMenu: OpenMenu | null;
modals: ModalSnap[];
}>('claude.ai', RENDERER_CAPTURE_BODY);
return {
capturedAt: new Date().toISOString(),
claudeAiUrl: target,
appVersion,
pageState: dom.pageState,
dfPills: dom.dfPills,
compactPills: dom.compactPills,
ariaLabeledButtons: dom.ariaLabeledButtons,
openMenu: dom.openMenu,
modals: dom.modals,
};
}
// Just the pills slice — used by `explore pills`. Reuses the same eval
// body to avoid drift between subcommands.
export async function capturePills(
client: InspectorClient,
): Promise<{
dfPills: DfPill[];
compactPills: CompactPillSnap[];
pageState: PageState;
}> {
const dom = await client.evalInRenderer<{
pageState: PageState;
dfPills: DfPill[];
compactPills: CompactPillSnap[];
ariaLabeledButtons: AriaButton[];
openMenu: OpenMenu | null;
modals: ModalSnap[];
}>('claude.ai', RENDERER_CAPTURE_BODY);
return {
dfPills: dom.dfPills,
compactPills: dom.compactPills,
pageState: dom.pageState,
};
}
// Just the open menu — used by `explore menu`.
export async function captureOpenMenu(
client: InspectorClient,
): Promise<OpenMenu | null> {
const dom = await client.evalInRenderer<{ openMenu: OpenMenu | null }>(
'claude.ai',
`(() => { ${OPEN_MENU_FN} return { openMenu: openMenu() }; })()`,
);
return dom.openMenu;
}
async function pickClaudeAiWebContents(
client: InspectorClient,
): Promise<string> {
const list = await client.evalInMain<Array<{ url: string }>>(`
const { webContents } = process.mainModule.require('electron');
return webContents.getAllWebContents().map(w => ({ url: w.getURL() }));
`);
const target = list.find((w) => w.url.includes('claude.ai'));
if (!target) {
throw new Error(
'snapshot: no claude.ai webContents — open the app to a ' +
'logged-in state first',
);
}
return target.url;
}
// app.getVersion() is the cleanest source of truth — same value the
// app.asar serves at runtime. Returns null if the call shape ever
// changes upstream rather than failing the whole snapshot.
async function readAppVersion(
client: InspectorClient,
): Promise<string | null> {
try {
return await client.evalInMain<string>(`
const { app } = process.mainModule.require('electron');
return app.getVersion();
`);
} catch {
return null;
}
}
// Single shared renderer-eval body. Definitions are inlined as IIFEs so
// the whole capture is one round-trip. Truncation limits (text 200,
// list 200) are wide enough for current claude.ai but bounded so a
// future infinite-scroll regression doesn't blow up the JSON file.
const OPEN_MENU_FN = `
function openMenu() {
const menu = document.querySelector('[role=menu][data-open]')
|| document.querySelector('[role=menu]');
if (!menu) return null;
const items = Array.from(menu.querySelectorAll(
'[role=menuitem], [role=menuitemradio], [role=menuitemcheckbox]'
)).slice(0, 200).map(el => ({
role: el.getAttribute('role') || '',
text: (el.textContent || '').trim().slice(0, 200),
ariaChecked: el.getAttribute('aria-checked'),
disabled: el.hasAttribute('data-disabled')
|| el.getAttribute('aria-disabled') === 'true',
}));
return {
ariaLabelledBy: menu.getAttribute('aria-labelledby'),
ariaLabel: menu.getAttribute('aria-label'),
items,
};
}
`;
const RENDERER_CAPTURE_BODY = `
(() => {
${OPEN_MENU_FN}
const buttons = Array.from(document.querySelectorAll('button'));
const dfPills = buttons
.filter(b => /\\bdf-pill\\b/.test(b.className))
.map(b => ({
ariaLabel: b.getAttribute('aria-label'),
text: (b.textContent || '').trim().slice(0, 200),
visible: !!b.getClientRects().length,
}));
const compactPills = buttons.flatMap(b => {
if (b.getAttribute('aria-haspopup') !== 'menu') return [];
const span = b.querySelector('span.truncate');
if (!span) return [];
const m = span.className.match(/max-w-\\[[^\\]]+\\]/);
if (!m) return [];
return [{
ariaLabel: b.getAttribute('aria-label'),
text: (span.textContent || '').trim().slice(0, 200),
maxW: m[0],
expanded: b.getAttribute('aria-expanded') === 'true',
}];
});
const ariaLabeledButtons = buttons
.filter(b => b.hasAttribute('aria-label'))
.slice(0, 200)
.map(b => ({
ariaLabel: b.getAttribute('aria-label') || '',
text: (b.textContent || '').trim().slice(0, 200),
expanded: b.hasAttribute('aria-expanded')
? b.getAttribute('aria-expanded') === 'true'
: null,
hasPopup: b.getAttribute('aria-haspopup'),
visible: !!b.getClientRects().length,
}));
const modals = Array.from(
document.querySelectorAll('[role=dialog]')
).slice(0, 20).map(d => {
const heading = d.querySelector(
'h1, h2, h3, [role=heading]'
);
const btnLabels = Array.from(d.querySelectorAll('button'))
.slice(0, 50)
.map(b => {
const al = b.getAttribute('aria-label');
if (al) return al;
return (b.textContent || '').trim().slice(0, 80);
})
.filter(s => s.length > 0);
return {
ariaLabel: d.getAttribute('aria-label'),
ariaLabelledBy: d.getAttribute('aria-labelledby'),
headingText: heading
? (heading.textContent || '').trim().slice(0, 200)
: null,
buttonLabels: btnLabels,
};
});
return {
pageState: {
url: location.href,
title: document.title,
readyState: document.readyState,
},
dfPills,
compactPills,
ariaLabeledButtons,
openMenu: openMenu(),
modals,
};
})()
`;

View File

@@ -1,240 +0,0 @@
// Drive a v7 walk inside the test harness's launch-with-isolation
// path so the run lives in a per-launch tmpdir (auth seeded from the
// host config) rather than the running host app's own profile.
//
// Why a separate driver instead of `explore walk`: the standalone CLI
// connects to whatever Node inspector is already on :9229 — i.e. the
// running host Claude Desktop. That path mutates the host profile
// (visited surfaces, navigation history, route changes) and races
// with the human at the keyboard. The launchClaude path here mirrors
// what H05 / U01 do: kill any running host instance, copy auth into
// a tmpdir, spawn a fresh Electron with isolated XDG_CONFIG_HOME,
// attach the inspector via SIGUSR1, and tear everything down on
// exit.
//
// Usage (matches `explore walk` flag set):
// npx tsx explore/walk-isolated.ts --verbose --max-elements 2000
//
// Flags:
// --max-elements N global cap (default 1000)
// --max-drills-per-surface N per-surface drilling fan-out cap (default 50)
// --checkpoint-every N write inventory every N entries (default 100)
// --output PATH inventory output (default docs/testing/
// ui-inventory.json)
// --allowlist PATH JSON file with `exemptions: string[]`
// --no-seed don't copy host auth — fresh sign-in
// required (rare; default seeds from host)
// --verbose walker chatter to stderr
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launchClaude } from '../src/lib/electron.js';
import { createIsolation } from '../src/lib/isolation.js';
import { walkRenderer, WALKER_VERSION } from './walker.js';
import type { Inventory } from './walker.js';
const TESTING_DIR = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'docs',
'testing',
);
const INVENTORY_PATH = resolve(TESTING_DIR, 'ui-inventory.json');
const INVENTORY_META_PATH = resolve(TESTING_DIR, 'ui-inventory.meta.json');
const INVENTORY_TMP_SUFFIX = '.tmp';
interface Options {
maxElements: number;
maxDrillsPerSurface: number;
checkpointEvery: number;
allowlist: string | null;
output: string;
verbose: boolean;
seed: boolean;
help: boolean;
}
function parseArgs(args: string[]): Options {
const opts: Options = {
maxElements: 1000,
maxDrillsPerSurface: 50,
checkpointEvery: 100,
allowlist: null,
output: INVENTORY_PATH,
verbose: false,
seed: true,
help: false,
};
for (let i = 0; i < args.length; i += 1) {
const a = args[i]!;
if (a === '-h' || a === '--help') opts.help = true;
else if (a === '--verbose') opts.verbose = true;
else if (a === '--no-seed') opts.seed = false;
else if (a === '--max-elements') {
const n = Number(args[++i]);
if (!Number.isFinite(n) || n < 0) die('--max-elements N (N≥0)');
opts.maxElements = n;
} else if (a === '--max-drills-per-surface') {
const n = Number(args[++i]);
if (!Number.isFinite(n) || n < 0) die('--max-drills-per-surface N');
opts.maxDrillsPerSurface = n;
} else if (a === '--checkpoint-every') {
const n = Number(args[++i]);
if (!Number.isInteger(n) || n < 0) die('--checkpoint-every N');
opts.checkpointEvery = n;
} else if (a === '--allowlist') {
const p = args[++i];
if (!p) die('--allowlist PATH');
opts.allowlist = p;
} else if (a === '--output') {
const p = args[++i];
if (!p) die('--output PATH');
opts.output = resolve(p);
} else {
die(`unknown flag: ${a}`);
}
}
return opts;
}
function die(msg: string): never {
process.stderr.write(`walk-isolated: ${msg}\n`);
process.exit(1);
}
function printUsage(): void {
process.stdout.write(
[
'usage: npx tsx explore/walk-isolated.ts [flags]',
'',
'flags:',
' --max-elements N global cap (default 1000)',
' --max-drills-per-surface N drilling fan-out cap (default 50)',
' --checkpoint-every N partial-write cadence (default 100; 0 disables)',
' --output PATH inventory output path',
' --allowlist PATH JSON { exemptions: string[] }',
' --no-seed skip host-config auth seeding',
' --verbose walker chatter on stderr',
'',
].join('\n'),
);
}
async function main(): Promise<void> {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
printUsage();
return;
}
let allowlist: string[] = [];
if (opts.allowlist) {
const raw = readFileSync(opts.allowlist, 'utf8');
const parsed = JSON.parse(raw) as { exemptions?: string[] };
allowlist = parsed.exemptions ?? [];
}
const outDir = dirname(opts.output);
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
const metaPath =
opts.output === INVENTORY_PATH
? INVENTORY_META_PATH
: opts.output.replace(/\.json$/, '.meta.json');
const writeCheckpoint = (inventory: Inventory, isPartial: boolean): void => {
const invTmp = `${opts.output}${INVENTORY_TMP_SUFFIX}`;
writeFileSync(invTmp, JSON.stringify(inventory, null, 2) + '\n', 'utf8');
renameSync(invTmp, opts.output);
const meta = {
capturedAt: inventory.capturedAt,
appVersion: inventory.appVersion,
walkerVersion: WALKER_VERSION,
startUrl: inventory.startUrl,
totalElements: inventory.totalElements,
deniedActions: inventory.deniedActions,
partial: isPartial,
isolation: 'launchClaude (test-harness path)',
seededFromHost: opts.seed,
allowlistEntries: allowlist,
};
const metaTmp = `${metaPath}${INVENTORY_TMP_SUFFIX}`;
writeFileSync(metaTmp, JSON.stringify(meta, null, 2) + '\n', 'utf8');
renameSync(metaTmp, metaPath);
};
process.stderr.write(
`walk-isolated: creating isolation (seedFromHost=${opts.seed})\n`,
);
const isolation = await createIsolation({ seedFromHost: opts.seed });
let app: Awaited<ReturnType<typeof launchClaude>> | null = null;
try {
process.stderr.write('walk-isolated: spawning Claude Desktop\n');
app = await launchClaude({ isolation });
process.stderr.write(
'walk-isolated: waiting for claude.ai webContents (90s budget)\n',
);
const { inspector, claudeAiUrl } = await app.waitForReady('claudeAi');
if (!claudeAiUrl) {
throw new Error(
'claude.ai webContents never loaded — host likely not signed in. ' +
'Open Claude Desktop, sign in, fully close, and re-run.',
);
}
process.stderr.write(`walk-isolated: at ${claudeAiUrl}\n`);
const inventory = await walkRenderer(inspector, {
maxElements: opts.maxElements,
maxDrillsPerSurface: opts.maxDrillsPerSurface,
allowlist,
verbose: opts.verbose,
checkpointEvery: opts.checkpointEvery,
checkpointWriter:
opts.checkpointEvery > 0
? (inv) => writeCheckpoint(inv, true)
: undefined,
});
writeCheckpoint(inventory, false);
process.stdout.write(
`wrote ${opts.output} (${inventory.totalElements} entries, ` +
`${inventory.deniedActions} denylisted)\n`,
);
process.stdout.write(`wrote ${metaPath}\n`);
} finally {
if (app) {
try {
await app.close();
} catch (err) {
process.stderr.write(
`walk-isolated: app.close() failed: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}
try {
await isolation.cleanup();
} catch (err) {
process.stderr.write(
`walk-isolated: isolation.cleanup() failed: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
}
}
}
main().catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`walk-isolated: ${msg}\n`);
process.exit(2);
});

File diff suppressed because it is too large Load Diff

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