Compare commits

..

65 Commits

Author SHA1 Message Date
aaddrick
9528c25e95 test(harness): fix T10 by driving daemon respawn from a main-side eipc call
T10 was passing on older bundles where the cowork client retried the
VM-service connection on a polling cadence — every retry tick was an
implicit trigger for the patched cooldown-gated auto-launch. Post-
1.5354.0 the client opens a persistent socket at boot (zI/E\$i happy
path → KSt) and routes every subsequent RPC through it, so steady
state has no traffic. After SIGKILL the persistent socket goes dead
but no client code is in flight, so kUe()'s catch branch never enters
and the daemon stays gone.

The case-doc claim is upheld by the production code; the patch is
correctly applied (`_lastSpawn` × 3 in installed asar, `_svcLaunched`
× 0). Only the test's trigger model was stale.

Three changes:

1. Wait for `userLoaded`, not `mainVisible`. The post-kill RPC has to
   land in a webContents whose URL matches `claude.ai`; pre-login
   `/login/...` URLs aren't reachable via that filter.

2. Phase 3 fires a daemon RPC each iteration. The renderer wrapper
   (`window['claude.web'].ClaudeVM.getRunningStatus`) was the obvious
   first try but was unreliable: 29/30 calls threw `Cannot find
   context with specified id` because the dead-daemon state forces a
   renderer re-render that invalidates the cached execution context.
   Switched to invoking the eipc handler from MAIN directly via
   `wc.ipc._invokeHandlers.get(channel)(fakeEvent)` with
   `senderFrame.url = 'https://claude.ai/'`. The handler still goes
   through zI/VsA/kUe, the dead socket still throws, the cooldown
   gate still opens, and the patched fork still fires — just without
   any renderer dependency. Three consecutive runs at 21.0s.

3. Budget bumped 20s → 30s. The 10s cooldown is a hard floor, and the
   daemon needs another second or two to bind the socket; 20s was on
   the edge.

Telemetry now reports `rpcAttempts` / `rpcFailures` /
`globalDaemonPidFinal` (the patched `__coworkDaemonPid` global) so
future regressions can be diagnosed from the failure attachment alone.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 07:29:57 -04:00
aaddrick
d12c491470 test(harness): fix S25 by routing require through process.mainModule
Three bare `require('node:fs')` calls inside evalInMain bodies were
failing with `ReferenceError: require is not defined` on the bundled
Electron's main-process CDP eval scope — `require` isn't exposed as a
global there, only on the current module object. Adjacent calls on
the same blocks already used `process.mainModule.require('electron')`
correctly; the `node:fs` lines were the outliers.

Doc comment on lib/inspector.ts:evalInMain captures the gotcha so the
next caller doesn't trip the same wire.

S25 verified: passes in 15.1s on KDE-W (CLAUDE_TEST_USE_HOST_CONFIG=1).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 07:29:35 -04:00
aaddrick
0a1f8071e9 docs(testing): session 16 verify T17 seedFromHost + schema-rev for listRemotePluginsPage / listSkillFiles + flag orchestrator STOP for session 17
Final session of the sessions-13-to-16 autonomous orchestration run.

