mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-06-11 04:53:34 +03:00
Compare commits
231 Commits
docs/compa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85450c90b | ||
|
|
e0c41b4e52 | ||
|
|
fa184216b3 | ||
|
|
dc2e0ecce2 | ||
|
|
4a6a540bf1 | ||
|
|
e1bdafd169 | ||
|
|
c2ceb5e74f | ||
|
|
63940b2684 | ||
|
|
55347230b2 | ||
|
|
73b504834a | ||
|
|
6f10b009ec | ||
|
|
50dd1f0366 | ||
|
|
9790c7d77c | ||
|
|
9648416aab | ||
|
|
26de2c2957 | ||
|
|
12cc726fc1 | ||
|
|
e50adf298a | ||
|
|
0aae168ab2 | ||
|
|
b0ca0c811f | ||
|
|
e18325dc25 | ||
|
|
98306b56b5 | ||
|
|
79973e3422 | ||
|
|
b35a1404ce | ||
|
|
974f4d397b | ||
|
|
bdd5b00f40 | ||
|
|
bcdc047dd5 | ||
|
|
1c241ab443 | ||
|
|
769036d55f | ||
|
|
a87024c3e2 | ||
|
|
8319407726 | ||
|
|
f3647b044d | ||
|
|
a4b85115f0 | ||
|
|
de957183f1 | ||
|
|
06736b4cd7 | ||
|
|
c6cc037a30 | ||
|
|
d1eb24a7d4 | ||
|
|
5c43ccd6e2 | ||
|
|
ab17b69a9c | ||
|
|
b78d3104e6 | ||
|
|
992dd34353 | ||
|
|
4002fd34f8 | ||
|
|
6ed49f5a3f | ||
|
|
9236476c10 | ||
|
|
29fa6daa36 | ||
|
|
eefaad3c9d | ||
|
|
133ffcee86 | ||
|
|
6b02fbbfa6 | ||
|
|
d854e67ed2 | ||
|
|
c4fd450cee | ||
|
|
b7b444d13e | ||
|
|
a691909ef2 | ||
|
|
62fca415e1 | ||
|
|
3cf58ca26d | ||
|
|
b2abc50a77 | ||
|
|
d8693baa6f | ||
|
|
b511a8ba6f | ||
|
|
d1fe02185b | ||
|
|
f2aa627a51 | ||
|
|
47e4011bd0 | ||
|
|
3a01379792 | ||
|
|
d99cdbd0e0 | ||
|
|
1b31a41835 | ||
|
|
b94aec10d1 | ||
|
|
2b332b99c9 | ||
|
|
9762043c84 | ||
|
|
4b6ff35236 | ||
|
|
6c09ca26b5 | ||
|
|
eb3fec9cd2 | ||
|
|
3cfe1812fc | ||
|
|
49acfd1ea0 | ||
|
|
3e18776965 | ||
|
|
fd72940516 | ||
|
|
407ba29add | ||
|
|
514bba919f | ||
|
|
77f7d8dafe | ||
|
|
af05e3d1dc | ||
|
|
e81f1a000d | ||
|
|
bd5c699f5a | ||
|
|
2f7c3e7148 | ||
|
|
c56130b906 | ||
|
|
230819fa23 | ||
|
|
21fe23d434 | ||
|
|
88eb10d1c2 | ||
|
|
95dc1840e1 | ||
|
|
d53474ba50 | ||
|
|
056fef077e | ||
|
|
6da192a6c2 | ||
|
|
09b883628d | ||
|
|
9ed1bf0f51 | ||
|
|
9505574cc3 | ||
|
|
5b55a98a7b | ||
|
|
aa08302d45 | ||
|
|
5772cc1eb9 | ||
|
|
2ad33d36c2 | ||
|
|
9dc431404f | ||
|
|
53dfe4a9dd | ||
|
|
32ce566080 | ||
|
|
0b281eb954 | ||
|
|
2ede75d049 | ||
|
|
8b4aa53449 | ||
|
|
a77cec7578 | ||
|
|
a5b54dd3c7 | ||
|
|
55bc328574 | ||
|
|
e13e331745 | ||
|
|
4fd8d8ce5d | ||
|
|
f803af9efc | ||
|
|
dd8d6e1dd6 | ||
|
|
1ae489dece | ||
|
|
623f1b0373 | ||
|
|
2ae2172a60 | ||
|
|
5dd948e96d | ||
|
|
5513f1b867 | ||
|
|
7dc26eae9b | ||
|
|
2ed019405f | ||
|
|
5b2fb4141b | ||
|
|
988a866310 | ||
|
|
e38066efef | ||
|
|
ec12c49092 | ||
|
|
73c9b8f6b2 | ||
|
|
7917ea4927 | ||
|
|
e7e647512a | ||
|
|
4409f3f0d4 | ||
|
|
4451694930 | ||
|
|
98232dbd81 | ||
|
|
76a5a21725 | ||
|
|
e31ac3b4da | ||
|
|
3e1e508f69 | ||
|
|
5f67aa1ae4 | ||
|
|
a894d41f76 | ||
|
|
a470b30079 | ||
|
|
b40441c66c | ||
|
|
364147ecc6 | ||
|
|
d6fc044490 | ||
|
|
9f99e578da | ||
|
|
ee3d656715 | ||
|
|
fed3b54bb5 | ||
|
|
7151d77b8d | ||
|
|
7b990c3aeb | ||
|
|
3df24958a3 | ||
|
|
58eef6d865 | ||
|
|
428777aca5 | ||
|
|
880d21d51f | ||
|
|
6bfb296d5c | ||
|
|
337e9a45b0 | ||
|
|
a32e1aa3c3 | ||
|
|
1e339aea93 | ||
|
|
e9b71cb567 | ||
|
|
016d8660c8 | ||
|
|
d54efca7de | ||
|
|
920c2be926 | ||
|
|
dc00767cd8 | ||
|
|
fa42d4d05f | ||
|
|
de604e9445 | ||
|
|
5b5c604723 | ||
|
|
97531b2cdf | ||
|
|
230bc7a9e4 | ||
|
|
66344770f8 | ||
|
|
ffb85a80c1 | ||
|
|
7a0fdb3e9a | ||
|
|
bdaff4acf4 | ||
|
|
d58a9188b9 | ||
|
|
808a9b739b | ||
|
|
0c74631f84 | ||
|
|
bdb7bec749 | ||
|
|
6a7c898e55 | ||
|
|
c81ca46179 | ||
|
|
9f260316c8 | ||
|
|
c48c438c68 | ||
|
|
a04ed9e6b4 | ||
|
|
a2685b0f6f | ||
|
|
b8fe6b8502 | ||
|
|
e649a485a6 | ||
|
|
bc3580c23e | ||
|
|
7c226fbfc9 | ||
|
|
5c1d54920b | ||
|
|
bebf8f2c36 | ||
|
|
6219d5a6a8 | ||
|
|
1a7083d765 | ||
|
|
decb512144 | ||
|
|
ba2846c8b3 | ||
|
|
6eca4da798 | ||
|
|
4a1bbc9e95 | ||
|
|
b676519c58 | ||
|
|
4b2b1d3390 | ||
|
|
d50e5c366e | ||
|
|
b017c72e8f | ||
|
|
25abb00e61 | ||
|
|
8fedb6a77e | ||
|
|
58f7ba3263 | ||
|
|
ba8ffa1637 | ||
|
|
d632fdb253 | ||
|
|
04f9a18b69 | ||
|
|
16f1bc8be1 | ||
|
|
56d22ca97a | ||
|
|
c9df9e2f2d | ||
|
|
5a9c7eb00c | ||
|
|
57cfab8c37 | ||
|
|
8796aa2c82 | ||
|
|
b0be17dd36 | ||
|
|
3b86003a6b | ||
|
|
b23e0aea12 | ||
|
|
755f283431 | ||
|
|
cf085711f2 | ||
|
|
fa8f3441c0 | ||
|
|
3db7866e69 | ||
|
|
d5a4104684 | ||
|
|
15813ca11f | ||
|
|
429d191f77 | ||
|
|
ab5636ef29 | ||
|
|
0d67646d21 | ||
|
|
cf64b78611 | ||
|
|
f7c4daeb89 | ||
|
|
51e0bc7acd | ||
|
|
368f83490e | ||
|
|
88676f44a6 | ||
|
|
a2411b8928 | ||
|
|
59ec0c6918 | ||
|
|
8882f0fe26 | ||
|
|
9df8b88e3a | ||
|
|
ccce3eab37 | ||
|
|
0efa67d417 | ||
|
|
023a736f1c | ||
|
|
3ddfb7353c | ||
|
|
3506c14918 | ||
|
|
b8e1a1fc30 | ||
|
|
0bbb550421 | ||
|
|
b351d42a2d | ||
|
|
b404ebd5f1 | ||
|
|
14d04c2dab | ||
|
|
fd352f4390 | ||
|
|
5a98854137 |
@@ -658,7 +658,7 @@ Bash scripts in this project are located in:
|
||||
- `.claude/hooks/` - Session lifecycle hooks (build tool installation, linting, PR simplification)
|
||||
|
||||
When writing scripts for this project:
|
||||
- Follow the style guide in `STYLEGUIDE.md` (enforced by shellcheck)
|
||||
- Follow the style guide in `docs/styleguides/bash_styleguide.md` (enforced by shellcheck)
|
||||
- Use existing modular scripts in `scripts/` as patterns for build logic
|
||||
- Reference `build.sh` for architecture detection and package orchestration patterns
|
||||
- Test scripts work on both amd64 and arm64 architectures where applicable
|
||||
|
||||
@@ -6,7 +6,7 @@ model: opus
|
||||
|
||||
You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions.
|
||||
|
||||
**Reference**: Follow the [Bash Style Guide](../../STYLEGUIDE.md)
|
||||
**Reference**: Follow the [Bash Style Guide](../../docs/styleguides/bash_styleguide.md)
|
||||
|
||||
You will analyze recently modified code and apply refinements that:
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Only launch delegates for domains that have changed files in the PR. All domain
|
||||
|
||||
| Changed Files | Agent | What to Ask |
|
||||
|---|---|---|
|
||||
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against STYLEGUIDE.md and CLAUDE.md conventions. Report issues with suggested fixes. |
|
||||
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against `docs/styleguides/bash_styleguide.md` and CLAUDE.md conventions. Report issues with suggested fixes. |
|
||||
| JS files in `scripts/` | `electron-linux-specialist` | Review for Electron API correctness, error handling, cross-DE robustness (GNOME, KDE, Xfce, Cinnamon). Note: frame-fix-entry.js is generated by build.sh. |
|
||||
| sed patterns in `build.sh` | `patch-engineer` | Check whitespace tolerance, idempotency guards, dynamic extraction error checks, match specificity, `-E` flag usage. Minified names change between releases — must use regex. |
|
||||
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Check format constraints (RPM version hyphens, AppImage --no-sandbox, deb permissions), cross-format consistency, desktop integration. |
|
||||
@@ -202,12 +202,12 @@ claude-desktop-debian/
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── resources/ # Desktop entries, icons
|
||||
├── CLAUDE.md # Project conventions
|
||||
└── STYLEGUIDE.md # Bash style guide
|
||||
└── docs/styleguides/bash_styleguide.md # Bash style guide (formerly STYLEGUIDE.md at root)
|
||||
# Note: frame-fix-entry.js is generated by build.sh, not a standalone file
|
||||
```
|
||||
|
||||
### Key Conventions
|
||||
- Shell: follows STYLEGUIDE.md strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
|
||||
- Shell: follows `docs/styleguides/bash_styleguide.md` strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
|
||||
- JS in scripts/: standalone files using Electron APIs (not minified)
|
||||
- JS in build.sh: sed patterns against minified source (must use regex)
|
||||
- Attribution: reviews end with `Written by Claude <model> via [Claude Code](...)`
|
||||
@@ -222,7 +222,7 @@ claude-desktop-debian/
|
||||
|
||||
| File Type | Delegate To | Focus Area |
|
||||
|-----------|------------|------------|
|
||||
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | STYLEGUIDE.md compliance, clarity |
|
||||
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | `docs/styleguides/bash_styleguide.md` compliance, clarity |
|
||||
| JS files (`scripts/*.js`) | `electron-linux-specialist` | Electron APIs, cross-DE compatibility |
|
||||
| sed patterns in `build.sh` | `patch-engineer` | Regex robustness, idempotency, extraction |
|
||||
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Format constraints, cross-format consistency |
|
||||
|
||||
@@ -24,7 +24,7 @@ You are a senior Electron and Linux desktop integration specialist with deep exp
|
||||
- **Menu Bar Management**: Hiding/showing menu bars on Linux, `autoHideMenuBar`, `setMenuBarVisibility`, and `Menu.setApplicationMenu` interception.
|
||||
|
||||
**Not in scope** (defer to other agents):
|
||||
- Shell script style and STYLEGUIDE.md compliance (defer to `cdd-code-simplifier`)
|
||||
- Shell script style and `docs/styleguides/bash_styleguide.md` compliance (defer to `cdd-code-simplifier`)
|
||||
- PR review orchestration (defer to `code-reviewer`)
|
||||
- CI/CD workflow YAML and release automation
|
||||
- Debian/RPM package metadata and control files
|
||||
@@ -241,7 +241,7 @@ The `code-reviewer` agent delegates JavaScript file reviews (files in `scripts/`
|
||||
|
||||
This agent provides Electron domain expertise; `cdd-code-simplifier` handles shell style:
|
||||
- This agent specifies WHAT Electron flags/env vars/APIs to use
|
||||
- `cdd-code-simplifier` ensures the shell code implementing them follows STYLEGUIDE.md
|
||||
- `cdd-code-simplifier` ensures the shell code implementing them follows `docs/styleguides/bash_styleguide.md`
|
||||
|
||||
### Providing Guidance on Patches
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ claude-desktop-debian/
|
||||
claude-native-stub.js # Native module replacement
|
||||
.github/workflows/ # CI/CD (defer to ci-workflow-architect)
|
||||
CLAUDE.md # Project conventions
|
||||
STYLEGUIDE.md # Bash style guide
|
||||
docs/styleguides/bash_styleguide.md # Bash style guide
|
||||
```
|
||||
|
||||
### Version String Flow
|
||||
|
||||
@@ -102,7 +102,7 @@ claude-desktop-debian/
|
||||
│ ├── frame-fix-entry.js # Generated entry point (by build.sh)
|
||||
│ └── claude-native-stub.js # Native module replacement
|
||||
├── CLAUDE.md # Project conventions
|
||||
└── STYLEGUIDE.md # Bash style guide
|
||||
└── docs/styleguides/bash_styleguide.md # Bash style guide
|
||||
```
|
||||
|
||||
### Key Files
|
||||
@@ -127,11 +127,11 @@ The electron module variable name changes every release. This extraction finds i
|
||||
|
||||
```bash
|
||||
# Primary: find the variable assigned from require("electron")
|
||||
electron_var=$(grep -oP '\b\w+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
|
||||
electron_var=$(grep -oP '\b[$\w]+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
|
||||
|
||||
# Fallback: find it from Tray usage if require pattern doesn't match
|
||||
if [[ -z $electron_var ]]; then
|
||||
electron_var=$(grep -oP '(?<=new )\w+(?=\.Tray\b)' "$index_js" | head -1)
|
||||
electron_var=$(grep -oP '(?<=new )[$\w]+(?=\.Tray\b)' "$index_js" | head -1)
|
||||
fi
|
||||
|
||||
# Always validate
|
||||
@@ -149,13 +149,13 @@ Three connected extractions, each depending on the previous:
|
||||
|
||||
```bash
|
||||
# Step 1: Find the tray rebuild function name from event handler
|
||||
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' "$index_js")
|
||||
|
||||
# Step 2: Find the tray variable using the function name as anchor
|
||||
tray_var=$(grep -oP "\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" "$index_js")
|
||||
tray_var=$(grep -oP "\}\);let \K[\$\w]+(?==null;(?:async )?function ${tray_func})" "$index_js")
|
||||
|
||||
# Step 3: Find the first const inside the function for insertion point
|
||||
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K\w+(?==)" "$index_js" | head -1)
|
||||
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K[\$\w]+(?==)" "$index_js" | head -1)
|
||||
```
|
||||
|
||||
Each uses a stable string literal as anchor and captures the adjacent minified name.
|
||||
@@ -206,7 +206,7 @@ Note: `e.hide()` uses a minified variable name `e`, but this is safe because it'
|
||||
```bash
|
||||
# Find all variables used with .nativeTheme that aren't the correct electron var
|
||||
mapfile -t wrong_refs < <(
|
||||
grep -oP '\b\w+(?=\.nativeTheme)' "$index_js" \
|
||||
grep -oP '\b[$\w]+(?=\.nativeTheme)' "$index_js" \
|
||||
| sort -u \
|
||||
| grep -v "^${electron_var}$" || true
|
||||
)
|
||||
@@ -288,7 +288,7 @@ When writing a new patch or modifying an existing one:
|
||||
|
||||
## SHELL STYLE NOTES
|
||||
|
||||
Follow the project's [Bash Style Guide](../../STYLEGUIDE.md) for all shell code:
|
||||
Follow the project's [Bash Style Guide](../../docs/styleguides/bash_styleguide.md) for all shell code:
|
||||
|
||||
- Tabs for indentation
|
||||
- Lines under 80 characters (exception: long regex patterns and URLs)
|
||||
|
||||
@@ -14,7 +14,7 @@ You are NOT a code quality reviewer. You do not evaluate:
|
||||
- Performance characteristics
|
||||
- Best practices or design patterns
|
||||
- Test coverage or test quality
|
||||
- Shell script conventions (STYLEGUIDE.md compliance)
|
||||
- Shell script conventions (`docs/styleguides/bash_styleguide.md` compliance)
|
||||
- Minified JS regex pattern quality
|
||||
|
||||
Those concerns belong to the `code-reviewer` agent, which runs in parallel with you.
|
||||
@@ -235,7 +235,7 @@ Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
|
||||
|
||||
Leave these concerns to the `code-reviewer` agent:
|
||||
- Code quality, style, and formatting
|
||||
- Shell script STYLEGUIDE.md compliance
|
||||
- Shell script `docs/styleguides/bash_styleguide.md` compliance
|
||||
- Regex pattern quality in sed commands
|
||||
- Performance implications
|
||||
- Security vulnerabilities
|
||||
|
||||
@@ -759,7 +759,7 @@ IMPORTANT SCOPE CONSTRAINT: This is for issue #$ISSUE_NUMBER. Only simplify code
|
||||
|
||||
If no relevant files were modified as part of this issue's implementation, make no changes and report 'No changes to simplify'.
|
||||
|
||||
Simplify code for clarity and consistency without changing functionality. Follow STYLEGUIDE.md conventions for shell scripts.
|
||||
Simplify code for clarity and consistency without changing functionality. Follow docs/styleguides/bash_styleguide.md conventions for shell scripts.
|
||||
Output a summary of changes made."
|
||||
|
||||
local simplify_result
|
||||
|
||||
@@ -107,7 +107,7 @@ Before writing the agent, gather domain knowledge and project context:
|
||||
Glob: "scripts/*.sh"
|
||||
Glob: ".github/workflows/*.yml"
|
||||
Grep: "function.*\(\)" # in shell scripts
|
||||
Read: "CLAUDE.md", "README.md", "STYLEGUIDE.md"
|
||||
Read: "CLAUDE.md", "README.md", "docs/styleguides/bash_styleguide.md"
|
||||
|
||||
# Find existing agent patterns
|
||||
Glob: ".claude/agents/*.md"
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,.codespellrc
|
||||
check-hidden = true
|
||||
# ignore-regex =
|
||||
# ignore-words-list =
|
||||
# ignore-regex =
|
||||
# openIn — substring of `openInEditor` IPC channel name (upstream).
|
||||
# YHe — minified function identifier in build-reference anchor.
|
||||
# hel — three-char literal in QE-13 example ("hel (3) submits").
|
||||
ignore-words-list = openIn,YHe,hel
|
||||
|
||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -62,7 +62,12 @@
|
||||
# ---- Docs & style ----
|
||||
/README.md @aaddrick
|
||||
/CLAUDE.md @aaddrick
|
||||
/STYLEGUIDE.md @aaddrick
|
||||
/AGENTS.md @aaddrick
|
||||
/CONTRIBUTING.md @aaddrick
|
||||
/CHANGELOG.md @aaddrick
|
||||
/RELEASING.md @aaddrick
|
||||
/SECURITY.md @aaddrick
|
||||
/docs/styleguides/ @aaddrick
|
||||
/docs/ @aaddrick
|
||||
|
||||
# ---- Testing & release quality ----
|
||||
@@ -76,9 +81,9 @@
|
||||
/.github/workflows/tests.yml @sabiut
|
||||
|
||||
# Shared review — either owner can approve.
|
||||
# TROUBLESHOOTING is mostly the --doctor user-facing guide; lint
|
||||
# troubleshooting.md is mostly the --doctor user-facing guide; lint
|
||||
# touches everything, so either maintainer can sign off.
|
||||
/docs/TROUBLESHOOTING.md @aaddrick @sabiut
|
||||
/docs/troubleshooting.md @aaddrick @sabiut
|
||||
/.github/workflows/shellcheck.yml @aaddrick @sabiut
|
||||
|
||||
#===============================================================================
|
||||
|
||||
23
.github/workflows/build-amd64.yml
vendored
23
.github/workflows/build-amd64.yml
vendored
@@ -49,6 +49,29 @@ jobs:
|
||||
fi
|
||||
./build.sh ${{ inputs.build_flags }} $TAG_FLAG
|
||||
|
||||
# Static-grep the shipped asar for the cowork patch markers
|
||||
# defined in scripts/cowork-patch-markers.tsv (issue #559 D6,
|
||||
# PR #555). Pinned to amd64-deb because the patched JS is
|
||||
# identical across formats, so one verification per CI run is
|
||||
# sufficient — no need to duplicate across the matrix.
|
||||
- name: Verify cowork patches in shipped asar
|
||||
if: inputs.artifact_suffix == 'deb'
|
||||
run: |
|
||||
deb_file=$(find . -maxdepth 1 -name 'claude-desktop_*amd64.deb' \
|
||||
-print -quit)
|
||||
if [[ -z "$deb_file" ]]; then
|
||||
echo "verify-patches: no .deb artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
extract_dir=$(mktemp -d)
|
||||
dpkg-deb -x "$deb_file" "$extract_dir"
|
||||
asar_path=$(find "$extract_dir" -name app.asar -print -quit)
|
||||
if [[ -z "$asar_path" ]]; then
|
||||
echo "verify-patches: app.asar not found in deb" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/verify-patches.sh "$asar_path"
|
||||
|
||||
- name: Upload AMD64 Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -54,9 +54,11 @@ jobs:
|
||||
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
|
||||
|
||||
test-artifacts:
|
||||
name: Test Build Artifacts
|
||||
name: Test Build Artifacts (amd64)
|
||||
needs: [build-amd64]
|
||||
uses: ./.github/workflows/test-artifacts.yml
|
||||
with:
|
||||
arch: amd64
|
||||
|
||||
build-arm64:
|
||||
name: Build Packages (arm64 - ${{ matrix.artifact_suffix }})
|
||||
@@ -82,10 +84,17 @@ jobs:
|
||||
artifact_suffix: ${{ matrix.artifact_suffix }}
|
||||
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
|
||||
|
||||
test-artifacts-arm64:
|
||||
name: Test Build Artifacts (arm64)
|
||||
needs: [build-arm64]
|
||||
uses: ./.github/workflows/test-artifacts.yml
|
||||
with:
|
||||
arch: arm64
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [test-flags, build-amd64, build-arm64, test-artifacts]
|
||||
needs: [test-flags, build-amd64, build-arm64, test-artifacts, test-artifacts-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -674,6 +683,7 @@ jobs:
|
||||
'gpgcheck=1' \
|
||||
'repo_gpgcheck=1' \
|
||||
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
|
||||
'metadata_expire=1h' \
|
||||
> rpm/claude-desktop.repo
|
||||
|
||||
- name: Re-upload signed RPMs to GitHub Release
|
||||
|
||||
59
.github/workflows/issue-triage-v2.yml
vendored
59
.github/workflows/issue-triage-v2.yml
vendored
@@ -215,6 +215,7 @@ jobs:
|
||||
if: steps.classify.outputs.classification == 'bug' || steps.classify.outputs.classification == 'enhancement'
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
FIRST_PASS: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/classify-doublecheck-bug-vs-enhancement.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -249,7 +250,7 @@ jobs:
|
||||
printf '%s' "${structured}" \
|
||||
> /tmp/triage/classification-doublecheck.json
|
||||
|
||||
first_pass="${{ steps.classify.outputs.classification }}"
|
||||
first_pass="${FIRST_PASS}"
|
||||
verdict=$(jq -r '.verdict' \
|
||||
/tmp/triage/classification-doublecheck.json)
|
||||
|
||||
@@ -271,10 +272,14 @@ jobs:
|
||||
# classifier entirely.
|
||||
- name: Decide route
|
||||
id: route
|
||||
env:
|
||||
SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
DISAGREED: ${{ steps.doublecheck.outputs.disagreed }}
|
||||
run: |
|
||||
suspicious="${{ steps.suspicious.outputs.suspicious }}"
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
disagreed="${{ steps.doublecheck.outputs.disagreed }}"
|
||||
suspicious="${SUSPICIOUS}"
|
||||
classification="${CLASSIFICATION}"
|
||||
disagreed="${DISAGREED}"
|
||||
|
||||
if [[ "${suspicious}" == "true" ]]; then
|
||||
echo "route=deferral" >> "$GITHUB_OUTPUT"
|
||||
@@ -484,6 +489,7 @@ jobs:
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/investigate.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -530,7 +536,7 @@ jobs:
|
||||
# the PR. The reporter named a culprit; the diff is a
|
||||
# primary input for Stage 4 because the defect site is
|
||||
# almost always inside the named PR's changed files.
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
echo "## Regression context (PR named by reporter)"
|
||||
echo ""
|
||||
reg_title=$(jq -r '.title' /tmp/triage/regression-of.json)
|
||||
@@ -763,6 +769,7 @@ jobs:
|
||||
|| steps.dup_fetch.outputs.dup_fetched == 'true')
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/review.json)
|
||||
@@ -867,7 +874,7 @@ jobs:
|
||||
# regression_of diff block — only when Stage 3b validated.
|
||||
# Lets the reviewer check whether a finding's citation
|
||||
# actually lands inside the named PR's changed files.
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
echo "## regression_of PR diff (reporter-named culprit)"
|
||||
echo ""
|
||||
reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json)
|
||||
@@ -1027,25 +1034,37 @@ jobs:
|
||||
# low-confidence cause).
|
||||
- name: Decide comment variant
|
||||
id: decide
|
||||
env:
|
||||
ROUTE: ${{ steps.route.outputs.route }}
|
||||
DEFERRAL_REASON_ID: ${{ steps.route.outputs.deferral_reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
FETCH_OK: ${{ steps.fetch.outputs.fetch_ok }}
|
||||
INVEST_OK: ${{ steps.investigate.outputs.investigate_ok }}
|
||||
DRIFT: ${{ steps.drift.outputs.drift_detected }}
|
||||
REVIEW_OK: ${{ steps.review.outputs.review_ok }}
|
||||
FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }}
|
||||
KEPT: ${{ steps.filter.outputs.review_findings_kept }}
|
||||
AVG: ${{ steps.filter.outputs.review_avg_confidence }}
|
||||
DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }}
|
||||
run: |
|
||||
route="${{ steps.route.outputs.route }}"
|
||||
route="${ROUTE}"
|
||||
|
||||
if [[ "${route}" == "deferral" ]]; then
|
||||
echo "variant=8b" >> "$GITHUB_OUTPUT"
|
||||
echo "reason_id=${{ steps.route.outputs.deferral_reason_id }}" \
|
||||
echo "reason_id=${DEFERRAL_REASON_ID}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
fetch_ok="${{ steps.fetch.outputs.fetch_ok }}"
|
||||
invest_ok="${{ steps.investigate.outputs.investigate_ok }}"
|
||||
drift="${{ steps.drift.outputs.drift_detected }}"
|
||||
review_ok="${{ steps.review.outputs.review_ok }}"
|
||||
findings_passed="${{ steps.validate.outputs.findings_passed }}"
|
||||
kept="${{ steps.filter.outputs.review_findings_kept }}"
|
||||
avg="${{ steps.filter.outputs.review_avg_confidence }}"
|
||||
dup_rating="${{ steps.filter.outputs.duplicate_of_rating }}"
|
||||
classification="${CLASSIFICATION}"
|
||||
fetch_ok="${FETCH_OK}"
|
||||
invest_ok="${INVEST_OK}"
|
||||
drift="${DRIFT}"
|
||||
review_ok="${REVIEW_OK}"
|
||||
findings_passed="${FINDINGS_PASSED}"
|
||||
kept="${KEPT}"
|
||||
avg="${AVG}"
|
||||
dup_rating="${DUP_RATING}"
|
||||
|
||||
# Shared gates that apply to every investigate route.
|
||||
if [[ "${fetch_ok}" != "true" ]]; then
|
||||
@@ -1735,9 +1754,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REASON_ID: ${{ steps.decide.outputs.reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
VARIANT: ${{ steps.decide.outputs.variant }}
|
||||
run: |
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
variant="${{ steps.decide.outputs.variant }}"
|
||||
classification="${CLASSIFICATION}"
|
||||
variant="${VARIANT}"
|
||||
|
||||
if [[ "${variant}" == "8a" ]]; then
|
||||
triage_label="triage: investigated"
|
||||
|
||||
53
.github/workflows/test-artifacts.yml
vendored
53
.github/workflows/test-artifacts.yml
vendored
@@ -2,6 +2,11 @@ name: Test Build Artifacts (Reusable)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
arch:
|
||||
description: Architecture of the artifacts under test (amd64/arm64)
|
||||
type: string
|
||||
default: amd64
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,17 +18,17 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- format: deb
|
||||
artifact: package-amd64-deb
|
||||
container: ""
|
||||
- format: rpm
|
||||
artifact: package-amd64-rpm
|
||||
container: "fedora:42"
|
||||
- format: appimage
|
||||
artifact: package-amd64-appimage
|
||||
container: ""
|
||||
|
||||
name: Validate ${{ matrix.format }} package
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate ${{ inputs.arch }} ${{ matrix.format }} package
|
||||
# arm64 artifacts run on a native arm64 runner (matching build-arm64)
|
||||
# so the launch smoke test actually executes the packaged binary
|
||||
# rather than failing on a foreign architecture.
|
||||
runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-latest' }}
|
||||
container: ${{ matrix.container || '' }}
|
||||
|
||||
steps:
|
||||
@@ -33,20 +38,52 @@ jobs:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
name: package-${{ inputs.arch }}-${{ matrix.format }}
|
||||
path: artifacts/
|
||||
|
||||
- name: Install test dependencies (Fedora)
|
||||
if: matrix.format == 'rpm'
|
||||
run: dnf install -y findutils file nodejs npm
|
||||
# Electron's shared libraries (nss/nspr/gtk3/X11/etc.) must be
|
||||
# installed explicitly: the rpm is installed with `rpm -ivh --nodeps`
|
||||
# and its spec sets `AutoReqProv: no`, so the package declares no
|
||||
# runtime Requires and nothing pulls these in. Without them the
|
||||
# launch smoke test dies with `libnspr4.so: cannot open shared
|
||||
# object file` (exit 127). The Ubuntu runner already carries them.
|
||||
run: |
|
||||
dnf install -y findutils file nodejs npm \
|
||||
xorg-x11-server-Xvfb dbus-daemon util-linux procps-ng \
|
||||
nss nspr atk at-spi2-atk at-spi2-core cups-libs gtk3 \
|
||||
libdrm mesa-libgbm alsa-lib libX11 libXcomposite libXdamage \
|
||||
libXext libXfixes libXrandr libxcb libxkbcommon pango cairo \
|
||||
libXScrnSaver libXtst libxshmfence
|
||||
|
||||
- name: Install test dependencies (Ubuntu)
|
||||
if: matrix.format != 'rpm'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y file libfuse2 nodejs npm
|
||||
sudo apt-get install -y file libfuse2 nodejs npm \
|
||||
xvfb dbus-x11 procps
|
||||
|
||||
# Fail loud if a smoke-test tool is missing. Without this guard a
|
||||
# missing/renamed tool turns run_launch_smoke_test into a silent
|
||||
# green skip (it does `pass "$skip"; return`), masking the test.
|
||||
- name: Verify smoke-test tools are present (Ubuntu)
|
||||
if: matrix.format != 'rpm'
|
||||
run: |
|
||||
for t in xvfb-run dbus-run-session setsid; do
|
||||
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Verify smoke-test tools are present (Fedora)
|
||||
if: matrix.format == 'rpm'
|
||||
run: |
|
||||
for t in xvfb-run dbus-run-session setsid runuser; do
|
||||
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Run artifact tests
|
||||
env:
|
||||
TARGET_ARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
chmod +x tests/test-artifact-${{ matrix.format }}.sh
|
||||
tests/test-artifact-${{ matrix.format }}.sh artifacts/
|
||||
|
||||
6
.github/workflows/test-flags.yml
vendored
6
.github/workflows/test-flags.yml
vendored
@@ -4,6 +4,12 @@ on:
|
||||
workflow_call: # Make this workflow reusable
|
||||
workflow_dispatch: # Allows manual triggering for testing
|
||||
|
||||
concurrency:
|
||||
group: test-flags-${{ github.ref }}
|
||||
# Matches ci.yml: queue rather than cancel, so a reusable invocation
|
||||
# from an in-flight CI run isn't killed mid-flight on the next push.
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-flags:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -24,6 +24,13 @@ Thumbs.db
|
||||
# Test build output
|
||||
test-build/
|
||||
|
||||
# Playwright stray output — the harness writes to
|
||||
# tools/test-harness/results/ per playwright.config.ts, but Playwright
|
||||
# also drops a default `test-results/.last-run.json` next to the cwd
|
||||
# it's invoked from. Ignore it at the repo root so an accidental run
|
||||
# from here doesn't dirty the tree.
|
||||
test-results/
|
||||
|
||||
# Reference files for source inspection
|
||||
build-reference/
|
||||
|
||||
@@ -34,6 +41,18 @@ result-*
|
||||
# Wrangler (Cloudflare Worker dev/deploy cache)
|
||||
worker/.wrangler/
|
||||
|
||||
# UI snapshots — captured renderer state, intentionally ignored to avoid
|
||||
# diff churn. See docs/testing/ui-snapshots/README.md.
|
||||
docs/testing/ui-snapshots/*.json
|
||||
# Graphify outputs and temporary files
|
||||
graphify-out/
|
||||
.graphify_*
|
||||
|
||||
# Local agent/editor state and helper bins
|
||||
.agents/
|
||||
.codex/
|
||||
.tmpbin/
|
||||
|
||||
# Local package artifacts
|
||||
*.rpm
|
||||
|
||||
# Root-level scratch extracts from app inspection
|
||||
/frame-fix-wrapper.js
|
||||
/index.js
|
||||
|
||||
493
AGENTS.md
493
AGENTS.md
@@ -1,13 +1,492 @@
|
||||
# AGENTS.md
|
||||
|
||||
All project instructions, conventions, and development guidelines are maintained in [CLAUDE.md](CLAUDE.md).
|
||||
<!--
|
||||
This file is read by AI tools that support the agents.md vendor-neutral
|
||||
standard. The content below is duplicated in CLAUDE.md (read by Claude
|
||||
Code) so that contributors using either receive the same instructions
|
||||
without needing to cross-reference. Keep CLAUDE.md and AGENTS.md
|
||||
byte-identical below the H1 title (the sync-policy comment above is the
|
||||
one place they intentionally differ) — if you edit one, edit the other.
|
||||
-->
|
||||
|
||||
Strictly follow the rules defined there.
|
||||
## Required reading
|
||||
|
||||
## Project Tooling
|
||||
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
|
||||
|
||||
Subagent definitions, skills, and orchestration scripts live in [`.claude/`](.claude/):
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
|
||||
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
|
||||
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
|
||||
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
|
||||
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
|
||||
|
||||
- `.claude/agents/` - Specialized subagent definitions for the Task tool
|
||||
- `.claude/skills/` - User-invocable skills (slash commands)
|
||||
- `.claude/scripts/` - Orchestration scripts that chain multiple Claude CLI calls
|
||||
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
|
||||
|
||||
## Learnings
|
||||
|
||||
The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical knowledge from debugging and fixing issues — things that aren't obvious from reading the code or docs alone. Consult these before working on related areas. Add new entries when you discover something non-obvious that would save future contributors (human or AI) significant time.
|
||||
|
||||
- [`nix.md`](docs/learnings/nix.md) — NixOS packaging, Electron resource path resolution, testing without NixOS
|
||||
- [`cowork-vm-daemon.md`](docs/learnings/cowork-vm-daemon.md) — Cowork VM daemon lifecycle, respawn logic, crash diagnosis
|
||||
- [`plugin-install.md`](docs/learnings/plugin-install.md) — Anthropic & Partners plugin install flow, gate logic, backend endpoints, and DevTools recipes
|
||||
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
|
||||
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
|
||||
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
|
||||
|
||||
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
|
||||
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
|
||||
- Single quotes for literals, double quotes for expansions
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
|
||||
1. **Fix the code** - Correct the underlying issue rather than suppressing the warning
|
||||
2. **Disable directives are a last resort** - Only use `# shellcheck disable=SCXXXX` when:
|
||||
- The warning is a false positive
|
||||
- The pattern is intentional and unavoidable
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
|
||||
- Use `gh` CLI for all GitHub interactions
|
||||
- Create branches based on issue numbers: `fix/123-description` or `feature/123-description`
|
||||
- Reference issues in commits and PRs with `#123` or `Fixes #123`
|
||||
- After creating a PR, add a comment to the related issue with a summary and link to the PR
|
||||
|
||||
### Investigating Issues
|
||||
|
||||
For older issues, review the state of the code when the issue was raised - it may have already been addressed:
|
||||
|
||||
```bash
|
||||
# Get issue creation date
|
||||
gh issue view 123 --json createdAt
|
||||
|
||||
# Find the commit just before the issue was created
|
||||
git log --oneline --until="2025-08-23T08:48:35Z" -1
|
||||
|
||||
# View a file at that point in time
|
||||
git show <commit>:path/to/file.sh
|
||||
|
||||
# Search for relevant changes since the issue was created
|
||||
git log --oneline --after="2025-08-23" -- path/to/file.sh
|
||||
|
||||
# View a specific commit that may have fixed the issue
|
||||
git show <commit>
|
||||
```
|
||||
|
||||
This helps identify if the issue was already fixed, and allows referencing the specific commit in the response.
|
||||
|
||||
### Attribution
|
||||
|
||||
**For PR descriptions**, include full attribution:
|
||||
|
||||
```
|
||||
---
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
|
||||
<XX>% AI / <YY>% Human
|
||||
Claude: <what AI did>
|
||||
Human: <what human did>
|
||||
```
|
||||
|
||||
- Use the actual model name (e.g., `Claude Opus 4.5`, `Claude Sonnet 4`)
|
||||
- The percentage split should honestly reflect the contribution balance for that specific work
|
||||
- This provides a trackable record of AI-assisted development over time
|
||||
|
||||
**For issues and comments**, use simplified attribution:
|
||||
|
||||
```
|
||||
---
|
||||
Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
|
||||
```
|
||||
|
||||
**For commits**, include a Co-Authored-By trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude <claude@anthropic.com>
|
||||
```
|
||||
|
||||
### Contributor Credits
|
||||
|
||||
The README Acknowledgments section credits external contributors in chronological order (by merge date or fix date). Update it when:
|
||||
|
||||
1. **Merging an external PR** — Add the author to the Acknowledgments list with a link to their GitHub profile and a brief description of their contribution.
|
||||
2. **Implementing a fix suggested in an issue** — If an issue author (or commenter) provided a concrete fix, workaround, code snippet, or detailed technical analysis that was directly used, credit them too.
|
||||
|
||||
Contributors are listed in chronological order: inspirational projects first (k3d3, emsi, leobuskin), then contributors ordered by when their contribution was merged or implemented.
|
||||
|
||||
## Working with Minified JavaScript
|
||||
|
||||
### Important Guidelines
|
||||
|
||||
1. **Always use regex patterns** when modifying the source JavaScript. Patches live in `scripts/patches/*.sh` (one file per subsystem: `tray.sh`, `cowork.sh`, `claude-code.sh`, etc.); `build.sh` is only an orchestrator that sources them. Variable and function names are minified and **change between releases**.
|
||||
|
||||
2. **The beautified code in `build-reference/` has different spacing** than the actual minified code in the app. Patterns must handle both:
|
||||
- Minified: `oe.nativeTheme.on("updated",()=>{`
|
||||
- Beautified: `oe.nativeTheme.on("updated", () => {`
|
||||
|
||||
3. **Use `-E` flag with sed** for extended regex support when patterns need grouping or alternation.
|
||||
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
```bash
|
||||
# Bad: assumes no spaces
|
||||
sed -i 's/oe.nativeTheme.on("updated",()=>{/...'
|
||||
|
||||
# Good: handles optional whitespace
|
||||
sed -i -E 's/(oe\.nativeTheme\.on\(\s*"updated"\s*,\s*\(\)\s*=>\s*\{)/...'
|
||||
```
|
||||
|
||||
### Reference Files
|
||||
|
||||
- `build-reference/app-extracted/` - Extracted and beautified source for analysis
|
||||
- `build-reference/tray-icons/` - Tray icon assets for reference
|
||||
|
||||
## Frame Fix Wrapper
|
||||
|
||||
The app uses a wrapper system to intercept and fix Electron behavior for Linux:
|
||||
|
||||
- **`frame-fix-wrapper.js`** - Intercepts `require('electron')` to patch BrowserWindow defaults (e.g., `frame: true` for proper window decorations on Linux)
|
||||
- **`frame-fix-entry.js`** - Entry point that loads the wrapper before the main app
|
||||
|
||||
These are injected by `scripts/patches/app-asar.sh` (inside `patch_app_asar`) and referenced in `package.json`'s `main` field. The wrapper pattern allows fixing Electron behavior without modifying the minified app code directly.
|
||||
|
||||
## Setting Up build-reference
|
||||
|
||||
If `build-reference/` is missing or you need to inspect source for a new version, follow these steps to download, extract, and beautify the source code.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Install required tools
|
||||
sudo apt install p7zip-full wget nodejs npm
|
||||
|
||||
# Install asar and prettier globally (or use npx)
|
||||
npm install -g @electron/asar prettier
|
||||
```
|
||||
|
||||
### Step 1: Download the Windows Installer
|
||||
|
||||
The Windows installer contains the app.asar which has the full Electron app source.
|
||||
|
||||
```bash
|
||||
# Create working directory
|
||||
mkdir -p build-reference && cd build-reference
|
||||
|
||||
# Download URL pattern (update version as needed):
|
||||
# x64: https://downloads.claude.ai/releases/win32/x64/VERSION/Claude-COMMIT.exe
|
||||
# arm64: https://downloads.claude.ai/releases/win32/arm64/VERSION/Claude-COMMIT.exe
|
||||
|
||||
# Example for version 1.1.381:
|
||||
wget -O Claude-Setup-x64.exe "https://downloads.claude.ai/releases/win32/x64/1.1.381/Claude-c2a39e9c82f5a4d51f511f53f532afd276312731.exe"
|
||||
```
|
||||
|
||||
### Step 2: Extract the Installer
|
||||
|
||||
```bash
|
||||
# Extract the exe (it's a 7z archive)
|
||||
7z x -y Claude-Setup-x64.exe -o"exe-contents"
|
||||
|
||||
# Find and extract the nupkg
|
||||
cd exe-contents
|
||||
NUPKG=$(find . -name "AnthropicClaude-*.nupkg" | head -1)
|
||||
7z x -y "$NUPKG" -o"nupkg-contents"
|
||||
cd ..
|
||||
|
||||
# Copy out the important files
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/app.asar .
|
||||
cp -a exe-contents/nupkg-contents/lib/net45/resources/app.asar.unpacked .
|
||||
|
||||
# Optional: copy tray icons for reference
|
||||
mkdir -p tray-icons
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/*.png tray-icons/ 2>/dev/null || true
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/*.ico tray-icons/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
### Step 3: Extract app.asar
|
||||
|
||||
```bash
|
||||
# Extract the asar archive
|
||||
asar extract app.asar app-extracted
|
||||
```
|
||||
|
||||
### Step 4: Beautify the JavaScript Files
|
||||
|
||||
The extracted JS files are minified. Use prettier to make them readable:
|
||||
|
||||
```bash
|
||||
# Beautify all JS files in the build directory
|
||||
npx prettier --write "app-extracted/.vite/build/*.js"
|
||||
|
||||
# Or beautify specific files
|
||||
npx prettier --write app-extracted/.vite/build/index.js
|
||||
npx prettier --write app-extracted/.vite/build/mainWindow.js
|
||||
```
|
||||
|
||||
### Step 5: Clean Up (Optional)
|
||||
|
||||
```bash
|
||||
# Remove intermediate files, keep only what's needed for reference
|
||||
rm -rf exe-contents
|
||||
rm Claude-Setup-x64.exe
|
||||
rm -rf app.asar app.asar.unpacked # Keep only app-extracted
|
||||
```
|
||||
|
||||
### Final Structure
|
||||
|
||||
```
|
||||
build-reference/
|
||||
├── app-extracted/
|
||||
│ ├── .vite/
|
||||
│ │ ├── build/
|
||||
│ │ │ ├── index.js # Main process (beautified)
|
||||
│ │ │ ├── mainWindow.js # Main window preload
|
||||
│ │ │ ├── mainView.js # Main view preload
|
||||
│ │ │ └── ...
|
||||
│ │ └── renderer/
|
||||
│ │ └── ...
|
||||
│ ├── node_modules/
|
||||
│ │ └── @ant/claude-native/ # Native bindings (stubs)
|
||||
│ └── package.json
|
||||
├── tray-icons/
|
||||
│ ├── TrayIconTemplate.png # Black icon (for light panels)
|
||||
│ ├── TrayIconTemplate-Dark.png # White icon (for dark panels)
|
||||
│ └── ...
|
||||
└── nupkg-contents/ # Optional: full extracted nupkg
|
||||
```
|
||||
|
||||
## Adding New Package Formats or Repositories
|
||||
|
||||
When adding support for new distribution formats (e.g., RPM, Flatpak, Snap) or package repositories, follow these guidelines to avoid iterative debugging in CI.
|
||||
|
||||
### Research Before Implementing
|
||||
|
||||
1. **Understand the target system's constraints** - Each package format has specific rules:
|
||||
- Version string formats (e.g., RPM cannot have hyphens in Version field)
|
||||
- Required metadata fields
|
||||
- Signing requirements and tools
|
||||
|
||||
2. **Search for existing CI implementations** - Look for "GitHub Actions [format] signing" or similar. Existing workflows reveal required flags, environment setup, and common pitfalls.
|
||||
|
||||
3. **Check tool behavior in non-interactive environments** - CI has no TTY. Tools like GPG need flags like `--batch` and `--yes` to work without prompts.
|
||||
|
||||
### Consider Concurrency
|
||||
|
||||
1. **Multiple jobs writing to the same branch will race** - If APT and DNF repos both push to `gh-pages`, add:
|
||||
- Job dependencies (`needs: [other-job]`), or
|
||||
- Retry loops with `git pull --rebase` before push
|
||||
|
||||
2. **External processes may also modify branches** - GitHub Pages deployment runs automatically and can cause push conflicts.
|
||||
|
||||
### Test the Full Pipeline
|
||||
|
||||
1. **Test CI steps locally first** - Run the signing/packaging commands manually to catch errors before committing.
|
||||
|
||||
2. **Use a test tag for new infrastructure** - Create a non-release tag to validate the full CI pipeline before merging to main.
|
||||
|
||||
3. **Verify the end-user experience** - After CI succeeds, actually test the install commands from the README on a clean system.
|
||||
|
||||
### Common CI Pitfalls
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| GPG "cannot open /dev/tty" | Add `--batch` flag |
|
||||
| GPG "File exists" error | Add `--yes` flag to overwrite |
|
||||
| Push rejected (ref changed) | Add `git pull --rebase` before push, with retry loop |
|
||||
| Version format invalid | Research target format's version constraints upfront |
|
||||
| Signing key not found | Ensure key is imported before signing step, check key ID output |
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Triggering Builds
|
||||
|
||||
```bash
|
||||
# Trigger CI on a branch
|
||||
gh workflow run CI --ref branch-name
|
||||
|
||||
# Watch the run
|
||||
gh run watch RUN_ID
|
||||
|
||||
# Download artifacts
|
||||
gh run download RUN_ID -n artifact-name
|
||||
```
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
- `claude-desktop-VERSION-amd64.deb` - Debian package for x86_64
|
||||
- `claude-desktop-VERSION-amd64.AppImage` - AppImage for x86_64
|
||||
- `claude-desktop-VERSION-arm64.deb` - Debian package for ARM64
|
||||
- `claude-desktop-VERSION-arm64.AppImage` - AppImage for ARM64
|
||||
- `result/` - Nix build output (symlink, gitignored)
|
||||
|
||||
## Distribution
|
||||
|
||||
APT and DNF binaries are fronted by a Cloudflare Worker at `pkg.claude-desktop-debian.dev`. Metadata (`InRelease`, `Packages`, `KEY.gpg`, `repodata/*`) passes through to the `gh-pages` branch; binary requests (`/pool/.../*.deb`, `/rpm/*/*.rpm`) get 302'd to the corresponding GitHub Release asset. This keeps `.deb` / `.rpm` files out of `gh-pages` entirely, so they never hit GitHub's 100 MB per-file push cap.
|
||||
|
||||
Key files:
|
||||
- `worker/src/worker.js` — Worker source
|
||||
- `worker/wrangler.toml` — Worker config (route, `custom_domain = true`)
|
||||
- `.github/workflows/deploy-worker.yml` — deploys on push to `main` when `worker/**` changes
|
||||
- `.github/workflows/apt-repo-heartbeat.yml` — daily chain validation, auto-opens tracking issue on failure
|
||||
- `update-apt-repo` and `update-dnf-repo` jobs in `.github/workflows/ci.yml` — gate a strip step on Worker liveness, so binaries are removed from the local pool tree before push
|
||||
|
||||
Repo secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`. Token scoped to the "Edit Cloudflare Workers" template.
|
||||
|
||||
Full details including the redirect chain, the http-scheme-downgrade gotcha, credential ownership, and heartbeat failure runbook: [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md).
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Build
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no
|
||||
```
|
||||
|
||||
### Nix Build
|
||||
|
||||
```bash
|
||||
nix build .#claude-desktop
|
||||
nix build .#claude-desktop-fhs
|
||||
```
|
||||
|
||||
### Testing AppImage
|
||||
|
||||
```bash
|
||||
# Run with logging
|
||||
./test-build/claude-desktop-*.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
### Inspecting the Running App's Code
|
||||
|
||||
```bash
|
||||
# Find the mounted AppImage path
|
||||
mount | grep claude
|
||||
# Example: /tmp/.mount_claudeXXXXXX
|
||||
|
||||
# Extract the running app's asar for inspection
|
||||
npx asar extract /tmp/.mount_claudeXXXXXX/usr/lib/node_modules/electron/dist/resources/app.asar /tmp/claude-inspect
|
||||
|
||||
# Search for patterns in the extracted code
|
||||
grep -n "pattern" /tmp/claude-inspect/.vite/build/index.js
|
||||
```
|
||||
|
||||
### Checking DBus/Tray Status
|
||||
|
||||
```bash
|
||||
# List registered tray icons
|
||||
gdbus call --session --dest=org.kde.StatusNotifierWatcher \
|
||||
--object-path=/StatusNotifierWatcher \
|
||||
--method=org.freedesktop.DBus.Properties.Get \
|
||||
org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems
|
||||
|
||||
# Find which process owns a DBus connection
|
||||
gdbus call --session --dest=org.freedesktop.DBus \
|
||||
--object-path=/org/freedesktop/DBus \
|
||||
--method=org.freedesktop.DBus.GetConnectionUnixProcessID ":1.XXXX"
|
||||
```
|
||||
|
||||
### Log Locations
|
||||
|
||||
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
|
||||
- App logs: `~/.config/Claude/logs/`
|
||||
- Run with logging: `./app.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log`
|
||||
|
||||
## Useful Locations
|
||||
|
||||
- App data: `~/.config/Claude/`
|
||||
- Logs: `~/.config/Claude/logs/`
|
||||
- SingletonLock: `~/.config/Claude/SingletonLock`
|
||||
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
|
||||
|
||||
## Versioning
|
||||
|
||||
Release versions are managed via two GitHub Actions repository variables (not files):
|
||||
|
||||
- **`REPO_VERSION`** - The project's own version (e.g., `1.3.23`). Bump this manually via `gh variable set REPO_VERSION --body "X.Y.Z"` when shipping project changes.
|
||||
- **`CLAUDE_DESKTOP_VERSION`** - The upstream Claude Desktop version (e.g., `1.1.8629`). Updated automatically by the `check-claude-version` workflow when a new upstream release is detected.
|
||||
|
||||
### Tag format
|
||||
|
||||
Tags follow the pattern `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}`, e.g., `v1.3.23+claude1.1.7714`. Pushing a tag triggers the CI release build.
|
||||
|
||||
```bash
|
||||
# Check current values
|
||||
gh variable get REPO_VERSION
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
|
||||
# Bump repo version and tag a release
|
||||
gh variable set REPO_VERSION --body "1.3.24"
|
||||
git tag "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
git push origin "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
```
|
||||
|
||||
When upstream Claude Desktop updates, the `check-claude-version` workflow automatically updates `CLAUDE_DESKTOP_VERSION`, patches the URLs in `scripts/setup/detect-host.sh`, and creates a new tag — no manual intervention needed.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **`.zsync` files** - Used for delta updates, can be ignored/deleted
|
||||
- **AppImage mount points** - Running AppImages mount to `/tmp/.mount_claude*`; check with `mount | grep claude`
|
||||
- **Killing the app** - Must kill all electron child processes, not just the main one:
|
||||
```bash
|
||||
pkill -9 -f "mount_claude"
|
||||
```
|
||||
- **SingletonLock** - If app won't start, check for stale lock: `~/.config/Claude/SingletonLock`
|
||||
- **Node version** - Build requires Node.js; the script downloads its own if needed
|
||||
- **Nix hashes** - When Claude Desktop version changes, both the URLs in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix` (version, URLs, SRI hashes) must be updated. The CI handles this automatically.
|
||||
- **Claude Desktop version** - A GitHub Action automatically updates the `CLAUDE_DESKTOP_VERSION` repo variable and the URLs in `scripts/setup/detect-host.sh` on main when a new version is detected. Before committing `scripts/setup/detect-host.sh`, ensure your branch has the latest URLs:
|
||||
```bash
|
||||
# Check repo variable (source of truth)
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
|
||||
# Check current version in the detect_architecture case statement
|
||||
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
|
||||
|
||||
# If outdated, pull URLs from main branch
|
||||
gh api repos/aaddrick/claude-desktop-debian/contents/scripts/setup/detect-host.sh?ref=main \
|
||||
--jq '.content' | base64 -d | grep -E "claude_download_url="
|
||||
```
|
||||
Update both amd64 and arm64 URLs in `detect_architecture()` to match main
|
||||
|
||||
350
CHANGELOG.md
Normal file
350
CHANGELOG.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `aaddrick/claude-desktop-debian` are documented in this file.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — semantic versioning applies to `REPO_VERSION`; upstream Claude Desktop bumps (the `+claude{X.Y.Z}` suffix on the tag) are tracked separately by the `check-claude-version` workflow.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
<!-- Updated automatically by check-claude-version; will be current at release time. -->
|
||||
|
||||
### Fixed
|
||||
|
||||
- `claude-desktop --doctor` reports the installed version from the package manager that actually owns the install (probed via `rpm -qf` on the bundled Electron binary) instead of trusting `dpkg-query` alone — rpm installs on hosts that also carry a stale dpkg record (e.g. Fedora boxes with dpkg installed as a build tool) no longer show a months-old version with a PASS. ([#712](https://github.com/aaddrick/claude-desktop-debian/pull/712), fixes [#711](https://github.com/aaddrick/claude-desktop-debian/issues/711))
|
||||
|
||||
## [v2.0.19] — 2026-06-10
|
||||
|
||||
Tracks upstream Claude Desktop 1.11847.5.
|
||||
|
||||
### Added
|
||||
|
||||
- AppStream metainfo (`io.github.aaddrick.claude-desktop-debian.metainfo.xml`) installed by the deb, RPM, and AppImage builds, so the package appears in GNOME Software, KDE Discover, and App Center with correct unofficial-repackaging branding and a `LicenseRef-proprietary` project license. Store search for not-yet-installed users needs repo-side DEP-11/appstream metadata, tracked in [#708](https://github.com/aaddrick/claude-desktop-debian/issues/708). ([#633](https://github.com/aaddrick/claude-desktop-debian/pull/633))
|
||||
- GPU crash auto-recovery in the launcher: when the previous launch died to a Chromium GPU-process FATAL (the [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583) SIGTRAP signature), the next launch automatically applies safe GPU flags — and stays recovered on subsequent launches instead of oscillating crash/work/crash. Detects NixOS launcher log headers too; set `CLAUDE_DISABLE_GPU=0` to override. ([#666](https://github.com/aaddrick/claude-desktop-debian/pull/666))
|
||||
|
||||
### Fixed
|
||||
|
||||
- `claude-desktop --doctor` no longer reports a false-green PASS when the password store reads back empty or when `df` returns a non-numeric disk reading — bad reads now fail or print a visible skip instead of falling through to the PASS branch, and leading-zero `df` output can no longer slip past as octal arithmetic. ([#692](https://github.com/aaddrick/claude-desktop-debian/pull/692))
|
||||
- Explicit quit now keeps the launcher alive until Electron exits, then runs
|
||||
stale-helper cleanup for Desktop-owned Cowork, Claude config, and extension
|
||||
helpers. Close-to-tray still leaves the app and helpers running.
|
||||
([#682](https://github.com/aaddrick/claude-desktop-debian/pull/682))
|
||||
- All launchers (deb, RPM, AppImage, nix) no longer pass `app.asar` as an Electron
|
||||
argument. Electron auto-loads `app.asar` from its default `resources/` dir next to the
|
||||
binary, so the extra argv entry was redundant — and the app treated it as a
|
||||
file-to-open, surfacing a spurious "Attach app.asar?" prompt on launch and on every
|
||||
taskbar reopen. This removes the path at the source, complementing the renderer-side
|
||||
`.asar` guards in [#669](https://github.com/aaddrick/claude-desktop-debian/pull/669)
|
||||
and surviving upstream re-minification. Live-UI detection in the launcher and doctor,
|
||||
which fingerprinted on the now-removed argv, was updated alongside.
|
||||
([#700](https://github.com/aaddrick/claude-desktop-debian/pull/700),
|
||||
fixes [#696](https://github.com/aaddrick/claude-desktop-debian/issues/696))
|
||||
- Cowork's VM daemon never auto-launched on packages built under a restrictive umask (CI builds with umask `022`, so released artifacts were unaffected; local builds with e.g. `umask 077` were) because the bundled `app.asar.unpacked/` directory shipped as mode `0700` owned by the build uid, so the desktop user running the app couldn't traverse it and the auto-launch `fs.existsSync()` fork guard silently returned `false` (symptom: endless `connect ENOENT …/cowork-vm-service.sock`, no `cowork_vm_daemon.log`, no `[cowork-autolaunch]` line). `deb.sh` now normalizes the installed tree to canonical permissions (directories and executables `755`, other files `644`) and builds with `dpkg-deb --root-owner-group` for `root:root` ownership; `appimage.sh` applies the same normalization to the AppDir before `mksquashfs` (it copies with `cp -a`, which preserved the bad modes); and `rpm.sh` normalizes file modes in `%install` — `%defattr(-, root, root, 0755)` forces directory modes in the payload, but its `-` first field preserves file modes from the `cp -r`-populated buildroot, so a restrictive-umask RPM build shipped an unreadable `app.asar` and a non-executable electron binary.
|
||||
- Claude Desktop no longer crashes on launch on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces Chromium's sandbox needs (`sandbox/linux/services/credentials.cc` FATAL, `Trace/breakpoint trap`, exit 133). The `.deb` `postinst` now installs a scoped AppArmor profile granting `userns` to the bundled Electron binary — mirroring the `google-chrome`/`code`/`slack` packages — and removes it again on uninstall. The Chromium sandbox stays enabled (no `--no-sandbox`). `claude-desktop --doctor` gained a **User namespaces** check that flags a missing profile. ([#687](https://github.com/aaddrick/claude-desktop-debian/pull/687))
|
||||
- Cowork mode no longer silently falls back to host-direct (no isolation) on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces its bubblewrap sandbox needs. The `.deb` `postinst` now installs a second scoped AppArmor profile granting `userns` to `/usr/bin/bwrap` (distinct from the Electron profile above), automating the manual workaround from [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351) (contributed by [@hfyeh](https://github.com/hfyeh)). The profile is gated on the kernel's `apparmor_restrict_unprivileged_userns` knob and defers to any profile already attaching to `/usr/bin/bwrap` (a hand-made `/etc/apparmor.d/bwrap`, `apparmor-profiles`' `bwrap-userns-restrict`); put local overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` — they survive upgrades. `bubblewrap` is now a `Recommends`. ([#694](https://github.com/aaddrick/claude-desktop-debian/pull/694))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI now validates the arm64 deb, RPM, and AppImage artifacts on native `ubuntu-22.04-arm` runners (previously only amd64 was tested), and the AppImage launch smoke test's process sweep is keyed to `mount_claude` and gated behind `$CI` so a local test run can't kill a developer's live Claude Desktop session. The launcher's orphaned-daemon reaper also gained mutation-tested BATS coverage. ([#691](https://github.com/aaddrick/claude-desktop-debian/pull/691), [#693](https://github.com/aaddrick/claude-desktop-debian/pull/693))
|
||||
- The native-Wayland launch path now routes Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal: `GlobalShortcutsPortal` is added to the `--enable-features` set, and all Chromium feature requests are merged into a single `--enable-features=` switch (Chromium honours only the last one, so the previous code could silently clobber features). GNOME Wayland users can opt into the portal route with `CLAUDE_USE_WAYLAND=1`, which works on GNOME ≤ 49 after a one-time portal permission dialog and fixes the focus-bound hotkey from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404). The default GNOME session stays on XWayland (no rendering/IME regression risk); auto-selecting native Wayland on GNOME is deferred until it can be gated on a real render check. **On GNOME 50 / xdg-desktop-portal ≥ 1.20 the portal route is currently a no-op** — Electron/Chromium doesn't perform the portal's new host `Registry.Register` app-id handshake (filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875)). `CLAUDE_USE_WAYLAND` is now tri-state: `1` native Wayland, `0` force XWayland, unset auto-detects. ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404))
|
||||
|
||||
## [v2.0.18] — 2026-06-04
|
||||
|
||||
Tracks upstream Claude Desktop 1.10628.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tray icon no longer stuck black at startup on dark desktops. `nativeTheme.shouldUseDarkColors` reads `false` for the first ~50 ms then flips `true`, but the leading-edge rebuild mutex latched the transient `false` and dropped the corrective `"updated"` events; the mutex is now trailing-edge (re-applies the final value) and the obsolete 3 s startup-suppression window was removed. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680), fixes [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679))
|
||||
- Restored the in-place tray `setImage` fast-path ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515)), which silently stopped applying after upstream changed the context-menu wiring from `setContextMenu(BUILDER())` to a prebuilt `setContextMenu(MENU)` object — `patch_tray_inplace_update` now resolves the builder in both shapes, so the duplicate-icon SNI race no longer regresses. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680))
|
||||
- File-drop collector no longer re-attaches the app's own `app.asar` on every taskbar reopen. Electron's ASAR VFS shim returns `true` from `existsSync()` for `.asar` paths, so the second-instance argv collector dispatched `app.asar` to the file-drop handler and surfaced an attach prompt on each relaunch; it now rejects `.asar` paths, mirroring the existing `statSync` guard. ([#669](https://github.com/aaddrick/claude-desktop-debian/pull/669), fixes [#668](https://github.com/aaddrick/claude-desktop-debian/issues/668))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI now runs a headless launch smoke test for the deb and rpm artifacts — previously only the AppImage actually booted, so a startup-only regression (e.g. the Fedora `SyntaxError`) could stay green on the formats it broke. A shared `run_launch_smoke_test` helper covers all three formats and gracefully skips when a container forbids Chromium's sandbox. ([#671](https://github.com/aaddrick/claude-desktop-debian/pull/671), closes [#670](https://github.com/aaddrick/claude-desktop-debian/issues/670))
|
||||
|
||||
## [v2.0.17] — 2026-06-04
|
||||
|
||||
Tracks upstream Claude Desktop 1.10628.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `addTrustedFolder` `.asar` guard re-anchored on the `async addTrustedFolder(…)` method declaration. Upstream Claude Desktop 1.10628.x folded the `LocalAgentModeSessions.addTrustedFolder: ${i}` log call into a comma-expression inside an `if`, removing the trailing `` `); `` the old anchor matched — `./build.sh` aborted with `[FAIL] addTrustedFolder anchor not found`. Both the parameter extraction and the injection point now key off the unminified method name, so they can't drift apart if upstream drops the log line. ([#685](https://github.com/aaddrick/claude-desktop-debian/pull/685))
|
||||
|
||||
## [v2.0.16] — 2026-05-27
|
||||
|
||||
Tracks upstream Claude Desktop 1.9255.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cowork spawn guard now captures `$`-prefixed minified function names (e.g. `$Be`) and uses `globalThis._lastSpawn` instead of a bare `_globalLastSpawn` identifier, fixing `ReferenceError: _globalLastSpawn is not defined` that broke Cowork on all platforms with upstream 1.9255.0. ([#660](https://github.com/aaddrick/claude-desktop-debian/pull/660), fixes [#658](https://github.com/aaddrick/claude-desktop-debian/issues/658), [#659](https://github.com/aaddrick/claude-desktop-debian/issues/659), [#661](https://github.com/aaddrick/claude-desktop-debian/issues/661))
|
||||
|
||||
## [v2.0.15] — 2026-05-27
|
||||
|
||||
Tracks upstream Claude Desktop 1.9255.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `StartupWMClass` aligned to `Claude` to match what Electron actually advertises via `productName`. The v2.0.14 value `claude-desktop` was silently ignored by Electron, causing orphan windows and duplicate gear icons on GNOME/KDE. Value centralized from 6 hardcoded locations to one source of truth in `build.sh`, with build-time substitution and a `productName` assertion guard. ([#655](https://github.com/aaddrick/claude-desktop-debian/pull/655), fixes [#652](https://github.com/aaddrick/claude-desktop-debian/issues/652))
|
||||
- Tray variable extraction re-anchored on `.Tray()` literal instead of minifier-dependent syntax that upstream 1.9255.0 reshuffled. ([#657](https://github.com/aaddrick/claude-desktop-debian/pull/657), fixes [#656](https://github.com/aaddrick/claude-desktop-debian/issues/656))
|
||||
|
||||
## [v2.0.14] — 2026-05-25
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `WM_CLASS` and `StartupWMClass` aligned to `claude-desktop` across all formats (deb, RPM, AppImage, autostart). Resolves ambiguity with the Claude Code CLI (`claude`) and ensures consistent taskbar grouping on KDE/GNOME. ([#648](https://github.com/aaddrick/claude-desktop-debian/pull/648), fixes [#647](https://github.com/aaddrick/claude-desktop-debian/issues/647))
|
||||
|
||||
### Changed
|
||||
|
||||
- AppImage smoke test: replaced flat 10s sleep with readiness-marker poll (30s ceiling, 0.5s tick), unified cleanup trap to prevent 190MB `squashfs-root` leaks on interrupt. ([#646](https://github.com/aaddrick/claude-desktop-debian/pull/646))
|
||||
|
||||
## [v2.0.13] — 2026-05-24
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
|
||||
### Added
|
||||
|
||||
- `CLAUDE_KEEP_AWAKE=0` env var to suppress `powerSaveBlocker` sleep inhibitor that upstream holds indefinitely on Linux (no lifecycle management). Adds diagnostic logging for all `powerSaveBlocker` calls and `--doctor` visibility. ([#605](https://github.com/aaddrick/claude-desktop-debian/issues/605))
|
||||
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
|
||||
- F11 fullscreen toggle via hidden menu accelerator — Linux parity with macOS green button / Windows F11. ([#638](https://github.com/aaddrick/claude-desktop-debian/pull/638), fixes [#580](https://github.com/aaddrick/claude-desktop-debian/issues/580))
|
||||
- Linux org-plugins path (`/etc/claude/org-plugins`) added to platform switch, enabling MDM-managed plugin configuration. ([#639](https://github.com/aaddrick/claude-desktop-debian/pull/639), fixes [#607](https://github.com/aaddrick/claude-desktop-debian/issues/607))
|
||||
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
|
||||
- `--password-store` keyring detection: probes D-Bus for kwallet6 / gnome-libsecret at startup and injects the flag before the app path, fixing session persistence on KDE Plasma and other desktops where `safeStorage.isEncryptionAvailable()` returned false. Adds `CLAUDE_PASSWORD_STORE` env override and `--doctor` diagnostic. Thanks @dubreal. ([#611](https://github.com/aaddrick/claude-desktop-debian/pull/611), fixes [#593](https://github.com/aaddrick/claude-desktop-debian/issues/593))
|
||||
- Unzip fallback for Node 24: detects missing electron binary after `extract-zip` silently no-ops and recovers from the `@electron/get` cache using system `unzip`. Thanks @JustinJLeopard. ([#631](https://github.com/aaddrick/claude-desktop-debian/pull/631), fixes [#584](https://github.com/aaddrick/claude-desktop-debian/issues/584))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Config writes no longer drop externally-added `mcpServers`. The stale in-memory cache was overwriting disk on every preference change; now re-reads `mcpServers` from disk before each write. ([#643](https://github.com/aaddrick/claude-desktop-debian/pull/643), fixes [#400](https://github.com/aaddrick/claude-desktop-debian/issues/400))
|
||||
- Menu bar toggle fires on Alt keyup only, not keydown — fixes Alt+Shift (language switch) and Alt+F4 accidentally triggering the menu bar. `CLAUDE_MENU_BAR=hidden` disables the Alt toggle entirely. ([#642](https://github.com/aaddrick/claude-desktop-debian/pull/642), fixes [#630](https://github.com/aaddrick/claude-desktop-debian/issues/630))
|
||||
- `.asar` paths rejected in directory check, preventing Electron's ASAR VFS shim from dispatching `app.asar` to Cowork as a "folder drop". Fixes permission dialog on every launch, forced Cowork mode on reopen from tray, and "No conversation found" loop in Claude Code >=2.1.111. ([#640](https://github.com/aaddrick/claude-desktop-debian/pull/640), fixes [#383](https://github.com/aaddrick/claude-desktop-debian/issues/383), [#622](https://github.com/aaddrick/claude-desktop-debian/issues/622), [#632](https://github.com/aaddrick/claude-desktop-debian/issues/632))
|
||||
- Identifier captures across all patch scripts hardened from `\w+` to `[$\w]+` (PCRE) / `[[:alnum:]_$]+` (ERE). Fixes broken idempotency guard in `tray.sh`, adds missing guards to `cowork.sh` patches 6/9/10, adds `\s*` whitespace tolerance to multiple patterns. ([#644](https://github.com/aaddrick/claude-desktop-debian/pull/644))
|
||||
- `exec` before Electron invocation in deb, RPM, and Nix launchers so Ctrl+C and signals forward correctly to the Electron process. ([#637](https://github.com/aaddrick/claude-desktop-debian/pull/637), fixes [#424](https://github.com/aaddrick/claude-desktop-debian/issues/424))
|
||||
- `--class=Claude` added to launcher args ensuring WM_CLASS matches `StartupWMClass` in the .desktop file, preventing GNOME extension crashes from unexpected class values. ([#636](https://github.com/aaddrick/claude-desktop-debian/pull/636), ref [#635](https://github.com/aaddrick/claude-desktop-debian/issues/635))
|
||||
- Sloppy/focus-follows-mouse: suppress redundant `webContents.focus()` calls that trigger X11 `_NET_ACTIVE_WINDOW` raise-on-hover. Grace window handles stale `isFocused()` on tray-restore and minimize-restore. Thanks @tkrag. ([#589](https://github.com/aaddrick/claude-desktop-debian/pull/589), fixes [#416](https://github.com/aaddrick/claude-desktop-debian/issues/416))
|
||||
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
|
||||
- RPM: silence "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install` (replaces `%attr` in `%files`). Adds regression guard that fails the build if the warning reappears. Thanks @JoshuaVlantis. ([#610](https://github.com/aaddrick/claude-desktop-debian/pull/610), fixes [#609](https://github.com/aaddrick/claude-desktop-debian/issues/609))
|
||||
- Window close with `CLAUDE_QUIT_ON_CLOSE=1` now actively quits via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux. Rides upstream's own quit-in-progress guard. Thanks @phelps-matthew. ([#624](https://github.com/aaddrick/claude-desktop-debian/pull/624), fixes [#623](https://github.com/aaddrick/claude-desktop-debian/issues/623))
|
||||
- node-pty: wipe upstream Windows binaries (winpty.dll, winpty-agent.exe, Windows `.node` files) before staging the Linux build, preventing PE32+ orphans in the packaged asar. Thanks @JoshuaVlantis. ([#597](https://github.com/aaddrick/claude-desktop-debian/pull/597), addresses [#401](https://github.com/aaddrick/claude-desktop-debian/issues/401))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI injection hardening: moved `${{ steps.*.outputs.* }}` expressions from `run:` blocks to `env:` blocks in `issue-triage-v2.yml`. Build pipeline: `process.exit(0)` → `process.exit(1)` in `quick-window.sh` when patch anchors aren't found so CI fails instead of shipping broken patches. Packaging scriptlets: replaced `&> /dev/null` with `> /dev/null 2>&1` for dash compatibility in deb/RPM postinst. ([#641](https://github.com/aaddrick/claude-desktop-debian/pull/641))
|
||||
- Credit @lizthegrey, @sabiut, @typedrat, @RayCharlizard in README Acknowledgments. ([#626](https://github.com/aaddrick/claude-desktop-debian/pull/626))
|
||||
- Troubleshooting: new "Repeated Electron Crashes / GPU Process FATAL" section documenting `CLAUDE_DISABLE_GPU=1`. Adds tuning-rationale comments around the `--doctor` 3-in-7-days threshold and the `coredumpctl` `COMM=electron` assumption. Thanks @sabiut. ([#615](https://github.com/aaddrick/claude-desktop-debian/pull/615), addresses [#608](https://github.com/aaddrick/claude-desktop-debian/issues/608))
|
||||
- Docs filenames are now lowercase kebab-case (`docs/building.md`, `docs/configuration.md`, `docs/decisions.md`, `docs/troubleshooting.md`); `STYLEGUIDE.md` moved to [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md). Cross-references swept across README, CONTRIBUTING, CODEOWNERS, `.github/`, `.claude/`, `scripts/`, and `claude-desktop --doctor` user-facing output.
|
||||
- `[$\w]+` is the codified identifier-capture convention for patch-script regexes (CONTRIBUTING § Patch-script regexes; `patch-engineer` agent examples updated to match). Closes a docs-vs-code gap that left the rule only in [`docs/learnings/patching-minified-js.md`](docs/learnings/patching-minified-js.md) — the same `\w+` trap fixed in patches by [#555](https://github.com/aaddrick/claude-desktop-debian/pull/555) and [#627](https://github.com/aaddrick/claude-desktop-debian/pull/627).
|
||||
|
||||
## [v2.0.12] — 2026-05-19
|
||||
|
||||
Tracks upstream Claude Desktop 1.7196.3.
|
||||
|
||||
### Added
|
||||
|
||||
- Headless launch + `--doctor` smoke tests for the AppImage artifact. ([#592](https://github.com/aaddrick/claude-desktop-debian/pull/592))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI: add concurrency group to `test-flags` workflow. ([#606](https://github.com/aaddrick/claude-desktop-debian/pull/606))
|
||||
|
||||
## [v2.0.11] — 2026-05-16
|
||||
|
||||
Tracks upstream Claude Desktop 1.7196.1.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Catch About window after upstream `titleBarStyle` change; guard Hardware Buddy. ([#481](https://github.com/aaddrick/claude-desktop-debian/pull/481), [#489](https://github.com/aaddrick/claude-desktop-debian/pull/489))
|
||||
- RPM `chrome-sandbox` SUID now set via `%attr` instead of `%post chmod`. ([#539](https://github.com/aaddrick/claude-desktop-debian/pull/539), [#595](https://github.com/aaddrick/claude-desktop-debian/pull/595))
|
||||
- No-op `autoUpdater` on Linux to defend against feed activation; mask thenable/coercion traps on the Proxy. ([#567](https://github.com/aaddrick/claude-desktop-debian/pull/567), [#596](https://github.com/aaddrick/claude-desktop-debian/pull/596))
|
||||
- `node-pty` install fails loudly on `npm install` failure; require `gcc`/`make`/`python3`. ([#401](https://github.com/aaddrick/claude-desktop-debian/pull/401), [#598](https://github.com/aaddrick/claude-desktop-debian/pull/598))
|
||||
- Fetch electron binary via `@electron/get`, drop `^41` pin; resolve from `work_dir` not script dir. ([#587](https://github.com/aaddrick/claude-desktop-debian/pull/587))
|
||||
- Dedupe packages mapped from multiple commands.
|
||||
|
||||
## [v2.0.10] — 2026-05-06
|
||||
|
||||
Tracks upstream Claude Desktop 1.6259.0, 1.6259.1, 1.6608.0, 1.6608.2, 1.7196.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `--doctor` surfaces recent Electron crashes with a `#583` pointer; `CLAUDE_DISABLE_GPU=1` opt-in for GPU-process fatal crashes. ([#583](https://github.com/aaddrick/claude-desktop-debian/pull/583), [#585](https://github.com/aaddrick/claude-desktop-debian/pull/585))
|
||||
- `--doctor` detects IBus/GTK misconfigurations that break input. ([#572](https://github.com/aaddrick/claude-desktop-debian/pull/572))
|
||||
- Launcher: `CLAUDE_GTK_IM_MODULE` opt-in override. ([#571](https://github.com/aaddrick/claude-desktop-debian/pull/571))
|
||||
- Launcher: log session/IME env block at startup. ([#570](https://github.com/aaddrick/claude-desktop-debian/pull/570))
|
||||
- Linux compatibility test harness. ([#579](https://github.com/aaddrick/claude-desktop-debian/pull/579))
|
||||
- Lifecycle: notify and offer restart on in-place package upgrade. ([#564](https://github.com/aaddrick/claude-desktop-debian/pull/564))
|
||||
- `desktopName` set for Wayland window grouping. Thanks @jslatten. ([#562](https://github.com/aaddrick/claude-desktop-debian/pull/562))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pin electron to `^41` to restore postinstall binary fetch. ([#584](https://github.com/aaddrick/claude-desktop-debian/pull/584), [#586](https://github.com/aaddrick/claude-desktop-debian/pull/586))
|
||||
- Nix: make electron binary executable. ([#581](https://github.com/aaddrick/claude-desktop-debian/pull/581))
|
||||
- `cowork.sh`: emit WARNING on Patch 2a/2b inner anchor miss. ([#576](https://github.com/aaddrick/claude-desktop-debian/pull/576))
|
||||
- CI: force primary GPG key for `repomd.xml` signing. Thanks @ProfFlow. ([#566](https://github.com/aaddrick/claude-desktop-debian/pull/566))
|
||||
- DNF: set `metadata_expire=1h` on generated `.repo`. ([#551](https://github.com/aaddrick/claude-desktop-debian/pull/551))
|
||||
- BATS: isolate `cleanup_stale_cowork_socket` from host `pgrep` state. ([#534](https://github.com/aaddrick/claude-desktop-debian/pull/534))
|
||||
|
||||
### Changed
|
||||
|
||||
- Static-grep shipped asar for PR #555 markers as a verification step. ([#559](https://github.com/aaddrick/claude-desktop-debian/pull/559), [#575](https://github.com/aaddrick/claude-desktop-debian/pull/575))
|
||||
- New `patching-minified-js` learnings doc + `CONTRIBUTING`. ([#574](https://github.com/aaddrick/claude-desktop-debian/pull/574))
|
||||
- Refine `mcp-double-spawn` root cause and routing in learnings. ([#546](https://github.com/aaddrick/claude-desktop-debian/pull/546), [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547))
|
||||
- Archive upstream report draft for #546 (filed as `anthropics/claude-code#55353`). ([#552](https://github.com/aaddrick/claude-desktop-debian/pull/552))
|
||||
|
||||
## [v2.0.8] — 2026-05-02
|
||||
|
||||
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.7).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cowork starts again on Claude Desktop 1.5354.0. Upstream's minifier started emitting `$`-containing identifiers (`C$i`, `g$i`); two regex anchors in `scripts/patches/cowork.sh` used `\w+`, which doesn't match `$`. Patch 2b silently no-op'd, the Swift VM module assignment never landed, and you'd hit `Swift VM addon not available` at session init. Widens both anchors to `[\w$]+`. Patch 6 also moves from `indexOf` to `lastIndexOf` on the retry-delay anchor. Thanks @sirfaber, @HumboldtJoker, @zabka. ([#555](https://github.com/aaddrick/claude-desktop-debian/pull/555), fixes [#558](https://github.com/aaddrick/claude-desktop-debian/issues/558), likely fixes [#553](https://github.com/aaddrick/claude-desktop-debian/issues/553) and [#445](https://github.com/aaddrick/claude-desktop-debian/issues/445))
|
||||
|
||||
## [v2.0.7] — 2026-05-01
|
||||
|
||||
Tracks upstream Claude Desktop 1.5354.0 (unchanged from v2.0.6).
|
||||
|
||||
### Added
|
||||
|
||||
- Linux in-app topbar works now. New `hybrid` titlebar mode is the default: native OS frame plus a BrowserView preload shim that satisfies claude.ai's UA gate, so the hamburger, sidebar, search, and nav buttons render and are clickable. Layout is stacked (DE titlebar above the in-app topbar) rather than combined like Windows. Set `CLAUDE_TITLEBAR_STYLE=native` to opt out and hide the in-app topbar. The upstream `frame:false` + WCO config is preserved as `hidden` for investigation but still has unclickable buttons on Linux; `--doctor` warns when it's active. Verified on KDE Plasma X11/Wayland and Hyprland; GNOME, Sway, Niri, and NixOS pending. ([#538](https://github.com/aaddrick/claude-desktop-debian/pull/538))
|
||||
|
||||
## [v2.0.6] — 2026-05-01
|
||||
|
||||
Tracks upstream Claude Desktop 1.5354.0. Absorbs three upstream bumps from v2.0.5: 1.4758.0, 1.5220.0, 1.5354.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Cowork bwrap mounts accept a `{src, dst}` form, so you can map a host directory under `$HOME` onto a different path inside the sandbox. Unlocks persistent-`/tmp` so Bash tool calls don't wipe state between invocations. String form unchanged. Thanks @cbonnissent. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
|
||||
- `--doctor` warns when `COWORK_VM_BACKEND` is set to an unknown value instead of silently falling through to auto-detect; adds a `COWORK_VM_BACKEND` row and a Cowork Backend section to `docs/configuration.md`. Thanks @CyPack. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
|
||||
- `--doctor` warns when an additional bwrap mount destination shadows a default sandbox path like `/usr`, `/etc`, `/bin`, `/sbin`, `/lib`. ([#531](https://github.com/aaddrick/claude-desktop-debian/pull/531))
|
||||
- Troubleshooting entries for Cowork VM connection timeout, virtiofsd outside `$PATH` on Fedora/RHEL (`/usr/libexec/virtiofsd`), and Fedora tmpfs `EXDEV` errors. ([#324](https://github.com/aaddrick/claude-desktop-debian/issues/324))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing the window no longer kills the app on Linux. The X button hides to tray, matching Windows and macOS. Quit explicitly with Ctrl+Q, the tray menu, or your DE's quit shortcut. Set `CLAUDE_QUIT_ON_CLOSE=1` to restore the old behavior. Fixes scheduled tasks and `/schedule` firings getting silently dropped overnight. Thanks @lizthegrey. ([#451](https://github.com/aaddrick/claude-desktop-debian/pull/451))
|
||||
- "Run on startup" toggle persists on Linux now. Electron's `setLoginItemSettings` isn't implemented on Linux; the wrapper backs the toggle with `~/.config/autostart/claude-desktop.desktop` per the XDG Autostart spec. Thanks @lizthegrey. ([#450](https://github.com/aaddrick/claude-desktop-debian/pull/450), fixes [#128](https://github.com/aaddrick/claude-desktop-debian/issues/128))
|
||||
- Tray icon updates in place on OS theme change instead of briefly duplicating on KDE Plasma. Uses `setImage` + `setContextMenu` rather than destroy + recreate. Thanks @IliyaBrook. ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515))
|
||||
- Window visibility check works again after an upstream minified-name change broke it. Thanks @Andrej730. ([#496](https://github.com/aaddrick/claude-desktop-debian/pull/496), fixes [#495](https://github.com/aaddrick/claude-desktop-debian/issues/495))
|
||||
|
||||
### Changed
|
||||
|
||||
- APT/DNF install instructions point at `pkg.claude-desktop-debian.dev` directly, bypassing the GitHub Pages 301. Pages serves the redirect over `http://` because it can't provision a cert for the `pkg.` subdomain (DNS belongs to the Cloudflare Worker), and `apt` refuses HTTPS→HTTP downgrades. DNF was unaffected. ([#510](https://github.com/aaddrick/claude-desktop-debian/pull/510), [#514](https://github.com/aaddrick/claude-desktop-debian/pull/514))
|
||||
|
||||
## [v2.0.5] — 2026-04-23
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: smoke test accepts release-assets CDN hostname. ([#509](https://github.com/aaddrick/claude-desktop-debian/pull/509))
|
||||
- Strip CRLF from `cowork-plugin-shim.sh` during staging. ([#499](https://github.com/aaddrick/claude-desktop-debian/pull/499), [#505](https://github.com/aaddrick/claude-desktop-debian/pull/505))
|
||||
|
||||
## [v2.0.4] — 2026-04-23
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: smoke test accepts `http://` on Pages 301 hop. ([#506](https://github.com/aaddrick/claude-desktop-debian/pull/506))
|
||||
- Worker: use `raw.githubusercontent.com` as origin to avoid Pages 301 loop. ([#504](https://github.com/aaddrick/claude-desktop-debian/pull/504))
|
||||
|
||||
### Changed
|
||||
|
||||
- Worker: flip route from staging to production for Phase 4a. ([#503](https://github.com/aaddrick/claude-desktop-debian/pull/503))
|
||||
|
||||
## [v2.0.3] — 2026-04-23
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0. No GitHub Release published.
|
||||
|
||||
### Added
|
||||
|
||||
- APT/DNF Worker scaffolding. ([#498](https://github.com/aaddrick/claude-desktop-debian/pull/498))
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: resolve DNF Worker chain blockers. ([#500](https://github.com/aaddrick/claude-desktop-debian/issues/500), [#501](https://github.com/aaddrick/claude-desktop-debian/issues/501), [#502](https://github.com/aaddrick/claude-desktop-debian/pull/502))
|
||||
|
||||
### Changed
|
||||
|
||||
- Plan APT/DNF distribution via Cloudflare Worker. ([#493](https://github.com/aaddrick/claude-desktop-debian/pull/493), [#494](https://github.com/aaddrick/claude-desktop-debian/pull/494))
|
||||
|
||||
## [v2.0.2] — 2026-04-22
|
||||
|
||||
Wrapper/packaging update; upstream Claude Desktop unchanged at 1.3883.0.
|
||||
|
||||
### Added
|
||||
|
||||
- BATS unit tests for `launcher-common.sh`. ([#395](https://github.com/aaddrick/claude-desktop-debian/pull/395))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Copy `ion-dist` static assets for the `app://` protocol handler. ([#490](https://github.com/aaddrick/claude-desktop-debian/pull/490))
|
||||
|
||||
## [v2.0.1] — 2026-04-21
|
||||
|
||||
Wrapper/packaging update; tracks upstream Claude Desktop 1.3561.0, 1.3883.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Triage Phase 4 sub-PRs: Stage 8c enhancement-design variant, suspicious-input tells, `regression_of` + edit-during-triage. ([#470](https://github.com/aaddrick/claude-desktop-debian/pull/470), [#471](https://github.com/aaddrick/claude-desktop-debian/pull/471), [#472](https://github.com/aaddrick/claude-desktop-debian/pull/472))
|
||||
- Triage Phase 3: Stage 6 adversarial reviewer + duplicate gate. ([#465](https://github.com/aaddrick/claude-desktop-debian/pull/465))
|
||||
- Decision log with D-001 (auto-update direction). ([#477](https://github.com/aaddrick/claude-desktop-debian/pull/477))
|
||||
- `@sabiut` added to CODEOWNERS for testing & release quality. ([#468](https://github.com/aaddrick/claude-desktop-debian/pull/468))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Export `GDK_BACKEND=wayland` in native Wayland mode. Thanks @aJV99. ([#397](https://github.com/aaddrick/claude-desktop-debian/pull/397))
|
||||
- Scope Ctrl+Q to the focused window, not system-wide. ([#484](https://github.com/aaddrick/claude-desktop-debian/pull/484))
|
||||
- Cowork: forward `CLAUDE_CODE_OAUTH_TOKEN` to VM spawn env. ([#482](https://github.com/aaddrick/claude-desktop-debian/pull/482), [#485](https://github.com/aaddrick/claude-desktop-debian/pull/485))
|
||||
- Launcher: disable GPU compositing on XRDP sessions. ([#475](https://github.com/aaddrick/claude-desktop-debian/pull/475))
|
||||
- Triage: normalize `claimed_version` before drift compare. ([#483](https://github.com/aaddrick/claude-desktop-debian/pull/483))
|
||||
- Triage: drift-as-banner — demote drift from gate to modifier. ([#476](https://github.com/aaddrick/claude-desktop-debian/pull/476))
|
||||
- Triage: pull broken-expectation rule up into first-pass classify. ([#469](https://github.com/aaddrick/claude-desktop-debian/pull/469))
|
||||
- Triage: raise 8b comment word cap 150 → 300. ([#464](https://github.com/aaddrick/claude-desktop-debian/pull/464))
|
||||
|
||||
### Changed
|
||||
|
||||
- Triage v2 production cutover; README synced with shipped pipeline (drop plan + research). ([#478](https://github.com/aaddrick/claude-desktop-debian/pull/478), [#480](https://github.com/aaddrick/claude-desktop-debian/pull/480))
|
||||
- Rename `feature` classification to `enhancement` in triage. ([#466](https://github.com/aaddrick/claude-desktop-debian/pull/466))
|
||||
|
||||
## [v2.0.0] — 2026-04-20
|
||||
|
||||
First v2 wrapper release; tracks upstream Claude Desktop 1.3109.0, 1.3561.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Always-on lifecycle logging for `cowork-vm-service`. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
- `cowork-vm-daemon` learnings doc and Anthropic & Partners plugin install flow doc. ([#439](https://github.com/aaddrick/claude-desktop-debian/pull/439))
|
||||
- `.github/CODEOWNERS` for per-subsystem review ownership.
|
||||
- `shellcheck -x` to follow sourced modules in CI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore `cowork-vm-service` daemon recovery after crash. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
- Forward `userSelectedFolders[0]` as `sharedCwdPath` on cowork spawn. ([#412](https://github.com/aaddrick/claude-desktop-debian/pull/412), [#436](https://github.com/aaddrick/claude-desktop-debian/pull/436))
|
||||
- Strip mode on `node-pty` cp at source; retire `chmod`. Chmod `node-pty` unpacked files before overwriting in Nix builds. ([#432](https://github.com/aaddrick/claude-desktop-debian/pull/432), [#438](https://github.com/aaddrick/claude-desktop-debian/pull/438))
|
||||
- Diagnose AppArmor userns block on bwrap probe. ([#351](https://github.com/aaddrick/claude-desktop-debian/issues/351), [#434](https://github.com/aaddrick/claude-desktop-debian/pull/434))
|
||||
- Suppress Cowork tab auto-select on every launch. ([#341](https://github.com/aaddrick/claude-desktop-debian/issues/341), [#433](https://github.com/aaddrick/claude-desktop-debian/pull/433))
|
||||
- `home --dir` before SDK `--ro-bind` in bwrap sandbox. ([#426](https://github.com/aaddrick/claude-desktop-debian/pull/426))
|
||||
- Only route `claude` commands through SDK binary in `cowork-vm-service`. ([#430](https://github.com/aaddrick/claude-desktop-debian/pull/430))
|
||||
- `launcher-common.sh` self-match and stale socket cleanup. ([#407](https://github.com/aaddrick/claude-desktop-debian/pull/407), [#425](https://github.com/aaddrick/claude-desktop-debian/pull/425))
|
||||
- Translate guest paths inside `--allowedTools` and `--disallowedTools`. ([#411](https://github.com/aaddrick/claude-desktop-debian/pull/411))
|
||||
- Resolve working directory from primary mount on HostBackend. ([#392](https://github.com/aaddrick/claude-desktop-debian/pull/392))
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING**: Split `build.sh` into topical modules under `scripts/`; relocate packaging scripts into `scripts/packaging/`; extract `--doctor` into `scripts/doctor.sh`. Patch files now live in `scripts/patches/*.sh` (one per subsystem); `build.sh` is just an orchestrator. CI paths updated to `scripts/setup/detect-host.sh`.
|
||||
- Simplify cowork daemon recovery patch. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
|
||||
[Unreleased]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.13+claude1.8555.2...HEAD
|
||||
[v2.0.13]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.12+claude1.8555.2...v2.0.13+claude1.8555.2
|
||||
[v2.0.12]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.11+claude1.7196.1...v2.0.12+claude1.7196.3
|
||||
[v2.0.11]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.10+claude1.7196.0...v2.0.11+claude1.7196.1
|
||||
[v2.0.10]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.8+claude1.5354.0...v2.0.10+claude1.6259.0
|
||||
[v2.0.8]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.7+claude1.5354.0...v2.0.8+claude1.5354.0
|
||||
[v2.0.7]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.6+claude1.5354.0...v2.0.7+claude1.5354.0
|
||||
[v2.0.6]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.5+claude1.5354.0...v2.0.6+claude1.5354.0
|
||||
[v2.0.5]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.4+claude1.3883.0...v2.0.5+claude1.3883.0
|
||||
[v2.0.4]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.3+claude1.3883.0...v2.0.4+claude1.3883.0
|
||||
[v2.0.3]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.2+claude1.3883.0...v2.0.3+claude1.3883.0
|
||||
[v2.0.2]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.1+claude1.3883.0...v2.0.2+claude1.3883.0
|
||||
[v2.0.1]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.0+claude1.3561.0...v2.0.1+claude1.3883.0
|
||||
[v2.0.0]: https://github.com/aaddrick/claude-desktop-debian/releases/tag/v2.0.0+claude1.3109.0
|
||||
47
CLAUDE.md
47
CLAUDE.md
@@ -1,5 +1,26 @@
|
||||
# Claude Desktop Debian - Development Notes
|
||||
|
||||
<!--
|
||||
This file is read by Claude Code. The content below is duplicated in
|
||||
AGENTS.md (read by other AI tools per the agents.md standard) so that
|
||||
contributors using either receive the same instructions without needing
|
||||
to cross-reference. Keep CLAUDE.md and AGENTS.md byte-identical below
|
||||
the H1 title (the sync-policy comment above is the one place they
|
||||
intentionally differ) — if you edit one, edit the other.
|
||||
-->
|
||||
|
||||
## Required reading
|
||||
|
||||
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
|
||||
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
|
||||
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
|
||||
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
|
||||
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
|
||||
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
|
||||
|
||||
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
|
||||
@@ -17,10 +38,12 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`wayland-global-shortcuts-portal.md`](docs/learnings/wayland-global-shortcuts-portal.md) — why Quick Entry's hotkey is focus-bound on GNOME Wayland (mutter dropped XWayland global key grabs), the native-Wayland + `GlobalShortcutsPortal` launcher change (opt-in via `CLAUDE_USE_WAYLAND=1`; fixes GNOME ≤49, default GNOME stays on XWayland), the "only the last `--enable-features` switch wins → merge into one flag" trap, the tri-state `CLAUDE_USE_WAYLAND` escape hatch, and the proof that GNOME 50 / xdg-desktop-portal ≥1.20 is still blocked upstream because Electron/Chromium never calls the host `Registry.Register` app-id handshake ([electron#51875](https://github.com/electron/electron/issues/51875)); wlroots (Niri/Sway/Hyprland) lack a portal GlobalShortcuts backend entirely
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.md). Key points:
|
||||
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
|
||||
|
||||
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
|
||||
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
|
||||
@@ -28,6 +51,16 @@ All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
@@ -39,6 +72,16 @@ Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `a
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
@@ -125,7 +168,7 @@ Contributors are listed in chronological order: inspirational projects first (k3
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
|
||||
161
CONTRIBUTING.md
Normal file
161
CONTRIBUTING.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Contributing
|
||||
|
||||
## Before you start
|
||||
|
||||
A few minutes here saves a round-trip later. Match your task to the right channel:
|
||||
|
||||
- **Found a bug?** File an [issue](https://github.com/aaddrick/claude-desktop-debian/issues/new/choose)
|
||||
with the bug template. Paste full `claude-desktop --doctor` output;
|
||||
include distro, DE, and session type (Wayland/X11). See
|
||||
[Filing an issue](#filing-an-issue).
|
||||
- **Have a fix in hand?** PRs that fix existing behaviour, restore parity
|
||||
with Windows/macOS, or improve packaging are always welcome. Open the
|
||||
PR; an issue isn't strictly required if the fix is small.
|
||||
- **Want to add a new feature?** Open a [discussion](https://github.com/aaddrick/claude-desktop-debian/discussions)
|
||||
or an issue first. We're a repackager; most net-new behaviour is
|
||||
declined by default — see [What we accept](#what-we-accept).
|
||||
- **Security concern?** Don't file a public issue. Use
|
||||
[SECURITY.md](SECURITY.md) — GitHub Security Advisories route to
|
||||
@aaddrick privately.
|
||||
|
||||
## Where to find what
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md): conventions, build, patches, attribution.
|
||||
- [AGENTS.md](AGENTS.md): vendor-neutral mirror of CLAUDE.md for non-Claude AI tools.
|
||||
- [docs/index.md](docs/index.md): full docs entry point.
|
||||
- [docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md):
|
||||
bash style ([style.ysap.sh](https://style.ysap.sh)). Tabs, 80 cols, `[[ ]]`, no `set -e`.
|
||||
- [docs/styleguides/docs_styleguide.md](docs/styleguides/docs_styleguide.md):
|
||||
page anatomy and naming if you're adding a doc.
|
||||
- [docs/learnings/](docs/learnings/): subsystem deep-dives. Read the
|
||||
relevant entry first.
|
||||
- [docs/building.md](docs/building.md): local build setup.
|
||||
- [docs/decisions.md](docs/decisions.md): architectural choices (ADR format).
|
||||
- [CHANGELOG.md](CHANGELOG.md): release-grouped history from v2.0.0 onward.
|
||||
- [RELEASING.md](RELEASING.md): how a release ships (tag-driven CI).
|
||||
- [SECURITY.md](SECURITY.md): private vulnerability reporting.
|
||||
- [.github/CODEOWNERS](.github/CODEOWNERS): auto-review routing.
|
||||
|
||||
## What we accept
|
||||
|
||||
We're a repackager, not a fork. Net-new feature PRs default to no: we'd
|
||||
own that behaviour across every re-minified upstream release.
|
||||
Exception: parity patches for Windows features broken on Linux
|
||||
(input methods, tray on Wayland/X11, frame defaults). Always welcome:
|
||||
|
||||
- Bug fixes against existing behaviour.
|
||||
- Parity patches bringing Linux closer to the Windows build.
|
||||
- Packaging, distribution, launcher fixes.
|
||||
- Docs, tests, CI improvements.
|
||||
|
||||
## What goes upstream, not here
|
||||
|
||||
We patch the binary blob; we don't fix application logic inside it.
|
||||
If the bug reproduces on Windows, file at
|
||||
[anthropics/claude-code](https://github.com/anthropics/claude-code).
|
||||
In-app `/bug` and `/feedback` are inert.
|
||||
|
||||
| File here | File upstream |
|
||||
|----------------------------------------|-------------------------------------|
|
||||
| `apt update` errors, install failures | Plugin install fails on all OSes |
|
||||
| Tray icon missing on KDE Wayland | Conversation rendering glitch |
|
||||
| AppImage won't launch on distro X | MCP server connection drops |
|
||||
| `--doctor` reports wrong diagnosis | Account / login flow broken |
|
||||
|
||||
## Filing an issue
|
||||
|
||||
1. Use the issue template, not freeform.
|
||||
2. Paste full `./build.sh --doctor` (or `claude-desktop --doctor`)
|
||||
output. Most-skipped step.
|
||||
3. Include distro, DE, session type (Wayland/X11). Most Linux-only
|
||||
bugs trace to one of these.
|
||||
4. Reproduce on a clean config: move `~/.config/Claude` aside, relaunch.
|
||||
Stale config causes false positives.
|
||||
|
||||
## Patches against upstream
|
||||
|
||||
Patches live in `scripts/patches/*.sh`, one per subsystem; `build.sh`
|
||||
sources them. Before writing or editing one, read [the
|
||||
patching-minified-js learnings doc][pmj]: anchor selection, capture,
|
||||
idempotency, beautified-vs-minified gap. Short form: CLAUDE.md §
|
||||
Working with Minified JavaScript.
|
||||
|
||||
Priority rule: a broken-patch upstream release beats feature work.
|
||||
|
||||
## Subsystem owners
|
||||
|
||||
CODEOWNERS auto-requests reviews; this list is for human discoverability.
|
||||
|
||||
- **@aaddrick**: default. Build, non-Cowork patches, desktop, packaging, docs.
|
||||
- **@sabiut**: `tests/`, `scripts/doctor.sh`, test workflows.
|
||||
- **@RayCharlizard**: Cowork (`scripts/patches/cowork.sh`,
|
||||
`scripts/cowork-vm-service.js`, `tests/cowork-*.bats`).
|
||||
- **@typedrat**: Nix (`flake.nix`, `flake.lock`, `/nix/`).
|
||||
|
||||
## Before submitting a PR
|
||||
|
||||
- Run `/lint` (or `shellcheck` + `actionlint`). See CLAUDE.md § Linting.
|
||||
- Local build: `./build.sh --build appimage --clean no`. Catches
|
||||
patch failures unit tests miss.
|
||||
- Branch: `fix/123-description` or `feature/123-description`.
|
||||
- PR body links the issue: `Fixes #123` or `Refs #123`.
|
||||
- AI-assisted? Add the attribution block (next section).
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
AI-assisted PRs accepted with disclosure. PR descriptions:
|
||||
|
||||
```
|
||||
---
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
|
||||
XX% AI / YY% Human
|
||||
Claude: <what AI did>
|
||||
Human: <what human did>
|
||||
```
|
||||
|
||||
Real model name (e.g., "Claude Opus 4.7"). Honest split.
|
||||
Breakdown lines make the ratio auditable against the diff.
|
||||
|
||||
Commits: `Co-Authored-By: Claude <claude@anthropic.com>`.
|
||||
|
||||
Issues/comments:
|
||||
`Written by Claude <model-name> via [Claude Code](https://claude.ai/code)`.
|
||||
|
||||
## Conventions in this file
|
||||
|
||||
### Patch-script regexes
|
||||
|
||||
Two rules apply to regexes that target the minified upstream bundle.
|
||||
|
||||
**Identifier captures use `[$\w]+`, not `\w+`.** Upstream's minifier
|
||||
emits `$` inside JS identifiers (`C$i`, `g$i`, `i$A`). `\w` is
|
||||
`[A-Za-z0-9_]` and does not match `$`, so a `\w+` capture against
|
||||
`$e` returns the suffix `e` instead of the whole identifier. PR #555
|
||||
and PR #627 closed two cohorts of patches with this exact bug. The
|
||||
learnings doc has the full background and the canonical character
|
||||
class is `[$\w]+` (the equivalent `[\w$]+` is fine; either form
|
||||
matches the same set, the order is convention only).
|
||||
|
||||
**Intent comments accompany whitespace-tolerant patterns.** When a
|
||||
patch regex uses `\s*` or `[ \t]*` between tokens, add a one-line
|
||||
intent comment with whitespace stripped so the matched shape stays
|
||||
readable:
|
||||
|
||||
```js
|
||||
// Intent: VAR.code==="ENOENT"
|
||||
const enoentRe = /([$\w]+)\.code\s*===\s*"ENOENT"/g;
|
||||
```
|
||||
|
||||
Apply both rules to new patches and to existing regexes when you're
|
||||
editing them for other reasons. No churn PRs. Background:
|
||||
[the patching-minified-js learnings doc][pmj].
|
||||
|
||||
[pmj]: docs/learnings/patching-minified-js.md
|
||||
|
||||
### Markdown prose wrapping
|
||||
|
||||
Wrap prose at ~80 chars, matching the bash column rule in
|
||||
[docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md).
|
||||
Tables, code blocks, URLs, alt text may exceed when breaking hurts
|
||||
readability.
|
||||
43
README.md
43
README.md
@@ -4,6 +4,8 @@ This project provides build scripts to run Claude Desktop natively on Linux syst
|
||||
|
||||
**Note:** This is an unofficial build script. For official support, please visit [Anthropic's website](https://www.anthropic.com). For issues with the build script or Linux implementation, please [open an issue](https://github.com/aaddrick/claude-desktop-debian/issues) in this repository.
|
||||
|
||||
**Documentation:** Full docs at [`docs/index.md`](docs/index.md). Release history in [`CHANGELOG.md`](CHANGELOG.md). Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports: [`SECURITY.md`](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ APT migration notice (April 2026)**
|
||||
@@ -135,7 +137,7 @@ Download the latest `.deb`, `.rpm`, or `.AppImage` from the [Releases page](http
|
||||
|
||||
### Building from Source
|
||||
|
||||
See [docs/BUILDING.md](docs/BUILDING.md) for detailed build instructions.
|
||||
See [docs/building.md](docs/building.md) for detailed build instructions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -144,13 +146,13 @@ Model Context Protocol settings are stored in:
|
||||
~/.config/Claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
For additional configuration options including environment variables and Wayland support, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
For additional configuration options including environment variables and Wayland support, see [docs/configuration.md](docs/configuration.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `claude-desktop --doctor` for built-in diagnostics that check common issues (display server, sandbox permissions, MCP config, stale locks, and more). It also reports cowork mode readiness — which isolation backend will be used, and which dependencies (KVM, QEMU, vsock, socat, virtiofsd, bubblewrap) are installed or missing.
|
||||
|
||||
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
|
||||
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/troubleshooting.md](docs/troubleshooting.md).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -185,6 +187,7 @@ Special thanks to:
|
||||
- Version update contributions
|
||||
- Close-to-tray on Linux to keep in-app schedulers, MCP servers, and the tray icon alive across window close
|
||||
- "Run on startup" persistence on Linux via XDG Autostart, fixing the toggle that would silently revert
|
||||
- In-place package upgrade detection that watches `app.asar` for dpkg/rpm replacement and offers a click-to-restart notification, fixing the Quick Entry / About / Ctrl+Q symptom cluster from a running v(N) main process loading v(N+1) renderer assets (#564)
|
||||
- **[mathys-lopinto](https://github.com/mathys-lopinto)**
|
||||
- AUR package
|
||||
- Automated deployment
|
||||
@@ -198,6 +201,9 @@ Special thanks to:
|
||||
- `--doctor` diagnostic command
|
||||
- SHA-256 checksum validation for downloads
|
||||
- Post-build integration tests for deb, rpm, and AppImage artifacts
|
||||
- `tests.yml` CI workflow that runs the 186-test BATS suite on push and PR — the suite was inert in CI before this (#520)
|
||||
- Isolating `cleanup_stale_cowork_socket` BATS from host `pgrep` state so the test passes on developer machines running Claude Desktop (#533, #534)
|
||||
- Headless launch and `--doctor` smoke tests for the AppImage artifact, catching runtime regressions (frame-fix-wrapper syntax errors, asar patch breakage, `main` field mismatches) that the structural test missed (#592)
|
||||
- **[milog1994](https://github.com/milog1994)**
|
||||
- Popup detection
|
||||
- Functional stubs
|
||||
@@ -227,6 +233,7 @@ Special thanks to:
|
||||
- node-pty derivation
|
||||
- CI auto-update
|
||||
- Fixing the flake package scoping regression
|
||||
- Fixing the NixOS electron binary not being marked executable (#431, #581)
|
||||
- **[cbonnissent](https://github.com/cbonnissent)**
|
||||
- Reverse-engineering the Cowork VM guest RPC protocol
|
||||
- Fixing the KVM startup blocker
|
||||
@@ -242,6 +249,8 @@ Special thanks to:
|
||||
- Detailed analysis of the self-referential `.mcpb-cache` symlink ELOOP bug
|
||||
- Fixing auto-memory path translation on HostBackend
|
||||
- Fixing the `ion-dist` static asset copy for the `app://` protocol handler
|
||||
- `--doctor` diagnostic that detects the Ubuntu 24.04 AppArmor `apparmor_restrict_unprivileged_userns=1` block on bwrap, instead of letting it silently fall through to a hanging KVM probe (#351, #434)
|
||||
- Documenting the upstream MCP double-spawn root-cause analysis in `docs/learnings/mcp-double-spawn.md` (#526, #527)
|
||||
- **[reinthal](https://github.com/reinthal)** for fixing the NixOS build breakage caused by the nixpkgs `nodePackages` removal
|
||||
- **[gianluca-peri](https://github.com/gianluca-peri)**
|
||||
- Reporting the GNOME quit accessibility issue
|
||||
@@ -259,6 +268,34 @@ Special thanks to:
|
||||
- **[zabka](https://github.com/zabka)** for identifying that `cowork-vm-service.js` was never auto-spawned on Linux and contributing a systemd-unit workaround that scoped the daemon auto-launch fix (#445)
|
||||
- **[sirfaber](https://github.com/sirfaber)** for fixing the `$`-in-minified-identifier breakage of cowork Patch 2b (vm module assignment) and Patch 6 step 2 (retry-delay auto-launch) on Claude Desktop 1.5354.0 (#555)
|
||||
- **[ProfFlow](https://github.com/ProfFlow)** for re-fixing the RPM repodata signing regression by appending `!` to the keyid passed to `gpg --default-key`, forcing `repomd.xml` to be signed by the primary key instead of the auto-selected signing subkey (#566)
|
||||
- **[jslatten](https://github.com/jslatten)** for fixing the KDE Plasma Wayland launcher-grouping bug by setting `pkg.desktopName` in the packaged `app.asar`'s `package.json`, format-conditional so deb/rpm get `claude-desktop.desktop` and AppImage gets `io.github.aaddrick.claude-desktop-debian.desktop` (#562)
|
||||
- **[JoshuaVlantis](https://github.com/JoshuaVlantis)**
|
||||
- RPM `chrome-sandbox` SUID via `%attr(4755, ...)` instead of a `%post` chmod scriptlet so the bit survives `--noscripts` and layered images (#539)
|
||||
- `autoUpdater` no-op Proxy on Linux that defends against future feed activation, with a thenable allowlist masking `then`/`catch`/`finally`/`Symbol.toPrimitive`/`Symbol.iterator` to `undefined` (#567)
|
||||
- Failing loudly on `npm install node-pty` failures instead of silently shipping the upstream Windows binaries, plus auto-installing `gcc`/`g++`/`make`/`python3` on minimal build environments (#401)
|
||||
- Silencing the RPM "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install`, with thorough investigation of four `%exclude`-based alternatives (#610)
|
||||
- Cleaning upstream Windows binaries from node-pty before staging the Linux build, preventing PE32+ orphans in the packaged asar (#597)
|
||||
- **[Hayao0819](https://github.com/Hayao0819)** for diagnosing the upstream `titleBarStyle:""` → `titleBarStyle:"hiddenInset"` migration that broke the About window render on GNOME/X11 and contributing the `isPopupWindow()` match extension (#481, #489)
|
||||
- **[michelsfun](https://github.com/michelsfun)** for reporting the cowork `ENAMETOOLONG` failure on eCryptfs-encrypted home directories with detailed `--doctor` output that pinpointed the short-NAME_MAX filesystem as the cause (#590)
|
||||
- **[proffalken](https://github.com/proffalken)** for the LUKS-volume + `pam_mount` workaround documented in `docs/troubleshooting.md`, restoring cowork support on legacy eCryptfs-encrypted home directories (#590)
|
||||
- **[phelps-matthew](https://github.com/phelps-matthew)** for fixing `CLAUDE_QUIT_ON_CLOSE=1` to actively quit via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux, with thorough root cause analysis and alternatives evaluation (#624, #623)
|
||||
- **[dubreal](https://github.com/dubreal)** for `--password-store` keyring detection that probes D-Bus for kwallet6 / gnome-libsecret at startup, fixing session persistence on KDE Plasma and other desktops where Electron's `safeStorage` was unavailable (#611, #593)
|
||||
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for detecting missing electron binaries after Node 24's `extract-zip` silently no-ops, with an `unzip` fallback that recovers from the `@electron/get` cache (#631, #584)
|
||||
- **[tkrag](https://github.com/tkrag)** for diagnosing and fixing the X11 window-raise-on-hover bug under sloppy/focus-follows-mouse WMs, tracing the upstream `webContents.focus()` → `_NET_ACTIVE_WINDOW` path through three iterations of review (#589, #416)
|
||||
- **[maplefater](https://github.com/maplefater)** for re-anchoring the `addTrustedFolder` `.asar` guard on the `async addTrustedFolder(…)` method declaration after upstream 1.10628.x folded the log call into a comma-expression, keying both the parameter extraction and the injection point off the unminified method name so they can't drift apart (#685)
|
||||
- **[MitchSchwartz](https://github.com/MitchSchwartz)** for finding the second `app.asar` file-drop path — the `existsSync()` branch in the second-instance argv collector that #640 never guarded — and rejecting `.asar` paths there so the app no longer prompts to attach its own bundle on every taskbar reopen (#669, #668)
|
||||
- **[LiukScot](https://github.com/LiukScot)** for making the tray rebuild mutex trailing-edge so the startup dark-theme icon no longer latches black, and restoring the in-place `setImage` fast-path after upstream changed the context-menu wiring to a prebuilt menu object (#680, #679)
|
||||
- **[sabiut](https://github.com/sabiut)**
|
||||
- BATS coverage for `cleanup_orphaned_cowork_daemon`, mutation-tested so the kill/escalation branches genuinely bite (#693)
|
||||
- Fixing two false-green `--doctor` PASSes: an empty password store read as healthy, and a non-numeric `df` reading falling through to the PASS branch (#692)
|
||||
- Extending the artifact launch smoke tests to arm64 on native `ubuntu-22.04-arm` runners, and re-keying the AppImage pkill sweep to `mount_claude` so escaped zygote/electron children stop leaking on the runner (#691)
|
||||
- **[jerem](https://github.com/jerem)** for routing Quick Entry's global shortcut through the XDG GlobalShortcuts portal on native Wayland, and merging all Chromium feature requests into a single `--enable-features=` switch — the old code silently clobbered `WindowControlsOverlay` (#690, #404)
|
||||
- **[caidejager](https://github.com/caidejager)** for diagnosing why Cowork's VM daemon never auto-launched on packages built under a restrictive umask — `app.asar.unpacked/` shipped mode `0700`, failing the auto-launch `existsSync()` guard — and normalizing install permissions across deb and AppImage, with `dpkg-deb --root-owner-group` closing a build-uid write exposure (#695)
|
||||
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for the AppStream metainfo that surfaces the package in GNOME Software, KDE Discover, and App Center, wired into the deb, rpm, and AppImage builds (#633)
|
||||
- **[DhanushSantosh](https://github.com/DhanushSantosh)** for the GPU crash auto-recovery in the launcher: detecting a previous GPU-process FATAL in the launcher log and re-launching with safe GPU flags automatically, instead of leaving users to discover `CLAUDE_DISABLE_GPU=1` by hand (#666)
|
||||
- **[diarized](https://github.com/diarized)** for auto-installing scoped AppArmor userns profiles from the `.deb` postinst on Ubuntu 24.04+ — one for the bundled Electron binary (fixing the launch crash without `--no-sandbox`) and one for `/usr/bin/bwrap` (keeping Cowork's sandbox isolated instead of silently falling back to host-direct), automating the workaround from #351 (#687, #694)
|
||||
- **[emandel82](https://github.com/emandel82)** for root-causing the "Attach app.asar?" prompt: every launcher passed `app.asar` as a redundant Electron argument, which the second-instance argv collector treated as a file to open — removed at the source across all four package formats (#700, #696)
|
||||
- **[svankirk](https://github.com/svankirk)** for cleaning up Desktop helper processes after an explicit quit — a quit wrapper with signal forwarding and a bundle-keyed live-UI check, so closing the app no longer strands helper processes (#682)
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
80
RELEASING.md
Normal file
80
RELEASING.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Releasing
|
||||
|
||||
This project ships through tag-driven CI. A tag of the form `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}` on `main` triggers the release job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml), which builds for both architectures, attaches the artifacts to a GitHub Release, and updates the APT, DNF, and AUR repositories.
|
||||
|
||||
There are two flavors of release:
|
||||
|
||||
- **Upstream-tracking retag.** A `check-claude-version` workflow runs daily, detects new Claude Desktop releases, bumps the `CLAUDE_DESKTOP_VERSION` repo variable, patches URLs and SRI hashes in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix`, and pushes a new tag with the same `REPO_VERSION` and a new `+claude{X.Y.Z}` suffix. **No human action required.** These do not get CHANGELOG entries — they're tracked in the tag suffix.
|
||||
- **Project release.** You bumped `REPO_VERSION` because you shipped project changes. Follow the checklist below.
|
||||
|
||||
## Pre-release checklist
|
||||
|
||||
1. **CI is green on `main`.** All required workflows (CI, tests, shellcheck, codespell) passed on the commit you're about to tag.
|
||||
|
||||
```bash
|
||||
gh run list --branch main --limit 5
|
||||
```
|
||||
|
||||
2. **`CHANGELOG.md` is updated.** The `[Unreleased]` section now reflects what you're about to ship. Move it under a new `[v{REPO_VERSION}]` heading with today's date.
|
||||
|
||||
3. **Local tests pass.**
|
||||
|
||||
```bash
|
||||
bats tests/
|
||||
shellcheck scripts/**/*.sh build.sh
|
||||
```
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md#linting) for the canonical lint command.
|
||||
|
||||
4. **AppImage artifact boots on a clean system.** The `test-artifacts.yml` reusable workflow already runs a `--doctor` smoke test against each format in CI (#592), but if you've touched the launcher or patch surface, build locally and confirm:
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no
|
||||
./test-build/claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
5. **The version variables are in sync.**
|
||||
|
||||
```bash
|
||||
gh variable get REPO_VERSION
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
|
||||
```
|
||||
|
||||
The grep value should match the `CLAUDE_DESKTOP_VERSION` variable. If not, pull the latest URLs from `main` — the `check-claude-version` workflow may have updated them on `main` without rebasing your branch ([`CLAUDE.md`](CLAUDE.md#common-gotchas) has the recipe).
|
||||
|
||||
## Bumping and tagging
|
||||
|
||||
```bash
|
||||
# 1. Bump the project version (this is a GitHub Actions variable, not a file).
|
||||
gh variable set REPO_VERSION --body "2.0.13"
|
||||
|
||||
# 2. Tag with both versions in the tag name.
|
||||
git tag "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
|
||||
# 3. Push the tag — this is what kicks off the release build.
|
||||
git push origin "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
```
|
||||
|
||||
The `REPO_VERSION` variable bump can happen before or after the tag push; CI reads neither directly. The variable exists so future workflow runs know the current project version.
|
||||
|
||||
## What CI does on tag push
|
||||
|
||||
The [`release`](.github/workflows/ci.yml) job in `ci.yml` is gated on `startsWith(github.ref, 'refs/tags/v')`. After `test-flags`, `build-amd64`, `build-arm64`, and `test-artifacts` pass:
|
||||
|
||||
1. Downloads all nine assets (six packages -- amd64 + arm64, each in deb/rpm/AppImage -- plus two `.zsync` delta files and a `reference-source.tar.gz`).
|
||||
2. Pulls release notes from the separate [`aaddrick/claude-desktop-versions`](https://github.com/aaddrick/claude-desktop-versions) repo if available; falls back to the autogenerated changelog otherwise.
|
||||
3. Creates the GitHub Release and attaches the nine assets.
|
||||
4. Hands off to `update-apt-repo`, `update-dnf-repo`, and `update-aur-repo`, which publish to the Cloudflare-fronted package repos ([`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) for the redirect chain).
|
||||
|
||||
## After the release lands
|
||||
|
||||
- **Verify the Release page.** Nine assets attached, sizes look right, release notes rendered.
|
||||
- **Smoke-test one artifact.** Download the AppImage and run `--doctor` against it.
|
||||
- **Watch `apt-repo-heartbeat`.** The next daily run validates the redirect chain end-to-end. If it opens a tracking issue, walk the chain in [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md#heartbeat-failure-runbook).
|
||||
|
||||
## If something goes wrong mid-release
|
||||
|
||||
- **Build fails.** Push the fix to `main`, then re-tag with a new `+claude` suffix (or a `+rebuild.N` suffix if upstream hasn't moved). The original tag stays — releases are append-only.
|
||||
- **A bad release shipped.** Mark the GitHub Release as a pre-release / draft and ship a follow-up. Don't delete artifacts that may already be cached by the APT/DNF Worker.
|
||||
- **The `check-claude-version` workflow conflicts with your local branch.** Pull URL changes from `main` before pushing your tag — the workflow autobumps `scripts/setup/detect-host.sh` between your work and your tag.
|
||||
35
SECURITY.md
Normal file
35
SECURITY.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Security Policy
|
||||
|
||||
Report suspected vulnerabilities privately via [GitHub Security Advisories](https://github.com/aaddrick/claude-desktop-debian/security/advisories/new). Do not open a public issue or post details in Discussions.
|
||||
|
||||
## Scope
|
||||
|
||||
This project repackages an upstream Electron app. The boundary matters:
|
||||
|
||||
**In scope** — things this repo ships:
|
||||
|
||||
- Patches in `scripts/patches/*.sh`
|
||||
- Packaging scripts in `scripts/packaging/`
|
||||
- The launcher (`scripts/launcher-common.sh`) and the `claude-desktop --doctor` surface
|
||||
- CI workflows under `.github/workflows/`
|
||||
- The APT/DNF Cloudflare Worker under `worker/`
|
||||
- The frame-fix wrapper and any other JS we inject into `app.asar`
|
||||
|
||||
**Out of scope** — file upstream:
|
||||
|
||||
- Vulnerabilities in the Claude Desktop application itself, the Anthropic API, or the claude.ai web app. Those go to Anthropic's support / disclosure channels — not here. This project can't fix them and shouldn't be the public record.
|
||||
|
||||
## What to include in a report
|
||||
|
||||
- Reproducer: commands, environment, distro / desktop / session type
|
||||
- Output of `claude-desktop --doctor` if relevant
|
||||
- Affected version(s) — `git describe --tags` or the release tag you installed from
|
||||
- Any related upstream CVEs or advisories you found while investigating
|
||||
|
||||
## Response
|
||||
|
||||
GitHub Advisories notify @aaddrick. Acknowledgement is usually within a few days. Fix turnaround depends on the surface — packaging-layer bugs are usually fast; patches against minified upstream JS may need to wait for a tractable anchor in a future upstream release.
|
||||
|
||||
## Disclosure history
|
||||
|
||||
Past privacy-sensitive fixes (e.g., issue-triage bot scoping, log redaction in `--doctor` output) landed through the normal PR flow with public history; there have been no embargoed disclosures to date. If that changes, this section gets entries with the advisory ID, the affected versions, and the fix.
|
||||
8
build.sh
8
build.sh
@@ -36,6 +36,8 @@ final_output_path=''
|
||||
|
||||
# Package metadata (constants)
|
||||
readonly PACKAGE_NAME='claude-desktop'
|
||||
readonly WM_CLASS='Claude'
|
||||
export WM_CLASS
|
||||
readonly MAINTAINER='Claude Desktop Linux Maintainers'
|
||||
readonly DESCRIPTION='Claude Desktop for Linux'
|
||||
|
||||
@@ -60,8 +62,12 @@ source "$script_dir/scripts/patches/quick-window.sh"
|
||||
source "$script_dir/scripts/patches/claude-code.sh"
|
||||
# shellcheck source=scripts/patches/cowork.sh
|
||||
source "$script_dir/scripts/patches/cowork.sh"
|
||||
# shellcheck source=scripts/patches/org-plugins.sh
|
||||
source "$script_dir/scripts/patches/org-plugins.sh"
|
||||
# shellcheck source=scripts/patches/wco-shim.sh
|
||||
source "$script_dir/scripts/patches/wco-shim.sh"
|
||||
# shellcheck source=scripts/patches/config.sh
|
||||
source "$script_dir/scripts/patches/config.sh"
|
||||
# shellcheck source=scripts/staging/electron.sh
|
||||
source "$script_dir/scripts/staging/electron.sh"
|
||||
# shellcheck source=scripts/staging/icons.sh
|
||||
@@ -155,7 +161,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;Network;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop (AppImage)
|
||||
EOF
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs 10 checks and prints pass/fail results with suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
| Desktop entry | `.desktop` file presence |
|
||||
| Disk space | Free space on config partition |
|
||||
| Log file | Log file size |
|
||||
|
||||
Example output:
|
||||
```
|
||||
Claude Desktop Diagnostics
|
||||
================================
|
||||
|
||||
[PASS] Installed version: 1.1.4498-1.3.15
|
||||
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
|
||||
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
|
||||
[PASS] Chrome sandbox: permissions OK
|
||||
[PASS] SingletonLock: no lock file (OK)
|
||||
[PASS] MCP config: valid JSON
|
||||
[PASS] Node.js: v22.14.0
|
||||
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
|
||||
[PASS] Disk space: 632284MB free
|
||||
[PASS] Log file: 1352KB
|
||||
|
||||
All checks passed.
|
||||
```
|
||||
|
||||
When opening an issue, include the output of `--doctor` to help with diagnosis.
|
||||
|
||||
## Application Logs
|
||||
|
||||
Runtime logs are available at:
|
||||
```
|
||||
~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Window Scaling Issues
|
||||
|
||||
If the window doesn't scale correctly on first launch:
|
||||
1. Right-click the Claude Desktop tray icon
|
||||
2. Select "Quit" (do not force quit)
|
||||
3. Restart the application
|
||||
|
||||
This allows the application to save display settings properly.
|
||||
|
||||
### Global Hotkey Not Working (Wayland)
|
||||
|
||||
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
|
||||
|
||||
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
|
||||
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
|
||||
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
|
||||
|
||||
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
|
||||
|
||||
See [CONFIGURATION.md](CONFIGURATION.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
|
||||
For enhanced security, consider:
|
||||
- Using the .deb package instead
|
||||
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
|
||||
- Using Gear Lever's integrated AppImage management for better isolation
|
||||
|
||||
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
|
||||
|
||||
Ubuntu 24.04 ships with `apparmor_restrict_unprivileged_userns=1`
|
||||
by default, which blocks the unprivileged user namespaces that
|
||||
Cowork's bubblewrap sandbox relies on. Symptoms:
|
||||
|
||||
- `claude-desktop --doctor` reports `bubblewrap: sandbox probe failed`
|
||||
with `Operation not permitted` in stderr.
|
||||
- `~/.config/Claude/logs/cowork_vm_daemon.log` contains
|
||||
`bwrap is installed but cannot create a user namespace`.
|
||||
- Cowork sessions hang at "Starting VM..." or loop on reconnect.
|
||||
|
||||
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
|
||||
setup, requires sudo):
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/bwrap>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
||||
```
|
||||
|
||||
After applying the profile, run `claude-desktop --doctor` — the
|
||||
bubblewrap probe should pass, and Cowork should start without
|
||||
falling back to host-direct.
|
||||
|
||||
**Security note:** this grants `/usr/bin/bwrap` the unconfined
|
||||
profile plus the `userns` capability. It matches the behavior
|
||||
bwrap had on Ubuntu 22.04 and earlier, and on most other distros,
|
||||
but is a system-wide change that affects every program invoking
|
||||
`/usr/bin/bwrap` (not just Claude Desktop). Review the profile
|
||||
against your threat model before applying.
|
||||
|
||||
Credit: this workaround was contributed by
|
||||
[@hfyeh](https://github.com/hfyeh) in
|
||||
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
|
||||
|
||||
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
|
||||
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
|
||||
|
||||
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
|
||||
|
||||
```bash
|
||||
COWORK_VM_BACKEND=bwrap claude-desktop
|
||||
```
|
||||
|
||||
See [CONFIGURATION.md](CONFIGURATION.md#cowork-backend) for how to make this permanent.
|
||||
|
||||
### Cowork: virtiofsd not found (Fedora/RHEL)
|
||||
|
||||
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
|
||||
outside `$PATH`. The `--doctor` check detects it there automatically and will
|
||||
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
|
||||
resolves it through `$PATH` only.
|
||||
|
||||
**Fix:** Create a symlink so the KVM backend can find it at runtime:
|
||||
|
||||
```bash
|
||||
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||
```
|
||||
|
||||
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
|
||||
|
||||
### Cowork: cross-device link error on Fedora tmpfs /tmp
|
||||
|
||||
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
|
||||
|
||||
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/Claude/tmp
|
||||
TMPDIR=~/.config/Claude/tmp claude-desktop
|
||||
```
|
||||
|
||||
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
|
||||
|
||||
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
|
||||
|
||||
1. Close Claude Desktop completely
|
||||
2. Edit `~/.config/Claude/config.json`
|
||||
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
|
||||
4. Save the file and restart Claude Desktop
|
||||
5. Log in again when prompted
|
||||
|
||||
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### For APT repository installations (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
|
||||
# Remove the repository and GPG key
|
||||
sudo rm /etc/apt/sources.list.d/claude-desktop.list
|
||||
sudo rm /usr/share/keyrings/claude-desktop.gpg
|
||||
```
|
||||
|
||||
### For DNF repository installations (Fedora/RHEL)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
|
||||
# Remove the repository
|
||||
sudo rm /etc/yum.repos.d/claude-desktop.repo
|
||||
```
|
||||
|
||||
### For AUR installations (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -R claude-desktop-appimage
|
||||
|
||||
# Or using paru
|
||||
paru -R claude-desktop-appimage
|
||||
|
||||
# Or using pacman directly
|
||||
sudo pacman -R claude-desktop-appimage
|
||||
```
|
||||
|
||||
### For .deb packages (manual install)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
# Or: sudo dpkg -r claude-desktop
|
||||
|
||||
# Remove package and configuration
|
||||
sudo dpkg -P claude-desktop
|
||||
```
|
||||
|
||||
### For .rpm packages
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
# Or: sudo rpm -e claude-desktop
|
||||
```
|
||||
|
||||
### For AppImages
|
||||
|
||||
1. Delete the `.AppImage` file
|
||||
2. Remove the `.desktop` file from `~/.local/share/applications/`
|
||||
3. If using Gear Lever, use its uninstall option
|
||||
|
||||
### Remove user configuration (all formats)
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/Claude
|
||||
```
|
||||
@@ -41,6 +41,17 @@ The build script automatically detects your distribution and selects the appropr
|
||||
| Arch Linux | `.AppImage` (via AUR) | yay/paru |
|
||||
| Other | `.AppImage` | - |
|
||||
|
||||
## Build Environment Variables
|
||||
|
||||
The build pulls the Electron prebuilt binary from `github.com/electron/electron/releases` via `@electron/get`. Two upstream environment variables let you redirect that fetch:
|
||||
|
||||
- `ELECTRON_MIRROR` — base URL to fetch Electron releases from instead of GitHub. Useful for mirrors or local proxies. Example: `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/`.
|
||||
- `ELECTRON_CUSTOM_DIR` — overrides the path segment after the mirror. Defaults to `v{version}`.
|
||||
|
||||
The cache location is fixed at `~/.cache/electron/` (resolved by `@electron/get` via `envPaths`) and is reused across builds. `ELECTRON_CACHE` is **not** read by `@electron/get` — set `ELECTRON_MIRROR` if you need to avoid the public CDN.
|
||||
|
||||
The pinned Electron version lives in `scripts/setup/dependencies.sh` (`electron_version`) and must match `build-reference/app-extracted/package.json` — the upstream Claude Desktop `app.asar` is built against a specific Electron major and running a different one is unsupported.
|
||||
|
||||
## Installing the Built Package
|
||||
|
||||
### For .deb packages (Debian/Ubuntu)
|
||||
@@ -13,24 +13,39 @@ Model Context Protocol settings are stored in:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
|
||||
| `CLAUDE_USE_WAYLAND` | unset (auto) | Force the display backend on Wayland: `1` = native Wayland, `0` = XWayland. Unset auto-detects per compositor (only Niri defaults to native Wayland). See [Wayland Support](#wayland-support) below. |
|
||||
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
|
||||
| `CLAUDE_TITLEBAR_STYLE` | unset (`hybrid`) | Controls window decoration style: `hybrid` (system frame + in-app topbar), `native` (system frame, no in-app topbar), `hidden` (frameless WCO — broken on X11, kept for diagnostics). See [Titlebar Style](#titlebar-style) below. |
|
||||
| `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. |
|
||||
|
||||
### Wayland Support
|
||||
|
||||
By default, Claude Desktop uses X11 mode (via XWayland) on Wayland sessions to ensure global hotkeys work. If you prefer native Wayland and don't need global hotkeys:
|
||||
On Wayland sessions the launcher picks a display backend per compositor:
|
||||
|
||||
| Compositor | Backend | Why |
|
||||
|------------|---------|-----|
|
||||
| Niri | native Wayland (auto) | no XWayland support at all |
|
||||
| Everything else (GNOME, KDE, Sway, Hyprland, COSMIC, …) | XWayland (auto) | XWayland global key grabs still work on most; mature path, broadest compatibility |
|
||||
|
||||
By default only Niri is auto-selected for native Wayland. GNOME Wayland stays on XWayland by default even though mutter no longer honours XWayland global key grabs ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)) — flipping the default GNOME session off XWayland is a rendering/IME/HiDPI risk, so it's left opt-in for now.
|
||||
|
||||
To route Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal on GNOME, opt into native Wayland with `CLAUDE_USE_WAYLAND=1`. On **GNOME ≤ 49** this works after a one-time portal permission dialog (accept it to bind the shortcut). On **GNOME 50 / xdg-desktop-portal ≥ 1.20 it does not work yet**: the newer portal requires apps to declare identity via `org.freedesktop.host.portal.Registry.Register`, which Electron/Chromium doesn't do, so `globalShortcut.register()` fails and the shortcut stays focus-bound. Tracked upstream at [electron/electron#51875](https://github.com/electron/electron/issues/51875).
|
||||
|
||||
Override the auto-detection with `CLAUDE_USE_WAYLAND`:
|
||||
|
||||
```bash
|
||||
# One-time launch
|
||||
# Force native Wayland (GNOME portal route, or Sway/Hyprland)
|
||||
CLAUDE_USE_WAYLAND=1 claude-desktop
|
||||
|
||||
# Or add to your environment permanently
|
||||
# Force XWayland (e.g. to override Niri's auto-native, or if native
|
||||
# Wayland regresses rendering)
|
||||
CLAUDE_USE_WAYLAND=0 claude-desktop
|
||||
|
||||
# Or persist either choice
|
||||
export CLAUDE_USE_WAYLAND=1
|
||||
```
|
||||
|
||||
**Important:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal. If global hotkeys (Ctrl+Alt+Space) are important to your workflow, keep the default X11 mode.
|
||||
**Note:** portal-routed global shortcuts only work where the compositor's portal backend implements `org.freedesktop.portal.GlobalShortcuts`. Support is per-compositor and currently uneven — GNOME and KDE implement it (though the app-id requirement above — enforced for GlobalShortcuts since xdg-desktop-portal 1.21 — applies to all desktops, KDE included); wlroots compositors (Sway, Hyprland, Niri) and COSMIC currently ship no GlobalShortcuts backend, so the portal route is a no-op there until their portal gains one.
|
||||
|
||||
### Menu Bar
|
||||
|
||||
65
docs/index.md
Normal file
65
docs/index.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Documentation
|
||||
|
||||
Linux packaging, patching, and operations docs for the [Claude Desktop for Debian](../README.md) project. The README is the storefront; this is the manual.
|
||||
|
||||
```bash
|
||||
# If you're here because something broke:
|
||||
claude-desktop --doctor
|
||||
# Then check troubleshooting.md below.
|
||||
```
|
||||
|
||||
## Installation & building
|
||||
|
||||
- [**Building from source**](building.md) — `./build.sh`, format flags, the Electron mirror env vars
|
||||
- [**Configuration**](configuration.md) — MCP config file locations, env vars, where state lives
|
||||
- [**Troubleshooting**](troubleshooting.md) — symptom-keyed fixes, `--doctor` warning index
|
||||
|
||||
## Project direction
|
||||
|
||||
- [**Decision log**](decisions.md) — ADR-format record of what we ship and (more importantly) what we won't
|
||||
- [**Releasing**](../RELEASING.md) — pre-release checklist, tag recipe, what CI does on tag push
|
||||
- [**Changelog**](../CHANGELOG.md) — `v2.0.0` onward, grouped by REPO_VERSION
|
||||
|
||||
## How the patches work — subsystem deep-dives
|
||||
|
||||
Hard-won knowledge from debugging real bugs. Consult before working on the related subsystem; add a new entry when you discover something non-obvious that would save the next contributor (human or AI) significant time.
|
||||
|
||||
- [**Patching minified JavaScript**](learnings/patching-minified-js.md) — anchor selection, the `\w` vs `$` capture trap, beautified false-negatives, idempotency guards
|
||||
- [**APT/DNF Worker architecture**](learnings/apt-worker-architecture.md) — Cloudflare Worker + GitHub Releases redirect chain, credential ownership, heartbeat runbook
|
||||
- [**Nix packaging**](learnings/nix.md) — NixOS specifics, Electron resource path resolution, testing without NixOS
|
||||
- [**Linux topbar shim**](learnings/linux-topbar-shim.md) — why the in-app topbar is missing on Linux and the four gates that hide it
|
||||
- [**Tray rebuild race**](learnings/tray-rebuild-race.md) — KDE SNI re-registration race; the in-place `setImage`/`setContextMenu` fast path
|
||||
- [**Plugin install flow**](learnings/plugin-install.md) — Anthropic & Partners plugin gate logic and DevTools recipes
|
||||
- [**Cowork VM daemon**](learnings/cowork-vm-daemon.md) — lifecycle, respawn logic, crash diagnosis
|
||||
- [**MCP double-spawn**](learnings/mcp-double-spawn.md) — why stdio MCPs spawn twice with chat + Code/Agent panels open
|
||||
- [**Test harness — Electron hooks**](learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps get bypassed by the frame-fix Proxy
|
||||
- [**Test harness — AX-tree walker**](learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker
|
||||
|
||||
## Testing
|
||||
|
||||
- [**Testing overview**](testing/README.md) — what we test and how it's organized
|
||||
- [**Test runbook**](testing/runbook.md) — running tests locally
|
||||
- [**Test matrix**](testing/matrix.md) — what runs on what distro / format
|
||||
- [**Test automation**](testing/automation.md) — CI workflow shape
|
||||
- [**Quick-entry closeout**](testing/quick-entry-closeout.md) — the Quick Entry test runner
|
||||
|
||||
## Operations
|
||||
|
||||
- [**Issue triage bot**](issue-triage/README.md) — how the GitHub Actions issue-triage workflow works
|
||||
- [**Upstream bug reports**](upstream-reports/) — bugs we've filed against the upstream Electron app
|
||||
|
||||
## Style guides
|
||||
|
||||
- [**Bash style guide**](styleguides/bash_styleguide.md) — the project's shell-script conventions (forked from YSAP)
|
||||
- [**Docs style guide**](styleguides/docs_styleguide.md) — how to write and organize docs (start here if you're adding a page)
|
||||
|
||||
## Contributing
|
||||
|
||||
- [**CONTRIBUTING.md**](../CONTRIBUTING.md) — what we accept, what goes upstream, AI-attribution policy
|
||||
- [**CLAUDE.md**](../CLAUDE.md) — instructions for AI coding assistants (and a useful project archaeology read for humans)
|
||||
- [**AGENTS.md**](../AGENTS.md) — vendor-neutral mirror of `CLAUDE.md` for non-Claude AI tools
|
||||
- [**SECURITY.md**](../SECURITY.md) — private vulnerability reporting
|
||||
|
||||
## Cowork-Linux handover (historical)
|
||||
|
||||
- [**Cowork-Linux handover**](cowork-linux-handover.md) — record of the original cowork Linux work, kept for the historical context. Day-to-day cowork docs live in [`learnings/cowork-vm-daemon.md`](learnings/cowork-vm-daemon.md).
|
||||
@@ -119,6 +119,51 @@ Interpreting the log after a failure:
|
||||
| `lifecycle uncaughtException ...` | JS-level crash, stack is in the log entry |
|
||||
| `lifecycle SIGTERM received` + `lifecycle exit code=0` | Clean app-initiated shutdown |
|
||||
| No `startup` entry at all | `fork()` didn't complete; check launcher.log for `[cowork-autolaunch]` errors |
|
||||
| No `cowork_vm_daemon.log` file at all **and** no `[cowork-autolaunch]` line | The auto-launch `fs.existsSync()` guard returned false — `app.asar.unpacked/` isn't traversable by the running user. Packaging perms bug; see [below](#packaging--appasarunpacked-must-be-traversable-by-the-run-time-user). |
|
||||
|
||||
## Packaging — `app.asar.unpacked/` must be traversable by the run-time user
|
||||
|
||||
The auto-launch fork is guarded by an existence check:
|
||||
|
||||
```javascript
|
||||
const _d = _p.join(process.resourcesPath, "app.asar.unpacked",
|
||||
"cowork-vm-service.js");
|
||||
if (_fs.existsSync(_d)) { /* fork daemon */ }
|
||||
```
|
||||
|
||||
`fs.existsSync()` returns **false** when the directory can't be
|
||||
traversed, not only when the file is genuinely absent — and there is no
|
||||
`else`/`catch`, so the fork is skipped with zero log output. If the
|
||||
packaged `app.asar.unpacked/` ships as mode `0700` owned by the build
|
||||
uid (a restrictive build umask, plus `dpkg-deb` recording ownership
|
||||
verbatim when not run under fakeroot or `--root-owner-group`), the
|
||||
desktop user — a *different* uid — can't enter it. `existsSync` is
|
||||
false, the daemon never forks, and the client loops forever on `connect
|
||||
ENOENT`. The tell is that **both** the daemon log file and the
|
||||
`[cowork-autolaunch]` error line are absent: nothing was even attempted.
|
||||
|
||||
Confirm what the run-time user actually sees, not what root sees:
|
||||
|
||||
```bash
|
||||
svc=.../app.asar.unpacked/cowork-vm-service.js
|
||||
test -r "$svc" && echo OK || echo BLOCKED # run as the desktop user
|
||||
stat -c '%A %U:%G' "$(dirname "$svc")" # 0700 + foreign uid == broken
|
||||
```
|
||||
|
||||
Fixed at the packaging boundary (not in the app code): `deb.sh` and
|
||||
`appimage.sh` normalize the staged tree to canonical modes (directories
|
||||
and executables `755`, other files `644`) before building, and the deb
|
||||
is built with `dpkg-deb --root-owner-group` so ownership is `root:root`.
|
||||
RPM has the same exposure through *file* modes: `%defattr(-, root,
|
||||
root, 0755)` forces directory modes in the payload, but the `-` in its
|
||||
first field preserves file modes verbatim from the buildroot, which
|
||||
`%install` populates with plain `cp -r` — so a `umask 077` build ships
|
||||
an unreadable `app.asar` and a non-executable electron binary (louder
|
||||
symptom: EACCES, since the forced `0755` keeps directories
|
||||
traversable). `rpm.sh` therefore normalizes file modes in `%install`
|
||||
too. To unstick an already-installed package without rebuilding:
|
||||
`sudo chmod -R o+rX /usr/lib/claude-desktop` (preserves the setuid
|
||||
`chrome-sandbox`).
|
||||
|
||||
## Key Files
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -37,46 +37,67 @@ external services with single-connection contracts, etc.
|
||||
|
||||
## Root Cause (Upstream)
|
||||
|
||||
Two parallel session managers live inside Electron main, each
|
||||
holding an independent Claude Agent SDK `query`:
|
||||
Multiple session managers live inside Electron main, each
|
||||
holding its own MCP coordinator state with its own registry. The
|
||||
two that spawn stdio MCPs from `claude_desktop_config.json` and
|
||||
trigger this bug:
|
||||
|
||||
| Manager class | IPC namespace | Coordinator | Logs prefix |
|
||||
|--------------------------|------------------------------------------|-----------------|-------------|
|
||||
| `LocalSessions` | `claude.web_$_LocalSessions_$_*` | `n2t("ccd")` | `[CCD]` |
|
||||
| `LocalAgentModeSessions` | `claude.web_$_LocalAgentModeSessions_$_*`| `n2t("cowork")` | `[LAM]` |
|
||||
|
||||
A third coordinator class — `SshMcpServerManager` — follows the
|
||||
same per-coordinator-registry pattern but uses an SSH transport
|
||||
and doesn't contribute to the local-node double-spawn. Its
|
||||
existence does say something about the design intent: per-
|
||||
coordinator isolated state appears to be a deliberate
|
||||
architectural pattern, not a one-off oversight.
|
||||
|
||||
The logs prefixes are what to grep `~/.config/Claude/logs/` for to
|
||||
confirm a session is hitting both coordinators (and therefore this
|
||||
bug specifically).
|
||||
|
||||
Each `query` holds its own SDK transport. The transport's
|
||||
`spawnLocalProcess` (`Du.spawn`) launches stdio MCPs **without
|
||||
consulting the global registry** that *would* dedupe them
|
||||
(`hZ` map, accessed via `oUt(serverName)` /
|
||||
`launchMcpServer`). That registry is only used for the
|
||||
"internal" cowork in-process MessageChannelMain path.
|
||||
Each coordinator dedups **within its own scope**: CCD's launch
|
||||
function serializes per server name through a promise queue and
|
||||
shuts down any prior entry before respawn; LAM's
|
||||
`getOrCreateConnection` reuses connected entries from its own
|
||||
`connections` Map. The double-spawn is strictly **cross-
|
||||
coordinator** — one process per coordinator that has the server
|
||||
in its config.
|
||||
|
||||
In current versions (verified against `1.5354.0`) both
|
||||
coordinators route their transport creation through a shared
|
||||
Claude Desktop-side factory, but the factory itself doesn't
|
||||
dedupe and the per-coordinator registries above it aren't
|
||||
unified.
|
||||
|
||||
Net result: 2 coordinators × N configured MCPs = 2N processes.
|
||||
|
||||
Symbol names (`n2t`, `hZ`, `oUt`, `LocalSessions`,
|
||||
`LocalAgentModeSessions`) are minified and **will rename across
|
||||
upstream releases**.
|
||||
### Symbol drift
|
||||
|
||||
Minified symbols rename across upstream releases. Issue
|
||||
[#546](https://github.com/aaddrick/claude-desktop-debian/issues/546)
|
||||
maintains the current symbol mappings (verified against
|
||||
`1.5354.0`) plus extraction regexes that work against both
|
||||
minified and beautified bundles.
|
||||
|
||||
## Status
|
||||
|
||||
**Upstream Claude Desktop bug. Not patchable in this repo.** A
|
||||
fix would require either:
|
||||
**Upstream Claude Desktop bug. Not patchable in this repo.** The
|
||||
proximate cause is in Claude Desktop's session manager wiring. A
|
||||
real fix needs either:
|
||||
|
||||
- Routing the SDK stdio transport through `oUt`/`hZ` (the
|
||||
existing serialized-per-name registry), or
|
||||
- Sharing one MCP-server registry between the `ccd` and
|
||||
`cowork` coordinators.
|
||||
- LAM proxying its MCP traffic through CCD's existing connection
|
||||
(so only one coordinator owns the spawn), or
|
||||
- A multiplexing wrapper transport that lets one spawned stdio
|
||||
child serve multiple SDK clients via demuxing.
|
||||
|
||||
Both live inside the closed-source SDK transport / session
|
||||
manager wiring. Regex-matching the minified symbols from
|
||||
`scripts/patches/` would be fragile against release-to-release
|
||||
renames and exceeds this repo's "minimal Linux-compat patches
|
||||
only" charter.
|
||||
Stdio MCP is 1:1 at the protocol layer — one stdin/stdout pair,
|
||||
one transport, one SDK client. Sharing one process across
|
||||
coordinators requires real engineering, not a sed patch on
|
||||
minified code, and exceeds this repo's "minimal Linux-compat
|
||||
patches only" charter.
|
||||
|
||||
## What's Already Verified Clean
|
||||
|
||||
@@ -118,13 +139,15 @@ The reporter's `baro-voyager` MCP shipped both in commit
|
||||
|
||||
- **Primary:** in-app feedback (Help → Send Feedback) or
|
||||
`support@anthropic.com`. The duplication happens in
|
||||
closed-source Desktop main.
|
||||
- **Secondary:** an SDK-transport-flavored issue on
|
||||
closed-source Desktop main, in the per-coordinator registry
|
||||
wiring.
|
||||
- **Secondary:** an issue on
|
||||
[`anthropics/claude-agent-sdk-typescript`](https://github.com/anthropics/claude-agent-sdk-typescript)
|
||||
is defensible — the spawn path goes through the **Claude Agent
|
||||
SDK's** `query` transport (`spawnLocalProcess` / `Du.spawn`),
|
||||
which is shared surface area. Reference the missing `hZ`
|
||||
consultation explicitly.
|
||||
is defensible only if it advocates for a shared-transport /
|
||||
multiplex primitive that would make this kind of bug
|
||||
structurally harder. The SDK's spawn implementation is doing
|
||||
what it's told — the bug is one layer up, in Claude Desktop
|
||||
calling spawn from two separate coordinators.
|
||||
|
||||
The embedded Claude Code CLI subprocess inside Claude Desktop is
|
||||
**not** the cause — it receives `--mcp-config` only when the
|
||||
|
||||
298
docs/learnings/patching-minified-js.md
Normal file
298
docs/learnings/patching-minified-js.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Patching minified JavaScript
|
||||
|
||||
Hard-won lessons from maintaining a long-lived patch suite against an
|
||||
actively re-minified upstream. Each section names a failure mode and
|
||||
the fix.
|
||||
|
||||
The verification recipes below use claude-desktop-debian-specific
|
||||
incantations (Claude-Setup.exe, nupkg extraction, `build.sh
|
||||
--build appimage`); substitute your own project's fetch/extract/build
|
||||
commands as needed.
|
||||
|
||||
## Capturing identifiers: `\w` doesn't match `$`
|
||||
|
||||
JS identifiers allow `$` and `_`; minifiers freely emit names like
|
||||
`$e`, `C$i`, `g$x`. The character class `\w` is `[A-Za-z0-9_]` — it
|
||||
does not match `$`. A `(\w+)` against `$e` captures the suffix `e`
|
||||
and returns a name that doesn't exist in the file. The failure is
|
||||
silent: regex matches, downstream sed runs against a truncated name,
|
||||
asar ships broken JS. Three recurrences (PRs #253, #421, #555) before
|
||||
the convention stuck.
|
||||
|
||||
Use `[$\w]+` (repo convention; `[\w$]+` is equivalent). Strict
|
||||
superset of `\w+`, so pre-`$` versions still match. Live at
|
||||
`cowork.sh:484-502`:
|
||||
|
||||
```bash
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
```
|
||||
|
||||
## The beautified false-negative trap
|
||||
|
||||
Testing a regex against `build-reference/` is not verification. The
|
||||
beautified copy has whitespace the regex doesn't account for.
|
||||
|
||||
During PR #555, both `\w+` and `[\w$]+` tested false against the
|
||||
beautified file. Shipped minified bytes:
|
||||
|
||||
```js
|
||||
await new Promise(n=>setTimeout(n,g$x))
|
||||
```
|
||||
|
||||
Beautified copy:
|
||||
|
||||
```js
|
||||
await new Promise((n) => setTimeout(n, g$x))
|
||||
```
|
||||
|
||||
`await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)` fails
|
||||
the beautified version on the parens and spaces around `=>`. Always
|
||||
close the loop against shipped bytes.
|
||||
|
||||
## Whitespace tolerance: `\s*` vs `[ \t]*`
|
||||
|
||||
`\s` matches newlines. A `\s*`-padded pattern is a license to span
|
||||
across structural boundaries the original line layout meant to
|
||||
keep apart — usually fine on minified bytes (no newlines to span),
|
||||
much looser on beautified.
|
||||
|
||||
Use `[ \t]*` when the intent is "spaces but stay on this line."
|
||||
Reserve `\s*` for crossing structural boundaries on purpose. The
|
||||
existing `cowork.sh` patches mix both — `\s*` where the surrounding
|
||||
context is bounded enough that newline-spanning is harmless, and
|
||||
literal token sequences (`",b:` etc.) when stricter adjacency is
|
||||
required.
|
||||
|
||||
## Replacement-string escaping: `\1`, `&`, `$1`
|
||||
|
||||
A regex can match correctly and still produce corrupted output
|
||||
because the *replacement string* has its own metacharacters. Match
|
||||
debugging shows green; the asar still ships broken bytes. Three
|
||||
flavors:
|
||||
|
||||
**sed `&`** — the entire match. `sed 's/foo/&_suffix/'` is fine
|
||||
(`foo_suffix`). `sed 's/foo/literal_&_dollar/'` accidentally
|
||||
interpolates the match (`literal_foo_dollar`). Escape with `\&` if
|
||||
you want a literal ampersand:
|
||||
|
||||
```bash
|
||||
sed 's/foo/literal_\&_dollar/' # → literal_&_dollar
|
||||
```
|
||||
|
||||
**sed `\1`** — backreferences in the replacement. These work as
|
||||
expected in BRE/ERE. The footgun is the *pattern* side: in BRE, `$`
|
||||
is the end-of-line anchor, so a literal `$` in the search pattern
|
||||
needs `\$`. `_common.sh:25` does exactly this for `electron_var`,
|
||||
which can be `$e` on newer upstream:
|
||||
|
||||
```bash
|
||||
electron_var_re="${electron_var//\$/\\$}"
|
||||
```
|
||||
|
||||
That escaping is for the sed *pattern*, not its replacement.
|
||||
|
||||
**JS `String.prototype.replace`: `$1`, `$&`, `$$`** — the JS
|
||||
replacement DSL is its own thing. `$&` is the whole match; `$1..$9`
|
||||
are capture groups; `$$` is a literal `$`. Plain `$` followed by an
|
||||
unrelated char is left alone, but `$&` and `$N` get interpolated:
|
||||
|
||||
```js
|
||||
code.replace(/foo/g, '$cost') // → '$cost' (safe, no special)
|
||||
code.replace(/foo/g, '$&_x') // → 'foo_x' ($& = match)
|
||||
code.replace(/foo/g, '$$cost') // → '$cost' (escaped)
|
||||
```
|
||||
|
||||
If the replacement is an injected JS snippet that happens to
|
||||
contain `$1` or `$&` (template literals, jQuery, regex source), JS
|
||||
will eat them. Use `$$` to escape, or build the string with
|
||||
concatenation so `$` never sits next to a digit or `&`.
|
||||
|
||||
## Idempotency: a re-run must be byte-identical
|
||||
|
||||
Without it, CI re-runs and partial builds layer mutations until
|
||||
something breaks visibly. Three patterns:
|
||||
|
||||
**Re-key the guard to post-rename names.** `tray.sh:174-180` keys its
|
||||
fast-path guard on the post-rename
|
||||
`${tray_var}.setImage(${electron_var}.nativeImage.createFromPath(${path_var}))`
|
||||
sequence, so the second run recognizes its own first-run output.
|
||||
|
||||
**Negative lookbehind, inline.** `cowork.sh:102-106` — the
|
||||
`(?<!...)` prevents a second match against text the first run
|
||||
already wrapped:
|
||||
|
||||
```js
|
||||
const logRe = new RegExp(
|
||||
'(?<!\\|\\|process\\.platform==="linux"\\))' +
|
||||
win32Var.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
||||
'(\\s*\\?\\s*"vmClient \\(TypeScript\\)")'
|
||||
);
|
||||
```
|
||||
|
||||
**Explicit `code.includes(...)` check.** `cowork.sh:227-230`
|
||||
separates "anchor missing" from "already applied" in the build log:
|
||||
|
||||
```js
|
||||
} else if (code.includes(
|
||||
'getDownloadStatus(){return process.platform==="linux"?'
|
||||
)) {
|
||||
console.log(' Cowork auto-nav suppression already applied');
|
||||
}
|
||||
```
|
||||
|
||||
PR #436 verified by running the patch twice and diffing the output.
|
||||
|
||||
## Anchor selection: prefer literals over identifiers
|
||||
|
||||
The above sections cover making a patch work on first run. This one
|
||||
covers keeping it working release after release. A patch can apply
|
||||
cleanly today and silently no-op next month.
|
||||
|
||||
Minified identifiers churn every release. Developer strings —
|
||||
property names, log messages, IPC channel names — survive
|
||||
minification untouched (true for the upstream bundler used here; a
|
||||
`--mangle-props` build would invalidate property-name anchors).
|
||||
Anchor on those. A hardcoded minified name silently no-ops the next
|
||||
release; the build log still says "patched."
|
||||
|
||||
Three patterns from the suite:
|
||||
|
||||
- **Quick-window (PR #390, fixing #144).** Original patch:
|
||||
`s/e.hide()/e.blur(),e.hide()/`. When `e` became `Sa`, it no-oped.
|
||||
The rewrite anchors on `"pop-up-menu"` (`quick-window.sh:17`), the
|
||||
`isWindowFocused` property name (`quick-window.sh:60`), and the
|
||||
`[QuickEntry]` log strings (`quick-window.sh:88-91`).
|
||||
- **Cowork spawn (PR #436).** Anchored on `,VAR.mountConda)`
|
||||
(`cowork.sh:741`) — unique to the 12-arg call path, absent from the
|
||||
10-arg one-shot. Asserts match count is exactly 1 and bails
|
||||
otherwise (`cowork.sh:744`), so a future second caller surfaces
|
||||
immediately.
|
||||
- **Tray (PR #515).** `tray.sh:16` uses the literal `"menuBarEnabled"`
|
||||
as a *position anchor*, then captures the surrounding minified
|
||||
identifier (`\K\w+(?=\(\)\})`) as the actual patch target. Two
|
||||
stages: stable literal → derived identifier. Every other tray name
|
||||
chains off that single dynamic extraction.
|
||||
|
||||
The lesson is about finding stable points to anchor on, not about
|
||||
what gets patched. The patch target is usually a minified identifier;
|
||||
the *anchor* should be a developer string nearby.
|
||||
|
||||
## Multi-site coordinated patches: surface partial application
|
||||
|
||||
Site 1 patches, site 2 misses, the asar ships half-wired. The
|
||||
pattern: each sub-patch sets a per-site boolean flag on success,
|
||||
then a single named WARNING fires if any flag is false:
|
||||
|
||||
```js
|
||||
if (!siteADone || !siteBDone) {
|
||||
console.log(' WARNING: <ticket> partial — siteA=' + siteADone +
|
||||
' siteB=' + siteBDone + '; <fallback consequence>');
|
||||
}
|
||||
```
|
||||
|
||||
CI greps the build log for `WARNING:` and fails the build. That
|
||||
catches the half-patched state even when individual sub-patches each
|
||||
log "applied." See `cowork.sh:759-763` for a real instance —
|
||||
three-site `sharedCwdPath` forwarding, daemon fallback if any site
|
||||
misses.
|
||||
|
||||
## Disambiguating non-unique anchors: lastIndexOf over indexOf
|
||||
|
||||
A string anchor can appear in source maps, dead exports, or
|
||||
chunk-merged duplicates alongside the live code. `indexOf` returns
|
||||
the first; that may be wrong.
|
||||
|
||||
`cowork.sh:264` uses `lastIndexOf(serviceErrorStr)` to bias toward
|
||||
appended code. On 1.5354.0 the string occurs once, so the change is
|
||||
a no-op there — the defense is for a future upstream that
|
||||
reintroduces the string in onboarding text or sample data far from
|
||||
the live retry-loop site.
|
||||
|
||||
When neither side is reliable, narrow the search region first.
|
||||
`cowork.sh:269-276` does this for the ENOENT check, scanning only a
|
||||
300-character window before the error string.
|
||||
|
||||
## Verifying a hypothesis before shipping a fix
|
||||
|
||||
Pull the pinned URL and SHA from `scripts/setup/detect-host.sh`,
|
||||
download, verify hash, extract without beautifying, and test the
|
||||
regex against the minified bytes:
|
||||
|
||||
```bash
|
||||
url=$(grep -oP "claude_download_url='\K[^']+" \
|
||||
scripts/setup/detect-host.sh | head -1)
|
||||
expected=$(grep -oP "claude_exe_sha256='\K[^']+" \
|
||||
scripts/setup/detect-host.sh | head -1)
|
||||
mkdir -p /tmp/verify && cd /tmp/verify
|
||||
wget -q -O Claude-Setup.exe "$url"
|
||||
echo "$expected Claude-Setup.exe" | sha256sum -c -
|
||||
|
||||
7z x -y Claude-Setup.exe -o exe
|
||||
nupkg=$(find exe -name 'AnthropicClaude-*.nupkg' | head -1)
|
||||
7z x -y "$nupkg" -o nupkg
|
||||
npx asar extract nupkg/lib/net45/resources/app.asar app
|
||||
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const code = fs.readFileSync(
|
||||
"app/.vite/build/index.js", "utf8");
|
||||
const re = /await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/;
|
||||
const m = code.match(re);
|
||||
console.log(m ? `MATCH: ${m[0]}` : "NO MATCH");
|
||||
'
|
||||
```
|
||||
|
||||
`NO MATCH` means the regex is wrong. Verifying the SHA defends against
|
||||
stale URL pinning or server-side binary swap.
|
||||
|
||||
## End-to-end verification (post-build)
|
||||
|
||||
Four layers: build log, syntactic validity, asar markers, runtime.
|
||||
|
||||
1. Check the patch-count line:
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no 2>&1 | tee build.log
|
||||
grep -E 'Applied [0-9]+ cowork patches' build.log
|
||||
```
|
||||
|
||||
Healthy 1.5354.0 build: `Applied 12 cowork patches`. A lower
|
||||
number, or any `WARNING:` in the cowork section, is a half-patched
|
||||
asar.
|
||||
|
||||
2. `node --check` on the patched `index.js` — catches malformed
|
||||
replacements that serialize but don't parse (PR #436 used this in
|
||||
dry-run validation):
|
||||
|
||||
```bash
|
||||
node --check test-build/.../app.asar.contents/.vite/build/index.js
|
||||
```
|
||||
|
||||
3. Static-grep the shipped asar for the 9 cowork markers from PR
|
||||
#555. `scripts/verify-patches.sh` automates this (issue #559 D6)
|
||||
and runs in CI on every `amd64-deb` build via the
|
||||
`Verify cowork patches in shipped asar` step in
|
||||
`.github/workflows/build-amd64.yml`. Reusable for non-cowork patch
|
||||
sets — pass any same-shape TSV as the second arg.
|
||||
|
||||
4. Launch the AppImage and check runtime state:
|
||||
|
||||
```bash
|
||||
tail -20 ~/.config/Claude/logs/cowork_vm_daemon.log
|
||||
ls -la "${XDG_RUNTIME_DIR}/cowork-vm-service.sock"
|
||||
ss -lpx | grep cowork-vm-service.sock
|
||||
```
|
||||
|
||||
Daemon log should have `lifecycle startup` and `lifecycle
|
||||
listening`; socket should exist and be owned by the
|
||||
`cowork-vm-service.js` process listed by `ss`.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tray-rebuild-race.md` "Resilience to minifier churn" — prior art
|
||||
for dynamic extraction across a six-variable patch site and the
|
||||
post-rename idempotency-guard pattern.
|
||||
- `plugin-install.md` "Getting the Minified Source for Any Shipped
|
||||
Version" — the `reference-source.tar.gz` release asset gives
|
||||
beautified asar contents of any prior version for diffing. Useful
|
||||
for spotting when an identifier renamed and which version did it.
|
||||
@@ -80,7 +80,7 @@ releases. All five are extracted dynamically in `tray.sh`:
|
||||
| `tray_func` | `on("menuBarEnabled",()=>{ … })` |
|
||||
| `tray_var` | `});let X=null;(async )?function ${tray_func}` |
|
||||
| `electron_var` | already extracted earlier in `_common.sh` |
|
||||
| `menu_func` | `${tray_var}.setContextMenu(X(` |
|
||||
| `menu_func` | `${tray_var}.setContextMenu(X(` — or, when upstream prebuilds the menu (`M=X(); setContextMenu(M)`), resolved one hop back via `M=X(` |
|
||||
| `path_var` | `${tray_var}=new ${electron_var}.Tray(${electron_var}.nativeImage.createFromPath(X))` |
|
||||
| `enabled_var` | `const X = fn("menuBarEnabled")` |
|
||||
|
||||
@@ -110,13 +110,54 @@ cd claude-desktop-debian
|
||||
After the patch: one SNI stays registered for the app's lifetime,
|
||||
icon updates in place on every theme change.
|
||||
|
||||
## Startup icon-colour race (leading-edge mutex drop)
|
||||
|
||||
A subtler bug lives in the same rebuild function. On a *dark* desktop
|
||||
(e.g. GNOME `color-scheme=prefer-dark`),
|
||||
`nativeTheme.shouldUseDarkColors` reads **`false` for the first
|
||||
~50 ms** of the process, then a burst of `nativeTheme "updated"`
|
||||
events flips it to `true`. Measured with a standalone Electron probe:
|
||||
|
||||
```
|
||||
[ready+0ms] shouldUseDarkColors=false <- tray created -> black icon
|
||||
[UPDATED-EVENT] shouldUseDarkColors=true <- ~50-100 ms later
|
||||
[ready+500ms] shouldUseDarkColors=true (stays true)
|
||||
```
|
||||
|
||||
The tray is created with the transient `false` (black). The
|
||||
correction never lands because the rebuild mutex was a *leading-edge*
|
||||
throttle (`if(f._running)return;f._running=true;setTimeout(...,1500)`):
|
||||
the first `"updated"` (false) takes the lock and renders black; the
|
||||
follow-up `"updated"` (true) events all arrive inside the 1500 ms
|
||||
window and are **dropped**. No further event fires on its own, so the
|
||||
icon stays black until a manual theme change forces a new `"updated"`.
|
||||
|
||||
The fix makes the mutex *trailing-edge* — a request that arrives while
|
||||
a rebuild is in flight is remembered and re-run once when the window
|
||||
clears, so the final value wins:
|
||||
|
||||
```js
|
||||
if (f._running) { f._pending = true; return; }
|
||||
f._running = true;
|
||||
setTimeout(() => {
|
||||
f._running = false;
|
||||
if (f._pending) { f._pending = false; f(); }
|
||||
}, 1500);
|
||||
```
|
||||
|
||||
The startup-suppression `_trayStartTime > 3e3` guard was removed at
|
||||
the same time: it gated the very `"updated"` → rebuild call the
|
||||
correction now depends on. Trade-off: a ~1.5 s black flash at startup
|
||||
before the trailing re-run lands (vs. permanently black before).
|
||||
See [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679).
|
||||
|
||||
## Pitfalls to watch for
|
||||
|
||||
- **Fast-path runs inside the 3 s startup window too.** The
|
||||
existing `_trayStartTime > 3e3` guard only gates the
|
||||
`nativeTheme.on('updated')` → `tray_func()` call; once
|
||||
`tray_func()` is running for any reason, our fast-path executes.
|
||||
Fine — it's cheaper than the slow path even at startup.
|
||||
- **No startup window gates the rebuild any more.** An earlier
|
||||
`_trayStartTime > 3e3` guard suppressed `tray_func()` for the first
|
||||
3 s; it was removed because it also swallowed the startup colour
|
||||
correction (see the section above). The trailing-edge mutex bounds
|
||||
rebuild frequency instead.
|
||||
- **macOS path is left untouched.** The condition
|
||||
`process.platform !== 'darwin' && …setContextMenu` keeps the
|
||||
Electron macOS tray model (right-click pops up a menu via
|
||||
|
||||
73
docs/learnings/wayland-global-shortcuts-portal.md
Normal file
73
docs/learnings/wayland-global-shortcuts-portal.md
Normal file
@@ -0,0 +1,73 @@
|
||||
[< Back to learnings](./)
|
||||
|
||||
# Wayland global shortcuts via the XDG GlobalShortcuts portal
|
||||
|
||||
Quick Entry's global hotkey (`Ctrl+Alt+Space`) is focus-bound on modern GNOME Wayland; the native-Wayland path now routes it through the XDG GlobalShortcuts portal (a merged `--enable-features=…,GlobalShortcutsPortal`), opt-in on GNOME via `CLAUDE_USE_WAYLAND=1` — which fixes GNOME ≤ 49, but GNOME 50 / xdg-desktop-portal ≥ 1.20 is still blocked by an upstream Electron gap ([electron/electron#51875](https://github.com/electron/electron/issues/51875)).
|
||||
|
||||
## The problem (#404)
|
||||
|
||||
Upstream registers Quick Entry's hotkey with a raw `globalShortcut.register()` (build-reference `index.js:499416`) and has no portal fallback. On X11 that becomes an X11 key grab. The launcher historically defaulted *every* Wayland session to XWayland (`--ozone-platform=x11`) precisely so that grab would keep working.
|
||||
|
||||
That stopped working on GNOME. mutter (GNOME ≥ 49) no longer honours XWayland-side global key grabs, so the grab only fires when the Claude window already has focus — the opposite of "open Claude from everywhere." The symptom is intermittent (a brief compositor state can make it appear to work, then it stops), which sent more than one reporter chasing ghosts.
|
||||
|
||||
## The launcher change (necessary, not sufficient)
|
||||
|
||||
Electron ≥ 35 (we bundle 41) exposes Chromium's `GlobalShortcutsPortal` feature: under the **native Wayland ozone platform** it is *supposed* to route `globalShortcut.register()` through the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface instead of an X11 grab. So `build_electron_args` adds `GlobalShortcutsPortal` to the native-Wayland feature set.
|
||||
|
||||
GNOME Wayland is **not** auto-flipped to native Wayland. `detect_display_backend` still only auto-forces Niri (no XWayland at all). The reason: GNOME Wayland is the default session for a large slice of users, and moving it off mature XWayland is a rendering / IME / HiDPI / fractional-scaling risk — shipped on argv-only verification, and on GNOME 50 the portal route is a no-op anyway (so those users would take the risk for zero benefit). GNOME users opt in with `CLAUDE_USE_WAYLAND=1`, which fully works on **GNOME ≤ 49** after the one-time portal dialog. Auto-selecting native Wayland on GNOME is deferred to a follow-up gated on a real "still renders correctly" check, not just "the flag reached argv."
|
||||
|
||||
KDE/Sway/Hyprland likewise stay on XWayland by default (opt in with `=1`).
|
||||
|
||||
## Two traps that bite
|
||||
|
||||
- **`GlobalShortcutsPortal` is inert under XWayland.** The feature lives in Chromium's ozone/wayland layer. Passing the flag while `--ozone-platform=x11` does nothing. The flag and `--ozone-platform=wayland` are a package deal — that's why the launcher flips the backend, not just appends a flag.
|
||||
|
||||
- **Chromium honours only the *last* `--enable-features=` switch.** Two separate `--enable-features=A` `--enable-features=B` on one command line silently drops `A`. `build_electron_args` previously emitted up to two (`WindowControlsOverlay` for hidden titlebars; `UseOzonePlatform,WaylandWindowDecorations` for native Wayland), so adding a third would have clobbered the others. The function now accumulates into one `enable_features` array and emits a single comma-joined `--enable-features=` at the end. The test-harness `argvHasFlag` (`tools/test-harness/src/lib/argv.ts`) already matches a subkey inside a comma-joined value, so `S12` passes against the merged form.
|
||||
|
||||
## Why GNOME 50 is still broken — and how it was proven
|
||||
|
||||
On Fedora 44 / GNOME 50.2 / xdg-desktop-portal **1.21.2**, `globalShortcut.register()` returns `false` and the portal is **never contacted** (no `CreateSession`, no `BindShortcuts`). The feature flag has zero observable effect:
|
||||
|
||||
| ozone backend | `GlobalShortcutsPortal` flag | `register()` | portal `CreateSession` |
|
||||
|---|---|---|---|
|
||||
| wayland | enabled | `false` | 0 |
|
||||
| wayland | default (no flag) | `false` | 0 |
|
||||
| wayland | disabled | `false` | 0 |
|
||||
| x11 (XWayland) | enabled | `true` | 0 (X11 grab; mutter ignores it → focus-bound, the #404 symptom) |
|
||||
|
||||
Reproduced identically on Electron **40.6.1, 41.5.0, 41.7.1, and 42.3.3** (latest), with the relevant app-id fixes already present (electron#49988 → backported to `41-x-y` via #50051). So the Electron *version* is not the variable.
|
||||
|
||||
**Root cause (pinned to source on both sides):** xdg-desktop-portal grew a host-app identity step — non-sandboxed apps must call `org.freedesktop.host.portal.Registry.Register(app_id)` (added in **1.20**, commit `8fd5bdd5ec`), and GlobalShortcuts `CreateSession` now hard-rejects an empty app id (`src/global-shortcuts.c` `handle_create_session()` → `NOT_ALLOWED "An app id is required"`, added in **1.21.0**, commit `38dd2c03f2`). Chromium never makes that call in the normal case: `components/dbus/xdg/portal.cc` `PortalRegistrar::OnServiceChecked()` only calls `Register()` when starting its transient systemd scope *fails* — when the scope starts (`kUnitStarted`, the usual path; the browser creates `app-<id>-<pid>.scope`) it skips `Register()`, assuming the portal derives the app id from the scope. On portal 1.21 that derivation is gone, so the connection has an empty app id and `CreateSession` (issued from `ui/base/accelerators/global_accelerator_listener/global_accelerator_listener_linux.cc`) is rejected. Confirmed on plain Chromium 151 (HEAD) and Chrome 149, not just Electron.
|
||||
|
||||
**Proof the portal itself works** — a ~60-line Python client that performs the missing `Registry.Register` call (reverse-DNS app id backed by a `.desktop` file, launched in a matching `app-<id>.scope` via `systemd-run --user --scope`) drives the whole flow and receives `Activated` from an *unfocused* window:
|
||||
|
||||
```
|
||||
Registry.Register('com.example.GsPortalProof') OK
|
||||
CreateSession OK
|
||||
BindShortcuts OK -> id='open-quick-entry' trigger='Press <Control><Alt>space'
|
||||
*** ACTIVATED *** (press #1) *** ACTIVATED *** (press #2)
|
||||
```
|
||||
|
||||
Secondary gate: GNOME's backend also rejects app ids that are not reverse-DNS and backed by an installed `.desktop` (`gnome-control-center-global-shortcuts-provider: Discarded shortcut bind request … invalid app_id >gsportalproof<`). Electron's default app id is the executable name (`claude-desktop`), which has no dot and would likely also fail this even once `Registry.Register` is wired up.
|
||||
|
||||
Why it works on GNOME ≤ 49: older xdg-desktop-portal derived the app id from the systemd scope automatically and did not require `Registry.Register`. GNOME 50 / portal 1.21 introduced the requirement Chromium hasn't adopted.
|
||||
|
||||
Filed upstream: [electron/electron#51875](https://github.com/electron/electron/issues/51875) (accepted, milestone `42-x-y`) and the underlying Chromium bug at [crbug 520262204](https://issues.chromium.org/issues/520262204) — fundamentally the `components/dbus/xdg/portal.cc` skip-`Register()`-on-`kUnitStarted` gap, surfacing through Electron.
|
||||
|
||||
## First-run UX and escape hatch
|
||||
|
||||
When the portal path *does* engage (GNOME ≤ 49), GNOME shows a **one-time permission dialog** the first time the shortcut is registered; the user must accept it to bind the shortcut. Expected portal behaviour, not a bug. A dismissed or denied dialog persists in the portal permission store and later `globalShortcut.register()` calls then fail silently; clearing the stored decision with `flatpak permission-reset <app-id>` (the store is shared with non-Flatpak apps) should re-trigger the dialog on the next launch — untested here.
|
||||
|
||||
`CLAUDE_USE_WAYLAND` is tri-state: `1` forces native Wayland, `0` forces XWayland (skipping auto-detect), unset auto-detects. The `0` value is the escape hatch for a GNOME user who hits a native-Wayland rendering regression and wants the old XWayland behaviour back (losing global-shortcut-from-unfocused in the process — which on GNOME 50 is not yet working anyway).
|
||||
|
||||
## wlroots caveat (Niri / Sway / Hyprland)
|
||||
|
||||
The portal flag is harmless where the compositor's portal has no GlobalShortcuts backend, but does nothing useful there. wlroots' `xdg-desktop-portal-wlr` ships no GlobalShortcuts implementation, so on Niri `BindShortcuts` fails with `error code 5`. That's the `S14` known-failing detector: the assertion encodes the contract and will start passing if/when the wlroots portal gains the interface — no spec edit needed.
|
||||
|
||||
## Tests / anchors
|
||||
|
||||
- `tests/launcher-common.bats` — `detect_display_backend` GNOME/`CLAUDE_USE_WAYLAND=0` cases; `build_electron_args` single-merged-flag + portal-present/absent cases.
|
||||
- `tools/test-harness/src/runners/S12_global_shortcuts_portal_flag.spec.ts` — GNOME-W flag-in-argv detector (passes: the launcher delivers the flag).
|
||||
- `tools/test-harness/src/runners/S14_quick_entry_from_other_focus_niri.spec.ts` — Niri portal `BindShortcuts` detector (known-failing by design).
|
||||
- `docs/testing/cases/shortcuts-and-input.md` (S12/S14), `docs/testing/quick-entry-closeout.md` (QE-6).
|
||||
- Upstream blockers: [electron/electron#51875](https://github.com/electron/electron/issues/51875), Chromium [crbug 520262204](https://issues.chromium.org/issues/520262204).
|
||||
144
docs/styleguides/docs_styleguide.md
Normal file
144
docs/styleguides/docs_styleguide.md
Normal file
@@ -0,0 +1,144 @@
|
||||
[< Back to docs index](../index.md)
|
||||
|
||||
# Docs Style Guide
|
||||
|
||||
How docs are organized and written in this repo. The patterns here come from a survey of well-organized open-source docs (Spatie, Filament, laravel-docs, earendil-works/pi) plus what's worked in this project's own `docs/` tree. If you're adding a page, read the **Page anatomy** section before you start.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Flat `docs/`**, **lowercase kebab-case** filenames (`troubleshooting.md`, not `TROUBLESHOOTING.md`; `building.md`, not `BUILDING.md`). Order belongs in this index, not filenames.
|
||||
- One entry point: **[`docs/index.md`](../index.md)**. It's the GitHub-browsable landing page and the link target from every other doc.
|
||||
- **Subdirectories only when a topic grows past ~5 pages.** Current subdirs:
|
||||
- [`docs/learnings/`](../learnings/) — subsystem deep-dives. Promoted out of the top level once there were >3.
|
||||
- [`docs/testing/`](../testing/) — test harness docs.
|
||||
- [`docs/issue-triage/`](../issue-triage/) — the issue-triage bot config and prompts.
|
||||
- [`docs/upstream-reports/`](../upstream-reports/) — bug reports filed against upstream that we keep alongside the patch.
|
||||
- `docs/styleguides/` — meta-docs about how to write docs and shell scripts.
|
||||
- **`docs/images/`** for screenshots and diagrams. Never scatter `.png`s next to `.md`s.
|
||||
- **Repo-root auxiliary files stay at the root** so GitHub auto-detects them: `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE-*`, `RELEASING.md`, `CLAUDE.md`, `AGENTS.md`. Don't move them under `docs/`.
|
||||
|
||||
## Page anatomy
|
||||
|
||||
Three skeletons recur across well-organized docs in this project. Pick one before starting a page.
|
||||
|
||||
### Setup / how-to page
|
||||
|
||||
Used for: `building.md`, `configuration.md`, `releasing.md` (in the root).
|
||||
|
||||
```
|
||||
<one declarative sentence: what this page is for>
|
||||
<one code block showing the minimum working command>
|
||||
## Prerequisites -> short list; assume Linux + git unless stated
|
||||
## <Step 1> -> one short paragraph + code block
|
||||
## <Step 2>
|
||||
## Common variations -> distro-specific or flag-specific quirks
|
||||
## Troubleshooting -> link out to troubleshooting.md, don't duplicate
|
||||
```
|
||||
|
||||
Open with the minimum command, not the prerequisites table. Readers skim to the code block first.
|
||||
|
||||
### Troubleshooting / FAQ page
|
||||
|
||||
Used for: `troubleshooting.md`.
|
||||
|
||||
```
|
||||
<one declarative sentence: what kind of problem this page solves>
|
||||
## <Symptom or error message verbatim> -> one ### Fix per symptom, with a code block
|
||||
## <Next symptom>
|
||||
```
|
||||
|
||||
The headings are **the symptom users type into search.** Don't editorialize ("Troubles with Wayland" is wrong — `## Black screen on Fedora KDE under Wayland` is right). One `### Fix` per `##`. If a symptom needs explanation, prose goes under the fix, not in the heading.
|
||||
|
||||
### Subsystem deep-dive (a "learning")
|
||||
|
||||
Used for: everything in `docs/learnings/`.
|
||||
|
||||
```
|
||||
<one paragraph: what subsystem this covers, when it runs, why it's non-obvious>
|
||||
**Source files:** bullet list of GitHub links to the relevant source
|
||||
## Overview -> 2–3 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 2–4 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.5–3 kB | Platform notes, single-flag install variants |
|
||||
| 3–8 kB | Standard how-to and setup pages |
|
||||
| 10–17 kB | Major how-to pages, learnings |
|
||||
| 17–25 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 (1–3 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.
|
||||
@@ -11,7 +11,6 @@ This directory holds the manual test plan for the Linux fork of Claude Desktop.
|
||||
| [`matrix.md`](./matrix.md) | **The dashboard.** Cross-environment results table + per-section env-specific status snapshots. Single source of truth for test status. |
|
||||
| [`runbook.md`](./runbook.md) | How to run a sweep: VM setup, diagnostic capture, status update workflow, severity guidance. |
|
||||
| [`cases/`](./cases/) | Functional test specs grouped by feature surface. Stable IDs: `T###` cross-env, `S###` env-specific. |
|
||||
| [`ui/`](./ui/) | UI element inventory. Per-surface checklists — every interactive element with expected state. |
|
||||
|
||||
## Environment key
|
||||
|
||||
|
||||
@@ -16,11 +16,10 @@ tests, which anti-patterns to design against, and what to build first.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The 67 tests in [`cases/`](./cases/) plus the 10 surfaces in [`ui/`](./ui/)
|
||||
already have stable IDs, standardized bodies, and per-element checklists. That
|
||||
structure is unusually friendly to automation — but only if the harness is
|
||||
shaped to match the corpus, rather than the other way around. Three things
|
||||
make that non-trivial:
|
||||
The 67 tests in [`cases/`](./cases/) already have stable IDs and
|
||||
standardized bodies. That structure is unusually friendly to
|
||||
automation — but only if the harness is shaped to match the corpus,
|
||||
rather than the other way around. Three things make that non-trivial:
|
||||
|
||||
1. The tests aren't homogeneous. Some are pure-renderer (Code tab), some are
|
||||
native-OS-level (tray, autostart, URL handler), some are visual/UX checks
|
||||
@@ -40,7 +39,7 @@ make that non-trivial:
|
||||
| 1 | **Single language: TypeScript.** Every runner is `.ts`; OS tools are shelled out via `child_process` and wrapped as TS helpers. Python only as a last-resort escape hatch for AT-SPI cases that resist portal mocking. | Playwright Electron is JS-native (post-Spectron); `dbus-next` covers DBus end-to-end; portal mocking removes the dogtail dependency for most native-dialog tests. Three-language overhead doesn't pay back. |
|
||||
| 2 | **Harness location: `tools/test-harness/`.** Sibling to `scripts/`. | Keeps `docs/testing/` documentation-only; matches the project's existing `tools/` / `scripts/` split. |
|
||||
| 3 | **VM images: Packer for imperative distros + Nix flake for `Hypr-N`.** | Packer builds golden snapshots that boot fast and rebuild as code; Nix flake handles NixOS natively without a second wrapper. Vagrant's per-boot provisioning model is the wrong tradeoff for hermetic per-test snapshots. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invokable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invocable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 5 | **Selectors: semantic locators only (`getByRole`, `getByLabel`, `getByText`).** No CSS classes against minified renderer output. No proactive `data-testid` injection patch. Escalate per-test only when a specific test proves unstable: first ask upstream for a stable `data-testid`; only carry an `app-asar.sh` patch if upstream declines. | Building selector-injection infrastructure up front is a guess at where rot will happen. Modern React apps usually have enough ARIA roles and visible text for `getByRole`/`getByText` to be durable. Measure before patching. |
|
||||
| 6 | **X11-default verification is Smoke. Wayland-native characterization is Should.** Add a Smoke test asserting the launcher log shows X11/XWayland selected on each row (the project's release-gate behavior). Add per-row Should tests characterizing what happens if Electron's default Wayland selection is allowed — these are informational, not release-gating. | The project chose X11 default because portal `GlobalShortcuts` coverage is patchy. The new Wayland-default tests exist to map that landscape, not to gate releases on it. |
|
||||
| 7 | **Diagnostic retention: last 10 greens + all reds, on `main` only.** Captures `--doctor`, launcher log, screenshot every run. Reds retained indefinitely; greens rotate. | Cheap regression-bisect baseline; bounded storage; reds are the things you actually need to look at six weeks later. |
|
||||
@@ -53,7 +52,7 @@ bucket maps to a different shape of TS code (not a different language):
|
||||
|
||||
| Layer | What it covers | Implementation |
|
||||
|-------|----------------|----------------|
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat, most of `ui/code-tab-panes.md`, `prompt-area.md`, `settings.md` | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **L2 — Native / OS** | Tray (DBus), window decorations, URL handler (`xdg-open`), autostart, `--doctor`, multi-instance, hide-to-tray, native file picker (T17) | TS + `dbus-next` for DBus; `child_process` shell-outs wrapped as TS helpers (`xprop`, `wlr-randr`, `swaymsg`, `niri msg`, `pgrep`, `ydotool`); `dbus-next`-driven portal mocking for native-dialog tests |
|
||||
| **L3 — Manual** | "Icon is crisp on HiDPI", drag-and-drop feel, T28 catch-up after suspend (real wall-clock), subjective UX checks | Human eyes; capture in [`runbook.md`](./runbook.md) sweep loop |
|
||||
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
# docs/testing/cases grounding sweep — implementation prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
orchestration depends on the exact directives below.
|
||||
|
||||
---
|
||||
|
||||
## Prompt to paste
|
||||
|
||||
You're picking up after the v7 walker, U01 wire-up, and the
|
||||
`claudeai.ts` AX-tree migration all landed. The page-objects are
|
||||
stable against the live renderer (T17_folder_picker passes on
|
||||
KDE-W). The next workstream is **grounding the case docs in
|
||||
`docs/testing/cases/` against actual upstream behavior**.
|
||||
|
||||
The cases were written from outside-in — observed user-visible
|
||||
flows, expected outcomes, diagnostic captures. Many describe
|
||||
behavior the test author *believed* exists in upstream Claude
|
||||
Desktop, but no one has cross-checked each Step / Expected against
|
||||
the actual extracted source. Your job is to spawn one subagent per
|
||||
case file, have each one read the case + grep the build-reference
|
||||
extract for the relevant feature, and report what's accurate, what's
|
||||
stale, and what's missing — then make in-place adjustments to the
|
||||
case files so each one is grounded in concrete code anchors before
|
||||
the next sweep cycle.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order. They're the substrate the subagents will pull
|
||||
from.
|
||||
|
||||
- `docs/testing/cases/README.md` — the case-doc structure (severity,
|
||||
surface, applies-to, steps, expected, diagnostics, references).
|
||||
The "Standard test body" template at the bottom is the contract
|
||||
every case currently follows.
|
||||
- `docs/testing/matrix.md` — live Pass/Fail/Pending matrix per row.
|
||||
Tells you which cases have a runner and which are still
|
||||
human-execution-only.
|
||||
- `build-reference/app-extracted/.vite/build/` — the extracted +
|
||||
beautified Claude Desktop source. ~14 files; `index.js` is the
|
||||
main process (~546k lines after beautification), `mainView.js` /
|
||||
`mainWindow.js` / `quickWindow.js` are renderer preloads,
|
||||
`coworkArtifact.js` is the cowork BrowserView preload,
|
||||
`buddy.js` is the supervisor, etc. **This is the ground truth.**
|
||||
- `tools/test-harness/src/runners/` — existing runners that *do*
|
||||
have working selectors / event hooks. Sometimes the runner has
|
||||
more accurate code anchors than the case doc.
|
||||
- `CLAUDE.md` (project root) — project conventions, attribution
|
||||
format, commit style. Don't violate.
|
||||
|
||||
### Case files in scope
|
||||
|
||||
Eleven files plus the README. One subagent per file:
|
||||
|
||||
| File | Tests covered |
|
||||
|---|---|
|
||||
| `code-tab-foundations.md` | T15-T20 |
|
||||
| `code-tab-handoff.md` | T23-T25, T34, T38, T39 |
|
||||
| `code-tab-workflow.md` | T21-T22, T29-T32 |
|
||||
| `distribution.md` | S01-S05, S15, S16, S26 |
|
||||
| `extensibility.md` | T11, T33, T35-T37, S27, S28 |
|
||||
| `launch.md` | T01, T02, T13, T14 |
|
||||
| `platform-integration.md` | T09, T10, T12, S17, S18, S22-S25 |
|
||||
| `routines.md` | T26-T28, S19-S21 |
|
||||
| `shortcuts-and-input.md` | T05, T06, S06-S14, S29-S37 |
|
||||
| `tray-and-window-chrome.md` | T03, T04, T07, T08, S08, S13 |
|
||||
|
||||
### Why this iteration
|
||||
|
||||
Several cases have been silently bit-rotting against upstream
|
||||
changes — a Step says "click the X menu" but X was renamed two
|
||||
upstream versions ago, or an Expected references a behavior the
|
||||
team shipped behind a feature flag that's now off by default. When
|
||||
the sweep runs against a row that's stale, the failure looks like a
|
||||
Linux compatibility issue but is actually a doc-vs-upstream drift.
|
||||
Grounding the cases against the actual extracted source closes
|
||||
that gap and makes future sweeps interpretable.
|
||||
|
||||
This isn't a one-time correctness pass — it's a cycle. After every
|
||||
upstream version bump (`CLAUDE_DESKTOP_VERSION` rolls in
|
||||
`scripts/setup/detect-host.sh`), the grounding can drift again.
|
||||
Optimise for **leaving concrete code-anchor breadcrumbs** in each
|
||||
case so the next grounding pass is fast.
|
||||
|
||||
### Repo conventions
|
||||
|
||||
- Tabs for indentation in code; markdown is space-indented as the
|
||||
existing files do it.
|
||||
- Markdown lines wrap at ~80 chars unless they're tables or links
|
||||
that don't break naturally.
|
||||
- Don't commit. The user reviews and commits.
|
||||
- Don't run the host Claude Desktop. The user runs it. Read from
|
||||
`build-reference/` instead — that's already extracted +
|
||||
beautified specifically so you don't have to attach to a live
|
||||
app to verify behavior.
|
||||
|
||||
### Code anchors
|
||||
|
||||
- `build-reference/app-extracted/.vite/build/index.js` — main
|
||||
process. Every IPC channel registration, window-management
|
||||
decision, app-lifecycle hook, tray-menu construction, autostart
|
||||
toggle, dialog invocation, and protocol handler lives here.
|
||||
- `build-reference/app-extracted/.vite/build/quickWindow.js` —
|
||||
Quick Entry preload + window setup.
|
||||
- `build-reference/app-extracted/.vite/build/mainWindow.js` —
|
||||
main shell BrowserWindow preload (claude.ai is loaded into a
|
||||
child BrowserView; this preload runs in the shell frame).
|
||||
- `build-reference/app-extracted/.vite/build/mainView.js` —
|
||||
preload running inside the claude.ai BrowserView itself.
|
||||
- `build-reference/app-extracted/.vite/build/coworkArtifact.js` —
|
||||
preload running inside cowork's iframe-shaped artifact view.
|
||||
- `build-reference/app-extracted/.vite/build/buddy.js` — supervisor
|
||||
process (the daemon that respawns the cowork worker; see
|
||||
`docs/learnings/cowork-vm-daemon.md`).
|
||||
- `build-reference/app-extracted/package.json` — declared main /
|
||||
preloads, electron version, native deps. Quick reference for
|
||||
whether a feature is wired up at all.
|
||||
|
||||
### Phases
|
||||
|
||||
#### Phase 0 — calibration
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — should pass; if
|
||||
not, stop and report.
|
||||
2. Read `docs/testing/cases/README.md` end-to-end and one full case
|
||||
file (suggest `launch.md` — small, four tests, easy
|
||||
surface-area). Confirm you understand the case-doc contract
|
||||
before fanning out.
|
||||
3. Pick T01 (App launch) as a calibration case. Manually grep
|
||||
`build-reference/app-extracted/.vite/build/index.js` for the
|
||||
launcher-log / backend-selection logic referenced in T01's
|
||||
Expected. Confirm you can read the beautified source and locate
|
||||
the relevant code. Report the anchor (`index.js:N-M`) so the
|
||||
user knows the workflow is sound before you fan out.
|
||||
|
||||
If Phase 0 surfaces a problem (build-reference stale relative to
|
||||
the case doc, calibration anchor not findable, README structure
|
||||
unclear), stop and report. Don't fan out subagents against an
|
||||
unverified workflow.
|
||||
|
||||
#### Phase 1 — fan-out
|
||||
|
||||
Spawn one subagent per case file (eleven total). Use
|
||||
`subagent_type: 'general-purpose'`. Send them in **parallel** —
|
||||
they're independent. Keep the prompt to each subagent
|
||||
self-contained; the subagent has no context from this conversation.
|
||||
|
||||
Per-subagent prompt template (fill in the case file path):
|
||||
|
||||
```
|
||||
You're grounding ONE test-case file in
|
||||
docs/testing/cases/<FILE>.md against the extracted Claude Desktop
|
||||
source at build-reference/app-extracted/.vite/build/.
|
||||
|
||||
Read these first:
|
||||
- docs/testing/cases/README.md (case-doc contract)
|
||||
- docs/testing/cases/<FILE>.md (your case file)
|
||||
- CLAUDE.md (project conventions)
|
||||
|
||||
For each test in the file:
|
||||
|
||||
1. Read the test's Steps + Expected.
|
||||
2. Identify the load-bearing claim — the upstream behavior the
|
||||
test depends on (an IPC channel, a tray-menu item, a
|
||||
dialog.showOpenDialog call, a globalShortcut.register, a
|
||||
nativeTheme listener, etc.).
|
||||
3. Grep build-reference/app-extracted/.vite/build/ for that claim.
|
||||
Use ripgrep / grep -E. The code is beautified but minified
|
||||
variable names — anchor on string literals, IPC channel names,
|
||||
menu labels, event names, not variable identifiers.
|
||||
4. Classify the result:
|
||||
- **Grounded** — claim verified, anchor found. Append a
|
||||
`**Code anchors:** <file>:<line>` line to the test body
|
||||
directly under the existing References field.
|
||||
- **Drifted** — feature exists but the case's Steps or Expected
|
||||
don't match what's actually shipping. Edit the case to
|
||||
match upstream behavior. Note what changed.
|
||||
- **Missing** — feature isn't in the build at all (deprecated,
|
||||
never shipped, behind unset flag). Mark the test with a
|
||||
prepended block:
|
||||
`> **⚠ Missing in build 1.5354.0** — <one-line note>. Re-verify after next upstream bump.`
|
||||
- **Ambiguous** — claim could be one of several upstream code
|
||||
paths and you can't disambiguate from the case alone. Don't
|
||||
edit; report under "Open questions".
|
||||
|
||||
Per-test, prefer concrete code anchors over wordy explanations.
|
||||
The next person reading this case should see exactly where
|
||||
upstream implements the feature.
|
||||
|
||||
Constraints:
|
||||
- Don't fabricate anchors. If you can't find it, mark Missing or
|
||||
Ambiguous — never invent a `index.js:12345` reference.
|
||||
- Don't restructure the case files. Keep the existing template
|
||||
(Severity / Surface / Applies to / Issues / Steps / Expected /
|
||||
Diagnostics / References). Only add code anchors and edit
|
||||
Steps/Expected for drift.
|
||||
- Don't expand scope. If you notice an unrelated bug or missing
|
||||
test, note it under "Open questions" — don't fix it inline.
|
||||
- Don't run the host Claude Desktop. Read from build-reference/
|
||||
only.
|
||||
|
||||
Report shape (~300-500 words):
|
||||
|
||||
## <FILE>.md grounding
|
||||
|
||||
- Tests reviewed: N
|
||||
- Grounded: N
|
||||
- Drifted (edited): N (one-line per: <test-id> — <what changed>)
|
||||
- Missing (marked): N (one-line per: <test-id> — <what's gone>)
|
||||
- Ambiguous (flagged): N (one-line per: <test-id> — <why>)
|
||||
|
||||
### Code anchor highlights
|
||||
- <test-id>: <file>:<line> — <what the anchor proves>
|
||||
|
||||
### Open questions
|
||||
- ...
|
||||
|
||||
### Files touched
|
||||
- docs/testing/cases/<FILE>.md
|
||||
```
|
||||
|
||||
Keep the report tight. The orchestrator reads eleven of these and
|
||||
synthesizes.
|
||||
|
||||
#### Phase 2 — synthesis
|
||||
|
||||
Once all eleven subagents return:
|
||||
|
||||
1. Aggregate per-classification counts across all files. Big
|
||||
numbers in any column are signals:
|
||||
- Lots of **Drifted** → upstream had a recent feature shuffle;
|
||||
the team should know.
|
||||
- Lots of **Missing** → either the case doc was written
|
||||
speculatively or upstream removed features without telling.
|
||||
- Lots of **Ambiguous** → the case-doc template needs a
|
||||
"Implementation hint" field so future grounding has a
|
||||
starting point.
|
||||
2. Cross-check: did any subagent edit the same anchor differently?
|
||||
(Unlikely since each owns one file, but worth a sanity pass.)
|
||||
3. Check that `git diff docs/testing/cases/` matches what the
|
||||
subagents reported. If a subagent claimed Drifted but didn't
|
||||
write to disk, surface it.
|
||||
4. Build the user-facing summary (see "Final report format" below).
|
||||
|
||||
Don't make the user re-read the eleven subagent reports — give
|
||||
them the synthesised view + the per-file links.
|
||||
|
||||
### Self-correction loop
|
||||
|
||||
After Phase 1 returns:
|
||||
|
||||
1. If any subagent failed (no report, error, hit token limit),
|
||||
re-spawn just that one with a tighter scope (e.g. "process
|
||||
tests T15-T17 only, not the full file").
|
||||
2. If a subagent's report claims edits but `git diff` shows no
|
||||
changes, the subagent silently dropped the writes — re-spawn
|
||||
with explicit instruction to use the Edit tool.
|
||||
3. If two subagents flag the same upstream code path with
|
||||
contradictory claims (one says Grounded, one says Missing),
|
||||
re-read the source yourself and adjudicate.
|
||||
|
||||
Cap re-spawns at **2 per file** — past that, mark the file as
|
||||
"needs human review" in the final report and move on.
|
||||
|
||||
### Termination conditions
|
||||
|
||||
Stop and write a final report when one of:
|
||||
|
||||
1. **All eleven files grounded.** Per-file classification counts +
|
||||
diff stat. Done.
|
||||
2. **Hit the re-spawn cap on 3+ files.** Stop, write up which
|
||||
files are blocked, what each blocker looks like.
|
||||
3. **Build-reference is stale.** If multiple subagents report
|
||||
"Missing" against features the user knows shipped, the
|
||||
extract may be out of date — verify the version
|
||||
(`build-reference/app-extracted/package.json` `version` field
|
||||
vs `CLAUDE_DESKTOP_VERSION` repo variable) before continuing.
|
||||
|
||||
### What you should NOT do
|
||||
|
||||
- Don't commit. The user reviews everything.
|
||||
- Don't restructure the case-doc template. Eleven files, one
|
||||
shape — keep it that way.
|
||||
- Don't add new tests. Grounding is a verify-and-anchor pass, not
|
||||
a coverage expansion.
|
||||
- Don't run the host Claude Desktop. The build-reference extract
|
||||
exists specifically so you don't have to attach to a live app.
|
||||
- Don't edit anything outside `docs/testing/cases/`. If you find
|
||||
a runner discrepancy (case says "click X", runner clicks "Y"),
|
||||
flag it under Open questions; don't edit the runner.
|
||||
- Don't invent anchors. If the grep doesn't find the literal,
|
||||
classify Missing or Ambiguous — never write a fictional
|
||||
`index.js:12345` reference.
|
||||
|
||||
### Final report format
|
||||
|
||||
```markdown
|
||||
## Cases grounding summary
|
||||
|
||||
- Files reviewed: 11 / 11
|
||||
- Tests reviewed: N (sum across all files)
|
||||
- Grounded: N (with code anchors added)
|
||||
- Drifted (edited): N
|
||||
- Missing (marked): N
|
||||
- Ambiguous: N
|
||||
- Files needing
|
||||
human review: N
|
||||
|
||||
## Per-file breakdown
|
||||
|
||||
| File | Reviewed | Grounded | Drifted | Missing | Ambiguous |
|
||||
|---|---|---|---|---|---|
|
||||
| code-tab-foundations.md | ... | ... | ... | ... | ... |
|
||||
| ... | | | | | |
|
||||
|
||||
## Notable findings
|
||||
- <test-id>: <one-line significance>
|
||||
- ...
|
||||
|
||||
## Open questions
|
||||
- ...
|
||||
|
||||
## Files touched
|
||||
git status output (only docs/testing/cases/*.md should appear)
|
||||
|
||||
## Diff summary
|
||||
git diff --stat docs/testing/cases/
|
||||
```
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Subagents are launched in parallel via a single message with
|
||||
multiple Agent tool calls. Don't serialize them — Phase 1 takes
|
||||
~15 minutes serial, ~3 minutes parallel.
|
||||
- Each subagent's Edit calls land directly in the working tree.
|
||||
No merge conflicts because each owns one file.
|
||||
- The build-reference `index.js` is 546k lines. Subagents should
|
||||
use `grep -nE` with anchored string literals, not full reads.
|
||||
Recommended grep pattern style:
|
||||
`grep -nE 'globalShortcut\.register\([^)]*' build-reference/app-extracted/.vite/build/index.js`
|
||||
- If a subagent needs to verify a renderer-side claim (DOM event
|
||||
flow, React component shape), the relevant preload is in
|
||||
`mainView.js` / `mainWindow.js`. Don't grep `index.js` for
|
||||
renderer-only behavior.
|
||||
|
||||
Begin with Phase 0. Don't fan out until calibration succeeds.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Functional Test Cases
|
||||
|
||||
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md). For the UI element inventory, see [`../ui/`](../ui/).
|
||||
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -130,10 +130,10 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
|
||||
**Diagnostics on failure:** Launcher log (note `Using X11 backend via XWayland (for global hotkey support)`), `XDG_CURRENT_DESKTOP`, mutter version (`gnome-shell --version`), the active patch set.
|
||||
|
||||
**Currently:** Fedora 43 GNOME Wayland reproduces [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — mutter doesn't honour the XWayland-side key grab, so the shortcut is focus-bound. On Ubuntu 24.04 GNOME, the [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406) KDE-only gate prevents the regressing patch from running, leaving the older (working) code path active — hence `🔧` on Ubu. The unsolved fix path is [S12](#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland).
|
||||
**Currently:** Fedora 43 GNOME Wayland reproduces [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) on the default (XWayland) path — mutter doesn't honour the XWayland-side key grab, so the shortcut is focus-bound. The fix is opt-in: launch with `CLAUDE_USE_WAYLAND=1` to use native Wayland + the XDG GlobalShortcuts portal (see [S12](#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland)), which mutter honours on **GNOME ≤ 49**. GNOME Wayland is not auto-flipped (rendering risk; GNOME 50 portal route is a no-op upstream). Re-verify on a GNOME Wayland host.
|
||||
|
||||
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)
|
||||
**Code anchors:** project `scripts/launcher-common.sh:96-99` (XWayland-default `--ozone-platform=x11`); upstream `index.js:499416` (`globalShortcut.register`).
|
||||
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (native Wayland opt-in via `CLAUDE_USE_WAYLAND=1`; only Niri auto-forced) and `build_electron_args` (native-Wayland `GlobalShortcutsPortal` feature); upstream `index.js:499416` (`globalShortcut.register`).
|
||||
|
||||
## S12 — `--enable-features=GlobalShortcutsPortal` launcher flag wired up for GNOME Wayland
|
||||
|
||||
@@ -143,20 +143,18 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
**Issues:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
|
||||
|
||||
**Steps:**
|
||||
1. On GNOME Wayland, launch the app.
|
||||
1. On GNOME Wayland, launch the app with `CLAUDE_USE_WAYLAND=1`.
|
||||
2. Inspect the Electron command line via `pgrep -af claude-desktop` — look for `--enable-features=GlobalShortcutsPortal`.
|
||||
3. Test Quick Entry shortcut from unfocused state (see [T06](#t06--quick-entry-global-shortcut-unfocused)).
|
||||
|
||||
**Expected:** Launcher detects GNOME Wayland and appends `--enable-features=GlobalShortcutsPortal` to Electron's argv, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs. Once wired, [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) is closeable.
|
||||
**Expected:** With `CLAUDE_USE_WAYLAND=1`, the launcher uses native Wayland and emits `GlobalShortcutsPortal` inside a single merged `--enable-features=…` switch, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404); GNOME is not auto-flipped — the portal route is opt-in). Note the flag is comma-joined with `UseOzonePlatform,WaylandWindowDecorations`, so match the `GlobalShortcutsPortal` subkey, not an exact `--enable-features=GlobalShortcutsPortal` token.
|
||||
|
||||
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f electron)/cmdline | tr '\0' ' '`), launcher log, `XDG_CURRENT_DESKTOP`.
|
||||
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f 'app\.asar')/cmdline | tr '\0' ' '`), launcher log (expect `Using native Wayland backend (global shortcuts via XDG portal)`), `XDG_CURRENT_DESKTOP`.
|
||||
|
||||
**Currently:** Not yet implemented. Tracking under [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404).
|
||||
|
||||
> **⚠ Missing in build 1.5354.0** — `--enable-features=GlobalShortcutsPortal` is not appended by `scripts/launcher-common.sh` for any GNOME Wayland variant. Re-verify after next upstream bump and after #404 lands.
|
||||
**Currently:** Launcher side implemented — `build_electron_args` adds `GlobalShortcutsPortal` to the native-Wayland feature set (opt-in via `CLAUDE_USE_WAYLAND=1`; GNOME is not auto-flipped). The flag is verified present in argv on that opt-in path (this case launches with `CLAUDE_USE_WAYLAND=1` and passes). Functional global-from-unfocused works on **GNOME ≤ 49** (first registration shows a one-time portal permission dialog). On **GNOME 50 / xdg-desktop-portal ≥ 1.20** it does not yet fire: Electron/Chromium never performs the portal's host `Registry.Register` app-id handshake, so `globalShortcut.register()` returns `false` and the portal is never contacted. Proven via D-Bus capture + a Python portal client; filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875).
|
||||
|
||||
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
|
||||
**Code anchors:** project `scripts/launcher-common.sh:59-112` (`build_electron_args` — no `GlobalShortcutsPortal` branch present).
|
||||
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (tri-state `CLAUDE_USE_WAYLAND` override) + `build_electron_args` (merged `enable_features` array). See [`wayland-global-shortcuts-portal.md`](../../learnings/wayland-global-shortcuts-portal.md).
|
||||
|
||||
## S14 — Global shortcuts via XDG portal work on Niri
|
||||
|
||||
@@ -177,7 +175,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
**Currently:** `Failed to call BindShortcuts (error code 5)` — portal global shortcuts fail on Niri. Different root cause from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), same user-visible symptom (Quick Entry shortcut doesn't fire). Not yet filed.
|
||||
|
||||
**References:** —
|
||||
**Code anchors:** project `scripts/launcher-common.sh:41-44` (Niri force-native-Wayland branch); upstream `index.js:499416` (`globalShortcut.register`, which on native Wayland routes through Electron's `xdg-desktop-portal` `BindShortcuts` path inside Chromium).
|
||||
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (Niri force-native-Wayland branch) + `build_electron_args` (native-Wayland `GlobalShortcutsPortal` feature, which Niri now also receives); upstream `index.js:499416` (`globalShortcut.register`, which on native Wayland routes through Electron's `xdg-desktop-portal` `BindShortcuts` path inside Chromium). wlroots' portal ships no GlobalShortcuts backend, so `BindShortcuts` still fails until that lands — this stays a known-failing detector.
|
||||
|
||||
## S29 — Quick Entry popup is created lazily on first shortcut press (closed-to-tray sanity)
|
||||
|
||||
@@ -335,7 +333,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
|
||||
**Diagnostics on failure:** `xrandr` (X11) / `wlr-randr` (wlroots) output before and after disconnect, captured popup coordinates, screenshot.
|
||||
|
||||
**Skip when:** Single-monitor VM or host. Not part of the [§ Mandatory matrix](../quick-entry-closeout.md#mandatory-matrix); skip with `-` in the dashboard.
|
||||
**Skip when:** Single-monitor VM or host. Skip with `-` in the dashboard.
|
||||
|
||||
**References:** upstream `index.js:515502`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515502 (`return cHn();` early-return when no saved position), 515523-515527 (`cHn()` centres popup on `screen.getPrimaryDisplay()` workArea), 515514-515515 (`label`-only match fallback before primary-display fallback).
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
# lib/claudeai.ts AX-tree migration — implementation prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
self-correction loop depends on the exact directives below.
|
||||
|
||||
---
|
||||
|
||||
## Prompt to paste
|
||||
|
||||
You're picking up after the v7 fingerprint walker + U01 wire-up
|
||||
landed. Walker, resolver, and U01 are all on the AX-tree substrate.
|
||||
The page-object library `tools/test-harness/src/lib/claudeai.ts` is
|
||||
still on the old substrate — `document.querySelector` against
|
||||
minified-tailwind class shapes (`button[aria-haspopup="menu"]` +
|
||||
`span.truncate.max-w-[Npx]`) — and that's where every claude.ai UI
|
||||
spec couples to upstream's React DOM. Your job is to migrate the
|
||||
brittle CSS-shape walks in `claudeai.ts` to AX-tree resolution using
|
||||
the v7 walker primitives, run the H/S spec families that consume
|
||||
them, and iterate until those specs pass without DOM-shape coupling.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order. They contain the design, the gotchas, and the
|
||||
runtime contract — the prompt below assumes them as background.
|
||||
|
||||
- `docs/testing/fingerprint-v7-plan.md` — design contract for the v7
|
||||
fingerprint, kind-strictness matrix, resolver fallback chain. Skim
|
||||
the "Capture algorithm" and "Resolver / fallback chain" sections;
|
||||
the migration consumes the same primitives.
|
||||
- `docs/learnings/test-harness-ax-tree-walker.md` — the five
|
||||
non-obvious AX-tree traps (AX-enable async lag, navigateTo no-op,
|
||||
flat dialog>button[] lists, more-options shape, sidebar
|
||||
virtualization). All apply here too — `lib/claudeai.ts` calls run
|
||||
inside the same renderer the walker drives.
|
||||
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
|
||||
~340 lines, eight functions plus two classes (`CodeTab`,
|
||||
`LocalEnvPill`). Every public function is a discovery walk against
|
||||
`evalInRenderer` with `document.querySelectorAll`.
|
||||
|
||||
### Why this iteration
|
||||
|
||||
Per the v7 plan's design goal §2 "Resilient to cosmetic drift" —
|
||||
upstream regenerates tailwind class signatures on rebuild
|
||||
(`max-w-[Npx]`, `df-pill`-style atoms), so `claudeai.ts`'s CSS-shape
|
||||
walks break on any minor UI rebuild even when the AX-computed role
|
||||
and accessible name are stable. The U01 wire-up confirmed the AX
|
||||
tree is a usable substrate end-to-end (~7s/test, 89/90 stable across
|
||||
two consecutive sweeps). Pulling `claudeai.ts` onto the same
|
||||
substrate eliminates the recurring "tailwind regen breaks H05/S31
|
||||
again" failure mode.
|
||||
|
||||
Acceptance per the plan: H05 + S29-S37 + T-prefix specs that consume
|
||||
`claudeai.ts` keep passing on the same account, with zero new
|
||||
flakes. Migration is mechanical (replace the eval-string walks with
|
||||
AX-tree queries) and the existing tests are the contract.
|
||||
|
||||
### Repo conventions
|
||||
|
||||
- Tabs for indentation, lines under 80 chars, single quotes for
|
||||
literals, TypeScript strict mode (`tools/test-harness/tsconfig.json`
|
||||
enforces it).
|
||||
- Comments only when the WHY is non-obvious — write the `because:`
|
||||
clause, not the `that:` clause.
|
||||
- No backward-compatibility shims. If a function's signature needs
|
||||
to change, change every caller. Don't keep both code paths.
|
||||
- Don't commit. The user reviews and commits.
|
||||
|
||||
### Code anchors
|
||||
|
||||
- `tools/test-harness/explore/walker.ts` — exports the primitives
|
||||
you'll consume:
|
||||
- `findByFingerprint(inspector, fingerprint, kind)` — full
|
||||
resolver with strictness gating + relaxed-scope fallback.
|
||||
Overkill for one-shot lookups against the live renderer.
|
||||
- `queryAccessibleTree(elements, query)` — pure filter, used at
|
||||
capture and resolve time. Takes a `RawElement[]` snapshot and
|
||||
an `AxQuery` (ariaPath + leaf criteria). What you'll likely
|
||||
wrap.
|
||||
- `axTreeToSnapshot(nodes)` — converts CDP `AxNode[]` to the
|
||||
walker's `RawElement[]` shape. Drops ignored nodes.
|
||||
- `walkLandmarkAncestors(raw)` — emits the AriaStep[] for an
|
||||
element. Useful if a method needs to disambiguate by landmark.
|
||||
- `waitForAxTreeStable(inspector, opts)` — gating primitive used
|
||||
by walker + U01. Use `{ minNodes: 1, timeoutMs: 10000 }` for
|
||||
post-click reads (matches `snapshotSurface`'s default).
|
||||
- `tools/test-harness/src/lib/inspector.ts` — `getAccessibleTree`
|
||||
fetches the raw CDP tree filtered to the claude.ai webContents.
|
||||
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
|
||||
Read the file-header comment first; it documents the discovery
|
||||
strategy you're replacing.
|
||||
- `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`,
|
||||
`S31_quick_entry_submit_reaches_new_chat.spec.ts`,
|
||||
`S32_quick_entry_submit_gnome_stale_isfocused.spec.ts` — primary
|
||||
consumers of the methods being migrated.
|
||||
|
||||
### Phases
|
||||
|
||||
#### Phase A — spike on one method
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — must pass before
|
||||
doing anything.
|
||||
2. Pick `openPill(inspector, labelPattern, opts)` as the spike.
|
||||
It's the most CSS-shape-coupled method and exercises the
|
||||
menu-render polling pattern the rest of `claudeai.ts` reuses.
|
||||
3. Replace its body with an AX-tree query:
|
||||
- Fetch the AX tree (`inspector.getAccessibleTree('claude.ai')`),
|
||||
convert via `axTreeToSnapshot`.
|
||||
- Filter to elements with `computedRole === 'button'` and
|
||||
accessibleName matching `labelPattern`.
|
||||
- For each candidate, compute its parent landmark via
|
||||
`walkLandmarkAncestors`. The compact-pill discriminator —
|
||||
"has a `span.truncate.max-w-[Npx]` child" — needs an AX
|
||||
analogue. Most likely: parent is `toolbar` / `group` and the
|
||||
element has `aria-haspopup === 'menu'` (exposed in AX as
|
||||
`hasPopup` property; check whether `RawElement` carries it
|
||||
and extend if needed).
|
||||
- Click via `inspector.clickByBackendNodeId(raw.backendDOMNodeId)`.
|
||||
- Poll for menu items via AX role match (`menuitem`,
|
||||
`menuitemradio`, `menuitemcheckbox`).
|
||||
4. Run H05 against your branch (`./node_modules/.bin/playwright
|
||||
test src/runners/H05_ui_drift_check.spec.ts`). H05 doesn't
|
||||
directly call `openPill` but exercises the same renderer state;
|
||||
if H05 regresses your AX walk is wrong.
|
||||
5. Run S31 (`./node_modules/.bin/playwright test
|
||||
src/runners/S31_quick_entry_submit_reaches_new_chat.spec.ts`).
|
||||
This calls `openPill` indirectly via `CodeTab.activate` →
|
||||
`findCompactPills`.
|
||||
6. If both pass, the AX substrate works for at least one method.
|
||||
Commit the shape mentally (don't `git commit` — the user does
|
||||
that). If either fails, the spike is in trouble; re-read the
|
||||
AX-tree learnings doc for traps you missed and fix the
|
||||
primitive before expanding.
|
||||
|
||||
#### Phase B — migrate the rest
|
||||
|
||||
For each remaining function in `claudeai.ts`, port the discovery
|
||||
walk to AX:
|
||||
|
||||
- `activateTab(inspector, name)` — `button` with
|
||||
`accessibleName === name` under root or banner landmark. Existing
|
||||
`aria-label="X"` selector → AX `name` literal match.
|
||||
- `findCompactPills(inspector)` — list of buttons with
|
||||
`hasPopup === 'menu'` AND inner `span.truncate.max-w-[…]` text
|
||||
child. AX equivalent: button role + hasPopup + a child
|
||||
`genericContainer` (or whatever AX exposes for `<span>`) carrying
|
||||
the visible text. Returns `{text, maxW, expanded}` today —
|
||||
`maxW` is a tailwind artifact and should be dropped from the AX
|
||||
shape (callers don't use it for matching, just for diagnostics;
|
||||
keep a placeholder or remove from the type).
|
||||
- `clickMenuItem(inspector, textPattern, opts)` — element with
|
||||
role in `{menuitem, menuitemradio, menuitemcheckbox}` and
|
||||
accessibleName matching `textPattern`. The CSS attribute selector
|
||||
has an AX direct equivalent.
|
||||
- `pressEscape(inspector)` — keep as-is. It's a keydown dispatch,
|
||||
not a discovery walk.
|
||||
- `CodeTab.activate(opts)` — calls `activateTab` + polls
|
||||
`findCompactPills`. Migrates by transitivity.
|
||||
- `LocalEnvPill` — read its body to enumerate callers.
|
||||
|
||||
After each migration:
|
||||
1. `npm run typecheck` — must pass.
|
||||
2. `npx tsx explore/walker.ts` — selfTest must pass (you may have
|
||||
touched walker.ts to expose new primitives).
|
||||
3. Run the affected spec(s).
|
||||
|
||||
#### Phase C — full sweep
|
||||
|
||||
1. Run all H/S/T runners that consume `claudeai.ts`:
|
||||
- H05 (UI drift)
|
||||
- S31 (Code-tab submit)
|
||||
- S32 (GNOME stale isFocused)
|
||||
- any T-prefix that uses `installOpenDialogMock` or `pressEscape`
|
||||
2. Tally pass/fail. The post-migration baseline must equal the
|
||||
pre-migration baseline, modulo flakes characterized in
|
||||
`docs/learnings/test-harness-ax-tree-walker.md`.
|
||||
|
||||
Cap iterations at **5 sweep cycles** total (spike + 4 fix-rerun
|
||||
cycles) — past that, stop and report.
|
||||
|
||||
##### Failure classes
|
||||
|
||||
1. **AX-shape mismatch.** Element has the CSS shape the old code
|
||||
relied on but a different AX role/name than expected. Fix:
|
||||
probe the AX tree for the actual shape (use
|
||||
`inspector.getAccessibleTree('claude.ai')` interactively from a
|
||||
one-shot script), update the AX query.
|
||||
2. **Missing AX property exposure.** `hasPopup`, `expanded`, etc.
|
||||
may not be in `RawElement` today (the walker only reads role,
|
||||
name, ancestors, sibling info). Extend `RawElement` and
|
||||
`axTreeToSnapshot` to expose what the migration needs. Update
|
||||
walker.ts selfTest if you change the snapshot shape.
|
||||
3. **Race against menu render.** Old code polled
|
||||
`document.querySelectorAll('[role=menuitem]')` every 50ms. AX
|
||||
tree updates lag DOM by hundreds of ms; bake a
|
||||
`waitForAxTreeStable({ minNodes: 1 })` between click and
|
||||
menuitem fetch instead of a short DOM poll.
|
||||
4. **Tailwind-class diagnostic loss.** `findCompactPills` returns
|
||||
`maxW` which callers use only in error messages. If the
|
||||
AX-only return shape drops `maxW`, error messages get less
|
||||
informative — accept it, don't reintroduce DOM walks just for
|
||||
diagnostics. Keep the `maxW` field optional/null in the type.
|
||||
|
||||
##### What "fix" means
|
||||
|
||||
A fix is one of:
|
||||
- A code change in `claudeai.ts`, `walker.ts`, or `inspector.ts`.
|
||||
- A targeted extension of `RawElement` / `axTreeToSnapshot` to
|
||||
expose an AX property the migration needs.
|
||||
|
||||
Not a fix:
|
||||
- `// eslint-disable-next-line` / `// @ts-ignore` / `as unknown as ...`.
|
||||
- Keeping the old `document.querySelector` walk as a fallback.
|
||||
- Adding an AX walk that wraps a CSS walk that wraps an AX walk.
|
||||
|
||||
### Self-correction loop (general protocol)
|
||||
|
||||
After each phase's specific loop:
|
||||
|
||||
1. If `npm run typecheck` reports errors, fix root causes — no
|
||||
`// @ts-ignore`, no `any`, no `as unknown as ...`.
|
||||
2. If `npx tsx explore/walker.ts` (selfTest) fails, the change broke
|
||||
an algorithmic invariant. Don't relax the test; fix the change.
|
||||
3. **Cap fix attempts per problem class at 3.** After 3 attempts
|
||||
on the same class without progress, stop and report.
|
||||
4. Mark Phase complete only when every step in that Phase passes
|
||||
cleanly.
|
||||
|
||||
### Termination conditions
|
||||
|
||||
Stop and write a final report when one of:
|
||||
|
||||
1. **Migration is clean.** All `claudeai.ts` methods on AX
|
||||
substrate, all consuming specs pass at the pre-migration
|
||||
baseline. Report final pass tallies + diff stat.
|
||||
2. **Hit the 5-sweep cap.** Report what's done, what's blocked,
|
||||
and what each remaining failure looks like.
|
||||
3. **Hit the 3-attempt cap on a non-trivial issue.** Report
|
||||
attempts, why each failed, what's blocked.
|
||||
4. **AX exposure gap.** A claude.ai surface uses a property the AX
|
||||
tree doesn't expose (e.g., custom `data-state` attributes
|
||||
without a corresponding ARIA reflection). Stop, document the
|
||||
gap, ask the user before adding a hybrid AX+DOM walk.
|
||||
|
||||
### What you should NOT do
|
||||
|
||||
- Don't commit. The user reviews everything.
|
||||
- Don't keep both substrates. The migration is atomic per method:
|
||||
CSS walk out, AX walk in. No fallback chains.
|
||||
- Don't add new abstractions in `claudeai.ts` that aren't required
|
||||
by the migration. The file's shape (one function per UI verb) is
|
||||
load-bearing for callers — don't introduce a `PageObject` base
|
||||
class or a generic AX builder.
|
||||
- Don't run the host Claude Desktop. The user runs it. The H/S
|
||||
specs use `launchClaude` with `seedFromHost` or `null` isolation
|
||||
per spec — confirm with the user before any sweep.
|
||||
- Don't widen `RawElement` speculatively. Only add fields the
|
||||
migration consumes. Each new field bloats every snapshot.
|
||||
- Don't drill into a single-method workaround that other methods
|
||||
would have to duplicate. If a fix wants to live in a helper,
|
||||
put it next to `queryAccessibleTree` in `walker.ts`.
|
||||
|
||||
### Final report format
|
||||
|
||||
```markdown
|
||||
## Migration summary
|
||||
|
||||
- Functions migrated: N / N
|
||||
- Walker.ts changes: <one-line summary>
|
||||
- Inspector.ts changes: <one-line summary or none>
|
||||
- H/S/T specs run: N
|
||||
- H/S/T specs passed: N
|
||||
- New flakes introduced: N (description)
|
||||
|
||||
## Iteration log
|
||||
|
||||
### Spike — openPill
|
||||
- Result: ...
|
||||
- AX shape used: ...
|
||||
- Issues hit: ...
|
||||
|
||||
### Phase B — remaining methods
|
||||
- One block per method ...
|
||||
|
||||
### Phase C — full sweep
|
||||
- Per-spec pass/fail tally
|
||||
- Diff against pre-migration baseline
|
||||
|
||||
## Open issues
|
||||
- ...
|
||||
|
||||
## Files touched
|
||||
git status output
|
||||
|
||||
## Diff for review
|
||||
git diff --stat output
|
||||
```
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Background runs: use `Bash run_in_background: true` for any
|
||||
multi-spec sweep, and `Monitor` with a tight grep filter
|
||||
(`✓|✘|Error|FAIL|EXIT=`) to stream events. Stop the monitor when
|
||||
the run completes.
|
||||
- Check for leftover Electron processes between runs
|
||||
(`pgrep -af '/usr/lib/claude-desktop/node_modules/electron'`)
|
||||
and stale tmpdirs (`ls /tmp/claude-test-*`) — clean both up if
|
||||
the prior run errored before teardown.
|
||||
- The U01 wire-up landed two `walker.ts` fixes that are part of
|
||||
the substrate you're inheriting:
|
||||
1. `findByFingerprint`: strictness gate also defers to
|
||||
`fingerprint.classification === 'instance'` for degenerate
|
||||
fingerprints.
|
||||
2. `redrivePath`: navigates to startUrl when current URL drifted;
|
||||
reloads only when already at startUrl.
|
||||
Both are live in the working tree (or just-merged main,
|
||||
depending on when this prompt fires).
|
||||
|
||||
Begin with Phase A. Read `claudeai.ts` end-to-end first — in
|
||||
particular the file-header discovery comment (lines 1-31) and the
|
||||
`openPill` body (lines 162-202) — so you understand what the
|
||||
existing CSS-shape walks are anchoring on before you replace them.
|
||||
@@ -1,218 +0,0 @@
|
||||
# claude.ai UI Map
|
||||
|
||||
*Last updated: 2026-05-02*
|
||||
|
||||
This file is the index from "UI surface" → "test-harness abstraction." It
|
||||
answers: *which renderer surface does each Layer-2 helper cover, and where
|
||||
are the gaps?* For human-readable behavior and visual specs of each surface
|
||||
(what each button looks like, what each menu does), see [`ui/`](./ui/).
|
||||
For the architectural rationale and growth strategy of the wrapper, see
|
||||
[`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md).
|
||||
|
||||
A `✓` marker means the helper exists today, with a `file:line` reference
|
||||
into [`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts).
|
||||
A `TODO` marker is a planned helper — when a third test needs the same
|
||||
shape, promote it from inline `evalInRenderer` to a top-level helper or
|
||||
page-object method (see plan Phase 3).
|
||||
|
||||
## Top-level routes
|
||||
|
||||
- `/new` — chat composer page (default landing for signed-in users)
|
||||
- `/chat/<uuid>` — open chat session
|
||||
- `/epitaxy` — Code tab landing
|
||||
- `/projects/<id>` — project view
|
||||
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
|
||||
|
||||
The Code df-pill click does **not** change the URL — the router rerenders
|
||||
the tab body inline. Helpers must poll for body-mount signals (e.g. a
|
||||
compact pill rendering) rather than waiting on navigation.
|
||||
|
||||
## Surfaces by tab
|
||||
|
||||
### Chat (df-pill "Chat", route /new)
|
||||
|
||||
UI reference: [`ui/prompt-area.md`](./ui/prompt-area.md),
|
||||
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Composer textarea — TODO `ChatTab.composer()`
|
||||
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
|
||||
— TODO `ChatTab.openAttachMenu()`
|
||||
- Slash menu (triggered by typing `/`) — TODO `ChatTab.openSlashMenu()`
|
||||
- Model picker — TODO `ChatTab.openModelPicker()`
|
||||
- Permission mode picker — TODO `ChatTab.openPermissionPicker()`
|
||||
- Effort picker — TODO
|
||||
- Send button — TODO `ChatTab.send()`
|
||||
- Stop button (replaces Send while responding) — TODO `ChatTab.stop()`
|
||||
- Attachment chip / drag-drop overlay — TODO
|
||||
- Usage ring — TODO
|
||||
|
||||
### Cowork (df-pill "Cowork")
|
||||
|
||||
UI reference: see ghost-icon row in
|
||||
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md). No
|
||||
dedicated surface doc yet — the ghost icon is the canonical "topbar shim
|
||||
alive" indicator and the tab body itself is largely undocumented at the
|
||||
time of writing.
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Workspace list — TODO `CoworkTab.listWorkspaces()`
|
||||
- Environment switcher — TODO `CoworkTab.switchEnvironment()`
|
||||
- Dispatch state indicator — TODO
|
||||
|
||||
### Code (df-pill "Code", route /epitaxy)
|
||||
|
||||
UI reference: [`ui/code-tab-panes.md`](./ui/code-tab-panes.md),
|
||||
[`ui/sidebar.md`](./ui/sidebar.md),
|
||||
[`ui/prompt-area.md`](./ui/prompt-area.md).
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Tab activation + body-mount wait — `lib/claudeai.ts:CodeTab.activate` (:285) ✓
|
||||
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill` (:317) ✓
|
||||
- Local env selection — `lib/claudeai.ts:CodeTab.selectLocal` (:350) ✓
|
||||
- Select-folder pill (rendered after Local) — used internally by
|
||||
`lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
|
||||
- Folder picker dialog (full chain) — `lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
|
||||
- Folder picker dialog mock + assertion — `lib/claudeai.ts:installOpenDialogMock`
|
||||
(:70) ✓ + `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
|
||||
- File tree (left panel) — TODO `CodeTab.fileTree()`
|
||||
- Editor pane — TODO `CodeTab.editor()`
|
||||
- Diff pane — TODO `CodeTab.openDiff()`
|
||||
- Preview pane — TODO `CodeTab.openPreview()`
|
||||
- Integrated terminal — TODO `CodeTab.openTerminal()`
|
||||
- Tasks / subagent / plan panes — TODO
|
||||
- Side-chat — TODO `CodeTab.openSideChat()`
|
||||
- Recent-folder selection (radio in Select-folder menu) — TODO
|
||||
|
||||
## Surfaces independent of tab
|
||||
|
||||
### Sidebar
|
||||
|
||||
UI reference: [`ui/sidebar.md`](./ui/sidebar.md).
|
||||
|
||||
- Search overlay (topbar Search icon) — TODO `SidebarNav.search()`
|
||||
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
|
||||
- "More options" per row — TODO `SidebarNav.rowContextMenu(uuid)`
|
||||
- "+ New session" button — TODO `SidebarNav.newSession()`
|
||||
- Routines link — TODO `SidebarNav.openRoutines()`
|
||||
- Customize link — TODO `SidebarNav.openCustomize()`
|
||||
- Status / project / environment filters — TODO
|
||||
- Group-by control — TODO
|
||||
- Collapse toggle — TODO
|
||||
|
||||
### Window chrome / topbar (in-app hybrid)
|
||||
|
||||
UI reference: [`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
|
||||
|
||||
- Hamburger menu — TODO `Topbar.openHamburger()`
|
||||
- Sidebar toggle — TODO `Topbar.toggleSidebar()`
|
||||
- Back / forward arrows — TODO
|
||||
- Cowork ghost icon (topbar-alive sentinel) — TODO `Topbar.coworkGhostPresent()`
|
||||
|
||||
### Native dialogs
|
||||
|
||||
- File / folder picker mock — `lib/claudeai.ts:installOpenDialogMock` (:70) ✓
|
||||
- File / folder picker call inspection — `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
|
||||
- Message box / confirm — TODO `installShowMessageBoxMock`
|
||||
- Save dialog — TODO `installShowSaveDialogMock`
|
||||
|
||||
### Menus / popovers
|
||||
|
||||
- Compact-pill discovery — `lib/claudeai.ts:findCompactPills` (:130) ✓
|
||||
- Compact-pill open + menu read — `lib/claudeai.ts:openPill` (:162) ✓
|
||||
- Click any menuitem by text regex — `lib/claudeai.ts:clickMenuItem` (:210) ✓
|
||||
- Dismiss popover via Escape — `lib/claudeai.ts:pressEscape` (:256) ✓
|
||||
- Modal dismiss / confirm — TODO `Modal.dismiss()` / `Modal.confirm()`
|
||||
- Toast / status — TODO `waitForToast(regex)`
|
||||
- Right-click context menus (sidebar row, etc.) — TODO `openContextMenu(target)`
|
||||
|
||||
### Settings
|
||||
|
||||
UI reference: [`ui/settings.md`](./ui/settings.md).
|
||||
|
||||
- Open Settings — TODO `Settings.open()`
|
||||
- Hotkey rebind — TODO `Settings.rebindHotkey(action, chord)`
|
||||
- Theme toggle — TODO `Settings.setTheme('dark' | 'light' | 'auto')`
|
||||
- Account / sign-out — TODO `Settings.signOut()`
|
||||
- Computer-use toggle (absent on Linux per S22) — TODO
|
||||
- Keep-computer-awake toggle (per S20) — TODO
|
||||
|
||||
### Routines page
|
||||
|
||||
UI reference: [`ui/routines-page.md`](./ui/routines-page.md).
|
||||
|
||||
- Routines list — TODO `RoutinesPage.list()`
|
||||
- New-routine form — TODO `RoutinesPage.create(spec)`
|
||||
- Routine detail page — TODO `RoutinesPage.open(id)`
|
||||
|
||||
### Connectors and plugins
|
||||
|
||||
UI reference: [`ui/connectors-and-plugins.md`](./ui/connectors-and-plugins.md).
|
||||
|
||||
- Connector picker — TODO `ConnectorPicker.open()`
|
||||
- Connector list / status — TODO
|
||||
- Plugin browser — TODO `PluginBrowser.open()`
|
||||
- Plugin install (Anthropic & Partners flow) — TODO `PluginBrowser.install(slug)`
|
||||
- Plugin manager (installed list) — TODO
|
||||
|
||||
### Quick Entry popup
|
||||
|
||||
UI reference: [`ui/quick-entry.md`](./ui/quick-entry.md). Note: the
|
||||
Quick Entry harness lives in [`quickentry.ts`](../../tools/test-harness/src/lib/quickentry.ts),
|
||||
not `claudeai.ts`. The `installOpenDialogMock` shape here intentionally
|
||||
mirrors `QuickEntry.installInterceptor` (quickentry.ts:86) — keep them
|
||||
aligned when extending either.
|
||||
|
||||
- Open Quick Entry (global shortcut) — covered by `lib/quickentry.ts`
|
||||
- Compose + send — covered by `lib/quickentry.ts`
|
||||
- Closeout cases (S29–S37) — 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##)
|
||||
@@ -1,415 +0,0 @@
|
||||
# claude.ai UI Mapping Plan
|
||||
|
||||
This is an executable plan for systematically mapping claude.ai's
|
||||
renderer UI into reusable test-harness abstractions. It can be picked
|
||||
up by a fresh session — start at "Phase 1" and walk down.
|
||||
|
||||
## Where we are
|
||||
|
||||
The harness already has one worked example: `tools/test-harness/src/lib/claudeai.ts`
|
||||
exports a `CodeTab` class plus atom helpers (`activateTab`,
|
||||
`installOpenDialogMock`, `findCompactPills`, `openPill`, `clickMenuItem`,
|
||||
`pressEscape`). `T17_folder_picker.spec.ts` is its only consumer
|
||||
today — drives the chain `Code df-pill → env pill → Local → Select
|
||||
folder → Open folder` and asserts `dialog.showOpenDialog` fires.
|
||||
|
||||
Discovery evidence captured by `tools/test-harness/probe.ts` (run
|
||||
against a live debugger on port 9229):
|
||||
|
||||
- df-pill is a stable atom — exactly 3 instances on Code-tab page
|
||||
(`Chat`, `Cowork`, `Code`), all with `class*="df-pill"` and
|
||||
matching `aria-label`.
|
||||
- compact-pill is a stable atom — `button[aria-haspopup=menu]` with
|
||||
a `span.truncate.max-w-[Npx]` child. Env pill uses 200px,
|
||||
Select-folder pill uses 160px. Same Tailwind class signature; we
|
||||
anchor on structure, not classes.
|
||||
- 80 `button[aria-haspopup=menu]` total on a Code-tab page; only the
|
||||
2 with the truncate fingerprint are pills, the other 78 are sidebar
|
||||
"More options" buttons.
|
||||
|
||||
Pattern proven: discovery-by-shape in the lib layer, page-object
|
||||
classes per major UI surface, specs use the lib. This doc covers
|
||||
how to extend that pattern across the rest of claude.ai.
|
||||
|
||||
## Strategy: three layers
|
||||
|
||||
**Layer 1 — atoms.** Generic helpers around stable structural
|
||||
patterns. Live in `lib/claudeai.ts`. Built once, reused everywhere.
|
||||
Examples already there: compact-pill, df-pill, menu, dialog mock.
|
||||
|
||||
**Layer 2 — page objects.** Domain classes per major UI surface
|
||||
(CodeTab, ChatTab, Settings, etc.). Compose atoms. Built per test
|
||||
demand — premature otherwise. CodeTab is the template.
|
||||
|
||||
**Layer 3 — discovery tooling.** Standalone scripts that connect to
|
||||
a running debugger and let humans + agents explore the renderer.
|
||||
`probe.ts` is the seed; this doc grows it into a small CLI.
|
||||
|
||||
The thing to avoid: comprehensively mapping the UI upfront. Even
|
||||
with a recording tool, that burns time on surfaces no test will
|
||||
exercise for months. Lazy + bookmark-the-shape wins.
|
||||
|
||||
## Phase 1 — Tooling foundation
|
||||
|
||||
**Goal:** turn `probe.ts` into a proper exploration CLI under
|
||||
`tools/test-harness/explore/`, with snapshot + diff capability that
|
||||
catches UI drift before tests do.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `tools/test-harness/explore/explore.ts` — entry point with
|
||||
subcommands.
|
||||
- `tools/test-harness/explore/snapshot.ts` — capture renderer state.
|
||||
- `tools/test-harness/explore/diff.ts` — compare two snapshots.
|
||||
- `tools/test-harness/explore/find.ts` — search for elements.
|
||||
- `docs/testing/ui-snapshots/` — directory for captured snapshots
|
||||
(gitignore the file contents but commit the directory + a README).
|
||||
- `tools/test-harness/package.json` — add scripts:
|
||||
`npm run explore`, `npm run explore:snapshot <name>`, etc.
|
||||
|
||||
**Subcommand spec:**
|
||||
|
||||
```
|
||||
npx tsx explore/explore.ts # full snapshot to stdout
|
||||
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
|
||||
npx tsx explore/explore.ts menu # currently-open menu structure
|
||||
npx tsx explore/explore.ts snapshot <name> # write to docs/testing/ui-snapshots/<name>.json
|
||||
npx tsx explore/explore.ts diff <a> <b> # diff two snapshots — flags renamed/removed
|
||||
npx tsx explore/explore.ts find <regex> # search renderer for matching text/aria-label
|
||||
```
|
||||
|
||||
Snapshot shape (per file):
|
||||
|
||||
```json
|
||||
{
|
||||
"capturedAt": "2026-05-02T17:30:00Z",
|
||||
"claudeAiUrl": "https://claude.ai/epitaxy",
|
||||
"appVersion": "1.1.7714",
|
||||
"dfPills": [...],
|
||||
"compactPills": [...],
|
||||
"ariaLabeledButtons": [...],
|
||||
"openMenu": null,
|
||||
"modals": [...]
|
||||
}
|
||||
```
|
||||
|
||||
`diff` should flag: removed elements (selector → no match), changed
|
||||
text/aria-label, new elements (informational, not a failure). Output
|
||||
human-readable + a `--json` flag for machine consumption.
|
||||
|
||||
**How to dispatch this work:**
|
||||
|
||||
Single agent, `general-purpose`. Brief:
|
||||
|
||||
> Build the explore CLI under `tools/test-harness/explore/`. Read
|
||||
> `tools/test-harness/probe.ts` as the seed implementation. Match the
|
||||
> existing project style (tabs, multi-line `//` why-blocks, terse).
|
||||
> Reuse `src/lib/inspector.ts` (`InspectorClient.connect(9229)`) for
|
||||
> the debugger connection. Subcommands as specified in
|
||||
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 1. Do not delete
|
||||
> probe.ts — leave it as a one-off; it can be removed in a follow-up.
|
||||
> Typecheck with `npx tsc --noEmit` (no test runs). Add npm scripts
|
||||
> to `package.json`. Add a thin README in
|
||||
> `docs/testing/ui-snapshots/README.md` explaining how to capture +
|
||||
> compare snapshots.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- `npx tsx explore/explore.ts pills` against a running debugger lists
|
||||
the 3 df-pills and 2 compact-pills (or whatever's on screen).
|
||||
- `explore/explore.ts snapshot baseline-code-tab` writes a JSON file.
|
||||
- `explore/explore.ts diff baseline-code-tab baseline-code-tab`
|
||||
reports zero diffs.
|
||||
- Typecheck green.
|
||||
|
||||
## Phase 2 — UI map document
|
||||
|
||||
**Goal:** maintain a living markdown index of every reachable UI
|
||||
surface, the navigation path to reach it, and which Layer-2 class
|
||||
covers it (or `TODO` if none yet).
|
||||
|
||||
**Deliverable:** `docs/testing/claudeai-ui-map.md`.
|
||||
|
||||
**Initial content** (populate from what's known today, leave gaps
|
||||
marked TODO):
|
||||
|
||||
```markdown
|
||||
# claude.ai UI Map
|
||||
|
||||
Source of truth for "where does each UI surface live, and which
|
||||
test-harness abstraction covers it." Update as new abstractions are
|
||||
added.
|
||||
|
||||
## Top-level routes
|
||||
|
||||
- `/new` — chat composer page (default landing for signed-in users)
|
||||
- `/chat/<uuid>` — open chat session
|
||||
- `/epitaxy` — Code tab landing
|
||||
- `/projects/<id>` — project view
|
||||
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
|
||||
|
||||
## Surfaces by tab
|
||||
|
||||
### Chat (df-pill "Chat", route /new)
|
||||
- Composer textarea — TODO `ChatTab.composer()`
|
||||
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
|
||||
— TODO `ChatTab.openAttachMenu()`
|
||||
- Model selector — TODO
|
||||
- Stop / regenerate — TODO
|
||||
|
||||
### Cowork (df-pill "Cowork")
|
||||
- Workspace list — TODO
|
||||
- Environment switcher — TODO
|
||||
|
||||
### Code (df-pill "Code", route /epitaxy)
|
||||
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill()` ✓
|
||||
- Select folder pill — `lib/claudeai.ts:CodeTab` (used internally by
|
||||
`openFolderPicker`) ✓
|
||||
- Folder picker dialog — `lib/claudeai.ts:installOpenDialogMock` ✓
|
||||
- File tree (left panel) — TODO
|
||||
- Editor pane — TODO
|
||||
|
||||
## Surfaces independent of tab
|
||||
|
||||
### Sidebar
|
||||
- Search — TODO `SidebarNav.search()`
|
||||
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
|
||||
- "More options" per row — TODO
|
||||
- New session button — TODO
|
||||
|
||||
### Native dialogs
|
||||
- File / folder picker — `lib/claudeai.ts:installOpenDialogMock` ✓
|
||||
- Message box / confirm — TODO `installShowMessageBoxMock`
|
||||
- Save dialog — TODO `installShowSaveDialogMock`
|
||||
|
||||
### Menus / popovers
|
||||
- Generic menu open + click — `lib/claudeai.ts:openPill` /
|
||||
`clickMenuItem` ✓
|
||||
- Modal — TODO `Modal.dismiss() / Modal.confirm()`
|
||||
- Toast / status — TODO `waitForToast(regex)`
|
||||
|
||||
### Settings
|
||||
- Hotkey rebind — TODO
|
||||
- Theme toggle — TODO
|
||||
- Account / sign-out — TODO
|
||||
|
||||
## Atoms inventory
|
||||
|
||||
Stable structural patterns the lib already anchors on:
|
||||
|
||||
| Atom | Fingerprint | Helper |
|
||||
|---|---|---|
|
||||
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` |
|
||||
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills`, `openPill` |
|
||||
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` |
|
||||
|
||||
Atoms not yet abstracted (when a third test needs the same shape,
|
||||
promote to a top-level helper):
|
||||
|
||||
| Atom | Probable fingerprint | Status |
|
||||
|---|---|---|
|
||||
| modal | `[role=dialog]` | not seen yet |
|
||||
| toast | `[role=status][aria-live]` | not seen yet |
|
||||
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
|
||||
| chat composer | textarea/contenteditable in composer container | not abstracted |
|
||||
```
|
||||
|
||||
**How to dispatch this work:**
|
||||
|
||||
A claude-code-guide or general-purpose agent can write the initial
|
||||
file. Single message:
|
||||
|
||||
> Create `docs/testing/claudeai-ui-map.md` matching the structure in
|
||||
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 2. Pull TODO
|
||||
> entries from the planned ChatTab/Settings/etc. surfaces. Mark
|
||||
> existing helpers from `tools/test-harness/src/lib/claudeai.ts`
|
||||
> with ✓ and the file:line. Don't run any tests.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- File exists with all top-level routes documented.
|
||||
- Every existing `lib/claudeai.ts` export is referenced ✓.
|
||||
- Every planned surface from this plan has a TODO entry.
|
||||
|
||||
## Phase 3 — Page objects per test demand
|
||||
|
||||
**Goal:** add new Layer-2 classes (ChatTab, Settings, etc.) when the
|
||||
first test needs them. Don't speculate.
|
||||
|
||||
**Template:** `tools/test-harness/src/lib/claudeai.ts:CodeTab`. Match
|
||||
its shape:
|
||||
|
||||
- Instance class taking `inspector: InspectorClient` in constructor.
|
||||
- Public methods are either single-step (`openEnvPill`,
|
||||
`selectLocal`) or multi-step convenience (`openFolderPicker`).
|
||||
- Discovery by shape, not Tailwind classes.
|
||||
- Multi-line `//` why-block at top of class explaining what UI
|
||||
surface it covers and the discovery strategy.
|
||||
- Failures throw with enough context for the spec to attach to
|
||||
`testInfo.attach()`.
|
||||
|
||||
**Workflow per new page object:**
|
||||
|
||||
1. Identify which test motivates the new class. Don't build
|
||||
speculatively.
|
||||
2. Run `explore.ts snapshot <name>` against a live debugger on the
|
||||
target UI surface. Commit the snapshot under
|
||||
`docs/testing/ui-snapshots/`.
|
||||
3. Inspect the snapshot — pick stable structural fingerprints, not
|
||||
Tailwind classes.
|
||||
4. Write the class in `lib/claudeai.ts`. If the file gets large
|
||||
(>1500 lines), split per-tab into separate files
|
||||
(`lib/claudeai/code-tab.ts`, `lib/claudeai/chat-tab.ts`, with
|
||||
`lib/claudeai.ts` as the barrel).
|
||||
5. Update `docs/testing/claudeai-ui-map.md` — replace the TODO with
|
||||
the class name + ✓.
|
||||
6. Add the spec that uses it.
|
||||
7. Run typecheck. Don't run tests until everything's wired.
|
||||
|
||||
**Don't pull out yet:**
|
||||
|
||||
- Single-consumer methods. If only one spec calls
|
||||
`Settings.toggleDarkMode()`, the inline implementation is fine.
|
||||
Promote to its own method when a second consumer arrives.
|
||||
- Generic primitives that haven't repeated three times. Three is
|
||||
the threshold for "this is an atom" — two could still be
|
||||
coincidence.
|
||||
|
||||
## Phase 4 — Atom promotion
|
||||
|
||||
**Goal:** keep the atom layer (Layer 1) growing in step with the
|
||||
page-object layer (Layer 2).
|
||||
|
||||
**Rule:** when a discovery pattern (CSS selector + JS predicate)
|
||||
appears in 3 different page objects, promote it to a top-level
|
||||
helper in `lib/claudeai.ts`.
|
||||
|
||||
**Examples of likely promotions in the next 6 months:**
|
||||
|
||||
- `findModal()` / `dismissModal()` — every page object that opens a
|
||||
confirmation modal will need this.
|
||||
- `waitForToast(regex, timeout)` — error and success toasts are
|
||||
pervasive.
|
||||
- `installShowMessageBoxMock(inspector, response)` — for native
|
||||
confirm dialogs.
|
||||
- `clickNavRow(label)` — sidebar interactions.
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Notice the third occurrence of the same pattern.
|
||||
2. Move the inline implementation up to a top-level export.
|
||||
3. Replace the three call sites with calls to the new export.
|
||||
4. Add an entry to the atoms inventory in `claudeai-ui-map.md`.
|
||||
|
||||
## Phase 5 — Drift detection
|
||||
|
||||
**Goal:** catch UI changes that break selectors *before* a sweep
|
||||
fails — fast, automatic, runs on every harness invocation.
|
||||
|
||||
**Deliverable:** `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`.
|
||||
|
||||
**Design:**
|
||||
|
||||
- Loads each `*.json` file from `docs/testing/ui-snapshots/`.
|
||||
- Connects to a running app via the existing `launchClaude` +
|
||||
`attachInspector` flow (NOT against an externally-running app —
|
||||
the harness must be self-contained).
|
||||
- For each snapshot, navigates to the captured URL (if not already
|
||||
there), then asserts each captured selector still resolves to an
|
||||
element with the same text/aria-label.
|
||||
- Failures are *attachments*, not full failures — the spec passes
|
||||
if ≥80% of snapshots match, surfaces the diffs as warnings. Hard
|
||||
threshold can be tightened later. Goal is "tell me what drifted,"
|
||||
not "block CI on every minor renderer change."
|
||||
|
||||
**How to dispatch:**
|
||||
|
||||
Single agent, after Phases 1–2 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.
|
||||
@@ -1,490 +0,0 @@
|
||||
# Fingerprint v7 Plan — Contextual, Account-Portable Identification
|
||||
|
||||
This is an executable plan for the v6 → v7 migration of the inventory
|
||||
fingerprint shape used by `tools/test-harness/explore/walker.ts` and
|
||||
`tools/test-harness/src/runners/U01_ui_visibility.spec.ts`. It can be
|
||||
picked up by a fresh session — start at "Phase 1" and walk down.
|
||||
|
||||
## Where we are
|
||||
|
||||
`docs/testing/ui-inventory.json` v6 (captured 2026-05-03 against app
|
||||
1.5354.0, 383 entries) records each interactive element with a
|
||||
fingerprint of this shape:
|
||||
|
||||
```ts
|
||||
fingerprint: {
|
||||
selector: 'button[aria-label="Search"]',
|
||||
ariaLabel: 'Search',
|
||||
role: null,
|
||||
tagName: 'BUTTON',
|
||||
textContent: null,
|
||||
}
|
||||
```
|
||||
|
||||
`U01` resolves entries by handing the `selector` field to Playwright.
|
||||
The current scheme has three load-bearing failure modes:
|
||||
|
||||
1. **Account-specific names baked into selectors and IDs.** Entries
|
||||
like `root.button.awaaddrick-max` (the user's plan badge,
|
||||
`button:has-text("AWAaddrick·Max")`) hardcode the walker-author's
|
||||
username + plan tier. Any contributor running U01 against their
|
||||
own auth fails this entry on selector match — the element is
|
||||
structurally present, just labeled differently.
|
||||
2. **Instance text in selectors of "stable" entries.** Search-result
|
||||
options, recent-conversations buttons, and pinned conversations
|
||||
carry titles like "Fine-tuning diffusion models with reinforcement
|
||||
learning" in their selectors. These are inherently per-account; the
|
||||
`kind: instance` taxonomy already exists to handle them, but the
|
||||
selector still encodes the literal title, so the v6 capture
|
||||
couldn't actually leverage `instance` semantics.
|
||||
3. **Selector brittleness under cosmetic redesigns.** `button:has-text(...)`
|
||||
selectors break under any label change. `button[aria-label="..."]`
|
||||
selectors break under any aria-label rewrite (which the upstream
|
||||
team does for accessibility audits without warning). Neither
|
||||
strategy carries enough redundancy to recover when one signal drifts.
|
||||
|
||||
The reconciliation doc (`ui-inventory-reconciliation.md`) flags these
|
||||
as "Walker coverage gap" and "Account-state-dependent" categories,
|
||||
and the U01 brief lists per-user inventory regeneration as "a
|
||||
separate workstream." This is that workstream.
|
||||
|
||||
## Design goals
|
||||
|
||||
In priority order:
|
||||
|
||||
1. **Account-portable.** A v7 inventory walked against User A's
|
||||
account matches against User B's renderer for any entry whose
|
||||
target element is structurally present in both accounts. Entries
|
||||
that genuinely don't exist in B's account fall back to the existing
|
||||
"skip if absent" semantics (`kind: instance` + ancestor-presence
|
||||
check).
|
||||
2. **Resilient to cosmetic drift.** Label changes, aria-label
|
||||
rewrites, minified-class churn, and CSS rewrites must not
|
||||
invalidate the fingerprint when the element's semantic role and
|
||||
structural position survive.
|
||||
3. **Surface drift before failure.** Soft drift (primary aria-path
|
||||
missed, relaxed-scope match recovered) attaches a warning to the
|
||||
test rather than passing silently. Hard drift (no strategy
|
||||
resolves) fails as today. The sweep gains a third state:
|
||||
`passed-with-drift`.
|
||||
4. **Atomic cutover, not gradual migration.** v7 walker, v7 inventory
|
||||
schema, and v7 resolver land together. The committed v6 inventory
|
||||
gets invalidated the moment v7 walker ships; no parallel-emit
|
||||
compatibility window, no `legacy` selector fallback in the
|
||||
resolver. Two systems are worse than one.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- Pixel-level visual diff. Separate concern; H05 is the right shape.
|
||||
- AI / embedding-based matching. Out of scope for a Linux repackager.
|
||||
- Behavioral fingerprints (click-and-verify-effect). Too expensive at
|
||||
383 entries.
|
||||
|
||||
## v7 schema
|
||||
|
||||
```ts
|
||||
interface FingerprintV7 {
|
||||
// Primary: accessibility-tree path from nearest landmark down to
|
||||
// the leaf. Each step carries (role, optional name).
|
||||
ariaPath: AriaStep[];
|
||||
|
||||
// The element itself. Drops `name` entirely when role + ariaPath
|
||||
// suffice for uniqueness on the captured surface.
|
||||
leaf: {
|
||||
role: string; // "button", "link", "menuitem", ...
|
||||
name: NameMatcher | null;
|
||||
siblingIndex: SiblingIndex | null;
|
||||
};
|
||||
|
||||
// Stability classification — drives how strictly the resolver
|
||||
// matches. See "Kind-strictness matrix" below. Distinct from the
|
||||
// existing `kind` field (persistent / structural / menu / instance)
|
||||
// which captures *lifecycle*, not *match strictness*.
|
||||
classification: 'stable' | 'positional' | 'instance';
|
||||
}
|
||||
|
||||
interface AriaStep {
|
||||
role: string; // landmark / region / grouping role
|
||||
name: NameMatcher | null; // optional — only included when needed
|
||||
}
|
||||
|
||||
type NameMatcher =
|
||||
| { kind: 'literal'; value: string } // "Search", "Cowork"
|
||||
| { kind: 'pattern'; regex: string }; // "\\w+·(Free|Pro|Max|...)"
|
||||
|
||||
interface SiblingIndex {
|
||||
role: string; // role of siblings being indexed
|
||||
position: number; // 0-based
|
||||
total: number; // total siblings of that role at capture
|
||||
}
|
||||
```
|
||||
|
||||
## Capture algorithm
|
||||
|
||||
Run during walker.ts's element emission, after the surface has settled.
|
||||
|
||||
```text
|
||||
captureFingerprint(element, surface):
|
||||
ariaPath = walkLandmarkAncestors(element)
|
||||
// Stop at <body>; emit a step for each role in
|
||||
// {banner, main, navigation, region, complementary,
|
||||
// contentinfo, search, form, toolbar, menu, menubar,
|
||||
// listbox, list, dialog, tablist, tabpanel, group}
|
||||
// with grouping role plus optional accessible name.
|
||||
|
||||
role = element.role
|
||||
name = element.accessibleName
|
||||
|
||||
// Step 1: try uniqueness without the name.
|
||||
matches = surface.queryAccessibleTree({
|
||||
ariaPath,
|
||||
leaf: { role }
|
||||
})
|
||||
if matches.length == 1:
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
|
||||
classification: 'stable' }
|
||||
|
||||
// Step 2: still too broad — try the name as a discriminator,
|
||||
// shaping it if it looks instance-specific.
|
||||
classification = classifyName(name, surface)
|
||||
if classification != 'instance':
|
||||
nameMatcher = (classification == 'positional')
|
||||
? null
|
||||
: (looksInstanceShaped(name)
|
||||
? { kind: 'pattern', regex: shapeOfName(name) }
|
||||
: { kind: 'literal', value: name })
|
||||
matches = surface.queryAccessibleTree({
|
||||
ariaPath, leaf: { role, name: nameMatcher }
|
||||
})
|
||||
if matches.length == 1:
|
||||
return { ariaPath, leaf: { role, name: nameMatcher,
|
||||
siblingIndex: null },
|
||||
classification }
|
||||
|
||||
// Step 3: still ambiguous — fall through to sibling position.
|
||||
siblings = element.parent.childrenWithRole(role)
|
||||
if siblings.length > 1:
|
||||
siblingIndex = {
|
||||
role,
|
||||
position: siblings.indexOf(element),
|
||||
total: siblings.length
|
||||
}
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex },
|
||||
classification: 'positional' }
|
||||
|
||||
// Step 4: instance — assert ≥1 match within ariaPath.
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
|
||||
classification: 'instance' }
|
||||
```
|
||||
|
||||
`queryAccessibleTree` should hit `Accessibility.getFullAXTree` over
|
||||
CDP, not the DOM. The accessibility tree is what screen readers see
|
||||
and what the platform APIs query — it's the substrate that aria
|
||||
roles and accessible names actually live in.
|
||||
|
||||
## Name classifier
|
||||
|
||||
`classifyName(name, surface)` decides whether a name is `stable`,
|
||||
`instance`, or `positional` (no usable name). Heuristics in priority
|
||||
order:
|
||||
|
||||
```text
|
||||
1. Empty / whitespace name → 'positional'
|
||||
2. Element is a list-row child → 'instance' (handled by ancestor
|
||||
role: option/listitem inside listbox/list)
|
||||
3. Name matches a known
|
||||
instance-shape regex → 'instance' (record as pattern)
|
||||
4. Name is in the corpus of
|
||||
"stable UI vocabulary" → 'stable'
|
||||
5. Default → 'stable' but flag for review
|
||||
```
|
||||
|
||||
### Known instance-shape regexes
|
||||
|
||||
| Regex | Example match | Shape recorded |
|
||||
|---|---|---|
|
||||
| `/^.+·(Free\|Pro\|Max\|Team\|Enterprise)$/` | `AWAaddrick·Max` | `\\w+·<PLAN>` |
|
||||
| `/^Opus \d/` `/^Sonnet \d/` `/^Haiku \d/` | `Opus 4.7Adaptive` | model-name passthrough (stable across users, just versioned) |
|
||||
| `/\d{1,3}%$/` | `Usage: plan 11%` | `Usage: plan \d+%` |
|
||||
| `/Today\|Yesterday\|\d+ (day\|hour\|minute)s? ago/` | `Today+12` | `<RELATIVE-DATE>(\\+\d+)?` |
|
||||
| `/^\d+\.\d+ \w+/` | `1.5 GB` | `\d+\.\d+ \w+` |
|
||||
| `/@\w+/` | `@aaddrick` | `@\w+` (treat as user-handle) |
|
||||
| `/[A-Z][a-z]+ [A-Z][a-z]+ [a-z]/` (3+ word title-case) | `Fine-tuning diffusion models...` | treat as `'instance'`, no pattern |
|
||||
|
||||
These regexes live in a registry that's part of the v7 capture
|
||||
config. Adding a new shape is a one-file change; the registry should
|
||||
be ordered (first match wins) so specific patterns take precedence
|
||||
over general ones.
|
||||
|
||||
### Building the stable UI vocabulary
|
||||
|
||||
After the walker finishes the BFS, run a second pass:
|
||||
|
||||
1. Collect every `accessibleName` from every captured element.
|
||||
2. Bucket by `kind` (existing taxonomy).
|
||||
3. Names appearing in 3+ entries with `kind: persistent` or
|
||||
`kind: structural`, across 2+ surfaces, are **stable**.
|
||||
4. Names appearing in only 1 entry with `kind: persistent`/`structural`
|
||||
are **suspect** — flag for human triage during reconciliation.
|
||||
5. Names in `kind: instance` entries are excluded from the corpus
|
||||
entirely.
|
||||
|
||||
Commit the resulting vocabulary list to
|
||||
`docs/testing/ui-vocabulary.json` so future walks can use it without
|
||||
re-deriving. Refresh the vocabulary on each major upstream release.
|
||||
|
||||
## Kind-strictness matrix
|
||||
|
||||
The existing `kind` field (`persistent` / `structural` / `menu` /
|
||||
`instance`) tunes how strictly the resolver matches at runtime,
|
||||
independently from the capture-time `classification`:
|
||||
|
||||
| kind | aria-path required | name required | siblingIndex strict | assertion |
|
||||
|---|---|---|---|---|
|
||||
| `persistent` | yes (deepest scope) | matcher must hit if present | yes | exactly 1 match |
|
||||
| `structural` | yes (or 1 step shallower) | matcher OR position | flexible (±1 ok) | exactly 1 match |
|
||||
| `menu` | yes, scoped to transient menu surface | literal text fallback ok | n/a | ≥1 match |
|
||||
| `instance` | yes (closest list/listbox ancestor) | ignored | ignored | ≥1 match within scope |
|
||||
|
||||
Examples:
|
||||
|
||||
- `root.button.search` → `kind: persistent`, `classification: stable`,
|
||||
`name: null` (unique by ariaPath alone). Strict 1-match assertion.
|
||||
- `root.button.awaaddrick-max` → `kind: persistent`, `classification: stable`,
|
||||
`name: { kind: 'pattern', regex: '\\w+·(Free|Pro|Max|...)' }`.
|
||||
Plan-shape pattern; user-portable.
|
||||
- `root.button.search.option.untitled-conversationtoday+12` →
|
||||
`kind: instance`, `classification: instance`, no name, scoped to
|
||||
search-results listbox. Assert ≥1 option in listbox.
|
||||
- `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` →
|
||||
`kind: instance`, scoped to pinned-conversations list. Assert ≥1
|
||||
button in pinned list.
|
||||
|
||||
## Resolver / fallback chain
|
||||
|
||||
In `findByFingerprint`:
|
||||
|
||||
```text
|
||||
resolve(fp):
|
||||
// Strategy 1 — primary: full aria-tree path
|
||||
result = tryAriaTreeMatch(fp.ariaPath, fp.leaf, fp.kind)
|
||||
if result.matched: return { found: true, strategy: 'aria-tree' }
|
||||
|
||||
// Strategy 2 — relaxed aria scope (drop deepest landmark step
|
||||
// in the path; keep the rest). Catches the common case where the
|
||||
// upstream team adds or removes one container layer.
|
||||
if fp.ariaPath.length > 1:
|
||||
result = tryAriaTreeMatch(fp.ariaPath.slice(0, -1), fp.leaf, fp.kind)
|
||||
if result.matched: return {
|
||||
found: true, strategy: 'aria-tree-relaxed', drift: 'scope-shifted'
|
||||
}
|
||||
|
||||
return { found: false, strategy: null }
|
||||
```
|
||||
|
||||
When `drift` is set, attach a soft warning to the Playwright test
|
||||
without failing it:
|
||||
|
||||
```ts
|
||||
testInfo.attach('drift-warning', {
|
||||
body: JSON.stringify({
|
||||
entryId: entry.id,
|
||||
expected: fp.ariaPath,
|
||||
matchedVia: result.strategy,
|
||||
drift: result.drift,
|
||||
note: 'primary aria-tree match failed; recovered via fallback. ' +
|
||||
'Re-walk inventory before drift compounds.',
|
||||
}, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
```
|
||||
|
||||
CI exposes `drift-warning` as a separate counter alongside pass /
|
||||
fail. Sweep summary becomes `383 passed, 12 with drift, 0 failed`.
|
||||
|
||||
## Migration plan
|
||||
|
||||
The cutover is atomic — no parallel-emit window. Walker, schema, and
|
||||
resolver all flip from v6 to v7 in the same merge. The committed v6
|
||||
inventory becomes invalid; first action after merge is a re-walk.
|
||||
|
||||
### Phase 1 — vocabulary scaffold (pre-walker)
|
||||
|
||||
The name classifier needs a stable-UI vocabulary corpus to
|
||||
disambiguate suspect names from known-stable copy. Build it from the
|
||||
existing v6 inventory before the walker rewrite:
|
||||
|
||||
1. Iterate `docs/testing/ui-inventory.json` v6.
|
||||
2. Names appearing in 3+ entries with `kind: persistent` or
|
||||
`kind: structural`, across 2+ surfaces, are **stable**.
|
||||
3. Names matching any registry regex (plan badge, model version,
|
||||
percentage, relative date, user handle) are **instance-shaped**.
|
||||
4. Names appearing in only 1 entry, not matching a regex, not in
|
||||
`kind: instance` — flag for human triage.
|
||||
5. Commit the resulting corpus to `docs/testing/ui-vocabulary.json`.
|
||||
|
||||
The corpus survives the walker rewrite — it's keyed on names, not on
|
||||
v6 schema specifics.
|
||||
|
||||
### Phase 2 — walker rewrite
|
||||
|
||||
1. Add `Accessibility.getFullAXTree` query to walker's surface-settle
|
||||
step (or AX subtree at target node if full-tree latency is
|
||||
unacceptable; see open questions).
|
||||
2. Implement `walkLandmarkAncestors`, `queryAccessibleTree`,
|
||||
`captureFingerprint` per the algorithm above.
|
||||
3. Implement the name classifier consuming `ui-vocabulary.json` and
|
||||
the instance-shape registry.
|
||||
4. Replace v6 fingerprint emit with v7. Inventory schema header bumps
|
||||
to `walkerVersion: 7`; v6 readers will fail loudly rather than
|
||||
silently mis-resolve.
|
||||
5. Walker passes that fail to compute a v7 fingerprint (AX query
|
||||
error, accessible-name-computation failure) emit the entry with
|
||||
`classification: 'positional'` and `name: null`, scoped to its
|
||||
ariaPath. Uncaptured fingerprints are not silently dropped — they
|
||||
become positional entries with explicit looseness.
|
||||
|
||||
Acceptance: a walk against the v6-author's account produces v7
|
||||
fingerprints for ≥98% of the surfaces v6 captured. ≥80% have
|
||||
`classification: 'stable'`; the rest split between `'positional'` and
|
||||
`'instance'`.
|
||||
|
||||
#### Live-walk shakedown (post-Phase 2)
|
||||
|
||||
The first end-to-end walks against the running renderer surfaced five
|
||||
real bugs the synthetic selfTest couldn't see. All landed in
|
||||
`walker.ts` / `name-classifier.ts` / `inspector.ts`:
|
||||
|
||||
1. **AX-tree settle gate.** `Accessibility.enable` populates the tree
|
||||
asynchronously; the existing `waitForStable` (1.5s ceiling on
|
||||
DOM-mutation quiescence) returned long before claude.ai's React
|
||||
tree mounted. Seed snapshots came back with 4 AX nodes (just the
|
||||
`RootWebArea` + a generic shell) and the walker emitted zero
|
||||
entries. Fix: `waitForAxTreeStable(inspector, { minNodes: 20 })`
|
||||
polls `getFullAXTree` until two consecutive reads return the same
|
||||
node count. Called once before the seed snapshot and once after
|
||||
each `navigateTo` in `redrivePath`. Baked into every
|
||||
`snapshotSurface` call too (with `minNodes: 1`) so post-click
|
||||
reads don't race the React update.
|
||||
2. **`reloadPage` in `redrivePath`.** `navigateTo(url)` short-circuits
|
||||
when `currentUrl === url`, but every BFS pop re-navigates to
|
||||
`startUrl`, so any state a prior drill left behind (open dialog,
|
||||
expanded sidebar, scrolled focus) carried into the next redrive
|
||||
and contaminated `clickById`'s snapshot. Replaced the redrive's
|
||||
initial `navigateTo` with `location.reload()` to discard the
|
||||
React tree.
|
||||
3. **List-row sibling-count heuristic.** The plan's `isListRowChild`
|
||||
check requires `option/listitem` inside `listbox/list`. claude.ai
|
||||
exposes the marketplace dialog as `dialog > button[]` with no
|
||||
list role at all (~80 cards) and the cowork sidebar as
|
||||
`complementary > button[]` (72 sessions). Without a heuristic,
|
||||
each row literal-matches by name and emits as a separate stable
|
||||
entry. Extension: `LIST_ROW_ROLES` includes `button`,
|
||||
`LIST_ANCESTOR_ROLES` includes `group`, AND `siblingTotal >= 15`
|
||||
on its own qualifies regardless of ancestor role. Step 3
|
||||
(positional fallback) also gates on `!isListRowChild` so list
|
||||
rows fall through to step 4's `instance` collapse instead of
|
||||
fragmenting into per-index positionals.
|
||||
4. **Two new instance shapes** in `name-classifier.ts`:
|
||||
`cowork-session` matches status-prefixed session titles
|
||||
(`^(Idle|Ready|Working|Awaiting input|Pull request merged|Done|Failed|Cancelled)\s`)
|
||||
and `row-more-options` matches per-row triggers
|
||||
(`^More options for `). Both ordered before `long-title` so the
|
||||
pattern wins over the no-pattern instance fallback.
|
||||
5. **Lookup-failure threshold bump** 25 → 75. Sidebar virtualization
|
||||
means the AX tree exposes a slightly different subset of cowork
|
||||
sessions on each fresh load; redrives accumulate
|
||||
"no element matches" misses in a row that aren't a real wedge.
|
||||
The timeout counter (5 strikes) still gates against actual
|
||||
renderer hangs.
|
||||
|
||||
Result on the AX migration's first clean walk
|
||||
(`startUrl: claude.ai/epitaxy`, account: aaddrick, app 1.5354.0):
|
||||
**90 entries** (37 persistent / 37 structural / 8 dialog / 8
|
||||
instance), 6 denylisted, 23 non-fatal lookup misses. The marketplace
|
||||
dialog folded to a single `button-instance+704`; the cowork sidebar
|
||||
to `button-instance+72`; search history to `option-instance+25`.
|
||||
Acceptance criteria from §Phase 2 met (≥98% structural overlap is
|
||||
trivially true on a re-walk; ≥80% stable hit at 75/90 ≈ 83%).
|
||||
|
||||
### Phase 3 — resolver rewrite (U01 + walker.ts findByFingerprint)
|
||||
|
||||
1. Replace `findByFingerprint` body with the two-strategy chain
|
||||
(primary aria-tree, relaxed-scope fallback). Drop the v6
|
||||
selector code path entirely.
|
||||
2. `gen-render-specs.ts` regenerates U01 from the v7 inventory; per-
|
||||
entry test bodies consume `entry.fingerprint` (now v7-shaped)
|
||||
directly.
|
||||
3. Add the `drift-warning` attachment shape to U01's test runner.
|
||||
4. Run U01 against the v7 inventory captured in Phase 2; baseline
|
||||
drift counts.
|
||||
|
||||
Acceptance: U01 against a fresh walker pass produces 0 drift
|
||||
warnings on the same account, fails 0 entries. Drift warnings only
|
||||
appear when actually-drifted elements are encountered.
|
||||
|
||||
### Phase 4 — account-portability validation
|
||||
|
||||
1. A second contributor walks their own v7 inventory.
|
||||
2. Diff against the v6-author's v7 inventory: structural overlap
|
||||
should be ≥80% on `kind: persistent` and `kind: structural`
|
||||
entries (the cross-user-stable subset).
|
||||
3. Run the v6-author's inventory's U01 against the second
|
||||
contributor's renderer (with `seedFromHost` lifting their auth).
|
||||
4. Expect ≥80% pass on the cross-user-stable subset; `kind: instance`
|
||||
entries pass via the ancestor-presence check.
|
||||
|
||||
This is the actual goal. If account-portability hits, the inventory
|
||||
is no longer a "my-account snapshot" but a true render contract.
|
||||
|
||||
## Open questions
|
||||
|
||||
### Resolved
|
||||
|
||||
- **CDP `Accessibility.getFullAXTree` cost.** Not a bottleneck. The
|
||||
signed-in `claude.ai/epitaxy` surface returns a 817-node tree;
|
||||
`waitForAxTreeStable` settles in <1s once Chromium has populated
|
||||
it. The cold-load gate dominates total latency, not per-call
|
||||
overhead. Plan B (subtree queries at the target node) is unused.
|
||||
- **Role overrides.** Confirmed working. `Skip to content` on
|
||||
claude.ai is captured as `link` (its AX-computed role) regardless
|
||||
of the underlying tag — a class of mismatch the v6 DOM walker
|
||||
silently got wrong.
|
||||
- **`account-bound` kind.** Not needed. The combination of
|
||||
shape-patterned name matchers (plan badge, cowork session) +
|
||||
the sibling-count list heuristic + persistent collapse handles
|
||||
every account-shaped element observed in the first clean walk.
|
||||
Re-evaluate if a future surface exposes account state without
|
||||
one of those signals.
|
||||
|
||||
### Open
|
||||
|
||||
- **Accessible-name computation parity.** Chrome's AX-tree-computed
|
||||
name should match what Playwright's `getByRole({ name })` matches
|
||||
at resolution time, but they're independent implementations of
|
||||
the ARIA name-computation spec. Validate at Phase 3 acceptance
|
||||
with a sample of 50 entries — capture vs resolve should agree.
|
||||
- **Stale vocabulary across releases.** When upstream renames
|
||||
"Cowork" to "Workspaces" (hypothetical), the corpus needs to
|
||||
update. Should vocabulary be re-derived automatically on each walk
|
||||
(cheap, drift-following) or pinned to a committed version (stable,
|
||||
manual updates)? Provisionally: re-derive on walk, commit the
|
||||
derived corpus alongside the inventory so reconciliation can diff
|
||||
vocabulary changes.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tools/test-harness/explore/walker.ts` — capture site
|
||||
- `tools/test-harness/explore/walk-isolated.ts` — driver that runs
|
||||
the walk inside the test-harness `launchClaude` + `seedFromHost`
|
||||
isolation path (use this rather than `explore walk` to avoid
|
||||
mutating the host profile)
|
||||
- `tools/test-harness/explore/gen-render-specs.ts` — emits U01 from
|
||||
inventory; needs to consume v7 fingerprints
|
||||
- `tools/test-harness/src/runners/U01_ui_visibility.spec.ts` —
|
||||
resolver consumer
|
||||
- `tools/test-harness/src/lib/inspector.ts` — `getAccessibleTree`
|
||||
+ `clickByBackendNodeId` for the AX-driven capture/click pair
|
||||
- `docs/testing/ui-inventory-reconciliation.md` — current v6 reconciliation
|
||||
- `docs/testing/claudeai-ui-mapping-plan.md` — broader UI mapping
|
||||
strategy this fits inside
|
||||
@@ -50,14 +50,6 @@ Status legend: `✓` pass · `✗` fail · `🔧` mitigated · `?` untested · `
|
||||
| [T38](./cases/code-tab-handoff.md#t38--continue-in-ide) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T39](./cases/code-tab-handoff.md#t39--desktop-cli-handoff-graceful-na) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## UI visibility (U-series)
|
||||
|
||||
Auto-generated render attestation: each entry in [`ui-inventory.json`](./ui-inventory.json) is asserted to mount with its recorded fingerprint on each platform. The single matrix cell aggregates every inventory entry — pass means every entry rendered, fail means at least one didn't (per-entry diagnostics in the JUnit attachments). Regenerate the spec with `npm run gen:render-specs` after re-walking. See [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) for the discovery + walker design.
|
||||
|
||||
| Test | KDE-W | KDE-X | GNOME | Ubu | Sway | i3 | Niri | Hypr-O | Hypr-N |
|
||||
|------|-------|-------|-------|-----|------|----|------|--------|--------|
|
||||
| [U01](../tools/test-harness/src/runners/U01_ui_visibility.spec.ts) — UI visibility | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## Environment-specific status
|
||||
|
||||
### Ubuntu / DEB
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
# Quick Entry Closeout — Test Plan
|
||||
# Quick Entry — Upstream Contract + Test Index
|
||||
|
||||
Focused sweep plan for closing the three open Quick Entry issues:
|
||||
Reference doc for the Quick Entry surface. Two halves:
|
||||
|
||||
- [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393) — Submit doesn't open the main window (Ubuntu 24.04 GNOME and friends). Mitigated by [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)'s KDE-only gate; root cause is `BrowserWindow.isFocused()` returning stale-true on Linux Electron.
|
||||
- [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — Shortcut doesn't fire from unfocused state on Fedora 43 GNOME. mutter no longer honours XWayland-side key grabs. Fix path: wire `--enable-features=GlobalShortcutsPortal` into the launcher on GNOME Wayland.
|
||||
- [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370) — Opaque square frame behind the transparent Quick Entry popup on KDE Wayland. Bisected to Electron 41.0.4 (electron/electron#50213); upstream regression. Workarounds in `frame-fix-wrapper.js` not yet attempted.
|
||||
- [§ Upstream design intent](#upstream-design-intent) documents what upstream Quick Entry promises vs. doesn't, with code anchors into `build-reference/app-extracted/.vite/build/index.js`. Treat as the authoritative answer when triaging whether a Quick Entry behavior is a Linux compat regression (our problem) or upstream-by-design (not our problem).
|
||||
- [§ Test list](#test-list) enumerates the QE-N items as conceptual checks and maps each to the concrete S-N / T-N case that backs it. Spec headnotes (S09, S12, S31, S37) cite specific QE-N IDs by anchor; [§ Scaffold integration](#scaffold-integration) is the authoritative QE-N → S-N table.
|
||||
|
||||
This doc is a **sweep plan**, not a test catalog. Test bodies and diagnostics live in [`cases/`](./cases/); the live status dashboard lives in [`matrix.md`](./matrix.md). The 21 `QE-*` items below map to existing `T*` / `S*` IDs where possible, and call out gaps to add as new `S*` cases.
|
||||
|
||||
## Goal
|
||||
|
||||
Pass all `QE-*` items in [§ Test list](#test-list) on every row in [§ Mandatory matrix](#mandatory-matrix). When that holds, all three issues are closeable (or, for #370, demonstrably blocked on upstream Electron with reproducible evidence).
|
||||
The QE-N items originated in the close-out sweep for [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), and [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370). The sweep has run; what remains is the upstream-contract reference + the test-index mapping.
|
||||
|
||||
## Upstream design intent
|
||||
|
||||
@@ -58,7 +53,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
| QE-3 | Critical | App on a different workspace, press shortcut | Popup appears on current workspace | [T06](./cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
|
||||
| QE-4 | Critical | App closed-to-tray (no window mapped), press shortcut | Popup appears | [S29](./cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity) |
|
||||
| QE-5 | Should | App quit entirely, press shortcut | No popup, no error, no zombie process | [S30](./cases/shortcuts-and-input.md#s30--quick-entry-shortcut-becomes-a-no-op-after-full-app-exit) |
|
||||
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` vs `Using native Wayland backend (global hotkeys may not work)` (verbatim from `scripts/launcher-common.sh:98, 102`). | **Pre-S12 fix:** flag absent; shortcut fails on GNOME Wayland (this is the #404 repro). **Post-S12 fix:** `--enable-features=GlobalShortcutsPortal` present in argv on GNOME Wayland; QE-2 / QE-3 begin to pass. | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
|
||||
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` (the GNOME default) vs `Using native Wayland backend (global shortcuts via XDG portal)` (after `CLAUDE_USE_WAYLAND=1`). | **Launcher implemented (S12).** GNOME defaults to XWayland (no portal flag); launching with `CLAUDE_USE_WAYLAND=1` adds `--ozone-platform=wayland` and a single `--enable-features=…,GlobalShortcutsPortal` (comma-joined with the ozone features, not a standalone token). On that opt-in path QE-2 / QE-3 pass on **GNOME ≤ 49** after the one-time portal dialog; on **GNOME 50 / xdg-desktop-portal ≥ 1.20** they don't yet — Electron skips the portal's `Registry.Register` handshake ([electron#51875](https://github.com/electron/electron/issues/51875)). | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
|
||||
|
||||
### Submit → main window — covers #393
|
||||
|
||||
@@ -77,9 +72,9 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-14 | Should | Inspect popup background | Transparent; no opaque square frame visible behind the rounded UI. **Note:** upstream already sets `transparent: true` and `backgroundColor: "#00000000"` (`:515380, :515383`), so the #370 triage-bot suggestion to "try setting backgroundColor to transparent" is moot — those are already in place. The Electron 41.0.4 regression is at the CSD/shadow rendering layer below those flags, not at the option-passing layer. | [S10](./cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | — |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | — |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | — |
|
||||
| QE-18 | Should | `electron --version` against the running app's bundled binary; record version in matrix | When > 41.0.4 ships and #370 still reproduces, the upstream-regression hypothesis is wrong | [S33](./cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version) |
|
||||
|
||||
### Patch-application sanity — regression prevention
|
||||
@@ -92,7 +87,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | — |
|
||||
|
||||
### Popup placement & lifecycle — upstream contract sanity
|
||||
|
||||
@@ -104,91 +99,9 @@ These verify upstream-promised behaviors that aren't directly broken by #393/#40
|
||||
| QE-23 | Smoke | **Multi-monitor required.** With an external monitor connected, invoke Quick Entry on the external monitor — let the position be saved (trigger QE-22's persistence path). Disconnect the external monitor (libvirt: `virsh detach-device` for the second display, or unplug the host monitor passing through). Invoke Quick Entry. | Popup falls back to the primary display via `cHn()` (`:515502`). Does **not** appear at off-screen coordinates. Skip this row in single-monitor VMs. | [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) |
|
||||
| QE-24 | Should | Launch app, focus main window, then **destroy** the main window without quitting the app. On this project the X button hide-to-tray override means the standard close path won't destroy `ut`; force the destroy via a) DevTools console (`Cmd+Opt+I` / `Ctrl+Shift+I` → `require('electron').remote.getCurrentWindow().destroy()` if exposed), or b) accept that this case is unreachable on Linux without a code change and skip. After destroy, invoke Quick Entry, type, submit. | Popup remains functional (lazy-recreation on shortcut press; the `!ut \|\| ut.isDestroyed()` guard at `:515595` skips the show/focus block but does not crash). New chat creation may not have a window to surface in — if app remains running with no main window, this is the "popup outlives main" path upstream guarantees. **If unreachable on Linux, mark this row N/A and document why.** | [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) |
|
||||
|
||||
## Mandatory matrix
|
||||
|
||||
The five rows below are the must-pass set to close all three issues. Display server is the **session selected at login** — KDE and GNOME both let you choose Wayland vs Xorg from the greeter.
|
||||
|
||||
| Row | Distro | DE | Display server | Closes / verifies | Reporter |
|
||||
|-----|--------|----|--------------:|-------------------|----------|
|
||||
| **GNOME-W** | Fedora 43 Workstation | GNOME 49.x | Wayland | #404 (S11/S12), #393 (QE-11/QE-12) | @gianluca-peri (#404), @Andrej730 (#393 root cause) |
|
||||
| **Ubu-W** | Ubuntu 24.04 LTS | GNOME (Ubuntu) | Wayland | #393 close-out (post-#406 gate). Also catches the `XDG_CURRENT_DESKTOP=ubuntu:GNOME` quirk (S02) | @Andrej730 |
|
||||
| **KDE-W** | Fedora 43 KDE *or* Nobara 43 KDE | Plasma 6 | Wayland | #370 (S10), QE-19 patch sanity, daily-driver regression baseline | @noctuum (#370), aaddrick |
|
||||
| **GNOME-X** | Ubuntu 24.04 (GNOME on Xorg session at greeter) | GNOME | Xorg | Differentiates whether #404 is mutter-as-compositor or mutter-XWayland-grabs specifically. **Note:** Fedora 43 GNOME may not ship an X11 session anymore (GNOME 49 deprecation); use Ubuntu's GNOME-on-Xorg session instead. | — |
|
||||
| **KDE-X** | Fedora 43 KDE (Plasma X11 session at greeter) | Plasma 6 | Xorg | Catches kwin-X11 specifics; regression baseline for the historic working path | — |
|
||||
|
||||
## Strongly recommended
|
||||
|
||||
Catches generalization gaps but not blocking close-out.
|
||||
|
||||
| Row | Distro | DE | Display server | Why |
|
||||
|-----|--------|----|--------------:|------|
|
||||
| **COSMIC** | popOS 24.04 (COSMIC alpha) | COSMIC | Wayland | @davidsmorais reported #393 there; not covered by KDE or GNOME branches |
|
||||
| **Ubu-X** | Ubuntu 24.04 (GNOME on Xorg) | GNOME | Xorg | Already counted under GNOME-X above. Listed here too because the Ubuntu install base is large — counts as its own row in the dashboard |
|
||||
|
||||
## Optional
|
||||
|
||||
Tracked under different bugs ([S06](./cases/shortcuts-and-input.md#s06--url-handler-doesnt-segfault-on-native-wayland), [S14](./cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) — skip unless closing those in the same sweep.
|
||||
|
||||
| Row | DE | Tracked under |
|
||||
|-----|----|--------------:|
|
||||
| Sway | wlroots | S06 |
|
||||
| Niri | wlroots | S14 |
|
||||
| Hypr-N (Omarchy) | wlroots | per @typedrat |
|
||||
| Hypr-O | Hyprland Xorg | per @typedrat |
|
||||
| i3 | Xorg | matrix |
|
||||
|
||||
## VM inventory
|
||||
|
||||
Existing host: `~/vms/` (libvirt, qcow2 images on a separate root-owned dir). Per-VM creation scripts in `~/vms/scripts/`. Per-VM test protocol in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md).
|
||||
|
||||
### Have
|
||||
|
||||
| Row | VM image | Status |
|
||||
|-----|----------|--------|
|
||||
| GNOME-W | `claude-fedora43-gnome.qcow2` | Ready |
|
||||
| Ubu-W | `claude-ubuntu-2404.qcow2` | Ready |
|
||||
| KDE-W | `claude-fedora43-kde.qcow2` | Ready (Nobara KDE on the bare-metal host is the alternative) |
|
||||
| GNOME-X | `claude-ubuntu-2404.qcow2` | Ready (use the GNOME-on-Xorg session at the greeter — same VM as Ubu-W) |
|
||||
| KDE-X | `claude-fedora43-kde.qcow2` | Ready (use the Plasma X11 session at the greeter — same VM as KDE-W) |
|
||||
|
||||
### Need to add for full mandatory + recommended coverage
|
||||
|
||||
| Row | What | Why |
|
||||
|-----|------|-----|
|
||||
| **COSMIC** | popOS 24.04 (COSMIC alpha) ISO + `~/vms/scripts/create-popos-cosmic.sh` | Davidsmorais's #393 environment; otherwise unrepresented |
|
||||
|
||||
### Need to add only if closing optional rows in the same sweep
|
||||
|
||||
| Row | What | Use existing | Why |
|
||||
|-----|------|--------------|-----|
|
||||
| Niri | Fedora-Niri-Live ISO + `~/vms/scripts/create-fedora-niri.sh` | — | S14 (`BindShortcuts` error 5) |
|
||||
| Hypr-N | Possibly already covered by `claude-omarchy` | `claude-omarchy.qcow2` | Omarchy is a Hypr-N variant; may not exercise stock Hyprland |
|
||||
| Sway | `claude-fedora43-sway.qcow2` | Existing | S06 URL handler segfault |
|
||||
| i3 | `claude-fedora43-i3.qcow2` | Existing | Coverage only |
|
||||
|
||||
## Minimum viable kill-set
|
||||
|
||||
If the goal is the smallest pass that justifies closing all three issues:
|
||||
|
||||
- **GNOME-W** — must pass QE-2/3/4/6/7/8/9/11 → closes #404, half of #393.
|
||||
- **Ubu-W** — must pass QE-7/8/9/11 → closes other half of #393.
|
||||
- **KDE-W** — must pass QE-7/8/9 + QE-14 + QE-19 → closes #370 (or punts upstream with QE-18 evidence) and confirms the gated patch path still works.
|
||||
|
||||
(QE-20 has been folded into QE-19 — the patch ships in every build, so a single bundled-JS check covers both KDE and non-KDE rows.)
|
||||
|
||||
Three VMs, ~21 items per row, one full sweep ≈ 90 minutes if the visual checks are batched.
|
||||
|
||||
## Per-row pass criteria
|
||||
|
||||
| Issue | Closeable when |
|
||||
|-------|----------------|
|
||||
| #393 | QE-7 through QE-12 pass on **GNOME-W**, **Ubu-W**, and **KDE-W**. QE-19 confirms the patch was applied at build (KDE gate string present). If QE-11 fails on GNOME-W, the KDE-only gate is preserved as a permanent fix; otherwise the patch can be widened. |
|
||||
| #404 | QE-2 and QE-3 pass on **GNOME-W**. QE-6 confirms the launcher actually appended `--enable-features=GlobalShortcutsPortal` on GNOME Wayland (S12). |
|
||||
| #370 | QE-14 passes on **KDE-W**. **OR** QE-18 records an Electron version > 41.0.4 in the bundled binary and QE-14 still fails — at that point the upstream-regression hypothesis is wrong and we re-investigate. |
|
||||
|
||||
## Scaffold integration
|
||||
|
||||
This sweep is fully wired into the existing test scaffold. The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
|
||||
| Case | Title | Backs |
|
||||
|------|-------|-------|
|
||||
@@ -202,24 +115,4 @@ This sweep is fully wired into the existing test scaffold. The `QE-*` items in [
|
||||
| [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) | Popup falls back to primary display when saved monitor is gone | QE-23 |
|
||||
| [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) | Popup remains functional after main window destroy | QE-24 |
|
||||
|
||||
UI-element-level checks for QE-14 through QE-17 and QE-21 live in [`ui/quick-entry.md`](./ui/quick-entry.md), which has been refined against the upstream evidence captured in [§ Upstream design intent](#upstream-design-intent).
|
||||
|
||||
(QE-13, QE-21 don't need their own S-IDs — they're documentation items / already covered by `ui/quick-entry.md`.)
|
||||
|
||||
## Sweep mechanics
|
||||
|
||||
Per-row procedure (one full pass):
|
||||
|
||||
1. Boot VM. Confirm session at greeter matches the row (Wayland vs Xorg, correct DE).
|
||||
2. Install the latest build:
|
||||
- DEB: `sudo apt install ./claude-desktop_*.deb`
|
||||
- RPM: `sudo dnf install ./claude-desktop-*.rpm`
|
||||
3. Capture environment baseline: `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP`, `gnome-shell --version` or `kwin --version`, `electron --version` (for QE-18).
|
||||
4. Launch app. Wait for main window. Run QE-21 input smoke first to catch obvious breakage early.
|
||||
5. Run shortcut tests (QE-1 → QE-6) in order. Each run, scrape `~/.cache/claude-desktop-debian/launcher.log` and `pgrep -af claude-desktop` argv.
|
||||
6. Run submit tests (QE-7 → QE-13). For each window-state precondition, set the state, then trigger Quick Entry, then submit.
|
||||
7. Run visual checks (QE-14 → QE-18). Screenshot QE-14 to attach to #370 if still failing.
|
||||
8. Run patch sanity (QE-19 / QE-20).
|
||||
9. Update [`matrix.md`](./matrix.md) status cells. Save logs under a row-tagged subdirectory: `~/vms/collected/<row>-<date>/`.
|
||||
|
||||
For the deeper #393 bisect (isolating which half of PR #390 regresses GNOME), see the two-variant build instructions in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md) — build a blur-only and a vis-only variant, run QE-7 through QE-11 on each on **Ubu-W** and **GNOME-W**, gate the offending half rather than the whole patch.
|
||||
QE-13, QE-15, QE-16, QE-17, and QE-21 are visual / input checks with no formal S-ID — run them by eye against [§ Upstream design intent](#upstream-design-intent).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Last updated: 2026-05-03*
|
||||
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/) and [`ui/`](./ui/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
|
||||
## When to sweep
|
||||
|
||||
@@ -315,9 +315,6 @@ When a test drifts, edit Steps/Expected in place. When a feature is
|
||||
gone from the build, prepend
|
||||
`> **⚠ Missing in build X.Y.Z** — <note>. Re-verify after next
|
||||
upstream bump.` under the test heading.
|
||||
[`cases-grounding-prompt.md`](./cases-grounding-prompt.md) is the
|
||||
fan-out prompt the last sweep used — paste verbatim into a fresh
|
||||
session to repeat the workflow.
|
||||
|
||||
### Runtime pass
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# test-harness runner implementation — session 17 prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
orchestration depends on the exact directives below.
|
||||
|
||||
> **ORCHESTRATION STOPPED AFTER SESSION 16.** This prompt is rotated
|
||||
> for completeness only. **Session 17 will NOT run automatically** —
|
||||
> the autonomous orchestration was halted at the end of session 16
|
||||
> after coverage stalled at 74/76 (97%) for four consecutive sessions
|
||||
> (13, 14, 15, 16). To resume, the user must manually trigger another
|
||||
> orchestration run AND meet at least one of these preconditions:
|
||||
>
|
||||
> 1. **Real signed-in Claude Desktop running with `--inspect=9229`**
|
||||
> on the dev box (debugger-attached, signed in, NOT a leaked test
|
||||
> isolation). This unblocks Categories A (operon-mode probe) and
|
||||
> B (Tier 3 read-only reframes that need auth-bearing renderer
|
||||
> state).
|
||||
> 2. **A real claude.ai account fixture for write-side state.** The
|
||||
> remaining 2 specs (matrix coverage 74/76 → 76/76) need real
|
||||
> write-side state (e.g. an installed plugin to exercise
|
||||
> `LocalPlugins.listSkillFiles`, or a deep-linked deferred install
|
||||
> intent for T11). The Tier 3 destructive constraint
|
||||
> (`Don't run destructive Tier 3 write-side tests`) explicitly
|
||||
> forbids the harness constructing this state itself.
|
||||
> 3. **Renderer-drift event** that requires re-anchoring page-objects
|
||||
> (e.g. claude.ai redesign breaks `findCompactPills`,
|
||||
> `clickMenuItem`, etc.). Triggers a defensive-migration session.
|
||||
> 4. **New IPC surface** added by upstream that the harness should
|
||||
> cover (e.g. a new `claude.web` interface, a new eipc method
|
||||
> that's case-doc-anchored).
|
||||
>
|
||||
> If none of those preconditions hold, the orchestration should NOT
|
||||
> resume — further sessions will produce documentation-only or
|
||||
> marginal output. The structural ceiling of the harness without
|
||||
> real-account fixtures is 74/76 (97%); we're already there.
|
||||
|
||||
You're picking up after session 16 of the test-harness runner
|
||||
implementation work. Session 16 was the final session of the
|
||||
sessions-13-to-16 orchestration run and produced: T17 verification
|
||||
(session-15 structural fix VERIFIED — bare 60s timeout gone, new
|
||||
failure mode at `openFolderPicker` post-`selectLocal` classified as
|
||||
renderer-state-dependent and deferred), schema-rev for
|
||||
`listRemotePluginsPage` / `listSkillFiles` (both schemas resolved by
|
||||
bundle inspection — neither shipped as a Tier 2 invocation because
|
||||
`listRemotePluginsPage` is not anchored in any case doc, and
|
||||
`listSkillFiles` needs Tier 3 destructive setup). NO coverage gain.
|
||||
Plan-doc updated. Followup-prompt rotated with the STOP flag (this
|
||||
document).
|
||||
|
||||
The plan doc at
|
||||
[`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
|
||||
captures the tier classification and execution-time reclassifications.
|
||||
Its "Status (post-execution)" section is the source of truth for
|
||||
what's done and what's deferred — read **session 16** first, then
|
||||
**session 15**, **session 14**, **session 13**, **session 12**,
|
||||
**session 11**, **session 10**, **session 9**, **session 8**,
|
||||
**session 7**, **session 6**, **session 5**, **session 4**, **session
|
||||
3**, **session 2**, then **session 1** sub-sections.
|
||||
|
||||
This session is a continuation, not a restart. Start by reading the
|
||||
plan doc's status sections AND verifying at least one of the
|
||||
preconditions above holds. If none hold, STOP and report; don't try
|
||||
to fan out.
|
||||
|
||||
### Session 16 final findings (key context for any session-17 attempt)
|
||||
|
||||
1. **T17's session-15 structural fix VERIFIED.** Bare 60s timeout is
|
||||
gone. `seedFromHost` clones the host's signed-in config,
|
||||
`waitForReady('userLoaded')` resolves to a post-login URL
|
||||
(`https://claude.ai/epitaxy` on the dev box), the dialog mock
|
||||
installs, and `CodeTab.activate({ timeout: 15_000 })` (session 14
|
||||
migration) succeeds first try.
|
||||
2. **T17's NEW failure mode is renderer-state-dependent, not AX.**
|
||||
After `selectLocal()` clicks the Local menuitem, the Select-folder
|
||||
pill never appears within 4s. The URL during the run was
|
||||
`/epitaxy` — the user's workspace route. The folder-picker UI
|
||||
may only render on `/new` (or a fresh project), not on a workspace
|
||||
already containing files. To unblock: navigate to `/new`
|
||||
post-userLoaded BEFORE `openFolderPicker()`. NOT shipped session
|
||||
16 — needs a careful navigation primitive that doesn't break
|
||||
existing seedFromHost specs.
|
||||
3. **`openPill` / `clickMenuItem` migration STILL parked.** Session
|
||||
16's T17 trace confirmed the env-pill open + Local click both
|
||||
succeeded, ruling out the AX-polling-loop hypothesis once and for
|
||||
all. Don't migrate those speculatively.
|
||||
4. **Schema-rev resolved both deferred validators.**
|
||||
`CustomPlugins.listRemotePluginsPage(limit: number, offset:
|
||||
number)`. `LocalPlugins.listSkillFiles(pluginId: string,
|
||||
skillName: string, pluginContext?: opaque)`. Neither shipped as a
|
||||
Tier 2 invocation: `listRemotePluginsPage` is not anchored in any
|
||||
case doc; `listSkillFiles` needs Tier 3 destructive setup.
|
||||
5. **Coverage stalled at 74/76 (97%) for 4 consecutive sessions.**
|
||||
Sessions 13-16 net deliverables: 1 primitive, 1 AX migration, 1
|
||||
structural fix, 1 verification + 1 schema-rev investigation.
|
||||
Without real-account fixtures, the harness's structural ceiling
|
||||
is 74/76. The remaining 2 specs need real-account write-side
|
||||
state.
|
||||
|
||||
### What a future session 17 might attempt (only if preconditions hold)
|
||||
|
||||
If precondition 1 (real signed-in debugger-attached Claude) holds:
|
||||
|
||||
- **Operon-mode probe** (Category A from sessions 13-16). Run
|
||||
`eipc-registry-probe.ts` against the user's Claude with operon mode
|
||||
toggled on/off, capture the diff in registered channels. May
|
||||
surface a new case-doc-coverable handler.
|
||||
- **Schema-rev smoke-test** for the session-16-resolved schemas
|
||||
against the live debugger. `listRemotePluginsPage(limit: 10,
|
||||
offset: 0)` should return an array shape; `listSkillFiles('some-
|
||||
installed-plugin', 'some-skill')` would test the LocalPlugins
|
||||
handler's auth path.
|
||||
|
||||
If precondition 2 (real-account write-side fixture) holds:
|
||||
|
||||
- **T11 runtime invocation.** With an installed plugin in
|
||||
`~/.claude/plugins/`, the post-install state can be probed via
|
||||
`listSkillFiles` and the slash-menu skills would assert the
|
||||
case-doc claim "skills appear in the slash menu" (T11 step 3).
|
||||
- **T17 navigation fix.** Add a `/new` navigation primitive to
|
||||
`claudeai.ts`'s `CodeTab` so `openFolderPicker` works on a fresh
|
||||
project route. Verify T17 reaches the dialog mock fired assertion.
|
||||
|
||||
If precondition 3 or 4 holds:
|
||||
|
||||
- **Defensive page-object refactor.** Re-snapshot the AX tree at the
|
||||
Customize panel and Plugin browser modal, refresh case-doc
|
||||
inventory anchors, migrate any decayed selectors.
|
||||
|
||||
### Termination signal interpretation
|
||||
|
||||
If session 17 is triggered without any precondition met, the right
|
||||
move is the same as session 16's STOP recommendation: write a one-
|
||||
paragraph "preconditions not met, no work shipped" plan-doc update
|
||||
and terminate. Don't burn a session on documentation-only output.
|
||||
|
||||
### Constraints to respect (unchanged from sessions 1-16)
|
||||
|
||||
- Use `seedFromHost: true` for any auth-required spec — never
|
||||
`CLAUDE_TEST_USE_HOST_CONFIG=1` / `isolation: null` (legacy shape
|
||||
removed in session 15).
|
||||
- eipc handlers register on `webContents.ipc._invokeHandlers`, NOT
|
||||
global `ipcMain._invokeHandlers`. Use `lib/eipc.ts`.
|
||||
- For arg validator schema-rev: smoke-test first, fall back to
|
||||
bundle-grep on the rejection literal.
|
||||
- For AX-tree consumers: use `lib/ax.ts` (`snapshotAx` /
|
||||
`waitForAxNode` / `waitForAxNodes`).
|
||||
- For call-site migrations to `waitForAxNode`: keep per-spec retry
|
||||
budgets matching existing tuning.
|
||||
- `lib/input.ts` is X11-only. `lib/input-niri.ts` is Niri-only. CDP
|
||||
auth gate is alive (runtime SIGUSR1 attach, never Playwright
|
||||
`_electron.launch()`). BrowserWindow Proxy gotcha — use
|
||||
`webContents.getAllWebContents()`. `skipUnlessRow()` always first.
|
||||
- No fixed sleeps. `retryUntil` from `lib/retry.ts`, Playwright
|
||||
auto-wait, or `waitForAxNode` from `lib/ax.ts`.
|
||||
- Diagnostics on every run via `testInfo.attach()`. Tag with
|
||||
`severity:` and `surface:` annotations.
|
||||
- Tabs in TS, ~80-char wrap.
|
||||
- Don't break existing runners. H01-H05 are the canaries.
|
||||
- `npm run typecheck` must stay clean.
|
||||
- Don't run destructive Tier 3 write-side tests.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order before fanning out:
|
||||
|
||||
- [`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
|
||||
— tier classification + status sections.
|
||||
- [`tools/test-harness/README.md`](../../tools/test-harness/README.md)
|
||||
— runner conventions, the 74-spec inventory, primitives in
|
||||
`lib/`, isolation defaults.
|
||||
- [`docs/testing/cases/README.md`](cases/README.md) — case-doc
|
||||
structure and the four anchor scopes.
|
||||
- [`tools/test-harness/src/lib/`](../../tools/test-harness/src/lib/)
|
||||
— the existing primitives.
|
||||
- [`tools/test-harness/src/runners/`](../../tools/test-harness/src/runners/)
|
||||
— every existing spec is a template.
|
||||
|
||||
### Phase 0 — calibration (mandatory before fanning out)
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — should pass.
|
||||
2. Check debugger ATTACHMENT QUALITY (not just port). `ss -tln |
|
||||
grep ':9229'`. If port open, probe webContents via `evalInMain`:
|
||||
|
||||
```ts
|
||||
import { InspectorClient } from './src/lib/inspector.js';
|
||||
const client = await InspectorClient.connect(9229);
|
||||
const wcs = await client.evalInMain<unknown>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
return webContents.getAllWebContents().map((w) => ({
|
||||
id: w.id, url: w.getURL(), title: w.getTitle(),
|
||||
}));
|
||||
`);
|
||||
console.log(wcs); client.close();
|
||||
```
|
||||
|
||||
If every URL is `/login` / `find_in_page` / `main_window`, treat
|
||||
as soft-blocked for auth-required investigations.
|
||||
3. Disambiguate running Claude processes. `pgrep -af
|
||||
"ozone-platform=x11.*app.asar"`; for each, inspect cmdline for
|
||||
`user-data-dir`. Real Claude has
|
||||
`~/.config/Claude` (or no user-data-dir flag); leaked test
|
||||
isolations have `/tmp/claude-test-*`.
|
||||
4. **Verify at least one precondition for resuming the orchestration
|
||||
holds.** If none hold, write a "no preconditions met" plan-doc
|
||||
update and STOP. Don't fan out.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- For the bundle-grep schema-rev pattern (sessions 9, 11, 12, 16
|
||||
precedents):
|
||||
|
||||
```bash
|
||||
cd tools/test-harness && node -e "
|
||||
const {extractFile} = require('@electron/asar');
|
||||
const buf = extractFile(
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar',
|
||||
'.vite/build/index.js'
|
||||
);
|
||||
const s = buf.toString('utf8');
|
||||
const idx = s.indexOf('<rejection-literal>');
|
||||
console.log(s.slice(Math.max(0, idx - 1500), idx + 500));
|
||||
"
|
||||
```
|
||||
|
||||
- For seedFromHost specs: host MUST have a signed-in Claude.
|
||||
`seedFromHost`'s host-claude-kill semantics will tear down any
|
||||
running Claude process — flag clearly in the report before
|
||||
invoking when the user's real Claude is running.
|
||||
|
||||
- For AX-tree polling: `lib/ax.ts`'s `waitForAxNode` /
|
||||
`waitForAxNodes` for predicate-based polling.
|
||||
|
||||
- The eipc-registry probe (`tools/test-harness/eipc-registry-probe.ts`)
|
||||
is the dedicated tool for inspecting per-wc IPC handler state.
|
||||
|
||||
Begin with Phase 0. Don't fan out until at least one of the
|
||||
preconditions for resuming the orchestration is verified to hold.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,597 +0,0 @@
|
||||
# claude.ai UI Inventory Reconciliation
|
||||
|
||||
*Generated against [`ui-inventory.json`](./ui-inventory.json) v6 (captured 2026-05-03, app version 1.5354.0, 383 entries).*
|
||||
*Reconciled 2026-05-02.*
|
||||
|
||||
This file diffs the human-written claims in [`ui/`](./ui/) against the
|
||||
machine-captured ground-truth in [`ui-inventory.json`](./ui-inventory.json).
|
||||
|
||||
It is one-shot output meant to drive human cleanup of `ui/*.md` — re-run
|
||||
the reconciliation script (TODO: not yet built) after major walker passes.
|
||||
|
||||
## Reading this document
|
||||
|
||||
Three categories of finding per surface:
|
||||
|
||||
- **In docs but not in renderer** — the doc names an element that has no
|
||||
corresponding inventory entry. Possible causes (don't read this as "doc
|
||||
is wrong"; the walker covers a subset of reality):
|
||||
- **OS / window-manager element** — title bar, close/min/max buttons,
|
||||
drop shadow, resize edges. These are drawn by the compositor, not by
|
||||
claude.ai's renderer; the walker can't see them.
|
||||
- **Out of renderer scope** — tray menu, libnotify notifications, IME
|
||||
composition popups, Quick Entry popup window. These are main-process
|
||||
or DE-level surfaces that don't exist in the claude.ai DOM.
|
||||
- **Walker coverage gap** — Settings overlay, dialogs, deep Code-tab
|
||||
panes (terminal, file pane, diff). The walker drilled some surfaces
|
||||
but not others; absence here is "not yet observed" not "not present."
|
||||
- **Account-state-dependent** — features that don't appear on this
|
||||
user's plan (e.g. SSH connections panel, managed-settings rows,
|
||||
specific Code-tab pane types).
|
||||
- **Speculative** — doc was written from upstream behavior, not from a
|
||||
Linux build. May not actually render.
|
||||
- **In renderer but not in docs** — inventory captured an element that no
|
||||
doc row mentions. Either the doc is incomplete for that surface, or the
|
||||
element is tangential (search-results recency rows, instance-suffix
|
||||
duplicates with `#2`/`+5` markers).
|
||||
- **Fingerprint potentially drifted** — doc and inventory agree on the
|
||||
element but the doc's selector hint disagrees with the inventory's
|
||||
`fingerprint.selector`. Most `ui/*.md` rows use prose ("Top-left of
|
||||
topbar") rather than CSS selectors, so this category is small.
|
||||
|
||||
Human triage is what closes any of these. Don't auto-edit `ui/*.md`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Inventory entries (total) | 383 |
|
||||
| Inventory entries by kind | persistent 65 / structural 276 / menu 33 / instance 9 |
|
||||
| Inventory entries marked `denylisted: true` | 9 (Send×4, Install×4, Remove×1) |
|
||||
| `ui/*.md` files reconciled | 11 (10 surface files + README) |
|
||||
| `ui/*.md` rows reconciled (rough — multi-element rows complicate the count) | ~210 element rows across all 10 surface files |
|
||||
| Rows with confirmed inventory match | ~70 (~33%) |
|
||||
| Rows flagged "in docs but not in renderer" | ~140 (~67%) — heavily skewed by OS-frame, tray, notifications, deep Code panes, Settings, Quick Entry being out-of-renderer or under-walked |
|
||||
| Inventory entries with no `ui/*.md` mention | ~190 (~50%) — heavily skewed by per-conversation/per-skill/per-prompt-card structural rows that the docs treat as categories rather than enumerating |
|
||||
| Doc rows with explicit selectors that drift from inventory | 0 verified — `ui/*.md` rows almost never carry CSS selectors |
|
||||
|
||||
Match counts are approximate. `ui/*.md` rows often describe categories
|
||||
("Recent conversations," "Per-history-entry hover") that map to many
|
||||
inventory entries; the inventory in turn enumerates structural elements
|
||||
the docs intentionally don't list (every project skill button, every
|
||||
search result option). The reconciliation is a triage signal, not a
|
||||
metric.
|
||||
|
||||
## Per-surface breakdown
|
||||
|
||||
### `ui/window-chrome-and-tabs.md`
|
||||
|
||||
**Inventory surfaces likely covered:** none directly — OS window frame is
|
||||
drawn by the compositor; the in-app topbar elements live under `root` as
|
||||
`root.button.menu`, `root.button.collapse-sidebar`, `root.button.search`,
|
||||
`root.button.back`, `root.button.forward`. The "tab strip" maps to
|
||||
`root.button.chat`, `root.button.cowork`, `root.button.code`.
|
||||
|
||||
**Doc rows reconciled:** ~22
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Title bar | OS / window-manager |
|
||||
| Close button (X) | OS / window-manager |
|
||||
| Minimize button | OS / window-manager |
|
||||
| Maximize / restore button | OS / window-manager |
|
||||
| Resize edges | OS / window-manager |
|
||||
| Window menu (right-click titlebar) | OS / window-manager |
|
||||
| Cowork ghost icon | Walker captures `root.button.cowork` (the tab) but not the ghost-icon visual within the topbar shim |
|
||||
| Drag region (gaps between buttons) | Renders as empty space — not an actionable element |
|
||||
| Active tab indicator | Visual styling, not an actionable element |
|
||||
| Tab badges (unread / Dispatch) | None observed; user state at capture had no badges |
|
||||
| About dialog | Walker did not surface a dialog; About is reachable only from app/tray menu, both out of renderer scope |
|
||||
| App menu (macOS-style) | Doc itself notes this is N/A on Linux |
|
||||
| Update prompt | Conditional, not present at capture |
|
||||
| Crash report dialog | Conditional, not present at capture |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.menu` ("Menu", `aria-label="Menu"`) | This is the doc's "Hamburger menu" — renamed |
|
||||
| `root.button.collapse-sidebar` ("Collapse sidebar") | Doc has "Sidebar toggle"; arguably the same |
|
||||
| `root.button.search` ("Search") | Doc's "Search icon"; same |
|
||||
| `root.button.back` / `root.button.forward` | Doc's back/forward arrows; same |
|
||||
| `root.a.skip-to-content` ("Skip to content") | A11y skip link; not in doc |
|
||||
| `root.button.new-chat-n` ("New chat⌘N") | Topbar new-chat button; not in doc |
|
||||
| `root.button.pinned`, `root.button.recents`, `root.button.projects`, `root.button.artifacts`, `root.button.customize` | Sidebar nav buttons; doc covers some of these in `sidebar.md` not here |
|
||||
| `root.button.awaaddrick-max` ("AWAaddrick·Max") | User/plan badge in topbar; not in doc |
|
||||
| `root.button.get-apps-and-extensions` | Topbar shortcut to apps page; not in doc |
|
||||
| `root.tab.write` / `root.tab.learn` / `root.tab.code` / `root.tab.from-calendar` / `root.tab.from-gmail` | Quick-prompt-template tabs in the prompt area; doc covers Write/Learn/Code as Chat/Cowork/Code tabs but the inventory's `root.tab.code` is distinct from `root.button.code` |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc rows for this surface use Location prose only.
|
||||
|
||||
#### Notable cross-cut
|
||||
|
||||
The doc's "Chat / Cowork / Code" tab strip maps cleanly to
|
||||
`root.button.chat`, `root.button.cowork`, `root.button.code`. But the
|
||||
inventory also has `root.tab.code` (a `[role="tab"]`, not a button) which
|
||||
is a separate element — the prompt-area template strip — that the doc
|
||||
conflates with the main Chat/Cowork/Code switcher. Worth a human note.
|
||||
|
||||
---
|
||||
|
||||
### `ui/tray.md`
|
||||
|
||||
**Inventory surfaces covered:** none — the tray is a main-process Electron
|
||||
`Tray` object on the system SNI bus, not part of claude.ai's DOM.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Tray icon (light / dark theme) — main-process `Tray.setImage()`
|
||||
- Right-click menu items (Show/Hide, Quick Entry, Open at Login,
|
||||
Settings, About, Quit) — main-process `Menu.buildFromTemplate()`
|
||||
- Left-click / double-click / middle-click behaviors — main-process
|
||||
event handlers
|
||||
- Tooltip on hover, position, icon resolution, theme switch — SNI
|
||||
daemon and DE behavior
|
||||
|
||||
This entire file is correctly out of renderer scope; the walker is doing
|
||||
the right thing by not capturing any of it.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `ui/sidebar.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root` (sidebar lives in the root
|
||||
chrome on claude.ai). Note: the doc opens "Code Tab Sidebar" but the
|
||||
sidebar in the captured renderer is the global claude.ai sidebar, not a
|
||||
Code-tab-specific one. The Code-tab-specific session list is captured
|
||||
separately under `root.button.code.button.new-session-n` (60 entries).
|
||||
|
||||
**Doc rows reconciled:** ~18
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Filter: status / project / environment | Walker did not drill the filter dropdown |
|
||||
| Group-by control | Same — within Code-tab session list |
|
||||
| Session status indicator (idle/running/...) | Visual decoration on row, not an actionable element |
|
||||
| Project / branch label | Same |
|
||||
| Diff stats badge `+12 -1` | Conditional — no session at capture had pending diffs |
|
||||
| Dispatch badge | Conditional — no Dispatch-spawned session at capture |
|
||||
| Scheduled badge | Conditional — same |
|
||||
| Hover archive icon | Hover-revealed; walker captures static state |
|
||||
| Right-click context menu (Rename / Archive / etc.) | Walker does not synthesise right-clicks |
|
||||
| Sidebar resize handle | Visual / draggable, not an aria-labeled element |
|
||||
| Sidebar collapse toggle | Inventory has `root.button.collapse-sidebar` but doc treats it as a Code-tab element rather than chrome |
|
||||
| Scrollbar | OS / theme-rendered |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` cycling | Keyboard shortcut, not a UI element |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` | A pinned recent conversation — sidebar content |
|
||||
| `root.button.more-options-for-fine-tuning-diffusion-models-with-reinforce` | Per-row menu trigger — doc mentions "right-click context menu" but inventory shows it's a discoverable button |
|
||||
| `root.button.how-to-use-claude` + `root.button.more-options-for-how-to-use-claude` | Same pattern |
|
||||
| `root.button.code.button.routines` | "Routines" link in Code-tab nav — doc's "Routines link" is here |
|
||||
| `root.button.code.button.more-navigation-items` | Likely the doc's "Customize / Routines" expander — not enumerated |
|
||||
| `root.button.code.button.filter` | The doc's "Filter: status" probably maps here |
|
||||
| `root.button.code.button.appearance` | Not in doc |
|
||||
| `root.button.code.button.show-5-more` | Pagination; not in doc |
|
||||
| `root.button.code.button.open-session-*` (5 entries) | Each is a single session row in the Code-tab list — the doc's "Per-session row" category |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc rows for this surface use Location prose only.
|
||||
|
||||
---
|
||||
|
||||
### `ui/prompt-area.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root` (top-level prompt area
|
||||
buttons), `root.button.add-files-connectors-and-more` (the `+` menu),
|
||||
`root.button.model-opus-4-7-adaptive` (model picker), and several deep
|
||||
sub-surfaces.
|
||||
|
||||
**Doc rows reconciled:** ~28
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Input field | The contenteditable / textarea itself isn't captured (no aria-label) |
|
||||
| Placeholder text | Not an interactive element |
|
||||
| Cursor caret / multi-line autosize / word wrap | Behavior, not element |
|
||||
| Paste plain text / paste image | Behavior |
|
||||
| `Enter` to send / `Shift+Enter` / `Esc` | Keyboard behavior |
|
||||
| IME composition | Not a renderer element |
|
||||
| Attachment button (left of input) | Not surfaced — possibly bundled into `root.button.add-files-connectors-and-more` |
|
||||
| File-attached chip | Conditional — no attachment at capture |
|
||||
| Multiple attachments / image preview / PDF preview | Conditional |
|
||||
| Drag-drop overlay | Conditional, only renders during drag |
|
||||
| `@filename` autocomplete | Conditional, only renders when typing `@` |
|
||||
| `+` button | Likely IS the `root.button.add-files-connectors-and-more` button — see below |
|
||||
| Slash menu (all rows: Built-in / Project skills / User skills / Plugin skills / filter / selection / `Esc`) | Walker did not type `/` to trigger the slash menu; no inventory entries |
|
||||
| Effort picker (`Cmd+Shift+E`) | Possibly inside `root.button.code.button.opus-4-7-1m-extra-high` — uncertain |
|
||||
| Stop button (replaces Send while responding) | Conditional — no in-flight response at capture |
|
||||
| Usage ring | Possibly `root.button.code.button.usage-plan-11` ("Usage: plan 11%") |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.press-and-hold-to-record` ("Press and hold to record") | Voice / dictation button in prompt area — doc has no voice input row |
|
||||
| `root.button.code.button.dictation-settings` | Dictation settings button |
|
||||
| `root.button.code.button.transcript-view-mode` | Transcript view toggle in prompt area |
|
||||
| `root.button.code.button.scroll-to-bottom` | Scroll-to-bottom affordance |
|
||||
| `root.button.code.button.accept-edits` | Permission-mode-related quick action |
|
||||
| `root.button.code.button.add` ("Add") | Likely the doc's `+` button, with a different label |
|
||||
| `root.button.code.button.usage-plan-11` ("Usage: plan 11%") | Probably the doc's "Usage ring" |
|
||||
| `root.button.code.button.opus-4-7-1m-extra-high` ("Opus 4.7 1M· Extra high") | Probably the doc's "Effort picker" |
|
||||
| All `root.button.add-files-connectors-and-more.menuitem.*` entries (Add files or photos / Add to project / Skills / Connectors / Plugins / Research / Web search / Use style) | The `+` menu contents — doc has Slash commands / Skills / Connectors / Plugins / Add plugin; inventory surfaces additional items the doc misses (Add files or photos, Add to project, Web search, Use style) |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.use-style.*` (8 entries: Normal / Learning / Concise / Explanatory / Formal / Create & edit styles / Research mode) | Style picker is a whole sub-surface the doc doesn't mention |
|
||||
| `root.button.model-opus-4-7-adaptive.menuitemradio.*` (Opus / Sonnet / Haiku / Adaptive thinking / More models) | Doc says "Sonnet, Opus, Haiku" — inventory adds Adaptive thinking + More models |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
| Doc claim | Inventory says |
|
||||
|-----------|----------------|
|
||||
| `+` button → opens menu of "Slash commands / Skills / Connectors / Plugins / Add plugin" | The corresponding inventory button is labeled "Add files, connectors, and more" with `aria-label="Add files, connectors, and more"`. Menu contents don't include "Slash commands" or "Add plugin" sub-entry — doc menu structure is partly speculative |
|
||||
|
||||
---
|
||||
|
||||
### `ui/code-tab-panes.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.code` (23 entries),
|
||||
`root.button.code.button.new-session-n` (60 entries) — but no per-pane
|
||||
sub-surfaces (no diff pane, no terminal pane, no preview pane, no file
|
||||
pane).
|
||||
|
||||
**Doc rows reconciled:** ~50
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Almost every Code-tab pane row is missing from the inventory. The walker
|
||||
landed in the Code-tab "New session" shell but did not open or drill any
|
||||
of the panes. Categories:
|
||||
|
||||
| Pane | Doc rows missing | Reason |
|
||||
|------|------------------|--------|
|
||||
| Pane chrome (header, drag/resize handles, close button, Views menu) | 5 rows | Walker coverage gap — no pane was open |
|
||||
| Diff pane | 9 rows (file list, diff content, line click, Cmd+Enter, Accept/Reject, Review code) | Walker coverage gap |
|
||||
| Preview pane | 11 rows | Walker coverage gap |
|
||||
| Terminal pane | 7 rows | Walker coverage gap (also: only renders for Local sessions) |
|
||||
| File pane | 7 rows | Walker coverage gap |
|
||||
| Tasks / subagent pane | 5 rows | Walker coverage gap |
|
||||
| Side chat overlay | 3 rows (trigger / content / close) | `root.button.code.button.close-side-chat` IS captured — the close button — but content isn't drilled |
|
||||
| CI status bar | 5 rows | Conditional — no PR open at capture |
|
||||
| View modes (Normal/Verbose/Summary) | 3 rows | Possibly behind `root.button.code.button.transcript-view-mode` — single inventory entry vs. 3 doc rows |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.code.button.local` ("Local") | Environment switcher chip — not in doc |
|
||||
| `root.button.code.button.select-folder` ("Select folder…") | Folder-picker entry — doc references this only via T17 cross-reference |
|
||||
| `root.button.code.button.send` (and `#2`, both denylisted) | Send button — doc has it under prompt-area, not panes |
|
||||
| `root.button.code.button.transcript-view-mode` | The doc's "Transcript view dropdown" — single inventory entry |
|
||||
| `root.button.code.button.opus-4-7-1m-extra-high` | Model selector inside Code-tab session shell |
|
||||
| `root.button.code.button.usage-plan-11` | Usage ring inside Code-tab session shell |
|
||||
| `root.button.code.button.accept-edits` ("Accept edits") | Permission-mode quick action — not in doc |
|
||||
| All 60 `root.button.code.button.new-session-n.button.open-session-*` and per-session entries | Doc covers the session list in `sidebar.md`, not here, so this isn't really a gap for `code-tab-panes.md` |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc is prose-only.
|
||||
|
||||
---
|
||||
|
||||
### `ui/settings.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.settings` (only 1
|
||||
entry — "Settings" button itself), `root.button.awaaddrick-max.menuitem.settingsctrl`
|
||||
(the menu-item route to Settings, label "SettingsCtrl,").
|
||||
|
||||
**Doc rows reconciled:** ~28
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
The Settings page itself is essentially un-walked. Settings opens as an
|
||||
overlay/modal which the walker treated as a single button rather than
|
||||
drilling into. Every row in the doc beyond "Settings window opens" lacks
|
||||
a matching inventory entry:
|
||||
|
||||
| Doc section | Rows missing | Reason |
|
||||
|-------------|--------------|--------|
|
||||
| Settings root (close button, sidebar nav) | 3 rows | Walker coverage gap |
|
||||
| Desktop app → General (Computer use, Keep computer awake, Denied apps, Unhide apps, Theme picker) | 5 rows | Walker coverage gap; some rows account-state-dependent |
|
||||
| Desktop app → Account (name/email, plan badge, Sign out) | 3 rows | Walker coverage gap |
|
||||
| Claude Code (Worktree location, Branch prefix, Auto-archive toggle, Persist preview, Preview toggle, Bypass-permissions toggle, Auto mode availability) | 7 rows | Walker coverage gap |
|
||||
| Connectors page (list, per-connector entry, Manage, Disconnect, Add connector) | 5 rows | Walker coverage gap; partially covered by the in-session connectors menu |
|
||||
| SSH connections (list, Add SSH connection button, per-connection entry) | 3 rows | Walker coverage gap; account-state-dependent |
|
||||
| Keyboard shortcuts (list, value, Reset, Quick Entry shortcut) | 4 rows | Walker coverage gap |
|
||||
| Local environment editor (open, Add variable, Remove variable, Apply to dev servers) | 4 rows | Walker coverage gap; account-state-dependent |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.settings` ("Settings", `aria-label="Settings"`) | The button that opens Settings — confirmed in chrome |
|
||||
| `root.button.awaaddrick-max.menuitem.settingsctrl` ("SettingsCtrl,") | Settings menu item under the user/plan menu — alternate path |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None.
|
||||
|
||||
#### Walker coverage note
|
||||
|
||||
Settings is a known walker coverage gap (see preamble). This doc is
|
||||
substantively un-reconciled until a Settings drill pass lands.
|
||||
|
||||
---
|
||||
|
||||
### `ui/routines-page.md`
|
||||
|
||||
**Inventory surfaces likely covered:** none directly. Routines are
|
||||
reachable via `root.button.code.button.routines`, but the page itself
|
||||
isn't drilled.
|
||||
|
||||
**Doc rows reconciled:** ~26
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every doc row except the "Routines page link" itself is unmatched — the
|
||||
walker captured the entry point but did not open the Routines page.
|
||||
|
||||
| Doc section | Rows missing | Reason |
|
||||
|-------------|--------------|--------|
|
||||
| Routines list (header, New routine button, list, per-routine row, Run-now icon, Pause/resume, click row) | 7 rows | Walker coverage gap |
|
||||
| New routine form Local (Name, Description, Instructions, permission-mode picker, model picker, Working folder, Worktree toggle, Schedule preset, Time picker, Day picker, Save, Cancel, Folder-trust prompt) | 13 rows | Walker coverage gap |
|
||||
| New routine form Remote (Trigger type, Connectors picker, Network access controls) | 3 rows | Walker coverage gap; doc itself is partly speculative ("Per upstream docs") |
|
||||
| Routine detail (Run now, Active/Paused toggle, Edit, Delete, Review history, hover tooltip, Show more, Always allowed, Revoke approval) | 9 rows | Walker coverage gap |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.code.button.routines` ("Routines") | The entry-point link — doc's "Routines page link" |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### `ui/connectors-and-plugins.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.add-files-connectors-and-more.menuitem.connectors`
|
||||
(the in-session connector picker, 5 entries), plus the deeper per-connector
|
||||
sub-surfaces under `.connectors.menuitemcheckbox.gmail.*` (15 entries).
|
||||
Plugin browser surfaces (`root.button.back.*`) cover Skills, Connectors,
|
||||
Add plugin, Typescript lsp, Php lsp, Playwright, Connectors, etc.
|
||||
|
||||
**Doc rows reconciled:** ~24
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Connectors menu — "Per-connector row" with status indicator | Inventory has Gmail and Google Calendar but not status decorations |
|
||||
| Empty state | Conditional — user has connectors configured |
|
||||
| Connector catalog (modal body, per-connector tile with logo/description) | Walker coverage gap — the Add-connector flow opens a modal that wasn't drilled |
|
||||
| OAuth in-app overlay | Conditional, not present at capture |
|
||||
| Permission consent screen | External (provider's UI) |
|
||||
| Callback completion | Behavior, not an element |
|
||||
| Custom connector entry point | Walker coverage gap |
|
||||
| Plugin browser modal (browser modal, marketplace selector, per-plugin tile, scope selector, install progress, success state, error state) | Walker captured plugin surfaces under `root.button.back.*` (Add plugin, Typescript lsp, Php lsp, Playwright) but not the modal anatomy |
|
||||
| Manage plugins (installed list, per-plugin row, Enable toggle, Plugin skills sub-list) | Walker coverage gap — no Manage-plugins surface drilled |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors` ("Connectors", in-session menu) | Doc covers this — the in-session Connectors menu |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail` ("Gmail") | Per-connector row — doc "Per-connector row" category |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.google-calendar` ("Google Calendar") | Per-connector row — same |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.manage-connectors` ("Manage connectors") | Doc's "Manage connectors entry" |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.add-connector` ("Add connector") | Doc has "Add connector button" in Settings; inventory shows it also exists in the in-session menu |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.tool-accessload-tools-when-needed` ("Tool accessLoad tools when needed") | Per-connector tool-access setting — not in doc |
|
||||
| `root.button.back.a.skills` ("Skills") | Plugin browser — Skills tab |
|
||||
| `root.button.back.a.connectors` / `root.button.back.a.connectors#2` (both "Connectors") | Plugin browser — Connectors tab (instance suffix `#2` indicates duplicate detection) |
|
||||
| `root.button.back.button.add-plugin` ("Add plugin") | Plugin browser — Add plugin button |
|
||||
| `root.button.back.a.typescript-lsp` / `root.button.back.a.php-lsp` / `root.button.back.a.playwright` | Installed plugins — doc treats this as "Manage plugins → Per-plugin row," walker captures the actual plugin names |
|
||||
| `root.button.back.button.connect-your-appslet-claude-read-and-write-to-the-tools-you-` ("Connect your appsLet Claude read...") | Plugin browser landing pane CTA — not in doc |
|
||||
| `root.button.back.a.create-new-skillsteach-claude-your-processes-team-norms-and-` ("Create new skillsTeach Claude your processes, team norms, and expertise.") | Skills-creation CTA — not in doc |
|
||||
| `root.button.back.button.browse-pluginsadd-pre-built-knowledge-for-your-field` ("Browse pluginsAdd pre-built knowledge for your field.") | Browse-plugins CTA — not in doc |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail.button.develop-storytelling-frameworks` and 9 similar `.option`/`.button` pairs | Connector-suggested prompt cards. Walker captured these as a side-effect of drilling Gmail — they aren't a doc-targeted UI element |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
| Doc claim | Inventory says |
|
||||
|-----------|----------------|
|
||||
| `+` → **Connectors** opens "Connectors menu" | Inventory: button is "Add files, connectors, and more" not "+"; menu item is "Connectors". Functionally the same surface |
|
||||
|
||||
---
|
||||
|
||||
### `ui/quick-entry.md`
|
||||
|
||||
**Inventory surfaces covered:** none — Quick Entry is a separate
|
||||
`BrowserWindow` constructed in the main process (`index.js:515375`), not
|
||||
part of claude.ai's renderer. The walker started at `https://claude.ai/new`
|
||||
which never reaches it.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Window appearance (frame, background, rounded corners, drop shadow,
|
||||
position, always-on-top, lifecycle, persistence after main destroy) —
|
||||
main-process BrowserWindow construction
|
||||
- Input area (text input, placeholder, multi-line, Enter/Shift+Enter,
|
||||
Esc, click-outside, paste, IME) — popup renderer (separate from
|
||||
claude.ai)
|
||||
- Submit feedback (transition, loading, error) — popup renderer + IPC
|
||||
bridge
|
||||
|
||||
This entire file is correctly out of renderer scope. Doc rows are
|
||||
already heavily annotated with `index.js:515xxx` references to upstream
|
||||
main-process source — that's the right substrate.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `ui/notifications.md`
|
||||
|
||||
**Inventory surfaces covered:** none — notifications fire via libnotify
|
||||
on the `org.freedesktop.Notifications` DBus path; they are not DOM
|
||||
elements.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Notification sources (Scheduled fires, Catch-up, CI status, PR merged,
|
||||
Dispatch handoff, Permission prompt) — main-process emitters
|
||||
- Per-notification anatomy (App identity, icon, title, body, actions,
|
||||
click target) — DBus payload
|
||||
- Per-DE rendering (KDE/GNOME/Mako/Dunst/swaync/Niri) — daemon behavior
|
||||
- Notification persistence (history, DND) — daemon behavior
|
||||
|
||||
This entire file is correctly out of renderer scope.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
## Top-level findings
|
||||
|
||||
### Coverage by source-of-truth axis
|
||||
|
||||
- **OS-level / window-manager elements** (window-chrome rows for
|
||||
title bar, close/min/max, resize edges, drop shadow) — never going to
|
||||
appear in the renderer inventory. ~10 doc rows.
|
||||
- **Main-process Electron windows** (Quick Entry popup, About dialog,
|
||||
crash dialog, file pickers) — never going to appear in the renderer
|
||||
inventory. ~25 doc rows.
|
||||
- **Tray menu** (Show/Hide, Quick Entry, Settings, About, Quit, Open
|
||||
at Login) — main-process `Menu.buildFromTemplate()`. ~12 doc rows.
|
||||
- **libnotify notifications** — DBus, not DOM. ~17 doc rows.
|
||||
- **Walker coverage gaps** (Settings overlay, Routines page, plugin
|
||||
browser modal, all Code-tab panes, dialogs, slash menu, drag-drop
|
||||
overlay) — would appear if the walker drilled them. ~70 doc rows.
|
||||
- **Account-state-dependent surfaces** (CI bar, Dispatch badges, file
|
||||
attachments, SSH connections panel) — would appear in some sessions
|
||||
but didn't at capture. ~15 doc rows.
|
||||
- **Conditional / hover / behavior** (right-click context menus, hover
|
||||
archive icons, drag-drop overlays, tooltips) — wouldn't appear in a
|
||||
static walker pass even if the surface was visited. ~10 doc rows.
|
||||
|
||||
The combined explanation: roughly half of the "in docs but not in
|
||||
renderer" mismatches are unfixable (different source of truth), and
|
||||
roughly half are walker coverage gaps that future passes can close.
|
||||
|
||||
### Top 3 surfaces with the most "in docs but not in renderer" mismatches
|
||||
|
||||
These are likely candidates for speculative claims OR for un-walked
|
||||
surfaces. Treat as triage queue:
|
||||
|
||||
1. **`ui/code-tab-panes.md`** — ~50 unmatched rows. Almost entirely
|
||||
walker-coverage gap (the walker landed in the Code-tab shell but
|
||||
opened no panes). Until the walker drills diff/preview/terminal/file/
|
||||
tasks panes, this doc is un-reconcilable.
|
||||
2. **`ui/settings.md`** — ~28 unmatched rows. Settings opens as an
|
||||
overlay; walker captured only the Settings entry-point button. Needs
|
||||
targeted drill.
|
||||
3. **`ui/routines-page.md`** — ~26 unmatched rows. Same shape as
|
||||
Settings — entry-point captured, page contents unwalked.
|
||||
|
||||
### Top 3 surfaces with the most "in renderer but not in docs" surplus
|
||||
|
||||
These docs are most-incomplete relative to ground truth:
|
||||
|
||||
1. **`ui/sidebar.md`** — Inventory has 60+ Code-tab session-list entries
|
||||
under `root.button.code.button.new-session-n`. Doc treats sessions as
|
||||
a single category row. This is intentional doc behavior, but it means
|
||||
the doc doesn't help when reasoning about the actual structural
|
||||
buttons (Filter, Appearance, Routines, More navigation items, Show 5
|
||||
more, etc.) that the walker found.
|
||||
2. **`ui/prompt-area.md`** — Inventory has the entire Use-style picker
|
||||
sub-tree (Normal / Learning / Concise / Explanatory / Formal / Create
|
||||
& edit styles + 5 preset cards), the Press-and-hold-to-record voice
|
||||
button, dictation settings, transcript view mode, scroll-to-bottom,
|
||||
and the model picker's "Adaptive thinking" / "More models" entries —
|
||||
none of which the doc enumerates.
|
||||
3. **`ui/connectors-and-plugins.md`** — Inventory has the entire plugin
|
||||
browser sub-tree (`root.button.back.*` — 12 entries: Skills, Add
|
||||
plugin, Typescript lsp, Php lsp, Playwright, Browse plugins, Create
|
||||
new skills, Connect your apps, Connectors×2, Back to Claude, Select
|
||||
a folder), and connector-suggested prompt cards (10 entries under
|
||||
`.gmail.button.*`). Doc treats these surfaces at a higher level of
|
||||
abstraction.
|
||||
|
||||
## Acknowledged gaps in inventory itself
|
||||
|
||||
Not all inventory absences are doc errors. Known walker gaps as of v6:
|
||||
|
||||
- **Settings page deep content** — only the entry-point button
|
||||
(`root.button.settings`) and the menu shortcut
|
||||
(`...menuitem.settingsctrl`) captured. Settings opens as an overlay
|
||||
the walker did not drill.
|
||||
- **Dialogs** — 0 captured. claude.ai may not use `[role=dialog]` for
|
||||
most modals, or the walker's drill paths didn't reach them.
|
||||
- **Code tab panes** — only the Code-tab session shell was drilled;
|
||||
diff, preview, terminal, file, tasks, subagent, plan, side chat, CI
|
||||
bar are uncaptured.
|
||||
- **Routines page** — only the entry-point link was captured.
|
||||
- **Plugin browser modal anatomy** — surrounding list captured, the
|
||||
per-plugin install modal wasn't.
|
||||
- **Slash menu** — walker did not type `/` to trigger.
|
||||
- **Hover/right-click/drag-only affordances** — static walker; no
|
||||
context menus or drag-drop overlays.
|
||||
- **Quick Entry / Tray / Notifications** — out of renderer scope.
|
||||
|
||||
These are walker tickets, not bugs against the v6 capture.
|
||||
|
||||
## Triage suggestions for `ui/*.md` cleanup
|
||||
|
||||
Aimed at humans editing the docs. Ordered by impact:
|
||||
|
||||
1. **Mark out-of-renderer surfaces explicitly.** `ui/tray.md`,
|
||||
`ui/quick-entry.md`, `ui/notifications.md`, and the OS-frame section
|
||||
of `ui/window-chrome-and-tabs.md` already reference main-process
|
||||
source and DE behavior — add a header note that this surface
|
||||
intentionally doesn't appear in `ui-inventory.json`.
|
||||
2. **Annotate walker-coverage-gap surfaces.** `ui/code-tab-panes.md`,
|
||||
`ui/settings.md`, `ui/routines-page.md` — header note that the
|
||||
inventory does not yet drill these surfaces; rows reflect upstream
|
||||
behavior and are unverified in the renderer.
|
||||
3. **Add missing topbar/prompt-area elements** to `ui/window-chrome-and-tabs.md`
|
||||
and `ui/prompt-area.md` from the "In renderer but not in docs" lists.
|
||||
4. **Decide the doc/inventory boundary for sidebar session lists.** Doc
|
||||
treats sessions as a category; inventory enumerates each. Pick one
|
||||
shape and document it.
|
||||
5. **Flag speculative Linux-conditional rows** — `ui/settings.md` SSH
|
||||
connections, "Denied apps" / "Unhide apps when Claude finishes" for
|
||||
Computer Use — mark as "may not render on Linux; verify before
|
||||
assuming."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"capturedAt": "2026-05-03T07:13:20.024Z",
|
||||
"appVersion": "1.5354.0",
|
||||
"walkerVersion": "7",
|
||||
"startUrl": "https://claude.ai/epitaxy",
|
||||
"totalElements": 90,
|
||||
"deniedActions": 6,
|
||||
"partial": false,
|
||||
"isolation": "launchClaude (test-harness path)",
|
||||
"seededFromHost": true,
|
||||
"allowlistEntries": []
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
# UI snapshots
|
||||
|
||||
Captured renderer state for the `claude.ai` web view, taken via the
|
||||
`explore` CLI in [`tools/test-harness/explore/`](../../../tools/test-harness/explore/).
|
||||
Use these to detect upstream UI drift before it breaks the harness.
|
||||
|
||||
The snapshot JSON files themselves are gitignored
|
||||
(`docs/testing/ui-snapshots/*.json`) — they're noisy diffs and
|
||||
specific to the moment of capture. This directory is checked in so the
|
||||
path exists; the README + `.gitkeep` are the only tracked files.
|
||||
|
||||
## Capture
|
||||
|
||||
Requires a running `claude-desktop` build with the main-process
|
||||
debugger attached on port 9229 (Developer menu → Enable Main Process
|
||||
Debugger). Then, from `tools/test-harness/`:
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts snapshot baseline-code-tab
|
||||
# → wrote /…/docs/testing/ui-snapshots/baseline-code-tab.json
|
||||
```
|
||||
|
||||
Snapshot names are restricted to `[a-zA-Z0-9._-]`.
|
||||
|
||||
## Compare
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts diff baseline-code-tab after-feature-x
|
||||
```
|
||||
|
||||
Add `--json` for machine-readable output. Add `--exit-on-diff` to fail
|
||||
the process (exit code 3) when there are any entries — useful inside a
|
||||
CI guard.
|
||||
|
||||
`diff` arguments accept either a bare name (looked up in this dir,
|
||||
`.json` appended) or an explicit path.
|
||||
|
||||
### What counts as a diff
|
||||
|
||||
| Kind | Meaning |
|
||||
|-----------|---------------------------------------------------------|
|
||||
| `removed` | Element keyed in A absent from B (drift signal). |
|
||||
| `changed` | Same key, different visible text or structural detail. |
|
||||
| `added` | New key in B (informational only — surface gained). |
|
||||
|
||||
## Snapshot shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"capturedAt": "2026-05-02T17:30:00Z",
|
||||
"claudeAiUrl": "https://claude.ai/…",
|
||||
"appVersion": "1.1.7714", // from app.getVersion(), null on failure
|
||||
"pageState": { "url", "title", "readyState" },
|
||||
"dfPills": [ /* Chat / Cowork / Code top-level tabs */ ],
|
||||
"compactPills": [ /* env pill, Select-folder pill, … */ ],
|
||||
"ariaLabeledButtons":[ /* every <button[aria-label]>, capped at 200 */ ],
|
||||
"openMenu": { "ariaLabelledBy", "ariaLabel", "items": [...] },
|
||||
"modals": [ /* role=dialog with heading + buttons */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Discovery is by **structural shape**, never by minified Tailwind class
|
||||
names. See the why-block at the top of
|
||||
[`tools/test-harness/explore/snapshot.ts`](../../../tools/test-harness/explore/snapshot.ts)
|
||||
for the rationale.
|
||||
|
||||
## Other subcommands
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts # full snapshot to stdout
|
||||
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
|
||||
npx tsx explore/explore.ts menu # currently-open menu (or null)
|
||||
npx tsx explore/explore.ts find <re> # regex search over text + aria-label
|
||||
```
|
||||
|
||||
`find` regex is case-insensitive by default.
|
||||
@@ -1,360 +0,0 @@
|
||||
{
|
||||
"derivedAt": "2026-05-03T02:51:23.409Z",
|
||||
"sourceInventory": {
|
||||
"capturedAt": "2026-05-03T00:21:38.299Z",
|
||||
"appVersion": "1.5354.0",
|
||||
"walkerVersion": "6",
|
||||
"totalElements": 383
|
||||
},
|
||||
"stable": [
|
||||
"Accept edits",
|
||||
"Add",
|
||||
"Add connector",
|
||||
"Add files",
|
||||
"Add files or photosCtrl+U",
|
||||
"Add files, connectors, and more",
|
||||
"Add from GitHub",
|
||||
"Add to project",
|
||||
"All projects",
|
||||
"Appearance",
|
||||
"Ask",
|
||||
"Back",
|
||||
"Back to Claude",
|
||||
"Chat",
|
||||
"Clear active",
|
||||
"Close",
|
||||
"Close side chat",
|
||||
"Close suggestions",
|
||||
"Code",
|
||||
"Completed: See Claude workTry a quick task — Claude does it, you watch",
|
||||
"ConcisePreset",
|
||||
"Connectors",
|
||||
"Conversation ID reference",
|
||||
"Copy invite",
|
||||
"Cowork",
|
||||
"Create custom style",
|
||||
"Create engaging headlines",
|
||||
"Create presentation scripts",
|
||||
"Develop content templates",
|
||||
"Develop storytelling frameworks",
|
||||
"Dictation settings",
|
||||
"Dismiss checklist",
|
||||
"Dismiss guest pass",
|
||||
"Draft PR visibility on GitHub",
|
||||
"ELKO HRN-33 and HRN-31 manuals",
|
||||
"Edit Instructions",
|
||||
"Electron apps Linux users desperately want but can't have\nDespite Electron's cross-platform promise, several high-profil",
|
||||
"Expand sidebar",
|
||||
"ExplanatoryPreset",
|
||||
"Feedback submission",
|
||||
"Filter",
|
||||
"Fine-tuning diffusion models with reinforcement learning",
|
||||
"FormalPreset",
|
||||
"Forward",
|
||||
"From Calendar",
|
||||
"From Gmail",
|
||||
"Get apps and extensions",
|
||||
"Gmail",
|
||||
"Google Calendar",
|
||||
"How to use ClaudeAaddrick Williams",
|
||||
"Install",
|
||||
"Invalid session description",
|
||||
"Lamination plate position offsetsAaddrick Williams",
|
||||
"Learn",
|
||||
"Learn about styles",
|
||||
"Learn how to use Cowork safely",
|
||||
"Learn more about styles",
|
||||
"Learning",
|
||||
"LearningPreset",
|
||||
"Local",
|
||||
"Manage connectors",
|
||||
"Menu",
|
||||
"Model: Legacy Model",
|
||||
"Model: Opus 4.7 Adaptive",
|
||||
"Model: Sonnet 4.6 Adaptive",
|
||||
"More navigation items",
|
||||
"More options",
|
||||
"More options for Fine-tuning diffusion models with reinforcement learning",
|
||||
"More options for How to use Claude",
|
||||
"New artifact",
|
||||
"New project",
|
||||
"Open session Audit for elementary-data supply chain vulnerability",
|
||||
"Open session Find contact method for Claude Desktop issue",
|
||||
"Open session Plan automated testing strategy for desktop app",
|
||||
"Open session Test DNS query for Claude desktop package",
|
||||
"Open session for PR #552",
|
||||
"Pair your phoneSend tasks from your phone for Claude to run here",
|
||||
"Pin project",
|
||||
"Pinned",
|
||||
"Plugins",
|
||||
"Press and hold to record",
|
||||
"Recents",
|
||||
"Research",
|
||||
"Research mode",
|
||||
"Schedule a recurring taskGreat for reminders, reports, or regular check-ins",
|
||||
"Scroll to bottom",
|
||||
"Search",
|
||||
"Search projects",
|
||||
"Select folder…",
|
||||
"Send",
|
||||
"Settings",
|
||||
"Show 5 more",
|
||||
"Show more",
|
||||
"Skills",
|
||||
"Skip to content",
|
||||
"Sort by",
|
||||
"Start a task in Cowork",
|
||||
"Style: Formal",
|
||||
"Terms apply",
|
||||
"Test",
|
||||
"Testing and Quality Assurance",
|
||||
"Tool accessLoad tools when needed",
|
||||
"Transcript view mode",
|
||||
"Untitled",
|
||||
"Use style",
|
||||
"View all",
|
||||
"Web search",
|
||||
"West Central Schools provincial takeover investigation",
|
||||
"Work in a project",
|
||||
"Write",
|
||||
"Write something in the voice of my favorite historical figure",
|
||||
"Your artifactsYour artifacts",
|
||||
"about_tab.py, py, 60 lines",
|
||||
"New chat⌘N",
|
||||
"New session⌘N",
|
||||
"New task⌘N",
|
||||
"Artifacts",
|
||||
"Live artifacts",
|
||||
"Scheduled",
|
||||
"DispatchBeta",
|
||||
"Routines",
|
||||
"How to use Claude",
|
||||
"Projects",
|
||||
"Customize"
|
||||
],
|
||||
"instanceShapes": [
|
||||
{
|
||||
"id": "plan-badge",
|
||||
"regex": "^.+·(Free|Pro|Max|Team|Enterprise)[-\\s]*$",
|
||||
"flags": "u",
|
||||
"pattern": "\\w+·(Free|Pro|Max|Team|Enterprise)",
|
||||
"matchedNames": [
|
||||
"AWAaddrick·Max"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "opus-version",
|
||||
"regex": "^Opus \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Opus \\d",
|
||||
"matchedNames": [
|
||||
"Opus 4.7 1M· Extra high",
|
||||
"Opus 4.7Most capable for ambitious work"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sonnet-version",
|
||||
"regex": "^Sonnet \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Sonnet \\d",
|
||||
"matchedNames": [
|
||||
"Sonnet 4.6Most efficient for everyday tasks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "haiku-version",
|
||||
"regex": "^Haiku \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Haiku \\d",
|
||||
"matchedNames": [
|
||||
"Haiku 4.5Fastest for quick answers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "percentage",
|
||||
"regex": "\\d{1,3}%$",
|
||||
"flags": "",
|
||||
"pattern": "\\d{1,3}%",
|
||||
"matchedNames": [
|
||||
"Usage: plan 11%"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "relative-date",
|
||||
"regex": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)",
|
||||
"flags": "",
|
||||
"pattern": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)(\\+\\d+)?",
|
||||
"matchedNames": [
|
||||
"Claude Desktop Debian1 year ago",
|
||||
"Draft PR visibility on GitHubYesterday",
|
||||
"ELKO HRN-33 and HRN-31 manualsYesterday",
|
||||
"Feedback submissionYesterday",
|
||||
"Find contact method for Claude Desktop issuePR #552 · Yesterday",
|
||||
"Review PR 555 for issue 558 fixToday",
|
||||
"Review and analyze issue 545Yesterday"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "size-with-unit",
|
||||
"regex": "^\\d+\\.\\d+\\s\\w+",
|
||||
"flags": "",
|
||||
"pattern": "^\\d+\\.\\d+\\s\\w+",
|
||||
"matchedNames": []
|
||||
},
|
||||
{
|
||||
"id": "user-handle",
|
||||
"regex": "@\\w+",
|
||||
"flags": "",
|
||||
"pattern": "@\\w+",
|
||||
"matchedNames": []
|
||||
},
|
||||
{
|
||||
"id": "long-title",
|
||||
"regex": "^[A-Z][a-z]+ [A-Z][a-z]+ [a-z]",
|
||||
"flags": "",
|
||||
"pattern": null,
|
||||
"matchedNames": [
|
||||
"Evaluate Terraform for infrastructure setup",
|
||||
"Host Obsidian library in second database"
|
||||
]
|
||||
}
|
||||
],
|
||||
"suspect": [
|
||||
"Adaptive thinkingThinks for more complex tasks",
|
||||
"Add build instructions and patch toggle option",
|
||||
"Add build instructions and quick menu patch toggle",
|
||||
"Add plugin",
|
||||
"Audit for elementary-data supply chain vulnerability",
|
||||
"Automate",
|
||||
"Browse pluginsAdd pre-built knowledge for your field.",
|
||||
"Build adversarial resume review platform MVP",
|
||||
"Change fonts to Lexend",
|
||||
"Check Quad9 DNS resolution for package domain",
|
||||
"Check flight map tile caching history",
|
||||
"Check for Trivy supply chain vulnerability",
|
||||
"Claude Desktop DebianAaddrick Williams",
|
||||
"Claude Desktop DebianEnter",
|
||||
"Claude is AI and can make mistakes. Please double-check responses.",
|
||||
"Claude prompting guide.md, md, 413 lines",
|
||||
"Clawdmartclawdmart.comClaudeCreate a shopping list, go on Chrome, and make an order",
|
||||
"Collapse sidebar",
|
||||
"Compare GPU options for gaming performance",
|
||||
"Concise",
|
||||
"Connect your appsLet Claude read and write to the tools you already use.",
|
||||
"Copy",
|
||||
"Create & edit styles",
|
||||
"Create new skillsTeach Claude your processes, team norms, and expertise.",
|
||||
"Create user documentation",
|
||||
"Customer Email",
|
||||
"Data",
|
||||
"Develop editorial guidelines",
|
||||
"Dispatch background conversation",
|
||||
"Download",
|
||||
"Draw",
|
||||
"Edit",
|
||||
"Educational Content",
|
||||
"Evaluate productization viability of methodology",
|
||||
"Explanatory",
|
||||
"Find contact method for Claude Desktop issue",
|
||||
"Fix Claude Desktop installation on Debian",
|
||||
"Formal",
|
||||
"Formulas",
|
||||
"Give negative feedback",
|
||||
"Give positive feedback",
|
||||
"Help me develop a unique voice for an audience",
|
||||
"Home",
|
||||
"How to use ClaudeAn example project that also doubles as a how-to guide for using Claude. Chat with it to learn more abo",
|
||||
"Identify tools for session start hook",
|
||||
"Insert",
|
||||
"Investigate GitHub Actions workflow failure",
|
||||
"Investigate GitHub issue 394 comment",
|
||||
"Investigate leaked crates.io API key",
|
||||
"Investigate leaked crates.io token in repository",
|
||||
"Lamination plate position offsetsAdjust existing code to just populate a table with original positions, new positions, a",
|
||||
"Marketing Blog Post",
|
||||
"More models",
|
||||
"More options for Claude Desktop Debian",
|
||||
"More options for Lamination plate position offsets",
|
||||
"My downloads folder is a mess! Can you clean it up?",
|
||||
"Normal",
|
||||
"Open",
|
||||
"Options",
|
||||
"Page Layout",
|
||||
"Php lsp",
|
||||
"Plan automated testing strategy for desktop app",
|
||||
"Playwright",
|
||||
"Product Review",
|
||||
"Read health data",
|
||||
"Retry",
|
||||
"Review",
|
||||
"Review PR 555 for issue 558 fix",
|
||||
"Review and address issue 88",
|
||||
"Review and analyze issue 545",
|
||||
"Review and close stale issues",
|
||||
"Review and investigate GitHub issue 445",
|
||||
"Review issue 156",
|
||||
"Review issue 172 and document related history",
|
||||
"Review issue 373",
|
||||
"Review last three repository commits",
|
||||
"Review path resolution issues and pull requests",
|
||||
"Review project issues and pull requests",
|
||||
"Review recent comments, issues, and pull requests",
|
||||
"Select a folder",
|
||||
"Share chat",
|
||||
"Short Story",
|
||||
"Start a new project",
|
||||
"Start return",
|
||||
"Style: Concise",
|
||||
"Style: Explanatory",
|
||||
"Style: Learning",
|
||||
"Test DNS lookup with Quad9 resolver",
|
||||
"Test DNS query for Claude desktop package",
|
||||
"Test path resolution",
|
||||
"Test startsession hook functionality",
|
||||
"Troubleshoot modem downstream connection issue",
|
||||
"Turn these receipts into an expense report",
|
||||
"Typescript lsp",
|
||||
"Unpin project",
|
||||
"Untitled, rename chat",
|
||||
"View",
|
||||
"Write case studies",
|
||||
"Write speech drafts",
|
||||
"analyze_project.py, py, 220 lines",
|
||||
"base_half_sheet.py, py, 32 lines",
|
||||
"changelog_viewer_component.py, py, 113 lines",
|
||||
"colors.py, py, 103 lines",
|
||||
"compensation.py, py, 50 lines",
|
||||
"components.py, py, 118 lines",
|
||||
"components.py, py, 119 lines",
|
||||
"config_reader.py, py, 120 lines",
|
||||
"contraction_tab.py, py, 105 lines",
|
||||
"contraction_tab.py, py, 82 lines",
|
||||
"conversions.py, py, 28 lines",
|
||||
"data_parser.py, py, 87 lines",
|
||||
"dialogs.py, py, 34 lines",
|
||||
"file_operations.py, py, 43 lines",
|
||||
"log.py, py, 140 lines",
|
||||
"log.py, py, 236 lines",
|
||||
"machines.ini, ini, 2 lines",
|
||||
"main.py, py, 203 lines",
|
||||
"main.py, py, 264 lines",
|
||||
"output_tab.py, py, 191 lines",
|
||||
"output_tab.py, py, 246 lines",
|
||||
"process_request.py, py, 632 lines",
|
||||
"processing_format.ini, ini, 2 lines",
|
||||
"setup_tab.py, py, 120 lines",
|
||||
"setup_tab.py, py, 177 lines",
|
||||
"sheet_dimensions.ini, ini, 3 lines",
|
||||
"version 0.1.0.md, md, 42 lines",
|
||||
"version 0.1.1.md, md, 31 lines",
|
||||
"version 0.1.2.md, md, 18 lines",
|
||||
"View all plans",
|
||||
"Get apps and extensions",
|
||||
"Gift Claude",
|
||||
"Language",
|
||||
"Get help",
|
||||
"Learn more",
|
||||
"Log out",
|
||||
"SettingsCtrl,"
|
||||
]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# UI Element Inventory
|
||||
|
||||
This directory holds per-surface UI checklists. Where [`../cases/`](../cases/) tests verify *behavior end-to-end*, files here verify *every UI element renders and responds* on Linux.
|
||||
|
||||
## Why a separate directory
|
||||
|
||||
A functional test like [T17 — Folder picker opens](../cases/code-tab-foundations.md#t17--folder-picker-opens) verifies the folder picker works. A UI checklist asks the smaller, more granular questions:
|
||||
|
||||
- Is the **Select folder** button visually present?
|
||||
- Does its hover state render?
|
||||
- Is the icon next to it the correct shape on a HiDPI screen?
|
||||
- Does it tab-focus correctly?
|
||||
- Does it have an accessible name (a11y)?
|
||||
|
||||
Functional tests catch "the feature broke." UI checklists catch "the feature works but looks wrong." Both matter on Linux because Electron under different DEs / display servers / GTK theme combinations produces visual artifacts that aren't behavioral failures.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Surface | Notes |
|
||||
|------|---------|-------|
|
||||
| [`window-chrome-and-tabs.md`](./window-chrome-and-tabs.md) | OS window frame + hybrid in-app topbar + Chat/Cowork/Code tabs | Crosses with [T04](../cases/tray-and-window-chrome.md#t04--window-decorations-draw), [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
|
||||
| [`tray.md`](./tray.md) | System tray icon + menu + theme variants | Crosses with [T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update) |
|
||||
| [`sidebar.md`](./sidebar.md) | Session sidebar in Code tab | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
| [`prompt-area.md`](./prompt-area.md) | Code-tab prompt input area | Crosses with [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
|
||||
| [`code-tab-panes.md`](./code-tab-panes.md) | Diff, preview, terminal, file, tasks, subagent, plan, side-chat | Crosses with [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens) |
|
||||
| [`settings.md`](./settings.md) | All Settings pages | Crosses with [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
|
||||
| [`routines-page.md`](./routines-page.md) | Routines list + new-routine form + detail page | Crosses with [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
|
||||
| [`connectors-and-plugins.md`](./connectors-and-plugins.md) | Connector picker, connector list, plugin browser, plugin manager | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| [`quick-entry.md`](./quick-entry.md) | Quick Entry popup window | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| [`notifications.md`](./notifications.md) | libnotify rendering for all notification sources | Crosses with [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
|
||||
## Standard checklist row
|
||||
|
||||
Each UI file uses tables of the form:
|
||||
|
||||
| Element | Selector / location | Expected | Notes |
|
||||
|---------|---------------------|----------|-------|
|
||||
| Close button | Top-right of titlebar | Renders, hover state visible, click hides to tray (see T08) | KDE-W: ✓ |
|
||||
|
||||
Columns:
|
||||
|
||||
- **Element** — human-readable name.
|
||||
- **Selector / location** — DOM selector if known, otherwise plain-language pointer ("right-click menu, second item from top"). The selector column is what becomes a Playwright/CDP assertion when automation lands.
|
||||
- **Expected** — what the user should see / what should happen on click. Concise.
|
||||
- **Notes** — known issues, environment caveats, screenshot links.
|
||||
|
||||
## Sweep workflow
|
||||
|
||||
A UI sweep on a row:
|
||||
|
||||
1. Take a baseline screenshot of each surface (`scrot`, `gnome-screenshot`, `grim`, `flameshot`).
|
||||
2. Walk each table top-to-bottom. For each row, look at the element, click/hover/tab to it, compare against Expected.
|
||||
3. Mark anomalies in the **Notes** column or file an issue if the deviation is environment-specific.
|
||||
4. Save screenshots of any failure to a dated folder; reference them inline.
|
||||
|
||||
UI rows don't have stable IDs (`T##` / `S##`) — they're append-only checkpoints. When something becomes a regression candidate worth tracking long-term, promote it to a functional test in [`../cases/`](../cases/).
|
||||
|
||||
## Automation roadmap
|
||||
|
||||
Each UI checklist row is a candidate Playwright (via [Electron driver](https://playwright.dev/docs/api/class-electron)) or `xdotool` assertion:
|
||||
|
||||
```typescript
|
||||
// Playwright shape
|
||||
await page.locator('[data-testid="close-button"]').click()
|
||||
await expect(window).toBeHidden()
|
||||
```
|
||||
|
||||
Or for pure visual diffing:
|
||||
|
||||
```bash
|
||||
# scrot + perceptualdiff
|
||||
scrot -u baseline.png
|
||||
# ... interaction ...
|
||||
scrot -u current.png
|
||||
perceptualdiff baseline.png current.png
|
||||
```
|
||||
|
||||
The structure here is intentionally diff-friendly: rows are stable, tables are append-only, selectors live in their own column.
|
||||
@@ -1,114 +0,0 @@
|
||||
# UI — Code Tab Panes
|
||||
|
||||
Drag-and-drop panes inside a Code-tab session: diff, preview, terminal, file editor, tasks, subagent, plan, side chat. Related functional tests: [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens).
|
||||
|
||||
## Pane chrome (common)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Pane header | Top of pane | Shows pane title, drag handle, close button | — |
|
||||
| Drag handle | Pane header | Drag repositions the pane in the layout | — |
|
||||
| Resize handle | Edge between panes | Drag resizes; double-click resets | — |
|
||||
| Close pane button | Pane header right | `Cmd+\` or Ctrl+\\ shortcut equivalent | — |
|
||||
| Views menu | Session toolbar | Lists all openable panes; click to add | — |
|
||||
|
||||
## Diff pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Diff stats indicator | Chat / sidebar (entry point) | Shows `+12 -1` style. Click opens diff pane | — |
|
||||
| File list | Left side of pane | Lists changed files, click to navigate | — |
|
||||
| Diff content | Right side | Side-by-side or unified diff renders cleanly | Theme-aware (dark/light) |
|
||||
| Line click → comment box | Click any line | Opens inline comment input | — |
|
||||
| Comment submit (`Cmd+Enter` / `Ctrl+Enter`) | Press the shortcut after writing | Submits all comments at once | — |
|
||||
| Accept button | Per-file or per-hunk | Applies the change to disk | — |
|
||||
| Reject button | Per-file or per-hunk | Discards the change | — |
|
||||
| **Review code** button | Top-right of pane | Triggers Claude self-review of diff | — |
|
||||
|
||||
## Preview pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Preview dropdown | Session toolbar | Lists configured servers from `.claude/launch.json` | — |
|
||||
| **Start** action | Per-server entry | Launches the dev server | — |
|
||||
| **Stop** action | Per-server entry | Stops the dev server | — |
|
||||
| **Stop all servers** | Dropdown bottom | Stops every running server | — |
|
||||
| **Edit configuration** | Dropdown bottom | Opens `.claude/launch.json` in the file pane | — |
|
||||
| **Persist sessions** toggle | Dropdown | Persists cookies / localStorage across server restarts | — |
|
||||
| Embedded browser frame | Pane content | Renders the running app | Uses Electron `<webview>` or `BrowserView` |
|
||||
| URL bar / address | Top of pane | Shows current URL; editable | — |
|
||||
| Reload button | Top of pane | Reloads the embedded URL | — |
|
||||
| DevTools toggle | Top of pane (right) | Opens Electron DevTools for the embedded view | — |
|
||||
| Auto-verify screenshots | When Claude verifies a change | Brief overlay shows screenshot being captured | — |
|
||||
|
||||
## Terminal pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Terminal pane | Opened via `Ctrl+`` or Views menu | Bash/zsh/fish session in the working directory ([T19](../cases/code-tab-foundations.md#t19--integrated-terminal)) | Local sessions only |
|
||||
| Cursor | Inside terminal | Blinks; cursor shape per shell | — |
|
||||
| Resize | Drag pane edges | Terminal cols/rows update; `tput cols` reflects new width | SIGWINCH should fire |
|
||||
| Scrollback | Type many lines | Scrollable history; mouse scroll wheel works | — |
|
||||
| Color rendering | Run `ls --color=auto`, `tput colors` | 256-color or truecolor support; theme-aware | — |
|
||||
| Copy / paste | Select + `Ctrl+Shift+C` / `Ctrl+Shift+V` | Standard terminal-emulator shortcuts | — |
|
||||
| Working directory inheritance | Open pane in a session | Opens at the session's project folder | Confirm with `pwd` |
|
||||
|
||||
## File pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| File pane | Opened by clicking a file path | Shows file content, syntax-highlighted | — |
|
||||
| Save button | Pane toolbar | Writes current content to disk | — |
|
||||
| Path label | Pane header | Click copies absolute path | — |
|
||||
| On-disk-changed warning | If file changed externally after open | Banner with Override / Discard options ([T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves)) | — |
|
||||
| Discard button | When edits unsaved | Reverts to disk content | — |
|
||||
| Cursor / selection | Inside content | Renders correctly; multi-cursor not supported | — |
|
||||
| Find / replace | `Ctrl+F` | Opens find-in-file overlay | Verify scoped to current pane only |
|
||||
|
||||
## Tasks pane / subagent pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Tasks pane | Opened via Views menu | Lists subagents, background shell commands, workflows | — |
|
||||
| Task entry click | Click any task | Opens the subagent pane with output | — |
|
||||
| Stop task button | Per-task | Sends interrupt signal | — |
|
||||
| Task status indicator | Per-task | Running / Completed / Failed | — |
|
||||
| Output stream | Inside subagent pane | Live-updating stdout/stderr | — |
|
||||
|
||||
## Side chat overlay
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Side chat trigger | `Ctrl+;` or `/btw` in main prompt | Opens overlay attached to current session ([T31](../cases/code-tab-workflow.md#t31--side-chat-opens)) | — |
|
||||
| Side chat content | Overlay body | Reads main thread context; replies stay in side chat | — |
|
||||
| Close button | Overlay top-right | Closes side chat, returns focus to main session | — |
|
||||
|
||||
## CI status bar
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| CI status row | Below prompt area when PR open | Shows current check states | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
|
||||
| **Auto-fix** toggle | Top of CI bar | Toggles automatic check-failure fixes | — |
|
||||
| **Auto-merge** toggle | Top of CI bar | Toggles auto-merge on green | Requires GitHub repo setting |
|
||||
| Per-check entries | Each CI check | Shows pass / fail / pending state | Click to see logs |
|
||||
| CI completion notification | When all checks resolve | Desktop notification posted ([T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire)) | — |
|
||||
|
||||
## View modes
|
||||
|
||||
| Mode | Trigger | Expected | Notes |
|
||||
|------|---------|----------|-------|
|
||||
| Normal | Default; cycle via `Ctrl+O` | Tool calls collapsed into summaries, full text responses | — |
|
||||
| Verbose | Cycle via `Ctrl+O` | Every tool call, file read, intermediate step | Use for debugging |
|
||||
| Summary | Cycle via `Ctrl+O` | Only Claude's final responses + changes | Use when scanning many sessions |
|
||||
| Transcript view dropdown | Next to send button | Same as `Ctrl+O` | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Pane drag doesn't snap to layout zones | Layout engine state corruption; restart session | — |
|
||||
| Terminal cursor doesn't blink | `xterm-256color` not propagated; `TERM` env wrong | `echo $TERM` inside the pane |
|
||||
| File pane "Save" silently no-ops | Read-only filesystem ([S28](../cases/extensibility.md#s28--worktree-creation-surfaces-clear-error-on-read-only-mounts)); permissions wrong | `stat <file>` for ownership |
|
||||
| Preview pane embedded browser blank | Dev server didn't bind expected port; `autoPort` config | Check launcher log; `lsof -i :<port>` |
|
||||
| Auto-verify screenshots fail | Headless screenshot in embedded view broken on Wayland | Test on X11 row; report to upstream |
|
||||
| CI bar shows stale state | `gh` polling interval; rate-limited | `gh api rate_limit`; manual `gh pr checks <num>` |
|
||||
@@ -1,70 +0,0 @@
|
||||
# UI — Connectors & Plugins
|
||||
|
||||
Connector picker, connectors list, plugin browser, plugin manager. Related functional tests: [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths).
|
||||
|
||||
## Connector picker (in-session)
|
||||
|
||||
Triggered by `+` → **Connectors** in the prompt area.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connectors menu | Opened from `+` button | Lists configured connectors + "Manage connectors" entry | — |
|
||||
| Per-connector row | Menu item | Name, status indicator (connected / not configured), action button | — |
|
||||
| **Manage connectors** entry | Bottom of menu | Opens Settings → Connectors | Crosses with [`settings.md`](./settings.md#connectors) |
|
||||
| Empty state | When no connectors configured | Helpful prompt with "Add connector" call to action | — |
|
||||
|
||||
## Connectors list (Settings → Connectors)
|
||||
|
||||
See [`settings.md`](./settings.md#connectors) for the surface.
|
||||
|
||||
## Add-connector flow
|
||||
|
||||
Triggered from the connector picker or Settings.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connector catalog | Modal body | Searchable list (Slack, GitHub, Linear, Notion, Google Calendar, etc.) | — |
|
||||
| Per-connector tile | Catalog entry | Logo, name, short description | — |
|
||||
| **Connect** button | Per tile | Initiates OAuth flow ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | Click → `xdg-open` to provider |
|
||||
| OAuth in-app overlay (if used) | Replaces system browser handoff in some flows | Embedded login pane | — |
|
||||
| Permission consent screen | OAuth provider side | Provider's UI; not under our control | — |
|
||||
| Callback completion | After OAuth completes | Returns to Claude Desktop, connector now in list | If the URL scheme handler is broken, user is stranded in browser |
|
||||
| Custom connector entry point | Catalog bottom | "Add custom connector via remote MCP" link | — |
|
||||
|
||||
## Plugin browser
|
||||
|
||||
Triggered by `+` → **Plugins** → **Add plugin**, or from sidebar **Customize** → **Plugins**.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Plugin browser modal | Opened from menu | Searchable marketplace catalog | — |
|
||||
| Marketplace selector | Top of modal | Default: Anthropic official; user-configured marketplaces also visible | — |
|
||||
| Per-plugin tile | Catalog body | Name, author, description, install count | — |
|
||||
| **Install** button | Per tile | Click installs to `~/.claude/plugins/` ([T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths)) | — |
|
||||
| Plugin scope selector | Per install | User / Project / Local-only | — |
|
||||
| Install progress indicator | During install | Spinner + "Installing X..." text | — |
|
||||
| Install success state | After install | Confirmation; plugin now in **Manage plugins** | — |
|
||||
| Install error state | On failure | Error message identifying the cause (network, signature, conflict) | — |
|
||||
|
||||
## Manage plugins
|
||||
|
||||
Triggered by `+` → **Plugins** → **Manage plugins**.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Installed plugins list | Modal body | One row per installed plugin | — |
|
||||
| Per-plugin row | List item | Name, version, scope (User / Project / Local), enable toggle, uninstall button | — |
|
||||
| Enable toggle | Per row | Toggles plugin on/off without uninstall | — |
|
||||
| **Uninstall** button | Per row | Removes plugin files from `~/.claude/plugins/` | Confirmation expected |
|
||||
| Plugin skills sub-list | Expand row | Lists skills, agents, hooks, MCP servers, LSP configs the plugin contributes | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Connect OAuth doesn't return to app | Custom URI scheme not registered ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | `xdg-mime query default x-scheme-handler/claude` |
|
||||
| Plugin browser empty | Marketplace fetch failed; offline | DevTools network panel |
|
||||
| Install progress stalls | Network / signature verification | Launcher log; check `~/.claude/plugins/.partial/` for incomplete downloads |
|
||||
| Plugin installed but skills don't appear | Slash menu cache stale; restart session | — |
|
||||
| Uninstall leaves files | Filesystem permissions; some plugin files owned by root | `find ~/.claude/plugins/ -not -user $USER` |
|
||||
| Connector "Connected" but tools fail | Token expired; backend refuses; needs reconnect | Disconnect → reconnect |
|
||||
@@ -1,59 +0,0 @@
|
||||
# UI — Desktop Notifications
|
||||
|
||||
Notification rendering across DEs. The app dispatches notifications via `org.freedesktop.Notifications` (libnotify spec); each DE renders them differently. Related functional tests: [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
|
||||
|
||||
## Notification sources
|
||||
|
||||
The app posts notifications for the following events. Each should fire reliably on every supported DE.
|
||||
|
||||
| Source | Trigger | Expected text | Click action | Notes |
|
||||
|--------|---------|---------------|--------------|-------|
|
||||
| Scheduled task fires | When a routine starts a run | "Scheduled task `<name>` started" or similar | Focus the new session in sidebar | Crosses with [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
|
||||
| Catch-up run | When a missed run starts after wake | "Catching up on `<name>`" + missed-time hint | Focus the catch-up session | Crosses with [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend) |
|
||||
| CI status change | When PR's CI state resolves | "CI passed for `<branch>`" or "CI failed: `<check>`" | Focus the session with CI bar | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
|
||||
| PR merged (auto-archive trigger) | When watched PR merges | "PR `<title>` merged. Session archived" | — | Crosses with [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
|
||||
| Dispatch handoff | When a Dispatch task creates a Code session | "Dispatch session ready: `<task>`" | Focus the new Dispatch-badged session | Crosses with [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
| Permission prompt awaiting approval | When a session in Ask mode needs user approval | "Claude needs your approval" | Focus the awaiting session | Sessions in Ask mode stall until answered |
|
||||
|
||||
## Per-notification anatomy
|
||||
|
||||
Each notification should include:
|
||||
|
||||
| Element | Expected | Notes |
|
||||
|---------|----------|-------|
|
||||
| App identity | "Claude" or "Claude Desktop" as the source | DE-specific (Plasma shows the app name and icon prominently) |
|
||||
| Notification icon | App icon (theme-aware) | Should match the same icon set as the tray |
|
||||
| Title | Short event headline | One line, no truncation issues for typical lengths |
|
||||
| Body | One or two short lines of context | Wrap correctly for the DE's notification width |
|
||||
| Actions (if any) | Inline buttons (e.g. "Open", "Dismiss") | Some DEs show actions, some require expand |
|
||||
| Click target | Activates the relevant session/window | — |
|
||||
|
||||
## Per-DE rendering
|
||||
|
||||
| DE / daemon | Expected render | Caveats |
|
||||
|-------------|-----------------|---------|
|
||||
| KDE Plasma | KDE notification daemon (KNotifications); appears top-right by default; inline action buttons supported | — |
|
||||
| GNOME Shell | gnome-shell built-in; appears top-center; limited action support | — |
|
||||
| Mako (wlroots) | Stacked notifications top-right by default; supports actions if config allows | — |
|
||||
| Dunst | Lightweight; respects `~/.config/dunst/dunstrc`; actions via keybinds | — |
|
||||
| swaync (Sway) | Notification center + popups | — |
|
||||
| Niri | Compositor-provided; usually a portable daemon (mako, dunst) | — |
|
||||
|
||||
## Notification persistence
|
||||
|
||||
| Element | Expected | Notes |
|
||||
|---------|----------|-------|
|
||||
| Notification history | DE-dependent (KDE has notification panel; GNOME has Calendar drawer; mako/dunst can be configured) | Don't rely on persistence — assume fire-and-forget |
|
||||
| Do-not-disturb mode | Respect DE's DND state | If user has DND on, notifications shouldn't fire — verify the daemon honors this |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| No notifications appear | No daemon running; service not registered | `gdbus call --session --dest=org.freedesktop.Notifications --object-path=/org/freedesktop/Notifications --method=org.freedesktop.DBus.Introspectable.Introspect`; `notify-send "test"` from terminal |
|
||||
| Notification fires but no icon | Icon path resolution failed; theme strip | Inspect the dbus call body for `app_icon` value |
|
||||
| Click does nothing | Action handler IPC missed; window already focused | Click while main window is hidden — does it appear? |
|
||||
| Title/body cut off | DE truncation policy | Test with shorter strings to confirm content vs. layout |
|
||||
| Notifications fire even in DND | Daemon ignoring DND, or our app sets `urgency=critical` inappropriately | Check `urgency` hint in the dbus call |
|
||||
| Notification persists indefinitely | `expire_timeout=-1` (never) used inappropriately | Confirm timeout passed in the dbus call |
|
||||
| Per-source duplicates | Multiple subscribers to the same event | Diagnose by isolating one source at a time |
|
||||
@@ -1,76 +0,0 @@
|
||||
# UI — Code Tab Prompt Area
|
||||
|
||||
The prompt input area is where users type messages, attach files, pick model and permission mode, and trigger send/stop. Related functional tests: [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu).
|
||||
|
||||
## Text input
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Input field | Bottom center of session pane | Single-line on focus, expands to multi-line as user types | — |
|
||||
| Placeholder text | Empty state | Helpful hint ("Type to message Claude...") | — |
|
||||
| Cursor caret | Inside input | Blinks; visible against any background | — |
|
||||
| Multi-line autosize | Type a long message | Input grows up to a max height, then scrolls | — |
|
||||
| Word wrap | Long text | Wraps at field width without horizontal scroll | — |
|
||||
| Paste plain text | `Ctrl+V` after copying text | Inserts at cursor | — |
|
||||
| Paste image | `Ctrl+V` after copying an image | Attaches as file (see attachments below) | — |
|
||||
| `Enter` to send | Press Enter | Submits prompt | — |
|
||||
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | — |
|
||||
| `Esc` | Press Esc when prompt has content | DE-dependent; typically does nothing in input | — |
|
||||
| IME composition | Compose a CJK character | Composition UI renders correctly above the input | Fcitx5/IBus integration |
|
||||
|
||||
## Attachments
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Attachment button | Left of input (paperclip icon) | Click opens native file chooser | Wayland: portal-backed |
|
||||
| File-attached chip | Above or inside input | Shows filename + remove (X) button | — |
|
||||
| Multiple attachments | Attach 3+ files | Each shows as a separate chip; stacked if needed | — |
|
||||
| Image preview thumbnail | Image attachments | Shows small thumbnail | — |
|
||||
| PDF preview | PDF attachments | Shows generic PDF icon + filename | — |
|
||||
| Drag-drop overlay | Drag a file from file manager into the prompt | Overlay highlight indicates drop zone; release attaches ([T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt)) | — |
|
||||
| `@filename` autocomplete | Type `@` in prompt | Dropdown shows matching project files | Local and SSH only |
|
||||
|
||||
## `+` menu (skills, plugins, connectors)
|
||||
|
||||
| Element | Position in menu | Expected | Notes |
|
||||
|---------|------------------|----------|-------|
|
||||
| `+` button | Adjacent to attachment button | Click opens menu | — |
|
||||
| **Slash commands** entry | Top of menu | Opens slash command picker (same as typing `/`) | Crosses with [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
|
||||
| **Skills** entry | Mid-menu | Opens skill browser | — |
|
||||
| **Connectors** entry | Mid-menu | Opens connector picker / status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| **Plugins** entry | Mid-menu | Opens installed plugin list | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser) |
|
||||
| **Add plugin** subentry | Under Plugins | Opens plugin browser | — |
|
||||
|
||||
## Slash menu (triggered by typing `/`)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Menu container | Above prompt input | Modal-like overlay, scrollable | — |
|
||||
| Built-in commands section | Top of list | Lists `/btw`, `/compact`, etc. | — |
|
||||
| Project skills section | Mid-list | Lists skills from `.claude/skills/` | — |
|
||||
| User skills section | Mid-list | Lists skills from `~/.claude/skills/` | — |
|
||||
| Plugin skills section | Bottom-list | Lists skills from installed plugins | — |
|
||||
| Filter by typing | Type after `/` | Narrows the list | — |
|
||||
| Selected item insertion | `Enter` or click | Inserts highlighted token in prompt | — |
|
||||
| `Esc` to dismiss | Press Esc | Closes menu, keeps `/` typed | — |
|
||||
|
||||
## Pickers next to send button
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Model picker | Right of input | Dropdown of Sonnet, Opus, Haiku (per current plan availability) | `Cmd+Shift+I` opens |
|
||||
| Permission mode picker | Right of input | Dropdown of Ask, Auto accept, Plan, Auto, Bypass | `Cmd+Shift+M` opens |
|
||||
| Effort picker (when applicable) | Right of input | Dropdown of effort levels for adaptive-reasoning models | `Cmd+Shift+E` opens |
|
||||
| Send button | Far right | Click submits prompt | — |
|
||||
| Stop button | Replaces Send while Claude responding | Click interrupts current response | `Esc` shortcut equivalent |
|
||||
| Usage ring | Adjacent to model picker | Shows context window usage + plan usage | Click for details |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Drag-drop overlay doesn't appear | Electron drag-drop event not firing on Wayland | Try X11 fallback to isolate |
|
||||
| `@filename` autocomplete returns empty | Project-folder access not granted; folder picker [T17](../cases/code-tab-foundations.md#t17--folder-picker-opens) failed silently | Verify env pill shows the right folder |
|
||||
| Slash menu shows wrong skills | Settings shared between desktop and CLI ([T36](../cases/extensibility.md#t36--hooks-fire), [T37](../cases/extensibility.md#t37--claudemd-memory-loads)) | Check `~/.claude/skills/` content vs what's listed |
|
||||
| Send button greyed out unexpectedly | Permission mode or model not loaded | Refresh; check model dropdown |
|
||||
| IME composition broken | Electron IME pipeline regression | Test with simpler Electron app |
|
||||
@@ -1,49 +0,0 @@
|
||||
# UI — Quick Entry Popup
|
||||
|
||||
The Quick Entry popup is the global-shortcut-triggered prompt overlay. Related functional tests: [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S09](../cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame), [S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity), [S33](../cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version), [S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone), [S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy).
|
||||
|
||||
## Window appearance
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Window frame | None (frameless popup) | No OS-titlebar; no close/min/max buttons | Upstream sets `frame: false` on the BrowserWindow (`index.js:515381`) |
|
||||
| Background | Behind prompt UI | Transparent (no opaque square frame visible) on KDE Plasma Wayland ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | Upstream already sets both `transparent: true` and `backgroundColor: "#00000000"` (`index.js:515380, 515383`). #370 regression is below the option-passing layer (Electron 41.0.4 CSD rework). KDE-W: pending; bug if opaque |
|
||||
| Rounded corners | Outer edge of UI | Visible | Compositor must support corner rounding via shaders / clip mask |
|
||||
| Drop shadow | Around popup | macOS-only at the Electron level; on Linux/Windows depends entirely on compositor | Upstream sets `hasShadow: Zr` where `Zr === process.platform === "darwin"` (`index.js:515384`). Linux is expected to render via compositor shadow support; wlroots without server-side decorations will not show one |
|
||||
| Position | Last-saved position, keyed on monitor; falls back to primary display if monitor is gone | Popup remembers its position across invocations and across app restarts ([S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone)) | Upstream uses `an.get("quickWindowPosition")` (`index.js:515491-515526`) keyed on monitor label + resolution. Falls back to `cHn()` (`:515502`) when the saved monitor is gone. **Upstream does NOT place on cursor display or focused-window display** — it's last-position or primary, nothing else |
|
||||
| Always-on-top | Window manager hint | Stays above other windows | Upstream sets `alwaysOnTop: true` with level `"pop-up-menu"` (`index.js:515399`). On macOS this is per-app; on Linux compositors the level hint is interpreted variably |
|
||||
| Lifecycle | Lazy-created on first shortcut press | First shortcut press constructs the BrowserWindow; subsequent presses reuse it ([S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity)) | Upstream `if (!Ko \|\| ...) Ko = new BrowserWindow(...)` near `index.js:515375`. Means popup works in tray-only state with no main window mapped |
|
||||
| Persistence after main window destroy | Popup survives `mainWindow.destroy()` | Popup remains functional; submit guards skip show/focus when `ut` is destroyed ([S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy)) | Upstream `!ut \|\| ut.isDestroyed()` guard at `index.js:515595`. Likely unreachable on this project due to hide-to-tray override of X button |
|
||||
|
||||
## Input area
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Text input field | Center of popup | Receives focus immediately on open; cursor blinks | — |
|
||||
| Placeholder text | Empty input state | Shows guidance like "Ask Claude anything..." | — |
|
||||
| Multi-line autosize | Type a long prompt | Input grows downward as text wraps; popup grows with it | — |
|
||||
| `Enter` to submit | Press Enter | Sends prompt, closes popup. Prompt must be > 2 chars trimmed (`index.js:515530, 515533`); 1-2 char prompts are silently dropped | Renderer-side keymap; reaches main process via IPC `requestDismissWithPayload()` (`:515409`) |
|
||||
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | Renderer-side |
|
||||
| `Esc` to dismiss | Press Esc | Closes popup without submitting | Renderer-side; reaches main process via IPC `requestDismiss()` (`:515409`) |
|
||||
| Click outside | Click outside the popup window | Closes popup without submitting | Wired in **main process** via the popup's `blur` handler (`Ko.on("blur", () => g3A(null))` at `index.js:515465`) |
|
||||
| Paste behavior | Paste rich text | Text-only paste; no HTML residue | — |
|
||||
| IME / dead-key composition | Type composed characters | Composition UI renders correctly above the input | Fcitx5/IBus integration is fragile under Electron |
|
||||
|
||||
## Submit feedback
|
||||
|
||||
| Element | Trigger | Expected | Notes |
|
||||
|---------|---------|----------|-------|
|
||||
| Submit transition | Press Enter | Popup closes; main window navigates to a **new** chat session ([S31](../cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state)). Quick Entry never appends to existing chats — `ynt(e)` at `index.js:515546` always creates new | Upstream calls `mainWin.show()` + `mainWin.focus()` only — no `restore()`, no workspace migration. Behavior on minimized / hidden / cross-workspace main is compositor-dependent |
|
||||
| Loading indicator | While prompt is in flight | Brief spinner or fade-out — popup should not appear frozen | — |
|
||||
| Error state | Submit when offline / API error | Inline error message; popup stays open so user can retry | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| Popup doesn't appear when shortcut pressed | Global shortcut not registered ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S11](../cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab), [S14](../cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) | Launcher log; portal `BindShortcuts` outcome |
|
||||
| Opaque square frame visible behind UI | Transparent background not respected ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | KDE compositor settings; BrowserWindow `transparent: true` arg |
|
||||
| Popup appears but input doesn't auto-focus | Focus stealing prevention by compositor; race in BrowserWindow `show()` + `focus()` | Wayland focus-request semantics; mutter is most strict |
|
||||
| IME composition cursor renders in wrong place | Electron IME integration bug | Try with simpler GTK app to isolate; report upstream Electron issue if reproducible |
|
||||
| Popup persists after submit | Close-on-submit IPC missed | Launcher log; DevTools console (if reachable on the popup window) |
|
||||
| Popup appears on wrong monitor / wrong workspace | Compositor places frameless windows differently | Test with `xdotool getactivewindow` (X11) before/after |
|
||||
@@ -1,72 +0,0 @@
|
||||
# UI — Routines Page
|
||||
|
||||
The Routines page hosts the list of scheduled tasks (local and remote), the new-routine form, and per-routine detail views. Related functional tests: [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend).
|
||||
|
||||
## Routines list
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Routines page link | Code-tab sidebar | Click opens the page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
|
||||
| Page header | Top of page | Title "Routines" + description | — |
|
||||
| **New routine** button | Top-right of page | Click shows Local / Remote selector | — |
|
||||
| Routines list | Page body | Lists all configured routines | — |
|
||||
| Per-routine row | List item | Name, schedule summary, last-run timestamp, status indicator | — |
|
||||
| Run-now icon | Per row, hover-revealed | Click triggers immediate run ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | — |
|
||||
| Pause / resume toggle | Per row | Pauses or resumes scheduled runs without deleting | — |
|
||||
| Click row | Per row | Opens routine detail page | — |
|
||||
|
||||
## New routine form (Local)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Routine type selector | Top of form | Local / Remote tabs or radio | — |
|
||||
| **Name** field | Top of form | Required; converted to lowercase kebab-case for filesystem | — |
|
||||
| **Description** field | Below name | Optional one-liner shown in list | — |
|
||||
| **Instructions** textarea | Mid-form | Rich textarea for the prompt | — |
|
||||
| Permission mode picker | Within Instructions area | Same options as session: Ask, Auto accept, Plan, Auto, Bypass | — |
|
||||
| Model picker | Within Instructions area | Sonnet, Opus, Haiku per plan | — |
|
||||
| **Working folder** picker | Below Instructions | Required; opens native file chooser | If folder not yet trusted, app prompts to trust |
|
||||
| **Worktree** toggle | Below folder | When ON, each run gets its own isolated worktree | — |
|
||||
| **Schedule** preset | Bottom of form | Manual / Hourly / Daily / Weekdays / Weekly | — |
|
||||
| Time picker | Visible for Daily, Weekdays, Weekly | Defaults to 9:00 AM local | — |
|
||||
| Day picker | Visible for Weekly only | Day-of-week selector | — |
|
||||
| **Save** button | Bottom-right | Disabled until required fields filled | — |
|
||||
| **Cancel** button | Bottom-left | Discards form, returns to list | — |
|
||||
| Folder-trust prompt | Triggered when folder not trusted | Modal asking to trust the selected folder | Required before save |
|
||||
|
||||
## New routine form (Remote)
|
||||
|
||||
Per upstream docs, remote routines run on Anthropic-managed cloud infrastructure. The form has additional fields for connectors and trigger types (cron, API, GitHub event). On Linux, the Remote tab should function identically to other platforms.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Trigger type selector | Top of form | Schedule / API call / GitHub event | — |
|
||||
| Connectors picker | Per-routine basis (remote) | Configures connectors at routine creation | — |
|
||||
| Network access controls | If applicable | Tied to cloud environment config | — |
|
||||
|
||||
## Routine detail page
|
||||
|
||||
Per upstream docs.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Run now** button | Top of page | Starts the task immediately | — |
|
||||
| Status toggle (Active / Paused) | Top of page | Pauses or resumes without deleting | — |
|
||||
| **Edit** button | Top of page | Opens the same form populated with current values | — |
|
||||
| **Delete** button | Top of page (or footer) | Removes routine; archives all sessions it created | Confirmation dialog expected |
|
||||
| **Review history** section | Page body | Lists every past run with timestamp and status | — |
|
||||
| Per-history-entry hover | Hover skipped runs | Tooltip explains why skipped (asleep, prior run still running, other concurrent task) | — |
|
||||
| **Show more** button | Bottom of history | Loads older entries | — |
|
||||
| **Always allowed** panel | Page body | Lists tools auto-approved for this routine | — |
|
||||
| Revoke approval | Per-tool entry | Removes the auto-approval | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Folder-trust modal doesn't appear | Trust state cached incorrectly | Clear `~/.claude/trusted-folders` (or equivalent) and retry |
|
||||
| Save button never enables | Required fields validation regression | DevTools console |
|
||||
| Time picker truncates / clips | Modal sizing on small viewports | Resize Settings window to reproduce |
|
||||
| History tooltips don't render | Tooltip component regression | — |
|
||||
| Run-now does nothing | Task runner thread not started | Launcher log; `pgrep -af claude` for runner subprocess |
|
||||
| Routines page blank | Code-tab failure ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) cascading | Confirm Code tab itself loads first |
|
||||
@@ -1,87 +0,0 @@
|
||||
# UI — Settings
|
||||
|
||||
The Settings window holds Desktop app preferences, Claude Code settings, connector management, and account controls. Related functional tests: [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge).
|
||||
|
||||
## Settings root
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Settings window | Opened via app menu, tray menu, or in-app shortcut | Window opens with sidebar nav and content area | — |
|
||||
| Window close button | Top-right (or top-left on GNOME) | Closes settings; main app continues running | — |
|
||||
| Sidebar nav | Left of window | Lists every settings page | — |
|
||||
|
||||
## Desktop app → General
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Computer use** toggle | Top of page | Either absent on Linux, or rendered disabled with a "not supported on Linux" hint ([S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux)) | Critical: must not appear functional |
|
||||
| **Keep computer awake** toggle | Mid-page | Toggles `systemd-inhibit --what=idle:sleep` lock ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify with `systemd-inhibit --list` |
|
||||
| **Denied apps** list | Computer-use related | Likely absent on Linux (computer use unsupported) | — |
|
||||
| **Unhide apps when Claude finishes** toggle | Computer-use related | Likely absent on Linux | — |
|
||||
| Theme picker (if exposed) | Mid-page | System / Light / Dark | Tray icon should respond ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) |
|
||||
|
||||
## Desktop app → Account
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Account name / email | Top of page | Reflects signed-in identity | — |
|
||||
| Plan badge | Below name | Shows Pro / Max / Team / Enterprise | — |
|
||||
| Sign out button | Bottom of page | Signs out cleanly; subsequent launches show sign-in screen | — |
|
||||
|
||||
## Claude Code
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Worktree location** | Top of page | Default: `<project-root>/.claude/worktrees/`. Editable to a custom directory | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation) |
|
||||
| **Branch prefix** | Mid-page | Optional prefix prepended to every worktree branch | — |
|
||||
| **Auto-archive after PR merge or close** toggle | Mid-page | When ON, sessions archive on PR resolution ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | — |
|
||||
| **Persist preview sessions** toggle | Mid-page | Toggles cookies/localStorage persistence in Preview pane | Crosses with [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane) |
|
||||
| **Preview** toggle | Mid-page | When OFF, preview pane and auto-verify are disabled | — |
|
||||
| **Allow bypass permissions mode** toggle | Mid-page | When ON, exposes Bypass mode in mode picker | Enterprise admins can disable |
|
||||
| **Auto** mode availability | Mid-page | Research preview; not on Pro plans | Per upstream docs |
|
||||
|
||||
## Connectors
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connectors list | Page content | Lists connected services with status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| Per-connector entry | List row | Name, last-connected timestamp, manage / disconnect buttons | — |
|
||||
| **Manage** button | Per row | Opens connector-specific settings | — |
|
||||
| **Disconnect** button | Per row | Revokes access; connector becomes unusable in subsequent sessions | — |
|
||||
| **Add connector** button | Top of page | Opens the connector picker (same surface as `+ → Connectors`) | — |
|
||||
|
||||
## SSH connections
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| SSH connections list | Page content | Lists user-added + managed (read-only) connections | — |
|
||||
| **Add SSH connection** button | Top of page | Opens dialog with Name / SSH Host / SSH Port / Identity File fields | — |
|
||||
| Per-connection entry | List row | Edit / delete (user-added) or "Managed" badge (admin-distributed) | — |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Shortcut list | Page content | Tabular list of all configurable shortcuts | — |
|
||||
| Shortcut value | Per row | Click to rebind; shows current binding | — |
|
||||
| Reset to default | Per row | Reverts to upstream default | — |
|
||||
| Quick Entry shortcut | Specifically called out | Default `Ctrl+Alt+Space`; rebind here | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
|
||||
|
||||
## Local environment editor
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Env editor open | Environment dropdown → Local → gear icon | Opens encrypted env-var editor | Crosses with [S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot) |
|
||||
| Add variable | In editor | Name + value fields; save | — |
|
||||
| Remove variable | Per row | Deletes the variable | — |
|
||||
| **Apply to dev servers** indicator | Near save | Confirms vars also reach preview servers | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Computer-use toggle visible and toggleable on Linux | [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux) regression | File a bug; users will be misled |
|
||||
| Keep-computer-awake toggle has no effect | `systemd-inhibit` integration not wired ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify lock list before/after |
|
||||
| Worktree location field rejects valid paths | Path validation too strict; absolute vs `~`-prefixed | Check both forms |
|
||||
| SSH connection list missing managed entries | Managed-settings file not loaded; admin distribution failed | Confirm file exists at expected path |
|
||||
| Env editor not encrypting | Linux secret-store not wired ([S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot)) | `secret-tool search`; `kwallet5-query` |
|
||||
@@ -1,55 +0,0 @@
|
||||
# UI — Code Tab Sidebar
|
||||
|
||||
The sidebar lists Code-tab sessions, lets you filter, group, archive, and rename. Related functional tests: [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
|
||||
|
||||
## Top controls
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **+ New session** button | Top of sidebar | Click opens a new session against the currently selected env. `Ctrl+N` shortcut equivalent | — |
|
||||
| **Routines** link | Top of sidebar | Click opens the Routines page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
|
||||
| **Customize** link | Top of sidebar | Click opens connectors / skills / plugins manager | — |
|
||||
| Filter: status | Top of session list | Dropdown / tabs filter by Active / Archived / All | — |
|
||||
| Filter: project | Top of session list | Dropdown filters by project (multi-select) | — |
|
||||
| Filter: environment | Top of session list | Dropdown filters by Local / Remote / SSH / All | — |
|
||||
| Group-by control | Top of session list | Toggle between flat list and grouped-by-project | — |
|
||||
|
||||
## Session row
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Session title | Row content | Shows session name (auto-generated or user-renamed) | Click row → switches to that session |
|
||||
| Session status indicator | Left of title or as colored dot | Reflects state: idle, running, awaiting-approval, errored, archived | — |
|
||||
| Project / branch label | Below title | Shows project folder name + branch | — |
|
||||
| Diff stats badge (e.g. `+12 -1`) | Right of title | Visible when session has uncommitted changes | Click → opens diff view |
|
||||
| **Dispatch** badge | Top-right of row | Visible on Dispatch-spawned sessions ([S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification)) | — |
|
||||
| **Scheduled** badge | Top-right of row | Visible on scheduled-task-spawned sessions ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | Sessions group under "Scheduled" header |
|
||||
| Hover archive icon | Right side, on row hover | Click archives the session and removes its worktree | — |
|
||||
| Right-click context menu | Right-click on row | Standard menu: Rename, Archive, Open in Files, Copy path | — |
|
||||
| Active session highlight | Selected row | Visually distinct from inactive rows | — |
|
||||
|
||||
## Sidebar layout
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Sidebar resize handle | Right edge of sidebar | Drag to resize; double-click to reset width | — |
|
||||
| Sidebar collapse toggle | Top of sidebar (hamburger or arrow) | Collapse to icons-only or hide entirely | Crosses with topbar hamburger |
|
||||
| Scrollbar | Right edge when content exceeds height | Renders, drags work | Theme-aware |
|
||||
|
||||
## Cycling shortcuts
|
||||
|
||||
| Shortcut | Expected | Notes |
|
||||
|----------|----------|-------|
|
||||
| `Ctrl+Tab` | Cycle to next session | Per upstream docs |
|
||||
| `Ctrl+Shift+Tab` | Cycle to previous session | Per upstream docs |
|
||||
| `Cmd+Shift+]` / `Cmd+Shift+[` | Same as above on macOS | N/A on Linux unless rebound |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Sidebar doesn't render | Code tab failed to load ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | Check DevTools console |
|
||||
| Sessions appear but clicking does nothing | IPC between sidebar and session pane broken | Launcher log, DevTools console |
|
||||
| Hover archive icon never appears | CSS hover state mis-applied; touch device might be assumed | Inspect element; check pointer events |
|
||||
| Dispatch / Scheduled badges missing | Feature flag or state not reaching the renderer | Check session metadata in launcher log |
|
||||
| Auto-archive doesn't fire | Session-archive logic bug ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | Confirm setting enabled; check PR state via `gh pr view` |
|
||||
@@ -1,44 +0,0 @@
|
||||
# UI — System Tray
|
||||
|
||||
Tray icon, menu, and theme variants. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests ([T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)).
|
||||
|
||||
## Tray icon
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Tray icon (light theme) | System tray / status area | Black icon (the "Template" variant) renders cleanly on a light tray | — |
|
||||
| Tray icon (dark theme) | System tray / status area | White icon (the "Template-Dark" variant) renders cleanly on a dark tray | — |
|
||||
| Theme switch | Trigger system theme change | Icon updates in place — no duplicate icons spawned ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | KDE-W ✓ via in-place fast-path |
|
||||
| Icon resolution / sharpness | Inspect at native scale | Icon is crisp, not pixelated. Check on HiDPI screens | — |
|
||||
| Position | Tray area | Appears among other SNI/tray icons | KDE Plasma sorts alphabetically by ID; adjusting position requires user config |
|
||||
| Tooltip on hover | Hover over icon | Shows "Claude" or app name | — |
|
||||
|
||||
## Right-click menu
|
||||
|
||||
| Element | Position in menu | Expected | Notes |
|
||||
|---------|------------------|----------|-------|
|
||||
| Show / Hide window | Top item | Toggles main window visibility | Label may change between "Show" and "Hide" based on state |
|
||||
| Quick Entry | Mid-menu | Opens Quick Entry popup ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused)) | — |
|
||||
| Open at Login (toggle) | Mid-menu | Reflects current XDG autostart state ([T09](../cases/platform-integration.md#t09--autostart-via-xdg)) | Toggle should write `~/.config/autostart/*.desktop` |
|
||||
| Settings | Mid-menu | Opens Settings window | — |
|
||||
| About | Bottom area | Opens About dialog | — |
|
||||
| Quit | Bottom item | Fully exits the app (no hide-to-tray) | — |
|
||||
| Menu separators | Between item groups | Render cleanly | — |
|
||||
|
||||
## Left-click behavior
|
||||
|
||||
| Element | Trigger | Expected | Notes |
|
||||
|---------|---------|----------|-------|
|
||||
| Single left-click | Click tray icon once | Toggles main window visibility | KDE-W ✓ |
|
||||
| Double left-click | Click twice quickly | DE-dependent; should not spawn duplicate windows | — |
|
||||
| Middle-click | Middle mouse button on tray icon | DE-dependent (no documented behavior); should not crash | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| Tray icon never appears | No SNI watcher (e.g. GNOME without AppIndicator extension); Electron fallback to legacy XEmbed not registered | `gdbus call ... org.kde.StatusNotifierWatcher` — see [runbook](../runbook.md#tray--dbus-state-kde) |
|
||||
| Two tray icons after theme switch | Tray rebuild race ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | SNI watcher state before/after; [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md) |
|
||||
| Icon renders as a generic placeholder | Icon path resolution failed; theme mismatch | Check Electron `Tray` constructor args; check `~/.cache/claude-desktop-debian/launcher.log` |
|
||||
| Menu items don't respond | IPC bridge to tray menu broken; main process busy | Click main window — does the rest of the app respond? `pgrep -af claude`; main process state |
|
||||
| Tray icon disappears after some time | Tray daemon restarted; Claude didn't re-register | KDE Plasma: restart `plasmashell`; observe whether icon comes back without restarting Claude |
|
||||
@@ -1,58 +0,0 @@
|
||||
# UI — Window Chrome & Tabs
|
||||
|
||||
OS-level window frame plus the in-app tab strip and (PR #538) hybrid in-app topbar. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests.
|
||||
|
||||
## OS window frame
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Title bar | Top of window | Drawn by DE/compositor; shows app title; right-click opens window menu | KDE-W ✓; Hypr-N ✓ |
|
||||
| Close button (X) | Top-right (or top-left on GNOME) | Renders, hover state visible, click hides-to-tray ([T08](../cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close)) | — |
|
||||
| Minimize button | Adjacent to close | Renders, hover state visible, click minimizes | — |
|
||||
| Maximize / restore button | Adjacent to minimize | Renders, hover state visible, click toggles maximize | — |
|
||||
| Resize edges (left, right, top, bottom, corners) | Window perimeter | Cursor changes to resize affordance on hover; drag resizes | Wlroots compositors may not show cursor change |
|
||||
| Window menu (right-click titlebar) | Right-click anywhere on titlebar | Standard window menu (Move, Resize, Close, Always on Top, etc.) | DE-dependent |
|
||||
|
||||
## Hybrid in-app topbar (PR #538 builds)
|
||||
|
||||
Sits below the OS frame in hybrid mode. Crosses with [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) and [S13](../cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports).
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Hamburger menu | Top-left of topbar | Renders, click opens sidebar | — |
|
||||
| Sidebar toggle | Adjacent to hamburger | Renders, click collapses/expands sidebar | — |
|
||||
| Search icon | Center-left | Renders, click opens search overlay | — |
|
||||
| Back arrow | Center | Renders, greyed out when no history; click navigates back | — |
|
||||
| Forward arrow | Adjacent to back | Same as back, but for forward history | — |
|
||||
| Cowork ghost icon | Right of nav arrows | Renders, click opens Cowork tab | The icon is the canonical "is the topbar shim alive" indicator |
|
||||
| Drag region (gaps between buttons) | Empty space between elements | Drag region behaves correctly — buttons remain clickable, no implicit drag region capturing button clicks | Critical: this is the regression mode in [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
|
||||
|
||||
## Tab strip (Chat / Cowork / Code)
|
||||
|
||||
Sits in the topbar (hybrid) or in the OS-frame area (legacy). Top center.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Chat** tab | Left tab | Renders, click switches to Chat | — |
|
||||
| **Cowork** tab | Center tab | Renders, click switches to Cowork; ghost icon may indicate Dispatch state | — |
|
||||
| **Code** tab | Right tab | Renders, click switches to Code; on Linux, may show 403 / sign-in upsell ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | — |
|
||||
| Active tab indicator | Underline / fill on active tab | Visually distinct from inactive tabs | — |
|
||||
| Tab badges (e.g. unread count, Dispatch badge) | Top-right of each tab | Render when applicable, dismiss when state clears | — |
|
||||
|
||||
## Other window-level UI
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| About dialog | App menu → About | Modal opens with app version, Electron version, license info; close button works | — |
|
||||
| App menu (macOS-style) | macOS only — N/A on Linux | Not present on Linux; menu items are in window menu instead | — |
|
||||
| Update prompt | Triggered by upstream update detection | On DEB/RPM, auto-update path is suppressed ([S26](../cases/distribution.md#s26--auto-update-is-disabled-when-installed-via-apt--dnf)). On AppImage, may surface a prompt | — |
|
||||
| Crash report dialog | Shown after a crash | Dialog explains what happened, offers to file an issue | Capture for Linux specifics — wording may reference macOS Console / Windows Event Viewer paths only |
|
||||
|
||||
## Display-server cross-cuts
|
||||
|
||||
| Concern | X11 | Wayland (mutter) | Wayland (KWin) | Wayland (wlroots) |
|
||||
|---------|-----|-------------------|----------------|---------------------|
|
||||
| HiDPI scaling | `--force-device-scale-factor=N` works | Auto via fractional scaling | Auto via fractional scaling | Auto where compositor supports it |
|
||||
| Drag-to-snap (Aero-style) | Works under most WMs | mutter snaps | KWin snaps | Compositor-dependent |
|
||||
| Always-on-top | Window menu | Window menu | Window menu | Compositor-dependent |
|
||||
| Cursor theme | Inherits from `gtk-cursor-theme-name` | Same | Same | Same |
|
||||
617
docs/troubleshooting.md
Normal file
617
docs/troubleshooting.md
Normal file
@@ -0,0 +1,617 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs a series of checks and prints pass/fail results with
|
||||
suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Input method | IBus/GTK immodule sanity (ibus-gtk3 installed, cache fresh, XWayland routing note) |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| User namespaces | AppArmor userns restriction + Claude profile presence (Ubuntu 24.04+) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
| Desktop entry | `.desktop` file presence |
|
||||
| Disk space | Free space on config partition |
|
||||
| Log file | Log file size |
|
||||
|
||||
Example output:
|
||||
```
|
||||
Claude Desktop Diagnostics
|
||||
================================
|
||||
|
||||
[PASS] Installed version: 1.1.4498-1.3.15
|
||||
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
|
||||
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
|
||||
[PASS] Chrome sandbox: permissions OK
|
||||
[PASS] SingletonLock: no lock file (OK)
|
||||
[PASS] MCP config: valid JSON
|
||||
[PASS] Node.js: v22.14.0
|
||||
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
|
||||
[PASS] Disk space: 632284MB free
|
||||
[PASS] Log file: 1352KB
|
||||
|
||||
All checks passed.
|
||||
```
|
||||
|
||||
When opening an issue, include the output of `--doctor` to help with diagnosis.
|
||||
|
||||
## Application Logs
|
||||
|
||||
Runtime logs are available at:
|
||||
```
|
||||
~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Window Scaling Issues
|
||||
|
||||
If the window doesn't scale correctly on first launch:
|
||||
1. Right-click the Claude Desktop tray icon
|
||||
2. Select "Quit" (do not force quit)
|
||||
3. Restart the application
|
||||
|
||||
This allows the application to save display settings properly.
|
||||
|
||||
### Global Hotkey Not Working (Wayland)
|
||||
|
||||
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
|
||||
|
||||
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
|
||||
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
|
||||
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
|
||||
|
||||
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
|
||||
|
||||
See [configuration.md](configuration.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
|
||||
|
||||
### Keyboard Input Doesn't Work (IBus / GTK Input Method)
|
||||
|
||||
If typing into the chat does nothing, characters get swallowed, or
|
||||
dead-key sequences (e.g. ``` `e ``` → `è`) don't compose, your GTK
|
||||
input module integration with the Electron-bundled GTK is broken.
|
||||
Common symptoms:
|
||||
|
||||
- No characters appear when typing into any text field
|
||||
- The first keystroke after focus is dropped, subsequent ones work
|
||||
- CJK input methods (IBus, Fcitx) not engaging
|
||||
- Compose key / dead-key sequences silently drop
|
||||
|
||||
**First step: run `claude-desktop --doctor`.** It checks for the
|
||||
common misconfigurations and prints fix commands inline:
|
||||
|
||||
- `ibus-gtk3` package missing while `GTK_IM_MODULE=ibus`
|
||||
- GTK immodules cache stale (the active module isn't listed by
|
||||
`gtk-query-immodules-3.0`)
|
||||
- XWayland session routing IBus through XIM (lossy for some IMEs —
|
||||
set `CLAUDE_USE_WAYLAND=1` to use native Wayland IME)
|
||||
- Active value of `CLAUDE_GTK_IM_MODULE` if you've set the override
|
||||
|
||||
If `--doctor` is clean but input still misbehaves, switch the
|
||||
launcher to a different GTK input module. Set `CLAUDE_GTK_IM_MODULE`
|
||||
and Claude Desktop will propagate it as `GTK_IM_MODULE` to Electron
|
||||
at startup:
|
||||
|
||||
```bash
|
||||
# Bypass IBus entirely — uses the X Input Method (XIM) protocol
|
||||
CLAUDE_GTK_IM_MODULE=xim claude-desktop
|
||||
|
||||
# To make it persistent, export it from your shell profile:
|
||||
# echo 'export CLAUDE_GTK_IM_MODULE=xim' >> ~/.profile
|
||||
```
|
||||
|
||||
Valid values: anything your GTK installation supports (`xim`, `ibus`,
|
||||
`fcitx`, `simple`, etc.). When the override is active, the launcher
|
||||
logs a line to `~/.cache/claude-desktop-debian/launcher.log`:
|
||||
|
||||
```
|
||||
GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)
|
||||
```
|
||||
|
||||
**Trade-off:** `xim` is the lowest-common-denominator input module
|
||||
and does not support advanced IME features like CJK candidate
|
||||
windows or rich compose-key sequences. Only reach for it if your
|
||||
real input method (IBus/Fcitx) is broken; if you depend on CJK or
|
||||
compose, prefer fixing the IBus/Fcitx integration instead.
|
||||
|
||||
### Repeated Electron Crashes / GPU Process FATAL ([#583](https://github.com/aaddrick/claude-desktop-debian/issues/583))
|
||||
|
||||
If Claude Desktop crashes repeatedly on launch or shortly after,
|
||||
the most common cause on Linux is the Chromium GPU process hitting
|
||||
a FATAL exhaustion path. `claude-desktop --doctor` surfaces this
|
||||
when `systemd-coredump` shows 3+ Electron crashes in the last 7
|
||||
days, pointing at this issue.
|
||||
|
||||
Two ways to disable hardware acceleration as a workaround:
|
||||
|
||||
1. **In-app:** Settings → toggle hardware acceleration off →
|
||||
restart Claude Desktop. Persists in the upstream config.
|
||||
2. **Env var (headless / persists across reinstalls):** set
|
||||
`CLAUDE_DISABLE_GPU=1` in the environment before launching.
|
||||
|
||||
```bash
|
||||
# One-off:
|
||||
CLAUDE_DISABLE_GPU=1 claude-desktop
|
||||
|
||||
# Persistent (shell profile):
|
||||
echo 'export CLAUDE_DISABLE_GPU=1' >> ~/.profile
|
||||
```
|
||||
|
||||
When `CLAUDE_DISABLE_GPU=1` is set, the launcher passes
|
||||
`--disable-gpu --disable-software-rasterizer` to Electron (see
|
||||
`scripts/launcher-common.sh`). This is the same pair of flags
|
||||
applied automatically inside XRDP sessions, where software
|
||||
rendering is required regardless. Either signal is sufficient —
|
||||
the launcher won't stack duplicate flags.
|
||||
|
||||
If the previous launch already died with the GPU-process FATAL
|
||||
signature and `CLAUDE_DISABLE_GPU` is unset, the next launch
|
||||
auto-applies the same flags and keeps them applied on subsequent
|
||||
launches. Set `CLAUDE_DISABLE_GPU=0` to suppress the auto-fallback
|
||||
when retesting hardware acceleration after a driver fix — any
|
||||
explicitly set value suppresses it; only `1` forces the flags on.
|
||||
|
||||
**When to prefer which:** the in-app toggle is friendlier if you
|
||||
can reach Settings without the app crashing. Reach for
|
||||
`CLAUDE_DISABLE_GPU=1` when the app crashes before you can open
|
||||
Settings, when running in environments with no GPU available
|
||||
(XRDP, headless CI smoke tests, some VMs), or when you want the
|
||||
behavior to persist across reinstalls and config resets.
|
||||
|
||||
Tracking issue: [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583).
|
||||
|
||||
### Black screen on Fedora KDE with Intel Iris Xe ([#706](https://github.com/aaddrick/claude-desktop-debian/issues/706))
|
||||
|
||||
If the window opens but renders entirely black on Fedora KDE with
|
||||
Intel Iris Xe graphics (TigerLake-LP GT2), force Mesa's reference
|
||||
software rasterizer:
|
||||
|
||||
```bash
|
||||
MESA_LOADER_DRIVER_OVERRIDE=softpipe claude-desktop
|
||||
```
|
||||
|
||||
The failing launch logs this signature in
|
||||
`~/.cache/claude-desktop-debian/launcher.log`:
|
||||
|
||||
```
|
||||
KMS: DRM_IOCTL_MODE_CREATE_DUMB failed: Permission denied
|
||||
```
|
||||
|
||||
**Try the faster fallbacks first.** softpipe renders everything on
|
||||
the CPU with no acceleration of any kind and is noticeably slow.
|
||||
Before reaching for it:
|
||||
|
||||
1. `CLAUDE_DISABLE_GPU=1 claude-desktop` — disables hardware
|
||||
acceleration entirely (see the previous section).
|
||||
2. `LIBGL_ALWAYS_SOFTWARE=1 claude-desktop` — selects llvmpipe,
|
||||
Mesa's supported software fallback, several times faster than
|
||||
softpipe.
|
||||
|
||||
Use `MESA_LOADER_DRIVER_OVERRIDE=softpipe` only if
|
||||
`LIBGL_ALWAYS_SOFTWARE=1` also produces a black screen. To make it
|
||||
persistent:
|
||||
|
||||
```bash
|
||||
echo 'export MESA_LOADER_DRIVER_OVERRIDE=softpipe' >> ~/.profile
|
||||
```
|
||||
|
||||
Tracking issue:
|
||||
[#706](https://github.com/aaddrick/claude-desktop-debian/issues/706).
|
||||
Credit: workaround discovered and confirmed by
|
||||
[@dubreal](https://github.com/dubreal) while diagnosing
|
||||
[#593](https://github.com/aaddrick/claude-desktop-debian/issues/593)
|
||||
and
|
||||
[#599](https://github.com/aaddrick/claude-desktop-debian/pull/599).
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
|
||||
For enhanced security, consider:
|
||||
- Using the .deb package instead
|
||||
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
|
||||
- Using Gear Lever's integrated AppImage management for better isolation
|
||||
|
||||
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
|
||||
|
||||
**Cause:** Ubuntu 24.04+ sets `apparmor_restrict_unprivileged_userns=1`. This blocks the user namespaces Cowork's bubblewrap sandbox needs.
|
||||
|
||||
**Symptom:** `claude-desktop --doctor` shows `Cowork isolation: host-direct (bwrap probe failed)`.
|
||||
|
||||
**Fix (`.deb` installs):** None needed. The `postinst` installs `/etc/apparmor.d/claude-desktop-bwrap`, granting `userns` to `/usr/bin/bwrap`. Still failing? Reinstall the package — the `postinst` recreates the profile.
|
||||
|
||||
**Fix (AppImage, Nix, rpm, and manual installs):** The auto-install is deb-only; install the profile by hand:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/bwrap>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
||||
```
|
||||
|
||||
**Existing profiles win:** The `postinst` defers to any profile already attaching to `/usr/bin/bwrap` — the hand-made `/etc/apparmor.d/bwrap` above, or `bwrap-userns-restrict` from the `apparmor-profiles` package — rather than shadowing it with its unconfined-mode one. If such a profile blocks `userns`, resolve the conflict yourself before expecting Cowork isolation to work.
|
||||
|
||||
**Customizing:** Put overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` — they survive upgrades. Direct edits to the managed profile do not: the `postinst` rewrites any profile carrying its marker header on every upgrade, and removes it on purge.
|
||||
|
||||
**Security:** The profile grants `userns` to `/usr/bin/bwrap` host-wide. Bubblewrap's own sandbox does the confining. Review against your threat model.
|
||||
|
||||
**Credit:** [@hfyeh](https://github.com/hfyeh), [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Claude Desktop crashes immediately on launch (Ubuntu 24.04+, AppArmor blocks user namespaces)
|
||||
|
||||
The `.deb` handles this automatically — this section is for the rare case
|
||||
where it doesn't. Ubuntu 24.04+ sets
|
||||
`apparmor_restrict_unprivileged_userns=1`, blocking the user namespaces
|
||||
Chromium's sandbox needs (same root cause as the Cowork case above, but it
|
||||
kills the **main app** on startup before any window appears). The deb's
|
||||
`postinst` installs a scoped AppArmor profile
|
||||
(`/etc/apparmor.d/claude-desktop`) that grants `userns` to the bundled
|
||||
Electron binary only — exactly as the `google-chrome`, `code`, and `slack`
|
||||
packages do — so a normal install needs no action.
|
||||
|
||||
You only need to act if the app still crashes on launch with:
|
||||
|
||||
- `FATAL:sandbox/linux/services/credentials.cc:131] Check failed: . :
|
||||
Permission denied (13)` in
|
||||
`~/.cache/claude-desktop-debian/launcher.log` (the line number varies by
|
||||
Electron version), and
|
||||
- a `Trace/breakpoint trap` / core dump (exit code 133).
|
||||
|
||||
Run `sudo claude-desktop --doctor` first — the **User namespaces** check
|
||||
reports whether the profile is actually loaded into the kernel (reading the
|
||||
loaded set needs root; without `sudo` it can only confirm the profile is
|
||||
present on disk). To (re)install it manually:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/claude-desktop <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile claude-desktop /usr/lib/claude-desktop/node_modules/electron/dist/electron flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/claude-desktop>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/claude-desktop
|
||||
```
|
||||
|
||||
To customize the profile on a `.deb` install, put overrides in
|
||||
`/etc/apparmor.d/local/claude-desktop` — they survive upgrades; direct
|
||||
edits to the managed profile are rewritten by the `postinst` on every
|
||||
upgrade.
|
||||
|
||||
Don't use `--no-sandbox` as a permanent fix on the `.deb` — it disables the
|
||||
Chromium sandbox entirely, which the package is built to keep. (AppImage
|
||||
builds already launch with `--no-sandbox` because they can't ship a SUID
|
||||
helper, so they never hit this crash.)
|
||||
|
||||
**Security note:** the profile grants the unconfined profile plus the
|
||||
`userns` capability to the bundled Electron binary only, not system-wide —
|
||||
narrower than relaxing `kernel.apparmor_restrict_unprivileged_userns`
|
||||
globally, which would lift the restriction for every program on the host.
|
||||
Review against your threat model before applying.
|
||||
|
||||
### Claude Desktop crashes immediately on launch (Ubuntu 24.04+, AppArmor blocks user namespaces)
|
||||
|
||||
The `.deb` handles this automatically — this section is for the rare case
|
||||
where it doesn't. Ubuntu 24.04+ sets
|
||||
`apparmor_restrict_unprivileged_userns=1`, blocking the user namespaces
|
||||
Chromium's sandbox needs (same root cause as the Cowork case above, but it
|
||||
kills the **main app** on startup before any window appears). The deb's
|
||||
`postinst` installs a scoped AppArmor profile
|
||||
(`/etc/apparmor.d/claude-desktop`) that grants `userns` to the bundled
|
||||
Electron binary only — exactly as the `google-chrome`, `code`, and `slack`
|
||||
packages do — so a normal install needs no action.
|
||||
|
||||
You only need to act if the app still crashes on launch with:
|
||||
|
||||
- `FATAL:sandbox/linux/services/credentials.cc:131] Check failed: . :
|
||||
Permission denied (13)` in
|
||||
`~/.cache/claude-desktop-debian/launcher.log` (the line number varies by
|
||||
Electron version), and
|
||||
- a `Trace/breakpoint trap` / core dump (exit code 133).
|
||||
|
||||
Run `sudo claude-desktop --doctor` first — the **User namespaces** check
|
||||
reports whether the profile is actually loaded into the kernel (reading the
|
||||
loaded set needs root; without `sudo` it can only confirm the profile is
|
||||
present on disk). To (re)install it manually:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/claude-desktop <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile claude-desktop /usr/lib/claude-desktop/node_modules/electron/dist/electron flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/claude-desktop>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/claude-desktop
|
||||
```
|
||||
|
||||
Don't use `--no-sandbox` as a permanent fix on the `.deb` — it disables the
|
||||
Chromium sandbox entirely, which the package is built to keep. (AppImage
|
||||
builds already launch with `--no-sandbox` because they can't ship a SUID
|
||||
helper, so they never hit this crash.)
|
||||
|
||||
**Security note:** the profile grants the unconfined profile plus the
|
||||
`userns` capability to the bundled Electron binary only, not system-wide —
|
||||
narrower than relaxing `kernel.apparmor_restrict_unprivileged_userns`
|
||||
globally, which would lift the restriction for every program on the host.
|
||||
Review against your threat model before applying.
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
|
||||
|
||||
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
|
||||
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
|
||||
|
||||
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
|
||||
|
||||
```bash
|
||||
COWORK_VM_BACKEND=bwrap claude-desktop
|
||||
```
|
||||
|
||||
See [configuration.md](configuration.md#cowork-backend) for how to make this permanent.
|
||||
|
||||
### Cowork: virtiofsd not found (Fedora/RHEL)
|
||||
|
||||
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
|
||||
outside `$PATH`. The `--doctor` check detects it there automatically and will
|
||||
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
|
||||
resolves it through `$PATH` only.
|
||||
|
||||
**Fix:** Create a symlink so the KVM backend can find it at runtime:
|
||||
|
||||
```bash
|
||||
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||
```
|
||||
|
||||
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
|
||||
|
||||
### Cowork: cross-device link error on Fedora tmpfs /tmp
|
||||
|
||||
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
|
||||
|
||||
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/Claude/tmp
|
||||
TMPDIR=~/.config/Claude/tmp claude-desktop
|
||||
```
|
||||
|
||||
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
|
||||
|
||||
### Cowork: ENAMETOOLONG on encrypted home (eCryptfs)
|
||||
|
||||
Cowork sessions can fail with an opaque `ENAMETOOLONG` error when
|
||||
`$HOME` is on a filesystem with a short filename limit. The common
|
||||
case is **eCryptfs** — the legacy "encrypted home" option on older
|
||||
Ubuntu and Linux Mint installs, which caps individual filenames at
|
||||
143 chars because of filename-encryption overhead. Standard
|
||||
filesystems (ext4, btrfs, xfs, zfs) cap at 255 chars and are fine.
|
||||
|
||||
**Why it happens:** Claude Code creates one directory per session
|
||||
under `~/.claude/projects/`, named after the sanitized host CWD. For
|
||||
cowork sessions the host CWD is the deeply nested outputs dir under
|
||||
`~/.config/Claude/local-agent-mode-sessions/<accountId>/<orgId>/local_<uuid>/outputs`,
|
||||
which sanitizes to ~180 chars — fits ext4 but exceeds the eCryptfs
|
||||
143-char ceiling.
|
||||
|
||||
**Diagnosis:** `claude-desktop --doctor` detects this automatically
|
||||
and emits a `[WARN] Filename limit: NAME_MAX=143…` line, plus an
|
||||
eCryptfs-specific hint when the filesystem type matches. You can
|
||||
also check by hand:
|
||||
|
||||
```bash
|
||||
df -T $HOME # look for type "ecryptfs"
|
||||
getconf NAME_MAX $HOME # eCryptfs reports 143; ext4 reports 255
|
||||
```
|
||||
|
||||
**Workaround:** move Claude's data onto a separate LUKS-encrypted
|
||||
ext4 volume (NAME_MAX = 255) and symlink the original paths back.
|
||||
`~/.claude/` is the critical one — that's where Claude Code creates
|
||||
the long-named per-session dirs that overflow the limit — and
|
||||
`~/.config/Claude/` plus `~/.cache/claude-desktop-debian/` are
|
||||
relocated alongside it so all Claude state lives on the same volume.
|
||||
This keeps the data encrypted at rest while sidestepping the
|
||||
eCryptfs filename-length cap.
|
||||
|
||||
```bash
|
||||
# 1. Create a 2 GB LUKS container
|
||||
sudo dd if=/dev/urandom of=/opt/claude-secure.img bs=1M count=2048 \
|
||||
status=progress
|
||||
sudo cryptsetup luksFormat /opt/claude-secure.img
|
||||
sudo cryptsetup open /opt/claude-secure.img claude-secure
|
||||
sudo mkfs.ext4 /dev/mapper/claude-secure
|
||||
|
||||
# 2. Mount and move Claude's data in
|
||||
sudo mkdir -p /mnt/claude-secure
|
||||
sudo mount /dev/mapper/claude-secure /mnt/claude-secure
|
||||
sudo chown "$USER:$USER" /mnt/claude-secure
|
||||
|
||||
mv ~/.config/Claude /mnt/claude-secure/Claude-config
|
||||
mv ~/.cache/claude-desktop-debian /mnt/claude-secure/claude-cache
|
||||
# ~/.claude may not exist yet on a fresh install — create the target
|
||||
# either way so the symlink below resolves.
|
||||
if [ -e ~/.claude ]; then
|
||||
mv ~/.claude /mnt/claude-secure/claude-home
|
||||
else
|
||||
mkdir -p /mnt/claude-secure/claude-home
|
||||
fi
|
||||
|
||||
ln -s /mnt/claude-secure/Claude-config ~/.config/Claude
|
||||
ln -s /mnt/claude-secure/claude-cache ~/.cache/claude-desktop-debian
|
||||
ln -s /mnt/claude-secure/claude-home ~/.claude
|
||||
|
||||
# 3. Verify the filename limit and the symlinks
|
||||
getconf NAME_MAX /mnt/claude-secure # should print 255
|
||||
mountpoint /mnt/claude-secure # confirms the volume is mounted
|
||||
readlink ~/.claude # /mnt/claude-secure/claude-home
|
||||
readlink ~/.config/Claude # /mnt/claude-secure/Claude-config
|
||||
```
|
||||
|
||||
**If you've set `CLAUDE_CONFIG_DIR`** (or otherwise reconfigured
|
||||
Claude Code to use a directory other than `~/.claude/`), the
|
||||
`~/.claude` symlink above doesn't apply — adapt the path to wherever
|
||||
your Claude Code config actually lives. The constraint is the same:
|
||||
the directory tree where Claude Code creates per-session project
|
||||
dirs must sit on a filesystem with `NAME_MAX` ≥ ~200.
|
||||
|
||||
**Auto-mount at login** with `pam_mount` so the volume unlocks
|
||||
without a manual `cryptsetup open`:
|
||||
|
||||
```bash
|
||||
sudo apt install libpam-mount
|
||||
```
|
||||
|
||||
Add a `<volume>` entry to `/etc/security/pam_mount.conf.xml`
|
||||
(replace `YOUR_USERNAME` with your login name):
|
||||
|
||||
```xml
|
||||
<volume user="YOUR_USERNAME" fstype="crypt"
|
||||
path="/opt/claude-secure.img"
|
||||
mountpoint="/mnt/claude-secure"
|
||||
options="" />
|
||||
```
|
||||
|
||||
`libpam-mount` registers itself with `/etc/pam.d/common-auth` and
|
||||
`/etc/pam.d/common-session` automatically on install.
|
||||
|
||||
**Notes:**
|
||||
- Tested on Linux Mint with LightDM as the display manager.
|
||||
- **LUKS passphrase tradeoff:** for `pam_mount` to unlock silently
|
||||
at login the LUKS passphrase must match your login password. That
|
||||
means one compromise unlocks both your session and the encrypted
|
||||
volume — equivalent to the threat surface eCryptfs already had,
|
||||
but worth a deliberate choice. Use a distinct LUKS passphrase if
|
||||
you'd rather be prompted on each unlock.
|
||||
- **Confidentiality posture vs eCryptfs.** The LUKS image lives at
|
||||
`/opt/claude-secure.img`, outside `$HOME` and outside whatever
|
||||
encryption envelope eCryptfs gives you. If `pam_mount` ever fails
|
||||
silently — wrong passphrase, mount race at login, profile error —
|
||||
Claude won't start (the symlink targets won't exist), so writes
|
||||
fail loudly rather than landing on plaintext disk. Verify with
|
||||
`mountpoint /mnt/claude-secure` after login if you're unsure.
|
||||
- 2 GB is a conservative starting size; the Claude config
|
||||
directory can exceed 500 MB once cowork session history
|
||||
accumulates. Resize if needed.
|
||||
- This is a system-wide change that affects login flow — review
|
||||
the pam_mount config against your threat model before applying.
|
||||
|
||||
Credit: reported with detailed `--doctor` output by
|
||||
[@michelsfun](https://github.com/michelsfun); LUKS-volume workaround
|
||||
contributed by [@proffalken](https://github.com/proffalken) in
|
||||
[#590](https://github.com/aaddrick/claude-desktop-debian/issues/590).
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
|
||||
|
||||
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
|
||||
|
||||
1. Close Claude Desktop completely
|
||||
2. Edit `~/.config/Claude/config.json`
|
||||
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
|
||||
4. Save the file and restart Claude Desktop
|
||||
5. Log in again when prompted
|
||||
|
||||
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### For APT repository installations (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
|
||||
# Remove the repository and GPG key
|
||||
sudo rm /etc/apt/sources.list.d/claude-desktop.list
|
||||
sudo rm /usr/share/keyrings/claude-desktop.gpg
|
||||
```
|
||||
|
||||
### For DNF repository installations (Fedora/RHEL)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
|
||||
# Remove the repository
|
||||
sudo rm /etc/yum.repos.d/claude-desktop.repo
|
||||
```
|
||||
|
||||
### For AUR installations (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -R claude-desktop-appimage
|
||||
|
||||
# Or using paru
|
||||
paru -R claude-desktop-appimage
|
||||
|
||||
# Or using pacman directly
|
||||
sudo pacman -R claude-desktop-appimage
|
||||
```
|
||||
|
||||
### For .deb packages (manual install)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
# Or: sudo dpkg -r claude-desktop
|
||||
|
||||
# Remove package and configuration
|
||||
sudo dpkg -P claude-desktop
|
||||
```
|
||||
|
||||
### For .rpm packages
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
# Or: sudo rpm -e claude-desktop
|
||||
```
|
||||
|
||||
### For AppImages
|
||||
|
||||
1. Delete the `.AppImage` file
|
||||
2. Remove the `.desktop` file from `~/.local/share/applications/`
|
||||
3. If using Gear Lever, use its uninstall option
|
||||
|
||||
### Remove user configuration (all formats)
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/Claude
|
||||
```
|
||||
164
docs/upstream-reports/546-mcp-double-spawn.md
Normal file
164
docs/upstream-reports/546-mcp-double-spawn.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Upstream report draft: MCP double-spawn (issue #546)
|
||||
|
||||
This is the draft for the upstream bug report covering [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Filing target is `anthropics/claude-code` GitHub Issues, with an in-app `/bug` from Claude Desktop as a complement so the report ties to build telemetry.
|
||||
|
||||
## Template mismatch note
|
||||
|
||||
The `anthropics/claude-code` bug template is built for the Claude Code CLI, not Claude Desktop. Required fields like "Claude Code Version" and "Terminal/Shell" don't apply cleanly. Other Claude Desktop bug reports in the same repo work around this by putting `N/A — Claude Desktop <version>` in the version field and selecting `Other` for terminal (see #43705, #36319, #14807).
|
||||
|
||||
## Title
|
||||
|
||||
```
|
||||
[BUG] Claude Desktop 1.5354.0: stdio MCP servers double-spawn from independent CCD/LAM coordinator registries
|
||||
```
|
||||
|
||||
## Form fields
|
||||
|
||||
### Preflight Checklist
|
||||
|
||||
- [x] I have searched existing issues and this hasn't been reported yet
|
||||
- [x] This is a single bug report
|
||||
- [x] I am using the latest version of Claude Code
|
||||
|
||||
### What's Wrong?
|
||||
|
||||
I maintain [claude-desktop-debian](https://github.com/aaddrick/claude-desktop-debian) (~2,300 package downloads/day across the last 3 releases), which repackages the Windows Electron build for Linux. I was reading the MCP spawn path in 1.5354.0 and found that stdio MCP servers configured in `claude_desktop_config.json` get spawned twice when both the chat panel and Code/Agent panel are active.
|
||||
|
||||
The user-visible symptom is two `node` processes per MCP, both children of the Electron main PID. Killing one disconnects one panel and the other keeps working. They're independent client/server pairs with no failover between them.
|
||||
|
||||
The original symptom report came from @communitytranslations against an earlier build (tracked in our repo as #526). I went back and read the bundle to confirm the cause. What I found was different from what we'd previously documented.
|
||||
|
||||
CCD wraps the spawn path in a per-key promise queue keyed by server name. It shuts down any prior entry in its global registry Map before respawning. That's correct dedup within CCD. But LAM (`LocalMcpServerManager`) has its own `this.connections` Map and its own `getOrCreateConnection` path. It never consults CCD's registry.
|
||||
|
||||
CCD and LAM each maintain independent spawn lifecycle management. They each spawn their own copy of the same MCP server. The double-spawn is structural in the current architecture. Each coordinator legitimately holds its own connection.
|
||||
|
||||
There's also a third coordinator class, `SshMcpServerManager`, that follows the same per-coordinator-registry pattern. It uses an SSH transport, so it doesn't contribute to local-node double-spawn directly. Its existence suggests per-coordinator isolated state is a deliberate pattern, not a one-off.
|
||||
|
||||
Secondary bug worth flagging while you're in this code. The `child_process.spawn` wrapper does proper signal escalation (end stdin, wait 2s, SIGTERM, wait 2s, SIGKILL). The `utilityProcess.fork` wrapper doesn't. It sends `process.kill()` (default SIGTERM), waits 5s, then calls `kill()` again with the same default signal. No SIGKILL escalation. A built-in-node MCP server that ignores SIGTERM could leak as an orphaned utility process.
|
||||
|
||||
### What Should Happen?
|
||||
|
||||
One process per stdio MCP server entry in `claude_desktop_config.json`, regardless of how many panels are open. Resource-side that means no more 2x memory and 2x stdin/stdout traffic per server. User-side that means `ps` shows one entry per declared server.
|
||||
|
||||
The fix is architectural. CCD and LAM share a registry, or the local-spawn factory dedups at the transport layer, or LAM proxies through CCD when running in-process. Any of those would collapse the duplication.
|
||||
|
||||
### Error Messages/Logs
|
||||
|
||||
The user-facing log prefixes are stable across releases. Grep `~/.config/Claude/logs/` for:
|
||||
|
||||
```
|
||||
[CCD]
|
||||
[LAM]
|
||||
[LocalMcpServerManager]
|
||||
[SshMcpServerManager]
|
||||
```
|
||||
|
||||
For the spawn lifecycle specifically, look for:
|
||||
|
||||
```
|
||||
"Launching MCP Server: <name>" (CCD spawn entry)
|
||||
"Shutting down MCP Server: <name>" (CCD shutdown entry)
|
||||
"local-mcp-server-cleanup" (LAM cleanup path)
|
||||
```
|
||||
|
||||
Two of these per declared MCP server is the diagnostic signal.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Linux host running Claude Desktop at or near 1.5354.0
|
||||
2. Declare at least one stdio MCP server in `~/.config/Claude/claude_desktop_config.json`
|
||||
3. Open Claude Desktop, start a session, open the Code/Agent panel and let it initialize fully (the original report waited about 5 minutes)
|
||||
4. `ps -ef | grep <server-binary-name>`
|
||||
|
||||
Expected: 1 process per MCP. Actual: 2 processes per MCP, both children of the same Electron main PID.
|
||||
|
||||
### Claude Model
|
||||
|
||||
Not sure / Multiple models
|
||||
|
||||
### Is this a regression?
|
||||
|
||||
I don't know
|
||||
|
||||
### Last Working Version
|
||||
|
||||
(leave blank)
|
||||
|
||||
### Claude Code Version
|
||||
|
||||
```
|
||||
N/A — this is a Claude Desktop issue. Bundle version: 1.5354.0
|
||||
```
|
||||
|
||||
### Platform
|
||||
|
||||
Anthropic API
|
||||
|
||||
### Operating System
|
||||
|
||||
Ubuntu/Debian Linux
|
||||
|
||||
### Terminal/Shell
|
||||
|
||||
Other
|
||||
|
||||
### Additional Information
|
||||
|
||||
Bundle reference table for 1.5354.0. Symbols rename across releases, so each row has a stable string anchor for re-finding them.
|
||||
|
||||
| Role | Symbol in 1.5354.0 | Stable anchor |
|
||||
|---|---|---|
|
||||
| CCD spawn function | `BPt` | `"Launching MCP Server:"` |
|
||||
| CCD shutdown function | `CPt` | `"Shutting down MCP Server:"` |
|
||||
| CCD per-key promise queue | `dPt` | called by CCD spawn fn: `await dPt(e, async () => {...})` |
|
||||
| CCD server registry Map | `xX` | `.get()` immediately preceding the CCD shutdown log line |
|
||||
| Shared transport factory | `oPt` | `"built-in-node"` literal in factory body |
|
||||
| LAM manager class | `p0A` | `"[LocalMcpServerManager]"` or `"local-mcp-server-cleanup"` |
|
||||
| SSH manager class | `Rde` | `"[SshMcpServerManager]"` or `"ssh-mcp-server-cleanup"` |
|
||||
| `utilityProcess.fork` wrapper | `mFr` | constructed in shared factory's `built-in-node` branch |
|
||||
| `child_process.spawn` wrapper | `tFr` | constructed in shared factory's default branch |
|
||||
|
||||
Extraction commands (verified against 1.5354.0):
|
||||
|
||||
```bash
|
||||
cd build-reference/app-extracted/.vite/build
|
||||
|
||||
# CCD spawn function name
|
||||
grep -Pzo 'async function \K\w+(?=\(\w*\)\s*\{(?s).{0,800}?Launching MCP Server)' index.js | tr '\0' '\n'
|
||||
|
||||
# Shared transport factory (anchored on the unique 'built-in-node' string)
|
||||
grep -Pzo 'async function \K\w+(?=\([^)]*\)\s*\{(?s).{0,400}?built-in-node)' index.js | tr '\0' '\n'
|
||||
|
||||
# All coordinator classes following the per-coordinator-registry pattern
|
||||
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,300}?this\.connections\s*=\s*new Map)' index.js | tr '\0' '\n'
|
||||
|
||||
# LAM manager class specifically
|
||||
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,500}?local-mcp-server-cleanup)' index.js | tr '\0' '\n'
|
||||
```
|
||||
|
||||
Two questions where a one-line answer from the team would help us route this downstream:
|
||||
|
||||
1. Is per-coordinator isolated state intentional, or is it legacy drift from when each coordinator instantiated its transport inline?
|
||||
2. Is the recent extraction of the shared transport factory (`oPt`) the start of a dedup refactor, or incidental cleanup?
|
||||
|
||||
If (1) is "intentional," we'll point users at the lockfile workaround as the supported path. If (2) is "in progress," this report saves you the duplicate analysis work.
|
||||
|
||||
Full provenance: [aaddrick/claude-desktop-debian#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Related learnings doc updates: [#527](https://github.com/aaddrick/claude-desktop-debian/pull/527) and [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547).
|
||||
|
||||
## Filing checklist
|
||||
|
||||
When you're ready to file:
|
||||
|
||||
1. Open https://github.com/anthropics/claude-code/issues/new?template=bug_report.yml
|
||||
2. Paste each section above into the matching form field
|
||||
3. Submit
|
||||
4. Drop the GitHub issue URL as a comment on [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546) so the trail is bidirectional
|
||||
|
||||
Note: there is no in-app engineering bug-report path in Claude Desktop. `/bug` and `/feedback` are inert. The Help menu has "Get Support" (routes to the support chat, wrong queue for engineering) and "Troubleshooting" (self-diagnostic — useful for attaching `Copy Installation ID` or `Show Logs in File Manager` output to a GitHub issue, but not a reporting step on its own).
|
||||
|
||||
## Voice and authorship
|
||||
|
||||
Drafted using the [aaddrick-voice](https://github.com/aaddrick/written-voice-replication/blob/78f178dcf832943bcf1d5a65bf7627c3a20053a6/.claude/agents/aaddrick-voice.md) style profile against the form schema in `anthropics/claude-code/.github/ISSUE_TEMPLATE/bug_report.yml`.
|
||||
|
||||
---
|
||||
Written by Claude Opus 4.7 via [Claude Code](https://claude.ai/code)
|
||||
18
flake.lock
generated
18
flake.lock
generated
@@ -5,11 +5,11 @@
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775087534,
|
||||
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776949667,
|
||||
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
|
||||
"lastModified": 1780747962,
|
||||
"narHash": "sha256-IX7G1dlKrOqPOImfbo7ADDfV5yU1+j+MRChI3TL4tAA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
|
||||
"rev": "cbb5cf358f50aa6acc9efd6113b7bcfbc352cd73",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1774748309,
|
||||
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
}:
|
||||
let
|
||||
pname = "claude-desktop";
|
||||
version = "1.5354.0";
|
||||
version = "1.11847.5";
|
||||
|
||||
srcs = {
|
||||
x86_64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-5hnHvTtnRqcwfr7+UJv+RHoUOu2X5sf2Zmd7Nqa2ulQ=";
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe";
|
||||
hash = "sha256-nm5i2wseSdmeQjuUNVbMQ8V4UeETZ9lop6wB+vfhQMs=";
|
||||
};
|
||||
aarch64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-v33l1sASVC/q331cqnenLfzqGyRRLpptKOAEukrioR0=";
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe";
|
||||
hash = "sha256-aCCeJS8TTOOgtvQstLCOxSAZYqFZw0vGKC//vZejDu8=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,6 +124,7 @@ stdenvNoCC.mkDerivation {
|
||||
# Copy the ELF binary — MUST be a real copy (not symlink) so that
|
||||
# /proc/self/exe resolves to our tree
|
||||
cp ${electronDir}/electron $electron_tree/electron
|
||||
chmod +x $electron_tree/electron
|
||||
|
||||
# Symlink everything else from electron-unwrapped
|
||||
for item in ${electronDir}/*; do
|
||||
@@ -238,6 +239,7 @@ fi
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -245,6 +247,7 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start (NixOS) ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -260,15 +263,20 @@ detect_display_backend
|
||||
# Build Electron arguments
|
||||
build_electron_args 'nix'
|
||||
|
||||
# Add app path
|
||||
electron_args+=("$app_path")
|
||||
# Intentionally NOT appended: app.asar sits in Electron's default
|
||||
# resources/ dir next to the binary, so Electron auto-loads it. Passing
|
||||
# the path again makes Electron treat it as a file-to-open, which the
|
||||
# app forwards to its file-drop handler, producing a spurious
|
||||
# "Attach app.asar?" prompt on launch and on every taskbar reopen
|
||||
# (the second-instance argv path). Omitting it is the root-cause fix.
|
||||
# See issue #696.
|
||||
log_message "App (auto-loaded by Electron): $app_path"
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep the launcher alive so explicit quit can
|
||||
# clean up Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: $electron_exec ''${electron_args[*]} $*"
|
||||
"$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
exit_code=$?
|
||||
log_message "Electron exited with code: $exit_code"
|
||||
exit $exit_code
|
||||
run_electron_and_cleanup "$electron_exec" "''${electron_args[@]}" "$@"
|
||||
exit $?
|
||||
LAUNCHER
|
||||
# Substitute placeholders — electron_exec points to our custom
|
||||
# wrapper (which sets GTK/GIO env then execs our merged binary)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
buildFHSEnv,
|
||||
bubblewrap,
|
||||
claude-desktop,
|
||||
nodejs,
|
||||
docker,
|
||||
@@ -12,6 +13,7 @@ buildFHSEnv {
|
||||
name = "claude-desktop";
|
||||
|
||||
targetPkgs = pkgs: [
|
||||
bubblewrap
|
||||
claude-desktop
|
||||
docker
|
||||
docker-compose
|
||||
|
||||
31
scripts/cowork-patch-markers.tsv
Normal file
31
scripts/cowork-patch-markers.tsv
Normal file
@@ -0,0 +1,31 @@
|
||||
# Cowork patch markers — single source of truth.
|
||||
#
|
||||
# Format:
|
||||
# <name><TAB><pcre_pattern><TAB><sample>
|
||||
# Lines starting with '#' and blank lines are ignored.
|
||||
#
|
||||
# Each row names a post-patch fingerprint from the patch suite in
|
||||
# scripts/patches/. Both verify-patches.sh and tests/verify-patches.bats
|
||||
# consume this file, so adding a marker here adds it to the runtime
|
||||
# check and the test matrix at the same time.
|
||||
#
|
||||
# Columns:
|
||||
# name — kebab-case id; surfaces in verify output and BATS names.
|
||||
# pattern — PCRE matched against the shipped index.js by `grep -P`.
|
||||
# sample — concrete string the pattern matches; BATS uses it to
|
||||
# build positive and per-marker negative fixtures.
|
||||
#
|
||||
# The first 9 markers correspond to the smoke-test set defined in
|
||||
# issue #559 (PR #555 retrofit, deliverable D6). Additional markers
|
||||
# cover other critical patches (e.g., .asar guards).
|
||||
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
|
||||
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
|
||||
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
|
||||
empty-linux-bundle-manifest linux:\{x64:\[\],arm64:\[\]\} ,linux:{x64:[],arm64:[]}
|
||||
getdownloadstatus-suppression getDownloadStatus\(\)\{return process\.platform==="linux"\?[\w$]+\.NotDownloaded getDownloadStatus(){return process.platform==="linux"?Z.NotDownloaded
|
||||
econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED" (n.code==="ENOENT"||process.platform==="linux"&&n.code==="ECONNREFUSED")
|
||||
cowork-daemon-pid global\.__coworkDaemonPid global.__coworkDaemonPid=_c.pid
|
||||
cowork-linux-daemon-shutdown cowork-linux-daemon-shutdown name:"cowork-linux-daemon-shutdown"
|
||||
sharedcwdpath-threadthrough sharedCwdPath:this\.sessions\.get\( sharedCwdPath:this.sessions.get(t)?.userSelectedFolders?.[0]
|
||||
asar-adddir-filter \.filter\(_d=>!_d\.endsWith\("\.asar"\)\).*"--add-dir" .filter(_d=>!_d.endsWith(".asar")))Y.push("--add-dir"
|
||||
asar-file-drop-guard \.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\) .startsWith("-")&&!i.endsWith(".asar")
|
||||
|
Can't render this file because it contains an unexpected character in line 21 and column 39.
|
@@ -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)'}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# shellcheck shell=bash
|
||||
#===============================================================================
|
||||
# Doctor Diagnostics
|
||||
#
|
||||
@@ -5,8 +6,9 @@
|
||||
# per-package launcher scripts — deb, rpm, AppImage, Nix).
|
||||
#
|
||||
# Provides: run_doctor (the `claude-desktop --doctor` entry point) plus its
|
||||
# internal helpers. Self-contained — no dependencies on launcher-common.sh
|
||||
# state or functions.
|
||||
# internal helpers. Self-contained except for the WM_CLASS constant defined
|
||||
# at the top of launcher-common.sh (substituted at build time), which the
|
||||
# live-UI fingerprint in the orphaned-daemon check reads at runtime.
|
||||
#
|
||||
# To add a new check: define an internal function `_check_<name>`, call it
|
||||
# from run_doctor in the appropriate section, use _pass / _fail / _warn /
|
||||
@@ -71,12 +73,110 @@ _cowork_pkg_hint() {
|
||||
arch) pkg='qemu-full' ;;
|
||||
esac
|
||||
;;
|
||||
ibus-gtk3)
|
||||
# Arch ships the GTK3 immodule as part of the main ibus
|
||||
# package; Debian/Ubuntu and Fedora split it out.
|
||||
case "$distro" in
|
||||
arch) pkg='ibus' ;;
|
||||
*) pkg='ibus-gtk3' ;;
|
||||
esac
|
||||
;;
|
||||
*) pkg="$tool" ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$pkg_cmd $pkg"
|
||||
}
|
||||
|
||||
# Return 0 if the named package is installed, 1 otherwise. Returns 2
|
||||
# (treated as "unknown") when no recognized package manager is
|
||||
# available — callers should not warn in that case to avoid false
|
||||
# positives on unsupported distros.
|
||||
_pkg_installed() {
|
||||
local distro="$1"
|
||||
local pkg="$2"
|
||||
case "$distro" in
|
||||
debian|ubuntu)
|
||||
command -v dpkg-query &>/dev/null || return 2
|
||||
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
|
||||
| grep -q 'install ok installed'
|
||||
;;
|
||||
fedora)
|
||||
command -v rpm &>/dev/null || return 2
|
||||
rpm -q "$pkg" &>/dev/null
|
||||
;;
|
||||
arch)
|
||||
command -v pacman &>/dev/null || return 2
|
||||
pacman -Q "$pkg" &>/dev/null
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Diagnose IBus / GTK input-method misconfigurations that break
|
||||
# keyboard input in the chat (#550). Surfaces:
|
||||
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
|
||||
# - XWayland-with-IBus routing note: on a Wayland session Electron
|
||||
# defaults to XWayland (preserves global hotkeys), which forces
|
||||
# the IBus path through XIM — a known weak link for some IMEs.
|
||||
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
|
||||
# - GTK immodules cache stale: active module not listed by
|
||||
# gtk-query-immodules-3.0 (--update-cache fixes it)
|
||||
#
|
||||
# Usage: _doctor_check_im_modules <distro_id>
|
||||
_doctor_check_im_modules() {
|
||||
local distro="$1"
|
||||
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
|
||||
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
|
||||
"(overrides GTK_IM_MODULE for Electron)"
|
||||
fi
|
||||
|
||||
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
|
||||
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
|
||||
_info \
|
||||
'IME note: Wayland session, Electron via XWayland —' \
|
||||
'IBus path goes through XIM (lossy for some IMEs).'
|
||||
_info \
|
||||
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
|
||||
'(loses global hotkeys).'
|
||||
fi
|
||||
|
||||
# Nothing further to check without an active IM module.
|
||||
[[ -n $active_im ]] || return 0
|
||||
|
||||
# ibus-gtk3 package check — only when the active module is ibus.
|
||||
# rc=1 means definitely missing (warn); rc=2 means unsupported
|
||||
# distro / no package manager (skip silently to avoid false
|
||||
# negatives). On warn, return early — `apt install` refreshes
|
||||
# the immodules cache, so the cache check below would be noise.
|
||||
if [[ $active_im == 'ibus' ]]; then
|
||||
_pkg_installed "$distro" ibus-gtk3
|
||||
case $? in
|
||||
1)
|
||||
_warn \
|
||||
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
|
||||
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
|
||||
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
|
||||
# means GTK 3 isn't in use — skip silently rather than warn.
|
||||
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
|
||||
|
||||
if ! gtk-query-immodules-3.0 2>/dev/null \
|
||||
| grep -q "\"$active_im\""; then
|
||||
_warn \
|
||||
"GTK immodules: '$active_im' not listed by" \
|
||||
"gtk-query-immodules-3.0 (cache may be stale)"
|
||||
_info \
|
||||
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
|
||||
fi
|
||||
}
|
||||
|
||||
# Read the version string from the version file beside an Electron binary.
|
||||
# Prints the raw version string, or nothing if unavailable.
|
||||
_electron_version() {
|
||||
@@ -338,6 +438,237 @@ JSEOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Diagnose short-filename-limit filesystems that break cowork session
|
||||
# initialization. Claude Code creates a per-session directory under
|
||||
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
|
||||
# sessions that flattens to ~180 chars (the host CWD is the deeply
|
||||
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
|
||||
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
|
||||
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
|
||||
# overhead — that mkdir fails with ENAMETOOLONG and the session never
|
||||
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
|
||||
# #590.
|
||||
_doctor_check_filename_limit() {
|
||||
# Walk up from ~/.claude/projects to the first dir that exists so
|
||||
# getconf has something to query on a fresh install where the tree
|
||||
# hasn't been created yet. $HOME is the floor — stop there rather
|
||||
# than crossing into /.
|
||||
local probe_dir="$HOME/.claude/projects"
|
||||
while [[ ! -d $probe_dir ]]; do
|
||||
probe_dir=$(dirname "$probe_dir")
|
||||
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
|
||||
done
|
||||
[[ -d $probe_dir ]] || return 0
|
||||
|
||||
local name_max
|
||||
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
|
||||
[[ $name_max =~ ^[0-9]+$ ]] || return 0
|
||||
# Force base 10 so a leading zero can't trip octal arithmetic.
|
||||
name_max=$((10#$name_max))
|
||||
|
||||
((name_max >= 200)) && return 0
|
||||
|
||||
_warn "Filename limit: NAME_MAX=$name_max on $probe_dir (< 200)"
|
||||
_info \
|
||||
'Cowork sessions create project-dir names up to ~180 chars' \
|
||||
'under ~/.claude/projects/; short limits cause ENAMETOOLONG'
|
||||
_info 'when Claude Code initializes a session inside cowork (#590).'
|
||||
|
||||
local fs_type
|
||||
fs_type=$(df --output=fstype "$probe_dir" 2>/dev/null \
|
||||
| awk 'NR==2 {print $1}')
|
||||
if [[ $fs_type == 'ecryptfs' ]]; then
|
||||
_info \
|
||||
'Detected eCryptfs (legacy Ubuntu/Mint encrypted home,' \
|
||||
'NAME_MAX=143 due to filename-encryption overhead).'
|
||||
_info \
|
||||
'Workaround: move ~/.config/Claude onto a separate' \
|
||||
'LUKS-encrypted ext4 volume (NAME_MAX=255) and symlink it'
|
||||
_info \
|
||||
'back. See docs/troubleshooting.md "Cowork: ENAMETOOLONG' \
|
||||
'on encrypted home (eCryptfs)" for the worked steps.'
|
||||
fi
|
||||
}
|
||||
|
||||
# Surface a warning when systemd-coredump shows N+ recent Electron
|
||||
# crashes. The most common cause on Linux is the GPU process FATAL
|
||||
# exhaustion tracked in #583 — workaround for affected users is the
|
||||
# upstream Settings → disable hardware acceleration toggle, or
|
||||
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
|
||||
#
|
||||
# Arguments: $1 = electron path (e.g.,
|
||||
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
|
||||
# Used to filter results to claude-desktop's electron when possible;
|
||||
# falls back to all-electron crashes when the path doesn't match
|
||||
# (e.g., AppImage mount paths are transient).
|
||||
_doctor_check_recent_crashes() {
|
||||
local electron_path="${1:-}"
|
||||
command -v coredumpctl &>/dev/null || return 0
|
||||
|
||||
# `coredumpctl list electron` filters by COMM=electron. If the
|
||||
# exact electron_path matches any entry's EXE column, prefer that
|
||||
# tighter count; otherwise fall back to all-electron entries.
|
||||
local listing total_count path_count
|
||||
listing=$(coredumpctl list electron \
|
||||
--since='7 days ago' --no-pager 2>/dev/null) || return 0
|
||||
[[ -n $listing ]] || return 0
|
||||
|
||||
# Drop the header line; count remaining entries.
|
||||
# Assumes `coredumpctl list electron`'s COMM=electron filter
|
||||
# excludes `-- Reboot --` separator rows from the listing (true
|
||||
# on systemd as of writing). The path-matched branch below uses
|
||||
# index($0, p) so it's unaffected even if that ever changes;
|
||||
# revisit this total-count branch if a future systemd version
|
||||
# starts leaking reboot markers into per-COMM listings.
|
||||
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
|
||||
((total_count == 0)) && return 0
|
||||
|
||||
if [[ -n $electron_path ]]; then
|
||||
path_count=$(awk -v p="$electron_path" \
|
||||
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
|
||||
else
|
||||
path_count=0
|
||||
fi
|
||||
|
||||
# Use the path-matched count when available; else the unfiltered
|
||||
# count with a footnote so the user knows it may include other
|
||||
# Electron apps (Slack, VSCode, etc.).
|
||||
local count footnote=''
|
||||
if ((path_count > 0)); then
|
||||
count=$path_count
|
||||
else
|
||||
count=$total_count
|
||||
footnote=' (some entries may be from other Electron apps)'
|
||||
fi
|
||||
|
||||
# Threshold tuned against the #583 repro (~10 crashes over 7 days
|
||||
# on the affected laptop); a noisy session typically clears 3 in a
|
||||
# week, so 3 is the floor for "worth surfacing the workaround".
|
||||
if ((count >= 3)); then
|
||||
_warn "Recent Electron crashes: $count in last 7 days$footnote"
|
||||
_info \
|
||||
'Most common cause: Chromium GPU process FATAL (#583).' \
|
||||
'Try one of:'
|
||||
_info ' Settings → toggle hardware acceleration off → restart'
|
||||
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
|
||||
_info \
|
||||
'Tracking:' \
|
||||
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
|
||||
elif ((count > 0)); then
|
||||
_info "Recent Electron crashes: $count in last 7 days$footnote"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report the active Chromium password-store backend.
|
||||
#
|
||||
# Calls _detect_password_store() (defined in launcher-common.sh, which
|
||||
# sources this file) to surface what keyring Electron will use for
|
||||
# safeStorage / cookie encryption. 'basic' is valid but means tokens
|
||||
# rely on filesystem permissions alone, so we note it for visibility.
|
||||
# An empty result means detection itself failed (e.g. a sourcing-order
|
||||
# regression) and warns rather than emitting a green PASS with a blank
|
||||
# value.
|
||||
_doctor_check_password_store() {
|
||||
local store
|
||||
store=$(_detect_password_store)
|
||||
if [[ -z $store ]]; then
|
||||
_warn 'Password store: unable to detect backend'
|
||||
return
|
||||
fi
|
||||
_pass "Password store: $store"
|
||||
if [[ $store == 'basic' ]]; then
|
||||
_info \
|
||||
' → using fixed-key fallback;' \
|
||||
'tokens are protected by filesystem permissions only'
|
||||
fi
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
_info \
|
||||
" → overridden by CLAUDE_PASSWORD_STORE=${CLAUDE_PASSWORD_STORE}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report free space on the partition holding the Claude config dir.
|
||||
# Arguments: $1 = config directory to check.
|
||||
#
|
||||
# Skips when df is unavailable or yields a non-numeric value, leaving
|
||||
# an _info line so the summary never claims a pass over an unrun
|
||||
# check: better a visible skip than a green PASS reporting space we
|
||||
# could not read.
|
||||
_doctor_check_disk_space() {
|
||||
local config_dir="$1"
|
||||
local avail
|
||||
avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
|
||||
| tail -1 | tr -d ' M') || true
|
||||
if [[ ! $avail =~ ^[0-9]+$ ]]; then
|
||||
_info 'Disk space: unable to read (df)'
|
||||
return 0
|
||||
fi
|
||||
# Force base 10: a leading zero ("0099") would otherwise make
|
||||
# (( )) parse the value as octal and error out, falling through
|
||||
# to the PASS branch.
|
||||
avail=$((10#$avail))
|
||||
if ((avail < 100)); then
|
||||
_fail "Disk space: ${avail}MB free on config partition"
|
||||
_info 'Fix: Free up disk space'
|
||||
elif ((avail < 500)); then
|
||||
_warn "Disk space: ${avail}MB free" \
|
||||
"on config partition (low)"
|
||||
else
|
||||
_pass "Disk space: ${avail}MB free"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report the installed claude-desktop version from the package manager
|
||||
# that actually owns the install (#711). On dual-DB hosts (e.g. a
|
||||
# Fedora box with dpkg installed for deb work) a stale dpkg record
|
||||
# must not shadow the live rpm install, so rpm ownership of the real
|
||||
# Electron binary is probed first: `rpm -qf <path>` succeeds only when
|
||||
# rpm installed the file, which a stale dpkg record can never claim.
|
||||
# dpkg is consulted only when rpm does not own the path.
|
||||
#
|
||||
# AppImage and Nix installs (no package owns the path) keep the
|
||||
# existing not-found warn; hosts with no package tools stay silent.
|
||||
#
|
||||
# Usage: _doctor_check_pkg_version <electron_path>
|
||||
_doctor_check_pkg_version() {
|
||||
local electron_path="${1:-}"
|
||||
local probe_path="$electron_path"
|
||||
local pkg_version=''
|
||||
|
||||
if [[ -z $probe_path ]]; then
|
||||
probe_path='/usr/lib/claude-desktop'
|
||||
probe_path+='/node_modules/electron/dist/electron'
|
||||
fi
|
||||
|
||||
# rpm branch: query the file, not the package name, so the answer
|
||||
# comes from the database that owns the actual install.
|
||||
if command -v rpm &>/dev/null; then
|
||||
pkg_version=$(rpm -qf --qf '%{VERSION}-%{RELEASE}' \
|
||||
"$probe_path" 2>/dev/null) || pkg_version=''
|
||||
if [[ -n $pkg_version ]]; then
|
||||
_pass "Installed version: $pkg_version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# dpkg branch: only consulted when rpm does not own the install.
|
||||
if command -v dpkg-query &>/dev/null; then
|
||||
pkg_version=$(dpkg-query -W -f='${Version}' \
|
||||
claude-desktop 2>/dev/null) || pkg_version=''
|
||||
if [[ -n $pkg_version ]]; then
|
||||
_pass "Installed version: $pkg_version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Neither manager knows the install — AppImage or Nix. Only warn
|
||||
# when a package tool exists; with none there is nothing to say.
|
||||
if command -v rpm &>/dev/null \
|
||||
|| command -v dpkg-query &>/dev/null; then
|
||||
_warn 'claude-desktop not found via dpkg/rpm (AppImage?)'
|
||||
fi
|
||||
}
|
||||
|
||||
# Run all diagnostic checks and print results
|
||||
# Arguments: $1 = electron path (optional, for package-specific checks)
|
||||
run_doctor() {
|
||||
@@ -345,21 +676,17 @@ run_doctor() {
|
||||
local _doctor_failures=0
|
||||
_doctor_colors
|
||||
|
||||
# Distro ID is shared between the IM-module check (#550) and the
|
||||
# Cowork Mode section further down. Resolve once.
|
||||
local _distro_id
|
||||
_distro_id=$(_cowork_distro_id)
|
||||
|
||||
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
|
||||
echo '================================'
|
||||
echo
|
||||
|
||||
# -- Installed package version --
|
||||
if command -v dpkg-query &>/dev/null; then
|
||||
local pkg_version
|
||||
pkg_version=$(dpkg-query -W -f='${Version}' \
|
||||
claude-desktop 2>/dev/null) || true
|
||||
if [[ -n $pkg_version ]]; then
|
||||
_pass "Installed version: $pkg_version"
|
||||
else
|
||||
_warn 'claude-desktop not found via dpkg (AppImage?)'
|
||||
fi
|
||||
fi
|
||||
_doctor_check_pkg_version "$electron_path"
|
||||
|
||||
# -- Display server --
|
||||
if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
|
||||
@@ -381,6 +708,9 @@ run_doctor() {
|
||||
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
|
||||
fi
|
||||
|
||||
# -- Input method (IBus / GTK) --
|
||||
_doctor_check_im_modules "$_distro_id"
|
||||
|
||||
# -- Menu bar mode --
|
||||
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
|
||||
if [[ -n $menu_bar_mode ]]; then
|
||||
@@ -429,6 +759,14 @@ run_doctor() {
|
||||
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
|
||||
fi
|
||||
|
||||
# -- Keep awake override --
|
||||
local keep_awake="${CLAUDE_KEEP_AWAKE:-}"
|
||||
if [[ $keep_awake == '0' ]]; then
|
||||
_pass 'Keep awake: suppressed (CLAUDE_KEEP_AWAKE=0)'
|
||||
elif [[ -n $keep_awake ]]; then
|
||||
_info "Keep awake: CLAUDE_KEEP_AWAKE=$keep_awake (default behavior)"
|
||||
fi
|
||||
|
||||
# -- Electron binary --
|
||||
# Version is read from the file next to the binary rather than
|
||||
# launching Electron, which can hang (see #371).
|
||||
@@ -484,6 +822,74 @@ run_doctor() {
|
||||
_warn 'Chrome sandbox not found (expected for AppImage)'
|
||||
fi
|
||||
|
||||
# -- User-namespace sandbox (Ubuntu 24.04+ AppArmor) --
|
||||
# Ubuntu 24.04+ sets apparmor_restrict_unprivileged_userns=1, which
|
||||
# blocks the user namespaces Chromium's sandbox needs and crashes the
|
||||
# app on launch (credentials.cc FATAL, exit 133). A scoped AppArmor
|
||||
# profile permits them for Claude only. Only report when the
|
||||
# restriction is actually in force — on other distros the knob is
|
||||
# absent and this check stays silent.
|
||||
local _userns_path='/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
|
||||
local _userns_val=''
|
||||
[[ -r $_userns_path ]] && _userns_val=$(<"$_userns_path")
|
||||
# Gate on the deb's installed Electron, not $electron_path (the
|
||||
# invoking build's binary): the profile pins this exact path, so only
|
||||
# a deb install is confined by it. AppImage always runs --no-sandbox
|
||||
# and Nix binaries live in the store — neither can hit the crash.
|
||||
local _deb_electron='/usr/lib/claude-desktop'
|
||||
_deb_electron+='/node_modules/electron/dist/electron'
|
||||
if [[ $_userns_val == 1 && -e $_deb_electron ]]; then
|
||||
# Profile name must match deb.sh's /etc/apparmor.d/$package_name
|
||||
# (PACKAGE_NAME in build.sh).
|
||||
local _aa_profile='/etc/apparmor.d/claude-desktop'
|
||||
local _aa_loaded='/sys/kernel/security/apparmor/profiles'
|
||||
# securityfs marks this file world-readable (0444), but the kernel
|
||||
# still denies the actual read without CAP_MAC_ADMIN — so a -r test
|
||||
# passes for non-root yet the read returns nothing. Attempt the read
|
||||
# and judge by whether we actually got data, not by the mode bits.
|
||||
local _loaded_set=''
|
||||
_loaded_set=$(cat "$_aa_loaded" 2>/dev/null)
|
||||
if [[ -n $_loaded_set ]]; then
|
||||
# Authoritative: we actually read the kernel's loaded profile
|
||||
# set (needs root), so report the real load state — not
|
||||
# mere presence on disk.
|
||||
if printf '%s\n' "$_loaded_set" | grep -q '^claude-desktop '; then
|
||||
_pass 'User namespaces: restricted, AppArmor profile loaded'
|
||||
else
|
||||
_warn 'User namespaces: restricted by AppArmor,' \
|
||||
'Claude profile not loaded'
|
||||
if [[ -e $_aa_profile ]]; then
|
||||
_info ' Profile is on disk but not loaded. Load it:'
|
||||
_info " sudo apparmor_parser -r $_aa_profile"
|
||||
else
|
||||
_info ' No profile found. See docs/troubleshooting.md'
|
||||
_info ' "Claude Desktop crashes immediately on launch".'
|
||||
fi
|
||||
fi
|
||||
elif [[ -e $_aa_profile ]]; then
|
||||
# The loaded set was unreadable: non-root (the kernel needs
|
||||
# CAP_MAC_ADMIN despite the 0444 mode), or securityfs is
|
||||
# unmounted (common in containers). Report presence on disk
|
||||
# only — never a definitive PASS.
|
||||
if (( EUID == 0 )); then
|
||||
_info 'User namespaces: AppArmor profile present on disk' \
|
||||
'(securityfs unavailable; cannot confirm it is loaded)'
|
||||
else
|
||||
_info 'User namespaces: AppArmor profile present on disk' \
|
||||
'(re-run with sudo to confirm it is loaded)'
|
||||
fi
|
||||
else
|
||||
_warn 'User namespaces: restricted by AppArmor,' \
|
||||
'no Claude profile found'
|
||||
_info ' Unprivileged user namespaces are blocked, which'
|
||||
_info ' crashes the app on launch in X11 sessions'
|
||||
_info ' (credentials.cc FATAL). Wayland sessions run with'
|
||||
_info ' --no-sandbox and are unaffected.'
|
||||
_info ' See docs/troubleshooting.md "Claude Desktop crashes'
|
||||
_info ' immediately on launch" for the profile to install.'
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- SingletonLock --
|
||||
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
||||
local lock_file="$config_dir/SingletonLock"
|
||||
@@ -502,6 +908,9 @@ run_doctor() {
|
||||
_pass 'SingletonLock: no lock file (OK)'
|
||||
fi
|
||||
|
||||
# -- Password store --
|
||||
_doctor_check_password_store
|
||||
|
||||
# -- MCP config --
|
||||
local mcp_config="$config_dir/claude_desktop_config.json"
|
||||
if [[ -f $mcp_config ]]; then
|
||||
@@ -569,30 +978,13 @@ print(len(servers))
|
||||
fi
|
||||
|
||||
# -- Disk space --
|
||||
local config_disk_avail
|
||||
config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
|
||||
| tail -1 | tr -d ' M') || true
|
||||
if [[ -n $config_disk_avail ]]; then
|
||||
if ((config_disk_avail < 100)); then
|
||||
_fail "Disk space: ${config_disk_avail}MB free on config partition"
|
||||
_info 'Fix: Free up disk space'
|
||||
elif ((config_disk_avail < 500)); then
|
||||
_warn "Disk space: ${config_disk_avail}MB free" \
|
||||
"on config partition (low)"
|
||||
else
|
||||
_pass "Disk space: ${config_disk_avail}MB free"
|
||||
fi
|
||||
fi
|
||||
_doctor_check_disk_space "$config_dir"
|
||||
|
||||
# -- Cowork Mode --
|
||||
echo
|
||||
echo -e "${_bold}Cowork Mode${_reset}"
|
||||
echo '----------------'
|
||||
|
||||
# Detect distro for package hints
|
||||
local _distro_id
|
||||
_distro_id=$(_cowork_distro_id)
|
||||
|
||||
# Determine whether bwrap is the active backend (for severity
|
||||
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
|
||||
# bwrap is active unless the user has overridden to KVM or host.
|
||||
@@ -641,9 +1033,8 @@ print(len(servers))
|
||||
' Common on Ubuntu 24.04+ where AppArmor sets' \
|
||||
'apparmor_restrict_unprivileged_userns=1'
|
||||
_info \
|
||||
' by default. See docs/TROUBLESHOOTING.md' \
|
||||
'"Cowork on Ubuntu 24.04"'
|
||||
_info ' for the AppArmor profile fix.'
|
||||
' by default. See docs/troubleshooting.md' \
|
||||
'"Cowork on Ubuntu 24.04" for the AppArmor profile fix.'
|
||||
fi
|
||||
fi
|
||||
else
|
||||
@@ -785,34 +1176,26 @@ print(len(servers))
|
||||
# Custom bwrap mount configuration
|
||||
_doctor_check_bwrap_mounts
|
||||
|
||||
# Short NAME_MAX on the host's ~/.claude tree (eCryptfs etc.)
|
||||
# blocks cowork session init with ENAMETOOLONG — see #590.
|
||||
_doctor_check_filename_limit
|
||||
|
||||
# -- Orphaned cowork daemon --
|
||||
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
|
||||
# above: a live UI is an Electron main process on app.asar that is
|
||||
# not a Chromium helper (--type=...), not the cowork daemon itself,
|
||||
# and not stopped/zombie. Counting any `claude-desktop`-matching
|
||||
# process (as the old check did) would include the launcher's own
|
||||
# bash and stuck launcher bashes from previous crashes, producing
|
||||
# false negatives where a real orphan is misreported as "parent
|
||||
# alive".
|
||||
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon:
|
||||
# _claude_desktop_ui_is_alive in launcher-common.sh fingerprints on
|
||||
# the --class=$WM_CLASS flag from build_electron_args (since #700
|
||||
# the launchers no longer pass app.asar in argv — Electron
|
||||
# auto-loads it), excluding Chromium helpers (--type=...), the
|
||||
# cowork daemon itself, our own launcher bash, and stopped/zombie
|
||||
# processes. Counting any `claude-desktop`-matching process (as
|
||||
# the old check did) would include the launcher's own bash and
|
||||
# stuck launcher bashes from previous crashes, producing false
|
||||
# negatives where a real orphan is misreported as "parent alive".
|
||||
local _cowork_pids
|
||||
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|
||||
|| true
|
||||
if [[ -n $_cowork_pids ]]; then
|
||||
local _daemon_orphaned=true _pid _cmdline _state
|
||||
for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
|
||||
[[ $_pid == "$$" || $_pid == "$PPID" ]] && continue
|
||||
_cmdline=$(tr '\0' ' ' \
|
||||
< "/proc/$_pid/cmdline" 2>/dev/null) || continue
|
||||
[[ $_cmdline == *cowork-vm-service* ]] && continue
|
||||
[[ $_cmdline == *--type=* ]] && continue
|
||||
_state=$(awk '/^State:/ {print $2; exit}' \
|
||||
"/proc/$_pid/status" 2>/dev/null) || continue
|
||||
[[ $_state == T || $_state == t || $_state == Z ]] \
|
||||
&& continue
|
||||
_daemon_orphaned=false
|
||||
break
|
||||
done
|
||||
if [[ $_daemon_orphaned == true ]]; then
|
||||
if ! _claude_desktop_ui_is_alive; then
|
||||
_warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)"
|
||||
_info 'Fix: Restart Claude Desktop' \
|
||||
'(daemon will be cleaned up automatically)'
|
||||
@@ -821,6 +1204,11 @@ print(len(servers))
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Recent crashes --
|
||||
# Surfaces the GPU process FATAL pattern (#583) before users
|
||||
# notice the in-app "Claude crashed repeatedly" prompt.
|
||||
_doctor_check_recent_crashes "$electron_path"
|
||||
|
||||
# -- Log file --
|
||||
local log_path
|
||||
log_path="${XDG_CACHE_HOME:-$HOME/.cache}"
|
||||
|
||||
@@ -81,15 +81,28 @@ const CLOSE_TO_TRAY = process.platform === 'linux'
|
||||
&& process.env.CLAUDE_QUIT_ON_CLOSE !== '1';
|
||||
console.log(`[Frame Fix] Close-to-tray: ${CLOSE_TO_TRAY ? 'on' : 'off'}`);
|
||||
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About)
|
||||
// Quick Entry: titleBarStyle:"", skipTaskbar:true, transparent:true, resizable:false
|
||||
// About: titleBarStyle:"", skipTaskbar:true, resizable:false
|
||||
// Main: titleBarStyle:"", titleBarOverlay:false(linux), resizable (has minWidth)
|
||||
// The main window has minWidth set; popups do not.
|
||||
// Power save blocker behavior, controlled by CLAUDE_KEEP_AWAKE env var:
|
||||
// unset / '1' - pass through with diagnostic logging
|
||||
// '0' - suppress powerSaveBlocker.start() calls entirely
|
||||
// Upstream's keepAwakeEnabled has no lifecycle management on Linux (the
|
||||
// darwin-only wake scheduler never runs), so the inhibitor fires at init
|
||||
// and never releases — preventing suspend and screensaver. See #605.
|
||||
const KEEP_AWAKE = process.env.CLAUDE_KEEP_AWAKE !== '0';
|
||||
console.log(`[Frame Fix] Keep awake: ${KEEP_AWAKE ? 'on (default)' : 'suppressed (CLAUDE_KEEP_AWAKE=0)'}`);
|
||||
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About).
|
||||
// Window kinds — see build-reference/app-extracted/.vite/build/index.js:
|
||||
// Quick Entry: titleBarStyle:"hidden", frame:false (caught early)
|
||||
// About: titleBarStyle:"hiddenInset", no minWidth, no parent
|
||||
// Main: titleBarStyle:"hidden", minWidth:600
|
||||
// Hardware Buddy: titleBarStyle:"hiddenInset", parent set (child modal — keep frame)
|
||||
// minWidth excludes Main; the `parent` key excludes Hardware Buddy. About
|
||||
// went from "" to "hiddenInset" upstream, so the test matches either.
|
||||
function isPopupWindow(options) {
|
||||
if (!options) return false;
|
||||
if (options.frame === false) return true;
|
||||
if (options.titleBarStyle === '' && !options.minWidth) return true;
|
||||
if ('parent' in options) return false;
|
||||
if ((options.titleBarStyle === '' || options.titleBarStyle === 'hiddenInset') && !options.minWidth) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -117,6 +130,28 @@ const LINUX_CSS = `
|
||||
}
|
||||
`;
|
||||
|
||||
// autoUpdater no-op: every property access returns a chainable function
|
||||
// so `.on(...).once(...).setFeedURL(...).checkForUpdates()` is harmless.
|
||||
// `getFeedURL` returns '' so any code that inspects the URL gets a
|
||||
// well-typed empty string rather than undefined. `then`/`catch`/`finally`
|
||||
// and `Symbol.toPrimitive`/`Symbol.iterator` resolve to `undefined` so the
|
||||
// Proxy is not mistaken for a thenable (which would call chainNoop as
|
||||
// `then(resolve, reject)` and never resolve — silent await hang) or
|
||||
// asked to coerce to a primitive. Writes land on the target but are
|
||||
// shadowed by the get-trap. Defined once and reused across all
|
||||
// require('electron') calls. Linux-only; macOS/Windows still see the
|
||||
// real autoUpdater. See #567.
|
||||
const autoUpdaterNoop = new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getFeedURL') return () => '';
|
||||
if (prop === 'then' || prop === 'catch' || prop === 'finally'
|
||||
|| prop === Symbol.toPrimitive || prop === Symbol.iterator) {
|
||||
return undefined;
|
||||
}
|
||||
return function chainNoop() { return autoUpdaterNoop; };
|
||||
},
|
||||
});
|
||||
|
||||
// Build the patched BrowserWindow class and Menu interceptor once,
|
||||
// on first require('electron'), then reuse via Proxy on every access.
|
||||
let PatchedBrowserWindow = null;
|
||||
@@ -152,10 +187,7 @@ Module.prototype.require = function(id) {
|
||||
} else if (TITLEBAR_STYLE === 'native') {
|
||||
// Main window, native mode: force system frame.
|
||||
options.frame = true;
|
||||
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
|
||||
// 'auto' (default): hidden, Alt toggles
|
||||
// 'visible'/'hidden': no Alt toggle
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
options.autoHideMenuBar = false;
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
|
||||
@@ -185,7 +217,7 @@ Module.prototype.require = function(id) {
|
||||
// CSS rule still applying within the framed
|
||||
// window's content area.
|
||||
options.frame = true;
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
options.autoHideMenuBar = false;
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
|
||||
@@ -220,6 +252,22 @@ Module.prototype.require = function(id) {
|
||||
this.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
// Track the most recent 'show' event timestamp on the
|
||||
// window. Read by the webContents.focus() guard below to
|
||||
// distinguish a genuine post-show activation (which must
|
||||
// pass through to send _NET_ACTIVE_WINDOW and actually
|
||||
// give the window WM focus) from a sloppy-focus
|
||||
// reassertion (which is what we want to skip). Required
|
||||
// because Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland — a freshly-restored
|
||||
// window reports focused=true even though the WM never
|
||||
// activated it, and skipping the focus() call leaves the
|
||||
// window visible-but-inert until the user clicks it.
|
||||
// See #416 review notes.
|
||||
this._lastShownAt = 0;
|
||||
this.on('show', () => { this._lastShownAt = Date.now(); });
|
||||
this.on('restore', () => { this._lastShownAt = Date.now(); });
|
||||
|
||||
// Inject CSS for Linux scrollbar styling
|
||||
this.webContents.on('did-finish-load', () => {
|
||||
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
|
||||
@@ -290,8 +338,7 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
|
||||
// In 'hidden' mode, suppress Alt toggle by re-hiding
|
||||
// on every show event. In 'auto' mode, let
|
||||
// autoHideMenuBar handle the toggle natively.
|
||||
// on every show event.
|
||||
if (MENU_BAR_MODE === 'hidden') {
|
||||
this.on('show', () => {
|
||||
this.setMenuBarVisibility(false);
|
||||
@@ -313,6 +360,44 @@ Module.prototype.require = function(id) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// CLAUDE_QUIT_ON_CLOSE=1: the bundled main-process code
|
||||
// (`.vite/build/index.js`) installs its own main-window
|
||||
// close listener that hardcodes `preventDefault()` +
|
||||
// `hide()` on every non-Windows platform, with no
|
||||
// setting or env var to disable it. The wrapper's
|
||||
// opt-out above only removes *this* file's hide handler;
|
||||
// the bundled one still runs, so without this branch
|
||||
// closing the window still leaves the app alive in the
|
||||
// tray (in-app schedulers / single-instance lock /
|
||||
// deleted-inode electron after dpkg upgrade-in-place).
|
||||
//
|
||||
// Approach: register a close listener that runs *first*
|
||||
// and calls app.quit(). app.quit() emits 'before-quit'
|
||||
// synchronously, which sets the bundled code's
|
||||
// "quitting in progress" flag. The bundled close
|
||||
// listener then runs second, sees that flag, and
|
||||
// short-circuits via its own `if (lC()) return;` guard
|
||||
// — so it never calls preventDefault, and the window
|
||||
// closes normally during the quit flow. We ride the
|
||||
// upstream's own quit-safety contract instead of trying
|
||||
// to remove or splice their listener; robust to any
|
||||
// refactor that preserves the quit-in-progress short-
|
||||
// circuit (which they need for Ctrl+Q / tray Quit /
|
||||
// SIGTERM anyway). Fixes: #623
|
||||
this.on('close', () => { result.app.quit(); });
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle state (auto mode). Tracked
|
||||
// per-window so chords spanning multiple webContents
|
||||
// (main window + BrowserView) share one state machine.
|
||||
// Reset on blur to avoid stale state after Alt-Tab.
|
||||
if (MENU_BAR_MODE === 'auto') {
|
||||
this._altMenuTracker = { pressed: false, chorded: false };
|
||||
this.on('blur', () => {
|
||||
this._altMenuTracker.pressed = false;
|
||||
this._altMenuTracker.chorded = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Directly set child view bounds to match content size.
|
||||
@@ -478,11 +563,32 @@ Module.prototype.require = function(id) {
|
||||
|
||||
// Intercept Menu.setApplicationMenu to hide menu bar on Linux.
|
||||
// In 'hidden' mode, force-hide after every menu update.
|
||||
// In 'auto' mode, only hide initially (autoHideMenuBar handles
|
||||
// Alt toggle — re-hiding here would break that). Fixes: #321
|
||||
// In 'auto' mode, only hide initially (the before-input-event
|
||||
// Alt-keyup handler manages toggle). Fixes: #321
|
||||
const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu);
|
||||
patchedSetApplicationMenu = function(menu) {
|
||||
console.log('[Frame Fix] Intercepting setApplicationMenu');
|
||||
|
||||
// Append a hidden View submenu with F11 fullscreen toggle.
|
||||
// Upstream has fullscreenable:true and persists isFullScreen
|
||||
// across sessions; macOS provides the green traffic-light
|
||||
// button; Linux has no equivalent OS-level trigger, so we
|
||||
// register an accelerator here. visible:false keeps it out
|
||||
// of the menu bar — it only registers the keybinding.
|
||||
// Fixes: #580
|
||||
if (process.platform === 'linux' && menu) {
|
||||
const { MenuItem, Menu: MenuClass } = electronModule;
|
||||
menu.append(new MenuItem({
|
||||
label: 'View',
|
||||
visible: false,
|
||||
submenu: MenuClass.buildFromTemplate([{
|
||||
label: 'Toggle Full Screen',
|
||||
role: 'togglefullscreen',
|
||||
accelerator: 'F11',
|
||||
}]),
|
||||
}));
|
||||
}
|
||||
|
||||
originalSetAppMenu(menu);
|
||||
if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') {
|
||||
for (const win of PatchedBrowserWindow.getAllWindows()) {
|
||||
@@ -535,13 +641,105 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
}
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (!input.control) return;
|
||||
if (input.alt || input.shift || input.meta) return;
|
||||
if (input.key !== 'q' && input.key !== 'Q') return;
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
if (input.type === 'keyDown' && input.control
|
||||
&& !input.alt && !input.shift && !input.meta
|
||||
&& (input.key === 'q' || input.key === 'Q')) {
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle (auto mode). Chromium's
|
||||
// autoHideMenuBar fires on keydown, grabbing focus
|
||||
// before Alt+Shift (language switch) or Alt+F4 can
|
||||
// complete. We suppress the keydown and toggle on
|
||||
// keyup only when Alt was released without any
|
||||
// intervening key. Fixes: #630
|
||||
if (MENU_BAR_MODE !== 'auto') return;
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return;
|
||||
const tracker = owner._altMenuTracker;
|
||||
if (!tracker) return;
|
||||
|
||||
if (input.key === 'Alt') {
|
||||
if (input.type === 'keyDown') {
|
||||
tracker.pressed = true;
|
||||
tracker.chorded = false;
|
||||
event.preventDefault();
|
||||
} else if (input.type === 'keyUp') {
|
||||
if (tracker.pressed && !tracker.chorded) {
|
||||
owner.setMenuBarVisibility(!owner.isMenuBarVisible());
|
||||
}
|
||||
tracker.pressed = false;
|
||||
}
|
||||
} else if (tracker.pressed && input.type === 'keyDown') {
|
||||
tracker.chorded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress redundant webContents.focus() calls that would
|
||||
// re-trigger Chromium's X11Window::Activate() and send a
|
||||
// _NET_ACTIVE_WINDOW client message — EWMH defines that as
|
||||
// focus-AND-raise, so under sloppy / focus-follows-mouse
|
||||
// WMs (Cinnamon Muffin, Mutter, i3 with focus_follows_mouse)
|
||||
// every BrowserWindow 'focus' event causes a raise on
|
||||
// mouse-enter, undoing the user's "no auto-raise" config.
|
||||
// Tracks electron/electron#38184.
|
||||
//
|
||||
// Hooked at app.on('web-contents-created') so child views
|
||||
// are covered too — the BrowserWindow-class wrap only
|
||||
// touches the window's own webContents, but the upstream
|
||||
// call site lives on a child WebContentsView (the claude.ai
|
||||
// host view) whose webContents is a different object.
|
||||
//
|
||||
// Skip is gated on the *owning toplevel*'s isFocused(),
|
||||
// not the webContents'. wc.isFocused() returns false on a
|
||||
// freshly-attached child view even when the window is
|
||||
// focused — that's exactly the state on every sloppy hover,
|
||||
// so guarding on it would never skip and the raise loop
|
||||
// would continue.
|
||||
//
|
||||
// The post-'show' grace window is the second half of the
|
||||
// story. Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland (the same trap that
|
||||
// drives the KDE-only patches in scripts/patches/
|
||||
// quick-window.sh); a tray-restore hide → show then sees
|
||||
// ownerFocused=true and a naive guard would skip, leaving
|
||||
// the window visible-but-inert (no _NET_ACTIVE_WINDOW, no
|
||||
// keyboard focus until the user clicks). Within
|
||||
// SHOW_GRACE_MS of a 'show' event we pass through
|
||||
// unconditionally, so the post-restore activation actually
|
||||
// lands. 1000 ms covers the synchronous show → focus
|
||||
// sequence with margin for slow restores.
|
||||
//
|
||||
// Trade-off: in sloppy mode, hover-induced focus events
|
||||
// are SKIPped, which suppresses both the X11 raise (the
|
||||
// bug we're fixing) and the renderer-focus direction that
|
||||
// webContents.focus() would also do. Net effect: hover
|
||||
// gives WM focus (frame highlight) but renderer focus
|
||||
// doesn't follow until the user clicks. The Electron API
|
||||
// doesn't expose a renderer-focus-only path on X11, so
|
||||
// this is the best available trade against the constant-
|
||||
// raise UX. Genuine activations (no recent show + not
|
||||
// already focused) still go through end-to-end.
|
||||
//
|
||||
// Known: deferred setTimeout focus sites (e.g. find-bar
|
||||
// dismiss) outside the grace window may lose renderer-focus
|
||||
// direction on keyboard dismissal. See #416 review.
|
||||
//
|
||||
// Fixes: #416
|
||||
const SHOW_GRACE_MS = 1000;
|
||||
const origFocus = wc.focus.bind(wc);
|
||||
wc.focus = (...args) => {
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return origFocus(...args);
|
||||
if (!owner.isFocused()) return origFocus(...args);
|
||||
const shownAt = owner._lastShownAt || 0;
|
||||
if (Date.now() - shownAt < SHOW_GRACE_MS) {
|
||||
return origFocus(...args);
|
||||
}
|
||||
return;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -595,9 +793,8 @@ Module.prototype.require = function(id) {
|
||||
return { exec: 'claude-desktop', icon: 'claude-desktop' };
|
||||
};
|
||||
|
||||
// StartupWMClass matches the value set by scripts/packaging/{deb,rpm}.sh
|
||||
// so DEs group an autostarted window with user-launched instances
|
||||
// under the same taskbar / dock entry.
|
||||
// StartupWMClass derived from Electron's app.name (upstream
|
||||
// productName) so DEs group autostarted and launched instances.
|
||||
const buildAutostartContent = () => {
|
||||
const { exec, icon } = resolveAutostartTarget();
|
||||
return `[Desktop Entry]
|
||||
@@ -605,7 +802,7 @@ Type=Application
|
||||
Name=Claude
|
||||
Exec=${exec}
|
||||
Icon=${icon}
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=${result.app.name}
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`;
|
||||
@@ -654,6 +851,74 @@ X-GNOME-Autostart-enabled=true
|
||||
console.log('[Autostart] XDG Autostart shim installed');
|
||||
}
|
||||
|
||||
// Detect in-place package upgrade (dpkg/rpm rename-replace of
|
||||
// app.asar) and offer a restart, since post-swap window loads
|
||||
// mix v(N+1) HTML/assets with the v(N) IPC/preload still in
|
||||
// memory. AppImage and Nix are immune (immutable running file);
|
||||
// the watcher just no-ops there. Fixes: see PR #564.
|
||||
const armUpgradeWatcher = () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const fs = require('fs');
|
||||
const asarPath = path.join(process.resourcesPath, 'app.asar');
|
||||
let baseline;
|
||||
try { baseline = fs.statSync(asarPath); } catch { return; }
|
||||
|
||||
let notified = false;
|
||||
let debounceTimer = null;
|
||||
const promptRestart = () => {
|
||||
if (notified) return;
|
||||
let cur;
|
||||
try { cur = fs.statSync(asarPath); } catch { return; }
|
||||
// ino catches rename-replace; mtime catches in-place
|
||||
// rewrite. Either is sufficient on its own for dpkg/rpm,
|
||||
// but checking both keeps us honest against odd packagers.
|
||||
if (cur.ino === baseline.ino
|
||||
&& cur.mtimeMs === baseline.mtimeMs) return;
|
||||
notified = true;
|
||||
console.log('[Frame Fix] app.asar replaced — prompting restart');
|
||||
// whenReady() resolves immediately if already ready, so no
|
||||
// isReady() branch needed. Linux libnotify ignores
|
||||
// Notification.actions (macOS-only), so whole-notification
|
||||
// click is the only restart affordance.
|
||||
result.app.whenReady().then(() => {
|
||||
try {
|
||||
const n = new result.Notification({
|
||||
title: 'Claude Desktop has been updated',
|
||||
body: 'Click to restart and apply the update.',
|
||||
});
|
||||
n.on('click', () => {
|
||||
result.app.relaunch();
|
||||
result.app.quit();
|
||||
});
|
||||
n.show();
|
||||
} catch (err) {
|
||||
console.warn('[Frame Fix] Restart notification failed:',
|
||||
err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Watch the parent dir, not the file: file-level fs.watch
|
||||
// loses the inode across rename-replace. Filename filter
|
||||
// ignores unrelated activity in the resources dir; 5s
|
||||
// debounce covers dpkg's .dpkg-new → rename dance and
|
||||
// similar multi-stage swaps in rpm/Nix.
|
||||
const watcher = fs.watch(path.dirname(asarPath),
|
||||
(_evt, filename) => {
|
||||
if (filename !== 'app.asar') return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(promptRestart, 5000);
|
||||
});
|
||||
// App's other handles drive process lifetime; the watcher
|
||||
// shouldn't keep the loop alive on its own.
|
||||
watcher.unref();
|
||||
console.log('[Frame Fix] Upgrade watcher armed:', asarPath);
|
||||
};
|
||||
try { armUpgradeWatcher(); } catch (err) {
|
||||
console.warn('[Frame Fix] Upgrade watcher failed to arm:',
|
||||
err.message);
|
||||
}
|
||||
|
||||
console.log('[Frame Fix] Patches built successfully');
|
||||
}
|
||||
|
||||
@@ -673,6 +938,56 @@ X-GNOME-Autostart-enabled=true
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'powerSaveBlocker' && process.platform === 'linux') {
|
||||
// Wrap powerSaveBlocker with logging and optional suppression
|
||||
const originalPSB = target.powerSaveBlocker;
|
||||
return new Proxy(originalPSB, {
|
||||
get(psTarget, psProp) {
|
||||
if (psProp === 'start') {
|
||||
return function(type) {
|
||||
if (!KEEP_AWAKE) {
|
||||
console.log(`[Power] powerSaveBlocker.start('${type}') suppressed (CLAUDE_KEEP_AWAKE=0)`);
|
||||
return -1;
|
||||
}
|
||||
const id = psTarget.start(type);
|
||||
console.log(`[Power] powerSaveBlocker.start('${type}') -> id=${id}`);
|
||||
return id;
|
||||
};
|
||||
}
|
||||
if (psProp === 'stop') {
|
||||
return function(id) {
|
||||
if (id < 0) return;
|
||||
console.log(`[Power] powerSaveBlocker.stop(${id})`);
|
||||
return psTarget.stop(id);
|
||||
};
|
||||
}
|
||||
if (psProp === 'isStarted') {
|
||||
return function(id) {
|
||||
if (id < 0) return false;
|
||||
return psTarget.isStarted(id);
|
||||
};
|
||||
}
|
||||
return Reflect.get(psTarget, psProp);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'autoUpdater' && process.platform === 'linux') {
|
||||
// Force autoUpdater into a no-op on Linux. Upstream's bundled
|
||||
// app code sets a feed URL of api.anthropic.com/api/desktop/linux/...
|
||||
// when app.isPackaged is true (we set ELECTRON_FORCE_IS_PACKAGED=true
|
||||
// unconditionally). Today this is a happy accident: Electron's Linux
|
||||
// autoUpdater is unimplemented and logs "AutoUpdater is not supported
|
||||
// on Linux", so the calls no-op. If a future Electron implements it,
|
||||
// every install would start hitting that feed and would either 404
|
||||
// or — worse — receive content the install wasn't prepared for.
|
||||
// .deb/.rpm/AppImage updates flow through the OS package manager
|
||||
// (or AppImageUpdate); the Anthropic feed has no Linux artifacts.
|
||||
// We replace the entire autoUpdater object with a Proxy that
|
||||
// no-ops every method and returns chainable stubs for EventEmitter
|
||||
// calls so listener registration in the bundled code is harmless.
|
||||
// See #567.
|
||||
return autoUpdaterNoop;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# Common launcher functions for Claude Desktop (AppImage and deb)
|
||||
# This file is sourced by both launchers to avoid code duplication
|
||||
|
||||
# WM_CLASS / StartupWMClass — must match upstream productName.
|
||||
# @@WM_CLASS@@ is replaced at build time; see build.sh.
|
||||
readonly WM_CLASS='@@WM_CLASS@@'
|
||||
|
||||
# Setup logging directory and file
|
||||
# Sets: log_dir, log_file
|
||||
setup_logging() {
|
||||
@@ -16,6 +20,41 @@ log_message() {
|
||||
echo "$1" >> "$log_file"
|
||||
}
|
||||
|
||||
# Log the session/IME environment vars that drive display and input
|
||||
# decisions, so bug reports include enough context to reason about
|
||||
# them without round-trip env-dump requests (#548).
|
||||
#
|
||||
# Emits one block:
|
||||
# env={
|
||||
# KEY=value
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
# Empty or unset values are emitted as `KEY=` so absence is
|
||||
# unambiguous (vs. silently omitted). Caller must run setup_logging
|
||||
# first.
|
||||
log_session_env() {
|
||||
local key
|
||||
log_message 'env={'
|
||||
for key in \
|
||||
XDG_SESSION_TYPE \
|
||||
WAYLAND_DISPLAY \
|
||||
DISPLAY \
|
||||
XDG_CURRENT_DESKTOP \
|
||||
GTK_IM_MODULE \
|
||||
XMODIFIERS \
|
||||
QT_IM_MODULE \
|
||||
CLAUDE_USE_WAYLAND \
|
||||
CLAUDE_TITLEBAR_STYLE \
|
||||
CLAUDE_PASSWORD_STORE \
|
||||
CLAUDE_GTK_IM_MODULE \
|
||||
CLAUDE_DISABLE_GPU
|
||||
do
|
||||
log_message " $key=${!key:-}"
|
||||
done
|
||||
log_message '}'
|
||||
}
|
||||
|
||||
# Detect display backend (Wayland vs X11)
|
||||
# Sets: is_wayland, use_x11_on_wayland
|
||||
detect_display_backend() {
|
||||
@@ -23,18 +62,38 @@ detect_display_backend() {
|
||||
is_wayland=false
|
||||
[[ -n "${WAYLAND_DISPLAY:-}" ]] && is_wayland=true
|
||||
|
||||
# Default: Use X11/XWayland on Wayland for global hotkey support
|
||||
# Set CLAUDE_USE_WAYLAND=1 to use native Wayland (global hotkeys disabled)
|
||||
# Default: Use X11/XWayland on Wayland so upstream's globalShortcut
|
||||
# (Quick Entry's Ctrl+Alt+Space) keeps working via an X11 key grab.
|
||||
#
|
||||
# CLAUDE_USE_WAYLAND is tri-state:
|
||||
# 1 - force native Wayland (global shortcuts via XDG portal)
|
||||
# 0 - force XWayland, skipping the auto-detect below
|
||||
# unset - auto-detect per compositor
|
||||
use_x11_on_wayland=true
|
||||
[[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]] && use_x11_on_wayland=false
|
||||
local wayland_override="${CLAUDE_USE_WAYLAND:-}"
|
||||
[[ $wayland_override == '1' ]] && use_x11_on_wayland=false
|
||||
|
||||
# Fixes: #226 - Auto-detect compositors that require native Wayland
|
||||
# Only Niri is auto-forced: it has no XWayland support.
|
||||
# Sway and Hyprland have working XWayland, so users on those
|
||||
# compositors who want native Wayland can set CLAUDE_USE_WAYLAND=1.
|
||||
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME");
|
||||
# glob matching with *niri* handles this correctly.
|
||||
if [[ $is_wayland == true && $use_x11_on_wayland == true ]]; then
|
||||
# Fixes: #226 - Only Niri is auto-forced to native Wayland: it has
|
||||
# no XWayland at all, so the X11 backend can't even start.
|
||||
#
|
||||
# GNOME Wayland is NOT auto-forced. mutter no longer honours
|
||||
# XWayland global key grabs (#404), and native Wayland would route
|
||||
# Quick Entry's globalShortcut through the XDG GlobalShortcuts portal
|
||||
# instead -- but flipping the default session off mature XWayland is
|
||||
# a rendering / IME / HiDPI risk, and on GNOME 50 the portal path is
|
||||
# a no-op anyway (electron/electron#51875). GNOME users who want the
|
||||
# portal route opt in with CLAUDE_USE_WAYLAND=1 (works on GNOME <=49
|
||||
# after the one-time portal permission dialog).
|
||||
#
|
||||
# Sway and Hyprland keep working XWayland grabs and their wlroots
|
||||
# portal has no GlobalShortcuts backend, so they also stay on the
|
||||
# XWayland default; opt in with CLAUDE_USE_WAYLAND=1 if desired. An
|
||||
# explicit CLAUDE_USE_WAYLAND=0 opts out of this auto-detect entirely.
|
||||
#
|
||||
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME"); the
|
||||
# *glob* substring match handles this.
|
||||
if [[ $is_wayland == true && $use_x11_on_wayland == true \
|
||||
&& $wayland_override != '0' ]]; then
|
||||
local desktop="${XDG_CURRENT_DESKTOP:-}"
|
||||
desktop="${desktop,,}"
|
||||
|
||||
@@ -67,6 +126,105 @@ _resolve_titlebar_style() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Determine the best available Chromium password-store backend.
|
||||
#
|
||||
# Electron's safeStorage API and Chromium's cookie encryption both rely
|
||||
# on the OS credential store selected by --password-store. Without a
|
||||
# working store safeStorage.isEncryptionAvailable() returns false, OAuth
|
||||
# tokens are silently discarded on exit, and users must re-authenticate
|
||||
# on every launch (Cookies file stays 0 bytes). Fixes: #593
|
||||
#
|
||||
# Detection order (first match wins):
|
||||
# CLAUDE_PASSWORD_STORE env var — explicit user override
|
||||
# kwallet6 — KDE Plasma 6 keyring
|
||||
# gnome-libsecret — GNOME Keyring / libsecret bridge
|
||||
# basic — fixed internal key (always works)
|
||||
#
|
||||
# With 'basic' the stored data is encrypted with a fixed key. Tokens
|
||||
# remain protected by Linux filesystem permissions on ~/.config/Claude/.
|
||||
#
|
||||
# Assumes a D-Bus session bus is available; this is true for any
|
||||
# graphical login session.
|
||||
_detect_password_store() {
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
echo "$CLAUDE_PASSWORD_STORE"
|
||||
return
|
||||
fi
|
||||
|
||||
# kwallet6: KDE Plasma 6 keyring
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.kde.kwalletd6 \
|
||||
/modules/kwalletd6 \
|
||||
org.kde.KWallet.isEnabled 2>/dev/null \
|
||||
| grep -q 'boolean true'
|
||||
then
|
||||
echo 'kwallet6'
|
||||
return
|
||||
fi
|
||||
|
||||
# gnome-libsecret: GNOME Keyring, KWallet 5 compat bridge, etc.
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.freedesktop.secrets \
|
||||
/org/freedesktop/secrets \
|
||||
org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1
|
||||
then
|
||||
echo 'gnome-libsecret'
|
||||
return
|
||||
fi
|
||||
|
||||
# No keyring accessible — fall back to fixed-key provider.
|
||||
echo 'basic'
|
||||
}
|
||||
|
||||
# Detect whether the previous launch ended in Chromium's
|
||||
# "GPU process isn't usable" crash signature (#583).
|
||||
#
|
||||
# setup_logging() must have run first so $log_file is available. The
|
||||
# launcher writes the current session header before build_electron_args()
|
||||
# runs, so the previous launch lives in the penultimate log section.
|
||||
#
|
||||
# A recovered launch (running with --disable-gpu) produces no GPU
|
||||
# output, so the crash signature alone would re-enable GPU on launch
|
||||
# N+2 and oscillate crash/work/crash on permanently broken hardware.
|
||||
# The launcher's own "disabling GPU" marker therefore also counts as
|
||||
# a trigger, making recovery sticky once tripped. CLAUDE_DISABLE_GPU=0
|
||||
# remains the escape hatch for retesting hardware acceleration.
|
||||
#
|
||||
# Section headers vary by package format: deb/rpm write "Launcher
|
||||
# Start", AppImage writes "AppImage Start", and Nix writes "Launcher
|
||||
# Start (NixOS)" (nix/claude-desktop.nix).
|
||||
_previous_launch_hit_gpu_fatal() {
|
||||
[[ -f ${log_file:-} ]] || return 1
|
||||
|
||||
awk '
|
||||
/^--- Claude Desktop (Launcher|AppImage) Start( \(NixOS\))? ---$/ {
|
||||
section++
|
||||
next
|
||||
}
|
||||
{
|
||||
sections[section] = sections[section] $0 "\n"
|
||||
}
|
||||
END {
|
||||
target = section > 1 ? section - 1 : section
|
||||
if (target < 1) {
|
||||
exit 1
|
||||
}
|
||||
text = sections[target]
|
||||
if (index(text,
|
||||
"GPU process launch failed: error_code=") &&
|
||||
index(text,
|
||||
"GPU process isn'\''t usable. Goodbye.")) {
|
||||
exit 0
|
||||
}
|
||||
if (index(text,
|
||||
"Previous launch hit GPU process FATAL")) {
|
||||
exit 0
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
' "$log_file"
|
||||
}
|
||||
|
||||
# Build Electron arguments array based on display backend
|
||||
# Requires: is_wayland, use_x11_on_wayland to be set
|
||||
# (call detect_display_backend first)
|
||||
@@ -77,6 +235,12 @@ build_electron_args() {
|
||||
|
||||
electron_args=()
|
||||
|
||||
# Chromium ignores all but the LAST --enable-features switch on a
|
||||
# command line, so every feature we want must end up in ONE
|
||||
# comma-joined flag. Accumulate them here and emit a single
|
||||
# --enable-features=... at the end of the function.
|
||||
local enable_features=()
|
||||
|
||||
# AppImage always needs --no-sandbox due to FUSE constraints
|
||||
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
|
||||
|
||||
@@ -84,18 +248,33 @@ build_electron_args() {
|
||||
# hybrid (default) / native: --disable-features=CustomTitlebar
|
||||
# so Chromium's drawn CSD titlebar doesn't compete with
|
||||
# the DE-drawn one. Both modes use frame:true.
|
||||
# hidden: --enable-features=WindowControlsOverlay because WCO
|
||||
# is off by default on Linux Chromium (Win/macOS have
|
||||
# it on by default). Without this flag, titleBarOverlay
|
||||
# is silently ignored at the page level.
|
||||
# hidden: WindowControlsOverlay because WCO is off by default on
|
||||
# Linux Chromium (Win/macOS have it on by default).
|
||||
# Without it, titleBarOverlay is silently ignored at the
|
||||
# page level.
|
||||
local _tb
|
||||
_tb=$(_resolve_titlebar_style)
|
||||
if [[ $_tb == 'hidden' ]]; then
|
||||
electron_args+=('--enable-features=WindowControlsOverlay')
|
||||
enable_features+=('WindowControlsOverlay')
|
||||
else
|
||||
electron_args+=('--disable-features=CustomTitlebar')
|
||||
fi
|
||||
|
||||
# WM_CLASS must match the .desktop StartupWMClass and upstream's
|
||||
# productName. Ref: #647, #652
|
||||
electron_args+=("--class=$WM_CLASS")
|
||||
|
||||
# Chromium's safeStorage API and cookie encryption both require a
|
||||
# system keyring selected by --password-store. Without an explicit
|
||||
# value, Electron may silently report encryption unavailable even
|
||||
# when a keyring daemon is running, discarding OAuth tokens on exit
|
||||
# and forcing re-authentication on every launch. We probe for the
|
||||
# best available store at startup. Fixes: #593
|
||||
local pw_store
|
||||
pw_store=$(_detect_password_store)
|
||||
electron_args+=("--password-store=${pw_store}")
|
||||
log_message "Password store: ${pw_store}"
|
||||
|
||||
# Remote XRDP sessions lack GPU acceleration and render a blank
|
||||
# window when GPU compositing is enabled. Detect via XRDP_SESSION
|
||||
# (set by xrdp's session init) and loginctl session Type. We do
|
||||
@@ -107,37 +286,129 @@ build_electron_args() {
|
||||
loginctl show-session "$XDG_SESSION_ID" \
|
||||
-p Type --value 2>/dev/null
|
||||
)
|
||||
# Track GPU-disable decision so XRDP and CLAUDE_DISABLE_GPU don't
|
||||
# stack duplicate flags. Either signal is sufficient.
|
||||
local _disable_gpu=false
|
||||
if [[ -n ${XRDP_SESSION:-} || $rdp_session_type == xrdp ]]; then
|
||||
electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
_disable_gpu=true
|
||||
log_message 'XRDP session detected - GPU compositing disabled'
|
||||
fi
|
||||
# CLAUDE_DISABLE_GPU=1: opt-in workaround for users hitting the
|
||||
# Chromium GPU process FATAL exhaustion (#583). The same upstream
|
||||
# behaviour is reachable via Settings → disable hardware
|
||||
# acceleration; this lets users persist it via the env without
|
||||
# having to reach the Settings UI through repeated crashes.
|
||||
if [[ -v CLAUDE_DISABLE_GPU ]]; then
|
||||
if [[ ${CLAUDE_DISABLE_GPU} == '1' ]]; then
|
||||
_disable_gpu=true
|
||||
log_message \
|
||||
'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
|
||||
fi
|
||||
elif _previous_launch_hit_gpu_fatal; then
|
||||
_disable_gpu=true
|
||||
log_message \
|
||||
'Previous launch hit GPU process FATAL - disabling GPU'
|
||||
fi
|
||||
[[ $_disable_gpu == true ]] \
|
||||
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
|
||||
# X11 session - no special flags needed
|
||||
# X11 session - no display-backend flags needed.
|
||||
if [[ $is_wayland != true ]]; then
|
||||
log_message 'X11 session detected'
|
||||
return
|
||||
fi
|
||||
|
||||
# Wayland: deb/nix packages need --no-sandbox in both modes
|
||||
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
|
||||
&& electron_args+=('--no-sandbox')
|
||||
|
||||
if [[ $use_x11_on_wayland == true ]]; then
|
||||
# Default: Use X11 via XWayland for global hotkey support
|
||||
log_message 'Using X11 backend via XWayland (for global hotkey support)'
|
||||
electron_args+=('--ozone-platform=x11')
|
||||
else
|
||||
# Native Wayland mode (user opted in via CLAUDE_USE_WAYLAND=1)
|
||||
log_message 'Using native Wayland backend (global hotkeys may not work)'
|
||||
electron_args+=('--enable-features=UseOzonePlatform,WaylandWindowDecorations')
|
||||
electron_args+=('--ozone-platform=wayland')
|
||||
electron_args+=('--enable-wayland-ime')
|
||||
electron_args+=('--wayland-text-input-version=3')
|
||||
# Override any system-wide GDK_BACKEND=x11 that would silently
|
||||
# prevent GTK from connecting to the Wayland compositor, causing
|
||||
# blurry rendering or launch failures on HiDPI displays.
|
||||
export GDK_BACKEND=wayland
|
||||
# Wayland: deb/nix packages need --no-sandbox in both modes
|
||||
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
|
||||
&& electron_args+=('--no-sandbox')
|
||||
|
||||
if [[ $use_x11_on_wayland == true ]]; then
|
||||
# Use X11 via XWayland; globalShortcut uses an X11 key grab.
|
||||
log_message 'Using X11 backend via XWayland (for global hotkey support)'
|
||||
electron_args+=('--ozone-platform=x11')
|
||||
else
|
||||
# Native Wayland: route globalShortcut through the XDG
|
||||
# GlobalShortcutsPortal instead of an X11 key grab. Needs
|
||||
# the wayland ozone platform (the feature is inert under
|
||||
# XWayland) and Electron >= 35. Fixes #404 on GNOME, where
|
||||
# mutter no longer honours XWayland grabs. On compositors
|
||||
# whose portal lacks a GlobalShortcuts backend (e.g.
|
||||
# wlroots) the feature is a harmless no-op.
|
||||
log_message 'Using native Wayland backend (global shortcuts via XDG portal)'
|
||||
enable_features+=(
|
||||
'UseOzonePlatform'
|
||||
'WaylandWindowDecorations'
|
||||
'GlobalShortcutsPortal'
|
||||
)
|
||||
electron_args+=('--ozone-platform=wayland')
|
||||
electron_args+=('--enable-wayland-ime')
|
||||
electron_args+=('--wayland-text-input-version=3')
|
||||
# Override any system-wide GDK_BACKEND=x11 that would silently
|
||||
# prevent GTK from connecting to the Wayland compositor, causing
|
||||
# blurry rendering or launch failures on HiDPI displays.
|
||||
export GDK_BACKEND=wayland
|
||||
fi
|
||||
fi
|
||||
|
||||
# Emit all accumulated Chromium features as a single switch (see the
|
||||
# enable_features declaration above for why a single switch matters).
|
||||
if [[ ${#enable_features[@]} -gt 0 ]]; then
|
||||
local IFS=','
|
||||
electron_args+=("--enable-features=${enable_features[*]}")
|
||||
fi
|
||||
}
|
||||
|
||||
# Does a /proc/PID/cmdline (joined with spaces) belong to the Claude
|
||||
# Desktop Electron UI main process?
|
||||
#
|
||||
# We can NOT fingerprint on `app.asar`: since #700 the launchers no
|
||||
# longer pass it as an argument (Electron auto-loads it from
|
||||
# resources/), so it never appears in any cmdline. The stable
|
||||
# signature across deb/rpm/AppImage/nix is the `--class=$WM_CLASS`
|
||||
# flag every launcher passes via build_electron_args; Chromium keeps
|
||||
# the exec'd argv in /proc/PID/cmdline and does not propagate --class
|
||||
# to its --type=... helper children (verified empirically).
|
||||
#
|
||||
# Callers join /proc/PID/cmdline with `tr '\0' ' '`, which leaves
|
||||
# every argument space-terminated, so anchoring on the trailing space
|
||||
# rejects look-alike classes (e.g. ClaudeDev).
|
||||
_claude_desktop_ui_cmdline_matches() {
|
||||
local cmdline="$1"
|
||||
|
||||
# Never the cowork daemon (defensive; it carries no --class) and
|
||||
# never a Chromium helper: zygote, renderer, gpu, utility, etc.
|
||||
[[ $cmdline == *cowork-vm-service* ]] && return 1
|
||||
[[ $cmdline == *--type=* ]] && return 1
|
||||
|
||||
[[ $cmdline == *"--class=$WM_CLASS "* ]]
|
||||
}
|
||||
|
||||
# Is a live Claude Desktop UI running for this user?
|
||||
#
|
||||
# We can NOT use `pgrep -f 'claude-desktop'` on its own for this: it
|
||||
# matches the launcher's own bash process (this script's cmdline
|
||||
# contains "/usr/bin/claude-desktop"), any stale launcher bash left
|
||||
# stopped/zombie after a previous crash, and the cowork daemon
|
||||
# itself. Counting any of those as "the UI is alive" causes false
|
||||
# negatives in the cleanup functions below. The reliable definition
|
||||
# is: a process whose cmdline carries our --class fingerprint (see
|
||||
# _claude_desktop_ui_cmdline_matches) and is actually runnable (not
|
||||
# stopped/zombie), excluding our own launcher bash and its parent.
|
||||
_claude_desktop_ui_is_alive() {
|
||||
local pid cmdline state
|
||||
for pid in \
|
||||
$(pgrep -u "$(id -u)" -f -- "--class=$WM_CLASS" 2>/dev/null); do
|
||||
# Skip our own launcher bash and its parent.
|
||||
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
|
||||
cmdline=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") \
|
||||
|| continue
|
||||
_claude_desktop_ui_cmdline_matches "$cmdline" || continue
|
||||
# Skip stopped (T/t) and zombie (Z) processes — not a live UI.
|
||||
state=$(awk '/^State:/ {print $2; exit}' \
|
||||
"/proc/$pid/status" 2>/dev/null) || continue
|
||||
[[ $state == T || $state == t || $state == Z ]] && continue
|
||||
# Found a genuine live Electron UI.
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Kill orphaned cowork-vm-service daemon processes.
|
||||
@@ -150,40 +421,16 @@ build_electron_args() {
|
||||
# Must run BEFORE cleanup_stale_lock / cleanup_stale_cowork_socket
|
||||
# so that stale files left behind by the daemon can be cleaned up.
|
||||
cleanup_orphaned_cowork_daemon() {
|
||||
local cowork_pids
|
||||
local cowork_pids pid
|
||||
cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|
||||
|| return 0
|
||||
|
||||
# Check if a live Claude Desktop UI process is also running.
|
||||
#
|
||||
# We can NOT use `pgrep -f 'claude-desktop'` on its own for this:
|
||||
# it matches the launcher's own bash process (this script's
|
||||
# cmdline contains "/usr/bin/claude-desktop"), any stale launcher
|
||||
# bash left stopped/zombie after a previous crash, and the cowork
|
||||
# daemon itself. Counting any of those as "the UI is alive"
|
||||
# causes a false negative and the orphan survives.
|
||||
#
|
||||
# The reliable definition of "UI is alive" is: an Electron main
|
||||
# process whose cmdline references app.asar and is NOT a Chromium
|
||||
# helper (--type=...) and NOT the cowork daemon, and is actually
|
||||
# runnable (not stopped/zombie).
|
||||
local pid cmdline state
|
||||
for pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
|
||||
# Skip our own launcher bash and its parent.
|
||||
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
|
||||
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) \
|
||||
|| continue
|
||||
# Skip the cowork daemon (matches app.asar.unpacked path).
|
||||
[[ $cmdline == *cowork-vm-service* ]] && continue
|
||||
# Skip Chromium helpers: zygote, renderer, gpu, utility, etc.
|
||||
[[ $cmdline == *--type=* ]] && continue
|
||||
# Skip stopped (T/t) and zombie (Z) processes — not a live UI.
|
||||
state=$(awk '/^State:/ {print $2; exit}' \
|
||||
"/proc/$pid/status" 2>/dev/null) || continue
|
||||
[[ $state == T || $state == t || $state == Z ]] && continue
|
||||
# Found a genuine live Electron UI — daemon is expected
|
||||
# A live Claude Desktop UI process means the daemon is expected;
|
||||
# leave it alone. See _claude_desktop_ui_is_alive for why neither
|
||||
# `pgrep -f 'claude-desktop'` nor an app.asar fingerprint works.
|
||||
if _claude_desktop_ui_is_alive; then
|
||||
return 0
|
||||
done
|
||||
fi
|
||||
|
||||
# No UI process found — daemon is orphaned, terminate it.
|
||||
# Escalate to SIGKILL if a daemon is stuck and does not exit
|
||||
@@ -208,6 +455,83 @@ cleanup_orphaned_cowork_daemon() {
|
||||
fi
|
||||
}
|
||||
|
||||
_desktop_helper_cmdline_matches() {
|
||||
local cmdline="$1"
|
||||
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
||||
|
||||
case "$cmdline" in
|
||||
*cowork-vm-service.js*)
|
||||
return 0
|
||||
;;
|
||||
*"--user-data-dir=$config_dir "*)
|
||||
return 0
|
||||
;;
|
||||
*"$config_dir/Claude Extensions/"*)
|
||||
return 0
|
||||
;;
|
||||
*/usr/lib/claude-desktop/*--type=*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
_desktop_helper_candidate_pids() {
|
||||
pgrep -u "$(id -u)" -f 'cowork-vm-service\.js|--user-data-dir=.*[/]Claude|Claude Extensions|/usr/lib/claude-desktop/' 2>/dev/null
|
||||
}
|
||||
|
||||
cleanup_stale_desktop_helpers() {
|
||||
# A live UI (any instance) suppresses all cleanup. We don't scope
|
||||
# helpers per-instance. Safe, not complete.
|
||||
if _claude_desktop_ui_is_alive; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pids pid cmdline
|
||||
pids=$(_desktop_helper_candidate_pids) || return 0
|
||||
|
||||
local matched=()
|
||||
for pid in $pids; do
|
||||
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
|
||||
[[ ${_electron_child_pid:-} == "$pid" ]] && continue
|
||||
cmdline=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") \
|
||||
|| continue
|
||||
_desktop_helper_cmdline_matches "$cmdline" || continue
|
||||
matched+=("$pid")
|
||||
done
|
||||
|
||||
[[ ${#matched[@]} -gt 0 ]] || return 0
|
||||
|
||||
for pid in "${matched[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
local wait_count=0 alive
|
||||
while ((wait_count < 20)); do
|
||||
alive=false
|
||||
for pid in "${matched[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
alive=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ $alive == false ]] && break
|
||||
sleep 0.1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
if [[ $alive == true ]]; then
|
||||
for pid in "${matched[@]}"; do
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
done
|
||||
log_message \
|
||||
"Killed stale Claude Desktop helpers (SIGKILL, PIDs: ${matched[*]})"
|
||||
else
|
||||
log_message "Killed stale Claude Desktop helpers (PIDs: ${matched[*]})"
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean up stale SingletonLock if the owning process is no longer running.
|
||||
# Electron uses requestSingleInstanceLock() which silently quits if the lock
|
||||
# is held. A stale lock (from a crash or unclean update) blocks all launches
|
||||
@@ -268,6 +592,47 @@ cleanup_stale_cowork_socket() {
|
||||
log_message "Removed stale cowork-vm-service socket (no daemon running)"
|
||||
}
|
||||
|
||||
cleanup_after_electron_exit() {
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
}
|
||||
|
||||
_electron_launcher_forward_signal() {
|
||||
local signal="$1"
|
||||
|
||||
if [[ -n ${_electron_child_pid:-} ]]; then
|
||||
kill "-$signal" "$_electron_child_pid" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_electron_and_cleanup() {
|
||||
local status
|
||||
|
||||
"$@" >> "$log_file" 2>&1 &
|
||||
_electron_child_pid=$!
|
||||
|
||||
trap '_electron_launcher_forward_signal TERM' TERM
|
||||
trap '_electron_launcher_forward_signal INT' INT
|
||||
trap '_electron_launcher_forward_signal HUP' HUP
|
||||
|
||||
wait "$_electron_child_pid"
|
||||
status=$?
|
||||
while kill -0 "$_electron_child_pid" 2>/dev/null; do
|
||||
wait "$_electron_child_pid" # reap only; keep status
|
||||
done
|
||||
|
||||
trap - TERM INT HUP
|
||||
|
||||
log_message "Electron exited with code: $status"
|
||||
cleanup_after_electron_exit
|
||||
_electron_child_pid=''
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# Set common environment variables
|
||||
setup_electron_env() {
|
||||
# ELECTRON_FORCE_IS_PACKAGED makes app.isPackaged return true, which
|
||||
@@ -282,6 +647,15 @@ setup_electron_env() {
|
||||
if [[ $(_resolve_titlebar_style) != 'hidden' ]]; then
|
||||
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
|
||||
fi
|
||||
# CLAUDE_GTK_IM_MODULE: opt-in override for users hit by broken
|
||||
# IBus integration on Linux (#549). Propagated to GTK_IM_MODULE
|
||||
# so e.g. `xim` can be persisted without wrapping every launch.
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
local prev="${GTK_IM_MODULE:-<unset>}"
|
||||
export GTK_IM_MODULE="$CLAUDE_GTK_IM_MODULE"
|
||||
log_message \
|
||||
"GTK_IM_MODULE override: $prev -> $GTK_IM_MODULE (via CLAUDE_GTK_IM_MODULE)"
|
||||
fi
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
|
||||
@@ -48,6 +48,7 @@ echo 'Application files copied to Electron resources directory'
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "$appdir_path/usr/lib/claude-desktop" || exit 1
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$appdir_path/usr/lib/claude-desktop/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -86,7 +87,13 @@ fi
|
||||
# Setup logging and environment
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
|
||||
# Path to the bundled Electron executable and app
|
||||
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
|
||||
app_path="$appdir/usr/lib/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -98,23 +105,28 @@ log_message '--- Claude Desktop AppImage Start ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_message "APPDIR: $appdir"
|
||||
|
||||
# Path to the bundled Electron executable and app
|
||||
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
|
||||
app_path="$appdir/usr/lib/node_modules/electron/dist/resources/app.asar"
|
||||
log_session_env
|
||||
|
||||
# Build electron args (appimage mode adds --no-sandbox)
|
||||
build_electron_args 'appimage'
|
||||
|
||||
# Add app path LAST - Chromium flags must come before this
|
||||
electron_args+=("$app_path")
|
||||
# Intentionally NOT appended: app.asar sits in Electron's default
|
||||
# resources/ dir next to the binary, so Electron auto-loads it. Passing
|
||||
# the path again makes Electron treat it as a file-to-open, which the
|
||||
# app forwards to its file-drop handler, producing a spurious
|
||||
# "Attach app.asar?" prompt on launch and on every taskbar reopen
|
||||
# (the second-instance argv path). Omitting it is the root-cause fix.
|
||||
# See issue #696.
|
||||
log_message "App (auto-loaded by Electron): $app_path"
|
||||
|
||||
# Change to HOME directory before exec'ing Electron to avoid CWD permission issues
|
||||
cd "$HOME" || exit 1
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep AppRun alive so explicit quit can clean up
|
||||
# Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: $electron_exec ${electron_args[*]} $*"
|
||||
exec "$electron_exec" "${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
run_electron_and_cleanup "$electron_exec" "${electron_args[@]}" "$@"
|
||||
exit $?
|
||||
EOF
|
||||
chmod +x "$appdir_path/AppRun" || exit 1
|
||||
echo 'AppRun script created'
|
||||
@@ -132,7 +144,7 @@ Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Comment=Claude Desktop for Linux
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop
|
||||
EOF
|
||||
@@ -169,14 +181,15 @@ mkdir -p "$metadata_dir" || exit 1
|
||||
appdata_file="$metadata_dir/${component_id}.appdata.xml"
|
||||
|
||||
# Generate the AppStream XML file
|
||||
# Use MIT license based on LICENSE-MIT file in repo
|
||||
# project_license describes the app the user launches (the proprietary
|
||||
# Claude binary), not the MIT packaging scripts
|
||||
# ID follows reverse DNS convention
|
||||
cat > "$appdata_file" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>$component_id</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
<developer id="io.github.aaddrick">
|
||||
<name>aaddrick</name>
|
||||
</developer>
|
||||
@@ -267,6 +280,17 @@ if [[ -z $appimagetool_path ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Normalize AppDir permissions before squashing. The staging copy above
|
||||
# uses `cp -a`, which preserves source modes, and a restrictive build
|
||||
# umask can leave directories at 0700. mksquashfs records those verbatim,
|
||||
# so a user who later runs the AppImage can't traverse into
|
||||
# app.asar.unpacked/ — silently breaking Cowork's daemon auto-launch (the
|
||||
# fork is guarded by fs.existsSync(), false on a directory it can't read).
|
||||
# Canonical modes: dirs and already-executable files 755, the rest 644.
|
||||
echo 'Normalizing AppDir permissions...'
|
||||
find "$appdir_path" -type d -exec chmod 755 {} + || exit 1
|
||||
find "$appdir_path" -type f -exec chmod u=rwX,go=rX {} + || exit 1
|
||||
|
||||
# --- Build AppImage ---
|
||||
echo 'Building AppImage...'
|
||||
output_filename="${package_name}-${version}-${architecture}.AppImage"
|
||||
|
||||
@@ -70,6 +70,7 @@ echo 'Application files copied to Electron resources directory'
|
||||
# at runtime, so both must live in the same directory)
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$install_dir/lib/$package_name/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -84,10 +85,17 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
EOF
|
||||
echo 'Desktop entry created'
|
||||
|
||||
# --- Install AppStream metainfo (App Center / GNOME Software / KDE Discover) ---
|
||||
echo 'Installing AppStream metainfo...'
|
||||
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
|
||||
install -Dm 644 "$script_dir/$metainfo_name" \
|
||||
"$install_dir/share/metainfo/$metainfo_name" || exit 1
|
||||
echo 'AppStream metainfo installed'
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
echo 'Creating launcher script...'
|
||||
cat > "$install_dir/bin/claude-desktop" << EOF
|
||||
@@ -106,7 +114,12 @@ fi
|
||||
# Setup logging and environment
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -114,6 +127,7 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -131,12 +145,14 @@ fi
|
||||
|
||||
# Determine Electron executable path
|
||||
electron_exec='electron'
|
||||
using_global_electron=false
|
||||
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
|
||||
if [[ -f \$local_electron_path ]]; then
|
||||
electron_exec="\$local_electron_path"
|
||||
log_message "Using local Electron: \$electron_exec"
|
||||
else
|
||||
if command -v electron &> /dev/null; then
|
||||
using_global_electron=true
|
||||
log_message "Using global Electron: \$electron_exec"
|
||||
else
|
||||
log_message 'Error: Electron executable not found'
|
||||
@@ -151,27 +167,35 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
# Build electron args
|
||||
build_electron_args 'deb'
|
||||
|
||||
# Add app path LAST
|
||||
electron_args+=("\$app_path")
|
||||
# Bundled Electron: app.asar sits in its default resources/ dir next
|
||||
# to the binary, so Electron auto-loads it. Passing the path again
|
||||
# makes Electron treat it as a file-to-open, which the app forwards
|
||||
# to its file-drop handler, producing a spurious "Attach app.asar?"
|
||||
# prompt on launch and on every taskbar reopen (the second-instance
|
||||
# argv path). Omitting it is the root-cause fix. See issue #696.
|
||||
# Global (PATH) Electron has no co-located app.asar and would boot
|
||||
# its default_app welcome screen instead — only there the explicit
|
||||
# app path is load-bearing and must stay.
|
||||
if [[ \$using_global_electron == true ]]; then
|
||||
electron_args+=("\$app_path")
|
||||
log_message "App (explicit arg, global Electron): \$app_path"
|
||||
else
|
||||
log_message "App (auto-loaded by Electron): \$app_path"
|
||||
fi
|
||||
|
||||
# Change to application directory
|
||||
app_dir="/usr/lib/$package_name"
|
||||
log_message "Changing directory to \$app_dir"
|
||||
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep the launcher alive so explicit quit can
|
||||
# clean up Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
|
||||
exit \$?
|
||||
EOF
|
||||
chmod +x "$install_dir/bin/claude-desktop" || exit 1
|
||||
echo 'Launcher script created'
|
||||
@@ -180,7 +204,9 @@ echo 'Launcher script created'
|
||||
echo 'Creating control file...'
|
||||
# Electron is bundled with its own Node.js runtime, so nodejs/npm are not
|
||||
# runtime dependencies. p7zip is only used at build time to extract the
|
||||
# installer. No external dependencies are required at runtime.
|
||||
# installer. bubblewrap is Recommended (not required): it provides the
|
||||
# default namespace-sandbox isolation for Cowork mode; the app runs without
|
||||
# it (Cowork falls back to host-direct). apt installs Recommends by default.
|
||||
|
||||
cat > "$package_root/DEBIAN/control" << EOF
|
||||
Package: $package_name
|
||||
@@ -188,6 +214,7 @@ Version: $version
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: $architecture
|
||||
Recommends: bubblewrap
|
||||
Maintainer: $maintainer
|
||||
Description: $description
|
||||
Claude is an AI assistant from Anthropic.
|
||||
@@ -205,7 +232,7 @@ set -e
|
||||
|
||||
# Update desktop database for MIME types
|
||||
echo "Updating desktop database..."
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox if electron is installed globally
|
||||
# or locally packaged
|
||||
@@ -226,11 +253,177 @@ else
|
||||
echo "Warning: chrome-sandbox binary not found in local package at \$LOCAL_SANDBOX_PATH. Sandbox may not function correctly."
|
||||
fi
|
||||
|
||||
# --- AppArmor profile for Chromium's user-namespace sandbox ---
|
||||
# Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1, which
|
||||
# blocks the unprivileged user namespaces Chromium's sandbox relies on,
|
||||
# crashing the app on launch with a sandbox/.../credentials.cc FATAL.
|
||||
# Grant userns to our Electron binary via a scoped AppArmor profile, exactly
|
||||
# as the google-chrome, code, and slack packages do. Gate on the kernel knob
|
||||
# (not just apparmor_parser): only Ubuntu-family systems impose the
|
||||
# restriction, so on stock Debian/others the knob is absent and we skip the
|
||||
# profile entirely rather than installing one they never need. The knob may
|
||||
# read 0 now and flip to 1 later, so existence — not value — is the gate.
|
||||
APPARMOR_PROFILE="/etc/apparmor.d/$package_name"
|
||||
if command -v apparmor_parser >/dev/null 2>&1 \
|
||||
&& [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
echo "Configuring AppArmor profile for Chromium sandbox..."
|
||||
# Writing the profile is best-effort: a read-only or atypical /etc must
|
||||
# never abort the install (this postinst runs under set -e). Keeping the
|
||||
# grep / mkdir + heredoc in the if/elif conditions exempts them from
|
||||
# errexit. Debian Policy 10.7.3: a profile without our marker header was
|
||||
# hand-created or hand-edited by the admin — preserve it, never overwrite.
|
||||
if [ -e "\$APPARMOR_PROFILE" ] \
|
||||
&& ! grep -qF "managed by the $package_name package" \
|
||||
"\$APPARMOR_PROFILE" 2>/dev/null; then
|
||||
echo "Preserving locally modified \$APPARMOR_PROFILE (no marker header)"
|
||||
apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || true
|
||||
elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$APPARMOR_PROFILE" <<'APPARMOR_EOF'
|
||||
# This profile is managed by the $package_name package (postinst); direct
|
||||
# edits will be overwritten on upgrade. Put local changes in
|
||||
# /etc/apparmor.d/local/$package_name instead.
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile $package_name /usr/lib/$package_name/node_modules/electron/dist/electron flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/$package_name>
|
||||
}
|
||||
APPARMOR_EOF
|
||||
then
|
||||
if apparmor_parser -Q "\$APPARMOR_PROFILE" >/dev/null 2>&1; then
|
||||
apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || echo "Note: AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot."
|
||||
echo "AppArmor profile installed at \$APPARMOR_PROFILE"
|
||||
else
|
||||
rm -f "\$APPARMOR_PROFILE"
|
||||
echo "AppArmor on this system does not support the userns rule; skipping profile (not required here)."
|
||||
fi
|
||||
else
|
||||
# A failed write may leave a truncated profile behind; clear it.
|
||||
# The || true is mandatory: this branch is errexit-live, and a bare
|
||||
# rm fails the upgrade on a read-only /etc.
|
||||
rm -f "\$APPARMOR_PROFILE" 2>/dev/null || true
|
||||
echo "Warning: could not write \$APPARMOR_PROFILE; skipping AppArmor profile."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- AppArmor profile for the Cowork bwrap sandbox helper ---
|
||||
# Cowork's "bwrap backend" runs the agent's Claude Code process inside a
|
||||
# bubblewrap sandbox, which itself needs unprivileged user namespaces — the
|
||||
# same thing Ubuntu 24.04+ blocks (apparmor_restrict_unprivileged_userns=1).
|
||||
# bwrap is a SEPARATE binary from the Electron app, so the claude-desktop
|
||||
# profile above (which scopes the Electron binary) does not cover it; it
|
||||
# needs its own profile on /usr/bin/bwrap. Without this, Cowork silently
|
||||
# falls back to host-direct (no isolation).
|
||||
#
|
||||
# Gate on the kernel knob, exactly like the Electron block above: only a
|
||||
# kernel that can enforce the restriction exposes the knob, and a userspace
|
||||
# parser that merely accepts the userns rule (AppArmor 4) is not
|
||||
# enforcement — without the knob the profile is dead weight on a binary
|
||||
# this package does not own. There is deliberately no [ -x /usr/bin/bwrap ]
|
||||
# gate: a profile attaching to a nonexistent binary is inert, and dpkg
|
||||
# gives Recommends no ordering edge, so gating on the binary races a
|
||||
# same-transaction bubblewrap install. Static checks only: postinst runs as
|
||||
# root, which is exempt from the unprivileged-userns restriction, so a
|
||||
# behavioral bwrap probe here would falsely pass — the behavioral probe
|
||||
# lives in 'claude-desktop --doctor' instead (runs as the user).
|
||||
BWRAP_PROFILE="/etc/apparmor.d/${package_name}-bwrap"
|
||||
if command -v apparmor_parser >/dev/null 2>&1 \
|
||||
&& [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
echo "Configuring AppArmor profile for the Cowork bwrap sandbox..."
|
||||
# Writing the profile is best-effort: a read-only or atypical /etc must
|
||||
# never abort the install (this postinst runs under set -e). Keeping the
|
||||
# grep / mkdir + heredoc in the if/elif conditions exempts them from
|
||||
# errexit. Debian Policy 10.7.3: a profile without our marker header was
|
||||
# hand-created or hand-edited by the admin — preserve it, never overwrite.
|
||||
if [ -e "\$BWRAP_PROFILE" ] \
|
||||
&& ! grep -qF "managed by the $package_name package" \
|
||||
"\$BWRAP_PROFILE" 2>/dev/null; then
|
||||
echo "Preserving locally modified \$BWRAP_PROFILE (no marker header)"
|
||||
apparmor_parser -r "\$BWRAP_PROFILE" >/dev/null 2>&1 || true
|
||||
elif grep -rl '/usr/bin/bwrap' /etc/apparmor.d/ 2>/dev/null \
|
||||
| grep -vxF "\$BWRAP_PROFILE" | grep -q .; then
|
||||
# Another profile already attaches to /usr/bin/bwrap — a hand-made
|
||||
# /etc/apparmor.d/bwrap, apparmor-profiles' bwrap-userns-restrict,
|
||||
# or any other filename. Identical attachment strings have no
|
||||
# specificity tiebreak, and shadowing a restrictive profile with our
|
||||
# unconfined-mode one would silently undo distro hardening, so defer
|
||||
# to the existing profile. (A false grep hit in a comment fails
|
||||
# safe: we merely skip our profile.)
|
||||
echo "An existing AppArmor profile already covers /usr/bin/bwrap; leaving it in charge."
|
||||
elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$BWRAP_PROFILE" <<'BWRAP_APPARMOR_EOF'
|
||||
# This profile is managed by the $package_name package (postinst); direct
|
||||
# edits will be overwritten on upgrade. Put local changes in
|
||||
# /etc/apparmor.d/local/${package_name}-bwrap instead.
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile ${package_name}-bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/${package_name}-bwrap>
|
||||
}
|
||||
BWRAP_APPARMOR_EOF
|
||||
then
|
||||
if apparmor_parser -Q "\$BWRAP_PROFILE" >/dev/null 2>&1; then
|
||||
apparmor_parser -r "\$BWRAP_PROFILE" >/dev/null 2>&1 || echo "Note: bwrap AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot."
|
||||
echo "Cowork bwrap AppArmor profile installed at \$BWRAP_PROFILE"
|
||||
else
|
||||
rm -f "\$BWRAP_PROFILE"
|
||||
echo "AppArmor on this system does not support the userns rule; skipping bwrap profile (not required here)."
|
||||
fi
|
||||
else
|
||||
# A failed write may leave a truncated profile behind; clear it.
|
||||
# The || true is mandatory: this branch is errexit-live, and a bare
|
||||
# rm fails the upgrade on a read-only /etc.
|
||||
rm -f "\$BWRAP_PROFILE" 2>/dev/null || true
|
||||
echo "Warning: could not write \$BWRAP_PROFILE; skipping bwrap AppArmor profile."
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$package_root/DEBIAN/postinst" || exit 1
|
||||
echo 'Postinst script created'
|
||||
|
||||
# --- Create Postrm Script ---
|
||||
echo 'Creating postrm script...'
|
||||
# The AppArmor profiles are generated by postinst, not tracked by dpkg, so we
|
||||
# unload and delete them ourselves. Cleanup lives in postrm (not prerm) so it
|
||||
# also fires on purge and abort-install. Skip on upgrade — the incoming
|
||||
# postinst rewrites and reloads them. 'disappear' is deliberately not handled:
|
||||
# matching it would also clean during the overwrite-by-another-package flow.
|
||||
# Two profiles: the Electron one (Chromium sandbox, #687) and the bwrap one
|
||||
# (Cowork sandbox helper, #694).
|
||||
# Per Debian Policy 10.7.3 the profiles are configuration: unload them
|
||||
# whenever the confined binaries go away, but delete the files only on
|
||||
# purge — a profile for an absent binary is a harmless no-op (google-chrome
|
||||
# leaves its profile behind the same way).
|
||||
cat > "$package_root/DEBIAN/postrm" << EOF
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "\$1" in
|
||||
remove|purge|abort-install)
|
||||
for _profile in "/etc/apparmor.d/$package_name" \
|
||||
"/etc/apparmor.d/${package_name}-bwrap"; do
|
||||
if [ -e "\$_profile" ] \
|
||||
&& command -v apparmor_parser >/dev/null 2>&1; then
|
||||
apparmor_parser -R "\$_profile" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Policy 10.7.3: config survives remove; delete on purge only.
|
||||
if [ "\$1" = purge ]; then
|
||||
rm -f "\$_profile" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$package_root/DEBIAN/postrm" || exit 1
|
||||
echo 'Postrm script created'
|
||||
|
||||
# --- Build .deb Package ---
|
||||
echo 'Building .deb package...'
|
||||
deb_file="$work_dir/${package_name}_${version}_${architecture}.deb"
|
||||
@@ -242,8 +435,27 @@ chmod 755 "$package_root/DEBIAN" || exit 1
|
||||
# Fix script permissions in DEBIAN directory
|
||||
echo 'Setting script permissions...'
|
||||
chmod 755 "$package_root/DEBIAN/postinst" || exit 1
|
||||
chmod 755 "$package_root/DEBIAN/postrm" || exit 1
|
||||
|
||||
if ! dpkg-deb --build "$package_root" "$deb_file"; then
|
||||
# Normalize the installed tree before building. A restrictive build umask
|
||||
# can leave directories at 0700, and dpkg-deb records file ownership
|
||||
# verbatim unless told otherwise. Both bite at runtime: the launcher runs
|
||||
# as the desktop user, who then can't traverse into app.asar.unpacked/ —
|
||||
# silently breaking Cowork's daemon auto-launch (the fork is guarded by
|
||||
# fs.existsSync(), which returns false on a directory it can't read, so
|
||||
# the symptom is an endless connect ENOENT on the VM-service socket with
|
||||
# no daemon log and no [cowork-autolaunch] line). Canonical modes: dirs
|
||||
# and already-executable files 755, every other file 644. The blanket
|
||||
# pass clears chrome-sandbox's setuid bit, but postinst re-asserts 4755
|
||||
# after install, so the net result is unchanged.
|
||||
echo 'Normalizing installed tree permissions...'
|
||||
find "$install_dir" -type d -exec chmod 755 {} + || exit 1
|
||||
find "$install_dir" -type f -exec chmod u=rwX,go=rX {} + || exit 1
|
||||
|
||||
# --root-owner-group forces root:root in the archive so a leaked build
|
||||
# uid can't deny access on the installed system (the build does not run
|
||||
# under fakeroot).
|
||||
if ! dpkg-deb --root-owner-group --build "$package_root" "$deb_file"; then
|
||||
echo 'Failed to build .deb package' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
AppStream metainfo for the claude-desktop package.
|
||||
Indexed by GNOME Software / Ubuntu App Center / KDE Discover under the
|
||||
Installed tab so users can see the package with name, summary, icon, and
|
||||
release history rather than as an unidentified entry.
|
||||
|
||||
See: https://www.freedesktop.org/software/appstream/docs/
|
||||
-->
|
||||
<component type="desktop-application">
|
||||
<id>io.github.aaddrick.claude-desktop-debian</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
|
||||
<name>Claude Desktop</name>
|
||||
<summary>Unofficial desktop client for Claude AI</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Claude Desktop is an unofficial community repackaging of Anthropic's
|
||||
Claude Desktop client for Debian and Ubuntu. The upstream Windows
|
||||
binary is repacked and patched for Linux compatibility (frame, tray,
|
||||
Cowork mode, MCP stdio, Quick Entry, etc.).
|
||||
</p>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>Conversations with the Claude model family (Sonnet, Opus, Haiku)</li>
|
||||
<li>Projects with persistent context and file uploads</li>
|
||||
<li>Cowork mode — local agent VM for sandboxed code tasks</li>
|
||||
<li>MCP (Model Context Protocol) stdio servers for tool integration</li>
|
||||
<li>System tray, Quick Entry hotkey, and tab system</li>
|
||||
</ul>
|
||||
<p>
|
||||
This packaging is community-maintained and is not affiliated with or
|
||||
endorsed by Anthropic. See the packaging source and issue tracker
|
||||
linked below.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">claude-desktop.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/aaddrick/claude-desktop-debian</url>
|
||||
<url type="bugtracker">https://github.com/aaddrick/claude-desktop-debian/issues</url>
|
||||
<url type="vcs-browser">https://github.com/aaddrick/claude-desktop-debian</url>
|
||||
|
||||
<developer id="io.github.aaddrick">
|
||||
<name>aaddrick</name>
|
||||
</developer>
|
||||
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<provides>
|
||||
<binary>claude-desktop</binary>
|
||||
</provides>
|
||||
</component>
|
||||
@@ -68,9 +68,13 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
EOF
|
||||
|
||||
# --- Stage AppStream metainfo (installed via %files block below) ---
|
||||
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
|
||||
cp "$script_dir/$metainfo_name" "$staging_dir/$metainfo_name" || exit 1
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
echo 'Creating launcher script...'
|
||||
cat > "$staging_dir/claude-desktop" << EOF
|
||||
@@ -89,7 +93,12 @@ fi
|
||||
# Setup logging and environment
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -97,6 +106,7 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -114,12 +124,14 @@ fi
|
||||
|
||||
# Determine Electron executable path
|
||||
electron_exec='electron'
|
||||
using_global_electron=false
|
||||
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
|
||||
if [[ -f \$local_electron_path ]]; then
|
||||
electron_exec="\$local_electron_path"
|
||||
log_message "Using local Electron: \$electron_exec"
|
||||
else
|
||||
if command -v electron &> /dev/null; then
|
||||
using_global_electron=true
|
||||
log_message "Using global Electron: \$electron_exec"
|
||||
else
|
||||
log_message 'Error: Electron executable not found'
|
||||
@@ -134,27 +146,35 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
# Build electron args - use 'deb' type (same sandbox behavior)
|
||||
build_electron_args 'deb'
|
||||
|
||||
# Add app path LAST
|
||||
electron_args+=("\$app_path")
|
||||
# Bundled Electron: app.asar sits in its default resources/ dir next
|
||||
# to the binary, so Electron auto-loads it. Passing the path again
|
||||
# makes Electron treat it as a file-to-open, which the app forwards
|
||||
# to its file-drop handler, producing a spurious "Attach app.asar?"
|
||||
# prompt on launch and on every taskbar reopen (the second-instance
|
||||
# argv path). Omitting it is the root-cause fix. See issue #696.
|
||||
# Global (PATH) Electron has no co-located app.asar and would boot
|
||||
# its default_app welcome screen instead — only there the explicit
|
||||
# app path is load-bearing and must stay.
|
||||
if [[ \$using_global_electron == true ]]; then
|
||||
electron_args+=("\$app_path")
|
||||
log_message "App (explicit arg, global Electron): \$app_path"
|
||||
else
|
||||
log_message "App (auto-loaded by Electron): \$app_path"
|
||||
fi
|
||||
|
||||
# Change to application directory
|
||||
app_dir="/usr/lib/$package_name"
|
||||
log_message "Changing directory to \$app_dir"
|
||||
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep the launcher alive so explicit quit can
|
||||
# clean up Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
|
||||
exit \$?
|
||||
EOF
|
||||
chmod +x "$staging_dir/claude-desktop"
|
||||
|
||||
@@ -220,35 +240,44 @@ cp -r $app_staging_dir/app.asar.unpacked %{buildroot}/usr/lib/$package_name/node
|
||||
# Copy shared launcher library (launcher-common.sh sources doctor.sh
|
||||
# at runtime, so both must live in the same directory)
|
||||
cp $(dirname "$script_dir")/launcher-common.sh %{buildroot}/usr/lib/$package_name/
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "%{buildroot}/usr/lib/$package_name/launcher-common.sh"
|
||||
cp $(dirname "$script_dir")/doctor.sh %{buildroot}/usr/lib/$package_name/
|
||||
|
||||
# Install desktop entry
|
||||
install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/applications/claude-desktop.desktop
|
||||
|
||||
# Install AppStream metainfo (GNOME Software / KDE Discover)
|
||||
install -Dm 644 $staging_dir/$metainfo_name %{buildroot}/usr/share/metainfo/$metainfo_name
|
||||
|
||||
# Install launcher script
|
||||
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
|
||||
|
||||
# Normalize file modes — the cp -r above honors the build umask, and
|
||||
# the "-" first field of %defattr ships buildroot *file* modes verbatim
|
||||
# (only directory modes are forced to 0755), so a umask-077 build would
|
||||
# package an unreadable app.asar and a non-executable electron binary.
|
||||
# Must run before the chrome-sandbox chmod below so 4755 survives.
|
||||
find %{buildroot}/usr/lib/$package_name -type f -exec chmod u=rwX,go=rX {} +
|
||||
|
||||
# Set the chrome-sandbox suid bit in the buildroot so the /usr/lib
|
||||
# directory walk in %files records 4755 in the payload (preserves #539
|
||||
# without the "File listed twice" warning #609 — see %files block).
|
||||
chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
%post
|
||||
# Update desktop database for MIME types
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox
|
||||
SANDBOX_PATH="/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox"
|
||||
if [ -f "\$SANDBOX_PATH" ]; then
|
||||
echo "Setting chrome-sandbox permissions..."
|
||||
chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox"
|
||||
chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox"
|
||||
fi
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
%postun
|
||||
# Update desktop database after removal
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
%files
|
||||
%defattr(-, root, root, 0755)
|
||||
%attr(755, root, root) /usr/bin/claude-desktop
|
||||
/usr/lib/$package_name
|
||||
/usr/share/applications/claude-desktop.desktop
|
||||
/usr/share/metainfo/$metainfo_name
|
||||
/usr/share/icons/hicolor/*/apps/claude-desktop.png
|
||||
SPECEOF
|
||||
|
||||
@@ -257,14 +286,26 @@ echo 'RPM spec file created'
|
||||
# --- Build RPM Package ---
|
||||
echo 'Building RPM package...'
|
||||
|
||||
if ! rpmbuild --define "_topdir $rpmbuild_dir" \
|
||||
rpmbuild_log="$work_dir/rpmbuild.log"
|
||||
rpmbuild --define "_topdir $rpmbuild_dir" \
|
||||
--define "_rpmdir $work_dir" \
|
||||
--target "$rpm_arch" \
|
||||
-bb "$rpmbuild_dir/SPECS/$package_name.spec"; then
|
||||
-bb "$rpmbuild_dir/SPECS/$package_name.spec" 2>&1 |
|
||||
tee "$rpmbuild_log"
|
||||
if (( PIPESTATUS[0] != 0 )); then
|
||||
echo 'Failed to build RPM package' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Guard against re-introducing #609. The "File listed twice" warning
|
||||
# means %files has overlapping listings, and on modern rpmbuild any
|
||||
# %exclude workaround silently strips the file from the payload.
|
||||
if grep -qF 'File listed twice' "$rpmbuild_log"; then
|
||||
echo 'rpmbuild emitted "File listed twice" — %files has overlapping listings (see #609)' >&2
|
||||
grep -F 'File listed twice' "$rpmbuild_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find and move the built RPM (it will be in a subdirectory)
|
||||
rpm_file=$(find "$work_dir" -name "${package_name}-${rpm_version}*.rpm" -type f | head -n 1)
|
||||
if [[ -z $rpm_file ]]; then
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -37,16 +37,32 @@ EOFENTRY
|
||||
|
||||
# Update package.json
|
||||
echo 'Modifying package.json to load frame fix and add node-pty...'
|
||||
local desktop_name='claude-desktop.desktop'
|
||||
if [[ ${build_format:-} == 'appimage' ]]; then
|
||||
desktop_name='io.github.aaddrick.claude-desktop-debian.desktop'
|
||||
fi
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = require('./app.asar.contents/package.json');
|
||||
pkg.originalMain = pkg.main;
|
||||
pkg.main = 'frame-fix-entry.js';
|
||||
pkg.desktopName = process.argv[1];
|
||||
pkg.optionalDependencies = pkg.optionalDependencies || {};
|
||||
pkg.optionalDependencies['node-pty'] = '^1.0.0';
|
||||
fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2));
|
||||
console.log('Updated package.json: main entry and node-pty dependency');
|
||||
"
|
||||
console.log('Updated package.json: main entry, desktopName, and node-pty dependency');
|
||||
" "$desktop_name"
|
||||
|
||||
# Fail fast if upstream changed productName — a mismatch silently
|
||||
# breaks StartupWMClass in every .desktop file we ship.
|
||||
local product_name
|
||||
product_name=$(node -e \
|
||||
"console.log(require('./app.asar.contents/package.json').productName)")
|
||||
if [[ $product_name != "$WM_CLASS" ]]; then
|
||||
echo "Error: upstream productName '$product_name' != WM_CLASS" \
|
||||
"'$WM_CLASS' — update WM_CLASS in build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create stub native module
|
||||
echo 'Creating stub native module...'
|
||||
@@ -87,9 +103,22 @@ console.log('Updated package.json: main entry and node-pty dependency');
|
||||
# Add Linux Claude Code support
|
||||
patch_linux_claude_code
|
||||
|
||||
# Reject .asar paths in the directory-check helper so Electron's
|
||||
# ASAR VFS shim doesn't misidentify app.asar as a folder and
|
||||
# trigger false Cowork dispatch (#383, #622, #632).
|
||||
patch_asar_path_filter
|
||||
|
||||
# Reject .asar paths in the argv file-drop collector so the
|
||||
# existsSync branch doesn't dispatch app.asar as a file drop,
|
||||
# triggering a permission prompt on every window reopen (#383, #622).
|
||||
patch_asar_argv_file_drop_guard
|
||||
|
||||
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
|
||||
patch_cowork_linux
|
||||
|
||||
# Add Linux org-plugins path for MDM-managed plugin marketplace
|
||||
patch_org_plugins_path
|
||||
|
||||
# Inject WCO shim into the BrowserView preload so claude.ai's
|
||||
# desktop topbar renders on Linux. The shim spoofs the bundle's
|
||||
# isWindows() UA check (load-bearing) plus matchMedia and
|
||||
@@ -97,6 +126,17 @@ console.log('Updated package.json: main entry and node-pty dependency');
|
||||
# docs/learnings/linux-topbar-shim.md.
|
||||
patch_wco_shim
|
||||
|
||||
# Preserve externally-added mcpServers across config writes (#400)
|
||||
patch_config_write_merge
|
||||
|
||||
# Reject .asar paths in addTrustedFolder to reduce spurious config
|
||||
# writes that amplify the stale-cache overwrite bug (#400)
|
||||
patch_asar_trusted_folder_guard
|
||||
|
||||
# Filter .asar paths from --add-dir dispatch and session restore
|
||||
# so corrupted pre-#640 sessions cannot crash local agent mode (#649)
|
||||
patch_asar_additional_dirs_guard
|
||||
|
||||
# Copy cowork VM service daemon for Linux Cowork mode
|
||||
echo 'Installing cowork VM service daemon...'
|
||||
cp "$source_dir/scripts/cowork-vm-service.js" \
|
||||
|
||||
@@ -16,12 +16,12 @@ patch_linux_claude_code() {
|
||||
|
||||
# New format (Claude >= 1.1.3541): getHostPlatform includes arch detection for win32
|
||||
# Pattern: if(process.platform==="win32")return e==="arm64"?"win32-arm64":"win32-x64";throw new Error(...)
|
||||
if grep -qP 'if\(process\.platform==="win32"\)return \w+==="arm64"\?"win32-arm64":"win32-x64";throw' "$index_js"; then
|
||||
sed -i -E 's/if\(process\.platform==="win32"\)return (\w+)==="arm64"\?"win32-arm64":"win32-x64";throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
if grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+[$\w]+\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+([[:alnum:]_$]+)\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
echo 'Added linux claude code support (new arch-aware format)'
|
||||
# Old format (Claude <= 1.1.3363): no arch detection for win32
|
||||
elif grep -q 'if(process.platform==="win32")return"win32-x64";' "$index_js"; then
|
||||
sed -i 's/if(process.platform==="win32")return"win32-x64";/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
elif grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
echo 'Added linux claude code support (legacy format)'
|
||||
else
|
||||
echo 'Warning: Could not find getHostPlatform pattern to patch for Linux claude code support'
|
||||
|
||||
304
scripts/patches/config.sh
Normal file
304
scripts/patches/config.sh
Normal file
@@ -0,0 +1,304 @@
|
||||
#===============================================================================
|
||||
# Config-related patches: preserve externally-added mcpServers across config
|
||||
# writes, guard addTrustedFolder against .asar paths, and filter .asar entries
|
||||
# from the --add-dir CLI dispatch and session restore.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: project_root
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_config_write_merge() {
|
||||
echo 'Patching config writer to preserve mcpServers from disk...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -q '_cdd_dc' "$index_js"; then
|
||||
echo ' mcpServers merge already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract variable names from the unique anchor:
|
||||
# await WRITE_FN(PATH_VAR, CONFIG_VAR), LOGGER.info("Config file written")
|
||||
local write_fn path_var config_var write_fn_re path_var_re
|
||||
|
||||
write_fn=$(grep -oP \
|
||||
'await \K[$\w]+(?=\([$\w]+,\s*[$\w]+\)\s*,\s*[$\w]+\.info\("Config file written"\))' \
|
||||
"$index_js")
|
||||
if [[ -z $write_fn ]]; then
|
||||
echo ' Could not extract write function name — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
write_fn_re="${write_fn//\$/\\$}"
|
||||
|
||||
path_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(\\K[\$\\w]+(?=,\\s*[\$\\w]+\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract path variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
path_var_re="${path_var//\$/\\$}"
|
||||
|
||||
config_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(${path_var_re},\\s*\\K[\$\\w]+(?=\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $config_var ]]; then
|
||||
echo ' Could not extract config variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
echo " Write fn: $write_fn, path: $path_var, config: $config_var"
|
||||
|
||||
if ! WRITE_FN="$write_fn" PATH_VAR="$path_var" CFG_VAR="$config_var" \
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const W = process.env.WRITE_FN;
|
||||
const P = process.env.PATH_VAR;
|
||||
const C = process.env.CFG_VAR;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const reEsc = (s) => s.replace(/[.*+?\${}()|[\\]\\\\]/g, '\\\\\$&');
|
||||
const anchor = new RegExp(
|
||||
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
|
||||
'\\\\)\\\\s*,\\\\s*\\\\w+\\\\.info\\\\(\"Config file written\"\\\\)'
|
||||
);
|
||||
if (!anchor.test(code)) {
|
||||
console.error(' [FAIL] Config-write anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const merge =
|
||||
'try{var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(' + P +
|
||||
',\"utf8\"));if(_cdd_dc.mcpServers){' + C +
|
||||
'.mcpServers=Object.assign({},_cdd_dc.mcpServers,' + C +
|
||||
'.mcpServers||{})}}catch(_cdd_ex){}';
|
||||
|
||||
code = code.replace(anchor, (m) => merge + ';' + m);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] mcpServers merge injected before config write');
|
||||
"; then
|
||||
echo 'Failed to inject config write merge' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_asar_trusted_folder_guard() {
|
||||
echo 'Patching addTrustedFolder to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -qF 'endsWith(".asar"))return' "$index_js"; then
|
||||
echo ' .asar guard already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
# Anchor on the method declaration itself — the method name
|
||||
# `addTrustedFolder` is not minified and is unique in the bundle.
|
||||
# Earlier releases let us anchor on the trailing `${param}`);` of the
|
||||
# log line, but upstream now folds that log call into the comma
|
||||
# expression `if(D.info(`…${i}`),await ZOe(i)===null){…}`, so the
|
||||
# `);` no longer exists. Injecting at the function body head is both
|
||||
# more robust and semantically earlier (reject .asar on entry).
|
||||
local folder_param
|
||||
folder_param=$(grep -oP \
|
||||
'async addTrustedFolder\(\K[$\w]+(?=\)\{)' \
|
||||
"$index_js")
|
||||
if [[ -z $folder_param ]]; then
|
||||
echo ' Could not extract folder parameter — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
echo " Found folder parameter: $folder_param"
|
||||
|
||||
if ! FOLDER_PARAM="$folder_param" node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const F = process.env.FOLDER_PARAM;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const anchor = 'async addTrustedFolder(' + F + '){';
|
||||
const idx = code.indexOf(anchor);
|
||||
if (idx === -1) {
|
||||
console.error(' [FAIL] addTrustedFolder anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const insertPoint = idx + anchor.length;
|
||||
const guard = 'if(' + F + '.endsWith(\".asar\"))return;';
|
||||
code = code.slice(0, insertPoint) + guard + code.slice(insertPoint);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] .asar guard injected in addTrustedFolder');
|
||||
"; then
|
||||
echo 'Failed to inject .asar trusted folder guard' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: filter .asar paths from --add-dir CLI dispatch and session restore
|
||||
#
|
||||
# PR #640 guards the directory-check helper and addTrustedFolder IPC
|
||||
# handler, but .asar paths in corrupted pre-#640 sessions survive
|
||||
# restore (existsSync passes via Electron's ASAR VFS shim) and reach
|
||||
# additionalDirectories -> --add-dir -> fatal Claude Code error.
|
||||
#
|
||||
# Fix: two sub-patches:
|
||||
# 1. Filter at the --add-dir CLI dispatch loop (the single convergence
|
||||
# point for ALL code paths that feed additionalDirectories).
|
||||
# 2. Filter at session restore to self-heal corrupted persisted state.
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_additional_dirs_guard() {
|
||||
echo 'Patching --add-dir dispatch to reject .asar paths (#649)...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency
|
||||
if grep -qF '.filter(_d=>!_d.endsWith(".asar"))' "$index_js"; then
|
||||
echo ' .asar --add-dir filter already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_ADDDIR_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
let patchCount = 0;
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 1: Filter .asar from --add-dir loop
|
||||
//
|
||||
// Target (unique, 1 occurrence):
|
||||
// for (let O of A) Y.push("--add-dir", O);
|
||||
// Fallback (if minifier uses .forEach):
|
||||
// A.forEach(O=>Y.push("--add-dir",O))
|
||||
// ================================================================
|
||||
{
|
||||
// Primary: for...of pattern
|
||||
const forOfRe = /for\s*\(\s*let\s+([\w$]+)\s+of\s+([\w$]+)\s*\)\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\1\s*\)/;
|
||||
// Fallback: .forEach pattern
|
||||
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/;
|
||||
|
||||
let match = code.match(forOfRe);
|
||||
let variant = 'for-of';
|
||||
if (!match) {
|
||||
match = code.match(forEachRe);
|
||||
variant = 'forEach';
|
||||
}
|
||||
if (!match) {
|
||||
console.error('FATAL: --add-dir dispatch loop not found.');
|
||||
console.error(' for(let X of Y) Z.push("--add-dir", X)');
|
||||
console.error(' Y.forEach(X=>Z.push("--add-dir", X))');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Count assertion: exactly 1 match expected
|
||||
const escaped = match[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allMatches = code.match(new RegExp(escaped, 'g'));
|
||||
if (allMatches && allMatches.length > 1) {
|
||||
console.error('FATAL: --add-dir pattern matches ' +
|
||||
allMatches.length + ' times (expected 1).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let filtered;
|
||||
if (variant === 'for-of') {
|
||||
const [, iterVar, arrVar, pushTarget] = match;
|
||||
filtered = 'for(let ' + iterVar + ' of ' + arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")))' +
|
||||
pushTarget + '.push("--add-dir",' + iterVar + ')';
|
||||
} else {
|
||||
const [, arrVar, iterVar, pushTarget] = match;
|
||||
filtered = arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
|
||||
iterVar + '=>' + pushTarget +
|
||||
'.push("--add-dir",' + iterVar + '))';
|
||||
}
|
||||
code = code.replace(match[0], filtered);
|
||||
console.log(' Filtered --add-dir dispatch (' +
|
||||
variant + ' variant)');
|
||||
patchCount++;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 2: Filter .asar from session restore
|
||||
//
|
||||
// Anchor: "Filtering out deleted folder from session" (unique)
|
||||
// Target: (VAR.userSelectedFolders||[]).filter(
|
||||
// Insert: .filter(l=>!l.endsWith(".asar")) before existing .filter(
|
||||
// ================================================================
|
||||
{
|
||||
const warn = (msg) => console.log(' WARNING: ' + msg +
|
||||
' (primary --add-dir filter still protects)');
|
||||
|
||||
const anchorIdx = code.indexOf(
|
||||
'Filtering out deleted folder from session');
|
||||
if (anchorIdx === -1) {
|
||||
warn('session restore anchor not found');
|
||||
} else {
|
||||
const searchStart = Math.max(0, anchorIdx - 500);
|
||||
const region = code.substring(searchStart, anchorIdx);
|
||||
const usIdx = region.lastIndexOf('userSelectedFolders');
|
||||
if (usIdx === -1) {
|
||||
warn('userSelectedFolders not found near anchor');
|
||||
} else {
|
||||
const absUsIdx = searchStart + usIdx;
|
||||
const afterUs = code.substring(absUsIdx, anchorIdx);
|
||||
const bracketMatch = afterUs.match(/\|\|\s*\[\s*\]\s*\)/);
|
||||
if (!bracketMatch) {
|
||||
warn('||[]) pattern not found');
|
||||
} else {
|
||||
const insertAt = absUsIdx + bracketMatch.index +
|
||||
bracketMatch[0].length;
|
||||
const peek = code.substring(insertAt, insertAt + 20);
|
||||
if (!peek.match(/^\s*\.filter\s*\(/)) {
|
||||
warn('.filter( not found after ||[])');
|
||||
} else if (code.substring(
|
||||
insertAt - 50, insertAt + 50
|
||||
).includes('!l.endsWith(".asar")')) {
|
||||
console.log(' Session restore filter ' +
|
||||
'already present');
|
||||
} else {
|
||||
code = code.substring(0, insertAt) +
|
||||
'.filter(l=>!l.endsWith(".asar"))' +
|
||||
code.substring(insertAt);
|
||||
console.log(' Injected .asar filter in ' +
|
||||
'session restore');
|
||||
patchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Applied ' + patchCount +
|
||||
' .asar additionalDirectories patch(es)');
|
||||
if (patchCount < 1) {
|
||||
console.error('FATAL: No patches applied — --add-dir filter ' +
|
||||
'must succeed (#649).');
|
||||
process.exit(1);
|
||||
}
|
||||
ASAR_ADDDIR_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar --add-dir filter patch failed' >&2
|
||||
echo 'Local agent mode will crash without this patch (#649).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
@@ -9,6 +9,211 @@
|
||||
# Modifies globals: node_pty_build_dir
|
||||
#===============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: reject .asar paths in the directory-check helper
|
||||
#
|
||||
# On Linux, app.asar is passed as an argv element to Electron. The
|
||||
# directory-check function (wFA in the current build) calls
|
||||
# fs.statSync(path).isDirectory(). Electron's ASAR virtual filesystem
|
||||
# shim makes .asar archives report isDirectory()===true, so app.asar
|
||||
# is dispatched to Cowork as a "folder drop". This causes:
|
||||
# - Permission dialog on every launch (#383)
|
||||
# - Forced Cowork mode (#622)
|
||||
# - Fatal --add-dir error in Claude Code >=2.1.111 (#632)
|
||||
#
|
||||
# Fix: inject !PARAM.endsWith(".asar")&& before the statSync call.
|
||||
# This runs independently of the Cowork-mode guard (the function
|
||||
# exists even if Cowork code is absent).
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_path_filter() {
|
||||
echo 'Patching directory check to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_FILTER_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
|
||||
// Find the directory-check helper function.
|
||||
// Beautified form:
|
||||
// function wFA(e) {
|
||||
// try { return ee.statSync(e).isDirectory(); }
|
||||
// catch { return !1; }
|
||||
// }
|
||||
// Minified form:
|
||||
// function wFA(e){try{return ee.statSync(e).isDirectory()}catch{return!1}}
|
||||
//
|
||||
// Stable anchors: .statSync( ).isDirectory() inside try/catch returning !1.
|
||||
// The function name, parameter, and fs variable are all minified.
|
||||
const dirCheckRe =
|
||||
/function\s+([\w$]+)\s*\(\s*([\w$]+)\s*\)\s*\{\s*try\s*\{\s*return\s+([\w$]+)\.statSync\(\s*\2\s*\)\.isDirectory\(\)/;
|
||||
const match = code.match(dirCheckRe);
|
||||
|
||||
if (!match) {
|
||||
console.error('FATAL: Could not find directory-check function' +
|
||||
' (statSync+isDirectory pattern).');
|
||||
console.error('This patch prevents .asar paths from triggering' +
|
||||
' false Cowork dispatch (#383, #622, #632).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [, funcName, paramName] = match;
|
||||
console.log(' Found directory-check function: ' + funcName +
|
||||
'(' + paramName + ')');
|
||||
|
||||
// Idempotency: check if already patched
|
||||
if (code.includes('.endsWith(".asar")')) {
|
||||
console.log(' .asar path filter already applied');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Insert the guard: !PARAM.endsWith(".asar")&&
|
||||
// Before: return FSVAR.statSync(PARAM).isDirectory()
|
||||
// After: return!PARAM.endsWith(".asar")&&FSVAR.statSync(PARAM).isDirectory()
|
||||
//
|
||||
// The replacement is scoped to the matched function via the full
|
||||
// regex match, so it cannot accidentally hit other statSync calls.
|
||||
code = code.replace(dirCheckRe, (whole, fn, param, fsVar) => {
|
||||
return 'function ' + fn + '(' + param + '){try{return!' +
|
||||
param + '.endsWith(".asar")&&' +
|
||||
fsVar + '.statSync(' + param + ').isDirectory()';
|
||||
});
|
||||
|
||||
// Verify the patch landed
|
||||
if (!code.includes('.endsWith(".asar")')) {
|
||||
console.error('FATAL: .asar path filter replacement failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Added .asar path rejection to ' + funcName + '()');
|
||||
ASAR_FILTER_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar path filter patch failed' >&2
|
||||
echo 'The app will show permission dialogs and may crash' \
|
||||
'without this patch (#383, #622, #632).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: reject .asar paths in the argv file-drop collector
|
||||
#
|
||||
# PR #640 patched the directory-check helper (isDirectory path) so
|
||||
# app.asar is no longer dispatched as a "folder drop". However, the
|
||||
# argv collector function (lKr in the current build) has a separate
|
||||
# branch:
|
||||
#
|
||||
# if (!i.startsWith("-") && FSVAR.existsSync(i)) { A.push(i); }
|
||||
#
|
||||
# Electron's ASAR VFS shim makes existsSync return true for .asar
|
||||
# paths, so app.asar passes this check and is dispatched to the
|
||||
# "file drop" handler (cCA), triggering a permission prompt on every
|
||||
# window close+reopen (#383, #622 regression in v2.0.16+).
|
||||
#
|
||||
# Fix: inject !PARAM.endsWith(".asar")&& before the existsSync call.
|
||||
#
|
||||
# Threat model: this argv path is reachable from user-launched
|
||||
# invocations (TPr's only caller is the second-instance handler, and
|
||||
# the desktop entries ship `Exec=... %u`), so it is not just the app's
|
||||
# own relaunch. The exact-suffix, case-sensitive ".asar" match is still
|
||||
# correct because the only sink here is attach-to-draft
|
||||
# (dispatchOnCoworkFromMain -> selectedFiles) — identical to a manual
|
||||
# drag, with no content read, privilege boundary, or traversal sink. So
|
||||
# don't "harden" it with toLowerCase(): that would diverge from the
|
||||
# sibling .asar guards for zero behavioral gain.
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_argv_file_drop_guard() {
|
||||
echo 'Patching argv file-drop collector to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency: check for the guard in context — specifically
|
||||
# !PARAM.startsWith("-")&&!PARAM.endsWith(".asar") — anchored to
|
||||
# startsWith to avoid false-positive matches from other .asar guards
|
||||
# (e.g. the statSync patch or the --add-dir filter).
|
||||
if grep -qP '\.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\)' \
|
||||
"$index_js"; then
|
||||
echo ' .asar file-drop guard already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_FILE_DROP_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
|
||||
// Find the argv file-drop collector branch.
|
||||
// Beautified form:
|
||||
// if (!i.startsWith("-") && ee.existsSync(i)) {
|
||||
// A.push(i);
|
||||
// continue;
|
||||
// }
|
||||
// Minified form:
|
||||
// if(!i.startsWith("-")&&ee.existsSync(i)){A.push(i);continue}
|
||||
//
|
||||
// Anchor: !PARAM.startsWith("-")&&FSVAR.existsSync(PARAM) — unique in
|
||||
// the bundle (verified). The .push() suffix is intentionally omitted
|
||||
// to avoid brittleness if the minifier reorders the if-body.
|
||||
// The param variable and fs variable are both minified and captured.
|
||||
const re =
|
||||
/(![\w$]+\.startsWith\s*\(\s*"-"\s*\)\s*&&\s*)([\w$]+)\.existsSync\(\s*([\w$]+)\s*\)/;
|
||||
const match = code.match(re);
|
||||
|
||||
if (!match) {
|
||||
console.error('FATAL: argv file-drop collector branch not found.');
|
||||
console.error(' Expected: !PARAM.startsWith("-")&&FSVAR.existsSync(PARAM)');
|
||||
console.error(
|
||||
' This patch prevents app.asar file-drop prompts (#383, #622).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify uniqueness — startsWith("-")&&existsSync must appear exactly
|
||||
// once; multiple matches would mean we cannot safely target this site.
|
||||
const escaped = match[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allMatches = code.match(new RegExp(escaped, 'g'));
|
||||
if (allMatches && allMatches.length > 1) {
|
||||
console.error('FATAL: file-drop pattern matched ' +
|
||||
allMatches.length + ' times (expected 1).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [, startsPart, fsVar, param] = match;
|
||||
console.log(
|
||||
' Found collector: param=' + param + ', fsVar=' + fsVar);
|
||||
|
||||
// Insert guard: !PARAM.endsWith(".asar")&&
|
||||
// Before: !PARAM.startsWith("-")&&FSVAR.existsSync(PARAM)
|
||||
// After: !PARAM.startsWith("-")&&!PARAM.endsWith(".asar")&&FSVAR.existsSync(PARAM)
|
||||
//
|
||||
// Replace the full outer match directly — no nested replace — to avoid
|
||||
// any risk of $ in minified identifiers being misread as replacement
|
||||
// pattern metacharacters.
|
||||
const patched = startsPart + '!' + param + '.endsWith(".asar")&&' +
|
||||
fsVar + '.existsSync(' + param + ')';
|
||||
code = code.replace(match[0], patched);
|
||||
|
||||
// Verify the patch landed with the correct context
|
||||
if (!code.match(/\.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\)/)) {
|
||||
console.error('FATAL: .asar file-drop guard replacement failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Added .asar guard to argv file-drop collector');
|
||||
ASAR_FILE_DROP_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar argv file-drop guard patch failed' >&2
|
||||
echo 'The app will show file-drop prompts on window reopen' \
|
||||
'without this patch (#383, #622).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_cowork_linux() {
|
||||
echo 'Patching Cowork mode for Linux...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
@@ -51,7 +256,7 @@ function extractBlock(str, startIdx, open = '{') {
|
||||
// Pattern: VAR!=="darwin"&&VAR!=="win32" (unique in platform gate)
|
||||
// Anchor: appears near 'unsupported_platform' code value
|
||||
// ============================================================
|
||||
const platformGateRe = /(\w+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
|
||||
const platformGateRe = /([\w$]+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
|
||||
const origCode = code;
|
||||
code = code.replace(platformGateRe, (match, varName, mid, end) => {
|
||||
// Only patch the instance near the "unsupported_platform" code value
|
||||
@@ -67,10 +272,10 @@ if (code !== origCode) {
|
||||
patchCount++;
|
||||
} else {
|
||||
// Try without backreference (in case minifier uses different var names)
|
||||
const simpleRe = /(!=="darwin"\s*&&\s*\w+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
|
||||
const simpleRe = /(!=="darwin"\s*&&\s*[\w$]+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
|
||||
const simpleMatch = code.match(simpleRe);
|
||||
if (simpleMatch) {
|
||||
const varMatch = simpleMatch[0].match(/(\w+)\s*!==\s*"win32"/);
|
||||
const varMatch = simpleMatch[0].match(/([\w$]+)\s*!==\s*"win32"/);
|
||||
if (varMatch) {
|
||||
code = code.replace(simpleMatch[1],
|
||||
simpleMatch[1] + '&&' + varMatch[1] + '!=="linux"');
|
||||
@@ -91,7 +296,7 @@ if (code === origCode) {
|
||||
// Anchor: unique string "vmClient (TypeScript)"
|
||||
// Extracts the win32 platform variable, adds Linux OR condition
|
||||
// ============================================================
|
||||
const vmClientLogMatch = code.match(/(\w+)(\s*\?\s*"vmClient \(TypeScript\)")/);
|
||||
const vmClientLogMatch = code.match(/([\w$]+)(\s*\?\s*"vmClient \(TypeScript\)")/);
|
||||
if (vmClientLogMatch) {
|
||||
const win32Var = vmClientLogMatch[1];
|
||||
|
||||
@@ -109,6 +314,13 @@ if (vmClientLogMatch) {
|
||||
'(' + win32Var + '||process.platform==="linux")$1');
|
||||
console.log(' Patched VM client log check for Linux');
|
||||
patchCount++;
|
||||
} else if (code.includes(
|
||||
'||process.platform==="linux")?"vmClient (TypeScript)"'
|
||||
)) {
|
||||
console.log(' VM client log gate already applied (Patch 2a)');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find anchor for VM client log' +
|
||||
' gate (Patch 2a) — half-patched asar will fail Cowork startup');
|
||||
}
|
||||
|
||||
// 2b: Patch the actual module assignment
|
||||
@@ -125,6 +337,12 @@ if (vmClientLogMatch) {
|
||||
'(' + win32Var + '||process.platform==="linux")$1');
|
||||
console.log(' Patched VM module assignment for Linux');
|
||||
patchCount++;
|
||||
} else if (/\|\|process\.platform==="linux"\)\??\(?[\w$]+=\{vm:[\w$]+\}/.test(code)) {
|
||||
console.log(' VM module assignment already applied (Patch 2b)');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find anchor for VM module' +
|
||||
' assignment (Patch 2b) — half-patched asar will fail' +
|
||||
' Cowork startup (PR #555 failure mode)');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find vmClient variable for module loading patch');
|
||||
@@ -134,7 +352,7 @@ if (vmClientLogMatch) {
|
||||
// Patch 3: Socket path - use Unix domain socket on Linux
|
||||
// Anchor: unique string "cowork-vm-service" in pipe path
|
||||
// ============================================================
|
||||
const pipeMatch = code.match(/(\w+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
||||
const pipeMatch = code.match(/([\w$]+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
||||
if (pipeMatch) {
|
||||
const pipeVar = pipeMatch[1];
|
||||
const assign = pipeMatch[2];
|
||||
@@ -213,7 +431,7 @@ if (!code.includes('"linux":{') && !code.includes("'linux':{") &&
|
||||
// calls download() which returns success immediately).
|
||||
// ============================================================
|
||||
{
|
||||
const statusRe = /getDownloadStatus\(\)\{return\s+(\w+\(\)\?(\w+)\.Downloading:\w+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
|
||||
const statusRe = /getDownloadStatus\(\)\{return\s+([\w$]+\(\)\?([\w$]+)\.Downloading:[\w$]+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
|
||||
const statusMatch = code.match(statusRe);
|
||||
if (statusMatch) {
|
||||
const [whole, origExpr, enumVar] = statusMatch;
|
||||
@@ -266,96 +484,104 @@ if (serviceErrorIdx !== -1) {
|
||||
// Step 1: Find the ENOENT check and expand it to include ECONNREFUSED
|
||||
// Pattern: VAR.code==="ENOENT"
|
||||
// Search backwards from the error string to find it
|
||||
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
||||
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
||||
const enoentRe = /(\w+)\.code\s*===\s*"ENOENT"/g;
|
||||
let enoentMatch;
|
||||
let lastEnoent = null;
|
||||
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
||||
lastEnoent = enoentMatch;
|
||||
}
|
||||
if (lastEnoent) {
|
||||
const enoentStr = lastEnoent[0];
|
||||
const errVar = lastEnoent[1];
|
||||
const enoentAbsIdx = searchStart + lastEnoent.index;
|
||||
// Replace: VAR.code==="ENOENT"
|
||||
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
||||
const expanded =
|
||||
'(' + enoentStr +
|
||||
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
||||
code = code.substring(0, enoentAbsIdx) +
|
||||
expanded +
|
||||
code.substring(enoentAbsIdx + enoentStr.length);
|
||||
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
||||
if (/process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED"/.test(code)) {
|
||||
console.log(' ENOENT/ECONNREFUSED expansion already applied');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
||||
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
||||
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
||||
const enoentRe = /([\w$]+)\.code\s*===\s*"ENOENT"/g;
|
||||
let enoentMatch;
|
||||
let lastEnoent = null;
|
||||
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
||||
lastEnoent = enoentMatch;
|
||||
}
|
||||
if (lastEnoent) {
|
||||
const enoentStr = lastEnoent[0];
|
||||
const errVar = lastEnoent[1];
|
||||
const enoentAbsIdx = searchStart + lastEnoent.index;
|
||||
// Replace: VAR.code==="ENOENT"
|
||||
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
||||
const expanded =
|
||||
'(' + enoentStr +
|
||||
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
||||
code = code.substring(0, enoentAbsIdx) +
|
||||
expanded +
|
||||
code.substring(enoentAbsIdx + enoentStr.length);
|
||||
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Inject auto-launch before the retry delay
|
||||
// Re-find serviceErrorStr since indices shifted after step 1
|
||||
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
|
||||
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
|
||||
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
|
||||
const retryMatch = searchRegion.match(
|
||||
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
|
||||
);
|
||||
if (retryMatch) {
|
||||
const retryStr = retryMatch[0];
|
||||
const retryOffset = searchRegion.indexOf(retryStr);
|
||||
const retryAbsIdx = newServiceErrorIdx + retryOffset;
|
||||
// Inject auto-launch before the retry delay
|
||||
// Service script is in app.asar.unpacked/ (not inside asar, since
|
||||
// child_process cannot execute scripts from inside an asar).
|
||||
// Uses fork() instead of spawn() because process.execPath in Electron
|
||||
// is the Electron binary - spawn would trigger "file open" handling
|
||||
// instead of executing the script as Node.js.
|
||||
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
|
||||
// Extract the enclosing function name (Ma or whatever it's
|
||||
// minified to) so the dedup guard attaches to it
|
||||
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
|
||||
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
|
||||
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
|
||||
const funcNameRe = /async function (\w+)\s*\(\s*\w+\s*,\s*\w+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
|
||||
let funcMatch;
|
||||
let retryFuncName = null;
|
||||
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
|
||||
retryFuncName = funcMatch[1];
|
||||
}
|
||||
const spawnGuard = retryFuncName
|
||||
? retryFuncName + '._lastSpawn'
|
||||
: '_globalLastSpawn';
|
||||
// Cooldown in ms — long enough to avoid fork storms, short enough
|
||||
// that the retry loop can re-spawn after a mid-session daemon death.
|
||||
const autoLaunch =
|
||||
'process.platform==="linux"&&' +
|
||||
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
|
||||
'&&(' + spawnGuard + '=Date.now(),' +
|
||||
'(()=>{try{' +
|
||||
'const _p=require("path"),_fs=require("fs");' +
|
||||
'const _d=_p.join(process.resourcesPath,' +
|
||||
'"app.asar.unpacked","' + svcPath + '");' +
|
||||
'if(_fs.existsSync(_d)){' +
|
||||
// Open daemon log for append; fall back to ignoring stdio.
|
||||
'let _stdio="ignore";' +
|
||||
'try{' +
|
||||
'const _ld=_p.join(process.env.HOME||"/tmp",' +
|
||||
'".config/Claude/logs");' +
|
||||
'_fs.mkdirSync(_ld,{recursive:true});' +
|
||||
'const _fd=_fs.openSync(' +
|
||||
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
|
||||
'_stdio=["ignore",_fd,_fd,"ipc"]' +
|
||||
'}catch(_){}' +
|
||||
'const _c=require("child_process").fork(_d,[],' +
|
||||
'{detached:true,stdio:_stdio,env:{...process.env,' +
|
||||
'ELECTRON_RUN_AS_NODE:"1"}});' +
|
||||
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
|
||||
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
|
||||
code = code.substring(0, retryAbsIdx) +
|
||||
autoLaunch + code.substring(retryAbsIdx);
|
||||
console.log(' Added service daemon auto-launch on Linux');
|
||||
patchCount++;
|
||||
if (code.includes('cowork-autolaunch')) {
|
||||
console.log(' Service daemon auto-launch already applied');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find retry delay for auto-launch patch');
|
||||
// Re-find serviceErrorStr since indices shifted after step 1
|
||||
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
|
||||
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
|
||||
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
|
||||
const retryMatch = searchRegion.match(
|
||||
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
|
||||
);
|
||||
if (retryMatch) {
|
||||
const retryStr = retryMatch[0];
|
||||
const retryOffset = searchRegion.indexOf(retryStr);
|
||||
const retryAbsIdx = newServiceErrorIdx + retryOffset;
|
||||
// Inject auto-launch before the retry delay
|
||||
// Service script is in app.asar.unpacked/ (not inside asar, since
|
||||
// child_process cannot execute scripts from inside an asar).
|
||||
// Uses fork() instead of spawn() because process.execPath in Electron
|
||||
// is the Electron binary - spawn would trigger "file open" handling
|
||||
// instead of executing the script as Node.js.
|
||||
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
|
||||
// Extract the enclosing function name (Ma or whatever it's
|
||||
// minified to) so the dedup guard attaches to it
|
||||
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
|
||||
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
|
||||
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
|
||||
const funcNameRe = /async function ([$\w]+)\s*\(\s*[$\w]+\s*,\s*[$\w]+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
|
||||
let funcMatch;
|
||||
let retryFuncName = null;
|
||||
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
|
||||
retryFuncName = funcMatch[1];
|
||||
}
|
||||
const spawnGuard = retryFuncName
|
||||
? retryFuncName + '._lastSpawn'
|
||||
: 'globalThis._lastSpawn';
|
||||
// Cooldown in ms — long enough to avoid fork storms, short enough
|
||||
// that the retry loop can re-spawn after a mid-session daemon death.
|
||||
const autoLaunch =
|
||||
'process.platform==="linux"&&' +
|
||||
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
|
||||
'&&(' + spawnGuard + '=Date.now(),' +
|
||||
'(()=>{try{' +
|
||||
'const _p=require("path"),_fs=require("fs");' +
|
||||
'const _d=_p.join(process.resourcesPath,' +
|
||||
'"app.asar.unpacked","' + svcPath + '");' +
|
||||
'if(_fs.existsSync(_d)){' +
|
||||
// Open daemon log for append; fall back to ignoring stdio.
|
||||
'let _stdio="ignore";' +
|
||||
'try{' +
|
||||
'const _ld=_p.join(process.env.HOME||"/tmp",' +
|
||||
'".config/Claude/logs");' +
|
||||
'_fs.mkdirSync(_ld,{recursive:true});' +
|
||||
'const _fd=_fs.openSync(' +
|
||||
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
|
||||
'_stdio=["ignore",_fd,_fd,"ipc"]' +
|
||||
'}catch(_){}' +
|
||||
'const _c=require("child_process").fork(_d,[],' +
|
||||
'{detached:true,stdio:_stdio,env:{...process.env,' +
|
||||
'ELECTRON_RUN_AS_NODE:"1"}});' +
|
||||
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
|
||||
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
|
||||
code = code.substring(0, retryAbsIdx) +
|
||||
autoLaunch + code.substring(retryAbsIdx);
|
||||
console.log(' Added service daemon auto-launch on Linux');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find retry delay for auto-launch patch');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find VM service error string for auto-launch');
|
||||
@@ -375,7 +601,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// toward recovery over re-download avoidance is correct.
|
||||
// ============================================================
|
||||
{
|
||||
const reinstallArrRe = /const (\w+)=\[("rootfs\.img"[^\]]*)\];/;
|
||||
const reinstallArrRe = /const ([\w$]+)=\[("rootfs\.img"[^\]]*)\];/;
|
||||
const arrMatch = code.match(reinstallArrRe);
|
||||
if (arrMatch) {
|
||||
const [whole, name, contents] = arrMatch;
|
||||
@@ -421,7 +647,7 @@ if (serviceErrorIdx !== -1) {
|
||||
{
|
||||
// Find: MKDTEMP(PATH.join(OS.tmpdir(), "wvm-"))
|
||||
// The bundle dir var is used in mkdir(VAR, ...) just before
|
||||
const mkdtempRe = /(\w+)\.mkdtemp\(\s*(\w+)\.join\(\s*(\w+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
|
||||
const mkdtempRe = /([\w$]+)\.mkdtemp\(\s*([\w$]+)\.join\(\s*([\w$]+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
|
||||
const mkdtempMatch = code.match(mkdtempRe);
|
||||
if (mkdtempMatch) {
|
||||
const [fullMatch, fsVar, pathVar, osVar] = mkdtempMatch;
|
||||
@@ -430,7 +656,7 @@ if (serviceErrorIdx !== -1) {
|
||||
const searchStart = Math.max(0, mkdtempIdx - 2000);
|
||||
const before = code.substring(searchStart, mkdtempIdx);
|
||||
// Look for: mkdir(VARNAME, { recursive
|
||||
const mkdirRe = /(\w+)\.mkdir\(\s*(\w+)\s*,\s*\{\s*recursive/g;
|
||||
const mkdirRe = /([\w$]+)\.mkdir\(\s*([\w$]+)\s*,\s*\{\s*recursive/g;
|
||||
let bundleVar = null;
|
||||
let lastMkdir;
|
||||
while ((lastMkdir = mkdirRe.exec(before)) !== null) {
|
||||
@@ -465,118 +691,122 @@ if (serviceErrorIdx !== -1) {
|
||||
// since minified names change between releases (#344).
|
||||
// ============================================================
|
||||
{
|
||||
const anchor = '"[VM:start] Windows VM service configured"';
|
||||
const anchorIdx = code.indexOf(anchor);
|
||||
if (anchorIdx !== -1) {
|
||||
// Find the "}" closing the win32 if-block after the anchor
|
||||
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
|
||||
if (closingBrace !== -1) {
|
||||
// Extract minified variable names from the win32 block
|
||||
// Search backwards from anchor to find the win32 block
|
||||
const regionStart = Math.max(0, anchorIdx - 1000);
|
||||
const region = code.substring(regionStart, anchorIdx);
|
||||
if (code.includes('[VM:start] Copying smol-bin') && code.includes('process.platform==="linux"')) {
|
||||
console.log(' Linux smol-bin copy block already present');
|
||||
} else {
|
||||
const anchor = '"[VM:start] Windows VM service configured"';
|
||||
const anchorIdx = code.indexOf(anchor);
|
||||
if (anchorIdx !== -1) {
|
||||
// Find the "}" closing the win32 if-block after the anchor
|
||||
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
|
||||
if (closingBrace !== -1) {
|
||||
// Extract minified variable names from the win32 block
|
||||
// Search backwards from anchor to find the win32 block
|
||||
const regionStart = Math.max(0, anchorIdx - 1000);
|
||||
const region = code.substring(regionStart, anchorIdx);
|
||||
|
||||
// JS identifier may start with $, _, or letter; \w doesn't
|
||||
// match $ so use [$\w]+ to capture vars like `$e` (Claude
|
||||
// >= 1.3109.0 uses $e for the fs module to avoid collision
|
||||
// with the parameter `e`). See issue #418.
|
||||
// path var: VAR.join(process.resourcesPath,
|
||||
const pathMatch = region.match(
|
||||
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
|
||||
);
|
||||
// fs var: VAR.existsSync(
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
// logger var: VAR.info("[VM:start]
|
||||
const logMatch = region.match(
|
||||
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
|
||||
);
|
||||
// stream/pipeline var: VAR.pipeline(
|
||||
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
|
||||
// arch function: const VAR=FUNC(), used in smol-bin
|
||||
const archMatch = region.match(
|
||||
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
|
||||
);
|
||||
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
|
||||
const bundleMatch = region.match(
|
||||
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
|
||||
);
|
||||
// JS identifier may start with $, _, or letter; \w doesn't
|
||||
// match $ so use [$\w]+ to capture vars like `$e` (Claude
|
||||
// >= 1.3109.0 uses $e for the fs module to avoid collision
|
||||
// with the parameter `e`). See issue #418.
|
||||
// path var: VAR.join(process.resourcesPath,
|
||||
const pathMatch = region.match(
|
||||
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
|
||||
);
|
||||
// fs var: VAR.existsSync(
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
// logger var: VAR.info("[VM:start]
|
||||
const logMatch = region.match(
|
||||
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
|
||||
);
|
||||
// stream/pipeline var: VAR.pipeline(
|
||||
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
|
||||
// arch function: const VAR=FUNC(), used in smol-bin
|
||||
const archMatch = region.match(
|
||||
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
|
||||
);
|
||||
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
|
||||
const bundleMatch = region.match(
|
||||
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
|
||||
);
|
||||
|
||||
if (pathMatch && fsMatch && logMatch &&
|
||||
streamMatch && archMatch && bundleMatch) {
|
||||
const pathVar = pathMatch[1];
|
||||
const fsVar = fsMatch[1];
|
||||
const logVar = logMatch[1];
|
||||
const streamVar = streamMatch[1];
|
||||
const archFunc = archMatch[2];
|
||||
const bundleVar = bundleMatch[1];
|
||||
if (pathMatch && fsMatch && logMatch &&
|
||||
streamMatch && archMatch && bundleMatch) {
|
||||
const pathVar = pathMatch[1];
|
||||
const fsVar = fsMatch[1];
|
||||
const logVar = logMatch[1];
|
||||
const streamVar = streamMatch[1];
|
||||
const archFunc = archMatch[2];
|
||||
const bundleVar = bundleMatch[1];
|
||||
|
||||
const linuxBlock =
|
||||
'if(process.platform==="linux"){' +
|
||||
'const _la=' + archFunc + '(),' +
|
||||
'_ls=' + pathVar + '.join(process.resourcesPath,' +
|
||||
'`smol-bin.${_la}.vhdx`),' +
|
||||
'_ld=' + pathVar + '.join(' + bundleVar +
|
||||
',"smol-bin.vhdx");' +
|
||||
fsVar + '.existsSync(_ls)?' +
|
||||
'(' + logVar + '.info(' +
|
||||
'`[VM:start] Copying smol-bin.${_la}' +
|
||||
'.vhdx to bundle (Linux)`),' +
|
||||
'await ' + streamVar + '.pipeline(' +
|
||||
fsVar + '.createReadStream(_ls),' +
|
||||
fsVar + '.createWriteStream(_ld)),' +
|
||||
logVar + '.info(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx copied successfully`))' +
|
||||
':' + logVar + '.warn(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx not found at ${_ls}`)' +
|
||||
'}';
|
||||
// Defensive: if a future upstream emits its own
|
||||
// if(process.platform==="linux"){...} block right
|
||||
// after the win32 close brace, strip it before
|
||||
// injecting our correctly-wired linuxBlock so we
|
||||
// don't end up with two competing blocks.
|
||||
const insertPos = closingBrace + 1;
|
||||
let stripUntil = insertPos;
|
||||
const afterWin32 = code.substring(insertPos);
|
||||
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
|
||||
const upstreamMatch = afterWin32.match(upstreamRe);
|
||||
if (upstreamMatch) {
|
||||
const matchEnd = insertPos + upstreamMatch[0].length;
|
||||
let depth = 1, pos = matchEnd;
|
||||
while (depth > 0 && pos < code.length) {
|
||||
if (code[pos] === '{') depth++;
|
||||
else if (code[pos] === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
if (depth === 0) {
|
||||
stripUntil = pos;
|
||||
console.log(' Stripped pre-existing upstream Linux block');
|
||||
} else {
|
||||
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
|
||||
const linuxBlock =
|
||||
'if(process.platform==="linux"){' +
|
||||
'const _la=' + archFunc + '(),' +
|
||||
'_ls=' + pathVar + '.join(process.resourcesPath,' +
|
||||
'`smol-bin.${_la}.vhdx`),' +
|
||||
'_ld=' + pathVar + '.join(' + bundleVar +
|
||||
',"smol-bin.vhdx");' +
|
||||
fsVar + '.existsSync(_ls)?' +
|
||||
'(' + logVar + '.info(' +
|
||||
'`[VM:start] Copying smol-bin.${_la}' +
|
||||
'.vhdx to bundle (Linux)`),' +
|
||||
'await ' + streamVar + '.pipeline(' +
|
||||
fsVar + '.createReadStream(_ls),' +
|
||||
fsVar + '.createWriteStream(_ld)),' +
|
||||
logVar + '.info(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx copied successfully`))' +
|
||||
':' + logVar + '.warn(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx not found at ${_ls}`)' +
|
||||
'}';
|
||||
// Defensive: if a future upstream emits its own
|
||||
// if(process.platform==="linux"){...} block right
|
||||
// after the win32 close brace, strip it before
|
||||
// injecting our correctly-wired linuxBlock so we
|
||||
// don't end up with two competing blocks.
|
||||
const insertPos = closingBrace + 1;
|
||||
let stripUntil = insertPos;
|
||||
const afterWin32 = code.substring(insertPos);
|
||||
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
|
||||
const upstreamMatch = afterWin32.match(upstreamRe);
|
||||
if (upstreamMatch) {
|
||||
const matchEnd = insertPos + upstreamMatch[0].length;
|
||||
let depth = 1, pos = matchEnd;
|
||||
while (depth > 0 && pos < code.length) {
|
||||
if (code[pos] === '{') depth++;
|
||||
else if (code[pos] === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
if (depth === 0) {
|
||||
stripUntil = pos;
|
||||
console.log(' Stripped pre-existing upstream Linux block');
|
||||
} else {
|
||||
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
|
||||
}
|
||||
}
|
||||
code = code.substring(0, insertPos) +
|
||||
linuxBlock +
|
||||
code.substring(stripUntil);
|
||||
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
|
||||
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
|
||||
patchCount++;
|
||||
} else {
|
||||
const missing = [];
|
||||
if (!pathMatch) missing.push('path');
|
||||
if (!fsMatch) missing.push('fs');
|
||||
if (!logMatch) missing.push('logger');
|
||||
if (!streamMatch) missing.push('stream');
|
||||
if (!archMatch) missing.push('arch');
|
||||
if (!bundleMatch) missing.push('bundlePath');
|
||||
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
|
||||
}
|
||||
code = code.substring(0, insertPos) +
|
||||
linuxBlock +
|
||||
code.substring(stripUntil);
|
||||
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
|
||||
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
|
||||
patchCount++;
|
||||
} else {
|
||||
const missing = [];
|
||||
if (!pathMatch) missing.push('path');
|
||||
if (!fsMatch) missing.push('fs');
|
||||
if (!logMatch) missing.push('logger');
|
||||
if (!streamMatch) missing.push('stream');
|
||||
if (!archMatch) missing.push('arch');
|
||||
if (!bundleMatch) missing.push('bundlePath');
|
||||
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
|
||||
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
|
||||
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,49 +816,53 @@ if (serviceErrorIdx !== -1) {
|
||||
// on Linux. Register our own to SIGTERM the daemon on app quit.
|
||||
// ============================================================
|
||||
{
|
||||
const quitFnRe = /registerQuitHandler:\s*(\w+)/;
|
||||
const quitFnMatch = code.match(quitFnRe);
|
||||
if (quitFnMatch) {
|
||||
const quitFn = quitFnMatch[1];
|
||||
console.log(' Found registerQuitHandler function: ' + quitFn);
|
||||
if (code.includes('cowork-linux-daemon-shutdown')) {
|
||||
console.log(' Linux cowork daemon quit handler already registered');
|
||||
} else {
|
||||
const quitFnRe = /registerQuitHandler:\s*([\w$]+)/;
|
||||
const quitFnMatch = code.match(quitFnRe);
|
||||
if (quitFnMatch) {
|
||||
const quitFn = quitFnMatch[1];
|
||||
console.log(' Found registerQuitHandler function: ' + quitFn);
|
||||
|
||||
const quitFnDef = 'function ' + quitFn + '(';
|
||||
const quitFnDefIdx = code.indexOf(quitFnDef);
|
||||
if (quitFnDefIdx !== -1) {
|
||||
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
|
||||
if (fnBlock) {
|
||||
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
|
||||
fnBlock.length;
|
||||
const shutdownHandler =
|
||||
'process.platform==="linux"&&' + quitFn + '({' +
|
||||
'name:"cowork-linux-daemon-shutdown",' +
|
||||
'fn:async()=>{' +
|
||||
'const _p=global.__coworkDaemonPid;' +
|
||||
'if(!_p)return;' +
|
||||
'try{const _cmd=require("fs").readFileSync(' +
|
||||
'"/proc/"+_p+"/cmdline","utf8");' +
|
||||
'if(!_cmd.includes("cowork-vm-service"))return' +
|
||||
'}catch(_e){return}' +
|
||||
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
|
||||
'for(let _i=0;_i<50;_i++){' +
|
||||
'await new Promise(_r=>setTimeout(_r,200));' +
|
||||
'try{process.kill(_p,0)}catch(_e){return}' +
|
||||
'}}});';
|
||||
code = code.substring(0, insertIdx) +
|
||||
shutdownHandler + code.substring(insertIdx);
|
||||
console.log(' Registered Linux cowork daemon quit handler');
|
||||
patchCount++;
|
||||
const quitFnDef = 'function ' + quitFn + '(';
|
||||
const quitFnDefIdx = code.indexOf(quitFnDef);
|
||||
if (quitFnDefIdx !== -1) {
|
||||
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
|
||||
if (fnBlock) {
|
||||
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
|
||||
fnBlock.length;
|
||||
const shutdownHandler =
|
||||
'process.platform==="linux"&&' + quitFn + '({' +
|
||||
'name:"cowork-linux-daemon-shutdown",' +
|
||||
'fn:async()=>{' +
|
||||
'const _p=global.__coworkDaemonPid;' +
|
||||
'if(!_p)return;' +
|
||||
'try{const _cmd=require("fs").readFileSync(' +
|
||||
'"/proc/"+_p+"/cmdline","utf8");' +
|
||||
'if(!_cmd.includes("cowork-vm-service"))return' +
|
||||
'}catch(_e){return}' +
|
||||
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
|
||||
'for(let _i=0;_i<50;_i++){' +
|
||||
'await new Promise(_r=>setTimeout(_r,200));' +
|
||||
'try{process.kill(_p,0)}catch(_e){return}' +
|
||||
'}}});';
|
||||
code = code.substring(0, insertIdx) +
|
||||
shutdownHandler + code.substring(insertIdx);
|
||||
console.log(' Registered Linux cowork daemon quit handler');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function body for quit handler');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function body for quit handler');
|
||||
' function definition');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function definition');
|
||||
console.log(' WARNING: Could not find registerQuitHandler' +
|
||||
' export for quit handler');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find registerQuitHandler' +
|
||||
' export for quit handler');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +898,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// 'sessionId:VAR' in the config itself — cheap, scoped, and
|
||||
// immune to unrelated *.userSelectedFolders references (e.g.
|
||||
// loop variables) that wander into the enclosing scope.
|
||||
const sidMatch = cfgBlock.match(/\{sessionId:(\w+)\b/);
|
||||
const sidMatch = cfgBlock.match(/\{sessionId:([\w$]+)\b/);
|
||||
if (!sidMatch) {
|
||||
console.log(' WARNING: #412 no sessionId field in config');
|
||||
} else {
|
||||
@@ -689,7 +923,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// --- 12c: accept a 13th param in spawn() method body ---
|
||||
let site3Done = false;
|
||||
const spawnIdempotent =
|
||||
/async spawn\([^)]+\)\{const \w+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
|
||||
/async spawn\([^)]+\)\{const [\w$]+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
|
||||
if (spawnIdempotent.test(code)) {
|
||||
console.log(' #412 spawn method already accepts sharedCwdPath');
|
||||
site3Done = true;
|
||||
@@ -697,7 +931,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// Match the spawn body with the trailing mountConda setter and the
|
||||
// IPC call. Captures: arg list, payload var, setter chain, IPC tail.
|
||||
const spawnRe =
|
||||
/async spawn\(([^)]+)\)\{const (\w+)=\{id:[^}]+\};([^{}]*?\w+&&\(\2\.mountConda=\w+\)),(await \w+\("spawn",\2\)\})/;
|
||||
/async spawn\(([^)]+)\)\{const ([\w$]+)=\{id:[^}]+\};([^{}]*?[\w$]+&&\(\2\.mountConda=[\w$]+\)),(await [\w$]+\("spawn",\2\)\})/;
|
||||
const spawnMatch = code.match(spawnRe);
|
||||
if (!spawnMatch) {
|
||||
console.log(' WARNING: #412 spawn method body regex did not match');
|
||||
@@ -734,11 +968,11 @@ if (serviceErrorIdx !== -1) {
|
||||
// the uniqueness so a second upstream caller wouldn't silently take
|
||||
// only the first hit.
|
||||
let site2Done = false;
|
||||
if (/,\w+\.mountConda,\w+\.sharedCwdPath\)/.test(code)) {
|
||||
if (/,[\w$]+\.mountConda,[\w$]+\.sharedCwdPath\)/.test(code)) {
|
||||
console.log(' #412 caller already forwards sharedCwdPath');
|
||||
site2Done = true;
|
||||
} else {
|
||||
const callMatches = [...code.matchAll(/,(\w+)\.mountConda\)/g)];
|
||||
const callMatches = [...code.matchAll(/,([\w$]+)\.mountConda\)/g)];
|
||||
if (callMatches.length === 0) {
|
||||
console.log(' WARNING: #412 no ",VAR.mountConda)" pattern found');
|
||||
} else if (callMatches.length > 1) {
|
||||
@@ -794,16 +1028,40 @@ install_node_pty() {
|
||||
echo '{"name":"node-pty-build","version":"1.0.0","private":true}' > package.json
|
||||
|
||||
echo 'Installing node-pty (this compiles native module)...'
|
||||
if npm install node-pty 2>&1; then
|
||||
echo 'node-pty installed successfully'
|
||||
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
|
||||
else
|
||||
echo 'Failed to install node-pty - terminal features may not work'
|
||||
# Fail loudly on npm install failure rather than warn-and-continue.
|
||||
# The previous behavior silently dropped pty_src_dir, skipped the
|
||||
# entire copy block, and shipped the upstream Windows node-pty
|
||||
# binaries (the #401 failure mode). check_dependencies should now
|
||||
# install gcc/g++/make/python3 before we get here, so this branch
|
||||
# is the last line of defense for build-tool gaps that auto-install
|
||||
# couldn't fix (unknown distro, broken package mirror, etc.).
|
||||
if ! npm install node-pty 2>&1; then
|
||||
echo "Error: 'npm install node-pty' failed." >&2
|
||||
echo 'node-pty has a native module compiled via node-gyp;' >&2
|
||||
echo 'this usually means the build environment lacks a C/C++' >&2
|
||||
echo 'compiler, make, or python3.' >&2
|
||||
echo '' >&2
|
||||
echo 'Install build tools and re-run:' >&2
|
||||
echo ' Debian/Ubuntu: sudo apt install build-essential python3' >&2
|
||||
echo ' Fedora/RHEL: sudo dnf install gcc gcc-c++ make python3' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'node-pty installed successfully'
|
||||
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
|
||||
fi
|
||||
|
||||
if [[ -n $pty_src_dir && -d $pty_src_dir ]]; then
|
||||
echo 'Copying node-pty JavaScript files into app.asar.contents...'
|
||||
# Wipe the upstream-extracted node-pty before staging the Linux
|
||||
# build. The Windows installer's app.asar ships node-pty with
|
||||
# Windows binaries (winpty.dll, winpty-agent.exe, Windows
|
||||
# build/Release/*.node files). `cp -r $pty_src_dir/build` only
|
||||
# overwrites same-named files; orphan Windows binaries persist
|
||||
# inside the asar, surface as PE32+ when users inspect with
|
||||
# `asar list`, and pollute /tmp via Electron's lazy-extract on
|
||||
# any spurious require() (#401).
|
||||
rm -rf "$app_staging_dir/app.asar.contents/node_modules/node-pty"
|
||||
mkdir -p "$app_staging_dir/app.asar.contents/node_modules/node-pty" || exit 1
|
||||
# --no-preserve=mode so read-only bits from the Nix store
|
||||
# (--node-pty-dir) don't propagate into the staging tree.
|
||||
|
||||
57
scripts/patches/org-plugins.sh
Normal file
57
scripts/patches/org-plugins.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#===============================================================================
|
||||
# Linux org-plugins path: inject a case"linux" into the platform switch
|
||||
# that resolves the org-plugins source directory.
|
||||
#
|
||||
# Upstream only has cases for darwin and win32; the default returns null,
|
||||
# silently disabling the entire org-plugins marketplace feature on Linux.
|
||||
# This adds: case"linux":return"/etc/claude/org-plugins"
|
||||
#
|
||||
# /etc/claude/org-plugins is FHS-correct for MDM-managed configuration,
|
||||
# consistent with Claude Code's /etc/claude-code/ path.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: (none)
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_org_plugins_path() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency: skip if a Linux case already exists near the
|
||||
# org-plugins path resolver (upstream may add one in the future).
|
||||
if grep -q 'case"linux":return"/etc/claude/org-plugins"' \
|
||||
"$index_js"; then
|
||||
echo 'Linux org-plugins path already present'
|
||||
return
|
||||
fi
|
||||
|
||||
# Anchor: the darwin path string is unique in the entire bundle.
|
||||
# Verify it exists before attempting the patch.
|
||||
local anchor='Application Support/Claude/org-plugins'
|
||||
if ! grep -q "$anchor" "$index_js"; then
|
||||
echo 'Warning: org-plugins path resolver not found' \
|
||||
'in this version, skipping' >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# Pattern (minified):
|
||||
# ..."org-plugins");default:return null}
|
||||
#
|
||||
# The compound anchor — "org-plugins") immediately before
|
||||
# default:return null — is unique to this switch statement.
|
||||
# Insert case"linux":return"/etc/claude/org-plugins"; between
|
||||
# the end of the win32 case and the default case.
|
||||
#
|
||||
# \s* between tokens handles any future whitespace variation,
|
||||
# though the target file is always minified in practice.
|
||||
if grep -qP '"org-plugins"\)\s*;\s*default\s*:\s*return\s+null' \
|
||||
"$index_js"; then
|
||||
sed -i -E \
|
||||
's/("org-plugins"\)\s*;\s*)(default\s*:\s*return\s+null)/\1case"linux":return"\/etc\/claude\/org-plugins";\2/' \
|
||||
"$index_js"
|
||||
echo 'Added Linux org-plugins path (/etc/claude/org-plugins)'
|
||||
else
|
||||
echo 'Warning: org-plugins switch pattern not matched,' \
|
||||
'skipping' >&2
|
||||
fi
|
||||
}
|
||||
@@ -14,7 +14,7 @@ patch_quick_window() {
|
||||
# Extract the quick window variable name from the unique "pop-up-menu"
|
||||
# setAlwaysOnTop call, e.g.: Sa.setAlwaysOnTop(!0,"pop-up-menu")
|
||||
local quick_var
|
||||
quick_var=$(grep -oP '\w+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
quick_var=$(grep -oP '[$\w]+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $quick_var ]]; then
|
||||
echo 'WARNING: Could not extract quick window variable name'
|
||||
@@ -35,9 +35,9 @@ patch_quick_window() {
|
||||
de_check+='.toLowerCase().includes("kde")'
|
||||
if grep -qF "${quick_var}.blur(),${quick_var}.hide()" "$index_js"; then
|
||||
echo ' Quick window blur already patched'
|
||||
elif grep -qP "\|\|${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i \
|
||||
"s/||${quick_var_re}\.hide()/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
elif grep -qP "\|\|\s*${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/\|\|\s*${quick_var_re}\.hide\(\)/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
"$index_js"
|
||||
echo ' Added KDE-gated blur() before hide() on quick window'
|
||||
else
|
||||
@@ -57,11 +57,11 @@ let patchCount = 0;
|
||||
|
||||
// Find the minified isWindowFocused function via its named property
|
||||
// export: isWindowFocused: () => !!NAME()
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!(\w+)\(\)/;
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!([\w$]+)\(\)/;
|
||||
const focusedMatch = code.match(focusedPropRe);
|
||||
if (!focusedMatch) {
|
||||
console.log(' WARNING: Could not find isWindowFocused function');
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
const focusFn = focusedMatch[1];
|
||||
console.log(' Found focus check function: ' + focusFn);
|
||||
@@ -74,12 +74,12 @@ console.log(' Found focus check function: ' + focusFn);
|
||||
// group keeps the prefix optional in either case.
|
||||
const focusFnIdx = code.indexOf('function ' + focusFn + '(');
|
||||
const nearbyCode = code.substring(focusFnIdx, focusFnIdx + 500);
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var \w+(?:,\w+)*;)?return!\w+\|\|\w+\.isDestroyed\(\)\?!1:\w+\.isVisible\(\)/;
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var [\w$]+(?:,[\w$]+)*;)?return![\w$]+\|\|[\w$]+\.isDestroyed\(\)\?!1:[\w$]+\.isVisible\(\)/;
|
||||
const visMatch = nearbyCode.match(visFnRe);
|
||||
if (!visMatch) {
|
||||
console.log(' WARNING: Could not find visibility function near ' +
|
||||
focusFn);
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
const visFn = visMatch[1];
|
||||
console.log(' Found visibility check function: ' + visFn);
|
||||
@@ -106,7 +106,7 @@ for (const anchor of anchors) {
|
||||
}
|
||||
// matches: <focusFn>()||(someVar).show()
|
||||
const showRe = new RegExp(
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|(\w+)\.show\(\)`
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|([\w$]+)\.show\(\)`
|
||||
);
|
||||
const showMatch = region.match(showRe);
|
||||
if (showMatch) {
|
||||
|
||||
@@ -11,9 +11,9 @@ patch_tray_menu_handler() {
|
||||
echo 'Patching tray menu handler...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
local tray_func tray_var first_const
|
||||
local tray_func tray_func_re tray_var
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo 'Failed to extract tray menu function name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -21,9 +21,12 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray function: $tray_func"
|
||||
|
||||
# Escape `$` for PCRE / sed -E patterns where it would otherwise act
|
||||
# as an end-of-line anchor. Minifier emits identifiers like `i$A`.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
|
||||
tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"$index_js")
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
if [[ -z $tray_var ]]; then
|
||||
echo 'Failed to extract tray variable name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -31,49 +34,40 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray variable: $tray_var"
|
||||
|
||||
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
|
||||
first_const=$(grep -oP \
|
||||
"async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $first_const ]]; then
|
||||
echo 'Failed to extract first const in function' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo " Found first const variable: $first_const"
|
||||
|
||||
# Add mutex guard to prevent concurrent tray rebuilds
|
||||
if ! grep -q "${tray_func}._running" "$index_js"; then
|
||||
sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
|
||||
# Idempotent: upstream may already ship the function as `async`
|
||||
# (1.8089.1 does). Re-applying the sed would produce
|
||||
# `async async function`, which then breaks downstream patches that
|
||||
# match `(?:async )?function NAME`.
|
||||
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
|
||||
sed -i -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
echo " Added mutex guard to ${tray_func}()"
|
||||
fi
|
||||
|
||||
# Trailing-edge mutex guard. Still prevents concurrent/reentrant
|
||||
# rebuilds (the slow path's 250ms DBus await can interleave), but —
|
||||
# unlike a plain leading-edge drop — it remembers a request that
|
||||
# arrives while a rebuild is in flight and re-runs once when the
|
||||
# window clears, so the FINAL nativeTheme value wins. At startup
|
||||
# shouldUseDarkColors reads false for ~50ms, then a burst of
|
||||
# "updated" events flips it true; a dropping mutex latches the
|
||||
# initial (wrong) value and leaves the tray icon stuck black on a
|
||||
# dark panel. See docs/learnings/tray-rebuild-race.md.
|
||||
if ! grep -q "${tray_func}._running" "$index_js"; then
|
||||
sed -i -E "s/async\s+function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){if(${tray_func}._running){${tray_func}._pending=true;return}${tray_func}._running=true;setTimeout(()=>{${tray_func}._running=false;if(${tray_func}._pending){${tray_func}._pending=false;${tray_func}()}},1500);/g" \
|
||||
"$index_js"
|
||||
echo " Added trailing-edge mutex guard to ${tray_func}()"
|
||||
fi
|
||||
|
||||
# Add DBus cleanup delay after tray destroy
|
||||
if ! grep -q "await new Promise.*setTimeout" "$index_js" \
|
||||
| grep -q "$tray_var"; then
|
||||
sed -i "s/${tray_var}\&\&(${tray_var}\.destroy(),${tray_var}=null)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
tray_var_re="${tray_var//\$/\\$}"
|
||||
if ! grep -q "await new Promise.*setTimeout.*${tray_var_re}" "$index_js"; then
|
||||
sed -i -E "s/${tray_var_re}\s*\&\&\s*\(\s*${tray_var_re}\.destroy\(\)\s*,\s*${tray_var_re}\s*=\s*null\s*\)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
"$index_js"
|
||||
echo " Added DBus cleanup delay after $tray_var.destroy()"
|
||||
fi
|
||||
|
||||
echo 'Tray menu handler patched'
|
||||
echo '##############################################################'
|
||||
|
||||
# Skip tray updates during startup (3 second window)
|
||||
echo 'Patching nativeTheme handler for startup delay...'
|
||||
if ! grep -q '_trayStartTime' "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
|
||||
"$index_js"
|
||||
sed -i -E \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"$index_js"
|
||||
echo ' Added startup delay check (3 second window)'
|
||||
fi
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_tray_icon_selection() {
|
||||
@@ -81,9 +75,9 @@ patch_tray_icon_selection() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
local dark_check="${electron_var_re}.nativeTheme.shouldUseDarkColors"
|
||||
|
||||
if grep -qP ':\$?\w+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
if grep -qP ':[$\w]+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/:(\\\$?\w+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"s/:([[:alnum:]_\$]+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"$index_js"
|
||||
echo 'Patched tray icon selection for Linux theme support'
|
||||
else
|
||||
@@ -98,18 +92,19 @@ patch_tray_inplace_update() {
|
||||
|
||||
# Re-extract the tray variable name — `patch_tray_menu_handler`
|
||||
# declares it `local` so it's not visible here. Same grep pattern.
|
||||
local tray_func local_tray_var tray_var_re
|
||||
local menu_func path_var enabled_var enabled_count
|
||||
local tray_func tray_func_re local_tray_var tray_var_re
|
||||
local menu_func menu_var menu_var_re path_var enabled_var enabled_count
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo ' Could not find tray function — skipping'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
# Escape `$` for PCRE patterns; matches the `tray_var_re` trick below.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
local_tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"$index_js")
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
if [[ -z $local_tray_var ]]; then
|
||||
echo ' Could not extract tray variable name — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -119,10 +114,38 @@ patch_tray_inplace_update() {
|
||||
|
||||
tray_var_re="${local_tray_var//\$/\\$}"
|
||||
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
|
||||
# Two upstream shapes wire the context menu differently:
|
||||
# old: ${tray_var}.setContextMenu(BUILDER()) — builder called inline
|
||||
# new: M=BUILDER(); ${tray_var}.setContextMenu(M) — prebuilt menu object
|
||||
# Resolve the BUILDER name in both. The injected fast-path emits
|
||||
# setContextMenu(BUILDER()), so landing on the menu *object* (M) instead
|
||||
# of its builder would emit setContextMenu(M()) and throw at runtime —
|
||||
# M is a Menu instance, not a function.
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\(\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_func ]]; then
|
||||
echo ' Could not extract menu function name — skipping'
|
||||
menu_var=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -n $menu_var ]]; then
|
||||
menu_var_re="${menu_var//\$/\\$}"
|
||||
# Word-boundary lookbehind, not a fixed [,;({] class, so the
|
||||
# assignment resolves whether it follows a separator or a
|
||||
# declarator (`let `/`const ` leaves a space before the var).
|
||||
# First assignment site wins, matching the inline-form grep.
|
||||
menu_func=$(grep -oP "(?<![\$\w])${menu_var_re}=\K[\$\w]+(?=\(\))" \
|
||||
"$index_js" | head -1)
|
||||
fi
|
||||
fi
|
||||
if [[ -z $menu_func ]]; then
|
||||
# Both the inline grep and the menu_var fallback came up empty.
|
||||
# A silent skip here is how the #515 duplicate-icon race
|
||||
# regressed before — make it loud on stderr so the next silent
|
||||
# regression surfaces in CI logs. Still skip gracefully so the
|
||||
# build completes.
|
||||
echo "WARNING: could not resolve tray menu function" \
|
||||
"(inline + fallback both failed) — in-place" \
|
||||
"fast-path NOT applied; duplicate-icon race" \
|
||||
"(#515) may regress" >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
@@ -134,7 +157,7 @@ patch_tray_inplace_update() {
|
||||
# suffix)` earlier in the function; minifier renames it between
|
||||
# releases, so it needs to be extracted (not hardcoded).
|
||||
path_var=$(grep -oP \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K\w+(?=\))" \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K[\$\w]+(?=\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract icon-path var — skipping'
|
||||
@@ -148,8 +171,8 @@ patch_tray_inplace_update() {
|
||||
# tests, so binding to the wrong site is silently broken. Bail if
|
||||
# upstream ever ships >1 declaration site instead of taking the
|
||||
# first one.
|
||||
enabled_count=$(grep -cE \
|
||||
'const \w+\s*=\s*\w+\("menuBarEnabled"\)' "$index_js")
|
||||
enabled_count=$(grep -cP \
|
||||
'const [$\w]+\s*=\s*[$\w]+\("menuBarEnabled"\)' "$index_js")
|
||||
if [[ $enabled_count -ne 1 ]]; then
|
||||
echo " Expected 1 menuBarEnabled declaration, found" \
|
||||
"${enabled_count} — skipping"
|
||||
@@ -157,7 +180,7 @@ patch_tray_inplace_update() {
|
||||
return
|
||||
fi
|
||||
enabled_var=$(grep -oP \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' "$index_js")
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' "$index_js")
|
||||
if [[ -z $enabled_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled var — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -236,7 +259,7 @@ patch_menu_bar_default() {
|
||||
|
||||
local menu_bar_var
|
||||
menu_bar_var=$(grep -oP \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' \
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_bar_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled variable name'
|
||||
|
||||
@@ -22,32 +22,51 @@ check_dependencies() {
|
||||
rpm) all_deps="$all_deps rpmbuild" ;;
|
||||
esac
|
||||
|
||||
# node-pty has a native C++ module compiled via node-gyp during
|
||||
# `npm install`. Without gcc/g++/make/python3 the install silently
|
||||
# emits a warning, leaves pty_src_dir empty, and the build ends up
|
||||
# shipping the upstream Windows binaries (the #401 failure mode).
|
||||
# Skip when --node-pty-dir is set (Nix and explicit overrides bring
|
||||
# their own pre-built node-pty).
|
||||
if [[ -z ${node_pty_dir:-} ]]; then
|
||||
all_deps="$all_deps gcc g++ make python3"
|
||||
fi
|
||||
|
||||
# Command-to-package mappings per distro family
|
||||
declare -A debian_pkgs=(
|
||||
[p7zip]='p7zip-full' [wget]='wget' [wrestool]='icoutils'
|
||||
[icotool]='icoutils' [convert]='imagemagick'
|
||||
[dpkg-deb]='dpkg-dev' [rpmbuild]='rpm'
|
||||
[gcc]='build-essential' [g++]='build-essential'
|
||||
[make]='build-essential' [python3]='python3'
|
||||
)
|
||||
declare -A rpm_pkgs=(
|
||||
[p7zip]='p7zip p7zip-plugins' [wget]='wget' [wrestool]='icoutils'
|
||||
[icotool]='icoutils' [convert]='ImageMagick'
|
||||
[dpkg-deb]='dpkg' [rpmbuild]='rpm-build'
|
||||
[gcc]='gcc' [g++]='gcc-c++'
|
||||
[make]='make' [python3]='python3'
|
||||
)
|
||||
|
||||
local cmd
|
||||
local cmd pkg
|
||||
for cmd in $all_deps; do
|
||||
if ! check_command "$cmd"; then
|
||||
case "$distro_family" in
|
||||
debian)
|
||||
deps_to_install="$deps_to_install ${debian_pkgs[$cmd]}"
|
||||
;;
|
||||
rpm)
|
||||
deps_to_install="$deps_to_install ${rpm_pkgs[$cmd]}"
|
||||
;;
|
||||
debian) pkg="${debian_pkgs[$cmd]}" ;;
|
||||
rpm) pkg="${rpm_pkgs[$cmd]}" ;;
|
||||
*)
|
||||
echo "Warning: Cannot auto-install '$cmd' on unknown distro. Please install manually." >&2
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
# Several commands map to the same package (gcc/g++/make
|
||||
# -> build-essential, wrestool/icotool -> icoutils). Skip
|
||||
# if the package is already queued so the log line stays
|
||||
# readable.
|
||||
case " $deps_to_install " in
|
||||
*" $pkg "*) ;;
|
||||
*) deps_to_install="$deps_to_install $pkg" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -198,6 +217,13 @@ setup_nodejs() {
|
||||
setup_electron_asar() {
|
||||
section_header 'Electron & Asar Handling'
|
||||
|
||||
# Pin Electron to the exact version upstream Claude Desktop ships
|
||||
# (build-reference/app-extracted/package.json). The shipped app.asar
|
||||
# binds to specific V8/NAPI ABI, Chromium pairing, and node-pty
|
||||
# native surface — running a different Electron major against this
|
||||
# asar is unsupported. Bump when upstream bumps.
|
||||
local electron_version='41.5.0'
|
||||
|
||||
echo "Ensuring local Electron and Asar installation in $work_dir..."
|
||||
cd "$work_dir" || exit 1
|
||||
|
||||
@@ -214,19 +240,91 @@ setup_electron_asar() {
|
||||
[[ ! -f $asar_bin_path ]] && echo 'Asar binary not found.' && install_needed=true
|
||||
|
||||
if [[ $install_needed == true ]]; then
|
||||
echo "Installing Electron and Asar locally into $work_dir..."
|
||||
if ! npm install --no-save electron @electron/asar; then
|
||||
echo "Installing electron@${electron_version} and Asar locally into $work_dir..."
|
||||
if ! npm install --no-save \
|
||||
"electron@${electron_version}" @electron/asar @electron/get extract-zip; then
|
||||
echo 'Failed to install Electron and/or Asar locally.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'Electron and Asar installation command finished.'
|
||||
|
||||
# electron@42+ no longer ships a postinstall script that fetches
|
||||
# the prebuilt binary into dist/. If npm didn't populate it,
|
||||
# fetch the matching binary explicitly via @electron/get. See
|
||||
# #584. Retry once on transient CDN failures (503, network drops).
|
||||
#
|
||||
# Check for the binary itself (not just the dist/ directory),
|
||||
# because under Node 24 the extract-zip step in both the npm
|
||||
# postinstall (electron <42 path) and @electron/get can silently
|
||||
# no-op — leaving an empty dist/locales/ behind, which would pass
|
||||
# a bare `-d` check while no electron binary actually landed.
|
||||
if [[ ! -f $electron_dist_path/electron ]]; then
|
||||
echo 'Electron dist/electron missing; fetching binary explicitly...'
|
||||
local fetch_ok=false
|
||||
local fetch_attempts=0
|
||||
while ! node "$project_root/scripts/setup/fetch-electron-binary.js"; do
|
||||
fetch_attempts=$((fetch_attempts + 1))
|
||||
if (( fetch_attempts >= 2 )); then
|
||||
echo 'Failed to fetch Electron binary via @electron/get after 2 attempts.' >&2
|
||||
echo 'For air-gapped or mirrored builds set ELECTRON_MIRROR or ELECTRON_CUSTOM_DIR; see docs/building.md.' >&2
|
||||
break
|
||||
fi
|
||||
echo "Retrying Electron binary fetch (attempt $((fetch_attempts + 1))/2)..."
|
||||
sleep 2
|
||||
done
|
||||
if (( fetch_attempts < 2 )); then
|
||||
fetch_ok=true
|
||||
fi
|
||||
|
||||
# Final fallback: even when @electron/get reports success,
|
||||
# extract-zip can leave dist/ empty under Node 24 (the
|
||||
# unzip stream resolves without writing files). If we still
|
||||
# have no binary, the cache zip was downloaded successfully
|
||||
# — unpack it with system `unzip`.
|
||||
if [[ ! -f $electron_dist_path/electron ]]; then
|
||||
if [[ $fetch_ok == false ]]; then
|
||||
echo 'Electron download failed; no cached zip to fall back on.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'extract-zip path produced no binary; unpacking @electron/get cache with system unzip...'
|
||||
local electron_cache_dir="$HOME/.cache/electron"
|
||||
local electron_arch
|
||||
case $architecture in
|
||||
amd64) electron_arch='x64' ;;
|
||||
arm64) electron_arch='arm64' ;;
|
||||
*) electron_arch='x64' ;;
|
||||
esac
|
||||
local cached_zip
|
||||
cached_zip=$(find "$electron_cache_dir" -name "electron-v${electron_version}-linux-${electron_arch}.zip" 2>/dev/null | head -1)
|
||||
if [[ -z $cached_zip ]]; then
|
||||
echo "No cached zip matching electron-v${electron_version}-linux-*.zip under $electron_cache_dir" >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
echo "unzip not installed; cannot apply final fallback. Install unzip and retry, or upgrade extract-zip upstream." >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$electron_dist_path"
|
||||
if ! unzip -oq "$cached_zip" -d "$electron_dist_path"; then
|
||||
echo 'unzip fallback failed.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
printf 'v%s\n' "$electron_version" > "$electron_dist_path/version"
|
||||
printf 'electron\n' > "$work_dir/node_modules/electron/path.txt"
|
||||
echo "unzip fallback populated $electron_dist_path ($(du -sh "$electron_dist_path" | awk '{print $1}'))"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo 'Local Electron distribution and Asar binary already present.'
|
||||
fi
|
||||
|
||||
if [[ -d $electron_dist_path ]]; then
|
||||
echo "Found Electron distribution directory at $electron_dist_path."
|
||||
if [[ -f $electron_dist_path/electron ]]; then
|
||||
echo "Found Electron binary at $electron_dist_path."
|
||||
chosen_electron_module_path="$(realpath "$work_dir/node_modules/electron")"
|
||||
echo "Setting Electron module path for copying to $chosen_electron_module_path."
|
||||
else
|
||||
|
||||
@@ -24,15 +24,15 @@ detect_architecture() {
|
||||
|
||||
case "$raw_arch" in
|
||||
x86_64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='e619c7bd3b6746a7307ebefe509bfe447a143aed97e6c7f666677b36a6b6ba54'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe'
|
||||
claude_exe_sha256='9e6e62db0b1e49d99e423b943556cc43c57851e11367d968a7ac01faf7e140cb'
|
||||
architecture='amd64'
|
||||
claude_exe_filename='Claude-Setup-x64.exe'
|
||||
echo 'Configured for amd64 (x86_64) build.'
|
||||
;;
|
||||
aarch64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='bf7de5d6c012542feadf7d5caa77a72dfcea1b24512e9a6d28e004ba4ae2a11d'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.11847.5/Claude-9692f0b44ffa0158a501a91309e361c0d48ed8e4.exe'
|
||||
claude_exe_sha256='68209e252f134ce3a0b6f42cb4b08ec5201962a159c34bc6282fffbd97a30eef'
|
||||
architecture='arm64'
|
||||
claude_exe_filename='Claude-Setup-arm64.exe'
|
||||
echo 'Configured for arm64 (aarch64) build.'
|
||||
|
||||
82
scripts/setup/fetch-electron-binary.js
Normal file
82
scripts/setup/fetch-electron-binary.js
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// Fetches the Electron prebuilt binary into node_modules/electron/dist/.
|
||||
//
|
||||
// electron@42.0.0 (2026-05-06) removed the postinstall script that
|
||||
// historically populated dist/ during `npm install`. This helper restores
|
||||
// that behavior using @electron/get + extract-zip, so the rest of the
|
||||
// build pipeline (which depends on the dist/ layout) keeps working.
|
||||
//
|
||||
// Run from the directory containing node_modules/electron. Reads the
|
||||
// installed electron version from its package.json and downloads the
|
||||
// matching binary for the host platform/arch.
|
||||
//
|
||||
// See: https://github.com/aaddrick/claude-desktop-debian/issues/584
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
async function main() {
|
||||
const cwd = process.cwd();
|
||||
const electronModuleDir = path.join(cwd, 'node_modules', 'electron');
|
||||
const distDir = path.join(electronModuleDir, 'dist');
|
||||
|
||||
if (!fs.existsSync(electronModuleDir)) {
|
||||
throw new Error(
|
||||
`Electron module not found at ${electronModuleDir}; ` +
|
||||
"run 'npm install electron' first.",
|
||||
);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(electronModuleDir, 'package.json');
|
||||
const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!version) {
|
||||
throw new Error(`Could not read version from ${pkgPath}`);
|
||||
}
|
||||
|
||||
const platform = 'linux';
|
||||
// node's process.arch values map cleanly to electron release archs,
|
||||
// except 'arm' which electron publishes as 'armv7l'.
|
||||
const arch = process.arch === 'arm' ? 'armv7l' : process.arch;
|
||||
|
||||
const supportedArchs = ['x64', 'arm64', 'armv7l', 'ia32'];
|
||||
if (!supportedArchs.includes(arch)) {
|
||||
throw new Error(
|
||||
`Unsupported architecture: ${arch}. ` +
|
||||
`Electron publishes Linux binaries for ${supportedArchs.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve @electron/get and extract-zip from the work-dir's
|
||||
// node_modules. The script lives at scripts/setup/ so a plain
|
||||
// require() walks up from there and never sees work_dir/.
|
||||
const workDirRequire = createRequire(path.join(cwd, 'package.json'));
|
||||
const { downloadArtifact } = workDirRequire('@electron/get');
|
||||
const extractZip = workDirRequire('extract-zip');
|
||||
|
||||
console.log(`Fetching electron@${version} for ${platform}-${arch}...`);
|
||||
const zipPath = await downloadArtifact({
|
||||
version,
|
||||
platform,
|
||||
arch,
|
||||
artifactName: 'electron',
|
||||
});
|
||||
|
||||
console.log(`Extracting ${zipPath} into ${distDir}`);
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
await extractZip(zipPath, { dir: distDir });
|
||||
|
||||
const electronBin = path.join(distDir, 'electron');
|
||||
if (fs.existsSync(electronBin)) {
|
||||
fs.chmodSync(electronBin, 0o755);
|
||||
}
|
||||
|
||||
console.log('Electron binary fetched and extracted successfully.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
200
scripts/verify-patches.sh
Executable file
200
scripts/verify-patches.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-patches.sh
|
||||
#
|
||||
# Static-greps a patched index.js for the patch markers defined in
|
||||
# a TSV (defaults to scripts/cowork-patch-markers.tsv). Exits non-zero
|
||||
# on any miss and names the missing markers in the output.
|
||||
#
|
||||
# Defends against silent half-patched asars (issue #559 D6, PR #555).
|
||||
# Reusable for non-cowork patch sets — pass any TSV of the same shape
|
||||
# via the second arg.
|
||||
#
|
||||
# Usage:
|
||||
# verify-patches.sh <path> [markers-tsv]
|
||||
#
|
||||
# <path> may be:
|
||||
# * a JavaScript file (the index.js itself)
|
||||
# * an .asar archive (extracted on the fly via npx @electron/asar)
|
||||
# * a directory containing app.asar.contents/.vite/build/index.js
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — every marker present.
|
||||
# 1 — usage error or input not found.
|
||||
# 2 — one or more markers missing (named on stderr).
|
||||
#
|
||||
|
||||
set -u
|
||||
IFS=$'\n\t'
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
default_markers_tsv="$script_dir/cowork-patch-markers.tsv"
|
||||
markers_tsv="$default_markers_tsv"
|
||||
|
||||
usage() {
|
||||
cat <<-EOF >&2
|
||||
Usage: $(basename "$0") <path> [markers-tsv]
|
||||
|
||||
<path> may be a .js file, an .asar archive, or a directory
|
||||
containing app.asar.contents/.vite/build/index.js. The script
|
||||
greps for patch markers (default: cowork, PR #555 / issue #559
|
||||
D6) and exits non-zero if any are missing.
|
||||
|
||||
[markers-tsv] overrides the default TSV so the same script can
|
||||
verify other patch sets.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse the marker TSV into three parallel arrays. Skips comments
|
||||
# and blank lines. Used by both the verify path here and by the
|
||||
# BATS test, which sources this script (see _is_sourced below) to
|
||||
# share parsing and avoid drift between the two consumers.
|
||||
load_markers() {
|
||||
marker_names=()
|
||||
marker_patterns=()
|
||||
marker_samples=()
|
||||
|
||||
if [[ ! -f $markers_tsv ]]; then
|
||||
echo "verify-patches: marker file not found:" \
|
||||
"$markers_tsv" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local name pattern sample
|
||||
while IFS=$'\t' read -r name pattern sample; do
|
||||
[[ -z $name || $name == '#'* ]] && continue
|
||||
if [[ -z ${pattern:-} || -z ${sample:-} ]]; then
|
||||
echo "verify-patches: malformed row '$name'" \
|
||||
'in markers file' >&2
|
||||
return 1
|
||||
fi
|
||||
marker_names+=("$name")
|
||||
marker_patterns+=("$pattern")
|
||||
marker_samples+=("$sample")
|
||||
done < "$markers_tsv"
|
||||
|
||||
if [[ ${#marker_names[@]} -eq 0 ]]; then
|
||||
echo 'verify-patches: no markers loaded' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the input path to an actual index.js. For .asar inputs,
|
||||
# extracts to a temp dir and echoes the inner index.js path. The
|
||||
# caller cleans up via cleanup_tmp.
|
||||
tmp_extract_dir=''
|
||||
cleanup_tmp() {
|
||||
if [[ -n $tmp_extract_dir && -d $tmp_extract_dir ]]; then
|
||||
rm -rf "$tmp_extract_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup_tmp EXIT
|
||||
|
||||
resolve_index_js() {
|
||||
local input="$1"
|
||||
|
||||
if [[ ! -e $input ]]; then
|
||||
echo "verify-patches: not found: $input" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -d $input ]]; then
|
||||
local candidate="$input/app.asar.contents/.vite/build/index.js"
|
||||
if [[ -f $candidate ]]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
echo "verify-patches: directory does not contain" \
|
||||
"app.asar.contents/.vite/build/index.js: $input" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $input == *.asar ]]; then
|
||||
if ! command -v npx > /dev/null 2>&1; then
|
||||
echo 'verify-patches: npx not found; install Node.js' \
|
||||
'or pre-extract the asar' >&2
|
||||
return 1
|
||||
fi
|
||||
tmp_extract_dir="$(mktemp -d)"
|
||||
if ! npx --yes @electron/asar extract "$input" \
|
||||
"$tmp_extract_dir" > /dev/null 2>&1; then
|
||||
echo "verify-patches: asar extraction failed:" \
|
||||
"$input" >&2
|
||||
return 1
|
||||
fi
|
||||
local extracted="$tmp_extract_dir/.vite/build/index.js"
|
||||
if [[ ! -f $extracted ]]; then
|
||||
echo 'verify-patches: extracted asar lacks' \
|
||||
'.vite/build/index.js' >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$extracted"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Treat as a JS file (.js or any other extension) — let grep
|
||||
# decide whether the contents are sensible.
|
||||
printf '%s\n' "$input"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
-h | --help)
|
||||
usage
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $# -eq 2 ]]; then
|
||||
markers_tsv="$2"
|
||||
fi
|
||||
|
||||
local index_js
|
||||
if ! index_js="$(resolve_index_js "$1")"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! load_markers; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Verifying patch markers in: $index_js"
|
||||
echo "Marker source: $markers_tsv"
|
||||
|
||||
local i missing_names=()
|
||||
for i in "${!marker_names[@]}"; do
|
||||
if grep -qP -- "${marker_patterns[$i]}" "$index_js"; then
|
||||
printf ' OK %s\n' "${marker_names[$i]}"
|
||||
else
|
||||
printf ' MISS %s\n' "${marker_names[$i]}" >&2
|
||||
missing_names+=("${marker_names[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_names[@]} -gt 0 ]]; then
|
||||
local joined
|
||||
joined="$(IFS=','; printf '%s' "${missing_names[*]}")"
|
||||
printf '\nverify-patches: %d/%d markers missing: %s\n' \
|
||||
"${#missing_names[@]}" "${#marker_names[@]}" "$joined" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
printf '\nAll %d patch markers present.\n' \
|
||||
"${#marker_names[@]}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Library mode: when sourced (BATS test), expose load_markers and
|
||||
# the markers_tsv path without running main.
|
||||
_is_sourced() {
|
||||
[[ ${BASH_SOURCE[0]} != "${0}" ]]
|
||||
}
|
||||
|
||||
if ! _is_sourced; then
|
||||
main "$@"
|
||||
fi
|
||||
614
tests/doctor.bats
Normal file
614
tests/doctor.bats
Normal file
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# doctor.bats
|
||||
# Tests for diagnostic helpers in scripts/doctor.sh
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
export HOME="$TEST_TMP/home"
|
||||
export XDG_CACHE_HOME="$TEST_TMP/cache"
|
||||
export XDG_CONFIG_HOME="$TEST_TMP/config"
|
||||
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
|
||||
|
||||
# Clear all input/display vars to avoid host-state leakage
|
||||
unset DISPLAY
|
||||
unset WAYLAND_DISPLAY
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset GTK_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
|
||||
# shellcheck source=scripts/doctor.sh
|
||||
source "$SCRIPT_DIR/../scripts/doctor.sh"
|
||||
|
||||
_doctor_colors
|
||||
_doctor_failures=0
|
||||
|
||||
# Default _pkg_installed to "unknown" (rc=2) so tests don't have
|
||||
# to stub it unless they're exercising the package-check branch.
|
||||
# Override in-test for rc=0 (installed) or rc=1 (missing).
|
||||
_pkg_installed() { return 2; }
|
||||
|
||||
# Default stub for _detect_password_store (defined in
|
||||
# launcher-common.sh, not sourced here). Tests that exercise
|
||||
# _doctor_check_password_store override this in-test if needed.
|
||||
_detect_password_store() { echo 'basic'; }
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Make `command -v gtk-query-immodules-3.0` report "not found" so the
|
||||
# immodules cache check is skipped. Used by tests that aren't
|
||||
# exercising the cache branch but reach it because no earlier gate
|
||||
# fires. `command -v` finds bash functions too, so just unsetting a
|
||||
# stub function isn't enough — we shadow `command` itself.
|
||||
_skip_gtk_query() {
|
||||
command() {
|
||||
if [[ $1 == '-v' && $2 == 'gtk-query-immodules-3.0' ]]; then
|
||||
return 1
|
||||
fi
|
||||
builtin command "$@"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _cowork_pkg_hint: ibus-gtk3 mapping (#550)
|
||||
# =============================================================================
|
||||
|
||||
@test "_cowork_pkg_hint: debian maps ibus-gtk3 to ibus-gtk3 via apt" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint debian ibus-gtk3)
|
||||
[[ $result == "sudo apt install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: fedora maps ibus-gtk3 to ibus-gtk3 via dnf" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint fedora ibus-gtk3)
|
||||
[[ $result == "sudo dnf install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: arch maps ibus-gtk3 to ibus (bundled)" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint arch ibus-gtk3)
|
||||
[[ $result == "sudo pacman -S ibus" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: CLAUDE_GTK_IM_MODULE override visibility
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits override line when CLAUDE_GTK_IM_MODULE set" {
|
||||
# CLAUDE_GTK_IM_MODULE makes active_im non-empty, so we'd reach
|
||||
# the cache check — skip it to keep this test focused.
|
||||
_skip_gtk_query
|
||||
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'CLAUDE_GTK_IM_MODULE=xim'* ]]
|
||||
[[ $output == *'overrides GTK_IM_MODULE for Electron'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no override line when CLAUDE_GTK_IM_MODULE unset" {
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'CLAUDE_GTK_IM_MODULE'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: XWayland-with-IBus routing note
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits XWayland note when wayland session and CLAUDE_USE_WAYLAND unset" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
# CLAUDE_USE_WAYLAND deliberately unset
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'XWayland'* ]]
|
||||
[[ $output == *'CLAUDE_USE_WAYLAND=1'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note when CLAUDE_USE_WAYLAND=1" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note on X11 session" {
|
||||
XDG_SESSION_TYPE='x11'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: ibus-gtk3 package check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when ibus selected but ibus-gtk3 missing" {
|
||||
# Package not installed (rc=1, definitive answer)
|
||||
_pkg_installed() { return 1; }
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'ibus-gtk3 is not installed'* ]]
|
||||
[[ $output == *'sudo apt install ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when ibus selected and ibus-gtk3 present" {
|
||||
# Package installed (rc=0); cache lists ibus.
|
||||
_pkg_installed() { return 0; }
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"ibus" "IBus" "ibus" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning when active module isn't ibus" {
|
||||
# Even with rc=1 for ibus-gtk3, the package check should be
|
||||
# skipped entirely when GTK_IM_MODULE isn't ibus.
|
||||
_pkg_installed() { return 1; }
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning on unsupported distro (rc=2)" {
|
||||
# Default _pkg_installed (rc=2) — no warning even with ibus.
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules unknown
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: immodules cache check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when GTK_IM_MODULE not in immodules cache" {
|
||||
# gtk-query-immodules-3.0 lists xim but not fcitx
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *"'fcitx' not listed"* ]]
|
||||
[[ $output == *'gtk-query-immodules-3.0 --update-cache'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when active module is in cache" {
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: skips cache check when gtk-query-immodules-3.0 missing" {
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'cache may be stale'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: CLAUDE_GTK_IM_MODULE takes precedence as active module" {
|
||||
# Cache lists xim but not ibus. CLAUDE_GTK_IM_MODULE=xim should
|
||||
# win over GTK_IM_MODULE=ibus, so no cache warning fires.
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no checks fire when no IM module selected" {
|
||||
# Neither GTK_IM_MODULE nor CLAUDE_GTK_IM_MODULE set — function
|
||||
# should return early before the package or cache checks.
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_recent_crashes: GPU FATAL crash counter (#583)
|
||||
# =============================================================================
|
||||
|
||||
# Install a coredumpctl shim. $1 is the coredumpctl-list-style
|
||||
# multi-line output to emit (header + entry rows). The shim ignores
|
||||
# its arguments — tests don't exercise the filter syntax.
|
||||
_install_coredumpctl_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/coredumpctl" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
$1
|
||||
OUT
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/coredumpctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: no coredumpctl on PATH — silent" {
|
||||
# Force coredumpctl off PATH so the helper short-circuits.
|
||||
# Restore PATH before returning so teardown's rm works.
|
||||
local saved_path="$PATH"
|
||||
export PATH="/no-such-dir-for-test"
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
export PATH="$saved_path"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: zero crashes — silent" {
|
||||
# Listing has the header line only, no entry rows.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 1 crash — info line, no warn" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 3+ crashes — warn + #583 pointer" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M
|
||||
Mon 2026-05-04 07:44:48 EDT 930532 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 22.8M
|
||||
Sun 2026-05-03 14:34:10 EDT 567221 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 12.4M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'Recent Electron crashes: 3'* ]]
|
||||
[[ $output == *'CLAUDE_DISABLE_GPU=1'* ]]
|
||||
[[ $output == *'/issues/583'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: path mismatch falls back with footnote" {
|
||||
# Three crashes from a DIFFERENT electron binary (e.g., Slack).
|
||||
# Caller passes claude-desktop's electron path, which doesn't
|
||||
# match — helper falls back to total count and adds the footnote
|
||||
# so the user knows the count may be cross-app.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 09:00:00 EDT 200001 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-05 09:00:00 EDT 200002 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-04 09:00:00 EDT 200003 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: empty electron_path falls back" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
# Caller didn't pass an electron_path — helper still counts and
|
||||
# emits the info line based on the unfiltered total.
|
||||
run _doctor_check_recent_crashes ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_filename_limit: NAME_MAX probe + eCryptfs hint (#590)
|
||||
# =============================================================================
|
||||
|
||||
# Install a getconf shim that emits $1 on stdout. Empty $1 → shim exits 1
|
||||
# so callers can test the "getconf failed" path.
|
||||
_install_getconf_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local value="$1"
|
||||
if [[ -z $value ]]; then
|
||||
cat > "$TEST_TMP/bin/getconf" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/getconf" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
echo ${value}
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/getconf"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
# Install a df shim that emits a single-column fstype listing matching
|
||||
# the `df --output=fstype` shape the helper relies on. Empty $1 → shim
|
||||
# exits 1 so callers can test the "df failed" path.
|
||||
_install_df_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local fstype="$1"
|
||||
if [[ -z $fstype ]]; then
|
||||
cat > "$TEST_TMP/bin/df" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/df" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
Type
|
||||
${fstype}
|
||||
OUT
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/df"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when NAME_MAX >= 200" {
|
||||
_install_getconf_shim '255'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: warns when NAME_MAX < 200" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ext4'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'#590'* ]]
|
||||
# Non-ecryptfs fs: no LUKS hint
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: eCryptfs adds LUKS workaround hint" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ecryptfs'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'eCryptfs'* ]]
|
||||
[[ $output == *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent on non-numeric getconf output" {
|
||||
_install_getconf_shim 'undefined'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when getconf fails" {
|
||||
_install_getconf_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: df failure suppresses eCryptfs hint, keeps warn" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_password_store: output contains 'Password store:' with a valid backend" {
|
||||
# setup() already stubs _detect_password_store to return 'basic'.
|
||||
run _doctor_check_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Password store:'* ]]
|
||||
[[ $output == *'basic'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_password_store: warns, not PASS, when detection returns empty" {
|
||||
# An empty backend means detection failed (e.g. sourcing-order
|
||||
# regression) — it must not surface as a green PASS with a blank value.
|
||||
_detect_password_store() { echo ''; }
|
||||
run _doctor_check_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_disk_space
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_disk_space: fails when under 100MB free" {
|
||||
df() { printf 'Avail\n50M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[FAIL]'* ]]
|
||||
[[ $output == *'50MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: warns when under 500MB free" {
|
||||
df() { printf 'Avail\n300M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'300MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: warns at exactly 100MB (tier boundary)" {
|
||||
# 100 is not < 100, so the FAIL tier must not fire; < 500 → WARN.
|
||||
df() { printf 'Avail\n100M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output != *'[FAIL]'* ]]
|
||||
[[ $output == *'100MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: passes at exactly 500MB (tier boundary)" {
|
||||
# 500 is not < 500, so the WARN tier must not fire → PASS.
|
||||
df() { printf 'Avail\n500M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output == *'500MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: no false PASS on leading-zero df output" {
|
||||
# '0099' clears the numeric regex but would make (( )) parse the
|
||||
# value as octal and error out, falling through to the PASS
|
||||
# branch. The 10# normalization must read it as 99 → FAIL tier.
|
||||
df() { printf 'Avail\n0099M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[FAIL]'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
[[ $output == *'99MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: passes with ample free space" {
|
||||
df() { printf 'Avail\n2048M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'2048MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: no false PASS on non-numeric df output" {
|
||||
# A malformed/empty avail field must not slip through as a PASS,
|
||||
# and the skip must be visible rather than hiding behind a clean
|
||||
# summary.
|
||||
df() { printf 'Avail\nN/A\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
[[ $output != *'[FAIL]'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output == *'Disk space: unable to read (df)'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: visible skip when df is unavailable" {
|
||||
df() { return 127; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Disk space: unable to read (df)'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
[[ $output != *'[FAIL]'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_pkg_version: package-manager ownership (#711)
|
||||
# =============================================================================
|
||||
|
||||
# Make `command -v` report the named package tools (rpm, dpkg-query)
|
||||
# as missing so tests can simulate single-manager or tool-less hosts
|
||||
# regardless of what the CI/dev box really has installed. Same shadow
|
||||
# trick as _skip_gtk_query: `command -v` finds functions too, so
|
||||
# shadowing `command` itself is the only reliable way.
|
||||
_hide_pkg_tools() {
|
||||
_hidden_pkg_tools=" $* "
|
||||
command() {
|
||||
if [[ $1 == '-v' \
|
||||
&& $_hidden_pkg_tools == *" $2 "* ]]; then
|
||||
return 1
|
||||
fi
|
||||
builtin command "$@"
|
||||
}
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: rpm owns the path — rpm version wins over stale dpkg record (#711)" {
|
||||
# The #711 repro: Fedora host, rpm owns the install, but a stale
|
||||
# dpkg record from an old deb experiment still answers. The rpm
|
||||
# answer must win; the stale dpkg version must not appear at all.
|
||||
rpm() { printf '1.11847.5-2.0.19'; }
|
||||
dpkg-query() { printf '1.5354.0'; }
|
||||
|
||||
run _doctor_check_pkg_version \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Installed version: 1.11847.5-2.0.19'* ]]
|
||||
[[ $output != *'1.5354.0'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: dpkg-only host reports dpkg version" {
|
||||
_hide_pkg_tools rpm
|
||||
dpkg-query() { printf '1.11847.5'; }
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Installed version: 1.11847.5'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: dual-DB host where rpm does not own the path falls back to dpkg" {
|
||||
# rpm exists but the install is a real deb: `rpm -qf` says "not
|
||||
# owned" (rc=1, message on stdout) and dpkg must be consulted.
|
||||
rpm() {
|
||||
# $4 = probe path ($1=-qf $2=--qf $3=<format>)
|
||||
printf 'file %s is not owned by any package\n' "$4"
|
||||
return 1
|
||||
}
|
||||
dpkg-query() { printf '1.11847.5'; }
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Installed version: 1.11847.5'* ]]
|
||||
[[ $output != *'not owned'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: neither manager owns the install — warn (AppImage/Nix)" {
|
||||
rpm() { return 1; }
|
||||
dpkg-query() { return 1; }
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'AppImage'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: silent when no package tools exist" {
|
||||
_hide_pkg_tools rpm dpkg-query
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
@@ -18,6 +18,46 @@ has_electron_arg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Count how many electron_args entries start with --enable-features=.
|
||||
# Chromium honours only the last such switch, so the launcher must emit
|
||||
# exactly one; this lets tests assert that invariant.
|
||||
count_enable_features() {
|
||||
local n=0 arg
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == --enable-features=* ]] && ((n++))
|
||||
done
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
# Install a dbus-send stub at the front of PATH.
|
||||
# kwallet6 — echoes 'boolean true', exits 0 (kwallet6 detectable)
|
||||
# secrets-ok — fails for kwalletd6 dest, succeeds for all other dests
|
||||
# fail — always exits 1 with no output (no keyring accessible)
|
||||
_stub_dbus_send() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
case "${1:-fail}" in
|
||||
kwallet6)
|
||||
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
echo 'boolean true'
|
||||
STUB
|
||||
;;
|
||||
secrets-ok)
|
||||
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
[[ "$*" == *kwalletd6* ]] && exit 1
|
||||
exit 0
|
||||
STUB
|
||||
;;
|
||||
*)
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' \
|
||||
> "$TEST_TMP/bin/dbus-send"
|
||||
;;
|
||||
esac
|
||||
chmod +x "$TEST_TMP/bin/dbus-send"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
@@ -35,13 +75,25 @@ setup() {
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset NIRI_SOCKET
|
||||
unset XDG_CURRENT_DESKTOP
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_MENU_BAR
|
||||
unset CLAUDE_TITLEBAR_STYLE
|
||||
unset COWORK_VM_BACKEND
|
||||
unset ELECTRON_USE_SYSTEM_TITLE_BAR
|
||||
unset GTK_IM_MODULE
|
||||
unset XMODIFIERS
|
||||
unset QT_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
|
||||
# Copy to temp dir so we can substitute the build-time placeholder
|
||||
# and co-locate doctor.sh (sourced via BASH_SOURCE dirname).
|
||||
cp "$SCRIPT_DIR/../scripts/launcher-common.sh" "$TEST_TMP/launcher-common.sh"
|
||||
cp "$SCRIPT_DIR/../scripts/doctor.sh" "$TEST_TMP/doctor.sh"
|
||||
sed -i 's/@@WM_CLASS@@/Claude/' "$TEST_TMP/launcher-common.sh"
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
source "$TEST_TMP/launcher-common.sh"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
@@ -86,6 +138,70 @@ teardown() {
|
||||
[[ "${lines[1]}" == "test message two" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# log_session_env
|
||||
# =============================================================================
|
||||
|
||||
@test "log_session_env: emits env={ ... } block with all required keys" {
|
||||
setup_logging
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
WAYLAND_DISPLAY='wayland-0'
|
||||
DISPLAY=':0'
|
||||
XDG_CURRENT_DESKTOP='KDE'
|
||||
GTK_IM_MODULE='ibus'
|
||||
XMODIFIERS='@im=ibus'
|
||||
QT_IM_MODULE='ibus'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
CLAUDE_TITLEBAR_STYLE='hybrid'
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
CLAUDE_DISABLE_GPU='1'
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match locks block structure (open/close braces on
|
||||
# their own lines) and per-key formatting in one pass.
|
||||
[[ "${lines[0]}" == 'env={' ]]
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=wayland' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=wayland-0' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=:0' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=KDE' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=@im=ibus' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=1' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=hybrid' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=basic' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=xim' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=1' ]]
|
||||
[[ "${lines[13]}" == '}' ]]
|
||||
}
|
||||
|
||||
@test "log_session_env: unset/empty values render as 'KEY=' (no value)" {
|
||||
setup_logging
|
||||
# All vars unset by setup() except this one, which exercises the
|
||||
# empty-string branch (must be indistinguishable from unset).
|
||||
GTK_IM_MODULE=''
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match proves the line ends right after '=' — a
|
||||
# substring like *'KEY='* would also match 'KEY=value'.
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=' ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# check_display
|
||||
# =============================================================================
|
||||
@@ -183,7 +299,7 @@ teardown() {
|
||||
[[ $use_x11_on_wayland == false ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: non-Niri Wayland keeps XWayland default" {
|
||||
@test "detect_display_backend: non-Niri non-GNOME Wayland keeps XWayland default" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="sway"
|
||||
setup_logging
|
||||
@@ -201,10 +317,68 @@ teardown() {
|
||||
[[ $use_x11_on_wayland == false ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: GNOME Wayland keeps XWayland default (not auto-flipped)" {
|
||||
# GNOME native+portal is opt-in only; the default session stays on
|
||||
# mature XWayland to avoid rendering/IME regressions (#404 portal
|
||||
# route is opt-in via CLAUDE_USE_WAYLAND=1).
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="GNOME"
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $is_wayland == true ]]
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: GNOME Wayland + CLAUDE_USE_WAYLAND=1 opts into native" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="ubuntu:GNOME"
|
||||
CLAUDE_USE_WAYLAND=1
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $use_x11_on_wayland == false ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: GNOME on X11 (not Wayland) stays X11" {
|
||||
DISPLAY=":0"
|
||||
XDG_CURRENT_DESKTOP="GNOME"
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $is_wayland == false ]]
|
||||
# use_x11_on_wayland is the default true; the auto-detect block is
|
||||
# guarded by is_wayland so it never flips it on an X11 session.
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on GNOME" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="GNOME"
|
||||
CLAUDE_USE_WAYLAND=0
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $is_wayland == true ]]
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on Niri" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
NIRI_SOCKET="/tmp/niri.sock"
|
||||
CLAUDE_USE_WAYLAND=0
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# build_electron_args
|
||||
# =============================================================================
|
||||
|
||||
@test "build_electron_args: includes --class matching upstream productName" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--class=Claude'
|
||||
}
|
||||
|
||||
@test "build_electron_args: X11 deb - only CustomTitlebar disabled" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
@@ -230,6 +404,17 @@ teardown() {
|
||||
has_electron_arg '--no-sandbox'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland XWayland deb - no GlobalShortcutsPortal feature" {
|
||||
# The portal feature is inert under XWayland, so it must not be
|
||||
# emitted on the X11-via-XWayland path.
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=true
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
# shellcheck disable=SC2314 # last command in test, ! works correctly
|
||||
! has_electron_arg '*GlobalShortcutsPortal*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland native deb - includes wayland platform flags" {
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
@@ -240,6 +425,45 @@ teardown() {
|
||||
has_electron_arg '*WaylandWindowDecorations*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland native deb - enables GlobalShortcutsPortal (#404)" {
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '*GlobalShortcutsPortal*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland native deb - portal + ozone share one --enable-features" {
|
||||
# Chromium honours only the last --enable-features switch, so the
|
||||
# portal feature, UseOzonePlatform and WaylandWindowDecorations must
|
||||
# all live in a single comma-joined flag — not separate switches.
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
# Exactly one --enable-features switch (Chromium honours only the
|
||||
# last), carrying both features. Order inside the value is irrelevant
|
||||
# to Chromium, so assert each subkey independently rather than with an
|
||||
# ordered glob.
|
||||
[[ $(count_enable_features) -eq 1 ]]
|
||||
has_electron_arg '--enable-features=*UseOzonePlatform*'
|
||||
has_electron_arg '--enable-features=*GlobalShortcutsPortal*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: hidden titlebar + native Wayland - one merged --enable-features" {
|
||||
# WindowControlsOverlay (hidden titlebar) and the wayland/portal
|
||||
# features must coexist in a single flag rather than clobber.
|
||||
CLAUDE_TITLEBAR_STYLE=hidden
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
[[ $(count_enable_features) -eq 1 ]]
|
||||
has_electron_arg '*WindowControlsOverlay*'
|
||||
has_electron_arg '*GlobalShortcutsPortal*'
|
||||
has_electron_arg '*WaylandWindowDecorations*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland appimage - always includes --no-sandbox" {
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=true
|
||||
@@ -293,6 +517,48 @@ teardown() {
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set propagates to GTK_IM_MODULE" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
# Override is logged so users can verify it took effect
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set logs <unset> when GTK_IM_MODULE was unset" {
|
||||
setup_logging
|
||||
# GTK_IM_MODULE unset by setup()
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: <unset> -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE unset leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
# CLAUDE_GTK_IM_MODULE unset by setup()
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
# No override line should appear in the log
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE empty leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE=''
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _resolve_titlebar_style
|
||||
# =============================================================================
|
||||
@@ -465,6 +731,219 @@ s.close()
|
||||
[[ ! -S "$sock" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# cleanup_orphaned_cowork_daemon
|
||||
#
|
||||
# Reaps a cowork-vm-service daemon left behind by a crashed UI, but only
|
||||
# when no live Claude UI is running. pgrep/kill/sleep are stubbed; the
|
||||
# "live UI" case uses a real background process so the /proc cmdline and
|
||||
# status reads resolve naturally without faking /proc.
|
||||
# =============================================================================
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: no daemon running — no action, no log" {
|
||||
# Daemon pgrep finds nothing, so the function returns before any
|
||||
# UI scan or kill.
|
||||
pgrep() { return 1; }
|
||||
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
|
||||
|
||||
setup_logging
|
||||
run cleanup_orphaned_cowork_daemon
|
||||
[[ $status -eq 0 ]]
|
||||
[[ ! -f "$TEST_TMP/kills" ]]
|
||||
[[ ! -f $log_file ]]
|
||||
}
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: live UI present — daemon left running" {
|
||||
# A real background process stands in for the live Electron UI so
|
||||
# the /proc cmdline and status reads resolve naturally. The UI
|
||||
# scan fingerprints on the launcher-passed --class flag (since
|
||||
# #700 app.asar no longer appears in any cmdline), so the
|
||||
# stand-in's argv[0] is renamed to carry it via exec -a. Its state
|
||||
# is sleeping (not T/t/Z), so the function treats it as a live UI
|
||||
# and must NOT kill the daemon.
|
||||
bash -c 'exec -a "--class=Claude" sleep 300' &
|
||||
ui_pid=$!
|
||||
|
||||
# Match on "$*", not "$2": the UI scan passes -u <uid> and a `--`
|
||||
# end-of-options separator before the pattern, so the pattern is
|
||||
# not at a fixed argument position.
|
||||
pgrep() {
|
||||
if [[ $* == *cowork-vm-service* ]]; then
|
||||
echo 4242
|
||||
elif [[ $* == *--class=Claude* ]]; then
|
||||
echo "$ui_pid"
|
||||
fi
|
||||
}
|
||||
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
|
||||
|
||||
setup_logging
|
||||
cleanup_orphaned_cowork_daemon
|
||||
local rc=$?
|
||||
builtin kill "$ui_pid" 2>/dev/null
|
||||
|
||||
[[ $rc -eq 0 ]]
|
||||
# Daemon kill must never have been attempted.
|
||||
[[ ! -f "$TEST_TMP/kills" ]]
|
||||
}
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: orphan exits on SIGTERM — no SIGKILL" {
|
||||
# Daemon present, no live UI. The daemon disappears once SIGTERM is
|
||||
# sent, so the escalation to SIGKILL must not fire.
|
||||
local term_sent="$TEST_TMP/term_sent"
|
||||
pgrep() {
|
||||
if [[ $* == *cowork-vm-service* ]]; then
|
||||
[[ -f $term_sent ]] && return 1
|
||||
echo 4242
|
||||
else
|
||||
# UI scan (--class fingerprint): no live UI.
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
kill() {
|
||||
echo "kill $*" >> "$TEST_TMP/kills"
|
||||
# A plain SIGTERM ($1 is the PID, not -KILL) reaps the daemon.
|
||||
[[ $1 == -KILL ]] || : > "$term_sent"
|
||||
}
|
||||
sleep() { :; }
|
||||
|
||||
setup_logging
|
||||
# Via `run` so the function's internal `((_wait++))` (which returns 1
|
||||
# when _wait starts at 0) doesn't trip bats' errexit. Production has
|
||||
# no set -e, so this is a harness concern, not a code defect.
|
||||
run cleanup_orphaned_cowork_daemon
|
||||
|
||||
grep -q 'Killed orphaned cowork-vm-service daemon (PIDs: 4242)' \
|
||||
"$log_file"
|
||||
# Negative assertions via `run` + status: a bare `! grep` that isn't
|
||||
# the last command does not fail a bats test (SC2314), so it would be
|
||||
# a hollow check.
|
||||
run grep -q 'SIGKILL' "$log_file"
|
||||
[[ $status -ne 0 ]]
|
||||
grep -q '^kill 4242$' "$TEST_TMP/kills"
|
||||
run grep -qF -- '-KILL' "$TEST_TMP/kills"
|
||||
[[ $status -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: orphan survives SIGTERM — escalates to SIGKILL" {
|
||||
# Daemon never dies, so after the SIGTERM grace window the function
|
||||
# escalates to SIGKILL and logs the SIGKILL variant.
|
||||
pgrep() {
|
||||
if [[ $* == *cowork-vm-service* ]]; then
|
||||
echo 4242
|
||||
else
|
||||
# UI scan (--class fingerprint): no live UI.
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
|
||||
sleep() { :; }
|
||||
|
||||
setup_logging
|
||||
# `run` for the same errexit reason as the SIGTERM test above.
|
||||
run cleanup_orphaned_cowork_daemon
|
||||
|
||||
grep -q 'Killed orphaned cowork-vm-service daemon (SIGKILL, PIDs: 4242)' \
|
||||
"$log_file"
|
||||
grep -q '^kill 4242$' "$TEST_TMP/kills"
|
||||
grep -q '^kill -KILL 4242$' "$TEST_TMP/kills"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# cleanup_stale_desktop_helpers
|
||||
# =============================================================================
|
||||
|
||||
@test "_desktop_helper_cmdline_matches: matches known Desktop helpers only" {
|
||||
local config_dir="$XDG_CONFIG_HOME/Claude"
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --type=utility --user-data-dir=$config_dir"
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
# tr '\0' ' ' joins cmdline args with a trailing space, so the
|
||||
# --user-data-dir arm anchors on "$config_dir " — exact dir only.
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=$config_dir "
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=${config_dir}Dev "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar.unpacked/cowork-vm-service.js"
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"node $config_dir/Claude Extensions/ant.dir.example/server.js"
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron /usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar"
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"claude --dangerously-skip-permissions"
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/home/scott/dev/dude/core/agent-dude/dist/index.js mcp"
|
||||
[[ $status -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "_claude_desktop_ui_cmdline_matches: keys on the --class fingerprint" {
|
||||
# Live UI: launcher argv carries --class=$WM_CLASS (tr '\0' ' '
|
||||
# leaves every argument space-terminated). Since #700 app.asar no
|
||||
# longer appears in any cmdline, so the --class flag from
|
||||
# build_electron_args is the only stable UI signature.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --class=Claude --enable-features=WaylandWindowDecorations "
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
# Another Electron app's asar path must not match.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/opt/other-electron-app/resources/app.asar "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
# Look-alike WM class is rejected by the trailing-space anchor.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/opt/claude-dev/electron --class=ClaudeDev "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
# Chromium helpers (--type=) never count as the UI, even if a
|
||||
# --class flag leaked into their argv.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --type=utility --user-data-dir=$XDG_CONFIG_HOME/Claude --class=Claude "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
# The cowork daemon never counts as the UI.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar.unpacked/cowork-vm-service.js --class=Claude "
|
||||
[[ $status -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "run_electron_and_cleanup: runs cleanup after Electron exits and preserves status" {
|
||||
local marker="$TEST_TMP/cleanup-ran"
|
||||
local electron="$TEST_TMP/electron"
|
||||
|
||||
cat > "$electron" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
echo "electron argv: $*"
|
||||
exit 7
|
||||
STUB
|
||||
chmod +x "$electron"
|
||||
|
||||
cleanup_after_electron_exit() {
|
||||
touch "$marker"
|
||||
}
|
||||
|
||||
setup_logging
|
||||
run run_electron_and_cleanup "$electron" '--flag' 'value'
|
||||
[[ $status -eq 7 ]]
|
||||
[[ -f $marker ]]
|
||||
run cat "$log_file"
|
||||
[[ $output == *'electron argv: --flag value'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Doctor helper functions
|
||||
# =============================================================================
|
||||
@@ -587,3 +1066,40 @@ s.close()
|
||||
result=$(_electron_version "$TEST_TMP/electron/electron") || true
|
||||
[[ -z $result ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _detect_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_detect_password_store: CLAUDE_PASSWORD_STORE env var wins without calling dbus-send" {
|
||||
CLAUDE_PASSWORD_STORE='mystore'
|
||||
# Stub dbus-send to fail — the early-return path must not reach it.
|
||||
_stub_dbus_send fail
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'mystore' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to kwallet6 when kwallet6 dbus-send call succeeds" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send kwallet6
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'kwallet6' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to gnome-libsecret when kwallet6 fails but secrets ping succeeds" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send secrets-ok
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'gnome-libsecret' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to basic when both dbus-send calls fail" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send fail
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'basic' ]]
|
||||
}
|
||||
|
||||
213
tests/launcher-disable-gpu.bats
Normal file
213
tests/launcher-disable-gpu.bats
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# launcher-disable-gpu.bats
|
||||
# Tests for the CLAUDE_DISABLE_GPU env var handling in
|
||||
# build_electron_args (scripts/launcher-common.sh). The var is an
|
||||
# opt-in workaround for the Chromium GPU process FATAL exhaustion
|
||||
# tracked in #583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
|
||||
# --disable-software-rasterizer; co-occurrence with XRDP must not
|
||||
# stack duplicate flags.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
LAUNCHER_COMMON="${SCRIPT_DIR}/../scripts/launcher-common.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
# loginctl shim — same pattern as launcher-xrdp-detection.bats.
|
||||
# Defaults to a non-XRDP session so CLAUDE_DISABLE_GPU is the
|
||||
# only signal in play unless a test overrides MOCK_LOGINCTL_TYPE.
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/loginctl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${MOCK_LOGINCTL_TYPE:-x11}"
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/loginctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
|
||||
log_file="$TEST_TMP/launcher.log"
|
||||
: > "$log_file"
|
||||
|
||||
unset CLAUDE_DISABLE_GPU
|
||||
unset XRDP_SESSION
|
||||
unset XDG_SESSION_ID
|
||||
unset MOCK_LOGINCTL_TYPE
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$LAUNCHER_COMMON"
|
||||
|
||||
is_wayland=false
|
||||
use_x11_on_wayland=true
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n ${TEST_TMP:-} && -d $TEST_TMP ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
args_contain() {
|
||||
local needle="$1"
|
||||
local arg
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
args_count() {
|
||||
local needle="$1"
|
||||
local arg count=0
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && ((count++))
|
||||
done
|
||||
printf '%d' "$count"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CLAUDE_DISABLE_GPU=1 — flags must be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: CLAUDE_DISABLE_GPU=1 adds flags + logs message" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Co-occurrence with XRDP — no duplicate flags
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: with XRDP_SESSION, flags added exactly once (no dup)" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
export XRDP_SESSION=1
|
||||
export XDG_SESSION_ID=5
|
||||
export MOCK_LOGINCTL_TYPE=xrdp
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
[[ "$(args_count '--disable-gpu')" -eq 1 ]]
|
||||
[[ "$(args_count '--disable-software-rasterizer')" -eq 1 ]]
|
||||
# Both signals should still log (independent diagnostic value),
|
||||
# but only one set of flags should reach electron_args.
|
||||
grep -q 'XRDP session detected' "$log_file"
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Off-states — flags must NOT be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: unset — flags NOT added" {
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
run args_contain '--disable-software-rasterizer'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: empty string — flags NOT added" {
|
||||
export CLAUDE_DISABLE_GPU=''
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =0 — flags NOT added (only literal '1' opts in)" {
|
||||
export CLAUDE_DISABLE_GPU=0
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =true — flags NOT added (no boolean aliases)" {
|
||||
# Documents the strict equality check. If we ever add aliases,
|
||||
# update this test to match. Strict-only matches the existing
|
||||
# CLAUDE_USE_WAYLAND pattern.
|
||||
export CLAUDE_DISABLE_GPU=true
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: prior GPU fatal auto-disables on next launch" {
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start ---
|
||||
LOG
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
|
||||
}
|
||||
|
||||
@test "disable-gpu: recovery stays sticky on launch N+2 (no oscillation)" {
|
||||
# A recovered launch runs with --disable-gpu and writes no GPU
|
||||
# output, so the crash signature alone would re-enable GPU on
|
||||
# launch N+2 (crash/work/crash forever). The launcher's own
|
||||
# "disabling GPU" marker in the penultimate section must keep
|
||||
# recovery tripped.
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start ---
|
||||
Previous launch hit GPU process FATAL - disabling GPU
|
||||
--- Claude Desktop Launcher Start ---
|
||||
LOG
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
}
|
||||
|
||||
@test "disable-gpu: NixOS launcher header sections are detected" {
|
||||
# nix/claude-desktop.nix writes "Launcher Start (NixOS)" headers;
|
||||
# the section regex must match them or recovery silently no-ops
|
||||
# on Nix.
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start (NixOS) ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start (NixOS) ---
|
||||
LOG
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
|
||||
}
|
||||
|
||||
@test "disable-gpu: CLAUDE_DISABLE_GPU=0 suppresses auto fallback" {
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start ---
|
||||
LOG
|
||||
export CLAUDE_DISABLE_GPU=0
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
35
tests/test-artifact-appimage.sh
Normal file → Executable file
35
tests/test-artifact-appimage.sh
Normal file → Executable file
@@ -7,6 +7,16 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=tests/test-artifact-common.sh
|
||||
source "$script_dir/test-artifact-common.sh"
|
||||
|
||||
# Single point of cleanup, set at script scope so any interruption
|
||||
# between resource alloc and normal exit is covered. _launch_smoke_cleanup
|
||||
# (test-artifact-common.sh) reaps an interrupted launch and its temp dirs;
|
||||
# extract_dir is AppImage-specific so it's torn down here.
|
||||
_cleanup() {
|
||||
_launch_smoke_cleanup
|
||||
[[ -n ${extract_dir:-} ]] && rm -rf "$extract_dir"
|
||||
}
|
||||
trap _cleanup EXIT INT TERM
|
||||
|
||||
component_id='io.github.aaddrick.claude-desktop-debian'
|
||||
|
||||
# Find the AppImage file (exclude .zsync)
|
||||
@@ -94,7 +104,30 @@ assert_contains "$appdir/AppRun" 'build_electron_args' \
|
||||
|
||||
# --- App contents (asar) ---
|
||||
resources_dir="$appdir/usr/lib/node_modules/electron/dist/resources"
|
||||
validate_app_contents "$resources_dir"
|
||||
validate_app_contents "$resources_dir" "${component_id}.desktop"
|
||||
|
||||
# --- Doctor smoke test ---
|
||||
# Some --doctor checks fail in CI (no display, etc.); we only care that
|
||||
# the script itself didn't crash via signal or exec failure (>=127).
|
||||
doctor_exit=0
|
||||
"$appimage_file" --doctor >/dev/null 2>&1 || doctor_exit=$?
|
||||
if [[ $doctor_exit -lt 127 ]]; then
|
||||
pass "--doctor runs without crashing (exit: $doctor_exit)"
|
||||
else
|
||||
fail "--doctor crashed (exit: $doctor_exit)"
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# The AppImage runs as the (non-root) CI user, so no privilege drop.
|
||||
# The pkill sweep matches 'mount_claude', not the .AppImage path: a running
|
||||
# AppImage execs Electron from its FUSE mount (/tmp/.mount_claudeXXXX), so
|
||||
# the escaped zygote/electron children live there. Matching the artifact
|
||||
# path would sweep nothing. See CLAUDE.md (`pkill -9 -f "mount_claude"`).
|
||||
# Sweep escaped children only in CI: locally, 'mount_claude' also
|
||||
# matches a developer's live Claude Desktop AppImage session.
|
||||
smoke_sweep=''
|
||||
[[ -n ${CI:-} ]] && smoke_sweep='mount_claude'
|
||||
run_launch_smoke_test 'AppImage' "$smoke_sweep" '' "$appimage_file"
|
||||
|
||||
# --- Cleanup ---
|
||||
rm -rf "$extract_dir"
|
||||
|
||||
@@ -38,6 +38,14 @@ assert_executable() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_setuid() {
|
||||
if [[ -u $1 ]]; then
|
||||
pass "Setuid bit set: $1"
|
||||
else
|
||||
fail "Setuid bit not set: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1" pattern="$2" desc="${3:-}"
|
||||
if grep -q "$pattern" "$file" 2>/dev/null; then
|
||||
@@ -59,8 +67,10 @@ assert_command_succeeds() {
|
||||
|
||||
# Validate app contents inside an Electron resources directory.
|
||||
# $1 = path to the resources/ dir containing app.asar
|
||||
# $2 = expected desktopName in app/package.json
|
||||
validate_app_contents() {
|
||||
local resources_dir="$1"
|
||||
local expected_desktop_name="${2:-claude-desktop.desktop}"
|
||||
|
||||
assert_file_exists "$resources_dir/app.asar"
|
||||
assert_dir_exists "$resources_dir/app.asar.unpacked"
|
||||
@@ -95,6 +105,11 @@ validate_app_contents() {
|
||||
'frame-fix-entry.js' \
|
||||
"package.json main field references frame-fix-entry.js"
|
||||
|
||||
# package.json desktopName matches the installed desktop file
|
||||
assert_contains "$extract_dir/app/package.json" \
|
||||
"\"desktopName\": \"$expected_desktop_name\"" \
|
||||
"package.json desktopName matches $expected_desktop_name"
|
||||
|
||||
# .vite/build/index.js exists (main process code)
|
||||
assert_file_exists "$extract_dir/app/.vite/build/index.js"
|
||||
|
||||
@@ -126,6 +141,187 @@ validate_app_contents() {
|
||||
rm -rf "$extract_dir"
|
||||
}
|
||||
|
||||
# Headless launch smoke test. Boots the packaged app under Xvfb + dbus
|
||||
# and waits for the frame-fix readiness marker
|
||||
# ('[Frame Fix] Patches built successfully'), which scripts/frame-fix-
|
||||
# wrapper.js emits on the FIRST require('electron') — i.e. before
|
||||
# app.whenReady(), not after full startup. Reaching it proves the asar
|
||||
# loaded and the wrapper's electron interception ran without a
|
||||
# SyntaxError (the #666 class) — note a hang after this point would
|
||||
# still pass. Catches startup-only regressions (asar/wrapper syntax
|
||||
# errors, bad patch anchors that yield a SyntaxError) that pure
|
||||
# structure checks miss. Ref: #670 (deb/rpm),
|
||||
# #646 (AppImage readiness-poll pattern this generalizes).
|
||||
#
|
||||
# Scope: main-process startup only. GPU/renderer crashes (#583-class)
|
||||
# leave the main process alive and pass — Xvfb has no GPU, so Electron
|
||||
# falls back to SwiftShader and that path isn't exercised here.
|
||||
#
|
||||
# Usage:
|
||||
# run_launch_smoke_test <label> <pkill_match> <run_as> <cmd> [args...]
|
||||
# label human name for pass/fail messages
|
||||
# pkill_match pattern for the pkill -f child sweep (may be empty)
|
||||
# run_as unprivileged user to drop to, or '' to run as-is.
|
||||
# Electron aborts as root without --no-sandbox, and the
|
||||
# launcher only adds that on Wayland/deb, so a root
|
||||
# container (rpm) must drop privileges to exercise the
|
||||
# real setuid-sandbox path.
|
||||
# cmd [args] the launch command
|
||||
#
|
||||
# Tool absence (xvfb-run/dbus-run-session/setsid, or runuser when a
|
||||
# run_as user is requested) is a skip, not a failure — matching
|
||||
# validate_app_contents. Loud failure on missing tools belongs at the
|
||||
# workflow layer.
|
||||
|
||||
# Module-scope state so the caller's trap can reap an interrupted launch.
|
||||
_smoke_launch_pid=''
|
||||
_smoke_cache_root=''
|
||||
_smoke_xvfb_log=''
|
||||
_smoke_pkill_match=''
|
||||
|
||||
_launch_smoke_cleanup() {
|
||||
if [[ -n $_smoke_launch_pid ]]; then
|
||||
# Negative PID targets the whole process group.
|
||||
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null
|
||||
[[ -n $_smoke_pkill_match ]] \
|
||||
&& pkill -KILL -f "$_smoke_pkill_match" 2>/dev/null
|
||||
fi
|
||||
[[ -n $_smoke_cache_root ]] && rm -rf "$_smoke_cache_root"
|
||||
[[ -n $_smoke_xvfb_log ]] && rm -rf "$_smoke_xvfb_log"
|
||||
}
|
||||
|
||||
# True when any passed log file carries the sandbox-namespace-denied
|
||||
# signature: the CI container forbidding Chromium's user/PID namespace
|
||||
# sandbox. Matches `Failed to move to new namespace`,
|
||||
# `zygote_host_impl_linux`, or `Operation not permitted` co-occurring
|
||||
# with `namespace`. Missing files are skipped silently.
|
||||
_smoke_sandbox_denied() {
|
||||
local log
|
||||
for log in "$@"; do
|
||||
[[ -f $log ]] || continue
|
||||
grep -qE 'Failed to move to new namespace|zygote_host_impl_linux' \
|
||||
"$log" && return 0
|
||||
grep -q 'Operation not permitted' "$log" \
|
||||
&& grep -q 'namespace' "$log" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_launch_smoke_test() {
|
||||
local label="$1" pkill_match="$2" run_as="$3"
|
||||
shift 3
|
||||
|
||||
local skip="Skipping launch smoke test for $label"
|
||||
if ! { command -v xvfb-run && command -v dbus-run-session \
|
||||
&& command -v setsid; } &>/dev/null; then
|
||||
pass "$skip (xvfb-run/dbus-run-session/setsid missing)"
|
||||
return
|
||||
fi
|
||||
if [[ -n $run_as ]] && ! command -v runuser &>/dev/null; then
|
||||
pass "$skip (runuser missing)"
|
||||
return
|
||||
fi
|
||||
|
||||
local cache_root xvfb_log launcher_log
|
||||
cache_root=$(mktemp -d)
|
||||
xvfb_log=$(mktemp)
|
||||
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
|
||||
_smoke_cache_root="$cache_root"
|
||||
_smoke_xvfb_log="$xvfb_log"
|
||||
_smoke_pkill_match="$pkill_match"
|
||||
|
||||
# setsid puts xvfb-run + Xvfb + dbus + launcher + electron in a fresh
|
||||
# process group; xvfb-run's own EXIT trap leaves Xvfb behind on TERM,
|
||||
# so we reap via kill -- -PGID below. XDG_CACHE_HOME is redirected so
|
||||
# the test owns the launcher log the readiness marker is written to
|
||||
# (the launcher execs electron with stdout/stderr >> "$log_file").
|
||||
local -a runner=(setsid)
|
||||
if [[ -n $run_as ]]; then
|
||||
# The unprivileged user must be able to write the redirected
|
||||
# cache (and read the world-readable install + setuid sandbox).
|
||||
chmod 0777 "$cache_root"
|
||||
runner+=(runuser -u "$run_as" --)
|
||||
fi
|
||||
runner+=(env "XDG_CACHE_HOME=$cache_root"
|
||||
xvfb-run -a -s '-screen 0 1280x720x24'
|
||||
dbus-run-session -- "$@")
|
||||
|
||||
"${runner[@]}" >"$xvfb_log" 2>&1 &
|
||||
_smoke_launch_pid=$!
|
||||
|
||||
# Poll for the readiness marker or early process death, up to 30s.
|
||||
# Replaces a flat sleep: faster on healthy startups, less flaky on
|
||||
# noisy runners.
|
||||
local readiness_marker='[Frame Fix] Patches built successfully'
|
||||
local readiness_timeout=30 deadline saw_marker=0
|
||||
deadline=$((SECONDS + readiness_timeout))
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -f $launcher_log ]] \
|
||||
&& grep -qF "$readiness_marker" "$launcher_log"; then
|
||||
saw_marker=1
|
||||
break
|
||||
fi
|
||||
kill -0 "$_smoke_launch_pid" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ((saw_marker == 1)); then
|
||||
pass "$label reached ready state under Xvfb"
|
||||
else
|
||||
# Build the failure detail message, but defer the fail/skip
|
||||
# verdict until after we've dumped and scanned the logs below.
|
||||
local detail exit_code
|
||||
if kill -0 "$_smoke_launch_pid" 2>/dev/null; then
|
||||
detail="$label did not reach ready state within"
|
||||
detail+=" ${readiness_timeout}s"
|
||||
else
|
||||
wait "$_smoke_launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
detail="$label exited before reaching ready state"
|
||||
detail+=" (exit: $exit_code)"
|
||||
fi
|
||||
if [[ -f $launcher_log ]]; then
|
||||
echo '--- launcher.log (last 40 lines) ---' >&2
|
||||
tail -40 "$launcher_log" >&2
|
||||
echo '------------------------------------' >&2
|
||||
fi
|
||||
if [[ -s $xvfb_log ]]; then
|
||||
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
|
||||
tail -20 "$xvfb_log" >&2
|
||||
echo '---------------------------------------' >&2
|
||||
fi
|
||||
# Narrow skip: the GHA container's default seccomp/userns policy
|
||||
# blocks Chromium's namespace sandbox, so the zygote aborts before
|
||||
# the readiness marker. That's an environment limit, not an app
|
||||
# defect (deb/appimage jobs prove the same code boots where the
|
||||
# sandbox is allowed). Treat ONLY this signature as a skip; every
|
||||
# other pre-marker exit stays a hard failure.
|
||||
if _smoke_sandbox_denied "$launcher_log" "$xvfb_log"; then
|
||||
pass "$label: SKIP — Chromium sandbox cannot initialize in this container (namespace creation denied by seccomp/userns policy); launch not exercised here. App boots where the sandbox is permitted (see deb/appimage jobs)."
|
||||
else
|
||||
fail "$detail"
|
||||
fi
|
||||
fi
|
||||
|
||||
kill -TERM -- "-$_smoke_launch_pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null || true
|
||||
wait "$_smoke_launch_pid" 2>/dev/null || true
|
||||
# Sweep any electron child that escaped the group (e.g. zygote).
|
||||
# Under the rpm runuser path PAM re-setsid()s the child into its own
|
||||
# session/process group, so the negative-PID group kills above miss
|
||||
# it entirely — this pkill -f sweep is the ACTUAL reaper there, not a
|
||||
# belt-and-suspenders extra. Don't drop it.
|
||||
if [[ -n $pkill_match ]]; then
|
||||
pkill -KILL -f "$pkill_match" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
_smoke_launch_pid=''
|
||||
_smoke_cache_root=''
|
||||
_smoke_xvfb_log=''
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo
|
||||
echo '================================'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user