Verified session 15's T17 seedFromHost migration end-to-end against
the dev box: bare 60s Playwright timeout is GONE, seedFromHost clones
host config, waitForReady('userLoaded') resolves to a post-login URL
(https://claude.ai/epitaxy), dialog mock installs, and the session-14
CodeTab.activate({ timeout: 15_000 }) AX migration succeeds first try.
T17 reaches a NEW failure mode at the next chain step
(openFolderPicker after selectLocal — Select-folder pill doesn't
render on /epitaxy workspace route, likely needs /new context).
Classified as renderer-state-dependent, not openPill / clickMenuItem
loop — ruling out sessions 14-15's parked AX migration hypothesis
once and for all. Deferred for a future session (needs careful /new
navigation primitive).

Schema-rev resolved both deferred validators by bundle inspection of
app.asar (no smoke-test possible — T17's seedFromHost step killed the
debugger-attached leaked isolations as expected):

- 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 (T33 anchors listMarketplaces +
listAvailablePlugins, both already covered by T33b/T33c);
listSkillFiles is meaningful only with an installed plugin, which
needs Tier 3 destructive setup explicitly forbidden by the
constraints. Schemas captured in plan-doc as a deferred reframe.

Coverage stays at 74/76 (97%) — verification + investigation, no
spec landed.

Orchestration-level summary (sessions 13-16):
- Coverage start 74/76 (97%) → end 74/76 (97%) — NO net coverage
  gain across 4 sessions
- Net deliverables: 1 primitive (lib/ax.ts, session 13), 1 AX
  migration (activateTab + CodeTab.activate, session 14, fixed T16
  pre-existing-flake), 1 structural fix (T17 seedFromHost, session
  15, verified working session 16), 1 verification + 1 schema-rev
  investigation (session 16)
- Why coverage stalled: structural ceiling reached. Remaining 2
  specs need real claude.ai account write-side state which the
  harness can't construct without violating the Tier 3 destructive
  constraint.

Followup prompt rotated for session 17 with a STOP flag at the top —
session 17 will only run if the user manually triggers another
orchestration AND at least one of four preconditions holds (real
signed-in debugger-attached Claude, real-account write-side fixture,
renderer-drift event, or new IPC surface).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 00:30:52 -04:00
aaddrick
14ccb61596 docs(testing): session 15 plan/inventory + rotate session 16 prompt
Plan-doc Status (post-execution): adds session 15 entry capturing
the T17 structural fix (legacy `CLAUDE_TEST_USE_HOST_CONFIG=1` →
`seedFromHost: true`), the RawElement import prune, the
debugger-attached-to-leaked-test-isolation discovery, the
`openPill` / `clickMenuItem` migration park decision, and the
"productivity signal is dimming — 3 consecutive sessions without
coverage gain" note for the orchestrator.

Followup prompt rotation: rewrites for session 16 with the new
PRIORITY (run T17 to verify the seedFromHost migration), the
upgraded Phase 0 calibration check (port-9229 attachment quality,
not just port status — must distinguish auth-bearing Claude from
leaked /login isolations via `evalInMain` webContents probe), the
narrowed category list (D-verify + C + STOP recommendation), and
the explicit STOP termination criterion if both D-verify and C
turn up empty.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 00:23:16 -04:00
aaddrick
af8a60bdb1 test(harness): session 15 migrate T17 to seedFromHost + prune unused RawElement import (no spec, coverage unchanged at 97%)
Session 15 investigation finding: T17's pre-existing 60s timeout
flake (hypothesised in sessions 13-14 to live in `openPill` /
`clickMenuItem` AX polling) was actually structural. The trace
showed a bare 60s Playwright spec timeout with NO `renderer-url`
attachment fired — meaning the test never reached line 49's
attach call, which means it never resolved
`waitForReady('userLoaded')` at line 40.

Root cause: T17 was the last spec on the legacy
`CLAUDE_TEST_USE_HOST_CONFIG=1` / `isolation: null` shape. Every
other auth-required spec (T07, T16, T19, T20, T21, T22b, T26, T27,
T31b, T33b/c, T35b, T37b, T38b) had moved to `seedFromHost: true`.
Without the env var (which CI / orchestration didn't set), T17
fell through to a fresh isolation with no auth, hit `/login`, and
`waitForUserLoaded`'s 90s default budget got preempted by
Playwright's 60s spec timeout (per `playwright.config.ts`).

Migration: rewrite T17 to use the canonical seedFromHost pattern
(mirroring T16 / T26): `createIsolation({ seedFromHost: true })`
with a clean skip path on host-config-unavailable, then
`launchClaude({ isolation })` and `waitForReady('userLoaded')` —
which now resolves cleanly within budget when host has signed-in
auth, or skips with a clear message when it doesn't.

Cleanup: prune unused `RawElement` re-export import from
`lib/claudeai.ts` per session 14's leftover hint (left over from
the migration that didn't end up needing the type re-export).

T17 not run this session because the dev box's running Electron
processes ambiguously include leaked test isolations and possibly
the user's real Claude — `seedFromHost` would kill both, deferred
to next session for verification with explicit user-Claude
disambiguation.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 00:23:05 -04:00
aaddrick
8b556f2997 docs(testing): session 14 plan/inventory + rotate session 15 prompt
Add session 14 status entry to runner-implementation-plan.md (call-
site migration + T16 fix verification + T17-stays-flaky verification).
Rotate the followup prompt for session 15: PRIORITY shape is T17
investigation + potential `openPill` / `clickMenuItem` migration if
the failure trace shows AX-polling-reachable cause; A / B / C
unchanged from session 14 (still need debugger).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 00:11:59 -04:00
aaddrick
865c147916 test(harness): session 14 migrate activateTab to waitForAxNode (no spec, coverage unchanged at 97%)
Migrate `activateTab` from a one-shot AX snapshot to a `waitForAxNode`
poll, plus migrate `CodeTab.activate`'s post-click `retryUntil`-around-
`findCompactPills` loop to `waitForAxNodes`. Fixes T16's pre-existing
`no AX-tree button with accessibleName="Code" found` failure mode
documented in session 13 — verified by stashing the migration and re-
running T16 against the baseline (same failure), then restoring and
seeing T16 pass 3/3 in succession against the migrated form.

`activateTab` now takes an optional `{ timeout?: number }` parameter,
defaulting to 5000ms (matches `lib/ax.ts` defaults). `CodeTab.activate`
passes its own timeout (T16 supplies 15s) through to both the pre-
click click-budget and the post-click pill poll. The post-click
predicate is copy-pasted from `findCompactPills` (role: button +
hasPopup: menu + non-empty accessibleName + not a `^More options for `
row trigger) to keep the page-object free-standing.

`findCompactPills` itself stays a one-shot snapshot — it has three
call-sites (the formerly-hand-rolled retry inside `CodeTab.activate`
that this commit migrates, plus T16's failure-diagnostic capture and
post-activate diagnostic that both want fail-fast snapshots). Pushing
retry latency into the helper itself would change the diagnostic
contract.

`openPill` and `clickMenuItem` not migrated this session — their
post-click stability gates plus per-iteration sleep budgets carry
T17-specific tuning that the followup prompt explicitly cautioned
against changing speculatively. T17 stays pre-existing-flaky on KDE-W;
verified that status by stashing the migration and re-running T17
(same 60s timeout — failure unchanged-by-migration).

Verification:
- npm run typecheck: clean
- H01 / H02 / H03 (canaries): pass
- T16: pass 3/3 (migration fixes the documented pre-existing failure)
- T17: still pre-existing-flaky (verified independent of migration)
- T26: pass (regression check — uses snapshotAx directly, not affected)

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-04 00:11:53 -04:00
aaddrick
113329f91f docs(testing): session 13 plan/inventory + rotate session 14 prompt
- runner-implementation-plan.md: session 13 status section
  (lib/ax.ts primitive shipped, no new spec, coverage stays at 74/76
  = 97% since primitive-only sessions don't move the spec count;
  Phase 0 found debugger detached on dev box which blocked Categories
  A/B/C; pivoted to the PRIORITY DOM unification primitive). Updated
  the "Primitive gaps to flag" entry — DOM/AX loading + traversal
  primitive moved from FLAGGED to LANDED with the consumer list and
  the deliberately-deferred shapes (waitForRenderedSurface registry,
  CSS-querySelector primitive).
- README.md: lib/ax.ts entry in the substrate-primitives note;
  session 13 consumer list (claudeai.ts page-objects + T26).
  Spec count unchanged at 74.
- runner-implementation-followup-prompt.md: rotated for session 14.
  Adds new Category D (call-site migration to waitForAxNode for
  flake reduction) as the PRIORITY shape — doesn't need the
  debugger, builds on session 13's primitive. Carries forward
  Categories A / B / C (still need debugger). Phase 0 must check
  port 9229 BEFORE picking a category. Reading order updated:
  session 13 first.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 23:57:00 -04:00
aaddrick
3d47f33ccb test(harness): session 13 lib/ax.ts AX substrate primitive (no spec, coverage unchanged at 97%)
Threshold-driven extraction of the AX-tree loading + traversal
substrate. `claudeai.ts` page-objects and `T26_routines_page_renders`
both carried inline copies of the same `snapshotAx` helper (T26's
even noted "premature abstraction at 1 consumer" — with two consumers
the threshold is met). Plus the user reports recurring AX-query flake.

Surface (`tools/test-harness/src/lib/ax.ts`):

- snapshotAx(inspector, opts) — single AX read with the stability
  gate. opts.fast skips the gate for inside-poll callers (matches
  the existing private-helper contract in claudeai.ts).
- waitForAxNode(inspector, predicate, opts) — repeatedly snapshot
  the tree and return the first matching RawElement, or null on
  timeout. Gates on stability once at the start (configurable),
  then iterates with fast: true. Built against the inline polling
  loops in CodeTab.activate, openPill, clickMenuItem, and T26's
  pre/post-click anchor scans — the existing call-sites are NOT
  migrated this session (per-spec retry budgets are tuned, changing
  them speculatively risks introducing flake).
- waitForAxNodes(inspector, predicate, opts) — same shape, returns
  every match. For consumers that want to enumerate.
- Re-exports: RawElement, AxNode, axTreeToSnapshot,
  waitForAxTreeStable from explore/walker.ts so consumers stay
  inside lib/ instead of reaching into explore/. Walker remains
  the source of truth for AX-snapshot construction; lib/ax.ts is
  the runner-facing alias.

Refactors:

- claudeai.ts swaps its private snapshotAx for the shared one
  (5-line import change; call-sites unchanged).
- T26_routines_page_renders.spec.ts drops its inlined helper and
  imports from lib/ax.ts.

Phase 0 of session 13 found port 9229 detached (Claude was running
but Developer → Enable Main Process Debugger had not been clicked),
which blocked Categories A (operon-mode navigation probe) and C
(schema-rev for listRemotePluginsPage / listSkillFiles) — both need
runtime probing. Category B (Tier 3 read-only reframes) effectively
needed the debugger too. The PRIORITY-flagged DOM unification
primitive was tractable without it (pure static-analysis-driven
extraction), so session 13 pivoted there. Coverage stays at 74/76
(97%) since primitive-only sessions don't move the spec count.

What's NOT in lib/ax.ts:

- waitForRenderedSurface(client, surfaceKey) — the plan-doc proposal
  mentioned a named-surface registry but no consumer asks for it
  today; promote when a third consumer crystallizes with a specific
  surface in mind.
- CSS-querySelector primitive — T07's topbar poll is a different
  abstraction (DOM, not AX). No second consumer signal yet.
- Call-site retry budget changes — the per-spec budgets are tuned;
  speculative changes risk introducing flake. Migration to
  waitForAxNode is a future session's work.

Verification: typecheck clean; H01-H03 canaries pass; T26 passes
(21.1s on KDE-W); T11_runtime spot-check passes. Pre-existing T16 /
T17 / T07 / S25 / S29-S31 flake is unchanged on the baseline (verified
by stashing the session-13 changes and re-running T16).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 23:56:47 -04:00
aaddrick
a8093a8e11 docs(testing): session 12 plan/inventory + rotate session 13 prompt
- runner-implementation-plan.md: session 12 status section (T11_runtime
  shipped, coverage 96% → 97%, dual-impl-object invocation pattern
  documented, full LocalPlugins/CustomPlugins method inventory). T11
  Tier 1 entry annotated with session-12 sibling reference. New
  "Primitive gaps" entry flagging the unified DOM/AX loading +
  traversal primitive proposal — user reports flake from tests not
  waiting long enough for DOM render; threshold for extraction is
  reached based on the 5+ AX-using specs each rolling their own
  retryUntil budget.
- README.md: T11_runtime row in inventory; eipc note extended with
  the cross-impl-object dual-invocation pattern; spec count 73 → 74.
- runner-implementation-followup-prompt.md: rotated for session 13.
  Carries forward the operon investigation, Tier 3 read-only reframe,
  and schema-rev categories; flags the DOM/AX loading-primitive build
  as the PRIORITY main bet (strictly higher impact than another
  reframe — flake reduction touches every existing AX-using spec).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 23:20:00 -04:00
aaddrick
23285d3d5a test(harness): session 12 T11 plugin install runtime (1 new spec, 96% → 97% coverage)
Tier 2 reframe of T11 (plugin install — Anthropic & Partners). Sibling
to the existing T11_plugin_install_fingerprint Tier 1 spec; promotes
from "install code path strings are in the bundle" to "install
handlers register at runtime AND read-sides across two impl objects
return the documented array shapes".

Five-suffix registration probe over the install-flow handlers:
- CustomPlugins/installPlugin (case-doc anchor index.js:507181)
- CustomPlugins/uninstallPlugin (lifecycle complement)
- CustomPlugins/updatePlugin (lifecycle complement)
- CustomPlugins/listInstalledPlugins (also invoked)
- LocalPlugins/getPlugins (also invoked)

Plus first cross-impl-object dual invocation:
- CustomPlugins/listInstalledPlugins([[]]) → array (drives Manage
  plugins panel — empty `egressAllowedDomains` per T33c pattern)
- LocalPlugins/getPlugins([]) → array (reads
  ~/.claude/plugins/installed_plugins.json per case-doc :465822)

Strictly stronger than single-interface dual invocation when the
case-doc surface spans two impl objects — proves the install
plumbing crosses both intact. Mixed-arg-shape (one needs [[]],
another []) follows session 11's mixed-shape pattern.

Smoke-test against the user's debugger-attached running Claude
surfaced the full LocalPlugins (15 methods) + CustomPlugins (16
methods) inventory; 9 read-sides invocable cleanly, 2 still-
rejecting candidates flagged for session 13 schema-rev
(listRemotePluginsPage limit, listSkillFiles pluginId+skillName).

Passes on KDE-W in 28.8s (cold). H01-H04 canaries stay clean.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 23:19:49 -04:00
aaddrick
22bd68d5b2 docs(testing): session 11 plan/inventory + rotate session 12 prompt
Plan-doc gets a new "Shipped session 11" status section above
session 10's. Captures the T21 spec landed (commit 3ea677f), the
cwd-validator-is-typeof-string finding, the 30-callable-Launch-
members observation (5 wrapper-only `on*` event subscribers + 2
proxies don't show in `_invokeHandlers`), and the dual case-doc-
anchored read-side invocation pattern (distinct from T19/T20's
foundational-surrogate shape).

README inventory adds T21 row, bumps spec count from 72 to 73 (35
T-tests now).

Followup prompt rotates for session 12 — T11 plugin install
runtime upgrade becomes the main bet (currently a Tier 1
fingerprint; LocalPlugins registers 15 handlers per session 7's
probe). Operon-mode navigation probe stays as the smaller-scope
fallback. Constraints / phases / self-correction loop sections
unchanged from sessions 10-11; the per-session section just
swaps in the new findings.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 23:02:28 -04:00
aaddrick
3ea677f563 test(harness): session 11 T21 dev server preview runtime (1 new spec, 95% → 96% coverage)
Tier 2 reframe of the T21 case-doc claim "dev server preview pane
starts on Preview → Start". First runtime probe for T21 — no
fingerprint sibling shipped (case-doc anchors point at impl-side
function names, not user-facing literals).

Multi-suffix `waitForEipcChannels` over five case-doc-anchored
Launch suffixes (`getConfiguredServices`, `startFromConfig`,
`stopServer`, `getAutoVerify`, `capturePreviewScreenshot`) plus
dual `invokeEipcChannel` on the case-doc-anchored read-side
getters: `getConfiguredServices(cwd)` returns array, `getAutoVerify(cwd)`
returns boolean. cwd validator is `typeof cwd === 'string'` only —
smoke-tested against the debugger-attached running Claude (session
11 finding); empty / relative / non-existent paths all pass, only
null / undefined / object wraps reject.

Different shape from T19 / T20: those use `LocalSessions/getAll` as
a foundational read-side surrogate because their case-doc anchors
are write-side. T21's case-doc anchors include native read-side
handlers, so invocation lands on case-doc-anchored handlers
directly (mirrors T33c's dual-handler pattern). Mixed-shape dual
invocation (one returns array, another returns boolean) is fine —
each shape asserted independently.

Read-only by design — neither `getConfiguredServices` nor
`getAutoVerify` spawns subprocesses, mutates fs, or performs
network egress. cwd is `process.cwd()` (the test process's own
working directory).

Passes on KDE-W in 16.7s (cold) / 5.2s (warm follow-up).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 23:02:17 -04:00
aaddrick
4c9a2ac951 docs(testing): session 10 plan/inventory + rotate session 11 prompt
- Plan-doc Status: session 10 sub-section (T19/T20 + Launch finding +
  operon partial answer + LocalSessions read-side enumeration).
- README inventory: T19/T20 rows; eipc primitive consumer lists
  (`waitForEipcChannels` and `invokeEipcChannel`) extended with T19/T20.
- Followup-prompt: session 11 candidates — Category A (T21 dev server
  preview, now tractable since Launch registers 25 handlers; needs cwd
  schema-rev), Category B (T11 plugin install runtime upgrade via
  LocalPlugins read-sides), Category C (operon-mode navigation probe).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 22:40:36 -04:00
aaddrick
cd1ad67f9a test(harness): session 10 T19/T20 runtime probes (2 new specs, 92% → 95% coverage)
T19 (integrated terminal) + T20 (file pane) ship as Tier 2 reframes —
multi-suffix `waitForEipcChannels` over the case-doc-anchored write-side
eipc surfaces (PTY trio + buffer + resize for T19; readSessionFile +
writeSessionFile + pickSessionFile for T20) plus a single
`invokeEipcChannel('LocalSessions_$_getAll', [])` array-shape assertion
as the foundational read-side surrogate.

Both surfaces bind to LocalSessions; getAll proves the LocalSessions
impl object — the same `A` reference all 117 LocalSessions handlers
close over — is reachable through the renderer wrapper. Strictly
stronger than registration alone, since a half-applied refactor where
the registration block runs but the impl object is missing methods
would pass registration-only and fail invocation.

Pass on KDE-W: T19 23.4s, T20 27.7s (~52.7s sequential).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 22:40:26 -04:00
aaddrick
8dd4a3229c docs(testing): session 9 plan/inventory + rotate session 10 prompt
Plan-doc Status section gains a session 9 block documenting the
schema-rev finding (hand-rolled positional validators on the two
CustomPlugins methods, byte offsets, minimal valid arg literal,
two impl variants), the dual-investigation pattern (bundle grep +
runtime closure inspection converged independently), and the
rejection-message-grep schema-rev shortcut for future sessions.

README inventory bumps to 70 specs, adds the T33c row, threads T33c
through the eipc-invoke consumer list and the seedFromHost
consumer list, and surfaces the validator-rejection-grep pattern
in the eipc note.

Followup-prompt rotated for session 10. Carries over the operon
scope question from session 8 and adds the Launch scope question
from session 9 (both "wrapper-exposed but registry-unconfirmed"
shape — feeds Category C). Promotes T19/T20 read-side reframes to
Category A (case-doc anchors at write-side handlers; read-side
equivalents need to be enumerated from the registry walker first).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 22:14:19 -04:00
aaddrick
6a3c8319e0 test(harness): session 9 T33c plugin browser invocation (1 new spec, 91% → 92% coverage)
Tier 2 invocation upgrade of T33b — calls both
`claude.web/CustomPlugins/{listMarketplaces, listAvailablePlugins}`
through the renderer-side wrapper at
`window['claude.web'].CustomPlugins.<method>` with `args = [[]]`
(empty `egressAllowedDomains`, omit optional `pluginContext`) and
asserts each response is an array. Strictly stronger than T33b's
registration-only check — proves the impls are wired through and
return the documented shape. Passes on KDE-W in 39.2s.

Schema-rev surfaced byte-identical hand-rolled positional validators
on both methods (bundle bytes 5013601 / 5018821): not Zod for args
(though Zod IS used for the result shape after the impl returns).
Required `string[]` for arg 0; empty array passes. Two impl variants
exist (CLI-shelling subprocess vs native file read); both return the
same array shape. Test budget 180s for worst-case sequential CLI
timeouts.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 22:14:09 -04:00
aaddrick
0bbb54d1b4 docs(testing): session 8 plan/inventory + rotate session 9 prompt
Updates the plan doc's "Status (post-execution)" section with the
session 8 findings:
- eipc invocation tractable via two paths (main-side direct call with
  synthesized event vs renderer-side wrapper); chose renderer-side
  for the primitive because it honors the per-handler origin gate
  honestly.
- mainView.js exposes 9 window['claude.*'] wrapper namespaces, more
  than the registry-side scope count — operon flagged for an
  exposure-vs-registration check before any operon spec lands.
- invokeEipcChannel API shape, T35b/T37b/T27 assertion shapes, and
  the renderer-eval string-error surface documented.
- session 8 prompt's :68820 le() reference flagged as off (le is at
  :5045138 in this build).

Updates README inventory table to add T27, T35b, T37b rows (now
69-spec inventory: 31 cross-env T-tests, 33 env-specific S-tests,
5 H-prefix harness self-tests). Updates the lib/eipc.ts substrate
description to mention invokeEipcChannel and its wrapper-path
explanation.

Rotates the followup prompt for session 9 — main bet is T33 Phase 2
(plugin browser invocation, blocked on egressAllowedDomains schema
reverse-engineering); fallback categories are T19/T20/T21 Code-tab
cluster and the operon scope exposure-vs-registration probe.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:50:15 -04:00
aaddrick
7ffd73add1 test(harness): session 8 runners + invokeEipcChannel primitive (3 new specs + 1 primitive extension, 87% → 91% coverage)
Adds three Tier 2 invocation probes — T35b / T37b paired with the
existing T35 / T37 Tier 1 fingerprints (session 4), plus T27 as the
case-doc Tier 2 reframe of "Scheduled task fires and notifies" (no
prior fingerprint sibling, mirrors T26's no-fingerprint shape). All
three call eipc handlers through the renderer-side wrapper at
\`window['claude.<scope>'].<Iface>.<method>\` and assert the
documented response shape:

- T35b — \`claude.settings/MCP/getMcpServersConfig\` returns a
  non-array object (Record<string, MCPServerConfig>).
- T37b — \`claude.web/CoworkMemory/readGlobalMemory\` returns
  \`string | null\`.
- T27 — both \`claude.web/CoworkScheduledTasks\` and
  \`claude.web/CCDScheduledTasks\` \`getAllScheduledTasks\` return
  arrays (parallel-scope assertion: Cowork = chat-side / Routines
  sidebar; CCD = Code-tab).

New \`invokeEipcChannel(inspector, suffix, args?, opts?)\` API on
\`lib/eipc.ts\` resolves the case-doc-anchored suffix through the
existing \`findEipcChannel\` walker, splits the full
\`<scope>_$_<iface>_$_<method>\` suffix to recover the wrapper path,
then calls through \`evalInRenderer('claude.ai',
"window['claude.<scope>'].<Iface>.<method>(...args)")\`. Renderer-
side rather than main-side direct-call because the per-handler
origin gates (\`le()\` / \`Vi()\` / \`mm()\` in the bundle) are
duck-typed structural checks that a fake event passes — but going
through the wrapper carries an honest \`senderFrame\` and aligns
test surface with real attack surface. Main-side direct call stays
available as a fallback for non-claude.ai webContents (no current
consumer).

Three parallel investigation subagents confirmed the gate semantics
empirically — see plan-doc session 8 status section for the
findings, the wrapper-namespace catalogue (9 \`window['claude.*']\`
namespaces), the \`mainView.js:792\`-onwards exposure-gate \`Qc()\`
behavior, and the operon-scope exposure-vs-registration question
flagged for session 9.

All three pass on KDE-W (Plasma 6 Wayland, XWayland) — T27 27.7s,
T35b 33.2s, T37b 25.8s, ~1.5m total sequential. \`npm run
typecheck\` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 21:49:51 -04:00
aaddrick
0daceb1e30 docs(testing): session 7 plan/inventory + rotate session 8 prompt
Documents the session 7 eipc-registry finding and the four T*b runtime
probes:

- Plan-doc Status section gains a session 7 entry covering the
  per-WebContents IPC scope discovery, the cross-route stickiness
  finding, the build-stable framing UUID, the 53-distinct-interface
  map, and the bonus interfaces (CoworkMemory, MCP, CoworkScheduledTasks,
  ClaudeCode) that unlock T35 Phase 2 / T37 Phase 2 / T27 Tier 2
  reframe / T19/T20/T21 cluster for next session.

- README inventory adds T22b/T31b/T33b/T38b rows + lib/eipc.ts to
  the lib/ tree + the substrate paragraph. The trailing "Note on
  eipc channels" gets rewritten to reflect the per-wc finding
  (sessions 2-6 had it wrong; the registry IS reachable, just
  on `webContents.ipc._invokeHandlers` not global ipcMain).

- Session 8 followup prompt rotated. Main bet for session 8: extend
  lib/eipc.ts with `invokeEipcChannel` to unlock T35 Phase 2 as the
  canary, then T37 Phase 2 / T27 reframe if budget. Three approach
  hypotheses pre-listed: renderer-side via evalInRenderer,
  direct main-side handler call with synthesized event, hook the
  dispatcher's invoke-side. Cap at 2-3 attempts before STOP AND
  REPORT (carry-over from session 5/6/7 self-correction loop).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 20:13:16 -04:00
aaddrick
b9697c2d1e test(harness): session 7 runners + eipc-registry primitive (4 new specs + 1 new primitive, 82% → 87% coverage)
Lands the eipc-registry exposer as Tier 2 runtime probe siblings of
session 3's Tier 1 fingerprints. Sessions 2-6 had marked the eipc
registry as closure-local — session 3 walked globalThis, found it
empty, and concluded the LocalSessions_$_* / CustomPlugins_$_* channels
weren't introspectable from main. Session 7 found the missing piece:
handlers DO go through Electron's stdlib IpcMainImpl, just on the
per-WebContents IPC scope (`webContents.ipc._invokeHandlers`,
Electron 17+) rather than the global ipcMain. Verified empirically
against a debugger-attached Claude — claude.ai webContents holds 490
handlers including all 117 LocalSessions + 16 CustomPlugins; global
ipcMain has the 3 chat-tab MCP-bridge handlers session 3 reported.

New primitive lib/eipc.ts (read-only by design):
- getEipcChannels — walks per-wc registries, filters by scope/iface
- findEipcChannel / findEipcChannels — case-doc-suffix lookup
- waitForEipcChannel / waitForEipcChannels — populate-on-init poll

Opaque on the $eipc_message$_<UUID>_$_ framing prefix (UUID has been
stable at c0eed8c9-… but the primitive doesn't pin it — match by
case-doc-anchored suffix).

Four new Tier 2 runtime probes paired with existing Tier 1 fingerprints
(T14a/T14b convention):
- T22b — LocalSessions_$_getPrChecks (PR monitoring)
- T31b — three-channel side-chat trio (load-bearing as a unit)
- T33b — two-channel plugin browser pair
- T38b — LocalSessions_$_openInEditor (Continue in IDE)

All four require seedFromHost (eipc handlers register on the claude.ai
webContents, which only exists post-login). Strictly stronger than
the bundle-string fingerprints — registry presence proves the upstream
code actually executed `e.ipc.handle(channel, fn)` during init, not
just that the constant is in the bundle.

All four pass on KDE-W (Plasma 6 Wayland, XWayland) — sequential
(workers: 1) at ~7.5s each, ~32s total.

Also adds tools/test-harness/eipc-registry-probe.ts as a re-runnable
read-only probe — connects to a debugger-attached Claude on port
9229, dumps per-wc IPC handler state with per-interface breakdown.
Useful when designing new probes or auditing for upstream drift.
Sibling of probe.ts (renderer-DOM) and grounding-probe.ts
(case-grounding).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 20:13:00 -04:00
aaddrick
e038768daa docs(testing): session 6 plan/inventory + rotate session 7 prompt
Plan-doc Status (post-execution): session 6 section added at top
covering S14 + lib/input-niri.ts ship + the cross-compositor-files-
not-dispatcher reasoning + Category B (eipc-registry exposer)
carrying over to session 7 unattempted.

Untested-on-real-Niri caveats explicitly documented (Ok-wrapper
schema version, Claude app_id literal value, foot-on-PATH) so the
first Niri-row sweep knows what to confirm without re-deriving the
recon.

README inventory updated to 62 specs (24 cross-env T-tests, 33
env-specific S-tests, 5 H-prefix harness self-tests). S14 row added;
lib/input-niri.ts entry added to the substrate-primitives layout
block and to the lib/ paragraph that lists each primitive's
consumer specs.

Followup prompt rewritten for session 7. Main bet now shifts to:

- A: eipc-registry exposer (now the cleanest single-session win
  available — sessions 3-6 each kept punting because lower-risk
  work was on the table; with the obvious focus-shifter / mock-
  then-call substrate work landed, Category A is the only path
  forward to proper Tier 2 runtime probes for T22/T31/T33/T38
  AND unblocks T35 Phase 2 / T37 Phase 2). Three approaches
  documented for the inspector walk: module-level grep for
  registry exposers, hook-the-eipc-registration-site, patch-in-
  a-dev-only-exposer.
- B: T35 Phase 2 / T37 Phase 2 paired with Category A. Skip
  unless A lands first.
- C: Single-spec deferred items audit (S20 still open on #569;
  T34 OAuth round-trip; T36 Phase 2 reclassified out;
  cross-compositor S14 variants speculative without a consumer).

New constraints from session 6 documented in the prompt:

- lib/input-niri.ts stays Niri-only by design — strict
  XDG_CURRENT_DESKTOP === 'niri' gate. Sway / Hyprland / River
  consumers must skip or live in their own per-compositor files.
- Don't speculate on a lib/input-wayland.ts dispatcher.
  Per-compositor files until a second Wayland consumer lands.

Cumulative "stop and report" outcome count bumped to ~13 across
sessions 1-6 (added: session-6 lib/input-niri.ts shipped untested-
on-niri).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 19:19:45 -04:00
aaddrick
34e9077dd2 test(harness): session 6 runner + niri-native focus-shifter primitive (1 new spec + 1 new primitive, 80% → 82% coverage)
Coverage 61/76 → 62/76. One new spec + one new primitive land. Per
session 5 recon, the niri IPC contract is stable in --json mode and
the API sketch in plan-doc was directly implementable.

New primitive (lib/input-niri.ts):

Wayland-native focus-shifter sibling of lib/input.ts. Niri-only by
design — strict XDG_CURRENT_DESKTOP === 'niri' gate via
isNiriSession(). Exports mirror the X11 sibling's shape:

- focusOtherWindow(title): three-step chain — niri msg --json windows
  → app_id !== 'Claude' filter + title match → niri msg action
  focus-window --id <u64> → honest readback via getFocusedWindowId()
  using retryUntil(3s/100ms). The readback is load-bearing: niri's
  focus-window action exits 0 even when the compositor refuses
  activation; only the focused-window IPC is the honest answer
  (mirrors lib/input.ts's xprop verification reasoning).
- spawnMarkerWindow(title): backgrounded foot --title <T> -e sleep
  600 with detached:false (matches lib/input.ts's xterm pattern —
  parent-death cleanup beats the marginal robustness of detached
  spawn). 500ms grace before SIGKILL fallback.
- getFocusedWindowId(): parses niri msg --json focused-window to
  number | null (niri u64 IDs are numeric, unlike X11's hex strings).
- isNiriSession(): pure XDG_CURRENT_DESKTOP env check.
- NiriIpcUnavailable / FootUnavailable typed errors for clean
  testInfo.skip() integration in consumers.

Defensive unwrapOk helper handles both the older
{Ok: {FocusedWindow: ...}} Result-style JSON envelope and newer
bare-payload responses; if a third niri version ships a different
shape, the parser falls through to null rather than crashing. The
app_id !== 'Claude' guard prevents the focus shift from accidentally
targeting Claude's own window.

Untested-on-real-Niri caveat: landed against session 5 recon notes,
not a live niri session. KDE-W typecheck + skip-via-row-gate confirms
the file is well-formed; the first real Niri sweep will confirm (a)
the Ok-wrapper unwrap covers the niri version on the row, (b)
Claude's literal app_id value is 'Claude', (c) foot is on the target
row's PATH.

Cross-compositor expansion deliberately not built — sway / hyprland /
river each have completely different IPCs and would each get their
own per-compositor file, not bolted into input-niri.ts. With S14 the
only consumer, a lib/input-wayland.ts dispatcher would be ceremony
(matches the threshold-driven extraction discipline of
lib/electron-mocks.ts and lib/input.ts).

New spec (S14):

S14 (Quick Entry shortcut fires from any focus on Niri) — Tier 2
known-failing detector. Near-clone of S11 with imports swapped to
lib/input-niri.js and the row gate flipped from ['GNOME-X', 'Ubu-X']
to ['Niri']. Same five-phase shape: setup → mainVisible ready →
foot marker spawn → focus loop with NiriIpcUnavailable /
FootUnavailable sticky-error short-circuits → Ctrl+Alt+Space press
+ assert popup.visible. Single-shot s14-diagnostics JSON attachment
mirrors S11's shape with activeWidBeforeFocus / activeWidAfterFocus
typed number | null per the niri u64 ID contract.

Currently a known-failing detector per case-doc S14 (Failed to call
BindShortcuts (error code 5) on Niri); same shape as S12's GNOME-W
--enable-features=GlobalShortcutsPortal detector — the spec encodes
the contract and will start passing on Niri rows once the upstream /
Chromium-side portal issue resolves, without any spec edit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 19:19:21 -04:00
aaddrick
88f3bd5941 docs(testing): session 5 plan/inventory + rotate session 6 prompt
Plan-doc Status (post-execution): session 5 section added at top
covering T18 ship + the SessionStart-hook-fires-on-prompt-submit
finding (which reclassified T36 Phase 2 Tier 2 → Tier 3/4) + the
runtime-probe AX-anchor capture for the Code-tab session opener
(saved without shipping a primitive — T36 Phase 2 was the only
known consumer and it just left Tier 2) + the niri msg IPC recon
verdict (TRACTABLE; lib/input-niri.ts API sketch in place).

Load-bearing finding — SessionStart hook timing:

Session 4's plan-doc framed T36 Phase 2 as needing "a Code-tab
session opener the AX-tree walker hasn't been taught" — implying
the AX tree was the only blocker. Session 5 traced the
SessionStart-hook fire path through bundled index.js and found a
deeper blocker: the hook fires inside the agent SDK process once
it boots, and the agent process is spawned only when there's a
prompt to bind to. Call chain: Ys.startSession (:454743 general,
:489371 CCD) requires A.message; the session record stores it as
initialMessage (:489270); the agent is spawned via
DN({ prompt: k, options: v }) (:489514) only when there's a prompt
stream to bind to. createOrResumeSession (:489208) creates the
session record but doesn't spawn the agent. Conclusion: clicking
"New session" alone navigates to a fresh composer but doesn't boot
the agent. The hook fires only after first prompt submission,
which is a real-account write. T36 Phase 2 unmockable without deep
agent-SDK reverse-engineering.

Code-tab session-opener AX surface verified — anchors saved in
plan-doc rather than shipped to claudeai.ts (premature without a
load-bearing consumer):

- Top-tab Code button: button[name="Code"] under group[Mode]
  under complementary. Disambiguator from the prompt-mode
  tab[name="Code"] in tablist[name="Prompt categories"] (which
  is what T16's existing CodeTab.activate() clicks).
- Sidebar entries (Code mode active): button[name="New session
  ⌘N"], button[name="Routines"], button[name="Customize"],
  button[name="More navigation items"], plus
  button[name="Pinned"] / button[name="Recents"] section
  headings.
- Recents items: button[name="<status> <title>"] where status ∈
  {Idle, Ready, Needs input, Awaiting input}. Main-pane Welcome
  surface uses button[name="Open session <title>"] — either
  anchor would work for an openExistingSession(re) consumer.
- URL of Code-tab landing: /epitaxy.

niri msg IPC recon — TRACTABLE:

Wiki contracts the --json output as stable; plain text is unstable.
niri msg --json windows returns Vec<Window> with {id, title,
app_id, pid, workspace_id, is_focused, ...}; niri msg action
focus-window --id <u64> injects focus; niri msg --json
focused-window is the honest readback (the equivalent of xprop
_NET_ACTIVE_WINDOW for the X11 primitive). foot --title <T> -e
sleep 600 is the wlroots-friendly marker. Cross-compositor
consideration: per-compositor files (lib/input-niri.ts,
lib/input-sway.ts, …) are cleaner than a unified abstraction —
sway / hyprland / river have totally different IPCs, a
lib/input-wayland.ts dispatcher would just be a 10-line switch.
libei is the long-term answer but isn't widely deployed; don't
block S14 on it.

Session 6 prompt rewritten. Three categories with the guidance to
pick ONE as the main bet:

- A: lib/input-niri.ts + S14 runner. Recon-sketched API, IPC
  contract is stable. Cleanest single-session win — single
  primitive build + single consumer ready to ship.
- B: eipc-registry exposer (unchanged from sessions 4 / 5;
  closure-local in main; reverse-engineering remains
  unattempted). Same warning: session 3's inspector walk came up
  empty; needs a fresh approach.
- C: Single-spec deferred items audit. T35 Phase 2 / T37 Phase 2
  still blocked on closure-local readback (skip unless paired
  with Category B); T36 Phase 2 NO LONGER A CANDIDATE.

New constraints from session 5 documented in the prompt:

- lib/input.ts stays X11-only by design; if Category A ships,
  the niri variant goes in lib/input-niri.ts (sibling, NOT a
  Wayland catch-all — sway/hyprland/river have totally different
  IPCs).
- Don't speculate on a lib/input-wayland.ts dispatcher.
  Per-compositor files until a second consumer (Sway / Hyprland /
  River row) lands.
- Code-tab AX anchors stay in plan-doc until a consumer needs
  them. Don't preemptively add CodeTab.activateTopTab() to
  claudeai.ts — T36 Phase 2 was the only consumer and it's now
  Tier 3/4. Premature abstraction is wrong abstraction.
- T36 hooks-fire-on-prompt-submit added to the destructive Tier 3
  list (alongside T22 PR write, T27 scheduling, T29 worktree,
  T34 OAuth) — only read-only reframes are in scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 18:49:36 -04:00
aaddrick
d5e1edc11b test(harness): session 5 runner + drag-drop bridge fingerprint (1 new spec, 79% → 80% coverage)
Coverage 60/76 → 61/76. One new spec lands. No new primitives —
session 5 ran light because the runtime probe + bundled-source
trace consumed half the budget (load-bearing finding documented in
the docs commit that follows).

New spec:

- T18 (Drag-and-drop files into prompt) — Tier 1 / asar fingerprint
  against bundled mainView.js (first runner to read a non-index.js
  source — lib/asar.ts's readAsarFile already supports it). Four
  needles pin the preload-bridged path-resolution wiring: the
  property key `getPathForFile` + the `webUtils.getPathForFile(`
  call (both at case-doc :9267 — count 2× combined), `webUtils`
  (1×, :9267), `filePickers` (1×, :9267), `claudeAppSettings` (1×,
  :9552 — the contextBridge.exposeInMainWorld namespace the
  renderer accesses as window.claudeAppSettings). Per-needle
  occurrence counts attached as JSON for drift detection (mirrors
  T36's pattern). Bundle form matches case-doc form verbatim — no
  minified-vs-beautified gotcha (unlike T35's
  ~/.claude.json → .claude.json).

Why Tier 1, not Tier 2/3:

A real OS-level drag-drop test needs to put file URIs on the
desktop's drag selection so Chromium's drop handler fires the
path-resolution bridge with a file payload. Both backends are
dead-ends with the primitives we have:

- X11: xdotool can simulate mouse motion + button press but
  cannot put file URIs on the X11 XDND selection. A simulated
  drag against a marker window arrives at Chromium as a mouse
  drag with no file payload — the bridge is never exercised. A
  real OS-level XDND test needs a custom XDND source app (heavy
  primitive build); deferred.
- Wayland: same shape — per-compositor IPC plus libei input
  injection. Same primitive gap.

Since the load-bearing surface is the bridge wiring (preload
expose + the webUtils.getPathForFile call), pinning the bundle
strings catches every regression that would matter to the
case-doc claim, without faking OS drag-drop. Same pattern as
T35/T36 from session 4: when Tier 2 readback isn't reachable,
ship the Tier 1 fingerprint against the actual load-bearing
strings.

README inventory updated to 61 specs (24 cross-env T-tests, 32
env-specific S-tests, 5 H-prefix harness self-tests). T18 row
added; the `app.asar content reads` footnote calls out that T18
reads mainView.js (every other asar-fingerprint runner reads
index.js).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-03 18:48:52 -04:00
aaddrick
9e561c0c49 docs(testing): session 4 plan/inventory + rotate session 5 prompt
Plan-doc Status (post-execution): session 4 section added at top
covering T35 / T36 / S11 ship + S14 primitive-gap deferral + the
lib/input.ts X11-only-by-design reasoning + the eipc-registry
exposer carrying over to session 5 unattempted.

Followup prompt rewritten for session 5. Three categories with the
guidance to pick ONE as the main bet:

- A: eipc-registry exposer (reverse-engineer the closure-local
  registry near :68816-:68820; high-risk-high-reward; would unblock
  T22/T31/T33/T38 Tier 2 runtime probes — currently Tier 1
  fingerprints).
- B: Code-tab session opener primitive in claudeai.ts (would unblock
  T11/T19/T20/T31/T32 full forms + T36 Phase 2 + T37 Phase 2). AX-
  tree teaching work; potentially multi-session.
- C: Single-spec deferred items audit (T18 X11 drag-drop, S14
  Wayland variant exploration, S20 once #569 lands).

New constraints from session 4 documented in the prompt:

- lib/input.ts is X11-only — strict XDG_SESSION_TYPE === 'x11' gate.
  Wayland-native focus injection goes in a sibling file, not bolted
  into the existing one.
- Always grep the installed asar before settling on a fingerprint
  string; case-doc text is sometimes the user-facing form (e.g.
  ~/.claude.json) not the bundle form (.claude.json — minified
  strips the path-prefix style and resolves home at use).
- Marker windows / sacrificial host processes always die in finally
  (S11 is the template).
- Single-shot diagnostic JSON dump (S11 / S31 pattern) cleaner than
  many separate testInfo.attach() calls for multi-state tests.

New termination condition: if Category A's inspector walk turns up
empty after 2-3 distinct approaches, STOP — document the dead-end
as a finding, ship a documentation runner if it surfaces useful
state, pivot to B or C.

Cumulative "stop and report" outcome count bumped to ~10 across
sessions 1-4 (added: S14 primitive-gap, T35 Phase 2 deferral, T36
Phase 2 deferral).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 18:15:12 -04:00
aaddrick
aa139be763 test(harness): session 4 runners + focus-shifter primitive (3 new specs, 75% → 79% coverage)
Coverage 57/76 → 60/76. Three new specs land plus one new primitive
(lib/input.ts focus-shifter). One case-doc spec (S14) explicitly NOT
shipped — documented as primitive-gap.

New specs:

- T35 (MCP server config picked up) — Tier 1 / Phase 1 fingerprint:
  four-needle asar probe pinning chat-tab vs Code-tab MCP separation
  (claude_desktop_config.json chat-tab path + .claude.json + .mcp.json
  Code-tab loaders + "user","project","local" settingSources triple
  Code-session passes to the agent SDK). Case-doc anchors :130821 /
  :176766 / :215418 / :489098. Phase 2 (fixture-then-readback)
  deferred — parsed MCP server state is closure-local, same blocker
  as T37b/S19/S28.
- T36 (Hooks fire) — Tier 1 / Phase 1 fingerprint: five-needle asar
  probe in T37's "single-occurrence high-signal anchor + registry
  tokens" shape — hook_started / hook_progress / hook_response (each
  1× at :493411, Verbose-transcript runtime emits) plus PreToolUse
  (17×, :455717) and UserPromptSubmit (4×, :455819) registry tokens.
  Per-needle occurrence counts attached for drift detection. Phase 2
  (settings.json fixture + Code-session marker readback) deferred —
  needs login + a Code-tab session opener the AX-tree walker hasn't
  been taught.
- S11 (Quick Entry shortcut from any focus) — Tier 2: spawn xterm
  marker via lib/input.ts:spawnMarkerWindow, focus it via
  focusOtherWindow (xdotool windowfocus + xprop _NET_ACTIVE_WINDOW
  verification), then fire Ctrl+Alt+Space via ydotool and assert
  popup is visible. Single-shot s11-diagnostics JSON attachment
  collects sessionEnv / markerTitle / active-WID before+after /
  popupState / openError / launcher-log tail. Marker xterm killed in
  finally before app.close.

Row-gate decision (load-bearing for S11):

S11's case-doc applies-to is "GNOME, Ubu" (W and X variants), but
the focus-shifter primitive is X11-only — strict
XDG_SESSION_TYPE === 'x11' gate. So the runner's row gate is
['GNOME-X', 'Ubu-X'] only. The case-doc's load-bearing concern is
the GNOME-W mutter XWayland key-grab regression (#404); that
regression CANNOT be detected here because there's no portable
focus-injection on native Wayland (each compositor exposes its own
IPC; libei isn't universally honored). What S11 catches: a
regression in the X11 path of the global shortcut on GNOME-X /
Ubu-X — a currently-passing detector unlike S12 which is
currently-failing.

S14 NOT shipped — primitive gap:

S14's only row gate is Niri (wlroots Wayland with no XWayland), so
the focus-shifter primitive throws WaylandFocusUnavailable there;
any S14 runner consuming the new primitive would skip on every row
in its gate — the definition of a stub. Per "don't ship stubs",
S14 stays unshipped and is documented as needing Wayland-native
focus injection (Niri's `niri msg` IPC, or libei when broadly
available). The Tier 1 reframe (assert
--enable-features=GlobalShortcutsPortal in argv) is already covered
by S12.

New primitive (lib/input.ts):

X11-only by design. Strict XDG_SESSION_TYPE === 'x11' gate via
isX11Session() — single source of truth. xdotool windowfocus exits
0 even when the compositor refuses activation, so post-focus
verification via xprop _NET_ACTIVE_WINDOW readback is the honest
answer. Exports:

- WaylandFocusUnavailable / XdotoolUnavailable (typed errors so
  consumers can `instanceof` skip vs fail).
- isX11Session() — single-source-of-truth env check.
- getFocusedWindowId() — parses xprop output to lowercase
  0x-prefixed hex; returns null on Wayland or xprop failure.
- focusOtherWindow(title) — xdotool search --name + windowfocus,
  then retryUntil-poll _NET_ACTIVE_WINDOW for ~3s budget; throws
  on compositor refusal so S11/S14 see refusals as real failures
  rather than silent skips.
- spawnMarkerWindow(title) — backgrounded `xterm -e 'sleep 600'`
  with kill-with-grace lifecycle (SIGTERM + 500ms grace + SIGKILL
  fallback). Caller owns kill in finally.
- MarkerWindow interface for the spawn return shape.

Wayland-native focus injection is intentionally NOT in this file —
sibling file (lib/input-niri.ts or libei layer) when needed.

KDE-W: T35 ✓ pass (182ms), T36 ✓ pass (112ms), S11 ⊘ skipped
(row mismatch — KDE-W not in [GNOME-X, Ubu-X], expected).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 18:14:49 -04:00
aaddrick
ee7b35ff86 docs(testing): session 3 plan/inventory + rotate session 4 prompt
Updates the post-execution status section with session 3's seven
shipped specs, the eipc-registry finding (corrects session 2's T38
assumption), and the four reclassifications (T22/T31/T33/T38 from
Tier 2 IPC probes to Tier 1 fingerprints). Captures the
authentication-state lesson too — launches that depend on
authenticated renderer state need createIsolation({ seedFromHost:
true }), even if the case-doc-shaped Tier 2 form looks hermetic on
paper.

README inventory grows from 50 to 57 specs and adds a note that
LocalSessions_$_* / CustomPlugins_$_* channels use a custom eipc
protocol, not Electron's standard ipcMain.handle() — so future
runners should anchor on channel-name strings (Tier 1) rather than
introspect _invokeHandlers (broken).

Followup prompt rewritten for session 4: focus-shifter primitive +
S11/S14, T35 MCP separation fingerprints (Phase 1) and optional
fixture-readback (Phase 2, may abort), and the eipc-registry
exposer as a flagged primitive gap.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 17:40:33 -04:00
aaddrick
549bf4281a test(harness): session 3 runners (7 new specs, 66% → 75% coverage)
Coverage 50/76 → 57/76. Seven new specs land + one session-2 carryover
(T38) reclassified after the eipc-registry finding below.

New specs:

- T22 (PR monitoring) — Tier 1 fingerprint: LocalSessions_$_getPrChecks
  eipc channel name + "gh CLI not found in PATH" Linux-fallthrough
  throw site (case-doc anchors :464281 / :464964 / :464368).
- T24 (Open in editor) — Tier 2 mock-then-call: installOpenExternalMock
  patches shell.openExternal from main, evalInMain calls it with a
  vscode://file/... URL, assert recorded call lists URL verbatim. No
  real editor launch (mock returns Promise<boolean>).
- T30 (Auto-archive cadence) — Tier 1 fingerprint: single regex
  anchoring 300*1e3 ≤ 3600*1e3 ≤ AutoArchiveEngine in colocation
  (≤200 / ≤3000 char proximity windows tuned to current bundle), plus
  ccAutoArchiveOnPrClose .includes() inside the captured window.
- T31 (Side chat) — Tier 1 fingerprint: side-chat eipc trio
  (startSideChat / sendSideChatMessage / stopSideChat).
- T32 (Slash menu) — Tier 1 fingerprint:
  LocalSessions_$_getSupportedCommands + slashCommands schema.
- T33 (Plugin browser) — Tier 1 fingerprint:
  CustomPlugins_$_listMarketplaces + listAvailablePlugins.
- T37 (CLAUDE.md memory) — Tier 1 fingerprint: high-signal
  "[GlobalMemory] Copied CLAUDE.md" log line + CLAUDE.md filename +
  CLAUDE_CONFIG_DIR env-var token. Fixture-readback form deferred —
  parsed-memory state is closure-local.

eipc-registry finding (T38 reclassification):

Session 2's T38 used ipcMain._invokeHandlers introspection. KDE-W run
revealed that registry holds only three chat-tab MCP-bridge handlers
(list-mcp-servers, connect-to-mcp-server, request-open-mcp-settings)
regardless of ready level (mainVisible / claudeAi / userLoaded) and
regardless of authentication state (default isolation vs.
seedFromHost: true verified via probe). The
$eipc_message$_<UUID>_$_claude.web_$_<name> protocol uses a closure-
local message-port registry not reachable from globalThis — same
gotcha as session 2's Sbn() (S28) and cE()/Tce() (S19).

T38 rewritten as a Tier 1 asar fingerprint anchoring on the
LocalSessions_$_openInEditor channel-name string in the bundle. T22,
T31, T33 (originally drafted with the same broken pattern) ship as
Tier 1 fingerprints from the start. T24 is unaffected — it patches
the stdlib Electron shell module from main, not the eipc layer.

KDE-W: 9/9 pass in 18.2s (7 new + T25 verifying the lib import-extract
didn't break it + T38 reclassified).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 17:40:21 -04:00
aaddrick
ce2e5325d3 refactor(harness): extract electron-mocks.ts once T24 lands the third helper
Session 3 brings the third mock-then-call helper online
(installOpenExternalMock for shell.openExternal, mirroring
installShowItemInFolderMock and installOpenDialogMock). Threshold from
the session prompt was met — pull the three install/get pairs out of
lib/claudeai.ts into a dedicated lib/electron-mocks.ts. The mocks are
generic Electron module patches (dialog, shell), not claude.ai-domain,
so the new home keeps claudeai.ts focused on AX-tree page-objects.

T17, T25 imports updated to point at the new module. T24 (added in the
follow-up commit) imports from electron-mocks.ts directly.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 17:39:50 -04:00
aaddrick
86385848d0 docs(testing): session 2 plan/inventory + rotate session 3 prompt
- runner-implementation-plan.md: new "Status (post-execution)" sub-
  section for session 2 listing the 10 new specs and the four
  reclassification notes (S28 → Tier 1, T38 framing, T23 tool choice,
  S19 honest-stub note). Session 1 sub-section preserved verbatim
  below for comparison.
- README.md: 50-spec inventory (was 40), new T-rows (T10, T16, T23,
  T25, T26, T38) and S-rows (S10, S19, S25, S28) interleaved into
  the existing tables. Substrate-primitives paragraph extended with
  dbus-monitor, mock-then-call, ipcMain registry introspection,
  safeStorage round-trip, extraEnv precedence.
- runner-implementation-followup-prompt.md: rewritten for session 3
  — deferred items (T31, T32, S06, S11, S14), Tier 3 → Tier 2
  reframes (T22, T35, T37), asar fingerprint cleanups (T24, T30,
  T33), the focus-shifter primitive build, and the mock-then-call
  extension for T24 as an alternative to its asar form. Includes
  the "known mechanism-recipe table" cumulating sessions 1+2.
- runner-implementation-prompt.md: deleted (session 1's prompt,
  superseded by the followup that's been the rolling document
  since session 1 ended).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 17:01:55 -04:00
aaddrick
fb5189fe45 test(harness): session 2 runners (10 new specs, 53% → 66% coverage)
Categories landed:
- B (seedFromHost-unlocked): T16 (Code tab loads), T26 (Routines page
  renders) — both promote Tier 3 → Tier 2 via the seedFromHost
  primitive shipped in session 1.
- A (Tier 2 single-launch deferred from session 1): T10 (Cowork daemon
  respawn after SIGKILL), S10 (KDE-W Quick Entry popup transparent),
  S25 (safeStorage round-trip across two launches with shared
  isolation handle).
- C (Tier 2 reframes): T23 (Notification reaches DBus via dbus-monitor
  subprocess), T25 (shell.showItemInFolder via mock-then-call —
  mirrors T17's installOpenDialogMock), T38 (openInEditor IPC handler
  registered probe via ipcMain._invokeHandlers), S19
  (CLAUDE_CONFIG_DIR extraEnv reaches main process).
- Tier 1 reclass: S28 (worktree permission classifier asar fingerprint
  — Sbn() is closure-local, not inspector-reachable).

Mechanism notes — see plan doc status section for full rationale:
- T23 uses dbus-monitor not gdbus monitor (the latter only sees
  signals owned by a destination, not method calls to it).
- T38 inspects ipcMain._invokeHandlers for handler registration; the
  channel ends in $eipc_message$_<UUID>_$_claude.web_$_<name> with a
  build-stable UUID prefix — anchors on the suffix.
- T25 mock-then-call beats invoke-then-cleanup (no host file manager
  pop-up, stronger assertion).
- S25 compares decrypted plaintexts not ciphertexts (safeStorage on
  Linux uses random IVs).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 17:01:42 -04:00
aaddrick
1f5702bc7b test(harness): add installShowItemInFolderMock for mock-then-call probes
Mirrors lib/claudeai.ts:installOpenDialogMock (used by T17). Replaces
electron.shell.showItemInFolder with a recording mock so Tier 2
reframe specs can assert "the IPC layer reaches the egress with the
right path" without firing the real DBus FileManager1 / xdg-open
dispatch on the host.

Idempotent (guarded by globalThis.__claudeAiShowItemMockInstalled),
matches the existing mock helper's call-recording shape, exports a
companion getShowItemInFolderCalls reader. Used by the rewritten T25
runner in the next commit.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 17:01:17 -04:00
aaddrick
11ab62afcd test(harness): Tier 2 runners (9 single-launch / hermetic-auth probes)
Single launchClaude() + inspector + Electron-API or window-state
assertion. Each runner asserts a contract that requires the app to
actually be running.

Specs landed:

- T05 — claude:// URL delivers via app.on('second-instance')
  (Tier 3 delivery probe: xdg-open fires the URL, the running app's
  hook captures it). Uses isolation: null because the SingletonLock
  collision must route to the same user-data dir.
- T06 — globalShortcut.isRegistered('Ctrl+Alt+Space') returns true
  after waitForReady('mainVisible')
- T07 — five topbar buttons render with non-zero rects. First spec
  to exercise createIsolation({ seedFromHost: true }) — kills host
  Claude, copies auth allowlist (Cookies, Local State, Local Storage,
  IndexedDB, etc.) into per-test tmpdir, runs hermetically against
  signed-in account, tmpdir destroyed on close.
- T08 — MainWindow.setState('close') fires the wrapper's close
  interceptor; window hidden, proc still alive
- T09 — setLoginItemSettings({ openAtLogin }) writes/removes
  $XDG_CONFIG_HOME/autostart/claude-desktop.desktop
- T12 — app.getGPUFeatureStatus() returns populated object;
  reaching mainVisible proves the renderer didn't crash
- T14b — second invocation under same isolation exits cleanly via
  requestSingleInstanceLock early-return; primary pid stays alive
- S07 — under CLAUDE_HARNESS_USE_WAYLAND=1, spawned Electron has
  --ozone-platform=wayland on argv (skips when env unset)
- S17 — shell-path-worker overlays the user's login-shell PATH onto
  a deliberately-scrubbed env. Re-forks shellPathWorker.js via
  utilityProcess.fork + MessageChannelMain to observe the worker
  output directly (the main-process FX() merger only fills undefined
  keys, so reading process.env.PATH after a non-undefined override
  wouldn't observe the effect).

T05 originally planned as a Tier 2 isDefaultProtocolClient probe
but reshaped — that runtime call is a no-op in the harness because
ELECTRON_FORCE_IS_PACKAGED=true makes app.getName() resolve to
"Claude" (not "claude-desktop"), so the xdg-mime shellout fails
silently. Real registration is install-time via the .desktop file
MimeType= line. T05 ships as the delivery probe instead.

T07 originally deferred to Tier 3 ("topbar is React-rendered SPA")
but the harness's seedFromHost primitive (isolation.ts:37-44, never
exercised before this commit) lifts it back to Tier 2.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 14:42:32 -04:00
aaddrick
bebe83d194 test(harness): Tier 1 runners (16 file/spawn/argv probes)
Each runner is independent of the others and matches one case-doc
test ID. Pure file probes (asar fingerprints, source-tree grep) and
short-lived spawn probes; no app launch needed.

Specs landed:

- T02 — claude-desktop --doctor exit code is 0
- T11 — plugin install code path fingerprints (installPlugin log,
  installed_plugins.json) present in bundled index.js
- T13 — --doctor does not false-flag rpm/deb installs as
  missing-dpkg AppImage
- T14a — requestSingleInstanceLock + 'second-instance' strings in
  bundle (T14b runtime probe lands separately)
- S01 — AppImage launches without libfuse.so.2 complaint (skips
  cleanly on non-AppImage rows)
- S02 — no strict == equality against XDG_CURRENT_DESKTOP in
  launcher / patches (regression detector)
- S03 — dpkg-query Depends: field non-empty (currently fails as
  upstream-contract regression detector — deb.sh:185-197 emits no
  Depends: line)
- S04 — rpm -qR has at least one non-rpmlib(...) requirement
  (currently fails — rpm.sh:188 has AutoReqProv: no, no manual
  Requires:)
- S05 — doctor does not false-flag rpm-installed package
- S08 — KDE tray-rebuild fast-path (.setImage(...createFromPath...))
  injected by tray.sh:212-217
- S15 — AppImage --appimage-extract fallback exits 0; squashfs-root/
  AppRun --version runs without FUSE error
- S16 — AppImage mount(8) entry appears post-launch and clears
  within ~10s of close
- S21 — no handle-lid-switch / HandleLidSwitch strings in bundle
  (lid policy deferred to OS)
- S22 — new Set(["darwin","win32"]) computer-use platform gate
  present, no 2-element Set pairing linux (file-probe form)
- S26 — setFeedURL present + project suppression marker absent
  (currently fails — gated on #567 auto-update suppression patch)
- S27 — installed_plugins.json + homedir resolver present, no
  */plugins system paths in bundle

Three specs are intentional regression detectors — they ship "red"
today (S03, S04, S26) because the upstream contract isn't yet met.
Each error message names the upstream defect or issue so matrix-regen
surfaces them as actionable cells.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 14:42:04 -04:00
aaddrick
61245bcc81 test(harness): scaffolding for Tier 1/2 runner batch
- runDoctor() now returns {output, exitCode} so T02/T13/S05 can
  assert against the doctor exit code (was string-only, swallowed
  the code).
- MainWindow.setState() accepts 'close' and calls win.close() so T08
  exercises frame-fix-wrapper.js:178-185 (the close-to-tray
  interceptor) — distinct from 'hide' which would bypass the
  wrapper.
- Add docs/testing/runner-implementation-plan.md: tiered triage of
  the 61 missing runners with execution-time reclassifications
  (T05 → Tier 3 delivery, T07 → Tier 2 via seedFromHost, T14 split
  into a/b, S20 deferred via #569).
- Refresh T13/S05 case-doc anchors: scripts/doctor.sh:290-299 →
  :353-362 (file edited since the anchor was written).
- Update test-harness README status to reflect the post-batch spec
  inventory and link to the plan doc.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 14:41:35 -04:00
aaddrick
2ca35610ec docs(testing): runner-implementation prompt for next session
Counterpart to docs/testing/cases-grounding-prompt.md — a fan-out
prompt for the workstream of wiring runners against the 61 of 76
tests that don't have one yet.

Structured the same way as the grounding prompt: Phase 0 calibration,
Phase 1 triage subagent producing a tiered plan
(docs/testing/runner-implementation-plan.md), Phase 2/3 fan-out per
test in Tiers 1-2, Phase 4 synthesis. Tier 3 (renderer-heavy /
login-required) deferred to follow-up sessions; Tier 4 (CLI binary,
issue-gated, env-blocked) marked out of scope with reasons.

Constraints flag the known landmines: CDP gate workaround, the
BrowserWindow Proxy gotcha, default isolation + escape hatches,
ydotool prereqs, skipUnlessRow as the first line of every spec.
"Don't ship stubs" called out explicitly so a session that hits a
blocker reports it instead of leaving placeholder runners that pass
trivially.

Realistic next-session goal: 13-16 new runners (Tier 1 + as much
Tier 2 as fits), bumping coverage from 15/76 (20%) to ~30/76 (40%).
Future sessions handle the renderer-heavy Tier 3 once they have a
session-time budget and host claude.ai login.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 08:13:04 -04:00
aaddrick
4d29cf83fa docs(testing): document grounding sweep workflow + probe + Wayland mode
The action items from the last few sessions (case-doc grounding,
runtime probe, autoUpdater issue, Wayland-mode runs) needed pointers
across the testing docs so the next contributor isn't reverse-
engineering them from git log.

- docs/testing/README.md — bump date, surface grounding sweep + probe
  in the automation-status section, fix the test corpus snapshot
  (S-tests went from 28 to 37 since this was last counted).
- docs/testing/runbook.md — add "Grounding sweep" section (static
  pass + runtime pass) alongside the existing test sweep, document
  the Wayland-mode sweep recipe, link upstream-bump trigger to it.
- tools/test-harness/README.md — add grounding-probe.ts to the
  layout, a Run-section recipe, and a dedicated "Grounding probe"
  section explaining when to reach for it vs the static grep.
- docs/testing/cases/distribution.md — link S26 to issue #567
  (autoUpdater no-op tracking), now that the bug is filed.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 08:08:23 -04:00
aaddrick
af3c31b511 test(harness): CLAUDE_HARNESS_USE_WAYLAND for full-suite native Wayland runs
Adds a top-level harness flag that flips every launchClaude() spawn from
the default X11-via-XWayland backend to native Wayland, so the full
suite can run under Wayland with a single env var instead of per-spec
plumbing.

Implementation mirrors scripts/launcher-common.sh:132-139:
- Renames LAUNCHER_INJECTED_FLAGS to LAUNCHER_INJECTED_FLAGS_X11 and
  adds LAUNCHER_INJECTED_FLAGS_WAYLAND with the launcher's Wayland
  flag set (UseOzonePlatform, WaylandWindowDecorations, ozone-platform,
  wayland-ime, wayland-text-input-version=3).
- harnessUseWayland() reads CLAUDE_HARNESS_USE_WAYLAND.
- launchClaude() picks the flag set, adds CLAUDE_USE_WAYLAND=1 and
  GDK_BACKEND=wayland to the spawn env. Spread order keeps caller-
  supplied extraEnv winning, so a single test can still opt back to X11
  inside a Wayland-mode sweep.
- sweep.sh advertises the mode on stderr.
- README documents the var + the npm-test recipe.

Default unchanged: every runner still gets X11. The flag opts in.

Verification (live): CLAUDE_HARNESS_USE_WAYLAND=1 npx playwright test
src/runners/T17_folder_picker.spec.ts, then while the app is up confirm
--ozone-platform=wayland is on argv via /proc/<pid>/cmdline. The
harness spawns Electron directly (CDP-gate workaround at electron.ts:
102), so launcher-common.sh isn't sourced and ~/.cache/claude-desktop-
debian/launcher.log is not written by harness runs.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 08:02:27 -04:00
aaddrick
b3baa8ad8f docs(testing): extend case-doc template with anchor + drift conventions
Folds the conventions the grounding sweep landed into the README so
future authors and sweeps work from the same shape. Adds:

- **Code anchors:** field — `<file>:<line>` pointers to where the
  load-bearing claim is implemented.
- **Inventory anchor:** field — optional, for surfaces present in
  the v7 walker's idle capture.
- "Anchor scope" section codifying the four buckets (upstream code,
  wrapper, server-rendered SPA, CLI binary) and where to anchor each.
- "Drift markers" section codifying the Drifted / Missing / Ambiguous
  classifications the sweep already uses.

No content changes to existing case files — they already follow these
conventions in practice; the README now documents them.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 08:00:56 -04:00
aaddrick
ade75d748d docs(testing): drop branch-divergence caveats from T07/S13 anchors
Branch was rebased onto main; scripts/wco-shim.js + scripts/patches/
wco-shim.sh are now on this branch via PR #538. The "lives on main, not
yet on docs/compat-matrix" notes the grounding subagent added are no
longer accurate — anchors point at files that exist locally.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:57:50 -04:00
aaddrick
66d390ccec test(harness): grounding-probe round 2 — AX fingerprint, editor channels, SNI
Closes the bulk of the remaining gaps from the last cut:

- AX fingerprint of the current claude.ai webContents (role+name+
  hasPopup, reduced form). Stored once at the top level; per-test
  entries for T22/T26/T31/T32 reference it via { axFingerprintRef }.
  Captures whatever surface is on screen at probe time, so the user
  opens the slash menu / side chat / routines modal / PR toolbar
  before re-running to anchor those surfaces.

- Editor handoff IPC channels (T24/T38). Static anchor is `Mtt` at
  index.js:463902 — variable name is minified, so we match handlers
  by /external|editor|openIn/i name pattern instead. Sufficient to
  diff across upstream versions (renames will surface as removed
  channels with similar replacements).

- SNI / tray registration (T03). `findItemByPid()` from sni.ts attribu-
  tes a registered StatusNotifierItem to our pid. dbus-next is loaded
  via dynamic import so non-DBus environments (CI containers without a
  session bus) still get a partial probe rather than a hard fail.

Reduced gaps[] to just T39 (CLI surface, out-of-scope) and the
optional opt-outs (powerSaveBlocker without --include-synthetic;
empty AX fingerprint when claude.ai isn't loaded yet).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
5957c8212b test(harness): grounding-probe --launch + synthetic powerSaveBlocker
Two extensions to the grounding probe, each closing a gap I flagged on
the first cut:

- --launch: spins up a fresh isolated instance via launchClaude(),
  waits for 'mainVisible' (cheapest level that returns the inspector),
  captures, tears down. Default still attaches to an already-running
  app on port 9229; --launch is the self-contained / CI-usable path.

- --include-synthetic + S20 powerSaveBlocker probe: starts a blocker,
  reads isStarted, stops immediately. Brief inhibit (~ms). Read-only by
  default — synthetic state changes are opt-in. Doesn't verify the
  case-doc claim that keepAwakeEnabled toggles trigger this; that needs
  correlating settings IO with the `PhA` Set at index.js:241897, which
  depends on minified-name stability. Left to the next sweep.

Argv parser rewritten to handle bare flags (--launch, --include-synthetic)
alongside key/value pairs (--port 9229, --out PATH).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
cb20fde797 test(harness): add grounding-probe for runtime case verification
Static greps against the 546k-line beautified bundle have known blind
spots — lazy require()s, dynamic handler tables, conditional wiring.
This probe connects to a running Claude Desktop via the existing
InspectorClient (port 9229, opened by launchClaude's SIGUSR1 path) and
dumps runtime state keyed by test-ID into a JSON the next grounding
sweep can diff across upstream versions.

Captures:
- App metadata (version, isPackaged, ready state)
- Full IPC handler registry (invoke + on channels)
- WebContents inventory (URLs, types)
- globalShortcut.isRegistered() for known accelerators
- app.getLoginItemSettings() (autostart resolution)
- safeStorage availability + backend (libsecret on Linux)
- autoUpdater.getFeedURL() — empirical answer to the S26 structural-
  open claim that static analysis couldn't resolve
- Notification.isSupported()

Read-only / non-destructive; observes API state, never clicks UI or
fires shortcuts. Records explicit gaps[] for surfaces it can't reach
from idle (S20 powerSaveBlocker enumeration; T22/T31/T32 contextual
renderer surfaces; T39 CLI binary).

Run: cd tools/test-harness && npm run grounding-probe
Output: /tmp/grounding-probe.json (override with --out PATH)

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
c76f7e62da docs(testing): ground cases against build-reference v1.5354.0
Static anchor sweep: each test in docs/testing/cases/*.md now points at
the upstream code (or wrapper script) backing its load-bearing claim,
so the next sweep can tell "Linux compat regression" apart from "case
doc drifted while we weren't looking."

- 75 tests across 10 files reviewed
- 63 grounded with code anchors (index.js:N, scripts/*.sh:N)
- 9 drifted Steps/Expected corrected against actual upstream behavior
- 2 marked Missing in build (S12 Wayland portal flag, S26 auto-update)
- 1 flagged Ambiguous (T39 /desktop is a CLI surface, not Electron asar)

Notable corrections:
- T05: scheme is claude://, not https:// (project never registers
  x-scheme-handler/https; old spec was always going to fail on Linux)
- T15: sign-in is in-app loadURL into mainView, not xdg-open handoff
- T18: drag-attach uses webUtils.getPathForFile, not text/uri-list MIME
- T20: file conflict check is sha256-based, not mtime-based
- T22: gh-install path is macOS/brew-only on Linux/Windows
- T30: PR-close auto-archive wait is ~5-6 min (5m setInterval + 30s
  startup + 1h non-terminal cooldown), not "~1 minute"
- T14: PR #536 is closed/docs-only — no in-tree multi-instance flag

Inventory anchors added for renderer-side surfaces present in the
idle-state v7 capture (T16 Code tab, T17 select-folder, T26 Routines,
T11/T33 plugin nav). Surfaces inside modals/popups (T22 toolbar, T25
Show-in-Files context menu, T31 side chat, T32 slash menu) are flagged
for re-capture with the surface open.

S26 finding worth follow-up: autoUpdater gate is structurally open on
Linux when packaged (lii() at index.js:508761-508774 returns true with
ELECTRON_FORCE_IS_PACKAGED=true from launcher-common.sh:249) — saved
from real download attempts only by Electron's Linux autoUpdater being
unimplemented.

T07/S13 reference WCO-shim files that exist on main (PR #538 merged
2026-05-01) but not on this branch (docs/compat-matrix forked earlier);
anchors point at main: with explicit caveats.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
5ae25247ef docs(testing): queue cases grounding sweep against build-reference
Adds the implementation prompt for the next session: spawn one
subagent per file in docs/testing/cases/, have each one cross-check
its tests against the extracted Claude Desktop source under
build-reference/app-extracted/, and edit in place to add code
anchors / mark drift / flag missing features. Mirrors the
structure of the already-retired claudeai-lib-ax-migration-prompt.md
so the workflow is consistent.

Triggered by the AX migration validation surfacing how easily case
docs drift from upstream — the test author's "click X menu" can
silently diverge from upstream's actual labels two versions later,
and the failure looks like a Linux compat issue when it's really a
doc-vs-source drift.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
e13660993b test(harness): drop auto-generated U01 sweep spec
The 90-test U01 sweep was wired against an account-specific v7
inventory snapshot; running it during routine sweeps fired noise
against unrelated drift. The spec is auto-generated from the v7
inventory via npm run gen:render-specs, so this is a soft delete —
regenerate any time a fresh inventory walk lands.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
7715952c3f test(harness): migrate claudeai.ts page-objects to AX-tree substrate
Replace every CSS-shape walk in lib/claudeai.ts with AX-tree queries
sourced from Chromium's Accessibility.getFullAXTree. Discovery now
reads role + accessibleName + hasPopup from the same substrate the v7
walker uses, dropping the brittle button[aria-haspopup=menu] +
span.truncate.max-w-[Npx] coupling that was the recurring break point
on every upstream tailwind regen.

Substrate change:
- inspector.ts: surface AxValue + AxProperty types; explicit
  properties? on AxNode so consumers can read state tokens.
- walker.ts: export RawElement, add hasPopup field, populate via
  readHasPopup() reading node.properties[].name === 'hasPopup'.
- selfTest Case 10 covers menu / 'false' / absent values.

Page-object migration (lib/claudeai.ts):
- snapshotAx() helper gates on waitForAxTreeStable by default
  (post-userLoaded the first AX read can return ~4 nodes — see
  docs/learnings/test-harness-ax-tree-walker.md §1).
- Polling loops in openPill (post-click) + clickMenuItem gate once
  upfront, then poll with { fast: true } so per-iteration stability
  re-checks don't fight the menuitem-appear poll.
- activateTab matches role:'button' + literal accessibleName.
- findCompactPills filters by role:'button' + hasPopup === 'menu',
  drops cowork sidebar via /^More options for / exclusion. Drops
  CompactPill.maxW field (tailwind artifact, only ever in error
  messages).
- openPill / clickMenuItem use clickByBackendNodeId for the click
  path — same backend-id flow the walker uses.

Live probe (explore/probe-claudeai-ax.ts) confirmed the discrimination
shapes against the host renderer — found 49 buttons with hasPopup
(48 menu, 1 dialog), env pill 'Local' resolves under main >
region[Primary pane], 37 cowork sidebar triggers correctly excluded
by the row-more-options filter. Caught one bug along the way: CDP
exposes the property as 'hasPopup' (camelCase), not 'haspopup' — the
synthetic selfTest fixture used the wrong casing too, so both sides
agreed on the wrong contract until the live probe surfaced it.

T17_folder_picker passes on KDE-W with CLAUDE_TEST_USE_HOST_CONFIG=1.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
2f308c868c docs(testing): retire spent v7 handoff prompts, queue claudeai.ts AX migration
The three v7 handoff prompts (vocabulary scaffold, AX-tree
substrate migration, U-prefix runner wire-up) have all been
implemented and merged. Retire them — the design contract still
lives in fingerprint-v7-plan.md; the per-iteration prompts were
single-use scaffolding for fresh sessions.

Add claudeai-lib-ax-migration-prompt.md as the next-iteration
handoff: tools/test-harness/src/lib/claudeai.ts is still on the
old substrate (document.querySelector against minified-tailwind
shapes) and is the highest-payoff target for the v7 plan's "design
goal §2: Resilient to cosmetic drift". The prompt mirrors the
prior handoffs' structure (authoritative refs, code anchors,
phases, self-correction loop, termination conditions, final report
format) and scopes the spike at openPill before fanning out to
the rest of the file.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
3ed5dfa84c test(harness): wire up U01 v7 sweep against fresh AX-tree inventory
U01 was a placeholder skipping with "v7 cutover — re-walk required";
the v7 walker has shipped a fresh inventory, so regenerate the spec
and land two resolver fixes the live sweep surfaced.

`findByFingerprint`: the strictness gate only consulted `kind`, so
entries with `kind: persistent` + `classification: instance` (the
post-walk persistent-collapse promotes degenerate-shaped fingerprints
when they appear on ≥3 surfaces) failed with "expected exactly one
match, got N". The fingerprint's own degenerate-shape claim should
win — defer to `classification === 'instance'` too.

`redrivePath`: the dangling `startUrl` parameter was the smoking
gun. After a prior test drilled into a deeper URL (e.g.
/settings/customize), `location.reload()` reloaded the deep URL
instead of returning to startUrl, and the next test's first
`clickById` saw a contaminated surface. Navigate to startUrl when
currentUrl has drifted; reload only when already at startUrl.

Sweep results across three runs: 73/17 → 89/1 → 89/1, with the
single failure being non-deterministic (different test each sweep,
both consistent with focus-management transients and sidebar
virtualization documented in docs/learnings/test-harness-ax-tree-walker.md).

Generator gate inverted to make the safe-by-default path
(seedFromHost: true) trigger when the env var is unset, mirroring
H05's pattern but with the seed lifted from the host config.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
5d7fda521f docs(testing): v7 fingerprint plan, AX-tree learnings, fresh inventory
Plan (docs/testing/fingerprint-v7-plan.md):
- Adds "Live-walk shakedown (post-Phase 2)" subsection enumerating
  the five real bugs the first end-to-end walks surfaced and their
  fixes (AX-stable gate, reload vs navigate, sibling-count list
  heuristic, two new instance shapes, threshold bump)
- Resolves three open questions with first-clean-walk data: CDP cost
  is not a bottleneck (817-node tree settles <1s), role overrides
  work as intended (Skip to content captured as link), no
  account-bound kind needed (existing pattern + heuristic + collapse
  cover the observed cases)
- Cross-references for walk-isolated.ts and clickByBackendNodeId

Learnings (docs/learnings/test-harness-ax-tree-walker.md):
- Five non-obvious AX-tree traps with symptoms + fixes:
  Accessibility.enable async lag, navigateTo no-op carrying state,
  claude.ai's flat dialog/complementary lists, per-row "More options
  for X" trigger needing its own shape, sidebar virtualization vs
  the lookup-failure threshold
- Closing note on driver choice (walk-isolated.ts over explore walk)

Prompts (docs/testing/fingerprint-v7-*-prompt.md):
- implementation-prompt: original v7 walker rewrite prompt
- ax-migration-prompt: DOM-walk -> AX-tree substrate migration prompt
- runners-prompt: NEW. Self-contained prompt for next session to wire
  U01 against the fresh inventory and iterate autonomously to a
  clean pass/drift/fail baseline

CLAUDE.md: link the new learnings doc

Inventory artifacts:
- ui-inventory.json + ui-inventory.meta.json: 90-entry inventory
  captured against claude.ai/epitaxy on app 1.5354.0 via
  walk-isolated.ts seedFromHost path. Marketplace dialog folded to
  single button-instance+704; cowork sidebar to button-instance+72;
  search history to option-instance+25
- ui-vocabulary.json: stable/suspect name corpus derived from prior
  walk
- ui-inventory-reconciliation.md: v6-era reconciliation notes
- ui-snapshots/{README.md,.gitkeep}: snapshots dir scaffold (JSON
  contents gitignored to avoid diff churn)

claudeai-ui-map.md: human-readable map of the inventory's reachable
surfaces

Matrix (docs/testing/matrix.md): U01 row added; entry-count phrasing
generalized so it doesn't go stale on each re-walk

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
04cd879d11 test(harness): v7 fingerprint walker on AX-tree substrate
Switches the inventory walker from a renderer-side
document.querySelectorAll IIFE to Chromium's accessibility tree
(Accessibility.getFullAXTree over CDP). Account-portable element
identification via ariaPath + role + AX-computed name; click path
moves to backendDOMNodeId via DOM.resolveNode + Runtime.callFunctionOn.

Walker (explore/walker.ts):
- snapshotSurface consumes AX nodes via axTreeToSnapshot
- waitForAxTreeStable gates seed snapshot, post-navigation snapshot,
  and every snapshotSurface call (Accessibility.enable lag is async;
  first read on a cold load returns 4 nodes vs 800+ when settled)
- redrivePath uses location.reload() instead of navigateTo to discard
  any state prior drills left in the SPA (open dialog, expanded
  sidebar, scrolled focus)
- captureFingerprint's isListRowChild extended: button + group
  ancestors, plus a sibling-count fallback (>=15 same-role siblings)
  for claude.ai's flat marketplace dialogs and complementary sidebar
- step 3 (positional) skipped for list-row children so they collapse
  via step 4's instance shape
- MAX_CONSECUTIVE_LOOKUP_FAILURES bumped 25 -> 75 for sidebar
  virtualization noise (timeout counter still gates real wedges)
- RawElement / RawAncestor reshaped: tagName / role / ariaLabel /
  textContent / dataState / parentChainSignature / ancestorAriaLabel
  dropped; backendDOMNodeId added; accessibleName is sole name source

Inspector (src/lib/inspector.ts):
- AxNode interface published
- clickByBackendNodeId: DOM.resolveNode + Runtime.callFunctionOn
  (replaces selector-based click reconstruction)

Name classifier (src/lib/name-classifier.ts):
- cowork-session shape regex (Idle|Ready|Awaiting input|...)
- row-more-options shape regex (^More options for )

Isolation (src/lib/isolation.ts):
- seedFromHost option: kill host Claude, copy auth-relevant subset of
  ~/.config/Claude into per-launch tmpdir for U01 / H05

Driver (explore/walk-isolated.ts):
- Replaces explore walk for safe walks: launches Claude inside the
  test-harness isolation rather than mutating the host profile

Runners:
- H05_ui_drift_check.spec.ts (claude.ai UI drift detection)
- U01_ui_visibility.spec.ts (placeholder stub; regenerated post-walk)

Self-test fixtures rewritten as synthetic AxNode trees fed through
axTreeToSnapshot; existing 7 plan-example traces produce identical
idTailFromFingerprint outputs.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:56:29 -04:00
aaddrick
9e72ebb3e0 test(harness): negative validations, harness self-tests, claude.ai UI lib
Adds eighteen pieces of work across the harness, partitioned by file
so they don't conflict, dispatched in parallel and merged together.

== Negative validations on existing runners ==

T03 — assert exactly one SNI item is registered (not just presence),
plus toggle nativeTheme.themeSource and re-assert. Catches the
tray-rebuild-race regression where the destroy+recreate path would
briefly register a duplicate item before deregistering the old one
(see docs/learnings/tray-rebuild-race.md).

S29 — assert the popup BrowserWindow is reused across shortcut
presses, not re-constructed. Counts entries in __qeWindows matching
the popup selector after the first press AND after a second press —
both must equal 1. Catches a regression where lazy-create runs every
press instead of show()/hide() on a persisted Ko ref.

S30 — broadens the "no ghost respawn" delta into a full closeout-
leak panel. Three additional checks BEFORE the post-exit shortcut
press: no `cowork-vm-service` pids remain, the SNI item is
deregistered (connection gone), no leftover `SingletonLock`
symlink under the isolation's configDir. Existing post-shortcut
delta assertion preserved.

S32 — replaces the silent `.catch(() => {})` on waitForPopupClosed
with explicit popup-state-after-submit assertion. The stale-
isFocused short-circuit can also leave the popup visible (since
popup.hide() lives downstream of the skipped show()) — independent
regression detector from the main-window-visibility check.

S34 — adds focus-side assertion to what was a suppression-only
test. Upstream contract is `if (ut.isFullScreen()) { ut.focus();
ide(); }` — verify main is still fullscreen AND focused after the
shortcut. KDE-W/KDE-X hard-fail (focus is reliable on Plasma);
GNOME-W/Ubu-W soft-fixme (mutter routinely no-ops focus on
fullscreen surfaces).

S35 — three-launch shape: the existing two-launch position-memory
check plus an on-disk round-trip (read parsed config.json between
launches to confirm the save handler reached disk) plus a clear-
and-default check (delete the saved key, launch a third time,
assert the popup lands somewhere other than the cleared TARGET —
proves the test is reading the real store). Bumped per-test
timeout from 180_000 to 240_000.

== New harness self-tests (H-prefix) ==

Introduces an H-prefix convention for runners that validate the
harness's preconditions and the build pipeline's invariants —
distinct from T-tests (upstream test cases) and S-tests (doc-
spec entries). Cheap, fast, ground-truth what the other tests
assume.

H01 — CDP gate canary. Spawns bundled Electron with
`--remote-debugging-port=0` and no CLAUDE_CDP_AUTH; asserts exit
code 1 within 10s. If the gate is ever accidentally removed, this
fires before the rest of the L1 strategy silently weakens.

H02 — frame-fix-wrapper presence. Asserts both
`frame-fix-wrapper.js` and `frame-fix-entry.js` exist in app.asar,
the wrapper contains `Proxy(`, and `package.json#main` references
the entry. File probe — sub-second.

H03 — patch fingerprints. Manifest-based check for every
build-pipeline patch (KDE gate, frame-fix inject, tray
nativeTheme guard, cowork Linux daemon shutdown, claude-code
linux-arm64 branch). Catches silent build-orchestrator drift.

H04 — cowork daemon lifecycle. Baseline pgrep, launchClaude,
wait for daemon to spawn, app.close(), assert daemon is gone.
Soft-skips on rows where the daemon isn't gated to spawn (most
default builds today).

== claude.ai renderer UI domain wrapper ==

New `lib/claudeai.ts` centralizes renderer-DOM discovery for
claude.ai UI patterns. Same shape as `lib/quickentry.ts` —
domain class with discovery-by-shape, atom helpers, idempotent
mocks. Exports:

  - activateTab(name) — clicks Chat/Cowork/Code df-pill
  - installOpenDialogMock + getOpenDialogCalls — idempotent
    dialog.showOpenDialog mock + recorded calls
  - findCompactPills, openPill, clickMenuItem, pressEscape —
    atoms shared by future page objects
  - class CodeTab — activate(), openEnvPill(), selectLocal(),
    openFolderPicker() (full chain)

Discovery is by structural fingerprint, not Tailwind classes
(those rebuild). Probed against a live debugger to confirm:
df-pill is exactly 3 instances (Chat/Cowork/Code), compact-pill
distinguishes env pill (max-w-[200px]) from Select-folder pill
(max-w-[160px]) — same component shape, different label widths.

T17 refactored to use the new lib — went from ~470 lines of
inline DOM walking to ~70 lines of intent. When claude.ai
re-renders the Code tab, the fix is one file over, not per-spec.

== Library brittleness fixes ==

`lib/quickentry.ts`:
  - getStoredPosition rewritten to read configDir/Claude/config.json
    directly via electron-store's known JSON shape. Replaces a
    fragile globalThis-walk that matched any object with .get/.set
    returning a quickWindowPosition value.
  - LOGIN_URL_RE anchored: `^https?://[^/]+/(login|auth|sign[-_]?in)
    (?:[/?#]|$)`. Previous unanchored form would match
    /oauth/callback as still-on-login.
  - Dropped dead `skipTaskbar: false` field from
    getPopupRuntimeProps return shape (no caller used it; the
    hardcoded false was misleading).

`lib/inspector.ts`:
  - InspectorClient.close() is now idempotent — second close is a
    no-op. Both runners and electron.ts auto-close path can safely
    invoke it.

`lib/electron.ts`:
  - ClaudeApp tracks the attached inspector internally; app.close()
    auto-closes it (existing inline inspector.close() calls in
    runners stay working idempotently).
  - Module-level activeLaunches set + signal handlers ensure
    Ctrl-C during a sweep kills tracked Electron pids and rms
    isolation tmpdirs before re-emitting the signal.
  - app.lastExitInfo: { code, signal } | null exposes non-zero
    exit info post-close. Runners can attach when nonzero;
    nothing breaks when ignored.

== Config + orchestrator ==

`playwright.config.ts`:
  - retries: process.env.CI ? 1 : 0 (one retry in CI to absorb
    compositor flake; local stays at 0 so flakes surface).
  - forbidOnly: !!process.env.CI prevents stray test.only from
    sneaking through CI.
  - /// <reference types="node" /> for `process.env` access (the
    file isn't covered by tsconfig.json's `src/**/*` include).

`orchestrator/sweep.sh`:
  - Replaces the four `grep -oP ... | head -1` lines (which read
    only the first <testsuite> element) with a Node-based summary
    that sums tests/failures/errors/skipped across every suite.
  - Wrapped in `command -v node` guard with the legacy grep
    fallback retained inline.
  - Output line is byte-identical for downstream consumers.

== Cleanup + docs ==

  - README.md status table updated: 20 specs, 13 pass on KDE-W,
    six skip cleanly per spec intent. T17 row reflects the new
    end-to-end click chain.
  - lib/claudeai.ts and probe.ts added to the Layout section.
  - Deleted _investigate_t17_urls.spec.ts (one-off diagnostic
    that confirmed T17's /login was a fresh-isolation auth
    miss, not a webContents race).
  - Kept probe.ts as the seed for the explore CLI in the
    upcoming UI-mapping plan.

== UI mapping plan ==

`docs/testing/claudeai-ui-mapping-plan.md` — executable plan
for systematically mapping claude.ai's renderer UI into reusable
test-harness abstractions. Three layers: shape-based atoms,
page objects per major surface, discovery tooling. Phase 1
(explore CLI with snapshot/diff) and Phase 2 (UI map markdown)
are independent and can run in parallel; Phase 5 (drift
detection H05) depends on Phase 1.

== Validation ==

KDE-W sweep: 13 pass, 6 cleanly skip, 0 fail. 2.7 min total.
T17 verified end-to-end via the env-pill chain after refactor.
npx tsc --noEmit clean across all changes.

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
70% AI / 30% Human
Claude: dispatched five parallel agents per file partition (libs / runners batch 1 / runners batch 2 / new H-tests / config), wrote the claudeai.ts extraction agent brief informed by live-debugger probe evidence, drafted the UI mapping plan
Human: scoped which improvements to make, called out skip vs fail edges (S34 KDE-strict / GNOME-fixme), shared live-renderer DOM dumps that ground-truthed T17's click chain (Code df-pill → env pill → Local → Select folder → Open folder), validated each step
2026-05-03 07:56:29 -04:00
aaddrick
3d3653f51d test(harness): consolidate QE readiness waits behind waitForReady(level)
Six QE specs (S29-S35) hand-rolled six different shapes of "wait
until the app is ready" — some polled mainWin.getState().visible,
some additionally polled for any claude.ai webContents, some
chained waitForUserLoaded for the URL-past-/login signal. Each
spec started with a 10-20 line block of polling boilerplate.

Replaces those with a tiered helper on the ClaudeApp interface:

  app.waitForReady(level, opts?) → ReadyResultFor<level>

with four levels:
  - 'window'      — X11 window mapped (no inspector)
  - 'mainVisible' — main shell BrowserWindow.isVisible()
  - 'claudeAi'    — any claude.ai webContents reachable
  - 'userLoaded'  — claude.ai URL past /login (lHn() precondition)

Higher levels include all lower-level checks. Returns a
conditionally-typed shape per level so the inspector handle is
non-optional at 'mainVisible' or higher (no `inspector!` casts at
call sites). Single overall timeout (default 90_000ms) flows
across steps — slow startup eats from later steps' budget rather
than tripping a per-step deadline.

Hard-fail vs soft-fail split mirrors what the specs already did:

  - 'window' / 'mainVisible' throw on timeout — no spec today
    has a skip path for these, treat as hard regression.
  - 'claudeAi' / 'userLoaded' return with claudeAiUrl /
    postLoginUrl absent on timeout. Caller checks the field and
    testInfo.skip()s — the existing not-signed-in skip pattern
    in S31, S32, S35.

Migrations:

  S29, S30, S34   → 'mainVisible'
  S31, S32        → 'claudeAi'  (preserves the not-signed-in skip)
  S35 (×2 launch) → 'userLoaded' (preserves the skip on both)

Net -64 lines across the six specs (boilerplate gone) and +130
lines in lib/electron.ts (the helper + types). The trade is
worth it for the next QE-* runner — readiness becomes a single
named call instead of another bespoke poll.

Deliberately preserved:

  - openAndWaitReady's retry loop in lib/quickentry.ts. The
    lHn() race (build-reference index.js:515604) lives on a
    different timeline than the renderer URL — main-process
    user state can lag the URL change past /login. 'userLoaded'
    is necessary but not sufficient; the retry-on-shortcut path
    is the cheapest mitigation and stays.
  - S35's first-launch 3s sleep between userLoaded and the
    first openAndWaitReady. openAndWaitReady's retry would
    catch the race too, but eating one full attempt +
    retryDelayMs is slower than the upfront sleep on a test
    that already runs ~30s.

waitForUserLoaded stays exported from lib/quickentry.ts (lHn()
race domain knowledge belongs there) and is consumed by
electron.ts. No re-export to keep one canonical import path.

Validated on KDE-W: 10 passed, 5 cleanly skipped (S12/S32 row,
S36 single-monitor, S37 Linux-unreachable, T17 on /login),
2.1 minutes total. npm run typecheck clean.

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>
60% AI / 40% Human
Claude: drafted the helper API, sorted out the conditional-type vs overload tradeoff, migrated the six specs, ran the validation sweep
Human: scoped which specs to migrate, defined the level semantics, called out openAndWaitReady's retry as untouchable, validated outcome
2026-05-03 07:56:29 -04:00
aaddrick
7d4b819a2d test(harness): land 10 Quick Entry closeout runners (S09-S37) on KDE-W
Wires up the remaining QE-* sweep runners from
docs/testing/quick-entry-closeout.md. Full sweep on KDE-W now runs
16 specs in ~2.2 min; 10 pass, 5 cleanly skip per spec intent
(S12/S32 row-gated to GNOME-W, S36 single-monitor, S37 unreachable
on Linux, T17 mid-air on selector tuning).

Specs landed:

- S09 — patch sanity (asar grep for the KDE-gate string). Pure file
  probe, no app launch, ~75ms.
- S12 — `--enable-features=GlobalShortcutsPortal` argv check.
  GNOME-W only. Currently a known-failing regression detector
  until the launcher patch lands; greens once #404 is closed.
- S29 — popup lazy-create from closed-to-tray. Verifies the popup
  webContents is null before the first shortcut, then opens.
- S30 — shortcut becomes a no-op after full app exit. Switched
  from "no leftover process" to a pgrep-pid-delta assertion; the
  spec's regression target is "no NEW pid spawned by the
  shortcut," not "zero leftovers" (renderer/zygote teardown is
  asynchronous, not what S30 is testing).
- S31 — pre-existing; updated to use openAndWaitReady().
- S32 — GNOME-W/Ubu-W variant of S31 with a main-reappears
  assertion that S31 explicitly avoids. Skips on KDE rows; will
  fail on GNOME-W until the stale-isFocused() patch is widened
  beyond the current KDE-only #406 gate.
- S33 — bundled Electron version. Reads from
  `electron/package.json` rather than running `electron --version`
  (the bundled binary auto-loads `resources/app.asar` so `--version`
  gets passed through as argv to Claude Desktop instead of being
  intercepted by Electron's flag parser).
- S34 — fullscreen main suppresses popup. Inverse-shape test:
  popup must NOT be visible within 3s of the shortcut.
- S35 — position memory across app restart. Two-launch test
  using a shared isolation handle so XDG_CONFIG_HOME persists
  across the restart. Heaviest runner (~30s).
- S36 — multi-monitor fallback. Skips with `-` on single-monitor
  hosts per the closeout spec; uses test.fixme() on multi-monitor
  hosts to surface the missing libvirt-detach orchestration as
  `?` (untested) rather than a misleading green.
- S37 — main-window destroy. Documented skip — unreachable on
  Linux per the close-to-tray override. Marked `-` on every
  Linux row in the matrix.

Two race conditions surfaced and fixed during the bring-up:

1. **lHn() user-loaded race.** Upstream's shortcut handler
   (build-reference index.js:515604) checks `!user.isLoggedOut`
   AFTER ready-to-show and silently skips Ko.show() if the
   main-process user object hasn't populated yet. URL-changes-past-
   /login (visible in the renderer) precedes user-object population
   (in the main process). Mitigation: a new `openAndWaitReady()`
   helper that retries the shortcut up to 3 times with a
   per-attempt timeout. Used by S29-S32, S35.
2. **Main-visible-then-trigger race.** Triggering the shortcut
   immediately after the X11 window appears races the popup show()
   flow on first invocation. Mitigation: wait for
   `mainWin.getState().visible === true` before the first shortcut
   call. The same wait fixes the in-process case where lHn() was a
   non-issue.

New harness primitive:

- `waitForUserLoaded(inspector, timeoutMs)` in lib/quickentry.ts —
  polls the claude.ai webContents URL until it's no longer on a
  /login or /auth path. The signal is necessary but not sufficient
  for the lHn() race (auth state has its own timeline), so the
  retry-loop in `openAndWaitReady()` does the actual heavy lifting.

README's Status table updated to list all 16 specs, layout
section adds the 10 new runner files.

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>
35% AI / 65% Human
Claude: drafted runners + helpers, traced lHn() race through build-reference, debugged race conditions iteratively against the local install
Human: scoped batches, validated each runner outcome, drove the diagnostic-attachment + retry-vs-sleep tradeoff decisions
2026-05-03 07:56:29 -04:00
aaddrick
e92ca9895a test(harness): foundation for QE-* runners + S31 passing on KDE-W
Three prerequisites built before adding the closeout sweep runners:

- Per-test isolation default in launchClaude(). Fresh
  XDG_CONFIG_HOME / CLAUDE_CONFIG_DIR per launch via mkdtemp,
  cleaned up on close. Three modes: default (fresh), shared
  (pass an Isolation handle for restart-style tests like S35),
  null (host config — opt-in for tests that need real claude.ai
  auth via CLAUDE_TEST_USE_HOST_CONFIG).
- Row-skipping primitive (skipUnlessRow) so spec files declare
  applicability once and the orchestrator routes correctly. Maps
  to JUnit <skipped> → matrix `-`.
- Layered Critical/Should assertion pattern. Local signals stay
  local (popup-closed = isVisible() === false), network-coupled
  signals (chat URL nav) are tracked separately so a claude.ai
  hiccup doesn't fail a regression cell.

New libs:
- isolation.ts — per-test sandbox
- row.ts — skipUnlessRow / skipOnRow
- argv.ts — /proc/$pid/cmdline + flag-presence check (QE-6, S07,
  S12, future Wayland-default Smoke)
- asar.ts — in-place app.asar reads via @electron/asar (QE-19,
  future patch sanity for tray.sh / cowork.sh / etc.)
- quickentry.ts — domain wrapper. Single point of coupling to
  upstream's main-process structure for QE-* tests. Anchors on
  stable strings (loadFile path '.vite/renderer/quick_window/
  quick-window.html', IPC channel names, settings keys), not
  minified vars.

S31 — Quick Entry submit reaches new chat from any main-window
state. Backs QE-7/8/9; passes on KDE-W in ~28s.

The interceptor pivot worth noting: scripts/frame-fix-wrapper.js
returns the electron module wrapped in a Proxy whose `get` trap
returns a closure-captured PatchedBrowserWindow. Constructor-level
wraps (`electron.BrowserWindow = Wrapped`) are silently bypassed —
writes succeed but reads ignore them. The reliable hook is at the
prototype-method level (loadFile / loadURL); captures every
instance regardless of subclass identity. Documented in
docs/learnings/test-harness-electron-hooks.md so the next
contributor doesn't re-discover the trap.

ydotool is a hard prerequisite for QE-* shortcut injection.
README's "Quick Entry runners" section walks through one-time
host setup (install + ydotoold systemd override for a
world-writable socket). sweep.sh fast-fails with a clear
diagnostic when the daemon isn't reachable.

What's left: ten more runners (S29/S30/S32/S33/S34/S35/S36/S37,
QE-6/19 patch sanity, QE-15/17/21 popup chrome). Each is a
~30-60-line recombination over the existing libs — see plan in
the closing message of this PR thread.

---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>
40% AI / 60% Human
Claude: drafted libs + runner, debugged the frame-fix-wrapper Proxy trap, wrote the learnings entry, ran S31 on bare-metal KDE-W
Human: scoped the prerequisites split, ran ydotool/ydotoold setup, validated the output, drove design tradeoffs (per-test isolation default, layered Critical/Should assertion, prototype-hook over constructor wrap)
2026-05-03 07:56:29 -04:00
aaddrick
bf9082067a docs(testing): add Quick Entry closeout sweep plan + S29-S37 case specs
Focused sweep plan for closing #393 / #404 / #370, anchored in upstream
design intent rather than user expectation (validated against
build-reference/.vite/build/index.js).

Adds nine functional test specs (S29-S37) covering Quick Entry popup
lifecycle, submit-flow reachability across main-window states, the
fullscreen edge case, position memory across restart, multi-monitor
fallback, and popup-survives-main-destroy behaviour. Each spec cites
specific upstream file:line evidence.

Refines ui/quick-entry.md rows with the same upstream evidence and adds
rows for popup lifecycle and main-window-destroy persistence. Submit
transition row now reflects "always a new chat session, never appended
to current" per index.js:515546.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
c97d9eb64e docs(testing): update README + runbook for landed automation
The README's "Automation roadmap" section was written when the harness
didn't exist; it described automation in the future tense. Same for the
runbook's "Eventual automation" section ("runner: fields are
aspirational"). Both lied as of last week.

  README "Automation status" — points at tools/test-harness/, lists the
                               four wired runners (T01/T03/T04/T17),
                               links automation.md for architecture,
                               links runbook for invocation.
  runbook "Automated runs"   — sweep.sh invocation, output paths,
                               JUnit-to-matrix mapping, coexistence
                               with manual tests, brief on the
                               SIGUSR1 / runtime-attach path through
                               the CDP gate (with link to the long
                               writeup in automation.md).

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
bfc0c0378e test(harness): runtime-attach inspector via SIGUSR1 unblocks L1
The CDP gate (lib/electron.ts) only matches --remote-debugging-port /
-pipe on argv. It doesn't check --inspect or runtime SIGUSR1 — which is
the same code path as the in-app Developer → Enable Main Process
Debugger menu item. Spotted by aaddrick.

So we spawn Electron clean (gate stays asleep), wait for the X11
window, then send SIGUSR1 to attach the Node inspector at runtime.
From there we get main-process JS evaluation, which reaches the
renderer via webContents.executeJavaScript() and supports main-process
mocks (dialog.showOpenDialog for T17).

What landed:

  src/lib/inspector.ts   — new. WebSocket Node-inspector client with
                           evalInMain<T>() and evalInRenderer<T>()
                           wrappers. Node 22+ built-in WebSocket; no
                           extra deps.
  src/lib/electron.ts    — adds app.attachInspector(timeoutMs) which
                           SIGUSR1's the pid and waits for port 9229
                           to answer.
  src/runners/T17        — re-enabled. Inspector attaches, dialog mock
                           installs, claude.ai webContents found,
                           Code-tab navigation click succeeds. Skips
                           with rich diagnostic if the folder-picker
                           click chain doesn't land — selector tuning
                           is iterate-as-needed work, not a blocker.

Two implementation gotchas captured in code comments:

  - BrowserWindow.getAllWindows() returns 0 because frame-fix-wrapper
    substitutes the class and breaks the static registry. Use
    webContents.getAllWebContents() instead — works correctly.
  - Runtime.evaluate's awaitPromise + returnByValue returns empty
    objects for awaited Promise resolutions. Workaround: IIFE returns
    JSON.stringify(value) and caller JSON.parses.

Sweep output:

  $ ./orchestrator/sweep.sh
  ✓  T01 — App launch (7.2s)
  ✓  T03 — Tray icon present (7.2s)
  ✓  T04 — Window decorations draw (7.1s)
  -  T17 — Folder picker opens
  3 passed, 1 skipped (44s)

Decision 1's escape-hatch reasoning (dogtail / AT-SPI) is no longer the
fallback for L1; it's only relevant for native dialogs the inspector
pattern can't reach. The three documented escape hatches under "The CDP
auth gate" can be retired — option (4), runtime-attach, is what we
actually use.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
d5d7081b35 test(harness): pivot off CDP, ship 3 passing tests on KDE-W
Discovered the real blocker behind every failed Playwright launch: the
shipped index.pre.js has an authenticated-CDP gate.

  uF(process.argv) && !qL() && process.exit(1);

uF matches --remote-debugging-port / --remote-debugging-pipe on argv;
qL validates an ed25519-signed token in CLAUDE_CDP_AUTH (signed payload
${timestamp_ms}.${base64(userDataDir)}, 5-minute TTL) against a hardcoded
public key. Without a valid signature the app exits with code 1 right
after frame-fix-wrapper completes.

Both _electron.launch() and chromium.connectOverCDP() inject
--remote-debugging-port=0 and trigger the gate. The signing key is held
upstream; we can't forge tokens. CDP-driven L1 testing is blocked until
one of: (a) upstream issues a test/CI token, (b) we carry an
app-asar.sh patch that neutralizes the gate, or (c) we drive the
renderer via accessibility (dogtail / AT-SPI). All three are real
options; none belong in this commit.

What ships here, working today:

  T01 — App launch                 ✓ on KDE-W
  T03 — Tray icon present          ✓ on KDE-W (already was)
  T04 — Window decorations draw    ✓ on KDE-W (already was)
  T17 — Folder picker opens        - (skipped, awaits portal mock v2)

The harness now spawns Electron without any debug-port flags and
probes the running app externally — xprop for window state, dbus-next
for tray. T01 verifies "an X11 window with our pid appears within 15s
and its title matches /claude/i" rather than reading navigator.userAgent;
T03/T04 were external-probe tests already.

Sweep output:

  $ ROW=KDE-W ./orchestrator/sweep.sh
  Running 4 tests using 1 worker
    ✓  1 T01 — App launch (7.2s)
    ✓  2 T03 — Tray icon present (7.2s)
    ✓  3 T04 — Window decorations draw (7.1s)
    -  4 T17 — Folder picker opens
    1 skipped
    3 passed (22.9s)
  summary: tests=4 failures=0 errors=0 skipped=1

JUnit XML written, .tar.zst bundle created, exit 0.

The CDP auth gate finding is documented at docs/testing/automation.md
"The CDP auth gate" with the three escape hatches enumerated. Decision 1
and Decision 5 reopen for L1 once the project picks a path.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
46f6dcdb9d test(harness): findings from first KDE-W run-through
Captures four real issues surfaced by trying to run T01 against the
installed claude-desktop on Nobara KDE-W, plus the fixes that landed.

Fixes that stuck:

1. Bypass the launcher script (/usr/bin/claude-desktop). It redirects
   Electron's stdout/stderr to ~/.cache/claude-desktop-debian/launcher.
   log, which means Playwright can't read the CDP advertisement on
   stderr. launchClaude now resolves the Electron binary + app.asar
   directly and spawns through Playwright. Override paths via
   CLAUDE_DESKTOP_ELECTRON / CLAUDE_DESKTOP_APP_ASAR env vars.

2. Inject the launcher's flags. Decision 6 (X11 default) is enforced
   in production via --disable-features=CustomTitlebar
   --ozone-platform=x11. Without these, Electron 41 hits a fatal
   Wayland communication error ("Broken pipe") on this build. Added
   as LAUNCHER_INJECTED_FLAGS.

3. Inject the launcher's env. ELECTRON_FORCE_IS_PACKAGED=true and
   ELECTRON_USE_SYSTEM_TITLE_BAR=1 mirror setup_electron_env(). The
   former makes app.isPackaged return true so resource resolution
   uses process.resourcesPath; the latter matches hybrid/native
   titlebar modes.

4. Pre-launch cleanup. Mirrors cleanup_orphaned_cowork_daemon +
   cleanup_stale_lock + cleanup_stale_cowork_socket in launcher-common
   .sh. Without it, a previous failed run leaves an orphaned cowork
   daemon and a stale SingletonLock that poison the next launch.

Also: dropped the xdotool dependency. wm.ts now finds the X11 window
by walking _NET_CLIENT_LIST + _NET_WM_PID via xprop only, which is
universally installed where xdotool isn't.

Open finding documented in README "Known limitations":

  Playwright's _electron.launch() currently fails after Frame Fix
  completes — the Node-inspector ws disconnects (code 1006) before
  the renderer ever advertises its DevTools port. Standalone
  electron --inspect=0 ... app.asar runs cleanly with the same flags
  (Frame Fix → "Starting app" → window created), so the failure is
  specific to Playwright + Electron 41 + this build. Likely
  workarounds: (a) chromium.connectOverCDP() against externally-
  spawned Electron with fixed --remote-debugging-port; (b) skip L1
  entirely for T03/T04 (those don't need Playwright owning the
  process — just spawn via child_process and use dbus-next / xprop).

Type-check passes; orchestrator/sweep.sh runs cleanly. The four .spec
.ts files all discover via npx playwright test --list. The blocker
is the launch handshake, not the harness shape.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
f8ba761c2e test(harness): scaffold first vertical slice — T01, T03, T04, T17
Adds the in-VM TS harness at tools/test-harness/ covering the four
tests that exercise every distinct shape of harness code:

- T01 — app launch (playwright-electron)
- T03 — tray icon present (dbus-next + StatusNotifierWatcher)
- T04 — window decorations draw (xprop + xdotool shell-out helpers)
- T17 — folder picker opens (Electron-level dialog intercept; v1)

Layout:

    tools/test-harness/
    ├── package.json / tsconfig / playwright.config
    ├── src/lib/         — electron, dbus, sni, wm, env, retry, diagnostics
    ├── src/runners/     — one .spec.ts per test ID
    └── orchestrator/sweep.sh

Per Decision 1 (single-language TS): every runner is .ts; OS tools
(xprop, xdotool, claude-desktop --doctor) are shelled out via
child_process and wrapped as typed TS helpers. dbus-next handles all
DBus introspection. No bash test scripts, no Python.

T17 is the shallow v1 — intercepts dialog.showOpenDialog at the
Electron main process via Playwright's app.evaluate() rather than
mocking the portal. Mocking org.freedesktop.portal.FileChooser via
dbus-next requires displacing the running portal service or running
under dbus-run-session, both intrusive enough to defer until signal
warrants it. The test file documents this and the upgrade path.

T04 uses xprop / xdotool which work on X11 native and KDE Wayland
(via XWayland — the project default per Decision 6). Native-Wayland
window-state queries are deferred.

Wires runner: fields into the four cases/*.md test specs.

Type-check passes; npx playwright test --list discovers all four.

Run with:
    cd tools/test-harness
    npm install
    ROW=KDE-W ./orchestrator/sweep.sh

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
47de8bff7d docs(testing): unify on TS, capture decisions
Restructures automation.md from brainstorm-with-open-questions to
direction-with-residual-decisions. Eight calls captured in a Decisions
table near the top:

1. Single language (TypeScript). dbus-next replaces gdbus shell-outs;
   child_process wraps OS-tool invocations as typed TS helpers; portal
   mocking via dbus-next handles native-dialog tests. Python only as a
   last-resort escape hatch for AT-SPI cases that resist mocking.
2. Harness lives at tools/test-harness/.
3. Packer for imperative distro images + Nix flake for Hypr-N.
4. No CI infrastructure initially; harness invokable from CI but
   sweeps run from the dev box for the first ~20 tests.
5. Semantic locators only (getByRole/getByLabel/getByText). No
   proactive data-testid injection patch; escalate per-test if a
   selector proves unstable.
6. X11-default verification is Smoke; Wayland-native characterization
   is Should. Project keeps X11 default because portal coverage for
   GlobalShortcuts is uneven across compositors.
7. Last 10 greens + all reds, on main only. Capture --doctor /
   launcher log / screenshot every run.
8. JUnit lives as workflow-run artifacts. Matrix-regen reads latest
   run's bundle and PRs the matrix update.

T17 (folder picker) moves out of "manual forever" — portal mocking
covers the integration test cleanly. dogtail demoted to escape-hatch
status, only invoked if a specific test forces it.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
28fc6e29a2 docs(testing): draft automation plan
Captures the brainstorm + research pass behind the eventual harness:
three-layer model (renderer / native / manual), why in-VM Playwright
beats orchestrator-driven CDP, toolchain choices per layer (playwright-
electron, dogtail/AT-SPI, ydotool→libei), anti-patterns to design
against from day one, and a suggested first vertical slice (KDE-W + T01).

Includes an Open questions section listing eight decisions still owed
before any of this becomes code — language split, harness location,
image-build tooling, CI execution model, data-testid injection, severity
for the Electron-Wayland-default tests, diagnostic retention, JUnit
output destination.

Sourced; not committed direction yet.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
aaddrick
ff3dd3c64e docs(testing): add Linux compatibility test plan
Establish a manual test plan for the Linux fork at docs/testing/, structured
to support eventual automation.

Layout:
- README.md         orientation, severity tiers, smoke set (10 tests),
                    automation roadmap
- matrix.md         cross-env dashboard (T01-T39) + env-specific status
                    snapshots (S01-S28) + known-failures rollup
- runbook.md        VM setup, diagnostic-capture commands, sweep workflow,
                    severity guidance, how to add tests
- cases/            67 functional tests grouped by feature surface; every
                    test has standardized Severity / Steps / Expected /
                    Diagnostics on failure / References sections
- ui/               per-surface UI checklists (window chrome, tray,
                    sidebar, prompt, code-tab panes, settings, routines,
                    connectors/plugins, quick entry, notifications). Every
                    row is an interactive element with selector + expected
                    state.

Coverage:
- Historical project surfaces: app launch, doctor, tray, window
  decorations, hybrid topbar, Quick Entry, autostart, hide-to-tray,
  multi-instance.
- Upstream Claude Code Desktop surfaces (officially "Linux not supported"
  per code.claude.com/docs/en/desktop): Code tab, sign-in flow, folder
  picker, drag-drop, integrated terminal, file pane, preview pane, PR
  monitoring, scheduled tasks, connectors OAuth, plugin browser, MCP /
  hooks / CLAUDE.md memory, Dispatch handoff.
- Env-specific failure modes: Ubuntu/DEB, Fedora/RPM, Wayland-native
  (wlroots), KDE, GNOME (mutter XWayland key-grab), Omarchy, Niri,
  AppImage, .desktop env handling, idle-sleep / suspend, Computer Use
  (out-of-scope per upstream), auto-update vs apt/dnf, plugin/worktree
  storage.

Automation hooks:
- Stable T## / S## test IDs (won't move).
- Standardized test bodies — Steps and Diagnostics fields are
  scripted-runner-shaped.
- UI checklists are per-element tables — every row a candidate
  Playwright / xdotool / DBus assertion.
- Smoke set explicit in README — first 10 tests for automation.

Co-Authored-By: Claude <claude@anthropic.com>
2026-05-03 07:55:51 -04:00
124 changed files with 16443 additions and 8152 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 `docs/styleguides/bash_styleguide.md` (enforced by shellcheck)
- Follow the style guide in `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](../../docs/styleguides/bash_styleguide.md)
**Reference**: Follow the [Bash Style Guide](../../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 `docs/styleguides/bash_styleguide.md` and CLAUDE.md conventions. Report issues with suggested fixes. |
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against 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
└── docs/styleguides/bash_styleguide.md # Bash style guide (formerly STYLEGUIDE.md at root)
└── STYLEGUIDE.md # Bash style guide
# Note: frame-fix-entry.js is generated by build.sh, not a standalone file
```
### Key Conventions
- Shell: follows `docs/styleguides/bash_styleguide.md` strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
- Shell: follows 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` | `docs/styleguides/bash_styleguide.md` compliance, clarity |
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | 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 `docs/styleguides/bash_styleguide.md` compliance (defer to `cdd-code-simplifier`)
- Shell script style and 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 `docs/styleguides/bash_styleguide.md`
- `cdd-code-simplifier` ensures the shell code implementing them follows 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
docs/styleguides/bash_styleguide.md # Bash style guide
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
└── docs/styleguides/bash_styleguide.md # Bash style guide
└── 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](../../docs/styleguides/bash_styleguide.md) for all shell code:
Follow the project's [Bash Style Guide](../../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 (`docs/styleguides/bash_styleguide.md` compliance)
- Shell script conventions (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 `docs/styleguides/bash_styleguide.md` compliance
- Shell script 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 docs/styleguides/bash_styleguide.md conventions for shell scripts.
Simplify code for clarity and consistency without changing functionality. Follow 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", "docs/styleguides/bash_styleguide.md"
Read: "CLAUDE.md", "README.md", "STYLEGUIDE.md"
# Find existing agent patterns
Glob: ".claude/agents/*.md"

View File

@@ -2,8 +2,5 @@
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,.codespellrc
check-hidden = true
# 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
# ignore-regex =
# ignore-words-list =

11
.github/CODEOWNERS vendored
View File

@@ -62,12 +62,7 @@
# ---- Docs & style ----
/README.md @aaddrick
/CLAUDE.md @aaddrick
/AGENTS.md @aaddrick
/CONTRIBUTING.md @aaddrick
/CHANGELOG.md @aaddrick
/RELEASING.md @aaddrick
/SECURITY.md @aaddrick
/docs/styleguides/ @aaddrick
/STYLEGUIDE.md @aaddrick
/docs/ @aaddrick
# ---- Testing & release quality ----
@@ -81,9 +76,9 @@
/.github/workflows/tests.yml @sabiut
# Shared review — either owner can approve.
# troubleshooting.md is mostly the --doctor user-facing guide; lint
# TROUBLESHOOTING 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,29 +49,6 @@ jobs:
fi
./build.sh ${{ inputs.build_flags }} $TAG_FLAG
# Static-grep the shipped asar for the cowork patch markers
# defined in scripts/cowork-patch-markers.tsv (issue #559 D6,
# PR #555). Pinned to amd64-deb because the patched JS is
# identical across formats, so one verification per CI run is
# sufficient — no need to duplicate across the matrix.
- name: Verify cowork patches in shipped asar
if: inputs.artifact_suffix == 'deb'
run: |
deb_file=$(find . -maxdepth 1 -name 'claude-desktop_*amd64.deb' \
-print -quit)
if [[ -z "$deb_file" ]]; then
echo "verify-patches: no .deb artifact found" >&2
exit 1
fi
extract_dir=$(mktemp -d)
dpkg-deb -x "$deb_file" "$extract_dir"
asar_path=$(find "$extract_dir" -name app.asar -print -quit)
if [[ -z "$asar_path" ]]; then
echo "verify-patches: app.asar not found in deb" >&2
exit 1
fi
./scripts/verify-patches.sh "$asar_path"
- name: Upload AMD64 Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:

View File

@@ -54,11 +54,9 @@ jobs:
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
test-artifacts:
name: Test Build Artifacts (amd64)
name: Test Build Artifacts
needs: [build-amd64]
uses: ./.github/workflows/test-artifacts.yml
with:
arch: amd64
build-arm64:
name: Build Packages (arm64 - ${{ matrix.artifact_suffix }})
@@ -84,17 +82,10 @@ jobs:
artifact_suffix: ${{ matrix.artifact_suffix }}
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
test-artifacts-arm64:
name: Test Build Artifacts (arm64)
needs: [build-arm64]
uses: ./.github/workflows/test-artifacts.yml
with:
arch: arm64
release:
name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [test-flags, build-amd64, build-arm64, test-artifacts, test-artifacts-arm64]
needs: [test-flags, build-amd64, build-arm64, test-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -683,7 +674,6 @@ jobs:
'gpgcheck=1' \
'repo_gpgcheck=1' \
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
'metadata_expire=1h' \
> rpm/claude-desktop.repo
- name: Re-upload signed RPMs to GitHub Release

View File

@@ -215,7 +215,6 @@ jobs:
if: steps.classify.outputs.classification == 'bug' || steps.classify.outputs.classification == 'enhancement'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
FIRST_PASS: ${{ steps.classify.outputs.classification }}
run: |
schema=$(cat .claude/scripts/schemas/classify-doublecheck-bug-vs-enhancement.json)
title=$(jq -r '.title' /tmp/triage/issue.json)
@@ -250,7 +249,7 @@ jobs:
printf '%s' "${structured}" \
> /tmp/triage/classification-doublecheck.json
first_pass="${FIRST_PASS}"
first_pass="${{ steps.classify.outputs.classification }}"
verdict=$(jq -r '.verdict' \
/tmp/triage/classification-doublecheck.json)
@@ -272,14 +271,10 @@ jobs:
# classifier entirely.
- name: Decide route
id: route
env:
SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }}
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
DISAGREED: ${{ steps.doublecheck.outputs.disagreed }}
run: |
suspicious="${SUSPICIOUS}"
classification="${CLASSIFICATION}"
disagreed="${DISAGREED}"
suspicious="${{ steps.suspicious.outputs.suspicious }}"
classification="${{ steps.classify.outputs.classification }}"
disagreed="${{ steps.doublecheck.outputs.disagreed }}"
if [[ "${suspicious}" == "true" ]]; then
echo "route=deferral" >> "$GITHUB_OUTPUT"
@@ -489,7 +484,6 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
run: |
schema=$(cat .claude/scripts/schemas/investigate.json)
title=$(jq -r '.title' /tmp/triage/issue.json)
@@ -536,7 +530,7 @@ jobs:
# the PR. The reporter named a culprit; the diff is a
# primary input for Stage 4 because the defect site is
# almost always inside the named PR's changed files.
if [[ "${HAS_REGRESSION}" == "true" ]]; then
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
echo "## Regression context (PR named by reporter)"
echo ""
reg_title=$(jq -r '.title' /tmp/triage/regression-of.json)
@@ -769,7 +763,6 @@ jobs:
|| steps.dup_fetch.outputs.dup_fetched == 'true')
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
run: |
schema=$(cat .claude/scripts/schemas/review.json)
@@ -874,7 +867,7 @@ jobs:
# regression_of diff block — only when Stage 3b validated.
# Lets the reviewer check whether a finding's citation
# actually lands inside the named PR's changed files.
if [[ "${HAS_REGRESSION}" == "true" ]]; then
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
echo "## regression_of PR diff (reporter-named culprit)"
echo ""
reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json)
@@ -1034,37 +1027,25 @@ jobs:
# low-confidence cause).
- name: Decide comment variant
id: decide
env:
ROUTE: ${{ steps.route.outputs.route }}
DEFERRAL_REASON_ID: ${{ steps.route.outputs.deferral_reason_id }}
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
FETCH_OK: ${{ steps.fetch.outputs.fetch_ok }}
INVEST_OK: ${{ steps.investigate.outputs.investigate_ok }}
DRIFT: ${{ steps.drift.outputs.drift_detected }}
REVIEW_OK: ${{ steps.review.outputs.review_ok }}
FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }}
KEPT: ${{ steps.filter.outputs.review_findings_kept }}
AVG: ${{ steps.filter.outputs.review_avg_confidence }}
DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }}
run: |
route="${ROUTE}"
route="${{ steps.route.outputs.route }}"
if [[ "${route}" == "deferral" ]]; then
echo "variant=8b" >> "$GITHUB_OUTPUT"
echo "reason_id=${DEFERRAL_REASON_ID}" \
echo "reason_id=${{ steps.route.outputs.deferral_reason_id }}" \
>> "$GITHUB_OUTPUT"
exit 0
fi
classification="${CLASSIFICATION}"
fetch_ok="${FETCH_OK}"
invest_ok="${INVEST_OK}"
drift="${DRIFT}"
review_ok="${REVIEW_OK}"
findings_passed="${FINDINGS_PASSED}"
kept="${KEPT}"
avg="${AVG}"
dup_rating="${DUP_RATING}"
classification="${{ steps.classify.outputs.classification }}"
fetch_ok="${{ steps.fetch.outputs.fetch_ok }}"
invest_ok="${{ steps.investigate.outputs.investigate_ok }}"
drift="${{ steps.drift.outputs.drift_detected }}"
review_ok="${{ steps.review.outputs.review_ok }}"
findings_passed="${{ steps.validate.outputs.findings_passed }}"
kept="${{ steps.filter.outputs.review_findings_kept }}"
avg="${{ steps.filter.outputs.review_avg_confidence }}"
dup_rating="${{ steps.filter.outputs.duplicate_of_rating }}"
# Shared gates that apply to every investigate route.
if [[ "${fetch_ok}" != "true" ]]; then
@@ -1754,11 +1735,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
REASON_ID: ${{ steps.decide.outputs.reason_id }}
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
VARIANT: ${{ steps.decide.outputs.variant }}
run: |
classification="${CLASSIFICATION}"
variant="${VARIANT}"
classification="${{ steps.classify.outputs.classification }}"
variant="${{ steps.decide.outputs.variant }}"
if [[ "${variant}" == "8a" ]]; then
triage_label="triage: investigated"

View File

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

View File

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

25
.gitignore vendored
View File

@@ -24,13 +24,6 @@ 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/
@@ -41,18 +34,6 @@ result-*
# Wrangler (Cloudflare Worker dev/deploy cache)
worker/.wrangler/
# Graphify outputs and temporary files
graphify-out/
.graphify_*
# Local agent/editor state and helper bins
.agents/
.codex/
.tmpbin/
# Local package artifacts
*.rpm
# Root-level scratch extracts from app inspection
/frame-fix-wrapper.js
/index.js
# 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,492 +1,13 @@
# AGENTS.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.
-->
All project instructions, conventions, and development guidelines are maintained in [CLAUDE.md](CLAUDE.md).
## Required reading
Strictly follow the rules defined there.
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.
## Project Tooling
- [`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.
Subagent definitions, skills, and orchestration scripts live in [`.claude/`](.claude/):
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
- `.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

View File

@@ -1,351 +0,0 @@
# Changelog
All notable changes to `aaddrick/claude-desktop-debian` are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — semantic versioning applies to `REPO_VERSION`; upstream Claude Desktop bumps (the `+claude{X.Y.Z}` suffix on the tag) are tracked separately by the `check-claude-version` workflow.
## [Unreleased]
<!-- Updated automatically by check-claude-version; will be current at release time. -->
### Fixed
- Builds (deb, RPM, AppImage, nix) no longer abort in the patch phase with `FATAL: --add-dir pattern matches 2 times (expected 1)`. Upstream Claude Desktop 1.12603.1 ships two identical `--add-dir` dispatch loops, but the `.asar` filter patch ([#650](https://github.com/aaddrick/claude-desktop-debian/pull/650)) asserted exactly one. The patch now filters every matching dispatch loop instead of bailing on a duplicate, and stays idempotent on re-runs. ([#718](https://github.com/aaddrick/claude-desktop-debian/issues/718))
- `claude-desktop --doctor` reports the installed version from the package manager that actually owns the install (probed via `rpm -qf` on the bundled Electron binary) instead of trusting `dpkg-query` alone — rpm installs on hosts that also carry a stale dpkg record (e.g. Fedora boxes with dpkg installed as a build tool) no longer show a months-old version with a PASS. ([#712](https://github.com/aaddrick/claude-desktop-debian/pull/712), fixes [#711](https://github.com/aaddrick/claude-desktop-debian/issues/711))
## [v2.0.19] — 2026-06-10
Tracks upstream Claude Desktop 1.11847.5.
### Added
- AppStream metainfo (`io.github.aaddrick.claude-desktop-debian.metainfo.xml`) installed by the deb, RPM, and AppImage builds, so the package appears in GNOME Software, KDE Discover, and App Center with correct unofficial-repackaging branding and a `LicenseRef-proprietary` project license. Store search for not-yet-installed users needs repo-side DEP-11/appstream metadata, tracked in [#708](https://github.com/aaddrick/claude-desktop-debian/issues/708). ([#633](https://github.com/aaddrick/claude-desktop-debian/pull/633))
- GPU crash auto-recovery in the launcher: when the previous launch died to a Chromium GPU-process FATAL (the [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583) SIGTRAP signature), the next launch automatically applies safe GPU flags — and stays recovered on subsequent launches instead of oscillating crash/work/crash. Detects NixOS launcher log headers too; set `CLAUDE_DISABLE_GPU=0` to override. ([#666](https://github.com/aaddrick/claude-desktop-debian/pull/666))
### Fixed
- `claude-desktop --doctor` no longer reports a false-green PASS when the password store reads back empty or when `df` returns a non-numeric disk reading — bad reads now fail or print a visible skip instead of falling through to the PASS branch, and leading-zero `df` output can no longer slip past as octal arithmetic. ([#692](https://github.com/aaddrick/claude-desktop-debian/pull/692))
- Explicit quit now keeps the launcher alive until Electron exits, then runs
stale-helper cleanup for Desktop-owned Cowork, Claude config, and extension
helpers. Close-to-tray still leaves the app and helpers running.
([#682](https://github.com/aaddrick/claude-desktop-debian/pull/682))
- All launchers (deb, RPM, AppImage, nix) no longer pass `app.asar` as an Electron
argument. Electron auto-loads `app.asar` from its default `resources/` dir next to the
binary, so the extra argv entry was redundant — and the app treated it as a
file-to-open, surfacing a spurious "Attach app.asar?" prompt on launch and on every
taskbar reopen. This removes the path at the source, complementing the renderer-side
`.asar` guards in [#669](https://github.com/aaddrick/claude-desktop-debian/pull/669)
and surviving upstream re-minification. Live-UI detection in the launcher and doctor,
which fingerprinted on the now-removed argv, was updated alongside.
([#700](https://github.com/aaddrick/claude-desktop-debian/pull/700),
fixes [#696](https://github.com/aaddrick/claude-desktop-debian/issues/696))
- Cowork's VM daemon never auto-launched on packages built under a restrictive umask (CI builds with umask `022`, so released artifacts were unaffected; local builds with e.g. `umask 077` were) because the bundled `app.asar.unpacked/` directory shipped as mode `0700` owned by the build uid, so the desktop user running the app couldn't traverse it and the auto-launch `fs.existsSync()` fork guard silently returned `false` (symptom: endless `connect ENOENT …/cowork-vm-service.sock`, no `cowork_vm_daemon.log`, no `[cowork-autolaunch]` line). `deb.sh` now normalizes the installed tree to canonical permissions (directories and executables `755`, other files `644`) and builds with `dpkg-deb --root-owner-group` for `root:root` ownership; `appimage.sh` applies the same normalization to the AppDir before `mksquashfs` (it copies with `cp -a`, which preserved the bad modes); and `rpm.sh` normalizes file modes in `%install``%defattr(-, root, root, 0755)` forces directory modes in the payload, but its `-` first field preserves file modes from the `cp -r`-populated buildroot, so a restrictive-umask RPM build shipped an unreadable `app.asar` and a non-executable electron binary.
- Claude Desktop no longer crashes on launch on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces Chromium's sandbox needs (`sandbox/linux/services/credentials.cc` FATAL, `Trace/breakpoint trap`, exit 133). The `.deb` `postinst` now installs a scoped AppArmor profile granting `userns` to the bundled Electron binary — mirroring the `google-chrome`/`code`/`slack` packages — and removes it again on uninstall. The Chromium sandbox stays enabled (no `--no-sandbox`). `claude-desktop --doctor` gained a **User namespaces** check that flags a missing profile. ([#687](https://github.com/aaddrick/claude-desktop-debian/pull/687))
- Cowork mode no longer silently falls back to host-direct (no isolation) on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces its bubblewrap sandbox needs. The `.deb` `postinst` now installs a second scoped AppArmor profile granting `userns` to `/usr/bin/bwrap` (distinct from the Electron profile above), automating the manual workaround from [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351) (contributed by [@hfyeh](https://github.com/hfyeh)). The profile is gated on the kernel's `apparmor_restrict_unprivileged_userns` knob and defers to any profile already attaching to `/usr/bin/bwrap` (a hand-made `/etc/apparmor.d/bwrap`, `apparmor-profiles`' `bwrap-userns-restrict`); put local overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` — they survive upgrades. `bubblewrap` is now a `Recommends`. ([#694](https://github.com/aaddrick/claude-desktop-debian/pull/694))
### Changed
- CI now validates the arm64 deb, RPM, and AppImage artifacts on native `ubuntu-22.04-arm` runners (previously only amd64 was tested), and the AppImage launch smoke test's process sweep is keyed to `mount_claude` and gated behind `$CI` so a local test run can't kill a developer's live Claude Desktop session. The launcher's orphaned-daemon reaper also gained mutation-tested BATS coverage. ([#691](https://github.com/aaddrick/claude-desktop-debian/pull/691), [#693](https://github.com/aaddrick/claude-desktop-debian/pull/693))
- The native-Wayland launch path now routes Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal: `GlobalShortcutsPortal` is added to the `--enable-features` set, and all Chromium feature requests are merged into a single `--enable-features=` switch (Chromium honours only the last one, so the previous code could silently clobber features). GNOME Wayland users can opt into the portal route with `CLAUDE_USE_WAYLAND=1`, which works on GNOME ≤ 49 after a one-time portal permission dialog and fixes the focus-bound hotkey from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404). The default GNOME session stays on XWayland (no rendering/IME regression risk); auto-selecting native Wayland on GNOME is deferred until it can be gated on a real render check. **On GNOME 50 / xdg-desktop-portal ≥ 1.20 the portal route is currently a no-op** — Electron/Chromium doesn't perform the portal's new host `Registry.Register` app-id handshake (filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875)). `CLAUDE_USE_WAYLAND` is now tri-state: `1` native Wayland, `0` force XWayland, unset auto-detects. ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404))
## [v2.0.18] — 2026-06-04
Tracks upstream Claude Desktop 1.10628.2.
### Fixed
- Tray icon no longer stuck black at startup on dark desktops. `nativeTheme.shouldUseDarkColors` reads `false` for the first ~50 ms then flips `true`, but the leading-edge rebuild mutex latched the transient `false` and dropped the corrective `"updated"` events; the mutex is now trailing-edge (re-applies the final value) and the obsolete 3 s startup-suppression window was removed. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680), fixes [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679))
- Restored the in-place tray `setImage` fast-path ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515)), which silently stopped applying after upstream changed the context-menu wiring from `setContextMenu(BUILDER())` to a prebuilt `setContextMenu(MENU)` object — `patch_tray_inplace_update` now resolves the builder in both shapes, so the duplicate-icon SNI race no longer regresses. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680))
- File-drop collector no longer re-attaches the app's own `app.asar` on every taskbar reopen. Electron's ASAR VFS shim returns `true` from `existsSync()` for `.asar` paths, so the second-instance argv collector dispatched `app.asar` to the file-drop handler and surfaced an attach prompt on each relaunch; it now rejects `.asar` paths, mirroring the existing `statSync` guard. ([#669](https://github.com/aaddrick/claude-desktop-debian/pull/669), fixes [#668](https://github.com/aaddrick/claude-desktop-debian/issues/668))
### Changed
- CI now runs a headless launch smoke test for the deb and rpm artifacts — previously only the AppImage actually booted, so a startup-only regression (e.g. the Fedora `SyntaxError`) could stay green on the formats it broke. A shared `run_launch_smoke_test` helper covers all three formats and gracefully skips when a container forbids Chromium's sandbox. ([#671](https://github.com/aaddrick/claude-desktop-debian/pull/671), closes [#670](https://github.com/aaddrick/claude-desktop-debian/issues/670))
## [v2.0.17] — 2026-06-04
Tracks upstream Claude Desktop 1.10628.2.
### Fixed
- `addTrustedFolder` `.asar` guard re-anchored on the `async addTrustedFolder(…)` method declaration. Upstream Claude Desktop 1.10628.x folded the `LocalAgentModeSessions.addTrustedFolder: ${i}` log call into a comma-expression inside an `if`, removing the trailing `` `); `` the old anchor matched — `./build.sh` aborted with `[FAIL] addTrustedFolder anchor not found`. Both the parameter extraction and the injection point now key off the unminified method name, so they can't drift apart if upstream drops the log line. ([#685](https://github.com/aaddrick/claude-desktop-debian/pull/685))
## [v2.0.16] — 2026-05-27
Tracks upstream Claude Desktop 1.9255.0.
### Fixed
- Cowork spawn guard now captures `$`-prefixed minified function names (e.g. `$Be`) and uses `globalThis._lastSpawn` instead of a bare `_globalLastSpawn` identifier, fixing `ReferenceError: _globalLastSpawn is not defined` that broke Cowork on all platforms with upstream 1.9255.0. ([#660](https://github.com/aaddrick/claude-desktop-debian/pull/660), fixes [#658](https://github.com/aaddrick/claude-desktop-debian/issues/658), [#659](https://github.com/aaddrick/claude-desktop-debian/issues/659), [#661](https://github.com/aaddrick/claude-desktop-debian/issues/661))
## [v2.0.15] — 2026-05-27
Tracks upstream Claude Desktop 1.9255.0.
### Fixed
- `StartupWMClass` aligned to `Claude` to match what Electron actually advertises via `productName`. The v2.0.14 value `claude-desktop` was silently ignored by Electron, causing orphan windows and duplicate gear icons on GNOME/KDE. Value centralized from 6 hardcoded locations to one source of truth in `build.sh`, with build-time substitution and a `productName` assertion guard. ([#655](https://github.com/aaddrick/claude-desktop-debian/pull/655), fixes [#652](https://github.com/aaddrick/claude-desktop-debian/issues/652))
- Tray variable extraction re-anchored on `.Tray()` literal instead of minifier-dependent syntax that upstream 1.9255.0 reshuffled. ([#657](https://github.com/aaddrick/claude-desktop-debian/pull/657), fixes [#656](https://github.com/aaddrick/claude-desktop-debian/issues/656))
## [v2.0.14] — 2026-05-25
Tracks upstream Claude Desktop 1.8555.2.
### Fixed
- `WM_CLASS` and `StartupWMClass` aligned to `claude-desktop` across all formats (deb, RPM, AppImage, autostart). Resolves ambiguity with the Claude Code CLI (`claude`) and ensures consistent taskbar grouping on KDE/GNOME. ([#648](https://github.com/aaddrick/claude-desktop-debian/pull/648), fixes [#647](https://github.com/aaddrick/claude-desktop-debian/issues/647))
### Changed
- AppImage smoke test: replaced flat 10s sleep with readiness-marker poll (30s ceiling, 0.5s tick), unified cleanup trap to prevent 190MB `squashfs-root` leaks on interrupt. ([#646](https://github.com/aaddrick/claude-desktop-debian/pull/646))
## [v2.0.13] — 2026-05-24
Tracks upstream Claude Desktop 1.8555.2.
### Added
- `CLAUDE_KEEP_AWAKE=0` env var to suppress `powerSaveBlocker` sleep inhibitor that upstream holds indefinitely on Linux (no lifecycle management). Adds diagnostic logging for all `powerSaveBlocker` calls and `--doctor` visibility. ([#605](https://github.com/aaddrick/claude-desktop-debian/issues/605))
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
- F11 fullscreen toggle via hidden menu accelerator — Linux parity with macOS green button / Windows F11. ([#638](https://github.com/aaddrick/claude-desktop-debian/pull/638), fixes [#580](https://github.com/aaddrick/claude-desktop-debian/issues/580))
- Linux org-plugins path (`/etc/claude/org-plugins`) added to platform switch, enabling MDM-managed plugin configuration. ([#639](https://github.com/aaddrick/claude-desktop-debian/pull/639), fixes [#607](https://github.com/aaddrick/claude-desktop-debian/issues/607))
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
- `--password-store` keyring detection: probes D-Bus for kwallet6 / gnome-libsecret at startup and injects the flag before the app path, fixing session persistence on KDE Plasma and other desktops where `safeStorage.isEncryptionAvailable()` returned false. Adds `CLAUDE_PASSWORD_STORE` env override and `--doctor` diagnostic. Thanks @dubreal. ([#611](https://github.com/aaddrick/claude-desktop-debian/pull/611), fixes [#593](https://github.com/aaddrick/claude-desktop-debian/issues/593))
- Unzip fallback for Node 24: detects missing electron binary after `extract-zip` silently no-ops and recovers from the `@electron/get` cache using system `unzip`. Thanks @JustinJLeopard. ([#631](https://github.com/aaddrick/claude-desktop-debian/pull/631), fixes [#584](https://github.com/aaddrick/claude-desktop-debian/issues/584))
### Fixed
- Config writes no longer drop externally-added `mcpServers`. The stale in-memory cache was overwriting disk on every preference change; now re-reads `mcpServers` from disk before each write. ([#643](https://github.com/aaddrick/claude-desktop-debian/pull/643), fixes [#400](https://github.com/aaddrick/claude-desktop-debian/issues/400))
- Menu bar toggle fires on Alt keyup only, not keydown — fixes Alt+Shift (language switch) and Alt+F4 accidentally triggering the menu bar. `CLAUDE_MENU_BAR=hidden` disables the Alt toggle entirely. ([#642](https://github.com/aaddrick/claude-desktop-debian/pull/642), fixes [#630](https://github.com/aaddrick/claude-desktop-debian/issues/630))
- `.asar` paths rejected in directory check, preventing Electron's ASAR VFS shim from dispatching `app.asar` to Cowork as a "folder drop". Fixes permission dialog on every launch, forced Cowork mode on reopen from tray, and "No conversation found" loop in Claude Code >=2.1.111. ([#640](https://github.com/aaddrick/claude-desktop-debian/pull/640), fixes [#383](https://github.com/aaddrick/claude-desktop-debian/issues/383), [#622](https://github.com/aaddrick/claude-desktop-debian/issues/622), [#632](https://github.com/aaddrick/claude-desktop-debian/issues/632))
- Identifier captures across all patch scripts hardened from `\w+` to `[$\w]+` (PCRE) / `[[:alnum:]_$]+` (ERE). Fixes broken idempotency guard in `tray.sh`, adds missing guards to `cowork.sh` patches 6/9/10, adds `\s*` whitespace tolerance to multiple patterns. ([#644](https://github.com/aaddrick/claude-desktop-debian/pull/644))
- `exec` before Electron invocation in deb, RPM, and Nix launchers so Ctrl+C and signals forward correctly to the Electron process. ([#637](https://github.com/aaddrick/claude-desktop-debian/pull/637), fixes [#424](https://github.com/aaddrick/claude-desktop-debian/issues/424))
- `--class=Claude` added to launcher args ensuring WM_CLASS matches `StartupWMClass` in the .desktop file, preventing GNOME extension crashes from unexpected class values. ([#636](https://github.com/aaddrick/claude-desktop-debian/pull/636), ref [#635](https://github.com/aaddrick/claude-desktop-debian/issues/635))
- Sloppy/focus-follows-mouse: suppress redundant `webContents.focus()` calls that trigger X11 `_NET_ACTIVE_WINDOW` raise-on-hover. Grace window handles stale `isFocused()` on tray-restore and minimize-restore. Thanks @tkrag. ([#589](https://github.com/aaddrick/claude-desktop-debian/pull/589), fixes [#416](https://github.com/aaddrick/claude-desktop-debian/issues/416))
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
- RPM: silence "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install` (replaces `%attr` in `%files`). Adds regression guard that fails the build if the warning reappears. Thanks @JoshuaVlantis. ([#610](https://github.com/aaddrick/claude-desktop-debian/pull/610), fixes [#609](https://github.com/aaddrick/claude-desktop-debian/issues/609))
- Window close with `CLAUDE_QUIT_ON_CLOSE=1` now actively quits via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux. Rides upstream's own quit-in-progress guard. Thanks @phelps-matthew. ([#624](https://github.com/aaddrick/claude-desktop-debian/pull/624), fixes [#623](https://github.com/aaddrick/claude-desktop-debian/issues/623))
- node-pty: wipe upstream Windows binaries (winpty.dll, winpty-agent.exe, Windows `.node` files) before staging the Linux build, preventing PE32+ orphans in the packaged asar. Thanks @JoshuaVlantis. ([#597](https://github.com/aaddrick/claude-desktop-debian/pull/597), addresses [#401](https://github.com/aaddrick/claude-desktop-debian/issues/401))
### Changed
- CI injection hardening: moved `${{ steps.*.outputs.* }}` expressions from `run:` blocks to `env:` blocks in `issue-triage-v2.yml`. Build pipeline: `process.exit(0)``process.exit(1)` in `quick-window.sh` when patch anchors aren't found so CI fails instead of shipping broken patches. Packaging scriptlets: replaced `&> /dev/null` with `> /dev/null 2>&1` for dash compatibility in deb/RPM postinst. ([#641](https://github.com/aaddrick/claude-desktop-debian/pull/641))
- Credit @lizthegrey, @sabiut, @typedrat, @RayCharlizard in README Acknowledgments. ([#626](https://github.com/aaddrick/claude-desktop-debian/pull/626))
- Troubleshooting: new "Repeated Electron Crashes / GPU Process FATAL" section documenting `CLAUDE_DISABLE_GPU=1`. Adds tuning-rationale comments around the `--doctor` 3-in-7-days threshold and the `coredumpctl` `COMM=electron` assumption. Thanks @sabiut. ([#615](https://github.com/aaddrick/claude-desktop-debian/pull/615), addresses [#608](https://github.com/aaddrick/claude-desktop-debian/issues/608))
- Docs filenames are now lowercase kebab-case (`docs/building.md`, `docs/configuration.md`, `docs/decisions.md`, `docs/troubleshooting.md`); `STYLEGUIDE.md` moved to [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md). Cross-references swept across README, CONTRIBUTING, CODEOWNERS, `.github/`, `.claude/`, `scripts/`, and `claude-desktop --doctor` user-facing output.
- `[$\w]+` is the codified identifier-capture convention for patch-script regexes (CONTRIBUTING § Patch-script regexes; `patch-engineer` agent examples updated to match). Closes a docs-vs-code gap that left the rule only in [`docs/learnings/patching-minified-js.md`](docs/learnings/patching-minified-js.md) — the same `\w+` trap fixed in patches by [#555](https://github.com/aaddrick/claude-desktop-debian/pull/555) and [#627](https://github.com/aaddrick/claude-desktop-debian/pull/627).
## [v2.0.12] — 2026-05-19
Tracks upstream Claude Desktop 1.7196.3.
### Added
- Headless launch + `--doctor` smoke tests for the AppImage artifact. ([#592](https://github.com/aaddrick/claude-desktop-debian/pull/592))
### Changed
- CI: add concurrency group to `test-flags` workflow. ([#606](https://github.com/aaddrick/claude-desktop-debian/pull/606))
## [v2.0.11] — 2026-05-16
Tracks upstream Claude Desktop 1.7196.1.
### Fixed
- Catch About window after upstream `titleBarStyle` change; guard Hardware Buddy. ([#481](https://github.com/aaddrick/claude-desktop-debian/pull/481), [#489](https://github.com/aaddrick/claude-desktop-debian/pull/489))
- RPM `chrome-sandbox` SUID now set via `%attr` instead of `%post chmod`. ([#539](https://github.com/aaddrick/claude-desktop-debian/pull/539), [#595](https://github.com/aaddrick/claude-desktop-debian/pull/595))
- No-op `autoUpdater` on Linux to defend against feed activation; mask thenable/coercion traps on the Proxy. ([#567](https://github.com/aaddrick/claude-desktop-debian/pull/567), [#596](https://github.com/aaddrick/claude-desktop-debian/pull/596))
- `node-pty` install fails loudly on `npm install` failure; require `gcc`/`make`/`python3`. ([#401](https://github.com/aaddrick/claude-desktop-debian/pull/401), [#598](https://github.com/aaddrick/claude-desktop-debian/pull/598))
- Fetch electron binary via `@electron/get`, drop `^41` pin; resolve from `work_dir` not script dir. ([#587](https://github.com/aaddrick/claude-desktop-debian/pull/587))
- Dedupe packages mapped from multiple commands.
## [v2.0.10] — 2026-05-06
Tracks upstream Claude Desktop 1.6259.0, 1.6259.1, 1.6608.0, 1.6608.2, 1.7196.0.
### Added
- `--doctor` surfaces recent Electron crashes with a `#583` pointer; `CLAUDE_DISABLE_GPU=1` opt-in for GPU-process fatal crashes. ([#583](https://github.com/aaddrick/claude-desktop-debian/pull/583), [#585](https://github.com/aaddrick/claude-desktop-debian/pull/585))
- `--doctor` detects IBus/GTK misconfigurations that break input. ([#572](https://github.com/aaddrick/claude-desktop-debian/pull/572))
- Launcher: `CLAUDE_GTK_IM_MODULE` opt-in override. ([#571](https://github.com/aaddrick/claude-desktop-debian/pull/571))
- Launcher: log session/IME env block at startup. ([#570](https://github.com/aaddrick/claude-desktop-debian/pull/570))
- Linux compatibility test harness. ([#579](https://github.com/aaddrick/claude-desktop-debian/pull/579))
- Lifecycle: notify and offer restart on in-place package upgrade. ([#564](https://github.com/aaddrick/claude-desktop-debian/pull/564))
- `desktopName` set for Wayland window grouping. Thanks @jslatten. ([#562](https://github.com/aaddrick/claude-desktop-debian/pull/562))
### Fixed
- Pin electron to `^41` to restore postinstall binary fetch. ([#584](https://github.com/aaddrick/claude-desktop-debian/pull/584), [#586](https://github.com/aaddrick/claude-desktop-debian/pull/586))
- Nix: make electron binary executable. ([#581](https://github.com/aaddrick/claude-desktop-debian/pull/581))
- `cowork.sh`: emit WARNING on Patch 2a/2b inner anchor miss. ([#576](https://github.com/aaddrick/claude-desktop-debian/pull/576))
- CI: force primary GPG key for `repomd.xml` signing. Thanks @ProfFlow. ([#566](https://github.com/aaddrick/claude-desktop-debian/pull/566))
- DNF: set `metadata_expire=1h` on generated `.repo`. ([#551](https://github.com/aaddrick/claude-desktop-debian/pull/551))
- BATS: isolate `cleanup_stale_cowork_socket` from host `pgrep` state. ([#534](https://github.com/aaddrick/claude-desktop-debian/pull/534))
### Changed
- Static-grep shipped asar for PR #555 markers as a verification step. ([#559](https://github.com/aaddrick/claude-desktop-debian/pull/559), [#575](https://github.com/aaddrick/claude-desktop-debian/pull/575))
- New `patching-minified-js` learnings doc + `CONTRIBUTING`. ([#574](https://github.com/aaddrick/claude-desktop-debian/pull/574))
- Refine `mcp-double-spawn` root cause and routing in learnings. ([#546](https://github.com/aaddrick/claude-desktop-debian/pull/546), [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547))
- Archive upstream report draft for #546 (filed as `anthropics/claude-code#55353`). ([#552](https://github.com/aaddrick/claude-desktop-debian/pull/552))
## [v2.0.8] — 2026-05-02
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.7).
### Fixed
- Cowork starts again on Claude Desktop 1.5354.0. Upstream's minifier started emitting `$`-containing identifiers (`C$i`, `g$i`); two regex anchors in `scripts/patches/cowork.sh` used `\w+`, which doesn't match `$`. Patch 2b silently no-op'd, the Swift VM module assignment never landed, and you'd hit `Swift VM addon not available` at session init. Widens both anchors to `[\w$]+`. Patch 6 also moves from `indexOf` to `lastIndexOf` on the retry-delay anchor. Thanks @sirfaber, @HumboldtJoker, @zabka. ([#555](https://github.com/aaddrick/claude-desktop-debian/pull/555), fixes [#558](https://github.com/aaddrick/claude-desktop-debian/issues/558), likely fixes [#553](https://github.com/aaddrick/claude-desktop-debian/issues/553) and [#445](https://github.com/aaddrick/claude-desktop-debian/issues/445))
## [v2.0.7] — 2026-05-01
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.6).
### Added
- Linux in-app topbar works now. New `hybrid` titlebar mode is the default: native OS frame plus a BrowserView preload shim that satisfies claude.ai's UA gate, so the hamburger, sidebar, search, and nav buttons render and are clickable. Layout is stacked (DE titlebar above the in-app topbar) rather than combined like Windows. Set `CLAUDE_TITLEBAR_STYLE=native` to opt out and hide the in-app topbar. The upstream `frame:false` + WCO config is preserved as `hidden` for investigation but still has unclickable buttons on Linux; `--doctor` warns when it's active. Verified on KDE Plasma X11/Wayland and Hyprland; GNOME, Sway, Niri, and NixOS pending. ([#538](https://github.com/aaddrick/claude-desktop-debian/pull/538))
## [v2.0.6] — 2026-05-01
Tracks upstream Claude Desktop 1.5354.0. Absorbs three upstream bumps from v2.0.5: 1.4758.0, 1.5220.0, 1.5354.0.
### Added
- Cowork bwrap mounts accept a `{src, dst}` form, so you can map a host directory under `$HOME` onto a different path inside the sandbox. Unlocks persistent-`/tmp` so Bash tool calls don't wipe state between invocations. String form unchanged. Thanks @cbonnissent. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
- `--doctor` warns when `COWORK_VM_BACKEND` is set to an unknown value instead of silently falling through to auto-detect; adds a `COWORK_VM_BACKEND` row and a Cowork Backend section to `docs/configuration.md`. Thanks @CyPack. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
- `--doctor` warns when an additional bwrap mount destination shadows a default sandbox path like `/usr`, `/etc`, `/bin`, `/sbin`, `/lib`. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
- Troubleshooting entries for Cowork VM connection timeout, virtiofsd outside `$PATH` on Fedora/RHEL (`/usr/libexec/virtiofsd`), and Fedora tmpfs `EXDEV` errors. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
### Fixed
- Closing the window no longer kills the app on Linux. The X button hides to tray, matching Windows and macOS. Quit explicitly with Ctrl+Q, the tray menu, or your DE's quit shortcut. Set `CLAUDE_QUIT_ON_CLOSE=1` to restore the old behavior. Fixes scheduled tasks and `/schedule` firings getting silently dropped overnight. Thanks @lizthegrey. ([#451](https://github.com/aaddrick/claude-desktop-debian/pull/451))
- "Run on startup" toggle persists on Linux now. Electron's `setLoginItemSettings` isn't implemented on Linux; the wrapper backs the toggle with `~/.config/autostart/claude-desktop.desktop` per the XDG Autostart spec. Thanks @lizthegrey. ([#450](https://github.com/aaddrick/claude-desktop-debian/pull/450), fixes [#128](https://github.com/aaddrick/claude-desktop-debian/issues/128))
- Tray icon updates in place on OS theme change instead of briefly duplicating on KDE Plasma. Uses `setImage` + `setContextMenu` rather than destroy + recreate. Thanks @IliyaBrook. ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515))
- Window visibility check works again after an upstream minified-name change broke it. Thanks @Andrej730. ([#496](https://github.com/aaddrick/claude-desktop-debian/pull/496), fixes [#495](https://github.com/aaddrick/claude-desktop-debian/issues/495))
### Changed
- APT/DNF install instructions point at `pkg.claude-desktop-debian.dev` directly, bypassing the GitHub Pages 301. Pages serves the redirect over `http://` because it can't provision a cert for the `pkg.` subdomain (DNS belongs to the Cloudflare Worker), and `apt` refuses HTTPS→HTTP downgrades. DNF was unaffected. ([#510](https://github.com/aaddrick/claude-desktop-debian/pull/510), [#514](https://github.com/aaddrick/claude-desktop-debian/pull/514))
## [v2.0.5] — 2026-04-23
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
### Fixed
- CI: smoke test accepts release-assets CDN hostname. ([#509](https://github.com/aaddrick/claude-desktop-debian/pull/509))
- Strip CRLF from `cowork-plugin-shim.sh` during staging. ([#499](https://github.com/aaddrick/claude-desktop-debian/pull/499), [#505](https://github.com/aaddrick/claude-desktop-debian/pull/505))
## [v2.0.4] — 2026-04-23
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
### Fixed
- CI: smoke test accepts `http://` on Pages 301 hop. ([#506](https://github.com/aaddrick/claude-desktop-debian/pull/506))
- Worker: use `raw.githubusercontent.com` as origin to avoid Pages 301 loop. ([#504](https://github.com/aaddrick/claude-desktop-debian/pull/504))
### Changed
- Worker: flip route from staging to production for Phase 4a. ([#503](https://github.com/aaddrick/claude-desktop-debian/pull/503))
## [v2.0.3] — 2026-04-23
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
### Added
- APT/DNF Worker scaffolding. ([#498](https://github.com/aaddrick/claude-desktop-debian/pull/498))
### Fixed
- CI: resolve DNF Worker chain blockers. ([#500](https://github.com/aaddrick/claude-desktop-debian/issues/500), [#501](https://github.com/aaddrick/claude-desktop-debian/issues/501), [#502](https://github.com/aaddrick/claude-desktop-debian/pull/502))
### Changed
- Plan APT/DNF distribution via Cloudflare Worker. ([#493](https://github.com/aaddrick/claude-desktop-debian/pull/493), [#494](https://github.com/aaddrick/claude-desktop-debian/pull/494))
## [v2.0.2] — 2026-04-22
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
### Added
- BATS unit tests for `launcher-common.sh`. ([#395](https://github.com/aaddrick/claude-desktop-debian/pull/395))
### Fixed
- Copy `ion-dist` static assets for the `app://` protocol handler. ([#490](https://github.com/aaddrick/claude-desktop-debian/pull/490))
## [v2.0.1] — 2026-04-21
Wrapper/packaging update; tracks upstream Claude Desktop 1.3561.0, 1.3883.0.
### Added
- Triage Phase 4 sub-PRs: Stage 8c enhancement-design variant, suspicious-input tells, `regression_of` + edit-during-triage. ([#470](https://github.com/aaddrick/claude-desktop-debian/pull/470), [#471](https://github.com/aaddrick/claude-desktop-debian/pull/471), [#472](https://github.com/aaddrick/claude-desktop-debian/pull/472))
- Triage Phase 3: Stage 6 adversarial reviewer + duplicate gate. ([#465](https://github.com/aaddrick/claude-desktop-debian/pull/465))
- Decision log with D-001 (auto-update direction). ([#477](https://github.com/aaddrick/claude-desktop-debian/pull/477))
- `@sabiut` added to CODEOWNERS for testing & release quality. ([#468](https://github.com/aaddrick/claude-desktop-debian/pull/468))
### Fixed
- Export `GDK_BACKEND=wayland` in native Wayland mode. Thanks @aJV99. ([#397](https://github.com/aaddrick/claude-desktop-debian/pull/397))
- Scope Ctrl+Q to the focused window, not system-wide. ([#484](https://github.com/aaddrick/claude-desktop-debian/pull/484))
- Cowork: forward `CLAUDE_CODE_OAUTH_TOKEN` to VM spawn env. ([#482](https://github.com/aaddrick/claude-desktop-debian/pull/482), [#485](https://github.com/aaddrick/claude-desktop-debian/pull/485))
- Launcher: disable GPU compositing on XRDP sessions. ([#475](https://github.com/aaddrick/claude-desktop-debian/pull/475))
- Triage: normalize `claimed_version` before drift compare. ([#483](https://github.com/aaddrick/claude-desktop-debian/pull/483))
- Triage: drift-as-banner — demote drift from gate to modifier. ([#476](https://github.com/aaddrick/claude-desktop-debian/pull/476))
- Triage: pull broken-expectation rule up into first-pass classify. ([#469](https://github.com/aaddrick/claude-desktop-debian/pull/469))
- Triage: raise 8b comment word cap 150 → 300. ([#464](https://github.com/aaddrick/claude-desktop-debian/pull/464))
### Changed
- Triage v2 production cutover; README synced with shipped pipeline (drop plan + research). ([#478](https://github.com/aaddrick/claude-desktop-debian/pull/478), [#480](https://github.com/aaddrick/claude-desktop-debian/pull/480))
- Rename `feature` classification to `enhancement` in triage. ([#466](https://github.com/aaddrick/claude-desktop-debian/pull/466))
## [v2.0.0] — 2026-04-20
First v2 wrapper release; tracks upstream Claude Desktop 1.3109.0, 1.3561.0.
### Added
- Always-on lifecycle logging for `cowork-vm-service`. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
- `cowork-vm-daemon` learnings doc and Anthropic & Partners plugin install flow doc. ([#439](https://github.com/aaddrick/claude-desktop-debian/pull/439))
- `.github/CODEOWNERS` for per-subsystem review ownership.
- `shellcheck -x` to follow sourced modules in CI.
### Fixed
- Restore `cowork-vm-service` daemon recovery after crash. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
- Forward `userSelectedFolders[0]` as `sharedCwdPath` on cowork spawn. ([#412](https://github.com/aaddrick/claude-desktop-debian/pull/412), [#436](https://github.com/aaddrick/claude-desktop-debian/pull/436))
- Strip mode on `node-pty` cp at source; retire `chmod`. Chmod `node-pty` unpacked files before overwriting in Nix builds. ([#432](https://github.com/aaddrick/claude-desktop-debian/pull/432), [#438](https://github.com/aaddrick/claude-desktop-debian/pull/438))
- Diagnose AppArmor userns block on bwrap probe. ([#351](https://github.com/aaddrick/claude-desktop-debian/issues/351), [#434](https://github.com/aaddrick/claude-desktop-debian/pull/434))
- Suppress Cowork tab auto-select on every launch. ([#341](https://github.com/aaddrick/claude-desktop-debian/issues/341), [#433](https://github.com/aaddrick/claude-desktop-debian/pull/433))
- `home --dir` before SDK `--ro-bind` in bwrap sandbox. ([#426](https://github.com/aaddrick/claude-desktop-debian/pull/426))
- Only route `claude` commands through SDK binary in `cowork-vm-service`. ([#430](https://github.com/aaddrick/claude-desktop-debian/pull/430))
- `launcher-common.sh` self-match and stale socket cleanup. ([#407](https://github.com/aaddrick/claude-desktop-debian/pull/407), [#425](https://github.com/aaddrick/claude-desktop-debian/pull/425))
- Translate guest paths inside `--allowedTools` and `--disallowedTools`. ([#411](https://github.com/aaddrick/claude-desktop-debian/pull/411))
- Resolve working directory from primary mount on HostBackend. ([#392](https://github.com/aaddrick/claude-desktop-debian/pull/392))
### Changed
- **BREAKING**: Split `build.sh` into topical modules under `scripts/`; relocate packaging scripts into `scripts/packaging/`; extract `--doctor` into `scripts/doctor.sh`. Patch files now live in `scripts/patches/*.sh` (one per subsystem); `build.sh` is just an orchestrator. CI paths updated to `scripts/setup/detect-host.sh`.
- Simplify cowork daemon recovery patch. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
[Unreleased]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.13+claude1.8555.2...HEAD
[v2.0.13]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.12+claude1.8555.2...v2.0.13+claude1.8555.2
[v2.0.12]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.11+claude1.7196.1...v2.0.12+claude1.7196.3
[v2.0.11]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.10+claude1.7196.0...v2.0.11+claude1.7196.1
[v2.0.10]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.8+claude1.5354.0...v2.0.10+claude1.6259.0
[v2.0.8]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.7+claude1.5354.0...v2.0.8+claude1.5354.0
[v2.0.7]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.6+claude1.5354.0...v2.0.7+claude1.5354.0
[v2.0.6]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.5+claude1.5354.0...v2.0.6+claude1.5354.0
[v2.0.5]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.4+claude1.3883.0...v2.0.5+claude1.3883.0
[v2.0.4]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.3+claude1.3883.0...v2.0.4+claude1.3883.0
[v2.0.3]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.2+claude1.3883.0...v2.0.3+claude1.3883.0
[v2.0.2]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.1+claude1.3883.0...v2.0.2+claude1.3883.0
[v2.0.1]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.0+claude1.3561.0...v2.0.1+claude1.3883.0
[v2.0.0]: https://github.com/aaddrick/claude-desktop-debian/releases/tag/v2.0.0+claude1.3109.0

View File

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

View File

@@ -1,161 +0,0 @@
# 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,8 +4,6 @@ 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)**
@@ -137,7 +135,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
@@ -146,13 +144,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
@@ -187,7 +185,6 @@ 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
@@ -201,9 +198,6 @@ 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
@@ -233,7 +227,6 @@ 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
@@ -249,8 +242,6 @@ 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
@@ -268,34 +259,6 @@ Special thanks to:
- **[zabka](https://github.com/zabka)** for identifying that `cowork-vm-service.js` was never auto-spawned on Linux and contributing a systemd-unit workaround that scoped the daemon auto-launch fix (#445)
- **[sirfaber](https://github.com/sirfaber)** for fixing the `$`-in-minified-identifier breakage of cowork Patch 2b (vm module assignment) and Patch 6 step 2 (retry-delay auto-launch) on Claude Desktop 1.5354.0 (#555)
- **[ProfFlow](https://github.com/ProfFlow)** for re-fixing the RPM repodata signing regression by appending `!` to the keyid passed to `gpg --default-key`, forcing `repomd.xml` to be signed by the primary key instead of the auto-selected signing subkey (#566)
- **[jslatten](https://github.com/jslatten)** for fixing the KDE Plasma Wayland launcher-grouping bug by setting `pkg.desktopName` in the packaged `app.asar`'s `package.json`, format-conditional so deb/rpm get `claude-desktop.desktop` and AppImage gets `io.github.aaddrick.claude-desktop-debian.desktop` (#562)
- **[JoshuaVlantis](https://github.com/JoshuaVlantis)**
- RPM `chrome-sandbox` SUID via `%attr(4755, ...)` instead of a `%post` chmod scriptlet so the bit survives `--noscripts` and layered images (#539)
- `autoUpdater` no-op Proxy on Linux that defends against future feed activation, with a thenable allowlist masking `then`/`catch`/`finally`/`Symbol.toPrimitive`/`Symbol.iterator` to `undefined` (#567)
- Failing loudly on `npm install node-pty` failures instead of silently shipping the upstream Windows binaries, plus auto-installing `gcc`/`g++`/`make`/`python3` on minimal build environments (#401)
- Silencing the RPM "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install`, with thorough investigation of four `%exclude`-based alternatives (#610)
- Cleaning upstream Windows binaries from node-pty before staging the Linux build, preventing PE32+ orphans in the packaged asar (#597)
- **[Hayao0819](https://github.com/Hayao0819)** for diagnosing the upstream `titleBarStyle:""``titleBarStyle:"hiddenInset"` migration that broke the About window render on GNOME/X11 and contributing the `isPopupWindow()` match extension (#481, #489)
- **[michelsfun](https://github.com/michelsfun)** for reporting the cowork `ENAMETOOLONG` failure on eCryptfs-encrypted home directories with detailed `--doctor` output that pinpointed the short-NAME_MAX filesystem as the cause (#590)
- **[proffalken](https://github.com/proffalken)** for the LUKS-volume + `pam_mount` workaround documented in `docs/troubleshooting.md`, restoring cowork support on legacy eCryptfs-encrypted home directories (#590)
- **[phelps-matthew](https://github.com/phelps-matthew)** for fixing `CLAUDE_QUIT_ON_CLOSE=1` to actively quit via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux, with thorough root cause analysis and alternatives evaluation (#624, #623)
- **[dubreal](https://github.com/dubreal)** for `--password-store` keyring detection that probes D-Bus for kwallet6 / gnome-libsecret at startup, fixing session persistence on KDE Plasma and other desktops where Electron's `safeStorage` was unavailable (#611, #593)
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for detecting missing electron binaries after Node 24's `extract-zip` silently no-ops, with an `unzip` fallback that recovers from the `@electron/get` cache (#631, #584)
- **[tkrag](https://github.com/tkrag)** for diagnosing and fixing the X11 window-raise-on-hover bug under sloppy/focus-follows-mouse WMs, tracing the upstream `webContents.focus()``_NET_ACTIVE_WINDOW` path through three iterations of review (#589, #416)
- **[maplefater](https://github.com/maplefater)** for re-anchoring the `addTrustedFolder` `.asar` guard on the `async addTrustedFolder(…)` method declaration after upstream 1.10628.x folded the log call into a comma-expression, keying both the parameter extraction and the injection point off the unminified method name so they can't drift apart (#685)
- **[MitchSchwartz](https://github.com/MitchSchwartz)** for finding the second `app.asar` file-drop path — the `existsSync()` branch in the second-instance argv collector that #640 never guarded — and rejecting `.asar` paths there so the app no longer prompts to attach its own bundle on every taskbar reopen (#669, #668)
- **[LiukScot](https://github.com/LiukScot)** for making the tray rebuild mutex trailing-edge so the startup dark-theme icon no longer latches black, and restoring the in-place `setImage` fast-path after upstream changed the context-menu wiring to a prebuilt menu object (#680, #679)
- **[sabiut](https://github.com/sabiut)**
- BATS coverage for `cleanup_orphaned_cowork_daemon`, mutation-tested so the kill/escalation branches genuinely bite (#693)
- Fixing two false-green `--doctor` PASSes: an empty password store read as healthy, and a non-numeric `df` reading falling through to the PASS branch (#692)
- Extending the artifact launch smoke tests to arm64 on native `ubuntu-22.04-arm` runners, and re-keying the AppImage pkill sweep to `mount_claude` so escaped zygote/electron children stop leaking on the runner (#691)
- **[jerem](https://github.com/jerem)** for routing Quick Entry's global shortcut through the XDG GlobalShortcuts portal on native Wayland, and merging all Chromium feature requests into a single `--enable-features=` switch — the old code silently clobbered `WindowControlsOverlay` (#690, #404)
- **[caidejager](https://github.com/caidejager)** for diagnosing why Cowork's VM daemon never auto-launched on packages built under a restrictive umask — `app.asar.unpacked/` shipped mode `0700`, failing the auto-launch `existsSync()` guard — and normalizing install permissions across deb and AppImage, with `dpkg-deb --root-owner-group` closing a build-uid write exposure (#695)
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for the AppStream metainfo that surfaces the package in GNOME Software, KDE Discover, and App Center, wired into the deb, rpm, and AppImage builds (#633)
- **[DhanushSantosh](https://github.com/DhanushSantosh)** for the GPU crash auto-recovery in the launcher: detecting a previous GPU-process FATAL in the launcher log and re-launching with safe GPU flags automatically, instead of leaving users to discover `CLAUDE_DISABLE_GPU=1` by hand (#666)
- **[diarized](https://github.com/diarized)** for auto-installing scoped AppArmor userns profiles from the `.deb` postinst on Ubuntu 24.04+ — one for the bundled Electron binary (fixing the launch crash without `--no-sandbox`) and one for `/usr/bin/bwrap` (keeping Cowork's sandbox isolated instead of silently falling back to host-direct), automating the workaround from #351 (#687, #694)
- **[emandel82](https://github.com/emandel82)** for root-causing the "Attach app.asar?" prompt: every launcher passed `app.asar` as a redundant Electron argument, which the second-instance argv collector treated as a file to open — removed at the source across all four package formats (#700, #696)
- **[svankirk](https://github.com/svankirk)** for cleaning up Desktop helper processes after an explicit quit — a quit wrapper with signal forwarding and a bundle-keyed live-UI check, so closing the app no longer strands helper processes (#682)
## Sponsorship

View File

@@ -1,80 +0,0 @@
# 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.

View File

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

View File

@@ -36,8 +36,6 @@ final_output_path=''
# Package metadata (constants)
readonly PACKAGE_NAME='claude-desktop'
readonly WM_CLASS='Claude'
export WM_CLASS
readonly MAINTAINER='Claude Desktop Linux Maintainers'
readonly DESCRIPTION='Claude Desktop for Linux'
@@ -62,12 +60,8 @@ source "$script_dir/scripts/patches/quick-window.sh"
source "$script_dir/scripts/patches/claude-code.sh"
# shellcheck source=scripts/patches/cowork.sh
source "$script_dir/scripts/patches/cowork.sh"
# shellcheck source=scripts/patches/org-plugins.sh
source "$script_dir/scripts/patches/org-plugins.sh"
# shellcheck source=scripts/patches/wco-shim.sh
source "$script_dir/scripts/patches/wco-shim.sh"
# shellcheck source=scripts/patches/config.sh
source "$script_dir/scripts/patches/config.sh"
# shellcheck source=scripts/staging/electron.sh
source "$script_dir/scripts/staging/electron.sh"
# shellcheck source=scripts/staging/icons.sh
@@ -161,7 +155,7 @@ Type=Application
Terminal=false
Categories=Office;Utility;Network;
MimeType=x-scheme-handler/claude;
StartupWMClass=$WM_CLASS
StartupWMClass=Claude
X-AppImage-Version=$version
X-AppImage-Name=Claude Desktop (AppImage)
EOF

View File

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

View File

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

259
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,259 @@
[< 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

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

View File

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

View File

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

View File

@@ -37,67 +37,46 @@ external services with single-connection contracts, etc.
## Root Cause (Upstream)
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:
Two parallel session managers live inside Electron main, each
holding an independent Claude Agent SDK `query`:
| 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 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.
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.
Net result: 2 coordinators × N configured MCPs = 2N processes.
### 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.
Symbol names (`n2t`, `hZ`, `oUt`, `LocalSessions`,
`LocalAgentModeSessions`) are minified and **will rename across
upstream releases**.
## Status
**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:
**Upstream Claude Desktop bug. Not patchable in this repo.** A
fix would require either:
- 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.
- 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.
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.
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.
## What's Already Verified Clean
@@ -139,15 +118,13 @@ 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, in the per-coordinator registry
wiring.
- **Secondary:** an issue on
closed-source Desktop main.
- **Secondary:** an SDK-transport-flavored issue on
[`anthropics/claude-agent-sdk-typescript`](https://github.com/anthropics/claude-agent-sdk-typescript)
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.
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.
The embedded Claude Code CLI subprocess inside Claude Desktop is
**not** the cause — it receives `--mcp-config` only when the

View File

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

View File

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

View File

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

View File

@@ -1,144 +0,0 @@
[< 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,6 +11,7 @@ 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,10 +16,11 @@ tests, which anti-patterns to design against, and what to build first.
## Why this exists
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:
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:
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
@@ -39,7 +40,7 @@ rather than the other way around. Three things 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 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. |
| 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. |
| 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. |
@@ -52,7 +53,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 | `playwright-electron` (`_electron.launch()`) directly |
| **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 |
| **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

@@ -0,0 +1,347 @@
# 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).
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/).
## Files

View File

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

@@ -0,0 +1,322 @@
# 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

@@ -0,0 +1,218 @@
# 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

@@ -0,0 +1,415 @@
# 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

@@ -0,0 +1,490 @@
# 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,6 +50,14 @@ 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,11 +1,16 @@
# Quick Entry — Upstream Contract + Test Index
# Quick Entry Closeout Test Plan
Reference doc for the Quick Entry surface. Two halves:
Focused sweep plan for closing the three open Quick Entry issues:
- [§ 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.
- [#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.
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.
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).
## Upstream design intent
@@ -53,7 +58,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
| QE-3 | Critical | App on a different workspace, press shortcut | Popup appears on current workspace | [T06](./cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
| QE-4 | Critical | App closed-to-tray (no window mapped), press shortcut | Popup appears | [S29](./cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity) |
| QE-5 | Should | App quit entirely, press shortcut | No popup, no error, no zombie process | [S30](./cases/shortcuts-and-input.md#s30--quick-entry-shortcut-becomes-a-no-op-after-full-app-exit) |
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` (the GNOME default) vs `Using native Wayland backend (global shortcuts via XDG portal)` (after `CLAUDE_USE_WAYLAND=1`). | **Launcher implemented (S12).** GNOME defaults to XWayland (no portal flag); launching with `CLAUDE_USE_WAYLAND=1` adds `--ozone-platform=wayland` and a single `--enable-features=…,GlobalShortcutsPortal` (comma-joined with the ozone features, not a standalone token). On that opt-in path QE-2 / QE-3 pass on **GNOME ≤ 49** after the one-time portal dialog; on **GNOME 50 / xdg-desktop-portal ≥ 1.20** they don't yet — Electron skips the portal's `Registry.Register` handshake ([electron#51875](https://github.com/electron/electron/issues/51875)). | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` vs `Using native Wayland backend (global hotkeys may not work)` (verbatim from `scripts/launcher-common.sh:98, 102`). | **Pre-S12 fix:** flag absent; shortcut fails on GNOME Wayland (this is the #404 repro). **Post-S12 fix:** `--enable-features=GlobalShortcutsPortal` present in argv on GNOME Wayland; QE-2 / QE-3 begin to pass. | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
### Submit → main window — covers #393
@@ -72,9 +77,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) | |
| 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-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-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
@@ -87,7 +92,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. | |
| 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) |
### Popup placement & lifecycle — upstream contract sanity
@@ -99,9 +104,91 @@ 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
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):
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):
| Case | Title | Backs |
|------|-------|-------|
@@ -115,4 +202,24 @@ The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases
| [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 |
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).
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.

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/). 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/) 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.
## When to sweep
@@ -315,6 +315,9 @@ 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

@@ -0,0 +1,238 @@
# 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

@@ -0,0 +1,597 @@
# 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

@@ -0,0 +1,12 @@
{
"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

View File

@@ -0,0 +1,76 @@
# 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

@@ -0,0 +1,360 @@
{
"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,"
]
}

78
docs/testing/ui/README.md Normal file
View File

@@ -0,0 +1,78 @@
# 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

@@ -0,0 +1,114 @@
# 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

@@ -0,0 +1,70 @@
# 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

@@ -0,0 +1,59 @@
# 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

@@ -0,0 +1,76 @@
# 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

@@ -0,0 +1,49 @@
# 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

@@ -0,0 +1,72 @@
# 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

@@ -0,0 +1,87 @@
# 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

@@ -0,0 +1,55 @@
# 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` |

44
docs/testing/ui/tray.md Normal file
View File

@@ -0,0 +1,44 @@
# 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

@@ -0,0 +1,58 @@
# 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 |

View File

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

View File

@@ -1,164 +0,0 @@
# 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": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"type": "github"
},
"original": {
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1781607440,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"type": "github"
},
"original": {
@@ -36,11 +36,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"lastModified": 1774748309,
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
"type": "github"
},
"original": {

View File

@@ -16,16 +16,16 @@
}:
let
pname = "claude-desktop";
version = "1.14271.0";
version = "1.5354.0";
srcs = {
x86_64-linux = fetchurl {
url = "https://downloads.claude.ai/releases/win32/x64/1.14271.0/Claude-c8f4d811b076f6d3bb0a320ac9da463cd82a6a11.exe";
hash = "sha256-Oru4BdZ3R5qvLTIO9oVglLL4Tu+SGXFcU58j6okDRVQ=";
url = "https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
hash = "sha256-5hnHvTtnRqcwfr7+UJv+RHoUOu2X5sf2Zmd7Nqa2ulQ=";
};
aarch64-linux = fetchurl {
url = "https://downloads.claude.ai/releases/win32/arm64/1.14271.0/Claude-c8f4d811b076f6d3bb0a320ac9da463cd82a6a11.exe";
hash = "sha256-HlzA7e+BVb+EBC84r8zdF8TJhuCDXRfzKpiM+DDspVM=";
url = "https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
hash = "sha256-v33l1sASVC/q331cqnenLfzqGyRRLpptKOAEukrioR0=";
};
};
@@ -124,7 +124,6 @@ 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
@@ -239,7 +238,6 @@ fi
setup_logging || exit 1
setup_electron_env
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
@@ -247,7 +245,6 @@ 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
@@ -263,20 +260,15 @@ detect_display_backend
# Build Electron arguments
build_electron_args 'nix'
# Intentionally NOT appended: app.asar sits in Electron's default
# resources/ dir next to the binary, so Electron auto-loads it. Passing
# the path again makes Electron treat it as a file-to-open, which the
# app forwards to its file-drop handler, producing a spurious
# "Attach app.asar?" prompt on launch and on every taskbar reopen
# (the second-instance argv path). Omitting it is the root-cause fix.
# See issue #696.
log_message "App (auto-loaded by Electron): $app_path"
# Add app path
electron_args+=("$app_path")
# Execute Electron and keep the launcher alive so explicit quit can
# clean up Desktop-owned helpers that outlive the Electron main process.
# Execute Electron
log_message "Executing: $electron_exec ''${electron_args[*]} $*"
run_electron_and_cleanup "$electron_exec" "''${electron_args[@]}" "$@"
exit $?
"$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
exit_code=$?
log_message "Electron exited with code: $exit_code"
exit $exit_code
LAUNCHER
# Substitute placeholders electron_exec points to our custom
# wrapper (which sets GTK/GIO env then execs our merged binary)

View File

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

View File

@@ -1,31 +0,0 @@
# Cowork patch markers — single source of truth.
#
# Format:
# <name><TAB><pcre_pattern><TAB><sample>
# Lines starting with '#' and blank lines are ignored.
#
# Each row names a post-patch fingerprint from the patch suite in
# scripts/patches/. Both verify-patches.sh and tests/verify-patches.bats
# consume this file, so adding a marker here adds it to the runtime
# check and the test matrix at the same time.
#
# Columns:
# name — kebab-case id; surfaces in verify output and BATS names.
# pattern — PCRE matched against the shipped index.js by `grep -P`.
# sample — concrete string the pattern matches; BATS uses it to
# build positive and per-marker negative fixtures.
#
# The first 9 markers correspond to the smoke-test set defined in
# issue #559 (PR #555 retrofit, deliverable D6). Additional markers
# cover other critical patches (e.g., .asar guards).
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
empty-linux-bundle-manifest linux:\{x64:\[\],arm64:\[\]\} ,linux:{x64:[],arm64:[]}
getdownloadstatus-suppression getDownloadStatus\(\)\{return process\.platform==="linux"\?[\w$]+\.NotDownloaded getDownloadStatus(){return process.platform==="linux"?Z.NotDownloaded
econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED" (n.code==="ENOENT"||process.platform==="linux"&&n.code==="ECONNREFUSED")
cowork-daemon-pid global\.__coworkDaemonPid global.__coworkDaemonPid=_c.pid
cowork-linux-daemon-shutdown cowork-linux-daemon-shutdown name:"cowork-linux-daemon-shutdown"
sharedcwdpath-threadthrough sharedCwdPath:this\.sessions\.get\( sharedCwdPath:this.sessions.get(t)?.userSelectedFolders?.[0]
asar-adddir-filter \.filter\(_d=>!_d\.endsWith\("\.asar"\)\).*"--add-dir" .filter(_d=>!_d.endsWith(".asar")))Y.push("--add-dir"
asar-file-drop-guard \.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\) .startsWith("-")&&!i.endsWith(".asar")
Can't render this file because it contains an unexpected character in line 21 and column 39.

View File

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

View File

@@ -1,4 +1,3 @@
# shellcheck shell=bash
#===============================================================================
# Doctor Diagnostics
#
@@ -6,9 +5,8 @@
# per-package launcher scripts — deb, rpm, AppImage, Nix).
#
# Provides: run_doctor (the `claude-desktop --doctor` entry point) plus its
# internal helpers. Self-contained except for the WM_CLASS constant defined
# at the top of launcher-common.sh (substituted at build time), which the
# live-UI fingerprint in the orphaned-daemon check reads at runtime.
# internal helpers. Self-contained — no dependencies on launcher-common.sh
# state or functions.
#
# To add a new check: define an internal function `_check_<name>`, call it
# from run_doctor in the appropriate section, use _pass / _fail / _warn /
@@ -73,110 +71,12 @@ _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() {
@@ -438,237 +338,6 @@ JSEOF
fi
}
# Diagnose short-filename-limit filesystems that break cowork session
# initialization. Claude Code creates a per-session directory under
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
# sessions that flattens to ~180 chars (the host CWD is the deeply
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
# overhead — that mkdir fails with ENAMETOOLONG and the session never
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
# #590.
_doctor_check_filename_limit() {
# Walk up from ~/.claude/projects to the first dir that exists so
# getconf has something to query on a fresh install where the tree
# hasn't been created yet. $HOME is the floor — stop there rather
# than crossing into /.
local probe_dir="$HOME/.claude/projects"
while [[ ! -d $probe_dir ]]; do
probe_dir=$(dirname "$probe_dir")
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
done
[[ -d $probe_dir ]] || return 0
local name_max
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
[[ $name_max =~ ^[0-9]+$ ]] || return 0
# Force base 10 so a leading zero can't trip octal arithmetic.
name_max=$((10#$name_max))
((name_max >= 200)) && return 0
_warn "Filename limit: NAME_MAX=$name_max on $probe_dir (< 200)"
_info \
'Cowork sessions create project-dir names up to ~180 chars' \
'under ~/.claude/projects/; short limits cause ENAMETOOLONG'
_info 'when Claude Code initializes a session inside cowork (#590).'
local fs_type
fs_type=$(df --output=fstype "$probe_dir" 2>/dev/null \
| awk 'NR==2 {print $1}')
if [[ $fs_type == 'ecryptfs' ]]; then
_info \
'Detected eCryptfs (legacy Ubuntu/Mint encrypted home,' \
'NAME_MAX=143 due to filename-encryption overhead).'
_info \
'Workaround: move ~/.config/Claude onto a separate' \
'LUKS-encrypted ext4 volume (NAME_MAX=255) and symlink it'
_info \
'back. See docs/troubleshooting.md "Cowork: ENAMETOOLONG' \
'on encrypted home (eCryptfs)" for the worked steps.'
fi
}
# Surface a warning when systemd-coredump shows N+ recent Electron
# crashes. The most common cause on Linux is the GPU process FATAL
# exhaustion tracked in #583 — workaround for affected users is the
# upstream Settings → disable hardware acceleration toggle, or
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
#
# Arguments: $1 = electron path (e.g.,
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
# Used to filter results to claude-desktop's electron when possible;
# falls back to all-electron crashes when the path doesn't match
# (e.g., AppImage mount paths are transient).
_doctor_check_recent_crashes() {
local electron_path="${1:-}"
command -v coredumpctl &>/dev/null || return 0
# `coredumpctl list electron` filters by COMM=electron. If the
# exact electron_path matches any entry's EXE column, prefer that
# tighter count; otherwise fall back to all-electron entries.
local listing total_count path_count
listing=$(coredumpctl list electron \
--since='7 days ago' --no-pager 2>/dev/null) || return 0
[[ -n $listing ]] || return 0
# Drop the header line; count remaining entries.
# Assumes `coredumpctl list electron`'s COMM=electron filter
# excludes `-- Reboot --` separator rows from the listing (true
# on systemd as of writing). The path-matched branch below uses
# index($0, p) so it's unaffected even if that ever changes;
# revisit this total-count branch if a future systemd version
# starts leaking reboot markers into per-COMM listings.
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
((total_count == 0)) && return 0
if [[ -n $electron_path ]]; then
path_count=$(awk -v p="$electron_path" \
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
else
path_count=0
fi
# Use the path-matched count when available; else the unfiltered
# count with a footnote so the user knows it may include other
# Electron apps (Slack, VSCode, etc.).
local count footnote=''
if ((path_count > 0)); then
count=$path_count
else
count=$total_count
footnote=' (some entries may be from other Electron apps)'
fi
# Threshold tuned against the #583 repro (~10 crashes over 7 days
# on the affected laptop); a noisy session typically clears 3 in a
# week, so 3 is the floor for "worth surfacing the workaround".
if ((count >= 3)); then
_warn "Recent Electron crashes: $count in last 7 days$footnote"
_info \
'Most common cause: Chromium GPU process FATAL (#583).' \
'Try one of:'
_info ' Settings → toggle hardware acceleration off → restart'
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
_info \
'Tracking:' \
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
elif ((count > 0)); then
_info "Recent Electron crashes: $count in last 7 days$footnote"
fi
}
# Report the active Chromium password-store backend.
#
# Calls _detect_password_store() (defined in launcher-common.sh, which
# sources this file) to surface what keyring Electron will use for
# safeStorage / cookie encryption. 'basic' is valid but means tokens
# rely on filesystem permissions alone, so we note it for visibility.
# An empty result means detection itself failed (e.g. a sourcing-order
# regression) and warns rather than emitting a green PASS with a blank
# value.
_doctor_check_password_store() {
local store
store=$(_detect_password_store)
if [[ -z $store ]]; then
_warn 'Password store: unable to detect backend'
return
fi
_pass "Password store: $store"
if [[ $store == 'basic' ]]; then
_info \
' → using fixed-key fallback;' \
'tokens are protected by filesystem permissions only'
fi
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
_info \
" → overridden by CLAUDE_PASSWORD_STORE=${CLAUDE_PASSWORD_STORE}"
fi
}
# Report free space on the partition holding the Claude config dir.
# Arguments: $1 = config directory to check.
#
# Skips when df is unavailable or yields a non-numeric value, leaving
# an _info line so the summary never claims a pass over an unrun
# check: better a visible skip than a green PASS reporting space we
# could not read.
_doctor_check_disk_space() {
local config_dir="$1"
local avail
avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
| tail -1 | tr -d ' M') || true
if [[ ! $avail =~ ^[0-9]+$ ]]; then
_info 'Disk space: unable to read (df)'
return 0
fi
# Force base 10: a leading zero ("0099") would otherwise make
# (( )) parse the value as octal and error out, falling through
# to the PASS branch.
avail=$((10#$avail))
if ((avail < 100)); then
_fail "Disk space: ${avail}MB free on config partition"
_info 'Fix: Free up disk space'
elif ((avail < 500)); then
_warn "Disk space: ${avail}MB free" \
"on config partition (low)"
else
_pass "Disk space: ${avail}MB free"
fi
}
# Report the installed claude-desktop version from the package manager
# that actually owns the install (#711). On dual-DB hosts (e.g. a
# Fedora box with dpkg installed for deb work) a stale dpkg record
# must not shadow the live rpm install, so rpm ownership of the real
# Electron binary is probed first: `rpm -qf <path>` succeeds only when
# rpm installed the file, which a stale dpkg record can never claim.
# dpkg is consulted only when rpm does not own the path.
#
# AppImage and Nix installs (no package owns the path) keep the
# existing not-found warn; hosts with no package tools stay silent.
#
# Usage: _doctor_check_pkg_version <electron_path>
_doctor_check_pkg_version() {
local electron_path="${1:-}"
local probe_path="$electron_path"
local pkg_version=''
if [[ -z $probe_path ]]; then
probe_path='/usr/lib/claude-desktop'
probe_path+='/node_modules/electron/dist/electron'
fi
# rpm branch: query the file, not the package name, so the answer
# comes from the database that owns the actual install.
if command -v rpm &>/dev/null; then
pkg_version=$(rpm -qf --qf '%{VERSION}-%{RELEASE}' \
"$probe_path" 2>/dev/null) || pkg_version=''
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
return 0
fi
fi
# dpkg branch: only consulted when rpm does not own the install.
if command -v dpkg-query &>/dev/null; then
pkg_version=$(dpkg-query -W -f='${Version}' \
claude-desktop 2>/dev/null) || pkg_version=''
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
return 0
fi
fi
# Neither manager knows the install — AppImage or Nix. Only warn
# when a package tool exists; with none there is nothing to say.
if command -v rpm &>/dev/null \
|| command -v dpkg-query &>/dev/null; then
_warn 'claude-desktop not found via dpkg/rpm (AppImage?)'
fi
}
# Run all diagnostic checks and print results
# Arguments: $1 = electron path (optional, for package-specific checks)
run_doctor() {
@@ -676,17 +345,21 @@ run_doctor() {
local _doctor_failures=0
_doctor_colors
# Distro ID is shared between the IM-module check (#550) and the
# Cowork Mode section further down. Resolve once.
local _distro_id
_distro_id=$(_cowork_distro_id)
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
echo '================================'
echo
# -- Installed package version --
_doctor_check_pkg_version "$electron_path"
if command -v dpkg-query &>/dev/null; then
local pkg_version
pkg_version=$(dpkg-query -W -f='${Version}' \
claude-desktop 2>/dev/null) || true
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
else
_warn 'claude-desktop not found via dpkg (AppImage?)'
fi
fi
# -- Display server --
if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
@@ -708,9 +381,6 @@ 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
@@ -759,14 +429,6 @@ run_doctor() {
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
fi
# -- Keep awake override --
local keep_awake="${CLAUDE_KEEP_AWAKE:-}"
if [[ $keep_awake == '0' ]]; then
_pass 'Keep awake: suppressed (CLAUDE_KEEP_AWAKE=0)'
elif [[ -n $keep_awake ]]; then
_info "Keep awake: CLAUDE_KEEP_AWAKE=$keep_awake (default behavior)"
fi
# -- Electron binary --
# Version is read from the file next to the binary rather than
# launching Electron, which can hang (see #371).
@@ -822,74 +484,6 @@ run_doctor() {
_warn 'Chrome sandbox not found (expected for AppImage)'
fi
# -- User-namespace sandbox (Ubuntu 24.04+ AppArmor) --
# Ubuntu 24.04+ sets apparmor_restrict_unprivileged_userns=1, which
# blocks the user namespaces Chromium's sandbox needs and crashes the
# app on launch (credentials.cc FATAL, exit 133). A scoped AppArmor
# profile permits them for Claude only. Only report when the
# restriction is actually in force — on other distros the knob is
# absent and this check stays silent.
local _userns_path='/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
local _userns_val=''
[[ -r $_userns_path ]] && _userns_val=$(<"$_userns_path")
# Gate on the deb's installed Electron, not $electron_path (the
# invoking build's binary): the profile pins this exact path, so only
# a deb install is confined by it. AppImage always runs --no-sandbox
# and Nix binaries live in the store — neither can hit the crash.
local _deb_electron='/usr/lib/claude-desktop'
_deb_electron+='/node_modules/electron/dist/electron'
if [[ $_userns_val == 1 && -e $_deb_electron ]]; then
# Profile name must match deb.sh's /etc/apparmor.d/$package_name
# (PACKAGE_NAME in build.sh).
local _aa_profile='/etc/apparmor.d/claude-desktop'
local _aa_loaded='/sys/kernel/security/apparmor/profiles'
# securityfs marks this file world-readable (0444), but the kernel
# still denies the actual read without CAP_MAC_ADMIN — so a -r test
# passes for non-root yet the read returns nothing. Attempt the read
# and judge by whether we actually got data, not by the mode bits.
local _loaded_set=''
_loaded_set=$(cat "$_aa_loaded" 2>/dev/null)
if [[ -n $_loaded_set ]]; then
# Authoritative: we actually read the kernel's loaded profile
# set (needs root), so report the real load state — not
# mere presence on disk.
if printf '%s\n' "$_loaded_set" | grep -q '^claude-desktop '; then
_pass 'User namespaces: restricted, AppArmor profile loaded'
else
_warn 'User namespaces: restricted by AppArmor,' \
'Claude profile not loaded'
if [[ -e $_aa_profile ]]; then
_info ' Profile is on disk but not loaded. Load it:'
_info " sudo apparmor_parser -r $_aa_profile"
else
_info ' No profile found. See docs/troubleshooting.md'
_info ' "Claude Desktop crashes immediately on launch".'
fi
fi
elif [[ -e $_aa_profile ]]; then
# The loaded set was unreadable: non-root (the kernel needs
# CAP_MAC_ADMIN despite the 0444 mode), or securityfs is
# unmounted (common in containers). Report presence on disk
# only — never a definitive PASS.
if (( EUID == 0 )); then
_info 'User namespaces: AppArmor profile present on disk' \
'(securityfs unavailable; cannot confirm it is loaded)'
else
_info 'User namespaces: AppArmor profile present on disk' \
'(re-run with sudo to confirm it is loaded)'
fi
else
_warn 'User namespaces: restricted by AppArmor,' \
'no Claude profile found'
_info ' Unprivileged user namespaces are blocked, which'
_info ' crashes the app on launch in X11 sessions'
_info ' (credentials.cc FATAL). Wayland sessions run with'
_info ' --no-sandbox and are unaffected.'
_info ' See docs/troubleshooting.md "Claude Desktop crashes'
_info ' immediately on launch" for the profile to install.'
fi
fi
# -- SingletonLock --
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
local lock_file="$config_dir/SingletonLock"
@@ -908,9 +502,6 @@ 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
@@ -978,13 +569,30 @@ print(len(servers))
fi
# -- Disk space --
_doctor_check_disk_space "$config_dir"
local config_disk_avail
config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
| tail -1 | tr -d ' M') || true
if [[ -n $config_disk_avail ]]; then
if ((config_disk_avail < 100)); then
_fail "Disk space: ${config_disk_avail}MB free on config partition"
_info 'Fix: Free up disk space'
elif ((config_disk_avail < 500)); then
_warn "Disk space: ${config_disk_avail}MB free" \
"on config partition (low)"
else
_pass "Disk space: ${config_disk_avail}MB free"
fi
fi
# -- Cowork Mode --
echo
echo -e "${_bold}Cowork Mode${_reset}"
echo '----------------'
# Detect distro for package hints
local _distro_id
_distro_id=$(_cowork_distro_id)
# Determine whether bwrap is the active backend (for severity
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
# bwrap is active unless the user has overridden to KVM or host.
@@ -1033,8 +641,9 @@ print(len(servers))
' Common on Ubuntu 24.04+ where AppArmor sets' \
'apparmor_restrict_unprivileged_userns=1'
_info \
' by default. See docs/troubleshooting.md' \
'"Cowork on Ubuntu 24.04" for the AppArmor profile fix.'
' by default. See docs/TROUBLESHOOTING.md' \
'"Cowork on Ubuntu 24.04"'
_info ' for the AppArmor profile fix.'
fi
fi
else
@@ -1176,26 +785,34 @@ 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:
# _claude_desktop_ui_is_alive in launcher-common.sh fingerprints on
# the --class=$WM_CLASS flag from build_electron_args (since #700
# the launchers no longer pass app.asar in argv — Electron
# auto-loads it), excluding Chromium helpers (--type=...), the
# cowork daemon itself, our own launcher bash, and stopped/zombie
# processes. Counting any `claude-desktop`-matching process (as
# the old check did) would include the launcher's own bash and
# stuck launcher bashes from previous crashes, producing false
# negatives where a real orphan is misreported as "parent alive".
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
# above: a live UI is an Electron main process on app.asar that is
# not a Chromium helper (--type=...), not the cowork daemon itself,
# and not stopped/zombie. Counting any `claude-desktop`-matching
# process (as the old check did) would include the launcher's own
# bash and stuck launcher bashes from previous crashes, producing
# false negatives where a real orphan is misreported as "parent
# alive".
local _cowork_pids
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|| true
if [[ -n $_cowork_pids ]]; then
if ! _claude_desktop_ui_is_alive; then
local _daemon_orphaned=true _pid _cmdline _state
for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
[[ $_pid == "$$" || $_pid == "$PPID" ]] && continue
_cmdline=$(tr '\0' ' ' \
< "/proc/$_pid/cmdline" 2>/dev/null) || continue
[[ $_cmdline == *cowork-vm-service* ]] && continue
[[ $_cmdline == *--type=* ]] && continue
_state=$(awk '/^State:/ {print $2; exit}' \
"/proc/$_pid/status" 2>/dev/null) || continue
[[ $_state == T || $_state == t || $_state == Z ]] \
&& continue
_daemon_orphaned=false
break
done
if [[ $_daemon_orphaned == true ]]; then
_warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)"
_info 'Fix: Restart Claude Desktop' \
'(daemon will be cleaned up automatically)'
@@ -1204,11 +821,6 @@ 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,28 +81,15 @@ const CLOSE_TO_TRAY = process.platform === 'linux'
&& process.env.CLAUDE_QUIT_ON_CLOSE !== '1';
console.log(`[Frame Fix] Close-to-tray: ${CLOSE_TO_TRAY ? 'on' : 'off'}`);
// Power save blocker behavior, controlled by CLAUDE_KEEP_AWAKE env var:
// unset / '1' - pass through with diagnostic logging
// '0' - suppress powerSaveBlocker.start() calls entirely
// Upstream's keepAwakeEnabled has no lifecycle management on Linux (the
// darwin-only wake scheduler never runs), so the inhibitor fires at init
// and never releases — preventing suspend and screensaver. See #605.
const KEEP_AWAKE = process.env.CLAUDE_KEEP_AWAKE !== '0';
console.log(`[Frame Fix] Keep awake: ${KEEP_AWAKE ? 'on (default)' : 'suppressed (CLAUDE_KEEP_AWAKE=0)'}`);
// Detect if a window intends to be frameless (popup/Quick Entry/About).
// Window kinds — see build-reference/app-extracted/.vite/build/index.js:
// Quick Entry: titleBarStyle:"hidden", frame:false (caught early)
// 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.
// 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.
function isPopupWindow(options) {
if (!options) return false;
if (options.frame === false) return true;
if ('parent' in options) return false;
if ((options.titleBarStyle === '' || options.titleBarStyle === 'hiddenInset') && !options.minWidth) return true;
if (options.titleBarStyle === '' && !options.minWidth) return true;
return false;
}
@@ -130,28 +117,6 @@ 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;
@@ -187,7 +152,10 @@ Module.prototype.require = function(id) {
} else if (TITLEBAR_STYLE === 'native') {
// Main window, native mode: force system frame.
options.frame = true;
options.autoHideMenuBar = false;
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
// 'auto' (default): hidden, Alt toggles
// 'visible'/'hidden': no Alt toggle
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
@@ -217,7 +185,7 @@ Module.prototype.require = function(id) {
// CSS rule still applying within the framed
// window's content area.
options.frame = true;
options.autoHideMenuBar = false;
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
@@ -252,22 +220,6 @@ Module.prototype.require = function(id) {
this.setMenuBarVisibility(false);
}
// Track the most recent 'show' event timestamp on the
// window. Read by the webContents.focus() guard below to
// distinguish a genuine post-show activation (which must
// pass through to send _NET_ACTIVE_WINDOW and actually
// give the window WM focus) from a sloppy-focus
// reassertion (which is what we want to skip). Required
// because Electron's isFocused() returns stale-true after
// hide() on Cinnamon/KDE/Wayland — a freshly-restored
// window reports focused=true even though the WM never
// activated it, and skipping the focus() call leaves the
// window visible-but-inert until the user clicks it.
// See #416 review notes.
this._lastShownAt = 0;
this.on('show', () => { this._lastShownAt = Date.now(); });
this.on('restore', () => { this._lastShownAt = Date.now(); });
// Inject CSS for Linux scrollbar styling
this.webContents.on('did-finish-load', () => {
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
@@ -338,7 +290,8 @@ Module.prototype.require = function(id) {
});
// In 'hidden' mode, suppress Alt toggle by re-hiding
// on every show event.
// on every show event. In 'auto' mode, let
// autoHideMenuBar handle the toggle natively.
if (MENU_BAR_MODE === 'hidden') {
this.on('show', () => {
this.setMenuBarVisibility(false);
@@ -360,44 +313,6 @@ Module.prototype.require = function(id) {
this.hide();
}
});
} else {
// CLAUDE_QUIT_ON_CLOSE=1: the bundled main-process code
// (`.vite/build/index.js`) installs its own main-window
// close listener that hardcodes `preventDefault()` +
// `hide()` on every non-Windows platform, with no
// setting or env var to disable it. The wrapper's
// opt-out above only removes *this* file's hide handler;
// the bundled one still runs, so without this branch
// closing the window still leaves the app alive in the
// tray (in-app schedulers / single-instance lock /
// deleted-inode electron after dpkg upgrade-in-place).
//
// Approach: register a close listener that runs *first*
// and calls app.quit(). app.quit() emits 'before-quit'
// synchronously, which sets the bundled code's
// "quitting in progress" flag. The bundled close
// listener then runs second, sees that flag, and
// short-circuits via its own `if (lC()) return;` guard
// — so it never calls preventDefault, and the window
// closes normally during the quit flow. We ride the
// upstream's own quit-safety contract instead of trying
// to remove or splice their listener; robust to any
// refactor that preserves the quit-in-progress short-
// circuit (which they need for Ctrl+Q / tray Quit /
// SIGTERM anyway). Fixes: #623
this.on('close', () => { result.app.quit(); });
}
// Alt-keyup menu bar toggle state (auto mode). Tracked
// per-window so chords spanning multiple webContents
// (main window + BrowserView) share one state machine.
// Reset on blur to avoid stale state after Alt-Tab.
if (MENU_BAR_MODE === 'auto') {
this._altMenuTracker = { pressed: false, chorded: false };
this.on('blur', () => {
this._altMenuTracker.pressed = false;
this._altMenuTracker.chorded = false;
});
}
// Directly set child view bounds to match content size.
@@ -563,32 +478,11 @@ Module.prototype.require = function(id) {
// Intercept Menu.setApplicationMenu to hide menu bar on Linux.
// In 'hidden' mode, force-hide after every menu update.
// In 'auto' mode, only hide initially (the before-input-event
// Alt-keyup handler manages toggle). Fixes: #321
// In 'auto' mode, only hide initially (autoHideMenuBar handles
// Alt toggle — re-hiding here would break that). Fixes: #321
const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu);
patchedSetApplicationMenu = function(menu) {
console.log('[Frame Fix] Intercepting setApplicationMenu');
// Append a hidden View submenu with F11 fullscreen toggle.
// Upstream has fullscreenable:true and persists isFullScreen
// across sessions; macOS provides the green traffic-light
// button; Linux has no equivalent OS-level trigger, so we
// register an accelerator here. visible:false keeps it out
// of the menu bar — it only registers the keybinding.
// Fixes: #580
if (process.platform === 'linux' && menu) {
const { MenuItem, Menu: MenuClass } = electronModule;
menu.append(new MenuItem({
label: 'View',
visible: false,
submenu: MenuClass.buildFromTemplate([{
label: 'Toggle Full Screen',
role: 'togglefullscreen',
accelerator: 'F11',
}]),
}));
}
originalSetAppMenu(menu);
if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') {
for (const win of PatchedBrowserWindow.getAllWindows()) {
@@ -641,105 +535,13 @@ Module.prototype.require = function(id) {
});
}
wc.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && input.control
&& !input.alt && !input.shift && !input.meta
&& (input.key === 'q' || input.key === 'Q')) {
event.preventDefault();
result.app.quit();
return;
}
// Alt-keyup menu bar toggle (auto mode). Chromium's
// autoHideMenuBar fires on keydown, grabbing focus
// before Alt+Shift (language switch) or Alt+F4 can
// complete. We suppress the keydown and toggle on
// keyup only when Alt was released without any
// intervening key. Fixes: #630
if (MENU_BAR_MODE !== 'auto') return;
const owner = result.BrowserWindow.fromWebContents(wc);
if (!owner || owner.isDestroyed()) return;
const tracker = owner._altMenuTracker;
if (!tracker) return;
if (input.key === 'Alt') {
if (input.type === 'keyDown') {
tracker.pressed = true;
tracker.chorded = false;
event.preventDefault();
} else if (input.type === 'keyUp') {
if (tracker.pressed && !tracker.chorded) {
owner.setMenuBarVisibility(!owner.isMenuBarVisible());
}
tracker.pressed = false;
}
} else if (tracker.pressed && input.type === 'keyDown') {
tracker.chorded = true;
}
if (input.type !== 'keyDown') return;
if (!input.control) return;
if (input.alt || input.shift || input.meta) return;
if (input.key !== 'q' && input.key !== 'Q') return;
event.preventDefault();
result.app.quit();
});
// Suppress redundant webContents.focus() calls that would
// re-trigger Chromium's X11Window::Activate() and send a
// _NET_ACTIVE_WINDOW client message — EWMH defines that as
// focus-AND-raise, so under sloppy / focus-follows-mouse
// WMs (Cinnamon Muffin, Mutter, i3 with focus_follows_mouse)
// every BrowserWindow 'focus' event causes a raise on
// mouse-enter, undoing the user's "no auto-raise" config.
// Tracks electron/electron#38184.
//
// Hooked at app.on('web-contents-created') so child views
// are covered too — the BrowserWindow-class wrap only
// touches the window's own webContents, but the upstream
// call site lives on a child WebContentsView (the claude.ai
// host view) whose webContents is a different object.
//
// Skip is gated on the *owning toplevel*'s isFocused(),
// not the webContents'. wc.isFocused() returns false on a
// freshly-attached child view even when the window is
// focused — that's exactly the state on every sloppy hover,
// so guarding on it would never skip and the raise loop
// would continue.
//
// The post-'show' grace window is the second half of the
// story. Electron's isFocused() returns stale-true after
// hide() on Cinnamon/KDE/Wayland (the same trap that
// drives the KDE-only patches in scripts/patches/
// quick-window.sh); a tray-restore hide → show then sees
// ownerFocused=true and a naive guard would skip, leaving
// the window visible-but-inert (no _NET_ACTIVE_WINDOW, no
// keyboard focus until the user clicks). Within
// SHOW_GRACE_MS of a 'show' event we pass through
// unconditionally, so the post-restore activation actually
// lands. 1000 ms covers the synchronous show → focus
// sequence with margin for slow restores.
//
// Trade-off: in sloppy mode, hover-induced focus events
// are SKIPped, which suppresses both the X11 raise (the
// bug we're fixing) and the renderer-focus direction that
// webContents.focus() would also do. Net effect: hover
// gives WM focus (frame highlight) but renderer focus
// doesn't follow until the user clicks. The Electron API
// doesn't expose a renderer-focus-only path on X11, so
// this is the best available trade against the constant-
// raise UX. Genuine activations (no recent show + not
// already focused) still go through end-to-end.
//
// Known: deferred setTimeout focus sites (e.g. find-bar
// dismiss) outside the grace window may lose renderer-focus
// direction on keyboard dismissal. See #416 review.
//
// Fixes: #416
const SHOW_GRACE_MS = 1000;
const origFocus = wc.focus.bind(wc);
wc.focus = (...args) => {
const owner = result.BrowserWindow.fromWebContents(wc);
if (!owner || owner.isDestroyed()) return origFocus(...args);
if (!owner.isFocused()) return origFocus(...args);
const shownAt = owner._lastShownAt || 0;
if (Date.now() - shownAt < SHOW_GRACE_MS) {
return origFocus(...args);
}
return;
};
});
}
@@ -793,8 +595,9 @@ Module.prototype.require = function(id) {
return { exec: 'claude-desktop', icon: 'claude-desktop' };
};
// StartupWMClass derived from Electron's app.name (upstream
// productName) so DEs group autostarted and launched instances.
// StartupWMClass matches the value set by scripts/packaging/{deb,rpm}.sh
// so DEs group an autostarted window with user-launched instances
// under the same taskbar / dock entry.
const buildAutostartContent = () => {
const { exec, icon } = resolveAutostartTarget();
return `[Desktop Entry]
@@ -802,7 +605,7 @@ Type=Application
Name=Claude
Exec=${exec}
Icon=${icon}
StartupWMClass=${result.app.name}
StartupWMClass=Claude
Terminal=false
X-GNOME-Autostart-enabled=true
`;
@@ -851,74 +654,6 @@ 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');
}
@@ -938,56 +673,6 @@ X-GNOME-Autostart-enabled=true
}
});
}
if (prop === 'powerSaveBlocker' && process.platform === 'linux') {
// Wrap powerSaveBlocker with logging and optional suppression
const originalPSB = target.powerSaveBlocker;
return new Proxy(originalPSB, {
get(psTarget, psProp) {
if (psProp === 'start') {
return function(type) {
if (!KEEP_AWAKE) {
console.log(`[Power] powerSaveBlocker.start('${type}') suppressed (CLAUDE_KEEP_AWAKE=0)`);
return -1;
}
const id = psTarget.start(type);
console.log(`[Power] powerSaveBlocker.start('${type}') -> id=${id}`);
return id;
};
}
if (psProp === 'stop') {
return function(id) {
if (id < 0) return;
console.log(`[Power] powerSaveBlocker.stop(${id})`);
return psTarget.stop(id);
};
}
if (psProp === 'isStarted') {
return function(id) {
if (id < 0) return false;
return psTarget.isStarted(id);
};
}
return Reflect.get(psTarget, psProp);
}
});
}
if (prop === 'autoUpdater' && process.platform === 'linux') {
// Force autoUpdater into a no-op on Linux. Upstream's bundled
// app code sets a feed URL of api.anthropic.com/api/desktop/linux/...
// when app.isPackaged is true (we set ELECTRON_FORCE_IS_PACKAGED=true
// unconditionally). Today this is a happy accident: Electron's Linux
// autoUpdater is unimplemented and logs "AutoUpdater is not supported
// on Linux", so the calls no-op. If a future Electron implements it,
// every install would start hitting that feed and would either 404
// or — worse — receive content the install wasn't prepared for.
// .deb/.rpm/AppImage updates flow through the OS package manager
// (or AppImageUpdate); the Anthropic feed has no Linux artifacts.
// We replace the entire autoUpdater object with a Proxy that
// no-ops every method and returns chainable stubs for EventEmitter
// calls so listener registration in the bundled code is harmless.
// See #567.
return autoUpdaterNoop;
}
return Reflect.get(target, prop, receiver);
}
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,13 +68,9 @@ Type=Application
Terminal=false
Categories=Office;Utility;
MimeType=x-scheme-handler/claude;
StartupWMClass=$WM_CLASS
StartupWMClass=Claude
EOF
# --- Stage AppStream metainfo (installed via %files block below) ---
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
cp "$script_dir/$metainfo_name" "$staging_dir/$metainfo_name" || exit 1
# --- Create Launcher Script ---
echo 'Creating launcher script...'
cat > "$staging_dir/claude-desktop" << EOF
@@ -93,12 +89,7 @@ fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
# App path
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
@@ -106,7 +97,6 @@ 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
@@ -124,14 +114,12 @@ fi
# Determine Electron executable path
electron_exec='electron'
using_global_electron=false
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
if [[ -f \$local_electron_path ]]; then
electron_exec="\$local_electron_path"
log_message "Using local Electron: \$electron_exec"
else
if command -v electron &> /dev/null; then
using_global_electron=true
log_message "Using global Electron: \$electron_exec"
else
log_message 'Error: Electron executable not found'
@@ -146,35 +134,27 @@ else
fi
fi
# App path
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
# Build electron args - use 'deb' type (same sandbox behavior)
build_electron_args 'deb'
# Bundled Electron: app.asar sits in its default resources/ dir next
# to the binary, so Electron auto-loads it. Passing the path again
# makes Electron treat it as a file-to-open, which the app forwards
# to its file-drop handler, producing a spurious "Attach app.asar?"
# prompt on launch and on every taskbar reopen (the second-instance
# argv path). Omitting it is the root-cause fix. See issue #696.
# Global (PATH) Electron has no co-located app.asar and would boot
# its default_app welcome screen instead — only there the explicit
# app path is load-bearing and must stay.
if [[ \$using_global_electron == true ]]; then
electron_args+=("\$app_path")
log_message "App (explicit arg, global Electron): \$app_path"
else
log_message "App (auto-loaded by Electron): \$app_path"
fi
# Add app path LAST
electron_args+=("\$app_path")
# Change to application directory
app_dir="/usr/lib/$package_name"
log_message "Changing directory to \$app_dir"
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
# Execute Electron and keep the launcher alive so explicit quit can
# clean up Desktop-owned helpers that outlive the Electron main process.
# Execute Electron
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
exit \$?
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
exit_code=\$?
log_message "Electron exited with code: \$exit_code"
log_message '--- Claude Desktop Launcher End ---'
exit \$exit_code
EOF
chmod +x "$staging_dir/claude-desktop"
@@ -240,44 +220,35 @@ cp -r $app_staging_dir/app.asar.unpacked %{buildroot}/usr/lib/$package_name/node
# Copy shared launcher library (launcher-common.sh sources doctor.sh
# at runtime, so both must live in the same directory)
cp $(dirname "$script_dir")/launcher-common.sh %{buildroot}/usr/lib/$package_name/
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "%{buildroot}/usr/lib/$package_name/launcher-common.sh"
cp $(dirname "$script_dir")/doctor.sh %{buildroot}/usr/lib/$package_name/
# Install desktop entry
install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/applications/claude-desktop.desktop
# Install AppStream metainfo (GNOME Software / KDE Discover)
install -Dm 644 $staging_dir/$metainfo_name %{buildroot}/usr/share/metainfo/$metainfo_name
# Install launcher script
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
# Normalize file modes — the cp -r above honors the build umask, and
# the "-" first field of %defattr ships buildroot *file* modes verbatim
# (only directory modes are forced to 0755), so a umask-077 build would
# package an unreadable app.asar and a non-executable electron binary.
# Must run before the chrome-sandbox chmod below so 4755 survives.
find %{buildroot}/usr/lib/$package_name -type f -exec chmod u=rwX,go=rX {} +
# Set the chrome-sandbox suid bit in the buildroot so the /usr/lib
# directory walk in %files records 4755 in the payload (preserves #539
# without the "File listed twice" warning #609 — see %files block).
chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox
%post
# Update desktop database for MIME types
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
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 2>&1 || true
update-desktop-database /usr/share/applications &> /dev/null || true
%files
%defattr(-, root, root, 0755)
%attr(755, root, root) /usr/bin/claude-desktop
/usr/lib/$package_name
/usr/share/applications/claude-desktop.desktop
/usr/share/metainfo/$metainfo_name
/usr/share/icons/hicolor/*/apps/claude-desktop.png
SPECEOF
@@ -286,26 +257,14 @@ echo 'RPM spec file created'
# --- Build RPM Package ---
echo 'Building RPM package...'
rpmbuild_log="$work_dir/rpmbuild.log"
rpmbuild --define "_topdir $rpmbuild_dir" \
if ! rpmbuild --define "_topdir $rpmbuild_dir" \
--define "_rpmdir $work_dir" \
--target "$rpm_arch" \
-bb "$rpmbuild_dir/SPECS/$package_name.spec" 2>&1 |
tee "$rpmbuild_log"
if (( PIPESTATUS[0] != 0 )); then
-bb "$rpmbuild_dir/SPECS/$package_name.spec"; then
echo 'Failed to build RPM package' >&2
exit 1
fi
# Guard against re-introducing #609. The "File listed twice" warning
# means %files has overlapping listings, and on modern rpmbuild any
# %exclude workaround silently strips the file from the payload.
if grep -qF 'File listed twice' "$rpmbuild_log"; then
echo 'rpmbuild emitted "File listed twice" — %files has overlapping listings (see #609)' >&2
grep -F 'File listed twice' "$rpmbuild_log" >&2
exit 1
fi
# Find and move the built RPM (it will be in a subdirectory)
rpm_file=$(find "$work_dir" -name "${package_name}-${rpm_version}*.rpm" -type f | head -n 1)
if [[ -z $rpm_file ]]; then

View File

@@ -11,10 +11,10 @@ extract_electron_variable() {
echo 'Extracting electron module variable name...'
local index_js='app.asar.contents/.vite/build/index.js'
electron_var=$(grep -oP '[$\w]+(?=\s*=\s*require\("electron"\))' \
electron_var=$(grep -oP '\$?\w+(?=\s*=\s*require\("electron"\))' \
"$index_js" | head -1)
if [[ -z $electron_var ]]; then
electron_var=$(grep -oP '(?<=new )[$\w]+(?=\.Tray\b)' \
electron_var=$(grep -oP '(?<=new )\$?\w+(?=\.Tray\b)' \
"$index_js" | head -1)
fi
if [[ -z $electron_var ]]; then
@@ -33,7 +33,7 @@ fix_native_theme_references() {
local wrong_refs
mapfile -t wrong_refs < <(
grep -oP '[$\w]+(?=\.nativeTheme)' "$index_js" \
grep -oP '\$?\w+(?=\.nativeTheme)' "$index_js" \
| sort -u \
| grep -Fxv "$electron_var" || true
)

View File

@@ -37,32 +37,16 @@ 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, desktopName, and node-pty dependency');
" "$desktop_name"
# Fail fast if upstream changed productName — a mismatch silently
# breaks StartupWMClass in every .desktop file we ship.
local product_name
product_name=$(node -e \
"console.log(require('./app.asar.contents/package.json').productName)")
if [[ $product_name != "$WM_CLASS" ]]; then
echo "Error: upstream productName '$product_name' != WM_CLASS" \
"'$WM_CLASS' — update WM_CLASS in build.sh" >&2
exit 1
fi
console.log('Updated package.json: main entry and node-pty dependency');
"
# Create stub native module
echo 'Creating stub native module...'
@@ -103,22 +87,9 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
# Add Linux Claude Code support
patch_linux_claude_code
# Reject .asar paths in the directory-check helper so Electron's
# ASAR VFS shim doesn't misidentify app.asar as a folder and
# trigger false Cowork dispatch (#383, #622, #632).
patch_asar_path_filter
# Reject .asar paths in the argv file-drop collector so the
# existsSync branch doesn't dispatch app.asar as a file drop,
# triggering a permission prompt on every window reopen (#383, #622).
patch_asar_argv_file_drop_guard
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
patch_cowork_linux
# Add Linux org-plugins path for MDM-managed plugin marketplace
patch_org_plugins_path
# Inject WCO shim into the BrowserView preload so claude.ai's
# desktop topbar renders on Linux. The shim spoofs the bundle's
# isWindows() UA check (load-bearing) plus matchMedia and
@@ -126,17 +97,6 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
# docs/learnings/linux-topbar-shim.md.
patch_wco_shim
# Preserve externally-added mcpServers across config writes (#400)
patch_config_write_merge
# Reject .asar paths in addTrustedFolder to reduce spurious config
# writes that amplify the stale-cache overwrite bug (#400)
patch_asar_trusted_folder_guard
# Filter .asar paths from --add-dir dispatch and session restore
# so corrupted pre-#640 sessions cannot crash local agent mode (#649)
patch_asar_additional_dirs_guard
# Copy cowork VM service daemon for Linux Cowork mode
echo 'Installing cowork VM service daemon...'
cp "$source_dir/scripts/cowork-vm-service.js" \

View File

@@ -16,12 +16,12 @@ patch_linux_claude_code() {
# New format (Claude >= 1.1.3541): getHostPlatform includes arch detection for win32
# Pattern: if(process.platform==="win32")return e==="arm64"?"win32-arm64":"win32-x64";throw new Error(...)
if grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+[$\w]+\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw' "$index_js"; then
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+([[:alnum:]_$]+)\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
if grep -qP 'if\(process\.platform==="win32"\)return \w+==="arm64"\?"win32-arm64":"win32-x64";throw' "$index_js"; then
sed -i -E 's/if\(process\.platform==="win32"\)return (\w+)==="arm64"\?"win32-arm64":"win32-x64";throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
echo 'Added linux claude code support (new arch-aware format)'
# Old format (Claude <= 1.1.3363): no arch detection for win32
elif grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;' "$index_js"; then
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
elif grep -q 'if(process.platform==="win32")return"win32-x64";' "$index_js"; then
sed -i 's/if(process.platform==="win32")return"win32-x64";/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
echo 'Added linux claude code support (legacy format)'
else
echo 'Warning: Could not find getHostPlatform pattern to patch for Linux claude code support'

View File

@@ -1,296 +0,0 @@
#===============================================================================
# Config-related patches: preserve externally-added mcpServers across config
# writes, guard addTrustedFolder against .asar paths, and filter .asar entries
# from the --add-dir CLI dispatch and session restore.
#
# Sourced by: build.sh
# Sourced globals: project_root
# Modifies globals: (none)
#===============================================================================
patch_config_write_merge() {
echo 'Patching config writer to preserve mcpServers from disk...'
local index_js='app.asar.contents/.vite/build/index.js'
# Idempotency guard
if grep -q '_cdd_dc' "$index_js"; then
echo ' mcpServers merge already present (idempotent)'
echo '##############################################################'
return
fi
# Extract variable names from the unique anchor:
# await WRITE_FN(PATH_VAR, CONFIG_VAR), LOGGER.info("Config file written")
local write_fn path_var config_var write_fn_re path_var_re
write_fn=$(grep -oP \
'await \K[$\w]+(?=\([$\w]+,\s*[$\w]+\)\s*,\s*[$\w]+\.info\("Config file written"\))' \
"$index_js")
if [[ -z $write_fn ]]; then
echo ' Could not extract write function name — skipping' >&2
echo '##############################################################'
return
fi
write_fn_re="${write_fn//\$/\\$}"
path_var=$(grep -oP \
"await ${write_fn_re}\\(\\K[\$\\w]+(?=,\\s*[\$\\w]+\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
"$index_js")
if [[ -z $path_var ]]; then
echo ' Could not extract path variable — skipping' >&2
echo '##############################################################'
return
fi
path_var_re="${path_var//\$/\\$}"
config_var=$(grep -oP \
"await ${write_fn_re}\\(${path_var_re},\\s*\\K[\$\\w]+(?=\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
"$index_js")
if [[ -z $config_var ]]; then
echo ' Could not extract config variable — skipping' >&2
echo '##############################################################'
return
fi
echo " Write fn: $write_fn, path: $path_var, config: $config_var"
if ! WRITE_FN="$write_fn" PATH_VAR="$path_var" CFG_VAR="$config_var" \
node -e "
const fs = require('fs');
const p = 'app.asar.contents/.vite/build/index.js';
const W = process.env.WRITE_FN;
const P = process.env.PATH_VAR;
const C = process.env.CFG_VAR;
let code = fs.readFileSync(p, 'utf8');
const reEsc = (s) => s.replace(/[.*+?\${}()|[\\]\\\\]/g, '\\\\\$&');
const anchor = new RegExp(
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
'\\\\)\\\\s*,\\\\s*\\\\w+\\\\.info\\\\(\"Config file written\"\\\\)'
);
if (!anchor.test(code)) {
console.error(' [FAIL] Config-write anchor not found');
process.exit(1);
}
const merge =
'try{var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(' + P +
',\"utf8\"));if(_cdd_dc.mcpServers){' + C +
'.mcpServers=Object.assign({},_cdd_dc.mcpServers,' + C +
'.mcpServers||{})}}catch(_cdd_ex){}';
code = code.replace(anchor, (m) => merge + ';' + m);
fs.writeFileSync(p, code);
console.log(' [OK] mcpServers merge injected before config write');
"; then
echo 'Failed to inject config write merge' >&2
cd "$project_root" || exit 1
exit 1
fi
echo '##############################################################'
}
patch_asar_trusted_folder_guard() {
echo 'Patching addTrustedFolder to reject .asar paths...'
local index_js='app.asar.contents/.vite/build/index.js'
# Idempotency guard
if grep -qF 'endsWith(".asar"))return' "$index_js"; then
echo ' .asar guard already present (idempotent)'
echo '##############################################################'
return
fi
# Anchor on the method declaration itself — the method name
# `addTrustedFolder` is not minified and is unique in the bundle.
# Earlier releases let us anchor on the trailing `${param}`);` of the
# log line, but upstream now folds that log call into the comma
# expression `if(D.info(`…${i}`),await ZOe(i)===null){…}`, so the
# `);` no longer exists. Injecting at the function body head is both
# more robust and semantically earlier (reject .asar on entry).
local folder_param
folder_param=$(grep -oP \
'async addTrustedFolder\(\K[$\w]+(?=\)\{)' \
"$index_js")
if [[ -z $folder_param ]]; then
echo ' Could not extract folder parameter — skipping' >&2
echo '##############################################################'
return
fi
echo " Found folder parameter: $folder_param"
if ! FOLDER_PARAM="$folder_param" node -e "
const fs = require('fs');
const p = 'app.asar.contents/.vite/build/index.js';
const F = process.env.FOLDER_PARAM;
let code = fs.readFileSync(p, 'utf8');
const anchor = 'async addTrustedFolder(' + F + '){';
const idx = code.indexOf(anchor);
if (idx === -1) {
console.error(' [FAIL] addTrustedFolder anchor not found');
process.exit(1);
}
const insertPoint = idx + anchor.length;
const guard = 'if(' + F + '.endsWith(\".asar\"))return;';
code = code.slice(0, insertPoint) + guard + code.slice(insertPoint);
fs.writeFileSync(p, code);
console.log(' [OK] .asar guard injected in addTrustedFolder');
"; then
echo 'Failed to inject .asar trusted folder guard' >&2
cd "$project_root" || exit 1
exit 1
fi
echo '##############################################################'
}
# ---------------------------------------------------------------------------
# Patch: filter .asar paths from --add-dir CLI dispatch and session restore
#
# PR #640 guards the directory-check helper and addTrustedFolder IPC
# handler, but .asar paths in corrupted pre-#640 sessions survive
# restore (existsSync passes via Electron's ASAR VFS shim) and reach
# additionalDirectories -> --add-dir -> fatal Claude Code error.
#
# Fix: two sub-patches:
# 1. Filter at the --add-dir CLI dispatch loop (the single convergence
# point for ALL code paths that feed additionalDirectories).
# 2. Filter at session restore to self-heal corrupted persisted state.
# ---------------------------------------------------------------------------
patch_asar_additional_dirs_guard() {
echo 'Patching --add-dir dispatch to reject .asar paths (#649)...'
local index_js='app.asar.contents/.vite/build/index.js'
if ! INDEX_JS="$index_js" node << 'ASAR_ADDDIR_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
let patchCount = 0;
let dispatchPatchCount = 0;
let dispatchAlreadyPresent = code.includes(
'.filter(_d=>!_d.endsWith(".asar"))'
);
// ================================================================
// Sub-patch 1: Filter .asar from --add-dir loop
//
// Targets (one or more occurrences):
// for (let O of A) Y.push("--add-dir", O);
// Fallback (if minifier uses .forEach):
// A.forEach(O=>Y.push("--add-dir",O))
// ================================================================
{
// Primary: for...of pattern
const forOfRe = /for\s*\(\s*let\s+([\w$]+)\s+of\s+([\w$]+)\s*\)\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\1\s*\)/g;
// Fallback: .forEach pattern
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/g;
let forOfCount = 0;
let forEachCount = 0;
code = code.replace(forOfRe, (match, iterVar, arrVar, pushTarget) => {
forOfCount++;
dispatchPatchCount++;
patchCount++;
return 'for(let ' + iterVar + ' of ' + arrVar +
'.filter(_d=>!_d.endsWith(".asar")))' +
pushTarget + '.push("--add-dir",' + iterVar + ')';
});
code = code.replace(forEachRe, (match, arrVar, iterVar, pushTarget) => {
forEachCount++;
dispatchPatchCount++;
patchCount++;
return arrVar +
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
iterVar + '=>' + pushTarget +
'.push("--add-dir",' + iterVar + '))';
});
if (dispatchPatchCount === 0 && !dispatchAlreadyPresent) {
console.error('FATAL: --add-dir dispatch loop not found.');
console.error(' for(let X of Y) Z.push("--add-dir", X)');
console.error(' Y.forEach(X=>Z.push("--add-dir", X))');
process.exit(1);
}
if (dispatchPatchCount > 0) {
console.log(' Filtered ' + dispatchPatchCount +
' --add-dir dispatch loop(s) (for-of=' + forOfCount +
', forEach=' + forEachCount + ')');
} else {
console.log(' .asar --add-dir filter already present ' +
'(idempotent)');
}
}
// ================================================================
// Sub-patch 2: Filter .asar from session restore
//
// Anchor: "Filtering out deleted folder from session" (unique)
// Target: (VAR.userSelectedFolders||[]).filter(
// Insert: .filter(l=>!l.endsWith(".asar")) before existing .filter(
// ================================================================
{
const warn = (msg) => console.log(' WARNING: ' + msg +
' (primary --add-dir filter still protects)');
const anchorIdx = code.indexOf(
'Filtering out deleted folder from session');
if (anchorIdx === -1) {
warn('session restore anchor not found');
} else {
const searchStart = Math.max(0, anchorIdx - 500);
const region = code.substring(searchStart, anchorIdx);
const usIdx = region.lastIndexOf('userSelectedFolders');
if (usIdx === -1) {
warn('userSelectedFolders not found near anchor');
} else {
const absUsIdx = searchStart + usIdx;
const afterUs = code.substring(absUsIdx, anchorIdx);
const bracketMatch = afterUs.match(/\|\|\s*\[\s*\]\s*\)/);
if (!bracketMatch) {
warn('||[]) pattern not found');
} else {
const insertAt = absUsIdx + bracketMatch.index +
bracketMatch[0].length;
const peek = code.substring(insertAt, insertAt + 20);
if (!peek.match(/^\s*\.filter\s*\(/)) {
warn('.filter( not found after ||[])');
} else if (code.substring(
insertAt - 50, insertAt + 50
).includes('!l.endsWith(".asar")')) {
console.log(' Session restore filter ' +
'already present');
} else {
code = code.substring(0, insertAt) +
'.filter(l=>!l.endsWith(".asar"))' +
code.substring(insertAt);
console.log(' Injected .asar filter in ' +
'session restore');
patchCount++;
}
}
}
}
}
fs.writeFileSync(indexJs, code);
console.log(' Applied ' + patchCount +
' .asar additionalDirectories patch(es)');
if (dispatchPatchCount < 1 && !dispatchAlreadyPresent) {
console.error('FATAL: --add-dir filter must succeed (#649).');
process.exit(1);
}
ASAR_ADDDIR_PATCH
then
echo 'FATAL: .asar --add-dir filter patch failed' >&2
echo 'Local agent mode will crash without this patch (#649).' >&2
exit 1
fi
echo '##############################################################'
}

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,9 @@ patch_tray_menu_handler() {
echo 'Patching tray menu handler...'
local index_js='app.asar.contents/.vite/build/index.js'
local tray_func tray_func_re tray_var
local tray_func 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,12 +21,9 @@ 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 \
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
"$index_js")
if [[ -z $tray_var ]]; then
echo 'Failed to extract tray variable name' >&2
cd "$project_root" || exit 1
@@ -34,40 +31,49 @@ patch_tray_menu_handler() {
fi
echo " Found tray variable: $tray_var"
# Idempotent: upstream may already ship the function as `async`
# (1.8089.1 does). Re-applying the sed would produce
# `async async function`, which then breaks downstream patches that
# match `(?:async )?function NAME`.
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
sed -i -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
"$index_js"
fi
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
"$index_js"
# Trailing-edge mutex guard. Still prevents concurrent/reentrant
# rebuilds (the slow path's 250ms DBus await can interleave), but —
# unlike a plain leading-edge drop — it remembers a request that
# arrives while a rebuild is in flight and re-runs once when the
# window clears, so the FINAL nativeTheme value wins. At startup
# shouldUseDarkColors reads false for ~50ms, then a burst of
# "updated" events flips it true; a dropping mutex latches the
# initial (wrong) value and leaves the tray icon stuck black on a
# dark panel. See docs/learnings/tray-rebuild-race.md.
first_const=$(grep -oP \
"async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \
"$index_js" | head -1)
if [[ -z $first_const ]]; then
echo 'Failed to extract first const in function' >&2
cd "$project_root" || exit 1
exit 1
fi
echo " Found first const variable: $first_const"
# Add mutex guard to prevent concurrent tray rebuilds
if ! grep -q "${tray_func}._running" "$index_js"; then
sed -i -E "s/async\s+function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){if(${tray_func}._running){${tray_func}._pending=true;return}${tray_func}._running=true;setTimeout(()=>{${tray_func}._running=false;if(${tray_func}._pending){${tray_func}._pending=false;${tray_func}()}},1500);/g" \
sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
"$index_js"
echo " Added trailing-edge mutex guard to ${tray_func}()"
echo " Added mutex guard to ${tray_func}()"
fi
# Add DBus cleanup delay after tray destroy
tray_var_re="${tray_var//\$/\\$}"
if ! grep -q "await new Promise.*setTimeout.*${tray_var_re}" "$index_js"; then
sed -i -E "s/${tray_var_re}\s*\&\&\s*\(\s*${tray_var_re}\.destroy\(\)\s*,\s*${tray_var_re}\s*=\s*null\s*\)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
if ! grep -q "await new Promise.*setTimeout" "$index_js" \
| grep -q "$tray_var"; then
sed -i "s/${tray_var}\&\&(${tray_var}\.destroy(),${tray_var}=null)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
"$index_js"
echo " Added DBus cleanup delay after $tray_var.destroy()"
fi
echo 'Tray menu handler patched'
echo '##############################################################'
# Skip tray updates during startup (3 second window)
echo 'Patching nativeTheme handler for startup delay...'
if ! grep -q '_trayStartTime' "$index_js"; then
sed -i -E \
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
"$index_js"
sed -i -E \
"s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
"$index_js"
echo ' Added startup delay check (3 second window)'
fi
echo '##############################################################'
}
patch_tray_icon_selection() {
@@ -75,9 +81,9 @@ patch_tray_icon_selection() {
local index_js='app.asar.contents/.vite/build/index.js'
local dark_check="${electron_var_re}.nativeTheme.shouldUseDarkColors"
if grep -qP ':[$\w]+="TrayIconTemplate\.png"' "$index_js"; then
if grep -qP ':\$?\w+="TrayIconTemplate\.png"' "$index_js"; then
sed -i -E \
"s/:([[:alnum:]_\$]+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
"s/:(\\\$?\w+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
"$index_js"
echo 'Patched tray icon selection for Linux theme support'
else
@@ -92,19 +98,18 @@ patch_tray_inplace_update() {
# Re-extract the tray variable name — `patch_tray_menu_handler`
# declares it `local` so it's not visible here. Same grep pattern.
local tray_func tray_func_re local_tray_var tray_var_re
local menu_func menu_var menu_var_re path_var enabled_var enabled_count
local tray_func 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 \
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
"$index_js")
if [[ -z $local_tray_var ]]; then
echo ' Could not extract tray variable name — skipping'
echo '##############################################################'
@@ -114,38 +119,10 @@ patch_tray_inplace_update() {
tray_var_re="${local_tray_var//\$/\\$}"
# Two upstream shapes wire the context menu differently:
# old: ${tray_var}.setContextMenu(BUILDER()) — builder called inline
# new: M=BUILDER(); ${tray_var}.setContextMenu(M) — prebuilt menu object
# Resolve the BUILDER name in both. The injected fast-path emits
# setContextMenu(BUILDER()), so landing on the menu *object* (M) instead
# of its builder would emit setContextMenu(M()) and throw at runtime —
# M is a Menu instance, not a function.
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\(\))" \
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
"$index_js" | head -1)
if [[ -z $menu_func ]]; then
menu_var=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\))" \
"$index_js" | head -1)
if [[ -n $menu_var ]]; then
menu_var_re="${menu_var//\$/\\$}"
# Word-boundary lookbehind, not a fixed [,;({] class, so the
# assignment resolves whether it follows a separator or a
# declarator (`let `/`const ` leaves a space before the var).
# First assignment site wins, matching the inline-form grep.
menu_func=$(grep -oP "(?<![\$\w])${menu_var_re}=\K[\$\w]+(?=\(\))" \
"$index_js" | head -1)
fi
fi
if [[ -z $menu_func ]]; then
# Both the inline grep and the menu_var fallback came up empty.
# A silent skip here is how the #515 duplicate-icon race
# regressed before — make it loud on stderr so the next silent
# regression surfaces in CI logs. Still skip gracefully so the
# build completes.
echo "WARNING: could not resolve tray menu function" \
"(inline + fallback both failed) — in-place" \
"fast-path NOT applied; duplicate-icon race" \
"(#515) may regress" >&2
echo ' Could not extract menu function name — skipping'
echo '##############################################################'
return
fi
@@ -157,7 +134,7 @@ patch_tray_inplace_update() {
# suffix)` earlier in the function; minifier renames it between
# releases, so it needs to be extracted (not hardcoded).
path_var=$(grep -oP \
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K[\$\w]+(?=\))" \
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K\w+(?=\))" \
"$index_js" | head -1)
if [[ -z $path_var ]]; then
echo ' Could not extract icon-path var — skipping'
@@ -171,8 +148,8 @@ patch_tray_inplace_update() {
# tests, so binding to the wrong site is silently broken. Bail if
# upstream ever ships >1 declaration site instead of taking the
# first one.
enabled_count=$(grep -cP \
'const [$\w]+\s*=\s*[$\w]+\("menuBarEnabled"\)' "$index_js")
enabled_count=$(grep -cE \
'const \w+\s*=\s*\w+\("menuBarEnabled"\)' "$index_js")
if [[ $enabled_count -ne 1 ]]; then
echo " Expected 1 menuBarEnabled declaration, found" \
"${enabled_count} — skipping"
@@ -180,7 +157,7 @@ patch_tray_inplace_update() {
return
fi
enabled_var=$(grep -oP \
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' "$index_js")
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' "$index_js")
if [[ -z $enabled_var ]]; then
echo ' Could not extract menuBarEnabled var — skipping'
echo '##############################################################'
@@ -259,7 +236,7 @@ patch_menu_bar_default() {
local menu_bar_var
menu_bar_var=$(grep -oP \
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' \
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' \
"$index_js" | head -1)
if [[ -z $menu_bar_var ]]; then
echo ' Could not extract menuBarEnabled variable name'

View File

@@ -22,51 +22,32 @@ 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 pkg
local cmd
for cmd in $all_deps; do
if ! check_command "$cmd"; then
case "$distro_family" in
debian) pkg="${debian_pkgs[$cmd]}" ;;
rpm) pkg="${rpm_pkgs[$cmd]}" ;;
debian)
deps_to_install="$deps_to_install ${debian_pkgs[$cmd]}"
;;
rpm)
deps_to_install="$deps_to_install ${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
@@ -217,13 +198,6 @@ 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
@@ -240,91 +214,19 @@ setup_electron_asar() {
[[ ! -f $asar_bin_path ]] && echo 'Asar binary not found.' && install_needed=true
if [[ $install_needed == true ]]; 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 "Installing Electron and Asar locally into $work_dir..."
if ! npm install --no-save electron @electron/asar; 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 [[ -f $electron_dist_path/electron ]]; then
echo "Found Electron binary at $electron_dist_path."
if [[ -d $electron_dist_path ]]; then
echo "Found Electron distribution directory 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.14271.0/Claude-c8f4d811b076f6d3bb0a320ac9da463cd82a6a11.exe'
claude_exe_sha256='3abbb805d677479aaf2d320ef6856094b2f84eef9219715c539f23ea89034554'
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
claude_exe_sha256='e619c7bd3b6746a7307ebefe509bfe447a143aed97e6c7f666677b36a6b6ba54'
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.14271.0/Claude-c8f4d811b076f6d3bb0a320ac9da463cd82a6a11.exe'
claude_exe_sha256='1e5cc0edef8155bf84042f38afccdd17c4c986e0835d17f32a988cf830eca553'
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
claude_exe_sha256='bf7de5d6c012542feadf7d5caa77a72dfcea1b24512e9a6d28e004ba4ae2a11d'
architecture='arm64'
claude_exe_filename='Claude-Setup-arm64.exe'
echo 'Configured for arm64 (aarch64) build.'

View File

@@ -1,82 +0,0 @@
#!/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);
});

View File

@@ -1,200 +0,0 @@
#!/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

View File

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

View File

@@ -1,614 +0,0 @@
#!/usr/bin/env bats
#
# doctor.bats
# Tests for diagnostic helpers in scripts/doctor.sh
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
export HOME="$TEST_TMP/home"
export XDG_CACHE_HOME="$TEST_TMP/cache"
export XDG_CONFIG_HOME="$TEST_TMP/config"
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
# Clear all input/display vars to avoid host-state leakage
unset DISPLAY
unset WAYLAND_DISPLAY
unset XDG_SESSION_TYPE
unset CLAUDE_USE_WAYLAND
unset GTK_IM_MODULE
unset CLAUDE_GTK_IM_MODULE
unset CLAUDE_PASSWORD_STORE
# shellcheck source=scripts/doctor.sh
source "$SCRIPT_DIR/../scripts/doctor.sh"
_doctor_colors
_doctor_failures=0
# Default _pkg_installed to "unknown" (rc=2) so tests don't have
# to stub it unless they're exercising the package-check branch.
# Override in-test for rc=0 (installed) or rc=1 (missing).
_pkg_installed() { return 2; }
# Default stub for _detect_password_store (defined in
# launcher-common.sh, not sourced here). Tests that exercise
# _doctor_check_password_store override this in-test if needed.
_detect_password_store() { echo 'basic'; }
}
teardown() {
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
# Make `command -v gtk-query-immodules-3.0` report "not found" so the
# immodules cache check is skipped. Used by tests that aren't
# exercising the cache branch but reach it because no earlier gate
# fires. `command -v` finds bash functions too, so just unsetting a
# stub function isn't enough — we shadow `command` itself.
_skip_gtk_query() {
command() {
if [[ $1 == '-v' && $2 == 'gtk-query-immodules-3.0' ]]; then
return 1
fi
builtin command "$@"
}
}
# =============================================================================
# _cowork_pkg_hint: ibus-gtk3 mapping (#550)
# =============================================================================
@test "_cowork_pkg_hint: debian maps ibus-gtk3 to ibus-gtk3 via apt" {
local result
result=$(_cowork_pkg_hint debian ibus-gtk3)
[[ $result == "sudo apt install ibus-gtk3" ]]
}
@test "_cowork_pkg_hint: fedora maps ibus-gtk3 to ibus-gtk3 via dnf" {
local result
result=$(_cowork_pkg_hint fedora ibus-gtk3)
[[ $result == "sudo dnf install ibus-gtk3" ]]
}
@test "_cowork_pkg_hint: arch maps ibus-gtk3 to ibus (bundled)" {
local result
result=$(_cowork_pkg_hint arch ibus-gtk3)
[[ $result == "sudo pacman -S ibus" ]]
}
# =============================================================================
# _doctor_check_im_modules: CLAUDE_GTK_IM_MODULE override visibility
# =============================================================================
@test "_doctor_check_im_modules: emits override line when CLAUDE_GTK_IM_MODULE set" {
# CLAUDE_GTK_IM_MODULE makes active_im non-empty, so we'd reach
# the cache check — skip it to keep this test focused.
_skip_gtk_query
CLAUDE_GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output == *'CLAUDE_GTK_IM_MODULE=xim'* ]]
[[ $output == *'overrides GTK_IM_MODULE for Electron'* ]]
}
@test "_doctor_check_im_modules: no override line when CLAUDE_GTK_IM_MODULE unset" {
run _doctor_check_im_modules debian
[[ $output != *'CLAUDE_GTK_IM_MODULE'* ]]
}
# =============================================================================
# _doctor_check_im_modules: XWayland-with-IBus routing note
# =============================================================================
@test "_doctor_check_im_modules: emits XWayland note when wayland session and CLAUDE_USE_WAYLAND unset" {
XDG_SESSION_TYPE='wayland'
# CLAUDE_USE_WAYLAND deliberately unset
run _doctor_check_im_modules debian
[[ $output == *'XWayland'* ]]
[[ $output == *'CLAUDE_USE_WAYLAND=1'* ]]
}
@test "_doctor_check_im_modules: no XWayland note when CLAUDE_USE_WAYLAND=1" {
XDG_SESSION_TYPE='wayland'
CLAUDE_USE_WAYLAND='1'
run _doctor_check_im_modules debian
[[ $output != *'XWayland'* ]]
}
@test "_doctor_check_im_modules: no XWayland note on X11 session" {
XDG_SESSION_TYPE='x11'
run _doctor_check_im_modules debian
[[ $output != *'XWayland'* ]]
}
# =============================================================================
# _doctor_check_im_modules: ibus-gtk3 package check
# =============================================================================
@test "_doctor_check_im_modules: warns when ibus selected but ibus-gtk3 missing" {
# Package not installed (rc=1, definitive answer)
_pkg_installed() { return 1; }
GTK_IM_MODULE='ibus'
run _doctor_check_im_modules debian
[[ $output == *'[WARN]'* ]]
[[ $output == *'ibus-gtk3 is not installed'* ]]
[[ $output == *'sudo apt install ibus-gtk3'* ]]
}
@test "_doctor_check_im_modules: no warning when ibus selected and ibus-gtk3 present" {
# Package installed (rc=0); cache lists ibus.
_pkg_installed() { return 0; }
gtk-query-immodules-3.0() {
echo '"ibus" "IBus" "ibus" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='ibus'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_im_modules: no package warning when active module isn't ibus" {
# Even with rc=1 for ibus-gtk3, the package check should be
# skipped entirely when GTK_IM_MODULE isn't ibus.
_pkg_installed() { return 1; }
_skip_gtk_query
GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output != *'ibus-gtk3'* ]]
}
@test "_doctor_check_im_modules: no package warning on unsupported distro (rc=2)" {
# Default _pkg_installed (rc=2) — no warning even with ibus.
_skip_gtk_query
GTK_IM_MODULE='ibus'
run _doctor_check_im_modules unknown
[[ $output != *'[WARN]'* ]]
}
# =============================================================================
# _doctor_check_im_modules: immodules cache check
# =============================================================================
@test "_doctor_check_im_modules: warns when GTK_IM_MODULE not in immodules cache" {
# gtk-query-immodules-3.0 lists xim but not fcitx
gtk-query-immodules-3.0() {
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='fcitx'
run _doctor_check_im_modules debian
[[ $output == *'[WARN]'* ]]
[[ $output == *"'fcitx' not listed"* ]]
[[ $output == *'gtk-query-immodules-3.0 --update-cache'* ]]
}
@test "_doctor_check_im_modules: no warning when active module is in cache" {
gtk-query-immodules-3.0() {
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_im_modules: skips cache check when gtk-query-immodules-3.0 missing" {
_skip_gtk_query
GTK_IM_MODULE='fcitx'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
[[ $output != *'cache may be stale'* ]]
}
@test "_doctor_check_im_modules: CLAUDE_GTK_IM_MODULE takes precedence as active module" {
# Cache lists xim but not ibus. CLAUDE_GTK_IM_MODULE=xim should
# win over GTK_IM_MODULE=ibus, so no cache warning fires.
gtk-query-immodules-3.0() {
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
}
export -f gtk-query-immodules-3.0
GTK_IM_MODULE='ibus'
CLAUDE_GTK_IM_MODULE='xim'
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_im_modules: no checks fire when no IM module selected" {
# Neither GTK_IM_MODULE nor CLAUDE_GTK_IM_MODULE set — function
# should return early before the package or cache checks.
run _doctor_check_im_modules debian
[[ $output != *'[WARN]'* ]]
[[ $output != *'ibus-gtk3'* ]]
}
# =============================================================================
# _doctor_check_recent_crashes: GPU FATAL crash counter (#583)
# =============================================================================
# Install a coredumpctl shim. $1 is the coredumpctl-list-style
# multi-line output to emit (header + entry rows). The shim ignores
# its arguments — tests don't exercise the filter syntax.
_install_coredumpctl_shim() {
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/coredumpctl" <<SHIM
#!/usr/bin/env bash
cat <<'OUT'
$1
OUT
SHIM
chmod +x "$TEST_TMP/bin/coredumpctl"
export PATH="$TEST_TMP/bin:$PATH"
}
@test "_doctor_check_recent_crashes: no coredumpctl on PATH — silent" {
# Force coredumpctl off PATH so the helper short-circuits.
# Restore PATH before returning so teardown's rm works.
local saved_path="$PATH"
export PATH="/no-such-dir-for-test"
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
export PATH="$saved_path"
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_recent_crashes: zero crashes — silent" {
# Listing has the header line only, no entry rows.
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_recent_crashes: 1 crash — info line, no warn" {
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'Recent Electron crashes: 1'* ]]
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_recent_crashes: 3+ crashes — warn + #583 pointer" {
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M
Mon 2026-05-04 07:44:48 EDT 930532 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 22.8M
Sun 2026-05-03 14:34:10 EDT 567221 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 12.4M'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'Recent Electron crashes: 3'* ]]
[[ $output == *'CLAUDE_DISABLE_GPU=1'* ]]
[[ $output == *'/issues/583'* ]]
}
@test "_doctor_check_recent_crashes: path mismatch falls back with footnote" {
# Three crashes from a DIFFERENT electron binary (e.g., Slack).
# Caller passes claude-desktop's electron path, which doesn't
# match — helper falls back to total count and adds the footnote
# so the user knows the count may be cross-app.
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 09:00:00 EDT 200001 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
Wed 2026-05-05 09:00:00 EDT 200002 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
Wed 2026-05-04 09:00:00 EDT 200003 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M'
run _doctor_check_recent_crashes \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'may be from other Electron apps'* ]]
}
@test "_doctor_check_recent_crashes: empty electron_path falls back" {
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
# Caller didn't pass an electron_path — helper still counts and
# emits the info line based on the unfiltered total.
run _doctor_check_recent_crashes ''
[[ $status -eq 0 ]]
[[ $output == *'Recent Electron crashes: 1'* ]]
[[ $output == *'may be from other Electron apps'* ]]
}
# =============================================================================
# _doctor_check_filename_limit: NAME_MAX probe + eCryptfs hint (#590)
# =============================================================================
# Install a getconf shim that emits $1 on stdout. Empty $1 → shim exits 1
# so callers can test the "getconf failed" path.
_install_getconf_shim() {
mkdir -p "$TEST_TMP/bin"
local value="$1"
if [[ -z $value ]]; then
cat > "$TEST_TMP/bin/getconf" <<'SHIM'
#!/usr/bin/env bash
exit 1
SHIM
else
cat > "$TEST_TMP/bin/getconf" <<SHIM
#!/usr/bin/env bash
echo ${value}
SHIM
fi
chmod +x "$TEST_TMP/bin/getconf"
export PATH="$TEST_TMP/bin:$PATH"
}
# Install a df shim that emits a single-column fstype listing matching
# the `df --output=fstype` shape the helper relies on. Empty $1 → shim
# exits 1 so callers can test the "df failed" path.
_install_df_shim() {
mkdir -p "$TEST_TMP/bin"
local fstype="$1"
if [[ -z $fstype ]]; then
cat > "$TEST_TMP/bin/df" <<'SHIM'
#!/usr/bin/env bash
exit 1
SHIM
else
cat > "$TEST_TMP/bin/df" <<SHIM
#!/usr/bin/env bash
cat <<'OUT'
Type
${fstype}
OUT
SHIM
fi
chmod +x "$TEST_TMP/bin/df"
export PATH="$TEST_TMP/bin:$PATH"
}
@test "_doctor_check_filename_limit: silent when NAME_MAX >= 200" {
_install_getconf_shim '255'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_filename_limit: warns when NAME_MAX < 200" {
_install_getconf_shim '143'
_install_df_shim 'ext4'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'NAME_MAX=143'* ]]
[[ $output == *'#590'* ]]
# Non-ecryptfs fs: no LUKS hint
[[ $output != *'eCryptfs'* ]]
[[ $output != *'LUKS'* ]]
}
@test "_doctor_check_filename_limit: eCryptfs adds LUKS workaround hint" {
_install_getconf_shim '143'
_install_df_shim 'ecryptfs'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'NAME_MAX=143'* ]]
[[ $output == *'eCryptfs'* ]]
[[ $output == *'LUKS'* ]]
}
@test "_doctor_check_filename_limit: silent on non-numeric getconf output" {
_install_getconf_shim 'undefined'
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_filename_limit: silent when getconf fails" {
_install_getconf_shim ''
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "_doctor_check_filename_limit: df failure suppresses eCryptfs hint, keeps warn" {
_install_getconf_shim '143'
_install_df_shim ''
run _doctor_check_filename_limit
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'NAME_MAX=143'* ]]
[[ $output != *'eCryptfs'* ]]
[[ $output != *'LUKS'* ]]
}
# =============================================================================
# _doctor_check_password_store
# =============================================================================
@test "_doctor_check_password_store: output contains 'Password store:' with a valid backend" {
# setup() already stubs _detect_password_store to return 'basic'.
run _doctor_check_password_store
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Password store:'* ]]
[[ $output == *'basic'* ]]
}
@test "_doctor_check_password_store: warns, not PASS, when detection returns empty" {
# An empty backend means detection failed (e.g. sourcing-order
# regression) — it must not surface as a green PASS with a blank value.
_detect_password_store() { echo ''; }
run _doctor_check_password_store
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output != *'[PASS]'* ]]
}
# =============================================================================
# _doctor_check_disk_space
# =============================================================================
@test "_doctor_check_disk_space: fails when under 100MB free" {
df() { printf 'Avail\n50M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[FAIL]'* ]]
[[ $output == *'50MB free'* ]]
}
@test "_doctor_check_disk_space: warns when under 500MB free" {
df() { printf 'Avail\n300M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[WARN]'* ]]
[[ $output == *'300MB free'* ]]
}
@test "_doctor_check_disk_space: warns at exactly 100MB (tier boundary)" {
# 100 is not < 100, so the FAIL tier must not fire; < 500 → WARN.
df() { printf 'Avail\n100M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[WARN]'* ]]
[[ $output != *'[FAIL]'* ]]
[[ $output == *'100MB free'* ]]
}
@test "_doctor_check_disk_space: passes at exactly 500MB (tier boundary)" {
# 500 is not < 500, so the WARN tier must not fire → PASS.
df() { printf 'Avail\n500M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[PASS]'* ]]
[[ $output != *'[WARN]'* ]]
[[ $output == *'500MB free'* ]]
}
@test "_doctor_check_disk_space: no false PASS on leading-zero df output" {
# '0099' clears the numeric regex but would make (( )) parse the
# value as octal and error out, falling through to the PASS
# branch. The 10# normalization must read it as 99 → FAIL tier.
df() { printf 'Avail\n0099M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[FAIL]'* ]]
[[ $output != *'[PASS]'* ]]
[[ $output == *'99MB free'* ]]
}
@test "_doctor_check_disk_space: passes with ample free space" {
df() { printf 'Avail\n2048M\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $output == *'[PASS]'* ]]
[[ $output == *'2048MB free'* ]]
}
@test "_doctor_check_disk_space: no false PASS on non-numeric df output" {
# A malformed/empty avail field must not slip through as a PASS,
# and the skip must be visible rather than hiding behind a clean
# summary.
df() { printf 'Avail\nN/A\n'; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $status -eq 0 ]]
[[ $output != *'[PASS]'* ]]
[[ $output != *'[FAIL]'* ]]
[[ $output != *'[WARN]'* ]]
[[ $output == *'Disk space: unable to read (df)'* ]]
}
@test "_doctor_check_disk_space: visible skip when df is unavailable" {
df() { return 127; }
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
[[ $status -eq 0 ]]
[[ $output == *'Disk space: unable to read (df)'* ]]
[[ $output != *'[PASS]'* ]]
[[ $output != *'[FAIL]'* ]]
[[ $output != *'[WARN]'* ]]
}
# =============================================================================
# _doctor_check_pkg_version: package-manager ownership (#711)
# =============================================================================
# Make `command -v` report the named package tools (rpm, dpkg-query)
# as missing so tests can simulate single-manager or tool-less hosts
# regardless of what the CI/dev box really has installed. Same shadow
# trick as _skip_gtk_query: `command -v` finds functions too, so
# shadowing `command` itself is the only reliable way.
_hide_pkg_tools() {
_hidden_pkg_tools=" $* "
command() {
if [[ $1 == '-v' \
&& $_hidden_pkg_tools == *" $2 "* ]]; then
return 1
fi
builtin command "$@"
}
}
@test "_doctor_check_pkg_version: rpm owns the path — rpm version wins over stale dpkg record (#711)" {
# The #711 repro: Fedora host, rpm owns the install, but a stale
# dpkg record from an old deb experiment still answers. The rpm
# answer must win; the stale dpkg version must not appear at all.
rpm() { printf '1.11847.5-2.0.19'; }
dpkg-query() { printf '1.5354.0'; }
run _doctor_check_pkg_version \
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Installed version: 1.11847.5-2.0.19'* ]]
[[ $output != *'1.5354.0'* ]]
}
@test "_doctor_check_pkg_version: dpkg-only host reports dpkg version" {
_hide_pkg_tools rpm
dpkg-query() { printf '1.11847.5'; }
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Installed version: 1.11847.5'* ]]
[[ $output != *'[WARN]'* ]]
}
@test "_doctor_check_pkg_version: dual-DB host where rpm does not own the path falls back to dpkg" {
# rpm exists but the install is a real deb: `rpm -qf` says "not
# owned" (rc=1, message on stdout) and dpkg must be consulted.
rpm() {
# $4 = probe path ($1=-qf $2=--qf $3=<format>)
printf 'file %s is not owned by any package\n' "$4"
return 1
}
dpkg-query() { printf '1.11847.5'; }
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ $output == *'[PASS]'* ]]
[[ $output == *'Installed version: 1.11847.5'* ]]
[[ $output != *'not owned'* ]]
}
@test "_doctor_check_pkg_version: neither manager owns the install — warn (AppImage/Nix)" {
rpm() { return 1; }
dpkg-query() { return 1; }
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ $output == *'[WARN]'* ]]
[[ $output == *'AppImage'* ]]
[[ $output != *'[PASS]'* ]]
}
@test "_doctor_check_pkg_version: silent when no package tools exist" {
_hide_pkg_tools rpm dpkg-query
run _doctor_check_pkg_version ''
[[ $status -eq 0 ]]
[[ -z $output ]]
}

View File

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

@@ -1,213 +0,0 @@
#!/usr/bin/env bats
#
# launcher-disable-gpu.bats
# Tests for the CLAUDE_DISABLE_GPU env var handling in
# build_electron_args (scripts/launcher-common.sh). The var is an
# opt-in workaround for the Chromium GPU process FATAL exhaustion
# tracked in #583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
# --disable-software-rasterizer; co-occurrence with XRDP must not
# stack duplicate flags.
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
LAUNCHER_COMMON="${SCRIPT_DIR}/../scripts/launcher-common.sh"
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
# loginctl shim — same pattern as launcher-xrdp-detection.bats.
# Defaults to a non-XRDP session so CLAUDE_DISABLE_GPU is the
# only signal in play unless a test overrides MOCK_LOGINCTL_TYPE.
mkdir -p "$TEST_TMP/bin"
cat > "$TEST_TMP/bin/loginctl" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "${MOCK_LOGINCTL_TYPE:-x11}"
SHIM
chmod +x "$TEST_TMP/bin/loginctl"
export PATH="$TEST_TMP/bin:$PATH"
log_file="$TEST_TMP/launcher.log"
: > "$log_file"
unset CLAUDE_DISABLE_GPU
unset XRDP_SESSION
unset XDG_SESSION_ID
unset MOCK_LOGINCTL_TYPE
# shellcheck disable=SC1090
source "$LAUNCHER_COMMON"
is_wayland=false
use_x11_on_wayland=true
}
teardown() {
if [[ -n ${TEST_TMP:-} && -d $TEST_TMP ]]; then
rm -rf "$TEST_TMP"
fi
}
args_contain() {
local needle="$1"
local arg
for arg in "${electron_args[@]}"; do
[[ $arg == "$needle" ]] && return 0
done
return 1
}
args_count() {
local needle="$1"
local arg count=0
for arg in "${electron_args[@]}"; do
[[ $arg == "$needle" ]] && ((count++))
done
printf '%d' "$count"
}
# =============================================================================
# CLAUDE_DISABLE_GPU=1 — flags must be added
# =============================================================================
@test "disable-gpu: CLAUDE_DISABLE_GPU=1 adds flags + logs message" {
export CLAUDE_DISABLE_GPU=1
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
}
# =============================================================================
# Co-occurrence with XRDP — no duplicate flags
# =============================================================================
@test "disable-gpu: with XRDP_SESSION, flags added exactly once (no dup)" {
export CLAUDE_DISABLE_GPU=1
export XRDP_SESSION=1
export XDG_SESSION_ID=5
export MOCK_LOGINCTL_TYPE=xrdp
build_electron_args deb
[[ "$(args_count '--disable-gpu')" -eq 1 ]]
[[ "$(args_count '--disable-software-rasterizer')" -eq 1 ]]
# Both signals should still log (independent diagnostic value),
# but only one set of flags should reach electron_args.
grep -q 'XRDP session detected' "$log_file"
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
}
# =============================================================================
# Off-states — flags must NOT be added
# =============================================================================
@test "disable-gpu: unset — flags NOT added" {
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
run args_contain '--disable-software-rasterizer'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: empty string — flags NOT added" {
export CLAUDE_DISABLE_GPU=''
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: =0 — flags NOT added (only literal '1' opts in)" {
export CLAUDE_DISABLE_GPU=0
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: =true — flags NOT added (no boolean aliases)" {
# Documents the strict equality check. If we ever add aliases,
# update this test to match. Strict-only matches the existing
# CLAUDE_USE_WAYLAND pattern.
export CLAUDE_DISABLE_GPU=true
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}
@test "disable-gpu: prior GPU fatal auto-disables on next launch" {
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start ---
LOG
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
}
@test "disable-gpu: recovery stays sticky on launch N+2 (no oscillation)" {
# A recovered launch runs with --disable-gpu and writes no GPU
# output, so the crash signature alone would re-enable GPU on
# launch N+2 (crash/work/crash forever). The launcher's own
# "disabling GPU" marker in the penultimate section must keep
# recovery tripped.
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start ---
Previous launch hit GPU process FATAL - disabling GPU
--- Claude Desktop Launcher Start ---
LOG
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
}
@test "disable-gpu: NixOS launcher header sections are detected" {
# nix/claude-desktop.nix writes "Launcher Start (NixOS)" headers;
# the section regex must match them or recovery silently no-ops
# on Nix.
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start (NixOS) ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start (NixOS) ---
LOG
build_electron_args deb
args_contain '--disable-gpu'
args_contain '--disable-software-rasterizer'
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
}
@test "disable-gpu: CLAUDE_DISABLE_GPU=0 suppresses auto fallback" {
cat > "$log_file" <<'LOG'
--- Claude Desktop Launcher Start ---
GPU process launch failed: error_code=1002
GPU process isn't usable. Goodbye.
--- Claude Desktop Launcher Start ---
LOG
export CLAUDE_DISABLE_GPU=0
build_electron_args deb
run args_contain '--disable-gpu'
[[ "$status" -ne 0 ]]
}

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

@@ -7,16 +7,6 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=tests/test-artifact-common.sh
source "$script_dir/test-artifact-common.sh"
# Single point of cleanup, set at script scope so any interruption
# between resource alloc and normal exit is covered. _launch_smoke_cleanup
# (test-artifact-common.sh) reaps an interrupted launch and its temp dirs;
# extract_dir is AppImage-specific so it's torn down here.
_cleanup() {
_launch_smoke_cleanup
[[ -n ${extract_dir:-} ]] && rm -rf "$extract_dir"
}
trap _cleanup EXIT INT TERM
component_id='io.github.aaddrick.claude-desktop-debian'
# Find the AppImage file (exclude .zsync)
@@ -104,30 +94,7 @@ 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" "${component_id}.desktop"
# --- Doctor smoke test ---
# Some --doctor checks fail in CI (no display, etc.); we only care that
# the script itself didn't crash via signal or exec failure (>=127).
doctor_exit=0
"$appimage_file" --doctor >/dev/null 2>&1 || doctor_exit=$?
if [[ $doctor_exit -lt 127 ]]; then
pass "--doctor runs without crashing (exit: $doctor_exit)"
else
fail "--doctor crashed (exit: $doctor_exit)"
fi
# --- Headless launch smoke test ---
# The AppImage runs as the (non-root) CI user, so no privilege drop.
# The pkill sweep matches 'mount_claude', not the .AppImage path: a running
# AppImage execs Electron from its FUSE mount (/tmp/.mount_claudeXXXX), so
# the escaped zygote/electron children live there. Matching the artifact
# path would sweep nothing. See CLAUDE.md (`pkill -9 -f "mount_claude"`).
# Sweep escaped children only in CI: locally, 'mount_claude' also
# matches a developer's live Claude Desktop AppImage session.
smoke_sweep=''
[[ -n ${CI:-} ]] && smoke_sweep='mount_claude'
run_launch_smoke_test 'AppImage' "$smoke_sweep" '' "$appimage_file"
validate_app_contents "$resources_dir"
# --- Cleanup ---
rm -rf "$extract_dir"

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