mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-26 04:57:20 +03:00
Compare commits
107 Commits
docs/compa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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:
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -674,6 +674,7 @@ jobs:
|
||||
'gpgcheck=1' \
|
||||
'repo_gpgcheck=1' \
|
||||
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
|
||||
'metadata_expire=1h' \
|
||||
> rpm/claude-desktop.repo
|
||||
|
||||
- name: Re-upload signed RPMs to GitHub Release
|
||||
|
||||
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"
|
||||
|
||||
3
.github/workflows/test-artifacts.yml
vendored
3
.github/workflows/test-artifacts.yml
vendored
@@ -44,7 +44,8 @@ jobs:
|
||||
if: matrix.format != 'rpm'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y file libfuse2 nodejs npm
|
||||
sudo apt-get install -y file libfuse2 nodejs npm \
|
||||
xvfb dbus-x11 procps
|
||||
|
||||
- name: Run artifact tests
|
||||
run: |
|
||||
|
||||
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
|
||||
|
||||
7
.gitignore
vendored
7
.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/
|
||||
|
||||
|
||||
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
|
||||
|
||||
272
CHANGELOG.md
Normal file
272
CHANGELOG.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 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. -->
|
||||
|
||||
## [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
|
||||
48
CLAUDE.md
48
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.
|
||||
@@ -15,10 +36,13 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
|
||||
- [`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](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
|
||||
@@ -26,6 +50,16 @@ All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
@@ -37,6 +71,16 @@ Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `a
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
@@ -123,7 +167,7 @@ Contributors are listed in chronological order: inspirational projects first (k3
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
|
||||
161
CONTRIBUTING.md
Normal file
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.
|
||||
29
README.md
29
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,20 @@ 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)
|
||||
|
||||
## 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.
|
||||
6
build.sh
6
build.sh
@@ -60,8 +60,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 +159,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;Network;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=claude-desktop
|
||||
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)
|
||||
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).
|
||||
@@ -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.
|
||||
134
docs/learnings/test-harness-ax-tree-walker.md
Normal file
134
docs/learnings/test-harness-ax-tree-walker.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Test-harness AX-tree walker — non-obvious traps
|
||||
|
||||
Notes from the v6 → v7 fingerprint migration that switched
|
||||
`tools/test-harness/explore/walker.ts` from a renderer-side
|
||||
`document.querySelectorAll` IIFE to Chromium's accessibility tree
|
||||
(`Accessibility.getFullAXTree` over CDP). All five gotchas below cost
|
||||
a wasted live-walk to find; capturing them here so the next person
|
||||
debugging a 0-entry inventory or a redrive cascade can skip the
|
||||
discovery loop.
|
||||
|
||||
## 1. `Accessibility.enable` is async; the first `getFullAXTree` lies
|
||||
|
||||
Inspector clients call `target.debugger.sendCommand('Accessibility.enable')`
|
||||
before the first `getFullAXTree`. Both calls return immediately, but
|
||||
Chromium populates the AX tree asynchronously — the very first
|
||||
read can return a tree containing only the `RootWebArea` and a
|
||||
generic shell (4 nodes total) even when the DOM has hundreds of
|
||||
interactive elements. The walker's existing `waitForStable` is a
|
||||
DOM-mutation-quiescence observer with a 1.5s ceiling; on claude.ai's
|
||||
SPA the DOM mutates constantly so `waitForStable` returns at the
|
||||
ceiling without the AX tree ever catching up.
|
||||
|
||||
**Fix:** `waitForAxTreeStable` polls `getFullAXTree` until two
|
||||
consecutive reads return the same node count. Called once before the
|
||||
seed snapshot (with `minNodes: 20` to gate against the 4-node "still
|
||||
loading" case), once after each `navigateTo` in `redrivePath`, and
|
||||
baked into every `snapshotSurface` call (with `minNodes: 1` for the
|
||||
post-click case where the tree is already populated).
|
||||
|
||||
**Symptom you'll see:** seed entries: 0. Walker exits with no
|
||||
inventory. Stderr says `walker: AX tree settled at 4 nodes` (or
|
||||
similar small number).
|
||||
|
||||
## 2. `navigateTo(sameUrl)` is a no-op; redrives carry prior state
|
||||
|
||||
The walker's `navigateTo(url)` short-circuits when `currentUrl === url`
|
||||
(per the original v6 implementation). Every BFS pop re-navigates
|
||||
to `startUrl` to replay the recorded path against a clean state, but
|
||||
when `currentUrl` already matches `startUrl` the navigation is
|
||||
skipped. Anything a prior drill left behind — open dialog, expanded
|
||||
sidebar, scrolled focus, route params — carries into the next
|
||||
redrive's snapshots. `clickById` then suffix-matches the requested
|
||||
fingerprint against a contaminated surface and silently fails to find
|
||||
elements that were absolutely on the seed surface.
|
||||
|
||||
**Fix:** `redrivePath` uses `reloadPage(inspector)` (which evals
|
||||
`location.reload()` in the renderer) instead of
|
||||
`navigateTo(startUrl)`. The reload discards the React tree and forces
|
||||
a fresh mount even when the URL matches.
|
||||
|
||||
**Symptom you'll see:** the first one or two BFS items succeed, then
|
||||
every subsequent redrive fails with
|
||||
`clickById: no element matches "<seed-id>" on current surface`. The
|
||||
`<seed-id>` is a button you can verify with the DevTools console is
|
||||
visibly present.
|
||||
|
||||
## 3. claude.ai uses flat `dialog>button[]` and `complementary>button[]`, not `role=list`
|
||||
|
||||
The v7 plan's `isListRowChild` check assumes list rows use ARIA list
|
||||
semantics (`option/listitem` inside `listbox/list`). claude.ai
|
||||
exposes the connect-apps marketplace as a `dialog` with ~80 plain
|
||||
`button` children (no `list` wrapper) and the cowork sidebar as a
|
||||
`complementary` landmark with ~70 plain `button` children. Without
|
||||
the heuristic those buttons literal-match by name → each gets a
|
||||
unique stable entry → the BFS queues each individually for drilling
|
||||
→ inventory bloats from 32 to 442+ entries and most drills fail
|
||||
because the per-row buttons are virtualized.
|
||||
|
||||
**Fix:** `isListRowChild` extended in two ways. (a) `LIST_ROW_ROLES`
|
||||
includes `button`, `LIST_ANCESTOR_ROLES` includes `group`. (b) A
|
||||
sibling-count fallback fires when `siblingTotal >= 15` regardless of
|
||||
ancestor role — sits well above realistic toolbar sizes (≤10) and
|
||||
well below the smallest claude.ai marketplace (~80). 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 that can't fold.
|
||||
|
||||
**Symptom you'll see:** dialog kind count balloons (>200). One surface
|
||||
dominates the `surfaceBreakdown` query in the inventory. Each
|
||||
marketplace card or sidebar row gets its own `kind: structural`
|
||||
entry with a slugified product name in the id-tail.
|
||||
|
||||
## 4. The `more options for X` per-row trigger needs its own shape
|
||||
|
||||
Cowork sidebar rows have a "⋮" menu next to each session whose
|
||||
aria-label is `More options for <session title>`. These don't match
|
||||
the `cowork-session` shape (which gates on status prefix), so even
|
||||
after `cowork-session` collapsed the session list, the sibling
|
||||
"More options for" buttons still emitted individually. Same for any
|
||||
future per-row action button claude.ai adds.
|
||||
|
||||
**Fix:** new `INSTANCE_SHAPES` entry `row-more-options` with regex
|
||||
`/^More options for /` and matching pattern. Generic enough to cover
|
||||
any per-row trigger that follows the `<verb> for <row title>` shape.
|
||||
|
||||
**Symptom you'll see:** after fixing (1)-(3), a fresh wave of
|
||||
redrive failures all matching `more-options-for-X` slugs.
|
||||
|
||||
## 5. Sidebar virtualization causes structural redrive misses; bump the threshold
|
||||
|
||||
claude.ai's cowork sidebar appears to virtualize the session list:
|
||||
each fresh page load exposes a slightly different subset of sessions
|
||||
in the AX tree (subset, not just ordering — actually different
|
||||
membership). The walker captures session N at seed time but on
|
||||
redrive after `reloadPage` session N may not be in the tree. Each
|
||||
miss counts toward `MAX_CONSECUTIVE_LOOKUP_FAILURES`, and a stretch
|
||||
of 25+ consecutive cowork-row redrives can blow through the original
|
||||
threshold without the renderer being meaningfully wedged.
|
||||
|
||||
**Fix:** threshold bumped 25 → 75. The timeout counter (still 5
|
||||
strikes) gates against actual renderer hangs; the lookup-failure
|
||||
counter is more about "discovered DOM has drifted from seed", and on
|
||||
a virtualized list a generous threshold is correct. Subtree pruning
|
||||
(already in place) keeps the bursts from compounding by dropping
|
||||
queue items whose path shares the failed step's prefix.
|
||||
|
||||
**Symptom you'll see:** the walker aborts mid-walk with
|
||||
`25 consecutive redrive lookup failures` and the failed ids all
|
||||
share a common ariaPath prefix (`root.complementary.button-by-name.X`).
|
||||
|
||||
## Driver: prefer `walk-isolated.ts` over `explore walk`
|
||||
|
||||
`npm run explore:walk` connects to whatever Node inspector is on
|
||||
:9229 — i.e. the host Claude Desktop the user is currently using.
|
||||
That mutates the host profile (visited surfaces, navigation history,
|
||||
route changes) and races with the human at the keyboard.
|
||||
|
||||
`tools/test-harness/explore/walk-isolated.ts` mirrors what H05 / U01
|
||||
do: kills any running host instance, copies auth into a tmpdir
|
||||
(`createIsolation({ seedFromHost: true })`), spawns a fresh Electron
|
||||
with isolated `XDG_CONFIG_HOME`, attaches the inspector via
|
||||
`SIGUSR1`, runs the walk, tears down. Same flag set as
|
||||
`explore walk` plus `--no-seed` for the rare case you want a
|
||||
fresh-sign-in run. Use it.
|
||||
99
docs/learnings/test-harness-electron-hooks.md
Normal file
99
docs/learnings/test-harness-electron-hooks.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Hooking Electron from the test harness
|
||||
|
||||
Why constructor-level `BrowserWindow` wraps don't work in this
|
||||
codebase, and the prototype-method hook that does.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The test harness attaches a Node inspector at runtime (see
|
||||
[`docs/testing/automation.md`](../testing/automation.md#the-cdp-auth-gate-and-the-runtime-attach-workaround-that-beats-it))
|
||||
and from there can evaluate arbitrary JS in the main process. To
|
||||
observe BrowserWindow construction (e.g. find the Quick Entry popup
|
||||
ref, capture construction-time options), the natural-feeling
|
||||
approach is to wrap `electron.BrowserWindow`:
|
||||
|
||||
```js
|
||||
const electron = process.mainModule.require('electron');
|
||||
const Orig = electron.BrowserWindow;
|
||||
electron.BrowserWindow = function(opts) {
|
||||
// record opts...
|
||||
return new Orig(opts);
|
||||
};
|
||||
```
|
||||
|
||||
**This is silently bypassed.** `scripts/frame-fix-wrapper.js`
|
||||
returns the electron module wrapped in a `Proxy`; the Proxy's
|
||||
`get` trap returns a closure-captured `PatchedBrowserWindow`
|
||||
class. Reads of `electron.BrowserWindow` go through the trap and
|
||||
always return `PatchedBrowserWindow`, regardless of what was
|
||||
written to the underlying module. Writes succeed (Reflect.set on
|
||||
the target) but reads ignore them. Upstream code calling
|
||||
`new hA.BrowserWindow(opts)` constructs from `PatchedBrowserWindow`,
|
||||
your wrap is never invoked, your registry stays empty.
|
||||
|
||||
The reliable hook is at the **prototype-method level**:
|
||||
|
||||
```js
|
||||
const proto = electron.BrowserWindow.prototype;
|
||||
const origLoadFile = proto.loadFile;
|
||||
proto.loadFile = function(filePath, ...rest) {
|
||||
// every BrowserWindow instance reaches this, regardless of
|
||||
// which subclass constructed it
|
||||
return origLoadFile.call(this, filePath, ...rest);
|
||||
};
|
||||
```
|
||||
|
||||
This is what `tools/test-harness/src/lib/quickentry.ts:installInterceptor`
|
||||
does.
|
||||
|
||||
## Why prototype-level works through the Proxy
|
||||
|
||||
`electron.BrowserWindow` returns `PatchedBrowserWindow`, which
|
||||
`extends` the original `BrowserWindow` class. Both share the
|
||||
underlying Electron-native prototype chain via `extends`. Setting
|
||||
`PatchedBrowserWindow.prototype.loadFile = wrappedFn` shadows the
|
||||
inherited method on every instance — `Patched`-constructed,
|
||||
frame-fix-constructed, plain. There's no Proxy in front of
|
||||
`PatchedBrowserWindow.prototype`, so the assignment sticks and is
|
||||
visible to all subsequent `instance.loadFile(...)` calls.
|
||||
|
||||
`loadFile` and `loadURL` are reasonable identification points
|
||||
because every BrowserWindow that displays content calls one of
|
||||
them shortly after construction. The file path / URL is a stable
|
||||
upstream-controlled string (no minification — these are file paths
|
||||
to bundle assets), making it a durable identifier across releases.
|
||||
|
||||
## Why constructor-level *can* work elsewhere
|
||||
|
||||
If frame-fix-wrapper is removed (or stops returning a Proxy), the
|
||||
naïve constructor wrap would work. Watch for this: an upstream
|
||||
fork that adopts `BaseWindow` over `BrowserWindow`, or a
|
||||
build-time replacement of frame-fix-wrapper, would change the
|
||||
hook surface. The prototype-method approach survives both.
|
||||
|
||||
## What can't be observed at the prototype level
|
||||
|
||||
Construction-time options (`transparent: true`, `frame: false`,
|
||||
`skipTaskbar: true`, etc.) are consumed by the native side
|
||||
during `super(options)` and not stored on the instance in a
|
||||
reflective form. The harness reads runtime equivalents instead:
|
||||
|
||||
- `transparent` → `getBackgroundColor() === '#00000000'`
|
||||
- `frame: false` → `getBounds().width === getContentBounds().width`
|
||||
(frameless windows have equal frame and content bounds)
|
||||
- `alwaysOnTop` → `isAlwaysOnTop()` (note: the popup sets this
|
||||
via `setAlwaysOnTop()` *after* construction at
|
||||
`index.js:515399`, so this is the only viable read regardless of
|
||||
hook approach)
|
||||
|
||||
`skipTaskbar` has no public getter; if a test needs it, capture
|
||||
it at the prototype level by hooking a method that takes the same
|
||||
options shape, or accept that this signal is unobservable
|
||||
post-construction.
|
||||
|
||||
## See also
|
||||
|
||||
- [`tools/test-harness/src/lib/quickentry.ts`](../../tools/test-harness/src/lib/quickentry.ts) — `installInterceptor()` worked example
|
||||
- [`scripts/frame-fix-wrapper.js`](../../scripts/frame-fix-wrapper.js) — the Proxy + closure
|
||||
- [`tools/test-harness/src/lib/inspector.ts`](../../tools/test-harness/src/lib/inspector.ts) — how the harness gets main-process JS access in the first place
|
||||
- [`docs/testing/automation.md`](../testing/automation.md) — overall harness architecture
|
||||
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.
|
||||
111
docs/testing/README.md
Normal file
111
docs/testing/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Linux Compatibility Testing
|
||||
|
||||
*Last updated: 2026-05-03*
|
||||
|
||||
This directory holds the manual test plan for the Linux fork of Claude Desktop. The structure is designed for human readers today and scripted runners tomorrow.
|
||||
|
||||
## Layout
|
||||
|
||||
| Folder / file | Purpose |
|
||||
|---------------|---------|
|
||||
| [`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. |
|
||||
|
||||
## Environment key
|
||||
|
||||
| Abbrev | Distro | DE | Display server |
|
||||
|--------|--------|-----|----------------|
|
||||
| KDE-W | Fedora 43 | KDE Plasma | Wayland |
|
||||
| KDE-X | Fedora 43 | KDE Plasma | X11 |
|
||||
| GNOME | Fedora 43 | GNOME | Wayland |
|
||||
| Ubu | Ubuntu 24.04 | GNOME | Wayland |
|
||||
| Sway | Fedora 43 | Sway | Wayland (wlroots) |
|
||||
| i3 | Fedora 43 | i3 | X11 |
|
||||
| Niri | Fedora 43 | Niri | Wayland (wlroots) |
|
||||
| Hypr-O | OmarchyOS | Hyprland | Wayland (wlroots) |
|
||||
| Hypr-N | NixOS | Hyprland | Wayland (wlroots) |
|
||||
|
||||
Status legend: `✓` pass · `✗` fail · `🔧` mitigated · `?` untested · `-` N/A
|
||||
|
||||
Cells include linked issue/PR numbers when relevant — e.g. `✗ #404` or `🔧 #406`. A bare `✗` means the failure is verified but no tracking issue is filed yet.
|
||||
|
||||
## Severity tiers
|
||||
|
||||
Each test is tagged with one of:
|
||||
|
||||
| Tier | Meaning | Sweep cadence |
|
||||
|------|---------|---------------|
|
||||
| **Smoke** | Release-gate. Must pass before any tag is cut. | Every release tag, on KDE-W + one wlroots row |
|
||||
| **Critical** | Regression-blocker. Failure on any supported environment blocks the release. | Every release tag, on every active row |
|
||||
| **Should** | Important but not blocking. Track as bugs, fix before next stable. | Quarterly + on demand |
|
||||
| **Could** | Edge cases, nice-to-have. | On demand only |
|
||||
|
||||
## Smoke set
|
||||
|
||||
The minimum set that gates a release. Run on **KDE-W** (daily-driver) plus **Hypr-N** (clean wlroots). Sweep target: ~20 minutes.
|
||||
|
||||
| ID | Surface | One-line check |
|
||||
|----|---------|----------------|
|
||||
| [T01](./cases/launch.md#t01--app-launch) | Launch | App opens; main window renders within ~10s |
|
||||
| [T03](./cases/tray-and-window-chrome.md#t03--tray-icon-present) | Tray | Tray icon appears; click toggles window |
|
||||
| [T04](./cases/tray-and-window-chrome.md#t04--window-decorations-draw) | Window | OS-native frame draws and responds |
|
||||
| [T05](./cases/shortcuts-and-input.md#t05--url-handler-opens-claudeai-links-in-app) | Input | `xdg-open https://claude.ai/...` opens in-app |
|
||||
| [T07](./cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) | Window | Hybrid topbar renders, every button clicks |
|
||||
| [T08](./cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close) | Window | Close button hides to tray, doesn't quit |
|
||||
| [T11](./cases/extensibility.md#t11--plugin-install-anthropic--partners) | Extensibility | Anthropic & Partners plugin install completes |
|
||||
| [T15](./cases/code-tab-foundations.md#t15--sign-in-completes-via-browser-handoff) | Auth | Sign-in completes via `xdg-open` browser handoff |
|
||||
| [T16](./cases/code-tab-foundations.md#t16--code-tab-loads) | Code tab | Code tab loads (no 403, no blank screen) |
|
||||
| [T17](./cases/code-tab-foundations.md#t17--folder-picker-opens) | Code tab | Folder picker opens via portal/native chooser |
|
||||
|
||||
## Test corpus snapshot
|
||||
|
||||
| Bucket | Count |
|
||||
|--------|-------|
|
||||
| Cross-environment functional (`T###`) | 39 |
|
||||
| Environment-specific functional (`S###`) | 37 |
|
||||
| UI surfaces inventoried | 10 |
|
||||
| Total functional tests | 76 |
|
||||
|
||||
For detailed status by ID, see [`matrix.md`](./matrix.md).
|
||||
|
||||
## Automation status
|
||||
|
||||
Automation is partially landed. The harness lives at
|
||||
[`tools/test-harness/`](../../tools/test-harness/) — twenty Playwright
|
||||
specs wired (T01, T03, T04, T17, S09, S12, S29-S37, plus four H-prefix
|
||||
self-tests), thirteen passing on KDE-W and six skipping cleanly per
|
||||
spec intent. See [`tools/test-harness/README.md`](../../tools/test-harness/README.md)
|
||||
for the live status table, [`automation.md`](./automation.md) for
|
||||
architectural decisions, and the SIGUSR1 / runtime-attach pattern that
|
||||
bypasses the app's CDP auth gate.
|
||||
|
||||
### Grounding sweep + probe
|
||||
|
||||
Separate from the test sweep:
|
||||
[`runbook.md` "Grounding sweep"](./runbook.md#grounding-sweep) covers
|
||||
the workflow for verifying case docs themselves against the live
|
||||
build on every upstream version bump — static anchor pass plus a
|
||||
runtime probe ([`tools/test-harness/grounding-probe.ts`](../../tools/test-harness/grounding-probe.ts))
|
||||
that captures IPC handler registry, accelerator state, autoUpdater
|
||||
gate, AX-tree fingerprint, and other claims static analysis can't
|
||||
disambiguate. Anchor and drift conventions live in
|
||||
[`cases/README.md`](./cases/README.md#anchor-scope).
|
||||
|
||||
The structure remains automation-friendly for new tests:
|
||||
|
||||
1. **Stable test IDs.** `T01`-`T39` and `S01`-`S28` won't move. New tests append. Sequential, not semantic.
|
||||
2. **Standardized test bodies.** Every functional test has `Severity`, `Steps`, `Expected`, `Diagnostics on failure`, and `References` sections. The Steps and Diagnostics fields are scripted-runner-shaped.
|
||||
3. **Per-element UI checklists.** Each UI surface file lists interactive elements in a table — every row is a candidate `webContents.executeJavaScript` / `xprop` / DBus assertion.
|
||||
4. **Severity-driven sweeps.** Tests with a `runner:` field execute via [`tools/test-harness/orchestrator/sweep.sh`](../../tools/test-harness/orchestrator/sweep.sh); JUnit XML lands in `results/results-${ROW}-${DATE}/junit.xml`. Tests without a `runner:` continue to run manually.
|
||||
|
||||
For tests that don't have a runner yet, status updates land in [`matrix.md`](./matrix.md) by hand after each manual sweep. For tests that do, the automation invocation is the source of truth — see [`runbook.md`](./runbook.md#automated-runs).
|
||||
|
||||
## Conventions
|
||||
|
||||
- **One PR per sweep result, not per cell change.** Bundle a full row update into a single commit titled `test: KDE-W sweep $(date +%F)`. Reduces matrix-merge noise.
|
||||
- **Tested-version pin.** Every status update should mention the `claude-desktop` upstream version + the project version (`v1.3.x+claude...`) in the commit. Otherwise a `✓` from six months ago looks current.
|
||||
- **Diagnostics on failure are mandatory.** Don't file `✗` without the captures listed in the test's `Diagnostics on failure` block. The runbook covers how to capture each.
|
||||
- **Issue links go inline.** Status cells link directly to the relevant issue/PR.
|
||||
|
||||
See [`runbook.md`](./runbook.md) for the full mechanics.
|
||||
439
docs/testing/automation.md
Normal file
439
docs/testing/automation.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Automation Plan
|
||||
|
||||
*Last updated: 2026-04-30*
|
||||
|
||||
> **Status:** Direction agreed; first vertical slice scaffolded at
|
||||
> [`tools/test-harness/`](../../tools/test-harness/) covering T01, T03, T04,
|
||||
> T17 on KDE-W. The [Decisions](#decisions) table captures the calls
|
||||
> already made; [Still open](#still-open) is the short list of things
|
||||
> genuinely undecided. This file will fold into [`README.md`](./README.md)
|
||||
> and [`runbook.md`](./runbook.md) once the harness has run a few real
|
||||
> sweeps.
|
||||
|
||||
The [`README.md`](./README.md) automation roadmap is one paragraph. This file
|
||||
is the longer version — what shape the harness takes, which tools fit which
|
||||
tests, which anti-patterns to design against, and what to build first.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The 67 tests in [`cases/`](./cases/) already have stable IDs and
|
||||
standardized bodies. That structure is unusually friendly to
|
||||
automation — but only if the harness is shaped to match the corpus,
|
||||
rather than the other way around. Three things make that non-trivial:
|
||||
|
||||
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
|
||||
that probably stay manual forever.
|
||||
2. The matrix is nine environments, four display servers, and two package
|
||||
formats. Input injection on Wayland is genuinely different from X11, and
|
||||
X11 is the project's default backend (Wayland-native is opt-in until
|
||||
portal coverage matures across compositors).
|
||||
3. Many failures are environment-specific by construction (mutter XWayland
|
||||
key-grab, BindShortcuts on Niri, Omarchy Ozone-Wayland env exports). A
|
||||
single "run everything everywhere" harness will mis-skip those.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|----------|-----------|
|
||||
| 1 | **Single language: TypeScript.** Every runner is `.ts`; OS tools are shelled out via `child_process` and wrapped as TS helpers. Python only as a last-resort escape hatch for AT-SPI cases that resist portal mocking. | Playwright Electron is JS-native (post-Spectron); `dbus-next` covers DBus end-to-end; portal mocking removes the dogtail dependency for most native-dialog tests. Three-language overhead doesn't pay back. |
|
||||
| 2 | **Harness location: `tools/test-harness/`.** Sibling to `scripts/`. | Keeps `docs/testing/` documentation-only; matches the project's existing `tools/` / `scripts/` split. |
|
||||
| 3 | **VM images: Packer for imperative distros + Nix flake for `Hypr-N`.** | Packer builds golden snapshots that boot fast and rebuild as code; Nix flake handles NixOS natively without a second wrapper. Vagrant's per-boot provisioning model is the wrong tradeoff for hermetic per-test snapshots. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invocable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 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. |
|
||||
| 8 | **JUnit XML lives as workflow-run artifacts.** Each sweep run uploads `results-${ROW}-${DATE}.tar.zst` containing JUnit + diagnostic bundle. Default 90-day retention, extend to 365 if needed. The matrix-regen step downloads the latest run's artifacts and updates `matrix.md` in a PR. | Zero new infrastructure; GH provides storage, lifecycle, auth. If cross-run analytics later require longer history, promote to a separate `claude-desktop-debian-test-history` repo *then* — not before there's signal on what to keep. |
|
||||
|
||||
## The three layers
|
||||
|
||||
Looking at the corpus, every test falls into one of three buckets, and each
|
||||
bucket maps to a different shape of TS code (not a different language):
|
||||
|
||||
| Layer | What it covers | Implementation |
|
||||
|-------|----------------|----------------|
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **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 |
|
||||
|
||||
The `runner:` field [`README.md`](./README.md) hints at is the right unit.
|
||||
One TS file per test under `tools/test-harness/runners/`, free to mix L1 and
|
||||
L2 calls within a single test file. Tests without a `runner:` field stay
|
||||
manual indefinitely — that's a feature, not a TODO.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
host (orchestrator) per-row VM (or Nobara host for KDE-W)
|
||||
───────────────────── ──────────────────────────────────────
|
||||
tools/sweep.sh ssh → tools/test-harness/run.ts
|
||||
├── L1 runners (playwright-electron)
|
||||
├── L2 runners (dbus-next + shell-outs)
|
||||
└── junit.xml + diagnostic bundle
|
||||
tools/render-matrix.sh ← scp /tmp/results-${ROW}-${DATE}.tar.zst
|
||||
matrix.md (regenerated)
|
||||
```
|
||||
|
||||
The orchestrator is dumb: copy artifact in, kick the harness, copy results
|
||||
out. Per-row variation lives in `tools/test-images/${ROW}/` (Packer recipe +
|
||||
cloud-init / autoinstall, or a Nix flake for `Hypr-N`). The harness inside
|
||||
each VM is the same checked-in TS code, branched on `XDG_CURRENT_DESKTOP` /
|
||||
`XDG_SESSION_TYPE` for env-specific helpers.
|
||||
|
||||
Result format pivots on **JUnit XML** — well-trodden ground. Several actions
|
||||
already exist that turn JUnit into Markdown summaries
|
||||
([`junit-to-md`](https://github.com/davidahouse/junit-to-md), the
|
||||
[Test Summary Action](https://github.com/marketplace/actions/junit-test-dashboard)).
|
||||
The matrix-regen step is just "download artifact, merge per-row JUnit, render
|
||||
cells, commit a PR."
|
||||
|
||||
### Why not drive Playwright over the wire?
|
||||
|
||||
The obvious sketch is "orchestrator on the host opens a CDP / DevTools port
|
||||
on each VM and runs the whole suite from one place." It looks clean but has
|
||||
real costs:
|
||||
|
||||
- CDP over network is fragile; port forwards are a constant footgun on
|
||||
flaky links.
|
||||
- Doesn't help with L2 at all — DBus calls, `xprop`, `pgrep`, file-system
|
||||
probes still have to run in-VM.
|
||||
- You'd end up maintaining two transports anyway, so the centralization
|
||||
win evaporates.
|
||||
|
||||
In-VM Playwright via `_electron.launch()` is the [official Electron
|
||||
recommendation](https://www.electronjs.org/docs/latest/tutorial/automated-testing)
|
||||
since Spectron was archived in Feb 2022. No remote debug port needed; it
|
||||
spawns Electron directly and gives you a context.
|
||||
|
||||
## Toolchain choices per layer
|
||||
|
||||
### L1 — `playwright-electron`
|
||||
|
||||
- Spawn via `_electron.launch({ args: ['main.js'] })` — no `--remote-debugging-port`.
|
||||
- Gate `nodeIntegration: true` and `contextIsolation: false` behind
|
||||
`process.env.CI === '1'` so tests get full main-process access without
|
||||
weakening production security. (Electron docs explicitly recommend this
|
||||
pattern.)
|
||||
- **Locator policy: semantic only.** `getByRole`, `getByLabel`,
|
||||
`getByText`, `getByPlaceholder`. No CSS selectors against minified class
|
||||
names — they rot every upstream release. No `data-testid` infrastructure
|
||||
built up front; if a specific test proves unstable, first ask upstream
|
||||
for a stable `data-testid`, only carry an `app-asar.sh` patch as a last
|
||||
resort.
|
||||
- Use Playwright auto-wait. No fixed `sleep`s anywhere in the harness.
|
||||
|
||||
### L2 — `dbus-next` + wrapped shell-outs
|
||||
|
||||
The unifying observation: most of L2 is either DBus (which `dbus-next`
|
||||
handles natively from TS) or short subprocess invocations of OS tools
|
||||
(which `child_process.exec()` handles, wrapped as a typed TS helper). No
|
||||
parallel bash test scripts; the test code reads as TS.
|
||||
|
||||
- **DBus everywhere it applies.**
|
||||
[`dbus-next`](https://github.com/dbusjs/node-dbus-next) is actively
|
||||
maintained, has TypeScript typings, and is designed for Linux desktop
|
||||
integration. Replaces `gdbus call ...` invocations:
|
||||
- Tray / SNI state queries (`org.kde.StatusNotifierWatcher`,
|
||||
`org.freedesktop.DBus`).
|
||||
- Portal availability checks (`org.freedesktop.portal.Desktop`).
|
||||
- Suspend inhibitor inspection (`org.freedesktop.login1`).
|
||||
- AT-SPI introspection where actually needed
|
||||
(`org.a11y.atspi.*`).
|
||||
- **Compositor / window-manager state via shell-out helpers.** No good
|
||||
Node bindings exist for `xprop`, `wlr-randr`, `swaymsg`, `niri msg` —
|
||||
but invoking them from `child_process.exec()` inside a TS helper is
|
||||
perfectly fine, and the test code stays unified:
|
||||
```ts
|
||||
// tools/test-harness/lib/wm.ts
|
||||
export async function listToplevels(): Promise<Toplevel[]> { ... }
|
||||
```
|
||||
Each helper is a thin typed wrapper; the test reads as TS, not
|
||||
bash-with-extra-steps.
|
||||
- **Native dialogs (T17 folder picker, etc.) via portal mocking.** The
|
||||
`org.freedesktop.portal.FileChooser` interface is just DBus. For tests
|
||||
that exercise the *integration* (does Claude make the right portal call
|
||||
and handle the result?) — which is what T17 actually tests — register
|
||||
a mock backend over `dbus-next`, intercept the call, return a canned
|
||||
path. No real dialog ever renders. This is both faster and a more
|
||||
honest unit of test than driving a real chooser.
|
||||
- **AT-SPI escape hatch.** For the rare test where portal mocking isn't
|
||||
enough (driving an *actual* GTK/Qt dialog tree), the fallback is a
|
||||
small Python [`dogtail`](https://pypi.org/project/dogtail/) script
|
||||
invoked via `child_process.exec()` — same shape as the other shell-out
|
||||
helpers, just Python on the other end. Today, T17 is the only test
|
||||
that might need this; portal mocking probably covers it. We adopt
|
||||
Python only when a specific test forces it, not speculatively.
|
||||
|
||||
### Input injection — `ydotool` now, `libei` next
|
||||
|
||||
- [`ydotool`](https://github.com/ReimuNotMoe/ydotool) goes through
|
||||
`/dev/uinput`, so it works on both X11 and Wayland. Needs root or a
|
||||
`uinput` group; not a problem inside a test VM. Invoked via the same
|
||||
`child_process` shell-out pattern — `tools/test-harness/lib/input.ts`.
|
||||
- Portal-grabbed shortcuts (T06, S11, S14) `ydotool` **cannot** trigger.
|
||||
That's a kernel-vs-compositor boundary issue, not a tool gap. Those
|
||||
tests stay manual until libei is widely available.
|
||||
- The future-correct path is
|
||||
[`libei`](https://www.phoronix.com/news/LIBEI-Emulated-Input-Wayland) +
|
||||
the `RemoteDesktop` portal via `libportal`. KDE, GNOME, and wlroots
|
||||
are all moving there. Worth a roadmap note that the shortcut tests
|
||||
have a path to automation — just not today.
|
||||
|
||||
### VM lifecycle
|
||||
|
||||
- One image-build recipe per row in `tools/test-images/${ROW}/`. Packer
|
||||
for the imperative distros (Fedora 43, Ubuntu 24.04, OmarchyOS, and
|
||||
manual-install rows like i3 / Niri); Nix flake for `Hypr-N`.
|
||||
- Rebuild nightly or per release-tag sweep — don't `apt update` /
|
||||
`dnf update` inside a test run; mirrors hiccup, tests go red for the
|
||||
wrong reason.
|
||||
- Each test gets a hermetic `XDG_CONFIG_HOME` / `CLAUDE_CONFIG_DIR`
|
||||
(S19 is already the test-isolation primitive). No shared state
|
||||
between tests.
|
||||
|
||||
## The CDP auth gate (and the runtime-attach workaround that beats it)
|
||||
|
||||
*Discovered during the first KDE-W run-through; resolved by routing
|
||||
through the in-app debugger menu's code path.*
|
||||
|
||||
The shipped `index.pre.js` contains an authenticated-CDP gate:
|
||||
|
||||
```js
|
||||
uF(process.argv) && !qL() && process.exit(1);
|
||||
```
|
||||
|
||||
`uF(argv)` matches **`--remote-debugging-port`** or
|
||||
**`--remote-debugging-pipe`** on argv. `qL()` validates an ed25519-signed
|
||||
token in `CLAUDE_CDP_AUTH` (signed payload
|
||||
`${timestamp_ms}.${base64(userDataDir)}`, 5-minute TTL) against a hardcoded
|
||||
public key. If the gate flag is on argv and a valid token isn't in env,
|
||||
the app exits with code 1 right after `frame-fix-wrapper` completes. Both
|
||||
Playwright's `_electron.launch()` and `chromium.connectOverCDP()` inject
|
||||
`--remote-debugging-port=0` and trigger the gate. The signing key is held
|
||||
upstream; we can't forge tokens.
|
||||
|
||||
**Crucially, the gate doesn't check `--inspect` or runtime SIGUSR1.** Those
|
||||
trigger the **Node inspector**, not the Chrome remote-debugging port —
|
||||
different surface. Notably, the in-app `Developer → Enable Main Process
|
||||
Debugger` menu item *also* opens the Node inspector at runtime; that
|
||||
menu's existence is the hint that this path is tolerated by upstream.
|
||||
|
||||
The harness uses this:
|
||||
|
||||
1. Spawn Electron with no debug-port flags. Gate stays asleep.
|
||||
2. Wait for the X11 window to appear (signal that the app is up).
|
||||
3. Send `SIGUSR1` to the main process pid. Same code path as the menu —
|
||||
`inspector.open()` runs at runtime and the Node inspector starts on
|
||||
port 9229.
|
||||
4. Connect a WebSocket to `http://127.0.0.1:9229/json/list[0].
|
||||
webSocketDebuggerUrl`.
|
||||
5. Use `Runtime.evaluate` to run JS in the main process. From there:
|
||||
- `webContents.getAllWebContents()` lists all live web contents
|
||||
(including `https://claude.ai/...` once it loads into the
|
||||
BrowserView).
|
||||
- `webContents.executeJavaScript(...)` drives renderer-side DOM /
|
||||
state queries.
|
||||
- Main-process mocks (e.g. `dialog.showOpenDialog = ...` for T17) are
|
||||
installed by direct assignment.
|
||||
|
||||
[`tools/test-harness/src/lib/inspector.ts`](../../tools/test-harness/src/lib/inspector.ts)
|
||||
wraps this; [`tools/test-harness/src/lib/electron.ts`](../../tools/test-harness/src/lib/electron.ts)
|
||||
exposes `app.attachInspector()` on the launched-app handle.
|
||||
|
||||
**Two implementation gotchas worth recording:**
|
||||
|
||||
- **`BrowserWindow.getAllWindows()` returns 0** because frame-fix-wrapper
|
||||
substitutes the `BrowserWindow` class and the substitution breaks the
|
||||
static registry. Use `webContents.getAllWebContents()` instead — that
|
||||
registry stays intact and includes both the shell window and the
|
||||
embedded claude.ai BrowserView.
|
||||
- **`Runtime.evaluate` with `awaitPromise: true` + `returnByValue: true`
|
||||
returns empty objects** for awaited Promise resolutions on this build's
|
||||
V8. Workaround: have the IIFE return a `JSON.stringify(value)` and
|
||||
`JSON.parse` on the caller side. `inspector.evalInMain<T>()` does this
|
||||
internally so callers don't think about it.
|
||||
|
||||
**Status of the harness today:**
|
||||
|
||||
- **L2** — fully working (DBus, xprop). T03 / T04 pass.
|
||||
- **L1 — T01** — passes via X11 window probe (no inspector needed).
|
||||
- **L1 — T17 / similar** — framework works end-to-end (verified inspector
|
||||
attach + dialog mock + webContents detection + Code-tab navigation
|
||||
click). Selector tuning to match claude.ai's actual Code-tab UI is
|
||||
ordinary iterate-as-needed work, not a blocker.
|
||||
- **No `app-asar.sh` patch needed** to neutralize the gate. The
|
||||
`dogtail`/AT-SPI escape hatch (Decision 1) is also no longer the
|
||||
fallback for L1 — it's only relevant for native dialogs that the
|
||||
inspector pattern can't reach.
|
||||
|
||||
## Notable shifts since the existing roadmap was written
|
||||
|
||||
These three changed the landscape in 2025 and the existing
|
||||
[`README.md`](./README.md) Automation roadmap section predates them:
|
||||
|
||||
1. **Electron 38+ defaults to native Wayland.** [Electron 38 release
|
||||
notes](https://www.electronjs.org/blog/electron-38-0) and the
|
||||
[Wayland tech talk](https://www.electronjs.org/blog/tech-talk-wayland)
|
||||
document this. Electron now has a Wayland CI job upstream. The project
|
||||
keeps X11 as the default backend (Decision 6) because portal coverage
|
||||
for `GlobalShortcuts` is uneven across compositors — the new tests
|
||||
characterize what works where, not what to ship by default.
|
||||
2. **Spectron is dead.** Archived Feb 2022; Playwright is the
|
||||
[official recommendation](https://www.electronjs.org/blog/spectron-deprecation-notice).
|
||||
No discussion needed about which framework — that's settled.
|
||||
3. **`libei` is real and shipping.** KWin, mutter, and wlroots have all
|
||||
moved. The shortcut-test gap (T06 / S11 / S14) is automatable in the
|
||||
medium term, not "manual forever."
|
||||
|
||||
## Anti-patterns to design against
|
||||
|
||||
Pulled from the [Playwright flaky-test
|
||||
checklist](https://testdino.com/blog/playwright-automation-checklist/),
|
||||
the [Codepipes anti-patterns
|
||||
catalogue](https://blog.codepipes.com/testing/software-testing-antipatterns.html),
|
||||
and the [TestDevLab top 5
|
||||
list](https://www.testdevlab.com/blog/5-test-automation-anti-patterns-and-how-to-avoid-them).
|
||||
Designing the harness with these in mind from day one is much cheaper than
|
||||
backing them out later:
|
||||
|
||||
| Anti-pattern | What it looks like | How to avoid in this project |
|
||||
|---|---|---|
|
||||
| Silent retry | Test passes on attempt 2; dashboard shows green; flake hidden | Log retry count to JUnit; `matrix.md` shows `✓*` for retried-pass; treat retried-pass as a Should-fix bug |
|
||||
| Async-wait by `sleep` | `sleep 5` instead of `waitFor`; ICSE 2021 found ~45% of UI flakes here | No fixed sleeps in `tools/test-harness/`. Always poll a condition (window exists, log line, DBus name owned). Lint for `\bsleep\b` and `setTimeout` with literal numbers in test code |
|
||||
| Mixing orchestration with verification | One test installs the package, launches, checks tray, asserts URL handler — five failure modes, one red cell | One test, one assertion class. Setup goes in shared fixtures, not test bodies |
|
||||
| End-to-end as the only layer | All regressions caught at full-stack UI level | Keep `scripts/patches/*.sh` independently testable; add unit-level tests on patcher logic separately from the full-app sweep |
|
||||
| Implementation-coupled selectors | `div.css-7xz92q` deep selectors against minified renderer classes | Decision 5: semantic locators only. If a selector proves unstable, first ask upstream for a stable `data-testid`; only carry an `app-asar.sh` patch as a last resort, per-test |
|
||||
| Timing-sensitive assertions | "Within 500ms after click, X appears" | Time bounds are upper-bound sanity only. Use Playwright's auto-wait with a generous `timeout`; don't fight the framework |
|
||||
| Hidden global state across tests | Test 4 fails because test 2 left `~/.config/Claude/SingletonLock` behind | Hermetic per-test `XDG_CONFIG_HOME` / `CLAUDE_CONFIG_DIR` (S19). Treat shared state as an isolation bug, not a known quirk |
|
||||
| Long-lived VM state drift | Six-month-old snapshot has stale package mirrors; tests fail with 404s | Image rebuild as code (Packer / Nix flake); rebuild nightly or per release-tag. Never `apt update` mid-test |
|
||||
| Treating skip as fail | wlroots-only test fails on KDE because it can't be skipped properly | `?` and `-` are first-class in [`matrix.md`](./matrix.md). Map JUnit `<skipped>` → `-`, `<error>` (harness broke) → `?`, only `<failure>` → `✗` |
|
||||
| Diagnostics only on failure | Test goes red; capture fires; previous green run had no baseline to diff against | Decision 7: capture `--doctor`, launcher log, screenshot **on every run**. Last 10 greens + all reds on `main` |
|
||||
| Network coupling | "Tray icon present" fails because Cloudflare hiccupped during sign-in | Tests that don't *need* network shouldn't touch it. Sign-in is one fixture; tray test runs on a pre-signed-in profile snapshot |
|
||||
|
||||
## What stays manual (for now)
|
||||
|
||||
These have no automation path that's worth the cost today, and that's
|
||||
honest to call out in the roadmap rather than pretending they'll be
|
||||
automated "soon":
|
||||
|
||||
- **T06 / S11 / S14** — global shortcut tests behind portal grabs. Path
|
||||
exists (libei + RemoteDesktop portal) but compositor-side support is
|
||||
patchy. Revisit when libei adoption broadens.
|
||||
- **T15** — sign-in browser handoff. Needs a fixture account and an
|
||||
upstream auth flow that won't necessarily welcome scripted login.
|
||||
- **T28** — scheduled task catch-up after suspend. Real wall-clock event;
|
||||
not worth simulating.
|
||||
- **Anything in `ui/` tagged "looks right"** — HiDPI sharpness, theme
|
||||
rendering, drag-feel. AT-SPI sees the tree, not the pixels.
|
||||
|
||||
T17 (folder picker) was previously in this list. Portal mocking via
|
||||
`dbus-next` moves it into L2. If real-dialog testing turns out to be
|
||||
necessary anyway, the dogtail escape hatch covers it.
|
||||
|
||||
The matrix already supports leaving these manual via the `?` / `-` /
|
||||
existing-cell semantics — no schema change needed.
|
||||
|
||||
## Suggested first vertical slice
|
||||
|
||||
The smallest end-to-end that proves every architectural decision:
|
||||
|
||||
- **One row:** KDE-W (daily-driver host, no VM startup tax).
|
||||
- **One test:** T01 — App launch.
|
||||
- **Full pipeline:** orchestrator glue → harness entry → Playwright
|
||||
`_electron.launch()` → JUnit XML → matrix-regen step → cell flips
|
||||
from `?` to `✓` automatically.
|
||||
|
||||
That single slice forces every decision out into the open: harness
|
||||
language (TS), JUnit emission, results-bundle layout, matrix-regen
|
||||
rules, diagnostic-capture format. Resist building the orchestrator
|
||||
before there's a passing test it can orchestrate. Once the slice is
|
||||
real, adding tests 2–10 is mostly mechanical.
|
||||
|
||||
After T01: the next sensible additions are T03 (tray — exercises
|
||||
`dbus-next` end-to-end), T04 (window decorations — exercises the
|
||||
shell-out helper pattern), and T17 (folder picker — exercises portal
|
||||
mocking). Those four runners cover every distinct shape of TS code in
|
||||
the harness; everything else after them is a recombination.
|
||||
|
||||
## Still open
|
||||
|
||||
Most of the framing decisions are settled in the [Decisions](#decisions)
|
||||
table. What remains:
|
||||
|
||||
1. **Owner assignments per row.** [`MEMORY.md`](https://github.com/aaddrick/claude-desktop-debian/blob/main/.claude/projects/-home-aaddrick-source-claude-desktop-debian/memory/MEMORY.md)
|
||||
notes cowork → @RayCharlizard, nix → @typedrat. Hypr-N row is the
|
||||
natural fit for @typedrat once the Nix flake exists. The other eight
|
||||
rows: aaddrick by default, but worth asking the contributor base in a
|
||||
discussion thread.
|
||||
2. **AT-SPI escape-hatch trigger.** Decision 1 punts on Python until a
|
||||
specific test forces it. T17 is the only candidate today, and portal
|
||||
mocking probably covers it. If T17 actually needs real-dialog
|
||||
automation, that's the first reopen.
|
||||
3. **Selector rot rate.** Decision 5 starts with semantic locators and
|
||||
measures. After ~20 tests on the renderer, revisit whether
|
||||
`getByRole`/`getByText` is holding up or whether per-test
|
||||
`data-testid` patches are warranted. No prediction; this is a
|
||||
measure-and-decide.
|
||||
4. **CI execution model.** Decision 4 punts on this entirely until the
|
||||
harness has signal on which tests are stable. Reopen after the first
|
||||
~20 tests have run from the dev box for a few weeks.
|
||||
5. **Smoke-set Wayland-default test wording.** Decision 6 calls for a
|
||||
Smoke test asserting X11/XWayland selection on each row, plus
|
||||
per-row Should tests for Wayland characterization. The exact T-IDs
|
||||
and case-file homes for those tests need to be drafted next time
|
||||
`cases/` is touched.
|
||||
|
||||
## Sources
|
||||
|
||||
Background reading the recommendations draw on. Linked here so the
|
||||
calls have receipts:
|
||||
|
||||
### Electron testing & Playwright
|
||||
- [Electron — Automated Testing](https://www.electronjs.org/docs/latest/tutorial/automated-testing) — official tutorial, recommends Playwright
|
||||
- [Electron — Spectron Deprecation Notice](https://www.electronjs.org/blog/spectron-deprecation-notice) — Feb 2022 archive
|
||||
- [Playwright — Electron class](https://playwright.dev/docs/api/class-electron)
|
||||
- [Playwright — ElectronApplication class](https://playwright.dev/docs/api/class-electronapplication)
|
||||
- [Testing Electron apps with Playwright and GitHub Actions (Simon Willison)](https://til.simonwillison.net/electron/testing-electron-playwright)
|
||||
- [`spaceagetv/electron-playwright-example`](https://github.com/spaceagetv/electron-playwright-example) — multi-window Playwright + Electron example
|
||||
|
||||
### DBus / TypeScript
|
||||
- [`dbus-next` — actively-maintained Node DBus library with TS typings](https://github.com/dbusjs/node-dbus-next)
|
||||
- [`dbus-next` on npm](https://www.npmjs.com/package/dbus-next)
|
||||
|
||||
### Wayland / X11 / input injection
|
||||
- [Electron — Tech Talk: How Electron went Wayland-native](https://www.electronjs.org/blog/tech-talk-wayland)
|
||||
- [Electron 38.0.0 release notes](https://www.electronjs.org/blog/electron-38-0)
|
||||
- [PR #33355: fix calling X11 functions under Wayland](https://github.com/electron/electron/pull/33355)
|
||||
- [LIBEI — Phoronix overview](https://www.phoronix.com/news/LIBEI-Emulated-Input-Wayland)
|
||||
- [libei + RemoteDesktop portal — RustDesk discussion](https://github.com/rustdesk/rustdesk/discussions/4515)
|
||||
- [`ydotool` README](https://github.com/ReimuNotMoe/ydotool)
|
||||
- [`kwin-mcp` — KDE Plasma 6 Wayland automation tools](https://github.com/isac322/kwin-mcp)
|
||||
|
||||
### Portals / AT-SPI
|
||||
- [XDG Desktop Portal — main repo](https://github.com/flatpak/xdg-desktop-portal)
|
||||
- [`org.freedesktop.portal.FileChooser` interface XML](https://github.com/flatpak/xdg-desktop-portal/blob/main/data/org.freedesktop.portal.FileChooser.xml)
|
||||
- [File Chooser portal documentation](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html)
|
||||
- [`dogtail` on PyPI](https://pypi.org/project/dogtail/) — fallback only
|
||||
- [Automation through Accessibility — Fedora Magazine](https://fedoramagazine.org/automation-through-accessibility/)
|
||||
|
||||
### Anti-patterns / flaky tests
|
||||
- [Playwright automation checklist to reduce flaky tests (TestDino)](https://testdino.com/blog/playwright-automation-checklist/)
|
||||
- [Flaky Tests: The Complete Guide to Detection & Prevention (TestDino)](https://testdino.com/blog/flaky-tests/)
|
||||
- [5 Test Automation Anti-Patterns (TestDevLab)](https://www.testdevlab.com/blog/5-test-automation-anti-patterns-and-how-to-avoid-them)
|
||||
- [Software Testing Anti-patterns (Codepipes)](https://blog.codepipes.com/testing/software-testing-antipatterns.html)
|
||||
|
||||
### JUnit XML reporting
|
||||
- [`junit-to-md`](https://github.com/davidahouse/junit-to-md)
|
||||
- [Test Summary GitHub Action](https://github.com/marketplace/actions/junit-test-dashboard)
|
||||
- [Test Reporter](https://github.com/marketplace/actions/test-reporter)
|
||||
|
||||
### CI / VM matrix
|
||||
- [Transient — QEMU CI wrapper](https://www.starlab.io/blog/simple-painless-application-testing-on-virtualized-hardwarenbsp)
|
||||
- [`cirruslabs/tart` — VMs for CI automation](https://github.com/cirruslabs/tart)
|
||||
|
||||
---
|
||||
|
||||
*Once the first vertical slice (KDE-W + T01) ships, the relevant pieces of
|
||||
this file fold into [`README.md`](./README.md) (Automation roadmap) and
|
||||
[`runbook.md`](./runbook.md) (the harness invocation). Until then: working
|
||||
notes that have crossed from brainstorm to plan.*
|
||||
94
docs/testing/cases/README.md
Normal file
94
docs/testing/cases/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 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).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Surfaces covered | Tests |
|
||||
|------|------------------|-------|
|
||||
| [`launch.md`](./launch.md) | App startup, doctor, package detection, multi-instance | T01, T02, T13, T14 |
|
||||
| [`tray-and-window-chrome.md`](./tray-and-window-chrome.md) | Tray icon, window decorations, hybrid topbar, hide-to-tray | T03, T04, T07, T08, S08, S13 |
|
||||
| [`shortcuts-and-input.md`](./shortcuts-and-input.md) | URL handler, Quick Entry, global shortcuts | T05, T06, S06, S07, S09, S10, S11, S12, S14, S29, S30, S31, S32, S33, S34, S35, S36, S37 |
|
||||
| [`code-tab-foundations.md`](./code-tab-foundations.md) | Sign-in, Code tab load, folder picker, drag-drop, terminal, file pane | T15, T16, T17, T18, T19, T20 |
|
||||
| [`code-tab-workflow.md`](./code-tab-workflow.md) | Preview, PR monitor, worktrees, auto-archive, side chat, slash menu | T21, T22, T29, T30, T31, T32 |
|
||||
| [`code-tab-handoff.md`](./code-tab-handoff.md) | Notifications, external editor, file manager, connector OAuth, IDE handoff | T23, T24, T25, T34, T38, T39 |
|
||||
| [`routines.md`](./routines.md) | Scheduled tasks, catch-up runs, suspend inhibit, config dir | T26, T27, T28, S19, S20, S21 |
|
||||
| [`extensibility.md`](./extensibility.md) | Plugins, MCP, hooks, CLAUDE.md memory, worktree storage | T11, T33, T35, T36, T37, S27, S28 |
|
||||
| [`distribution.md`](./distribution.md) | DEB, RPM, AppImage, dependency pulls, auto-update | S01, S02, S03, S04, S05, S15, S16, S26 |
|
||||
| [`platform-integration.md`](./platform-integration.md) | Autostart, Cowork, WebGL, PATH inheritance, Computer Use, Dispatch | T09, T10, T12, S17, S18, S22, S23, S24, S25 |
|
||||
|
||||
## Standard test body
|
||||
|
||||
Every test in this directory follows this structure:
|
||||
|
||||
```markdown
|
||||
### T## — Title
|
||||
|
||||
**Severity:** Smoke | Critical | Should | Could
|
||||
**Surface:** human-readable surface tag (e.g. "Code tab → Environment")
|
||||
**Applies to:** All | <subset of rows>
|
||||
**Issues:** linked issue/PR list, or `—`
|
||||
|
||||
**Steps:**
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
**Expected:** what should happen.
|
||||
|
||||
**Diagnostics on failure:** which captures to attach when filing. See [`../runbook.md#diagnostic-capture`](../runbook.md#diagnostic-capture).
|
||||
|
||||
**References:** docs links, learnings, related issues.
|
||||
|
||||
**Code anchors:** `<file>:<line>` pointers to the upstream code or
|
||||
wrapper script that backs the load-bearing claim above. Added during
|
||||
the grounding sweep — see "Anchor scope" for guidance on where
|
||||
anchors can and can't land.
|
||||
|
||||
**Inventory anchor:** (optional) `<element-id>` from
|
||||
[`../ui-inventory.json`](../ui-inventory.json) — only if the surface
|
||||
shows up in the v7 walker's idle capture. For surfaces inside modals
|
||||
or popups, append a sentence noting which click-chain opens them so
|
||||
the next inventory regeneration can grab them.
|
||||
```
|
||||
|
||||
The Steps and Diagnostics fields are written so they can later become
|
||||
script entry points without a rewrite.
|
||||
|
||||
### Anchor scope
|
||||
|
||||
Where the load-bearing claim lives determines where the anchor goes:
|
||||
|
||||
- **Upstream code** — any file under
|
||||
`build-reference/app-extracted/.vite/build/` (most often `index.js`,
|
||||
the main process). Use `index.js:N` style anchors.
|
||||
- **Our wrapper code** — `scripts/launcher-common.sh`, `scripts/doctor.sh`,
|
||||
`scripts/patches/*.sh`, `scripts/frame-fix-wrapper.js`,
|
||||
`scripts/wco-shim.js`. Use `<repo-relative-path>:N` style anchors.
|
||||
- **Server-rendered (claude.ai SPA)** — anchorable only via the v7
|
||||
walker inventory (`docs/testing/ui-inventory.json`) or a runtime
|
||||
capture from `tools/test-harness/grounding-probe.ts`. Idle-state
|
||||
inventory misses contextual surfaces (modals, popups, slash menus,
|
||||
context menus, side panels) — note that explicitly.
|
||||
- **Upstream `claude` CLI binary** — out of scope for this matrix
|
||||
(e.g. T39 `/desktop` is a CLI slash-command, not in the Electron
|
||||
asar). Mark as Ambiguous and link to a separate CLI matrix if one
|
||||
exists.
|
||||
|
||||
If a claim spans multiple scopes (a wrapper script triggering
|
||||
upstream behavior, e.g. T01's launcher-log + main-window-opens),
|
||||
list all the anchors. The whole point is making the next sweep
|
||||
faster — over-anchoring is fine, missing anchors is not.
|
||||
|
||||
### Drift markers
|
||||
|
||||
When a sweep finds upstream behavior no longer matches the case:
|
||||
|
||||
- **Edited Steps/Expected** — fix the case in place, mention what
|
||||
changed in the commit message. The case is the spec.
|
||||
- **Missing in build X.Y.Z** — prepend a blockquote under the test
|
||||
heading: `> **⚠ Missing in build 1.5354.0** — <one-line note>.
|
||||
Re-verify after next upstream bump.` Use when the feature isn't
|
||||
in the build at all (deprecated, behind unset flag, never shipped).
|
||||
- **Ambiguous** — don't edit; flag in the sweep report. Use when
|
||||
the load-bearing claim could be one of several candidate code
|
||||
paths and static analysis can't disambiguate.
|
||||
197
docs/testing/cases/code-tab-foundations.md
Normal file
197
docs/testing/cases/code-tab-foundations.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Code Tab — Foundations
|
||||
|
||||
Tests covering Code-tab availability on Linux (officially unsupported per upstream docs), sign-in flow, folder picker, drag-and-drop, and the basic editing surfaces (terminal, file pane). See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T15 — Sign-in completes in the embedded webview
|
||||
|
||||
> **Drift in build 1.5354.0** — Sign-in is an in-app `mainView.webContents.loadURL` flow, not an `xdg-open` browser handoff. Claude.ai/login renders inside the embedded BrowserView; the resulting `sessionKey` cookie is then exchanged at `${apiHost}/v1/oauth/${org}/authorize` with redirect URI `https://claude.ai/desktop/callback`. No system browser is involved.
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Auth / embedded webview
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch a fresh app instance (signed-out state).
|
||||
2. Click **Sign in**. Observe claude.ai/login rendering inside the app.
|
||||
3. Authenticate. Observe the in-app navigation completing back to the
|
||||
workspace.
|
||||
|
||||
**Expected:** Sign-in stays inside the embedded webview (`will-navigate`
|
||||
handler `Ihr` keeps `/login/` paths in-app). After auth the
|
||||
`sessionKey` cookie is captured and silently exchanged for an OAuth
|
||||
token via the `desktop/callback` redirect. Account dropdown populates;
|
||||
no auth banner remains.
|
||||
|
||||
**Diagnostics on failure:** DevTools console for the `mainView`
|
||||
BrowserView, network captures of the `/v1/oauth/{org}/authorize` and
|
||||
`/v1/oauth/token` calls, launcher log, cookie jar inspection
|
||||
(`sessionKey` on `.claude.ai`).
|
||||
|
||||
**References:** [Code tab auth troubleshooting](https://code.claude.com/docs/en/desktop#403-or-authentication-errors-in-the-code-tab)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:141996` — desktop
|
||||
OAuth redirect URI `https://claude.ai/desktop/callback`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:142431` — POST to
|
||||
`${apiHost}/v1/oauth/${org}/authorize` with `Bearer ${sessionKey}`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:216565` — `Ihr`
|
||||
treats `/login/` paths as in-app (not external)
|
||||
- `build-reference/app-extracted/.vite/build/index.js:141316` —
|
||||
`mainView.webContents.loadURL(...)` drives the embedded sign-in
|
||||
|
||||
## T16 — Code tab loads
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Code tab — top-level UI
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. After sign-in, click the **Code** tab at the top center.
|
||||
2. Wait a few seconds.
|
||||
|
||||
**Expected:** Code tab renders the session UI (sidebar, prompt area, environment dropdown). Per upstream docs the Code tab is "not supported" on Linux — the patched build under this project should render the UI normally or surface a clear, actionable message. Not a blank screen, infinite spinner, or `Error 403: Forbidden`.
|
||||
|
||||
**Diagnostics on failure:** Screenshot, DevTools console, network captures (auth/feature-flag responses), launcher log, the active patch set in `scripts/patches/`.
|
||||
|
||||
**References:** [Use Claude Code Desktop](https://code.claude.com/docs/en/desktop), [Get started with the desktop app](https://code.claude.com/docs/en/desktop-quickstart)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:525066` —
|
||||
`sidebarMode === "code"` rewrites the BrowserView path to `/epitaxy`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:496066` — Code
|
||||
deeplinks (`claude://code?...`) navigate to `/epitaxy?...`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:105273` — `IHi`
|
||||
recognises `/epitaxy` and `/epitaxy/...` as the Code-tab path
|
||||
- `build-reference/app-extracted/.vite/build/index.js:105346` —
|
||||
`sidebarMode` enum contains `"code"`
|
||||
|
||||
**Inventory anchor:** `…tablist.tab-by-name.code` (role `tab`, label
|
||||
`Code`) — confirms the Code tab is reachable from the new-chat tablist
|
||||
in the captured idle state.
|
||||
|
||||
## T17 — Folder picker opens
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Code tab → Environment selection
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
**Runner:** [`tools/test-harness/src/runners/T17_folder_picker.spec.ts`](../../../tools/test-harness/src/runners/T17_folder_picker.spec.ts) — runtime-attach via SIGUSR1 + main-process `dialog.showOpenDialog` mock + `webContents.executeJavaScript` to drive the renderer. Click chain to reach the folder-picker button awaits selector tuning
|
||||
|
||||
**Steps:**
|
||||
1. In the Code tab, click the environment pill → **Local** → **Select folder**.
|
||||
2. Choose a project directory.
|
||||
|
||||
**Expected:** Native file chooser opens. On Wayland sessions the chooser is `xdg-desktop-portal`-backed (verify with `busctl --user tree org.freedesktop.portal.Desktop`). On X11 sessions the GTK/Qt native picker fires. Selected path appears in the env pill.
|
||||
|
||||
**Diagnostics on failure:** `systemctl --user status xdg-desktop-portal`, `XDG_SESSION_TYPE`, the portal backend in use (`xdg-desktop-portal-kde`, `xdg-desktop-portal-gnome`, `xdg-desktop-portal-wlr`), launcher log.
|
||||
|
||||
**References:** [Local sessions](https://code.claude.com/docs/en/desktop#local-sessions)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:66403` — IPC
|
||||
channel `claude.web_FileSystem_browseFolder` (renderer → main)
|
||||
- `build-reference/app-extracted/.vite/build/index.js:509188` —
|
||||
`browseFolder` impl calls `dialog.showOpenDialog` with
|
||||
`properties: ["openDirectory", "createDirectory"]`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:450534` —
|
||||
`grantViaPicker` (Operon host-access folder grant) uses the same
|
||||
`["openDirectory"]` shape
|
||||
- `tools/test-harness/src/lib/claudeai.ts:122` — `installOpenDialogMock`
|
||||
intercepts both `(opts)` and `(window, opts)` arities, matching the
|
||||
call sites at index.js:509196 and :450534
|
||||
|
||||
**Inventory anchor:** `root.main.region.button-by-name.select-folder`
|
||||
(role `button`, label `Select folder…`) — the persistent button the
|
||||
T17 runner clicks before the dialog mock fires.
|
||||
|
||||
## T18 — Drag-and-drop files into prompt
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Code tab → Prompt area
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Open a Code-tab session.
|
||||
2. From the system file manager, drag one or more files into the prompt area.
|
||||
3. Repeat with multiple files at once.
|
||||
|
||||
**Expected:** Files attach to the prompt. The renderer resolves dropped
|
||||
`File` objects to absolute paths via the preload-bridged
|
||||
`claudeAppSettings.filePickers.getPathForFile` (Electron's
|
||||
`webUtils.getPathForFile`). Multi-file drops attach each file. Works on
|
||||
both Wayland and X11.
|
||||
|
||||
**Diagnostics on failure:** Screen recording, `wl-paste --list-types` (Wayland) or `xclip -selection clipboard -t TARGETS -o` (X11) during drag, DevTools console, launcher log.
|
||||
|
||||
**References:** [Add files and context](https://code.claude.com/docs/en/desktop#add-files-and-context-to-prompts)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/mainView.js:9267` —
|
||||
`filePickers.getPathForFile` wraps `webUtils.getPathForFile`
|
||||
- `build-reference/app-extracted/.vite/build/mainView.js:9552` —
|
||||
exposed to the renderer as `window.claudeAppSettings`
|
||||
|
||||
## T19 — Integrated terminal
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Code tab → Terminal pane
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, press `` Ctrl+` `` (or open via the Views menu).
|
||||
2. Confirm the terminal opens in the session's working directory.
|
||||
3. Run `git status`, `npm --version`, `gh auth status`.
|
||||
|
||||
**Expected:** Terminal pane opens in the session's working directory, inherits the same `PATH` Claude sees. Standard commands run cleanly. Terminal pane is local-session-only per docs.
|
||||
|
||||
**Diagnostics on failure:** Terminal pane content, `echo $PATH` from inside the pane, `pwd`, the shell binary in use, launcher log.
|
||||
|
||||
**References:** [Run commands in the terminal](https://code.claude.com/docs/en/desktop#run-commands-in-the-terminal)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:69135` — IPC
|
||||
channel `claude.web_LocalSessions_startShellPty` (also
|
||||
`resizeShellPty`, `writeShellPty` at :69184, :69210)
|
||||
- `build-reference/app-extracted/.vite/build/index.js:486438` —
|
||||
`startShellPty` body: spawns `node-pty` in
|
||||
`n.worktreePath ?? n.cwd` with `TERM=xterm-256color`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:486463` —
|
||||
`node-pty` dynamic import (optional dep, `package.json` line 100)
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259306` —
|
||||
`shell-path-worker/shellPathWorker.js` resolves the user's interactive
|
||||
PATH; `FX()` (line 259311) returns it for the spawned PTY env
|
||||
|
||||
## T20 — File pane opens and saves
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Code tab → File pane
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, click a file path in chat or diff to open it in the file pane.
|
||||
2. Make a small edit. Click **Save**.
|
||||
3. Modify the file externally (e.g. `echo >> file`). Re-edit in the pane. Observe the on-disk-changed warning.
|
||||
|
||||
**Expected:** File opens in the editor pane. Edits write back to disk on Save. If the file changed on disk since opening, the pane shows the on-disk-changed warning and offers override or discard. (The conflict check is sha256-based, not mtime-based — `writeSessionFile` reads the current bytes, hashes them, and rejects with `Conflict` if the renderer-supplied `expectedHash` doesn't match.)
|
||||
|
||||
**Diagnostics on failure:** `sha256sum <file>` output (and stat mtime for cross-checking), launcher log, DevTools console, screen recording of the warning state.
|
||||
|
||||
**References:** [Open and edit files](https://code.claude.com/docs/en/desktop#open-and-edit-files)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:68922` — IPC
|
||||
channel `claude.web_LocalSessions_readSessionFile`
|
||||
- `build-reference/app-extracted/.vite/build/index.js:69003` — IPC
|
||||
channel `claude.web_LocalSessions_writeSessionFile` with
|
||||
`expectedHash` argument at position 3
|
||||
- `build-reference/app-extracted/.vite/build/index.js:492874` —
|
||||
`readSessionFile` impl
|
||||
- `build-reference/app-extracted/.vite/build/index.js:492954` —
|
||||
`writeSessionFile` impl: sha256-hashes current on-disk bytes,
|
||||
returns `{ status: nW.Conflict, currentHash }` when `expectedHash`
|
||||
mismatches
|
||||
163
docs/testing/cases/code-tab-handoff.md
Normal file
163
docs/testing/cases/code-tab-handoff.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Code Tab — Handoffs to Other Apps
|
||||
|
||||
Tests covering desktop notifications, "Open in" external editor, "Show in Files" file manager, connector OAuth round-trips, IDE handoff, and graceful failure of the macOS/Windows-only `/desktop` CLI command. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T23 — Desktop notifications fire
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Notifications (libnotify / XDG Notifications)
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Trigger each notification source: scheduled-task fire ([T27](./routines.md#t27--scheduled-task-fires-and-notifies)), CI completion ([T22](./code-tab-workflow.md#t22--pr-monitoring-via-gh)), Dispatch handoff ([S24](./platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification)).
|
||||
2. Observe each notification appears.
|
||||
3. Click each — confirm it focuses the relevant session.
|
||||
|
||||
**Expected:** Notifications appear in the active DE's notification area (Plasma's notification daemon, Mako on wlroots, gnome-shell, etc.) and are clickable to focus the relevant session.
|
||||
|
||||
**Diagnostics on failure:** `gdbus call --session --dest=org.freedesktop.Notifications --object-path=/org/freedesktop/Notifications --method=org.freedesktop.DBus.Introspectable.Introspect`, `notify-send "test"` (sanity check daemon), launcher log, DE-specific notification logs.
|
||||
|
||||
**References:** [Scheduled tasks](https://code.claude.com/docs/en/desktop-scheduled-tasks), [Monitor pull request status](https://code.claude.com/docs/en/desktop#monitor-pull-request-status)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:494456` (`new hA.Notification(r)` — backed by Electron's libnotify on Linux); `:495110` (`showNotification(title, body, tag, navigateTo)` dispatches Swift on macOS, Electron elsewhere); `:511174`, `:512738` (cu-lock / tool-permission notifications wire a click callback that navigates to `/local_sessions/{sessionId}` to focus the session).
|
||||
|
||||
## T24 — Open in external editor
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Right-click → Open in
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Install at least one of: VS Code, Cursor, Zed, Windsurf (any install method —
|
||||
flatpak, AppImage, distro package). Xcode is darwin-only and absent on Linux.
|
||||
2. In the Code tab, right-click a file path → **Open in** → choose the editor.
|
||||
3. Confirm the editor opens at that file.
|
||||
|
||||
**Expected:** Right-click → **Open in** launches the chosen editor with the file
|
||||
path. Editor is invoked by URL scheme (`vscode://file/<path>`,
|
||||
`cursor://file/<path>`, `zed://file/<path>`, `windsurf://file/<path>`) via
|
||||
`shell.openExternal`, which delegates to `xdg-open`'s
|
||||
`x-scheme-handler/<editor>` resolution rather than hard-coded paths.
|
||||
|
||||
**Diagnostics on failure:** `xdg-mime query default x-scheme-handler/vscode` (or
|
||||
`cursor`/`zed`/`windsurf`), `desktop-file-validate` on the editor's `.desktop`
|
||||
file, `xdg-open vscode://file/<path>` from terminal (sanity check), launcher
|
||||
log.
|
||||
|
||||
**References:** [Open files in other apps](https://code.claude.com/docs/en/desktop#open-files-in-other-apps)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:59076`
|
||||
(editor enum: VSCode, Cursor, Zed, Windsurf, Xcode); `:463902` (`Mtt`
|
||||
registry — `vscode://`, `cursor://`, `zed://`, `windsurf://`, `xcode://` with
|
||||
darwin-only flag on Xcode); `:463956` (`getInstalledEditors` probes via
|
||||
`app.getApplicationInfoForProtocol`); `:464011`
|
||||
(`shell.openExternal('<scheme>://file/<encoded-path>:<line>')` — path is
|
||||
URL-encoded but `/` separators are preserved); `:68816` IPC handler
|
||||
`LocalSessions.openInEditor(path, editor, sshConfig, line)`.
|
||||
|
||||
## T25 — Show in Files / file manager
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Right-click → Show in Files
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In the Code tab, right-click a file path → "Show in Files" (Linux equivalent of macOS "Show in Finder" / Windows "Show in Explorer").
|
||||
2. Confirm the system file manager opens with the containing folder selected.
|
||||
|
||||
**Expected:** System file manager (Nautilus on GNOME, Dolphin on KDE, Thunar on Xfce, etc.) opens with the file pre-selected. Resolution respects `xdg-mime` defaults.
|
||||
|
||||
**Diagnostics on failure:** `xdg-mime query default inode/directory`, `xdg-open <dir>` from terminal, the menu label rendered (was it Linux-specific or stuck on "Show in Finder"?), launcher log.
|
||||
|
||||
**References:** [Open files in other apps](https://code.claude.com/docs/en/desktop#open-files-in-other-apps)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:66652` IPC
|
||||
handler `FileSystem.showInFolder(path)`; `:509431` impl thin-wraps
|
||||
`hA.shell.showItemInFolder(Tc(path))`. Electron's `showItemInFolder` on Linux
|
||||
falls back to `xdg-open` on the parent directory when no DBus FileManager1
|
||||
service is present, so the file is rarely pre-selected on minimal DEs — only
|
||||
the parent folder opens.
|
||||
|
||||
## T34 — Connector OAuth round-trip
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Connectors → OAuth handoff
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, click **+** → **Connectors** → choose a service (Slack, GitHub, Linear, Notion, Google Calendar).
|
||||
2. Step through the OAuth flow in the system browser.
|
||||
3. Return to Claude Desktop and verify the connector appears in **Settings → Connectors**.
|
||||
4. Use the connector in a prompt (e.g. "list my Slack channels").
|
||||
|
||||
**Expected:** Adding a connector launches the browser via `xdg-open`, OAuth callback hands control back to Claude Desktop, connector appears in Settings, and is usable in subsequent prompts.
|
||||
|
||||
**Diagnostics on failure:** `xdg-mime query default x-scheme-handler/https`, the callback URL scheme, network captures of OAuth redirect, launcher log, DevTools console.
|
||||
|
||||
**References:** [Connect external tools](https://code.claude.com/docs/en/desktop#connect-external-tools), [Connectors for everyday life](https://claude.com/blog/connectors-for-everyday-life)
|
||||
|
||||
**Code anchors:**
|
||||
`build-reference/app-extracted/.vite/build/index.js:524819`
|
||||
(`hA.app.setAsDefaultProtocolClient("claude")` — registers the `claude://`
|
||||
deep-link scheme used by the OAuth callback); `:525026` mainWindow
|
||||
`setWindowOpenHandler` routes external URLs through `MAA(url)` →
|
||||
`:525102`–`:525135` (only `http:`/`https:`/`mailto:`/`tel:`/`sms:`/
|
||||
`ms-(excel|powerpoint|word):` are forwarded to system handlers; everything
|
||||
else is dropped); `:136233` `$a(url)` thin-wraps `hA.shell.openExternal(url)`
|
||||
(this is the single egress point for browser handoff); `:159634`
|
||||
`mcpSubmitOAuthCallbackUrl(serverName, callbackUrl)` and `:159651`
|
||||
`claudeOAuthCallback(authorizationCode, state)` — IPC bridges that consume
|
||||
the deep-link callback. See [`docs/learnings/plugin-install.md`](../../learnings/plugin-install.md)
|
||||
for orgId/sessionKey cookie chain that gates connector listing.
|
||||
|
||||
## T38 — Continue in IDE
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Continue in menu
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, click the IDE icon (bottom right of session toolbar) → **Continue in** → choose an IDE.
|
||||
2. Confirm the IDE opens at the working directory.
|
||||
|
||||
**Expected:** Selected IDE opens the project at the current working directory. Resolution via `xdg-open` / `.desktop` files.
|
||||
|
||||
**Diagnostics on failure:** `xdg-open <project-dir>` sanity check, `xdg-mime query default x-scheme-handler/vscode` (or matching scheme for the chosen IDE), launcher log, the IDE's `.desktop` file.
|
||||
|
||||
**References:** [Continue in another surface](https://code.claude.com/docs/en/desktop#continue-in-another-surface)
|
||||
|
||||
**Code anchors:** Same IPC surface as [T24](#t24--open-in-external-editor) —
|
||||
`build-reference/app-extracted/.vite/build/index.js:68816`
|
||||
(`LocalSessions.openInEditor(path, editor, sshConfig, line)` accepts a
|
||||
directory path the same way as a file path); `:463902` editor registry;
|
||||
`:464011` `shell.openExternal('<scheme>://file/<cwd>')`. The "Continue in"
|
||||
chooser UI is rendered server-side by claude.ai and not present in the local
|
||||
asar — only the IPC bridge can be code-anchored.
|
||||
|
||||
## T39 — `/desktop` CLI handoff (graceful N/A)
|
||||
|
||||
> **Note** — This test exercises the upstream `claude` CLI binary, not the
|
||||
> Electron app. The CLI ships separately from this packaging (out of
|
||||
> `build-reference/`), so no anchor in `app-extracted/.vite/build/` exists for
|
||||
> the slash-command handler. Re-verify behaviour against the CLI binary that
|
||||
> ships with the upstream version under test (currently 1.5354.0).
|
||||
|
||||
**Severity:** Could
|
||||
**Surface:** CLI `/desktop` command
|
||||
**Applies to:** All rows (Linux equally)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a CLI session, run `/desktop`.
|
||||
2. Inspect exit code and output.
|
||||
|
||||
**Expected:** `/desktop` is documented as macOS/Windows-only. On Linux it must fail gracefully — print a clear "not supported on Linux" message and exit cleanly. No partial state transition, no panic, no corrupted session file.
|
||||
|
||||
**Diagnostics on failure:** Full CLI output, exit code, the session file before/after (`~/.claude/sessions/...`), strace if the CLI hangs.
|
||||
|
||||
**References:** [Coming from the CLI](https://code.claude.com/docs/en/desktop#coming-from-the-cli)
|
||||
151
docs/testing/cases/code-tab-workflow.md
Normal file
151
docs/testing/cases/code-tab-workflow.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Code Tab — Workflow Surfaces
|
||||
|
||||
Tests covering the dev-server preview pane, PR monitoring, worktree isolation, auto-archive, side chat, and the slash command menu. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T21 — Dev server preview pane
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Preview pane
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, ensure `.claude/launch.json` is configured (or let auto-detect populate it).
|
||||
2. Click **Preview** dropdown → **Start**.
|
||||
3. Interact with the embedded browser. Verify auto-verify takes screenshots.
|
||||
4. Stop the server from the dropdown.
|
||||
|
||||
**Expected:** Configured dev server starts. Embedded browser renders the running app. Auto-verify takes screenshots and inspects DOM. Stopping from the dropdown actually stops the process.
|
||||
|
||||
**Diagnostics on failure:** `lsof -i :<port>` to see the server, screenshot of preview pane state, `.claude/launch.json` content, launcher log, DevTools console.
|
||||
|
||||
**References:** [Preview your app](https://code.claude.com/docs/en/desktop#preview-your-app)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:262175` — `Pae = "Claude Preview"` + `preview_*` MCP tool table (`preview_start`, `preview_stop`, `preview_list`, `preview_screenshot`, `preview_snapshot`, `preview_inspect`, `preview_click`, `preview_fill`, `preview_eval`, `preview_network`, `preview_resize`).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259604` — `setAutoVerify()` and `parseLaunchJson()` (reads `.claude/launch.json`, honours `autoVerify` flag default-on).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:260015` — `capturePage()` / `captureViaCDP()` drive `preview_screenshot` against the embedded preview WebContents.
|
||||
|
||||
## T22 — PR monitoring via `gh`
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Code tab → CI status bar
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Ensure `gh` is installed and authenticated (`gh auth status`).
|
||||
2. In a Code-tab session, ask Claude to open a PR for a small change.
|
||||
3. Observe the CI status bar. Toggle **Auto-fix** and **Auto-merge**.
|
||||
4. Run a separate test on a row where `gh` is **not** installed — confirm the missing-`gh` prompt appears the first time a PR action is taken.
|
||||
|
||||
**Expected:** With `gh` present and authenticated, CI status bar surfaces in the session toolbar. Auto-fix and Auto-merge toggles work (auto-merge requires the corresponding GitHub repo setting). If `gh` is missing, the app surfaces a prompt directing the user to https://cli.github.com (auto-install via `installGh` only runs on macOS/brew; Linux returns an error string with the install URL).
|
||||
|
||||
**Diagnostics on failure:** `gh auth status`, `which gh`, launcher log, DevTools console, screenshot of status bar, the GitHub repo's "Allow auto-merge" setting.
|
||||
|
||||
**References:** [Monitor pull request status](https://code.claude.com/docs/en/desktop#monitor-pull-request-status)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:464281` — `GitHubPrManager` (`prStateCache`, `prChecksCache`); `getPrChecks` at line 464964 fans out to `gh pr view`.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:464368` — `"gh CLI not found in PATH"` throw site that backs the missing-`gh` prompt.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:464480` — `installGh()`: macOS-only `brew install gh`; Linux/Windows return error pointing to https://cli.github.com.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:465019` — `autoMergeRequest { enabledAt }` GraphQL fragment; `enableAutoMerge` / `disableAutoMerge` at lines 465531 / 465556.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:534033` — `AutoFixEngine.handleSessionEvent` toggles on `autoFixEnabled` per session.
|
||||
|
||||
## T29 — Worktree isolation
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Code tab → Sidebar (parallel sessions)
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session against a Git project, open two new sessions in parallel via **+ New session**.
|
||||
2. Make different edits in each session.
|
||||
3. Confirm `<project-root>/.claude/worktrees/<branch>` exists for each.
|
||||
4. Archive one session via the sidebar archive icon.
|
||||
|
||||
**Expected:** Each session creates an isolated worktree at `<project-root>/.claude/worktrees/<branch>` (or the dir configured in Settings → Claude Code → "Worktree location"). Edits in one session do not appear in another until committed. Archiving removes the worktree.
|
||||
|
||||
**Diagnostics on failure:** `git worktree list` from project root, `ls -la <project-root>/.claude/worktrees/`, launcher log.
|
||||
|
||||
**References:** [Work in parallel with sessions](https://code.claude.com/docs/en/desktop#work-in-parallel-with-sessions)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:462835` — `getWorktreeParentDir()`: returns `<baseRepo>/.claude/worktrees`, or `<chillingSlothLocation.customPath>/<basename>` when overridden in Settings.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:462843` — `createWorktree()`: runs `git worktree add` with `core.longpaths=true` under the parent dir.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:463290` — `git worktree remove --force` invoked on archive (cleanup path).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:55231` — `chillingSlothLocation: "default"` settings key (Settings → "Worktree location").
|
||||
|
||||
## T30 — Auto-archive on PR merge
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Sidebar
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In Settings → Claude Code, enable **Auto-archive on PR close** (`ccAutoArchiveOnPrClose`).
|
||||
2. Open a PR from a local session. Merge or close it on GitHub.
|
||||
3. Wait up to ~5–6 minutes (sweep runs every 5 minutes, with a 30s startup delay). Observe the sidebar.
|
||||
|
||||
**Expected:** Local session whose PR is `merged` or `closed` is archived from the sidebar on the next sweep tick (≤ ~5 min) after the merge/close event. Cached PR-state lookups have a 1-hour cooldown for sessions whose state isn't yet terminal. Remote and SSH sessions are not affected.
|
||||
|
||||
**Diagnostics on failure:** Screenshot of sidebar, `gh pr view <num>` output (confirming merge state), launcher log, settings file content (`ccAutoArchiveOnPrClose`).
|
||||
|
||||
**References:** [Work in parallel with sessions](https://code.claude.com/docs/en/desktop#work-in-parallel-with-sessions)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:55269` — default `ccAutoArchiveOnPrClose: !1` setting.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:533517` — sweep cadence constants: `$3n = 300_000` ms (5 min interval), `W3n = 3_600_000` ms (1 h recheck cooldown), `Fst = 10` (concurrent batch size).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:533520` — `AutoArchiveEngine.start()` schedules the 5-min interval + 30s initial delay.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:533537` — `sweep()` gates on `Qi("ccAutoArchiveOnPrClose")` and archives sessions whose `prState` lowercases to `merged` or `closed` (`D3A` predicate at line 533607).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:533571` — `archiveSession(..., { cleanupWorktree: true })` removes the worktree alongside the archive.
|
||||
|
||||
## T31 — Side chat opens
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Side chat overlay
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, press `Ctrl+;` (or type `/btw` in the prompt).
|
||||
2. Ask a question in the side chat. Confirm the side chat sees the main thread context.
|
||||
3. Close the side chat. Confirm focus returns to the main session and the side chat content is not in the main thread.
|
||||
|
||||
**Expected:** Side chat opens, has access to main-thread context, but its replies do not appear in the main conversation. Closing returns focus.
|
||||
|
||||
**Diagnostics on failure:** Screenshot, launcher log, DevTools console.
|
||||
|
||||
**References:** [Ask a side question](https://code.claude.com/docs/en/desktop#ask-a-side-question-without-derailing-the-session)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:487025` — side-chat system-prompt suffix: "You are running in a side chat — a lightweight fork… nothing you say here lands in the main transcript."
|
||||
- `build-reference/app-extracted/.vite/build/index.js:487265` — `this.sideChats = new Map()` per-session fork registry.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:491658` — `startSideChat()` implementation; emits `side_chat_ready` / `side_chat_assistant` / `side_chat_turn_end` / `side_chat_closed` / `side_chat_error` events.
|
||||
- `build-reference/app-extracted/.vite/build/mainView.js:7506` — preload IPC bridges: `startSideChat`, `sendSideChatMessage`, `stopSideChat` (the renderer SPA wires `Ctrl+;` / `/btw` to these — UI lives in claude.ai's remote bundle, not build-reference).
|
||||
|
||||
## T32 — Slash command menu
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Code tab → Prompt slash menu
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, type `/` in the prompt box.
|
||||
2. Verify built-in commands, custom skills under `~/.claude/skills/`, project skills, and skills from installed plugins all appear.
|
||||
3. Select an entry — confirm it inserts as a highlighted token.
|
||||
|
||||
**Expected:** Slash menu lists every available command/skill. Selection inserts the token correctly.
|
||||
|
||||
**Diagnostics on failure:** Screenshot of slash menu, `ls ~/.claude/skills/`, project `.claude/skills/`, installed plugin manifest, launcher log.
|
||||
|
||||
**References:** [Use skills](https://code.claude.com/docs/en/desktop#use-skills)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:459463` — `getSupportedCommands({sessionId})` aggregates per-session `slashCommands` + cowork command registry (`p2()`) + built-ins (`Q_t`).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:332711` — `slashCommands: Di.array(Di.string()).optional()` schema field on the session record.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:377670` — `SkillManager` constructor: `skillDir = <agentDir>/.claude/skills`, `_discoverSkills()` walks project skills.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:444678` — private/public skill split under `<skillsRoot>/skills/{private,public}` for plugin-supplied skills.
|
||||
168
docs/testing/cases/distribution.md
Normal file
168
docs/testing/cases/distribution.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Distribution — DEB, RPM, AppImage
|
||||
|
||||
Tests covering Ubuntu/DEB-specific install behavior, Fedora/RPM-specific install behavior, AppImage fallback paths, and the auto-update interaction with system package managers. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## S01 — AppImage launches without manual `libfuse2t64` install
|
||||
|
||||
**Severity:** Critical (for Ubuntu users)
|
||||
**Surface:** AppImage runtime / FUSE
|
||||
**Applies to:** Ubu (and any Ubuntu 24.04+ host)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Fresh Ubuntu 24.04 install with default packages only.
|
||||
2. Download the project AppImage.
|
||||
3. Make executable and run it.
|
||||
|
||||
**Expected:** AppImage runs without first installing `libfuse2t64`. Either the AppImage bundles its own FUSE shim, the `.desktop`/postinst declares the dep, or the launcher gives a clear error pointing at the package name.
|
||||
|
||||
**Currently:** Fails on Ubuntu 24.04 with `dlopen(): error loading libfuse.so.2`. Workaround: `sudo apt install libfuse2t64`. Not yet filed.
|
||||
|
||||
**Diagnostics on failure:** Full stderr from the AppImage launch, `ldd ./claude-desktop-*.AppImage`, `dpkg -l | grep -i fuse`.
|
||||
|
||||
**References:** —
|
||||
|
||||
**Code anchors:** `scripts/packaging/appimage.sh:226` (downloads the upstream `appimagetool` AppImage as-is — no FUSE shim or static-mksquashfs bundling), `scripts/launcher-common.sh:64` (AppImage forces `--no-sandbox` "due to FUSE constraints"), `.github/workflows/test-artifacts.yml:47` (CI installs `libfuse2` before running the AppImage — i.e. the runtime hard-depends on libfuse2/libfuse2t64). No postinst dep declaration or user-facing FUSE error message exists.
|
||||
|
||||
## S02 — `XDG_CURRENT_DESKTOP=ubuntu:GNOME` doesn't break DE detection
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** DE detection / patch gate
|
||||
**Applies to:** Ubu
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. On Ubuntu 24.04 (where `XDG_CURRENT_DESKTOP=ubuntu:GNOME`), launch the app.
|
||||
2. Inspect launcher log for any DE-detection branches that should fire as GNOME.
|
||||
3. Audit `scripts/launcher-common.sh` and any DE-gated patches for string-equality checks against `XDG_CURRENT_DESKTOP`.
|
||||
|
||||
**Expected:** DE-detection logic handles Ubuntu's colon-separated value. `contains "GNOME"` or splitting on `:` is the safe pattern; `== "GNOME"` would miss Ubuntu.
|
||||
|
||||
**Diagnostics on failure:** `echo $XDG_CURRENT_DESKTOP`, the relevant launcher.sh code path, launcher log, the patches that ran or didn't.
|
||||
|
||||
**References:** Surfaced via session-capture review.
|
||||
|
||||
**Code anchors:** `scripts/launcher-common.sh:35-44` (Niri auto-detect lowercases `XDG_CURRENT_DESKTOP` and uses `*niri*` glob — handles colon-separated values), `scripts/patches/quick-window.sh:34-35` and `:117-118` (KDE gate uses `.toLowerCase().includes("kde")` — substring, not equality), `scripts/doctor.sh:304` (purely informational `_info "Desktop: $desktop"`, no branching). No `==` equality checks against `XDG_CURRENT_DESKTOP` exist anywhere in shell or patched JS.
|
||||
|
||||
## S03 — DEB install via APT pulls all required runtime deps
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** APT repository / dependency declarations
|
||||
**Applies to:** Ubu (any DEB-based distro)
|
||||
**Issues:** [`docs/learnings/apt-worker-architecture.md`](../../learnings/apt-worker-architecture.md)
|
||||
|
||||
**Steps:**
|
||||
1. Add the project's APT repo per the README install instructions.
|
||||
2. `sudo apt install claude-desktop` on a fresh container/VM.
|
||||
3. Run `claude-desktop` — first launch should succeed with no further package installs.
|
||||
|
||||
**Expected:** All transitive runtime deps are declared in the package and pulled by APT. First launch succeeds without manual `apt install` of any extra package.
|
||||
|
||||
**Diagnostics on failure:** `apt-cache depends claude-desktop`, missing-library errors from the launcher, `ldd` against the binary.
|
||||
|
||||
**References:** [`docs/learnings/apt-worker-architecture.md`](../../learnings/apt-worker-architecture.md)
|
||||
|
||||
**Code anchors:** `scripts/packaging/deb.sh:185-197` (DEBIAN/control file — no `Depends:` field is emitted; relies on bundled Electron + the comment "No external dependencies are required at runtime" at line 183), `scripts/packaging/deb.sh:202-230` (postinst only sets chrome-sandbox suid, no dep-pull). Worker chain serving the package: `worker/src/worker.js:22-31` (`DEB_RE`) and `:33-43` (302 → GitHub Releases).
|
||||
|
||||
## S04 — RPM install via DNF pulls all required runtime deps
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** DNF repository / dependency declarations
|
||||
**Applies to:** KDE-W, KDE-X, GNOME, Sway, i3, Niri (any RPM-based distro)
|
||||
**Issues:** [`docs/learnings/apt-worker-architecture.md`](../../learnings/apt-worker-architecture.md) *(covers both APT and DNF)*
|
||||
|
||||
**Steps:**
|
||||
1. Add the project's DNF repo per the README.
|
||||
2. `sudo dnf install claude-desktop` on a fresh container/VM.
|
||||
3. Run `claude-desktop` — first launch should succeed.
|
||||
|
||||
**Expected:** All transitive runtime deps are declared in the RPM and pulled by DNF. First launch succeeds with no further package installs.
|
||||
|
||||
**Diagnostics on failure:** `dnf repoquery --requires claude-desktop`, `rpm -qR claude-desktop`, launcher missing-library errors.
|
||||
|
||||
**References:** [`docs/learnings/apt-worker-architecture.md`](../../learnings/apt-worker-architecture.md)
|
||||
|
||||
**Code anchors:** `scripts/packaging/rpm.sh:188` (`AutoReqProv: no` — explicitly disables RPM's auto-dep generation; spec declares no `Requires:`), `scripts/packaging/rpm.sh:194-198` (strip + build-id disabled because Electron binaries don't tolerate them — bundled approach). Worker chain: `worker/src/worker.js:28-31` (`RPM_RE`).
|
||||
|
||||
## S05 — Doctor recognises dnf-installed package, doesn't false-flag as AppImage
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Doctor package-format detection
|
||||
**Applies to:** KDE-W, KDE-X, GNOME, Sway, i3, Niri
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. On a Fedora/Nobara/RPM-based distro with claude-desktop installed via dnf, run `claude-desktop --doctor`.
|
||||
2. Look for the install-method line.
|
||||
|
||||
**Expected:** Doctor detects rpm install (e.g. via `rpm -qf` against the binary path) and reports it cleanly. No `not found via dpkg (AppImage?)` warning.
|
||||
|
||||
**Currently:** Doctor's install-method check is gated on `command -v dpkg-query`, so on RPM-only hosts (no dpkg installed) the block is skipped entirely — no install-method line is printed. On hosts that have *both* `dpkg-query` and an rpm-installed `claude-desktop` (uncommon, e.g. mixed Debian + dnf), the misleading `claude-desktop not found via dpkg (AppImage?)` WARN does fire. Either way, no `rpm -qf` branch exists. Affects KDE-W, KDE-X, GNOME, Sway, i3, Niri rows ([T13](./launch.md#t13--doctor-reports-correct-package-format)). Not yet filed.
|
||||
|
||||
**Diagnostics on failure:** Full `--doctor` output, `rpm -qf $(which claude-desktop)`, the doctor source line that decides the format.
|
||||
|
||||
**References:** [T13](./launch.md#t13--doctor-reports-correct-package-format)
|
||||
|
||||
**Code anchors:** `scripts/doctor.sh:353-362` — install-method check is gated on `command -v dpkg-query`; only runs on Debian-family hosts. Falls through to `_warn 'claude-desktop not found via dpkg (AppImage?)'` only if `dpkg-query` is present but returns empty. On Fedora/RPM hosts (`dpkg-query` absent), the entire block is skipped and **no install-method line is printed at all** — neither the misleading WARN nor a correct `rpm -qf` PASS. The drift is "no detection" rather than "false-flag as AppImage" on dpkg-less systems.
|
||||
|
||||
## S15 — AppImage extraction (`--appimage-extract`) works as documented fallback
|
||||
|
||||
**Severity:** Could
|
||||
**Surface:** AppImage runtime / FUSE-less fallback
|
||||
**Applies to:** Any AppImage row
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. On a host without FUSE, run `./claude-desktop-*.AppImage --appimage-extract`.
|
||||
2. Inspect `squashfs-root/`.
|
||||
3. Run `squashfs-root/AppRun`.
|
||||
|
||||
**Expected:** Extraction completes. `squashfs-root/AppRun` launches the app cleanly without FUSE.
|
||||
|
||||
**Diagnostics on failure:** Extraction stderr, `ls squashfs-root/`, AppRun stderr.
|
||||
|
||||
**References:** Linked from the runtime error message when FUSE is missing.
|
||||
|
||||
**Code anchors:** `scripts/packaging/appimage.sh:282` and `:312` (built with stock `appimagetool`, which always supports `--appimage-extract`), `scripts/packaging/appimage.sh:70-118` (`AppRun` script that lives at `squashfs-root/AppRun` after extraction). CI exercises this path: `tests/test-artifact-appimage.sh:36-44` and `.github/workflows/ci.yml:388` both run `--appimage-extract` and assert `squashfs-root/` exists.
|
||||
|
||||
## S16 — AppImage mount cleans up on app exit
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** AppImage mount lifecycle
|
||||
**Applies to:** Any AppImage row
|
||||
**Issues:** [CLAUDE.md "Common Gotchas"](https://github.com/aaddrick/claude-desktop-debian/blob/main/CLAUDE.md)
|
||||
|
||||
**Steps:**
|
||||
1. Launch the AppImage. Confirm `mount | grep claude` shows the mount.
|
||||
2. Quit the app cleanly via tray → Quit (or `Ctrl+Q`).
|
||||
3. Re-run `mount | grep claude` — mount should be gone.
|
||||
|
||||
**Expected:** AppImage's mount at `/tmp/.mount_claude*` is unmounted and the directory removed when all child Electron processes exit. Stale mounts after force-quit are handled by `pkill -9 -f "mount_claude"` per CLAUDE.md but should not be the common case.
|
||||
|
||||
**Diagnostics on failure:** `mount | grep claude` after exit, `ls -la /tmp/.mount_claude*`, `pgrep -af claude`, `journalctl -k -n 50` for mount errors.
|
||||
|
||||
**References:** [CLAUDE.md "Common Gotchas"](https://github.com/aaddrick/claude-desktop-debian/blob/main/CLAUDE.md)
|
||||
|
||||
**Code anchors:** Mount lifecycle is owned by upstream `appimagetool`'s runtime, not this repo — `scripts/packaging/appimage.sh:282`/`:312` invokes the stock tool with no custom AppRun-side cleanup. `CLAUDE.md:179-183` documents `pkill -9 -f "mount_claude"` as the manual recovery for stale mounts after force-quit. No project-side unmount handler exists; the test asserts upstream behavior, not ours.
|
||||
|
||||
## S26 — Auto-update is disabled when installed via `apt` / `dnf`
|
||||
|
||||
> **⚠ Missing in build 1.5354.0** — No project-side suppression of upstream auto-update exists; the launcher exports `ELECTRON_FORCE_IS_PACKAGED=true`, which causes upstream's `lii()` gate to return true on Linux and the auto-update tick loop to start. Suppression is "accidental" — it relies on Electron's built-in `autoUpdater` module being unimplemented on Linux (so `setFeedURL`/`checkForUpdates` throw, the `error` listener logs, and no download happens). Tracked at [#567](https://github.com/aaddrick/claude-desktop-debian/issues/567); re-verify after next upstream bump.
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Auto-update path
|
||||
**Applies to:** All DEB/RPM rows
|
||||
**Issues:** [#567](https://github.com/aaddrick/claude-desktop-debian/issues/567)
|
||||
|
||||
**Steps:**
|
||||
1. Install via APT or DNF.
|
||||
2. Launch the app and let it sit for ~5 minutes.
|
||||
3. Inspect launcher log + filesystem for any auto-update download attempt.
|
||||
|
||||
**Expected:** When installed via the project's APT or DNF repo, the in-app auto-update path is suppressed. The app does not download replacement binaries (which would race the package manager). Updates flow through `apt upgrade` / `dnf upgrade` only. AppImage installs may continue to self-update or punt to the user.
|
||||
|
||||
**Diagnostics on failure:** Launcher log, network captures (look for downloads from `releases.anthropic.com` or `api.anthropic.com/api/desktop/linux/...`), filesystem changes under `~/.config/Claude/`.
|
||||
|
||||
**References:** [`docs/learnings/apt-worker-architecture.md`](../../learnings/apt-worker-architecture.md)
|
||||
|
||||
**Code anchors:** `scripts/launcher-common.sh:249` (`export ELECTRON_FORCE_IS_PACKAGED=true` — makes upstream think it's installed); `build-reference/app-extracted/.vite/build/index.js:508761-508769` (upstream `lii()` returns `hA.app.isPackaged` on Linux — passes the gate); `:508554-508559` (only suppression hook is enterprise-policy `disableAutoUpdates`, no Linux/distro carve-out); `:508770-508774` (feed URL `https://api.anthropic.com/api/desktop/linux/<arch>/squirrel/update?...`); `:508800-508803` (calls `hA.autoUpdater.setFeedURL` + `.checkForUpdates()` unconditionally on Linux). No patch in `scripts/patches/*.sh` neutralizes the autoUpdater module or sets `disableAutoUpdates`. AppImage continues to ship update info: `scripts/packaging/appimage.sh:308-309` (`gh-releases-zsync` zsync metadata embedded for releases).
|
||||
153
docs/testing/cases/extensibility.md
Normal file
153
docs/testing/cases/extensibility.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Extensibility — Plugins, MCP, Hooks, Memory
|
||||
|
||||
Tests covering the Anthropic & Partners plugin install flow, the plugin browser, MCP server config, hooks, `CLAUDE.md` memory loading, and per-user storage of plugins/worktrees. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T11 — Plugin install (Anthropic & Partners)
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Plugin browser → install flow
|
||||
**Applies to:** All rows
|
||||
**Issues:** [`docs/learnings/plugin-install.md`](../../learnings/plugin-install.md)
|
||||
|
||||
**Steps:**
|
||||
1. In a Code-tab session, click **+** → **Plugins** → **Add plugin**.
|
||||
2. Find an Anthropic & Partners plugin. Click **Install**.
|
||||
3. Verify it lands in **Manage plugins** and its skills appear in the slash menu.
|
||||
4. Re-install the same plugin to verify idempotence.
|
||||
|
||||
**Expected:** Install completes end-to-end: gate logic accepts, backend endpoint responds, plugin appears in the plugin list. Re-install is idempotent.
|
||||
|
||||
**Diagnostics on failure:** DevTools network panel during install, launcher log, `~/.claude/plugins/` content, the gate-logic code path (see learnings doc).
|
||||
|
||||
**References:** [`docs/learnings/plugin-install.md`](../../learnings/plugin-install.md), [Install plugins](https://code.claude.com/docs/en/desktop#install-plugins)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:507181` (`installPlugin` IPC + gate, with `pluginSource === "remote"` branch and CLI fallback); `:507193` log `[CustomPlugins] installPlugin: attempting remote API install`; `:465816` `dx()` returns `~/.claude/plugins`; `:465822` `installed_plugins.json` (idempotency record).
|
||||
|
||||
**Inventory anchor:** `…customize.main.navigation.button-by-name.add-plugin` (role `button`, label `Add plugin`); sibling `…button-by-name.browse-plugins` (label `Browse plugins`). Both are persistent in the Customize panel — anchors the entry-point click chain.
|
||||
|
||||
## T33 — Plugin browser
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Plugin browser UI
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Click **+** → **Plugins** → **Add plugin**.
|
||||
2. Confirm entries from the official Anthropic marketplace appear.
|
||||
3. Install a non-Anthropic plugin end-to-end.
|
||||
4. Verify it shows in **Manage plugins** and contributes its skills to the slash menu.
|
||||
|
||||
**Expected:** Plugin browser opens, shows the marketplace, install completes. Installed plugins appear under Manage plugins and contribute to the slash menu.
|
||||
|
||||
**Diagnostics on failure:** Screenshot of plugin browser, network captures, launcher log, `~/.claude/plugins/` listing.
|
||||
|
||||
**References:** [Install plugins](https://code.claude.com/docs/en/desktop#install-plugins)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:71392` (`CustomPlugins.listMarketplaces` IPC); `:71534` (`listAvailablePlugins` IPC); `:507176` (`listMarketplaces` main-process handler); `:496236` deep-link route `plugins/new` opens the browser surface.
|
||||
|
||||
**Inventory anchor:** `…customize.main.navigation.button-by-name.browse-plugins` (role `button`, label `Browse plugins`); sibling `…link-by-name.connectors` (role `link`, label `Connectors`). The browser surface itself (marketplace listings, install button) appears under a child dialog not captured at idle — re-capture with the dialog open to anchor those.
|
||||
|
||||
## T35 — MCP server config picked up
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** MCP / Code tab
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Add an MCP server to `~/.claude.json` or `<project>/.mcp.json`.
|
||||
2. Open a Code-tab session against the project.
|
||||
3. Type `/` in the prompt — verify MCP-provided tools appear in the slash menu (or invoke one directly).
|
||||
4. Separately, confirm `claude_desktop_config.json` (Chat-tab MCP) is **not** picked up by Code tab.
|
||||
|
||||
**Expected:** MCP servers in `~/.claude.json` or `.mcp.json` start when a Code session opens. Tools appear in the slash menu, calls succeed end-to-end. `claude_desktop_config.json` is separate per upstream docs.
|
||||
|
||||
**Diagnostics on failure:** Server stderr (MCP servers log to stderr), `~/.claude.json` and `.mcp.json` content, launcher log, DevTools console for MCP wire errors.
|
||||
|
||||
**References:** [MCP servers: desktop chat app vs Claude Code](https://code.claude.com/docs/en/desktop#shared-configuration), [`docs/learnings/plugin-install.md`](../../learnings/plugin-install.md)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:215418` (Code-tab loads `<project>/.mcp.json` per scanned dir); `:176766` reads `~/.claude.json`; `:489098` Code-session passes `settingSources: ["user", "project", "local"]` to the agent SDK; `:130821` `claude_desktop_config.json` is the chat-tab path constant (separate userData dir at `:130829` `kee()`), confirming the two trees do not overlap.
|
||||
|
||||
## T36 — Hooks fire
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Hooks runtime
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Add a `SessionStart` hook in `~/.claude/settings.json` that writes a marker file.
|
||||
2. Open a new Code-tab session.
|
||||
3. Confirm the marker file exists.
|
||||
4. Repeat with `PreToolUse` / `PostToolUse` hooks. Switch transcript view to Verbose to see the hook output.
|
||||
|
||||
**Expected:** Hooks defined in `~/.claude/settings.json` execute at the documented points. Hook output is visible in Verbose transcript mode. A failing hook surfaces a clear error rather than silently breaking the session.
|
||||
|
||||
**Diagnostics on failure:** Hook script stderr, marker file presence, launcher log, settings file content, Verbose transcript output.
|
||||
|
||||
**References:** [Shared configuration](https://code.claude.com/docs/en/desktop#shared-configuration)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:489098` Code-session sets `settingSources: ["user", "project", "local"]` (agent SDK reads `~/.claude/settings.json` hooks from this); `:455717` built-in `PreToolUse` hooks registry the runtime extends; `:455819` `UserPromptSubmit`; `:465680` `PostToolUse`; `:465754` `Stop`; `:493411` runtime emits `hook_started` / `hook_progress` / `hook_response` for `SessionStart` (Verbose transcript path).
|
||||
|
||||
## T37 — `CLAUDE.md` memory loads
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Memory / Code tab session prompt
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Confirm a project `CLAUDE.md` exists at the working folder.
|
||||
2. Confirm `~/.claude/CLAUDE.md` exists with at least one identifying token.
|
||||
3. Open a Code-tab session against the project.
|
||||
4. Ask Claude "what's in your CLAUDE.md" — verify the response matches on-disk content.
|
||||
5. Edit `CLAUDE.md`. Start a new session — verify the new content is loaded.
|
||||
|
||||
**Expected:** Project `CLAUDE.md` and `CLAUDE.local.md` at the working folder, plus `~/.claude/CLAUDE.md`, are loaded into the session's system prompt. Updates after edit on the next session start.
|
||||
|
||||
**Diagnostics on failure:** `cat CLAUDE.md` and `cat ~/.claude/CLAUDE.md` outputs, launcher log, system-prompt dump if accessible (Verbose transcript may show it).
|
||||
|
||||
**References:** [Shared configuration](https://code.claude.com/docs/en/desktop#shared-configuration)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:259691` working-dir scan reads `CLAUDE.md` and `.claude/CLAUDE.md`; `:455188` global account memory `zhA(accountId, orgId)` is copied to the per-session `.claude/CLAUDE.md` at session start (`[GlobalMemory] Copied CLAUDE.md`); `:283107` `cE()` resolves `CLAUDE_CONFIG_DIR` or `~/.claude`, the dir whose `CLAUDE.md` the agent SDK loads via `settingSources: ["user", ...]` (see T36 anchor at `:489098`).
|
||||
|
||||
## S27 — Plugins install per-user, not into system paths
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Plugin storage
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. As a non-root user, install a plugin via the desktop plugin browser.
|
||||
2. Inspect `~/.claude/plugins/` for the install.
|
||||
3. Verify nothing was written under `/usr` or other system-managed trees (`find /usr -newer /tmp/marker -name '*claude*' 2>/dev/null` after `touch /tmp/marker; install plugin`).
|
||||
|
||||
**Expected:** Plugins land under `~/.claude/plugins/` (or the equivalent per-user dir). Never under `/usr`. Non-root install/enable/disable works without `sudo`.
|
||||
|
||||
**Diagnostics on failure:** `find / -name '*<plugin-name>*' 2>/dev/null`, install logs, launcher log.
|
||||
|
||||
**References:** [Install plugins](https://code.claude.com/docs/en/desktop#install-plugins)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:283107` `cE()` resolves the config root to `CLAUDE_CONFIG_DIR` or `~/.claude` — never `/usr`; `:465815` `dx()` returns `<cE()>/plugins`; `:465821`/`:465824`/`:465827` `installed_plugins.json`, `known_marketplaces.json`, `marketplaces/` all sit under `dx()`. No system-path writes in the install path.
|
||||
|
||||
## S28 — Worktree creation surfaces clear error on read-only mounts
|
||||
|
||||
**Severity:** Could
|
||||
**Surface:** Worktree creation on read-only filesystem
|
||||
**Applies to:** All rows (NixOS users hit this most often)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Place a project on a read-only mount (e.g. squashfs, NFS read-only export, `mount -o ro` bind).
|
||||
2. Open a Code-tab session against it.
|
||||
3. Try to start a parallel session that needs a worktree.
|
||||
|
||||
**Expected:** Worktree creation fails with a clear error pointing at the read-only mount. No silent loss of work, no writes to a wrong directory, no parent-repo corruption.
|
||||
|
||||
**Diagnostics on failure:** `mount | grep <project-path>`, `git worktree add` direct invocation (does it fail the same way?), launcher log, screenshot of error dialog.
|
||||
|
||||
**References:** [Work in parallel with sessions](https://code.claude.com/docs/en/desktop#work-in-parallel-with-sessions)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:462841` worktree parent dir is `<repo>/.claude/worktrees` (or `chillingSlothLocation.customPath` override at `:462836`); `:462928` `git worktree add` failure path returns `null` after `R.error("Failed to create git worktree: …")`; `:462760` `Sbn()` classifies "Permission denied" / "Access is denied" / "could not lock config file" as `"permission-denied"` (the read-only-mount taxonomy bucket).
|
||||
77
docs/testing/cases/launch.md
Normal file
77
docs/testing/cases/launch.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Launch & Process Lifecycle
|
||||
|
||||
Tests covering app startup, the `--doctor` health check, package-format detection, and multi-instance behavior. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T01 — App launch
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** App startup
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
**Runner:** [`tools/test-harness/src/runners/T01_app_launch.spec.ts`](../../../tools/test-harness/src/runners/T01_app_launch.spec.ts)
|
||||
|
||||
**Steps:**
|
||||
1. From a clean session, run `claude-desktop` (deb/rpm) or launch the AppImage.
|
||||
2. Wait up to 10 seconds.
|
||||
|
||||
**Expected:** Main window opens within ~10s. No error toast, no crash. The launcher log at `~/.cache/claude-desktop-debian/launcher.log` shows the expected backend selection (`Using X11 backend via XWayland` on Wayland sessions, or native Wayland when forced).
|
||||
|
||||
**Diagnostics on failure:** Launcher log, `--doctor` output, session env (`XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP`), `dmesg | tail -50`, any crash report under `~/.config/Claude/logs/`.
|
||||
|
||||
**References:** —
|
||||
**Code anchors:** `scripts/launcher-common.sh:98` (X11-via-XWayland log line), `scripts/launcher-common.sh:102` (native-Wayland log line), `build-reference/app-extracted/.vite/build/index.js:524875` (`app.on("ready")` registration), `build-reference/app-extracted/.vite/build/index.js:524881-524931` (main `BrowserWindow` factory `Ori()` — `titleBarStyle`, mainWindow.js preload, initial `show`).
|
||||
|
||||
## T02 — Doctor health check
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** CLI / `--doctor`
|
||||
**Applies to:** All rows
|
||||
**Issues:** [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538)
|
||||
|
||||
**Steps:**
|
||||
1. Run `claude-desktop --doctor`.
|
||||
2. Inspect exit code (`echo $?`) and stdout/stderr.
|
||||
|
||||
**Expected:** Exits 0. All checks PASS or report expected WARN. No FAIL checks. Doctor currently reports display-server, menu-bar mode, Electron path/version, Chrome sandbox perms, SingletonLock, MCP config, Node.js, desktop entry, disk space, and a Cowork section — it does **not** surface the resolved titlebar style. See also [T13](#t13--doctor-reports-correct-package-format) for the package-format detection slice.
|
||||
|
||||
**Diagnostics on failure:** Full `--doctor` output, the install path being inspected (`which claude-desktop`), package metadata (`dpkg -S` / `rpm -qf` against the binary).
|
||||
|
||||
**References:** [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538)
|
||||
**Code anchors:** `scripts/doctor.sh:280` (`run_doctor` entry point), `scripts/doctor.sh:301-319` (display-server check), `scripts/doctor.sh:401-417` (SingletonLock check), `scripts/doctor.sh:744-753` (exit-code summary).
|
||||
|
||||
## T13 — Doctor reports correct package format
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** CLI / `--doctor`
|
||||
**Applies to:** All rows (currently `✗` on every Fedora row — see [S05](./distribution.md#s05--doctor-recognises-dnf-installed-package-doesnt-false-flag-as-appimage))
|
||||
**Issues:** — *(no issue filed; surfaced via session-capture review)*
|
||||
|
||||
**Steps:**
|
||||
1. Install via the relevant package manager (`apt` / `dnf`) or AppImage.
|
||||
2. Run `claude-desktop --doctor` and look for the install-method line.
|
||||
|
||||
**Expected:** Doctor identifies the install method correctly. On RPM-based distros (Fedora, Nobara) it does **not** report `not found via dpkg (AppImage?)` — that warning currently false-flags every dnf install. On DEB-based distros it does not assume AppImage when dpkg returns the package metadata.
|
||||
|
||||
**Diagnostics on failure:** `dpkg -S $(which claude-desktop)`, `rpm -qf $(which claude-desktop)`, full `--doctor` output, the line of doctor source that decides the format.
|
||||
|
||||
**References:** [S05](./distribution.md#s05--doctor-recognises-dnf-installed-package-doesnt-false-flag-as-appimage)
|
||||
**Code anchors:** `scripts/doctor.sh:353-362` — version probe is dpkg-only (`dpkg-query -W -f='${Version}' claude-desktop`); on RPM/AppImage hosts that lack `dpkg-query` the block is skipped, but on a Fedora host that *does* have `dpkg-query` installed (e.g. for cross-distro tooling) the `_warn 'claude-desktop not found via dpkg (AppImage?)'` branch fires for any dnf-installed copy. There is no corresponding `rpm -qf` / `rpm -q claude-desktop` branch.
|
||||
|
||||
## T14 — Multi-instance behavior
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** App lifecycle
|
||||
**Applies to:** All rows
|
||||
**Issues:** [PR #536](https://github.com/aaddrick/claude-desktop-debian/pull/536) (closed, docs-only — no in-tree opt-in flag)
|
||||
|
||||
**Steps:**
|
||||
1. Launch `claude-desktop`. Wait for the main window.
|
||||
2. Launch `claude-desktop` again from another terminal or `.desktop` invocation.
|
||||
3. Optionally: follow the manual `--user-data-dir` recipe sketched in PR #536 (separate Electron `userData` per profile so each gets its own `SingletonLock` — note the PR was closed, the recipe is not shipped in-tree).
|
||||
|
||||
**Expected:** Second invocation focuses the existing window — no new process. The launcher's `cleanup_stale_lock` removes a `SingletonLock` whose owning PID is no longer running. With separate `--user-data-dir` per profile (manual workaround, not an in-tree feature), each profile runs an independent Electron instance.
|
||||
|
||||
**Diagnostics on failure:** `pgrep -af claude-desktop`, `ls -la ~/.config/Claude/SingletonLock`, launcher log, any "another instance is running" dialog text.
|
||||
|
||||
**References:** [PR #536](https://github.com/aaddrick/claude-desktop-debian/pull/536)
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:525162-525173` (`requestSingleInstanceLock()` + `app.on("second-instance", ...)` — shows existing window, restores if minimized, focuses), `build-reference/app-extracted/.vite/build/index.js:525204-525207` (early-return on lost lock at `app.on("ready")`), `scripts/launcher-common.sh:187-208` (`cleanup_stale_lock` — drops a `SingletonLock` symlink whose `hostname-PID` target points at a dead PID).
|
||||
282
docs/testing/cases/platform-integration.md
Normal file
282
docs/testing/cases/platform-integration.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Platform Integration
|
||||
|
||||
Tests covering autostart, Cowork integration, WebGL graceful degradation, `.desktop`-launch env inheritance, encrypted env-var storage, the macOS/Windows-only Computer Use feature, and Dispatch session pairing. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T09 — AutoStart via XDG
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** XDG Autostart
|
||||
**Applies to:** All rows
|
||||
**Issues:** [PR #450](https://github.com/aaddrick/claude-desktop-debian/pull/450)
|
||||
|
||||
**Steps:**
|
||||
1. In Settings, toggle "Open at Login" / "Start at boot" ON.
|
||||
2. Inspect `~/.config/autostart/` for a `.desktop` entry.
|
||||
3. Logout/login. Verify app launches automatically.
|
||||
4. Toggle OFF. Verify the autostart entry is removed.
|
||||
|
||||
**Expected:** Toggling ON creates a `~/.config/autostart/*.desktop` entry that is XDG-spec compliant (not a custom systemd unit or shell hook). After login, app launches automatically. Toggling OFF removes the entry.
|
||||
|
||||
**Diagnostics on failure:** `ls -la ~/.config/autostart/`, content of the .desktop file, `desktop-file-validate` on it, launcher log.
|
||||
|
||||
**References:** [PR #450](https://github.com/aaddrick/claude-desktop-debian/pull/450)
|
||||
|
||||
**Code anchors:**
|
||||
- `scripts/frame-fix-wrapper.js:376` — XDG Autostart shim
|
||||
intercepting `app.{get,set}LoginItemSettings` (writes/removes
|
||||
`$XDG_CONFIG_HOME/autostart/claude-desktop.desktop`).
|
||||
- `scripts/frame-fix-wrapper.js:429` — `buildAutostartContent()`
|
||||
emits the spec-compliant `[Desktop Entry]` block.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:524205` —
|
||||
upstream `isStartupOnLoginEnabled` / `setStartupOnLoginEnabled` IPC
|
||||
surface that the wrapper interposes on.
|
||||
|
||||
## T10 — Cowork integration
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Cowork tab + VM daemon
|
||||
**Applies to:** All rows
|
||||
**Issues:** [`docs/learnings/cowork-vm-daemon.md`](../../learnings/cowork-vm-daemon.md)
|
||||
|
||||
**Steps:**
|
||||
1. Sign into the app. Open the Cowork tab.
|
||||
2. Confirm Cowork-specific UI renders (ghost icon in topbar, Cowork menus).
|
||||
3. Trigger a Cowork action that needs the VM daemon.
|
||||
4. Kill the VM daemon process; verify it respawns within the documented timeout.
|
||||
|
||||
**Expected:** Cowork features render. VM daemon spawns when needed, files are visible, daemon respawns within the documented timeout if it crashes.
|
||||
|
||||
**Diagnostics on failure:** `pgrep -af cowork`, daemon logs, launcher log, the respawn-logic code path (see learnings doc).
|
||||
|
||||
**References:** [`docs/learnings/cowork-vm-daemon.md`](../../learnings/cowork-vm-daemon.md)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:143371` —
|
||||
upstream's Windows named-pipe path (`\\.\pipe\cowork-vm-service`)
|
||||
that `scripts/patches/cowork.sh` Patch 1 rewrites to
|
||||
`$XDG_RUNTIME_DIR/cowork-vm-service.sock`.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:143453` —
|
||||
`kUe()` retry loop (5 attempts, 1 s gap) that the auto-launch
|
||||
injection from Patch 6 piggybacks on after the rewrite.
|
||||
- `scripts/patches/cowork.sh:244` — Patch 6 (auto-launch + stdio
|
||||
pipe + 10 s rate-limited respawn — issue #408).
|
||||
- `scripts/patches/cowork.sh:365` — Patch 6b (extends the
|
||||
reinstall-delete list with `sessiondata.img` / `rootfs.img.zst`
|
||||
so a wedged daemon can self-recover).
|
||||
|
||||
## T12 — WebGL warn-only
|
||||
|
||||
**Severity:** Could
|
||||
**Surface:** Chromium GPU diagnostics
|
||||
**Applies to:** All rows (especially VM rows and hybrid-GPU laptops)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch the app. Open DevTools → navigate to `chrome://gpu`.
|
||||
2. Inspect WebGL1/WebGL2 status.
|
||||
3. Use the app for ~5 minutes — exercise UI, sidebar, settings.
|
||||
|
||||
**Expected:** WebGL1/2 may report as blocklisted (typical on virtio-gpu in VMs and on hybrid GPU laptops). This is informational. UI continues to render without graphical glitches; no feature is broken by the blocklist.
|
||||
|
||||
**Diagnostics on failure:** `chrome://gpu` full content, screenshot of any visual glitch, `glxinfo | head -20` (X11) or `eglinfo` (Wayland), `lspci -k | grep -A2 VGA`.
|
||||
|
||||
**References:** —
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:524809` —
|
||||
`app.disableHardwareAcceleration()` is gated on the user-toggleable
|
||||
`isHardwareAccelerationDisabled` setting; upstream does not pass
|
||||
`--ignore-gpu-blocklist` or `--use-gl=*`, so chrome://gpu reflects
|
||||
Chromium's stock blocklist behaviour.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:500571` —
|
||||
the only `webgl:!1` override is scoped to the feedback popup
|
||||
(`in-memory-feedback` partition); main UI does not disable WebGL.
|
||||
|
||||
## S17 — App launched from `.desktop` inherits shell `PATH`
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** `.desktop`-launch env handling
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Configure `~/.bashrc` (or `~/.zshrc`) with `export PATH="$HOME/.custom-bin:$PATH"` and a custom binary in that dir.
|
||||
2. Launch the app via dmenu/krunner/GNOME Activities/Plasma launcher (i.e. **not** from a terminal).
|
||||
3. Open a Code-tab terminal pane. Run `which <custom-binary>`.
|
||||
4. Repeat for `npm`, `node`, `git`, `gh`.
|
||||
|
||||
**Expected:** Code session can find tools defined in the user's shell profile, even when the app was launched non-interactively. Either the launcher script sources the user's shell profile, or the app reads `~/.bashrc` / `~/.zshrc` to extract `PATH` the way macOS does.
|
||||
|
||||
**Diagnostics on failure:** `echo $PATH` from inside the integrated terminal, the env passed to the app process (`cat /proc/$(pgrep -f electron)/environ | tr '\0' '\n' | grep PATH`), launcher log.
|
||||
|
||||
**References:** [Local sessions](https://code.claude.com/docs/en/desktop#local-sessions), [Session not finding installed tools](https://code.claude.com/docs/en/desktop#session-not-finding-installed-tools)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259300` —
|
||||
`SLr()` resolves the bundled `shell-path-worker/shellPathWorker.js`.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259349` —
|
||||
`NLr()` forks it via `utilityProcess.fork`; on success
|
||||
`FX()` (line 259311) merges the extracted env into `process.env`.
|
||||
- `build-reference/app-extracted/.vite/build/shell-path-worker/shellPathWorker.js:205`
|
||||
— `extractPathFromShell()` runs the user's login shell (`-l -i`)
|
||||
and parses the printed `$PATH` between sentinels (mac-style env
|
||||
inheritance now applied on Linux too).
|
||||
|
||||
## S18 — Local environment editor persists across reboot
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Local env editor / encrypted store
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Open the local environment editor. Add `TEST_VAR=hello`.
|
||||
2. Restart the app — verify variable is still there.
|
||||
3. Reboot the host. Sign back in. Verify variable is still there.
|
||||
|
||||
**Expected:** Variables saved via the local environment editor (per-app, encrypted) survive a logout/login cycle and a full reboot. On Linux this implies the encrypted store is wired to libsecret / kwallet / gnome-keyring and unlocks at session start.
|
||||
|
||||
**Diagnostics on failure:** `secret-tool search` (libsecret), `kwallet5-query` (KDE), `seahorse` UI inspection (GNOME), launcher log, the env-editor IPC call.
|
||||
|
||||
**References:** [Local sessions](https://code.claude.com/docs/en/desktop#local-sessions)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259251` —
|
||||
`I2t = new K_({ name: "ccd-environment-config", ... })` electron-store
|
||||
backing file (`~/.config/Claude/ccd-environment-config.json`).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259253` —
|
||||
`hLr()` writes via `safeStorage.encryptString` (libsecret on Linux).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:259268` —
|
||||
`J1()` decrypts on read; bails to `{}` if `safeStorage` reports
|
||||
encryption unavailable (no keyring backend running).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:70782` —
|
||||
`LocalSessionEnvironment.save` IPC entry that calls into `hLr`.
|
||||
|
||||
## S22 — Computer-use toggle is absent or visibly disabled on Linux
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Settings → Desktop app → General
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Open Settings → Desktop app → General.
|
||||
2. Look for the "Computer use" toggle.
|
||||
|
||||
**Expected:** Toggle either does not render on Linux, or renders as a disabled control with a clear "not supported on Linux" hint. Must not appear functional and silently fail (e.g. flip on but never produce screen-control behavior).
|
||||
|
||||
**Diagnostics on failure:** Screenshot of the Settings page, DevTools inspection of the toggle DOM (is it conditionally hidden? disabled? always-rendered?), launcher log.
|
||||
|
||||
**References:** [Let Claude use your computer](https://code.claude.com/docs/en/desktop#let-claude-use-your-computer), [Dispatch and computer use](https://claude.com/blog/dispatch-and-computer-use)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:240557` —
|
||||
`qDA = new Set(["darwin", "win32"])` excludes Linux from the
|
||||
computer-use platform set.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:241190` —
|
||||
`TF()` (the master enable check) short-circuits to `false` when
|
||||
`qDA.has(process.platform)` is false, so toggling
|
||||
`chicagoEnabled` on Linux can't activate the feature.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:242387` —
|
||||
`tvr()` returns `{ status: "unsupported", reason: "Computer use
|
||||
is not available on this platform", unsupportedCode:
|
||||
"unsupported_platform" }` for the Settings UI — confirms the
|
||||
toggle should render with a platform-unavailable hint, not silent
|
||||
failure.
|
||||
|
||||
## S23 — Dispatch-spawned sessions don't soft-lock on a never-approvable computer-use prompt
|
||||
|
||||
**Severity:** Critical (for Dispatch users)
|
||||
**Surface:** Dispatch session lifecycle on Linux
|
||||
**Applies to:** All rows with Dispatch enabled
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. From a paired phone, dispatch a task that would invoke computer use.
|
||||
2. Observe the Code-tab session that spawns on the desktop.
|
||||
3. Try to interact with other parts of the app.
|
||||
|
||||
**Expected:** Permission prompt times out or denies cleanly rather than hanging the session indefinitely. User can continue interacting with the rest of the app.
|
||||
|
||||
**Diagnostics on failure:** Screenshot of session state, launcher log, sidebar state (is the Dispatch session blocking the whole sidebar?), `pgrep -af claude`.
|
||||
|
||||
**References:** [Sessions from Dispatch](https://code.claude.com/docs/en/desktop#sessions-from-dispatch)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:512789` —
|
||||
`tool_permission_request` notification handler explicitly skips
|
||||
`toolName.startsWith("computer:")`, so the desktop never queues a
|
||||
user-facing prompt for computer-use tool calls (which couldn't run
|
||||
on Linux anyway — see S22).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:241190` —
|
||||
`TF()` gates computer-use execution off entirely on Linux, so a
|
||||
Dispatch-spawned session that requests it should hit the upstream
|
||||
"Set up computer use" remote-client setup card
|
||||
(`index.js:330114`) rather than block on a desktop prompt.
|
||||
|
||||
## S24 — Dispatch-spawned Code session appears with badge and notification
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Dispatch handoff
|
||||
**Applies to:** All rows with Dispatch enabled
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. From a paired phone, dispatch a task that routes to Code (e.g. "fix this bug").
|
||||
2. Observe the desktop sidebar.
|
||||
3. Confirm a desktop notification fires.
|
||||
4. Open the session and confirm 30-min approval expiry per upstream docs.
|
||||
|
||||
**Expected:** Dispatch task creates a sidebar entry tagged **Dispatch**, posts a desktop notification, and lands ready for review. App-permission approvals on this session expire after 30 minutes per upstream docs.
|
||||
|
||||
**Diagnostics on failure:** Screenshot of sidebar (badge present?), notification daemon state, launcher log, the Dispatch pairing config under `~/.config/Claude/`.
|
||||
|
||||
**References:** [Sessions from Dispatch](https://code.claude.com/docs/en/desktop#sessions-from-dispatch), [Dispatch and computer use](https://claude.com/blog/dispatch-and-computer-use)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:144561` —
|
||||
`Sd = "dispatch_child"` session-type constant.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:512200` —
|
||||
`onRemoteSessionStart` IPC routes a Dispatch-initiated child
|
||||
session into the local sidebar via `dispatchOnRemoteSessionStart`.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:285621` —
|
||||
`notifyDispatchParentIfNeeded()` posts the
|
||||
`Task "<title>" <state>` meta-notification when the dispatch
|
||||
child finishes (lands the result in the parent thread's
|
||||
notification queue).
|
||||
- `build-reference/app-extracted/.vite/build/index.js:285954` —
|
||||
`kind:"dispatch_child"` is the sidebar badge tag.
|
||||
|
||||
## S25 — Mobile pairing survives Linux session restart
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Dispatch pairing persistence
|
||||
**Applies to:** All rows with Dispatch enabled
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Pair the desktop with a phone.
|
||||
2. Quit the app fully. Re-launch.
|
||||
3. Try a Dispatch task. Verify pairing still works without re-pairing.
|
||||
4. Logout/login the desktop. Re-test.
|
||||
|
||||
**Expected:** Pairing remains active across app restart and logout/login. Pairing token is stored under `~/.config/Claude/` (or wherever the secure store lives) and survives.
|
||||
|
||||
**Diagnostics on failure:** `ls -la ~/.config/Claude/`, secret-store inspection, launcher log, pairing-flow IPC.
|
||||
|
||||
**References:** [Sessions from Dispatch](https://code.claude.com/docs/en/desktop#sessions-from-dispatch)
|
||||
|
||||
**Code anchors:**
|
||||
- `build-reference/app-extracted/.vite/build/index.js:511984` —
|
||||
`ZEe = "coworkTrustedDeviceToken"` electron-store key for the
|
||||
trusted-device token.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:511989` —
|
||||
`oYn()` writes the token via `safeStorage.encryptString` (libsecret
|
||||
on Linux); `aYn()` (`:512003`) decrypts on read.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:512022` —
|
||||
`gYn()` re-enrolls via `POST /api/auth/trusted_devices` only when
|
||||
there's no cached token, so a successful pair survives restart.
|
||||
- `build-reference/app-extracted/.vite/build/index.js:330229` —
|
||||
`_5r = "bridge-state.json"` (per-org/account bridge state under
|
||||
`~/.config/Claude/bridge-state.json`); `JF()`/`X0A()` at `:330230`
|
||||
read/locate it.
|
||||
125
docs/testing/cases/routines.md
Normal file
125
docs/testing/cases/routines.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Routines & Scheduled Tasks
|
||||
|
||||
Tests covering the Routines page, scheduled task firing, catch-up runs after suspend, and the suspend-inhibit toggle. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T26 — Routines page renders
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Routines page
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Sign into the app, open the Code tab.
|
||||
2. Click **Routines** in the sidebar.
|
||||
3. Click **New routine** → **Local**.
|
||||
|
||||
**Expected:** Routines list opens. New-routine form shows all schedule presets (Manual, Hourly, Daily, Weekdays, Weekly), permission-mode picker, model picker, working-folder picker, and worktree toggle.
|
||||
|
||||
**Diagnostics on failure:** Screenshot of the Routines page (or the failure state), DevTools console output, launcher log, network captures of the routines API call (`mitmproxy` or DevTools network panel).
|
||||
|
||||
**References:** [Schedule recurring tasks](https://code.claude.com/docs/en/desktop-scheduled-tasks)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:507710` (create payload — `permissionMode`, `model`, `userSelectedFolders`, `useWorktree`, `cronExpression`, `fireAt`); `build-reference/app-extracted/.vite/build/index.js:280299` (`@hourly: "0 * * * *"` preset)
|
||||
|
||||
**Inventory anchors:** `root.complementary.button-by-name.routines` (sidebar entry); `root.complementary.button-by-name.routines.main.region.button-by-name.new-routine` (form trigger); siblings `…button-by-name.all`, `…button-by-name.calendar` (list-view tabs). Preset list (Hourly/Daily/etc.) lives inside the New-routine modal and is not in the idle-state inventory — re-capture with the modal open to anchor.
|
||||
|
||||
## T27 — Scheduled task fires and notifies
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Routines runtime + libnotify
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Create a Manual task with a simple instruction (e.g. "echo hello").
|
||||
2. Click **Run now**. Observe.
|
||||
3. Optionally: create an Hourly task and verify across the next hour boundary.
|
||||
|
||||
**Expected:** A fresh session starts, appears in the **Scheduled** section of the sidebar, and posts a desktop notification when it begins. Subsequent runs respect the deterministic offset described in upstream docs.
|
||||
|
||||
**Diagnostics on failure:** Launcher log, screenshot of sidebar, `gdbus call --session --dest=org.freedesktop.Notifications --object-path=/org/freedesktop/Notifications --method=org.freedesktop.DBus.Introspectable.Introspect` (verify daemon present), task SKILL.md content under `~/.claude/scheduled-tasks/<task-name>/`.
|
||||
|
||||
**References:** [How scheduled tasks run](https://code.claude.com/docs/en/desktop-scheduled-tasks#how-scheduled-tasks-run)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:282332` (`runNow(A)` — manual dispatch); `build-reference/app-extracted/.vite/build/index.js:512837` (`Rc.showNotification(...,scheduled-${l},...)` — desktop notification on completion); `build-reference/app-extracted/.vite/build/index.js:282654` (`getJitterSecondsForTask` — deterministic per-task offset via `v2r(A, n*60)`, capped by `dispatchJitterMaxMinutes` default 10)
|
||||
|
||||
## T28 — Scheduled task catch-up after suspend
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Routines runtime / wake-from-suspend
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Create an Hourly task.
|
||||
2. Suspend the host (`systemctl suspend`).
|
||||
3. Wait past at least one hourly slot. Wake the host.
|
||||
4. Observe whether a catch-up run starts.
|
||||
|
||||
**Expected:** Exactly one catch-up run for the most recently missed slot (older missed slots are discarded). Notification announces the catch-up. Missed runs older than seven days are not retried.
|
||||
|
||||
**Diagnostics on failure:** Task history in the routines detail page, launcher log, `journalctl --since="-1 day" | grep -i suspend`.
|
||||
|
||||
**References:** [Missed runs](https://code.claude.com/docs/en/desktop-scheduled-tasks#missed-runs)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:281695` (`R2r` — walks back from now, capped at `10080 * 60 * 1e3` ms = 7 days, returns at most one missed slot, dedupes by `IfA` bucket-key); `build-reference/app-extracted/.vite/build/index.js:281942` (`scheduledTaskPostWakeDelayMs` default 60000 ms — gates dispatch after `powerMonitor.on("resume")`); `build-reference/app-extracted/.vite/build/index.js:282569` (catch-up branch: `c ? 0 : this.getJitterSecondsForTask(o.id)` — missed-slot dispatch skips jitter)
|
||||
|
||||
## S19 — `CLAUDE_CONFIG_DIR` redirects scheduled-task storage
|
||||
|
||||
**Severity:** Could
|
||||
**Surface:** Config dir env var
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. In the local environment editor, set `CLAUDE_CONFIG_DIR=/some/other/path`.
|
||||
2. Restart the app.
|
||||
3. Create a scheduled task. Inspect filesystem.
|
||||
|
||||
**Expected:** Tasks resolve under `${CLAUDE_CONFIG_DIR}/scheduled-tasks/<task-name>/SKILL.md` rather than `~/.claude/scheduled-tasks/`. Pre-existing tasks under the old path are not silently dropped.
|
||||
|
||||
**Diagnostics on failure:** `ls -la ${CLAUDE_CONFIG_DIR}/scheduled-tasks/` and `~/.claude/scheduled-tasks/`, launcher log, env dump.
|
||||
|
||||
**References:** [Manage scheduled tasks](https://code.claude.com/docs/en/desktop-scheduled-tasks#manage-scheduled-tasks)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:283108` (`cE()` — resolves `process.env.CLAUDE_CONFIG_DIR ?? ~/.claude`, handles `~` prefix); `build-reference/app-extracted/.vite/build/index.js:283118` (`Tce()` — returns `${cE()}/scheduled-tasks`); `build-reference/app-extracted/.vite/build/index.js:488317` and `:509032` (call sites passing `taskFilesDir: Tce()` into the scheduled-tasks substrate)
|
||||
|
||||
## S20 — "Keep computer awake" inhibits idle suspend
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Suspend inhibitor
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Open Settings → Desktop app → General → "Keep computer awake". Toggle ON.
|
||||
2. Run `systemd-inhibit --list`. Look for a Claude-owned lock with `idle:sleep` what.
|
||||
3. Toggle OFF. Re-run `systemd-inhibit --list` — lock should be gone.
|
||||
|
||||
**Expected:** Toggling ON registers `systemd-inhibit --what=idle:sleep` (or the `org.freedesktop.PowerManagement.Inhibit` DBus call). Toggling OFF releases the lock.
|
||||
|
||||
**Diagnostics on failure:** `systemd-inhibit --list` before/after, `busctl --user tree org.freedesktop.PowerManagement` (if the path uses that backend), launcher log, the relevant settings IPC call.
|
||||
|
||||
**References:** [How scheduled tasks run](https://code.claude.com/docs/en/desktop-scheduled-tasks#how-scheduled-tasks-run)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:241897` (`hA.powerSaveBlocker.start("prevent-app-suspension")` — single block call, ref-counted by `PhA` Set); `build-reference/app-extracted/.vite/build/index.js:241905` (`hA.powerSaveBlocker.stop(BP)` when last claim drops); `build-reference/app-extracted/.vite/build/index.js:241909` (settings binding: `PHe = "keepAwakeEnabled"`); `build-reference/app-extracted/.vite/build/index.js:241914` (`vy.on("keepAwakeEnabled", YHe)` — toggle observer)
|
||||
|
||||
## S21 — Lid-close still suspends per OS policy
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Suspend inhibitor scope
|
||||
**Applies to:** All rows (laptop hosts)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. With "Keep computer awake" ON, close the laptop lid.
|
||||
2. Observe whether the machine suspends.
|
||||
|
||||
**Expected:** Machine still suspends per logind's `HandleLidSwitch=suspend`. The inhibit lock taken in [S20](#s20--keep-computer-awake-inhibits-idle-suspend) targets `idle:sleep`, not `handle-lid-switch`, so lid-close behavior is unaffected.
|
||||
|
||||
**Diagnostics on failure:** `loginctl show-session --property=HandleLidSwitch`, `journalctl --since="-5 minutes"`, the actual `--what=` flags on the Claude-owned inhibitor.
|
||||
|
||||
**References:** [How scheduled tasks run](https://code.claude.com/docs/en/desktop-scheduled-tasks#how-scheduled-tasks-run)
|
||||
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:241897` (only `"prevent-app-suspension"` is passed to `powerSaveBlocker.start` — Electron maps this to `idle:sleep`); no `handle-lid-switch` / `HandleLidSwitch` token anywhere in `index.js` (verified via `grep -nE 'lid|HandleLidSwitch|handle-lid' index.js`)
|
||||
365
docs/testing/cases/shortcuts-and-input.md
Normal file
365
docs/testing/cases/shortcuts-and-input.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# Shortcuts & Input
|
||||
|
||||
Tests covering URL handling, the Quick Entry global shortcut, and DE-specific shortcut/input failure modes. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T05 — `claude://` URL handler opens links in-app
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** URL handler / xdg-open
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. With Claude Desktop running, in another app run `xdg-open 'claude://chat/new?q=hello'` (or click a `claude://` link in a browser/terminal).
|
||||
2. Observe.
|
||||
|
||||
**Expected:** Link is delivered to the running Claude Desktop process — no new browser tab, no crash, no error dialog. (Upstream's `claudeURLHandler` only accepts the `claude:`, `claude-dev:`, `claude-nest:`, `claude-nest-dev:`, `claude-nest-prod:` schemes; bare `https://claude.ai/...` clicks route through the user's default browser, not Claude Desktop. The `.desktop` file registers `MimeType=x-scheme-handler/claude` only, matching the upstream contract.)
|
||||
|
||||
**Diagnostics on failure:** `xdg-mime query default x-scheme-handler/claude`, the registered `.desktop` file content, launcher log, app crash report (if any), `coredumpctl list claude-desktop` (if subprocess died — see [S06](#s06--url-handler-doesnt-segfault-on-native-wayland)).
|
||||
|
||||
**References:** upstream `index.js:495996-496009` (`bEe()` protocol filter), `index.js:524819` (`setAsDefaultProtocolClient("claude")`), `index.js:525140-525148` (macOS `open-url`), `index.js:525162-525172` (Linux/Win `second-instance` argv path), project `scripts/packaging/{deb,rpm,appimage}.sh` (MimeType registration).
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:495996, 524819, 525140, 525162
|
||||
|
||||
## T06 — Quick Entry global shortcut (unfocused)
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Global shortcut / Electron globalShortcut
|
||||
**Applies to:** All rows
|
||||
**Issues:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406), [PR #102](https://github.com/aaddrick/claude-desktop-debian/pull/102), [PR #153](https://github.com/aaddrick/claude-desktop-debian/pull/153)
|
||||
|
||||
**Steps:**
|
||||
1. Launch app, focus another application (browser, terminal).
|
||||
2. Press the configured Quick Entry shortcut (default `Ctrl+Alt+Space`).
|
||||
3. Type a prompt and submit.
|
||||
4. Repeat from a different virtual desktop / workspace.
|
||||
|
||||
**Expected:** Quick Entry prompt opens regardless of focused app or workspace. Shortcut is globally registered, not focus-bound. Submitting creates a new session and shows it in the main window.
|
||||
|
||||
**Diagnostics on failure:** Launcher log (look for `Using X11 backend via XWayland (for global hotkey support)` or portal-shortcut markers), `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP`, output of `gdbus call --session --dest=org.freedesktop.portal.Desktop --object-path=/org/freedesktop/portal/desktop --method=org.freedesktop.DBus.Introspectable.Introspect`, the active patch set in `scripts/patches/`.
|
||||
|
||||
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:499376 (`ort` default accelerator: `"Ctrl+Alt+Space"` non-mac, `"Alt+Space"` on mac), 499416 (`globalShortcut.register`), 525287-525290 (Quick Entry trigger callback registered against `Pw.QUICK_ENTRY`).
|
||||
|
||||
## S06 — URL handler doesn't segfault on native Wayland
|
||||
|
||||
**Severity:** Critical (for wlroots rows)
|
||||
**Surface:** URL handler subprocess
|
||||
**Applies to:** Sway, Niri, Hypr-O, Hypr-N (any native-Wayland session)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch the app on a native Wayland session (no XWayland forcing).
|
||||
2. From another app, click a `claude.ai` link or run `xdg-open https://claude.ai/...`.
|
||||
|
||||
**Expected:** Link opens in-app cleanly. No `Failed to connect to Wayland display` errors followed by a SIGSEGV from the URL handler subprocess.
|
||||
|
||||
**Diagnostics on failure:** `coredumpctl info claude-desktop`, `WAYLAND_DISPLAY` env in the subprocess (if capturable via `strace -f -e execve`), launcher log, full env dump.
|
||||
|
||||
**Currently:** Sway capture shows `Failed to connect to Wayland display: No such file or directory (2)` followed by `Segmentation fault` from the URL handler subprocess. The main app process keeps running; the URL handler dies. Not yet filed.
|
||||
|
||||
**References:** —
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:495996 (`bEe()` URL handler), 525140-525148 (`open-url` macOS), 525162-525172 (`second-instance` argv path on Linux); project `scripts/launcher-common.sh:96-99` (`--ozone-platform=x11` default), `scripts/launcher-common.sh:41-44` (Niri force-native-Wayland).
|
||||
|
||||
## S07 — `CLAUDE_USE_WAYLAND=1` opt-in path works without crashing
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Native Wayland mode
|
||||
**Applies to:** Sway, Niri, Hypr-O, Hypr-N
|
||||
**Issues:** [PR #228](https://github.com/aaddrick/claude-desktop-debian/pull/228), [PR #232](https://github.com/aaddrick/claude-desktop-debian/pull/232)
|
||||
|
||||
**Steps:**
|
||||
1. Set `CLAUDE_USE_WAYLAND=1`. Launch the app.
|
||||
2. Use the app for ~5 minutes — open chats, switch tabs, exercise basic flows.
|
||||
|
||||
**Expected:** App forces native Wayland (no XWayland), continues to render and respond. Previously broken paths in PR #228 still hold.
|
||||
|
||||
**Diagnostics on failure:** Launcher log (confirm Wayland mode active), `--doctor`, full env dump, screenshot of any crash dialog.
|
||||
|
||||
**References:** [PR #228](https://github.com/aaddrick/claude-desktop-debian/pull/228), [PR #232](https://github.com/aaddrick/claude-desktop-debian/pull/232)
|
||||
**Code anchors:** project `scripts/launcher-common.sh:28-29` (`CLAUDE_USE_WAYLAND=1` opt-out of XWayland), 100-111 (native-Wayland Electron flags: `UseOzonePlatform,WaylandWindowDecorations`, `--ozone-platform=wayland`, `--enable-wayland-ime`, `--wayland-text-input-version=3`, `GDK_BACKEND=wayland`).
|
||||
|
||||
## S09 — Quick window patch runs only on KDE (post-#406 gate)
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Patch gate
|
||||
**Applies to:** All rows (verifies the gate, not the feature)
|
||||
**Issues:** [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406), [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393)
|
||||
|
||||
**Steps:**
|
||||
1. On a KDE row, launch the app. Inspect launcher log for quick-window-patch markers.
|
||||
2. On a non-KDE row, launch the app. Inspect launcher log — the markers should be absent.
|
||||
|
||||
**Expected:** On KDE sessions the quick-window patch is applied (Quick Entry uses the patched code path). On non-KDE sessions the patch is **not** applied, preventing the [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393) regression on GNOME etc.
|
||||
|
||||
**Diagnostics on failure:** Launcher log, `XDG_CURRENT_DESKTOP`, the patch-gate code path in `scripts/patches/`.
|
||||
|
||||
**References:** [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406), [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393)
|
||||
**Code anchors:** project `scripts/patches/quick-window.sh:32-42` (KDE-gated `blur()` insertion), 115-125 (KDE-gated focus/visibility check replacement); upstream sites the patch rewrites are around `index.js:515374-515471` (Quick Entry popup construction + handlers).
|
||||
|
||||
## S10 — Quick Entry popup is transparent (no opaque square frame)
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Quick Entry window (KDE Wayland)
|
||||
**Applies to:** KDE-W
|
||||
**Issues:** [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370), [#223](https://github.com/aaddrick/claude-desktop-debian/issues/223), [PR #244](https://github.com/aaddrick/claude-desktop-debian/pull/244)
|
||||
|
||||
**Steps:**
|
||||
1. On KDE Plasma Wayland, invoke Quick Entry.
|
||||
2. Observe the popup background.
|
||||
|
||||
**Expected:** Quick Entry popup renders with a transparent background — no opaque square frame visible behind the rounded prompt UI.
|
||||
|
||||
**Diagnostics on failure:** Screenshot, KDE compositor settings (`kwriteconfig5 --read kwinrc Compositing/Backend`), launcher log, BrowserWindow construction args.
|
||||
|
||||
**References:** [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370) (current open report), [#223](https://github.com/aaddrick/claude-desktop-debian/issues/223) (closed predecessor), [PR #244](https://github.com/aaddrick/claude-desktop-debian/pull/244)
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515380 (`transparent: !0`), 515383 (`backgroundColor: "#00000000"`), 515381 (`frame: !1`), 515377 (`skipTaskbar: !0`).
|
||||
|
||||
## S11 — Quick Entry shortcut fires from any focus on Wayland (mutter XWayland key-grab)
|
||||
|
||||
**Severity:** Critical (for GNOME users)
|
||||
**Surface:** Global shortcut on GNOME mutter
|
||||
**Applies to:** GNOME, Ubu
|
||||
**Issues:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)
|
||||
|
||||
**Steps:**
|
||||
1. On GNOME/mutter Wayland, launch the app.
|
||||
2. Focus another application; press the Quick Entry shortcut.
|
||||
3. Repeat from another virtual desktop.
|
||||
|
||||
**Expected:** Shortcut fires regardless of focused app or workspace.
|
||||
|
||||
**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).
|
||||
|
||||
**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`).
|
||||
|
||||
## S12 — `--enable-features=GlobalShortcutsPortal` launcher flag wired up for GNOME Wayland
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Launcher flag wiring
|
||||
**Applies to:** GNOME, Ubu (any GNOME Wayland)
|
||||
**Issues:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
|
||||
|
||||
**Steps:**
|
||||
1. On GNOME Wayland, launch the app.
|
||||
2. Inspect the Electron command line via `pgrep -af claude-desktop` — look for `--enable-features=GlobalShortcutsPortal`.
|
||||
3. Test Quick Entry shortcut from unfocused state (see [T06](#t06--quick-entry-global-shortcut-unfocused)).
|
||||
|
||||
**Expected:** Launcher detects GNOME Wayland and appends `--enable-features=GlobalShortcutsPortal` to Electron's argv, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs. Once wired, [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) is closeable.
|
||||
|
||||
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f electron)/cmdline | tr '\0' ' '`), launcher log, `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.
|
||||
|
||||
**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).
|
||||
|
||||
## S14 — Global shortcuts via XDG portal work on Niri
|
||||
|
||||
**Severity:** Critical (for Niri users)
|
||||
**Surface:** XDG Desktop Portal `BindShortcuts`
|
||||
**Applies to:** Niri
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. On Niri, launch the app (the launcher special-cases Niri to native Wayland + portal).
|
||||
2. Configure the Quick Entry shortcut.
|
||||
3. Observe portal interaction in launcher log.
|
||||
|
||||
**Expected:** `BindShortcuts` succeeds. Configured Quick Entry shortcut is registered and fires.
|
||||
|
||||
**Diagnostics on failure:** Launcher log capture of the `BindShortcuts` call, `busctl --user tree org.freedesktop.portal.Desktop`, Niri version, full env.
|
||||
|
||||
**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).
|
||||
|
||||
## S29 — Quick Entry popup is created lazily on first shortcut press (closed-to-tray sanity)
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Quick Entry popup lifecycle
|
||||
**Applies to:** All rows
|
||||
**Issues:** [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393)
|
||||
|
||||
**Steps:**
|
||||
1. Launch app, wait for main window to appear, hide-to-tray (close via X — see [T08](./tray-and-window-chrome.md#t08--hide-to-tray-on-close)).
|
||||
2. Confirm no Claude window is mapped (e.g. `wmctrl -l | grep -i claude` returns empty on X11; `swaymsg -t get_tree` for Wayland equivalents).
|
||||
3. Press the Quick Entry shortcut.
|
||||
4. Type `hello`, press Enter.
|
||||
|
||||
**Expected:** Popup appears even though no Claude window was mapped before the keypress. Upstream constructs the popup `BrowserWindow` lazily on first shortcut invocation (`if (!Ko || ...) Ko = new BrowserWindow(...)` near `index.js:515375`), so the popup does not need a pre-existing main window. New chat session is created and reachable on submit.
|
||||
|
||||
**Diagnostics on failure:** Launcher log, `~/.config/Claude/logs/`, `XDG_CURRENT_DESKTOP`, screenshot of empty desktop after shortcut press.
|
||||
|
||||
**References:** [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), upstream `index.js:515375-515397`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515374 (`if (!Ko ...) Ko = new BrowserWindow(...)` lazy construction guard), 515394 (`preload: ".vite/build/quickWindow.js"`), 515438 (`Ko.loadFile(".vite/renderer/quick_window/quick-window.html")`).
|
||||
|
||||
## S30 — Quick Entry shortcut becomes a no-op after full app exit
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Global shortcut unregistration
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch app. Confirm Quick Entry shortcut works (popup opens).
|
||||
2. Quit Claude Desktop fully via tray → Quit (or `pkill -f app.asar`). Confirm no `electron` processes for the app remain.
|
||||
3. Press the Quick Entry shortcut.
|
||||
|
||||
**Expected:** No popup appears. No error dialog. No zombie process. Electron unregisters the global shortcut on app exit; the shortcut becomes a system-level no-op.
|
||||
|
||||
**Diagnostics on failure:** `pgrep -af app.asar` output, `journalctl --user -e -n 100`, OS-level shortcut bindings (`gsettings list-recursively | grep -i shortcut`).
|
||||
|
||||
**References:** upstream `index.js:499416` (registration site)
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:499398-499428 (`nG()` register/unregister wrapper — passing `null` accelerator unregisters), 499416 (`hA.globalShortcut.register`), 499403 (`hA.globalShortcut.unregister`).
|
||||
|
||||
## S31 — Quick Entry submit makes the new chat reachable from any main-window state
|
||||
|
||||
**Severity:** Critical
|
||||
**Surface:** Submit → main window show
|
||||
**Applies to:** All rows
|
||||
**Issues:** [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)
|
||||
|
||||
**Steps:**
|
||||
1. For each main-window state: (a) visible-and-focused, (b) minimized, (c) hidden-to-tray, (d) on a different workspace, (e) closed via X (project's hide-to-tray override).
|
||||
2. Set the state, then invoke Quick Entry, type `hello`, submit.
|
||||
3. Record what happens to the main window: auto-restored, requires tray click, came to current workspace, stayed on its own workspace.
|
||||
|
||||
**Expected:** The new chat session is **reachable** from each starting state. Acceptance is "user can reach the new chat" — not "main window auto-restored." Upstream calls `mainWin.show()` + `mainWin.focus()` only (`index.js:515566, 515599`), with no `restore()`, no `setVisibleOnAllWorkspaces()`, no `moveTop()`. Whether `show()` un-minimizes or migrates workspaces is purely compositor-dependent. The failure case is "new chat created but the user has no way to surface it" — that's a regression. Anything that reaches the chat (even via a tray click) is upstream-acceptable.
|
||||
|
||||
**Diagnostics on failure:** `~/.config/Claude/logs/`, screenshot at each state, output of `wmctrl -l` (X11) or `swaymsg -t get_tree` (sway), launcher log.
|
||||
|
||||
**Currently:** On non-KDE rows, the post-#406 KDE-only patch gate leaves the upstream code path (`isFocused()` short-circuit) active. Andrej730's #393 GNOME repro shows the stale-`isFocused()` bug can still suppress `show()` in tray-only state. See [S32](#s32--quick-entry-submit-on-gnome-mutter-doesnt-trip-electron-stale-isfocused).
|
||||
|
||||
**References:** [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), upstream `index.js:515566, 515599, 105164-171`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515567 (`h1() || ut.show(), ut.focus()` in `gHn()` existing-chat path), 515598-515599 (`h1() || ut.show(), ut.focus()` in `ynt()` new-chat path), 105164-105171 (`h1()` returns `ut.isFocused() || mainView.webContents.isFocused()`).
|
||||
|
||||
## S32 — Quick Entry submit on GNOME mutter doesn't trip Electron stale-`isFocused()`
|
||||
|
||||
**Severity:** Critical (for GNOME users)
|
||||
**Surface:** Electron `BrowserWindow.isFocused()` on Linux
|
||||
**Applies to:** GNOME, Ubu
|
||||
**Issues:** [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393)
|
||||
|
||||
**Steps:**
|
||||
1. On GNOME Wayland, launch the app, then close to tray.
|
||||
2. Confirm the app is in tray-only state (no window mapped, no Dash entry, no taskbar entry).
|
||||
3. Invoke Quick Entry, type `hello`, submit.
|
||||
4. Repeat after re-pinning the app to the Dash and reproducing the tray-only state from there.
|
||||
|
||||
**Expected:** Submit produces a reachable new chat session in both Dash-pinned and not-pinned cases. **The Dash distinction is empirical, not code-driven** — upstream has no notion of Dash presence. The underlying failure mode is Electron's `BrowserWindow.isFocused()` returning stale-true on Linux mutter, which causes upstream's `h1() || ut.show()` short-circuit (`index.js:515566`) to skip `show()`. Andrej730 traced this on #393.
|
||||
|
||||
**Diagnostics on failure:** Bundled `index.js` h1() body (extract via `npx asar extract`); add temporary logging in `h1()` per Andrej730's diff in #393 if reproducing locally; `gnome-shell --version`; `~/.config/Claude/logs/`.
|
||||
|
||||
**Currently:** Open. The KDE-only gate from PR #406 leaves this path unfixed on GNOME. Resolution requires either (a) widening the patch to all DEs by dropping the `isFocused()` fallback in the patched code, or (b) waiting for an upstream Electron fix to `isFocused()` on Linux.
|
||||
|
||||
**References:** [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393) (Andrej730's diagnosis with `eU()` logging output)
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:105164-105171 (`h1()` body — the exact short-circuit Andrej730 instrumented), 515567 + 515598 (the two `h1() || ut.show()` call sites the suppression hits).
|
||||
|
||||
## S33 — Quick Entry transparent rendering tracked against bundled Electron version
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Bundled Electron version
|
||||
**Applies to:** All rows (relevant where #370 reproduces)
|
||||
**Issues:** [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370)
|
||||
|
||||
**Steps:**
|
||||
1. After install, capture the Electron version bundled with the app: extract `app.asar.unpacked` and run the bundled Electron with `--version`, or read it from the bundled binary's metadata.
|
||||
2. Record the version in [`../matrix.md`](../matrix.md) per row, alongside the [S10](#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) status.
|
||||
|
||||
**Expected:** Captured version is recorded. If the version is **41.0.4 through 41.x.y** and S10 fails, the upstream electron/electron#50213 regression hypothesis (per @noctuum's bisect on #370) holds and the issue is blocked on upstream. If the version is **41.0.3 or earlier** and S10 fails, the bisect is wrong — investigate. If the version is **a later release that includes a CSD-rendering fix** and S10 still fails, the upstream-regression hypothesis is also wrong.
|
||||
|
||||
**Diagnostics on failure:** Output of the version capture command, link to electron/electron#50213, the BrowserWindow construction args from the bundled `index.js`.
|
||||
|
||||
**Currently:** Per @noctuum's bisect, 41.0.4 introduced the regression. No upstream fix shipped as of last check.
|
||||
|
||||
**References:** [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370), upstream `index.js:515380, 515383` (already sets `transparent: true` and `backgroundColor: "#00000000"`)
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515380 (`transparent: !0`), 515383 (`backgroundColor: "#00000000"`), 515374-515397 (popup `BrowserWindow` construction args block, including `frame: !1`, `hasShadow: Zr`, `type: Zr ? "panel" : void 0`).
|
||||
|
||||
## S34 — Quick Entry shortcut focuses fullscreen main window instead of showing popup
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Shortcut behavior on fullscreen main
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch app. Put the main window into native fullscreen (F11 or platform equivalent).
|
||||
2. Press the Quick Entry shortcut.
|
||||
|
||||
**Expected:** Popup does **not** appear. Main window receives focus and `ide()` runs (upstream behavior at `index.js:525287-525290`). This is intentional upstream UX — assumes the user wants to interact with the existing fullscreen Claude rather than overlay a popup on it.
|
||||
|
||||
**Diagnostics on failure:** Screenshot, launcher log, confirm fullscreen state via `wmctrl -l -G` / Wayland equivalent.
|
||||
|
||||
**References:** upstream `index.js:525287-525290`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:525287-525290 (Quick Entry callback: `ut && !ut.isDestroyed() && ut.isFullScreen() ? (ut.focus(), ide()) : Yri()`), 515234-515241 (`ide()` — `show()` + `focus()` + `webContents.send(TEe.cmdK)` for the cmd-K dispatch).
|
||||
|
||||
## S35 — Quick Entry popup position is persisted across invocations and across app restarts
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Popup placement memory
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch app. Invoke Quick Entry. Note the popup position (record monitor + coordinates if possible — e.g. `xdotool getactivewindow getwindowgeometry` on X11).
|
||||
2. Dismiss (Esc). Re-invoke. Position should be unchanged across this dismiss/re-invoke cycle.
|
||||
3. Quit Claude Desktop fully (`pkill -f app.asar`). Re-launch. Invoke Quick Entry.
|
||||
4. Confirm position matches the pre-restart capture.
|
||||
|
||||
**Expected:** Popup reappears at the same monitor + position before and after a full app restart. Upstream persists position via `an.get("quickWindowPosition")` (`index.js:515491-515526`), keyed on monitor label + resolution.
|
||||
|
||||
**Diagnostics on failure:** Captured coordinates pre/post-restart, content of any persisted settings file (project's settings storage location varies by OS).
|
||||
|
||||
**References:** upstream `index.js:515491-515526`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515444-515461 (`Ko.on("hide", …)` persists `quickWindowPosition` via `an.set(...)`), 515491-515521 (`aHn()` resolves saved monitor by `label + bounds.width + bounds.height`, falling back to label-only or proportional placement), 515489 (`Ko.setPosition(...)` after show).
|
||||
|
||||
## S36 — Quick Entry popup falls back to primary display when saved monitor is gone
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Multi-monitor placement
|
||||
**Applies to:** All rows with a multi-monitor capable host
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. **Multi-monitor required.** With an external monitor connected, invoke Quick Entry on the external monitor. Trigger position persistence (per [S35](#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts)).
|
||||
2. Disconnect the external monitor (libvirt: detach the second display device; bare metal: unplug).
|
||||
3. Invoke Quick Entry.
|
||||
|
||||
**Expected:** Popup appears on the primary display, not at off-screen coordinates. Upstream falls back to `cHn()` when the saved monitor is no longer present (`index.js:515502`).
|
||||
|
||||
**Diagnostics on failure:** `xrandr` (X11) / `wlr-randr` (wlroots) output before and after disconnect, captured popup coordinates, screenshot.
|
||||
|
||||
**Skip when:** Single-monitor VM or host. Skip with `-` in the dashboard.
|
||||
|
||||
**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).
|
||||
|
||||
## S37 — Quick Entry popup remains functional after main window destroy
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Popup lifecycle independence from main window
|
||||
**Applies to:** All rows (where reachable)
|
||||
**Issues:** —
|
||||
|
||||
**Steps:**
|
||||
1. Launch app, focus main window.
|
||||
2. **Trigger main window destroy without quitting the app.** On this project, the X-button hide-to-tray override means the standard close path does **not** destroy `ut`. Reach the destroy path via one of:
|
||||
- DevTools console on the main window: `require('electron').remote.getCurrentWindow().destroy()` (if `remote` is exposed; not guaranteed).
|
||||
- A debug build with the hide-to-tray override removed.
|
||||
- Skip and mark `-` if unreachable.
|
||||
3. After destroy: invoke Quick Entry, type `hello`, submit.
|
||||
|
||||
**Expected:** Popup appears and accepts input. Upstream's `!ut || ut.isDestroyed()` guard at `index.js:515595` skips the show/focus block without crashing. The new chat is created in the data layer; whether it has a window to surface in is a separate question (upstream contract is "popup itself does not crash").
|
||||
|
||||
**Diagnostics on failure:** Crash dump, `~/.config/Claude/logs/`, sequence of actions taken to reach the destroy path.
|
||||
|
||||
**Currently:** Likely unreachable on Linux without a debug build, due to project's hide-to-tray override of the X button. Mark `-` (N/A) on rows where the destroy path can't be triggered.
|
||||
|
||||
**References:** upstream `index.js:515595`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515595-515602 (`setTimeout(() => { !ut || ut.isDestroyed() || (h1() || ut.show(), ut.focus(), Qe == null || Qe.webContents.focus(), iri()); }, 0)` — guard skips show/focus block on destroy without throwing); 515547 (companion guard in `nde()` chat-id submit path: `else if (ut && !ut.isDestroyed())`).
|
||||
123
docs/testing/cases/tray-and-window-chrome.md
Normal file
123
docs/testing/cases/tray-and-window-chrome.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Tray & Window Chrome
|
||||
|
||||
Tests covering the tray icon, OS-native window decorations, the hybrid in-app topbar (PR #538), and hide-to-tray on close. See [`../matrix.md`](../matrix.md) for status.
|
||||
|
||||
## T03 — Tray icon present
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** System tray / SNI
|
||||
**Applies to:** All rows
|
||||
**Issues:** —
|
||||
**Runner:** [`tools/test-harness/src/runners/T03_tray_icon_present.spec.ts`](../../../tools/test-harness/src/runners/T03_tray_icon_present.spec.ts) — registration only (left-click toggle + theme-switch in-place rebuild are v2)
|
||||
|
||||
**Steps:**
|
||||
1. Launch the app. Wait a few seconds.
|
||||
2. Locate the tray icon in the system tray / status area.
|
||||
3. Right-click → confirm standard menu (Show, Quit, etc.). Left-click → confirm window toggles.
|
||||
4. Switch the system theme between light and dark; observe the tray icon update.
|
||||
|
||||
**Expected:** Tray icon appears within a few seconds of app launch. Right-click exposes the standard menu. Left-click toggles main window visibility. Theme changes update the icon in place without spawning a duplicate.
|
||||
|
||||
**Diagnostics on failure:** `RegisteredStatusNotifierItems` from the SNI watcher (see [runbook](../runbook.md#tray--dbus-state-kde)), the tray daemon process for the DE (Plasma's `plasmashell`, GNOME's `gnome-shell` + AppIndicator extension state, etc.), launcher log.
|
||||
|
||||
**References:** [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md)
|
||||
**Code anchors:** `build-reference/app-extracted/.vite/build/index.js:525627` (`vy.on("menuBarEnabled", () => { Sde() })` — re-entry), `index.js:525631-525673` (`function Sde()` — tray construction), `index.js:525645` (`new hA.Tray(hA.nativeImage.createFromPath(t))`), `index.js:525646` (`qh.on("click", () => void Yri())` — left-click handler), `index.js:525653` (`qh.setContextMenu(mnt())` — Linux right-click via context menu), `index.js:515150-515169` (`function mnt()` — Show App + Quit menu items), `index.js:525623` (`hA.nativeTheme.on("updated", ...)` — theme-change re-entry).
|
||||
|
||||
## T04 — Window decorations draw
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Window chrome
|
||||
**Applies to:** All rows
|
||||
**Issues:** [PR #127](https://github.com/aaddrick/claude-desktop-debian/pull/127), [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538)
|
||||
**Runner:** [`tools/test-harness/src/runners/T04_window_decorations.spec.ts`](../../../tools/test-harness/src/runners/T04_window_decorations.spec.ts) — X11 / XWayland only (checks `_NET_FRAME_EXTENTS`); native-Wayland window-state queries are deferred
|
||||
|
||||
**Steps:**
|
||||
1. Launch the app.
|
||||
2. Confirm window has a working OS-native frame: close, minimize, maximize render and respond.
|
||||
3. Resize via window edges.
|
||||
|
||||
**Expected:** Frame is drawn by the DE/compositor (not the app). All controls render and respond. Resize works.
|
||||
|
||||
**Diagnostics on failure:** `xprop _NET_WM_WINDOW_TYPE` (X11) / `swaymsg -t get_tree` or compositor-equivalent (Wayland), launcher log line for `frame:` setting, screenshot.
|
||||
|
||||
**References:** [PR #127](https://github.com/aaddrick/claude-desktop-debian/pull/127), [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538) (hybrid mode keeps native frame), [`docs/learnings/linux-topbar-shim.md`](../../learnings/linux-topbar-shim.md)
|
||||
**Code anchors:** Upstream factory passes `titleBarStyle: "hidden"` and `titleBarOverlay: ys` (Windows-only flag) to `BrowserWindow` at `build-reference/app-extracted/.vite/build/index.js:524892-524909` (`Ori()`). On Linux the wrapper at `scripts/frame-fix-wrapper.js:122` overrides to `options.frame = true` and at `scripts/frame-fix-wrapper.js:129-130` deletes the macOS-only `titleBarStyle` / `titleBarOverlay` so the DE draws the frame. (Hybrid-mode plumbing — `CLAUDE_TITLEBAR_STYLE` resolution and the `native`/`hybrid`/`hidden` branches — lives on `main` per PR #538; the docs/compat-matrix branch's `frame-fix-wrapper.js` carries only the unconditional `frame:true` patch, which is sufficient for T04's "frame draws" assertion.)
|
||||
|
||||
## T07 — In-app topbar renders + clickable
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** In-app topbar (hybrid mode)
|
||||
**Applies to:** All rows on PR #538 builds
|
||||
**Issues:** [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538), [PR #127](https://github.com/aaddrick/claude-desktop-debian/pull/127)
|
||||
|
||||
**Steps:**
|
||||
1. Launch a PR #538 build.
|
||||
2. Observe the in-app topbar below the OS frame.
|
||||
3. Click each of: hamburger menu, sidebar toggle, search, back, forward, Cowork ghost.
|
||||
|
||||
**Expected:** All five topbar buttons render below the native frame. Each responds to mouse clicks (no implicit drag region capturing the events). If any single button fails to render or click, the test is `✗` — note which one in the linked issue.
|
||||
|
||||
**Diagnostics on failure:** Screenshot, env (`OZONE_PLATFORM`, `ELECTRON_OZONE_PLATFORM_HINT`, `GDK_BACKEND`, `QT_QPA_PLATFORM`, `MOZ_ENABLE_WAYLAND`, `SDL_VIDEODRIVER`), launcher log, DevTools `document.querySelector('.topbar')` HTML if accessible.
|
||||
|
||||
**References:** [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538), [PR #127](https://github.com/aaddrick/claude-desktop-debian/pull/127), [`docs/learnings/linux-topbar-shim.md`](../../learnings/linux-topbar-shim.md)
|
||||
**Code anchors:** UA-spoof shim source `scripts/wco-shim.js` (lines 1-30 module guard / `CLAUDE_TITLEBAR_STYLE != 'native'` gate, lines 184-191 `navigator.userAgent` redefinition matching `/(win32|win64|windows|wince)/i`, lines 52-53 `CONTROLS_WIDTH=140` / `TITLEBAR_HEIGHT=40`); injection orchestrator `scripts/patches/wco-shim.sh` (`patch_wco_shim()` prepends shim source to `mainView.js`); hybrid-mode wrapper branch `scripts/frame-fix-wrapper.js:62-70` (`VALID_TITLEBAR_STYLES`, default `hybrid`) and `:152-240` (per-mode `frame` / `titleBarStyle` handling).
|
||||
|
||||
## T08 — Hide-to-tray on close
|
||||
|
||||
**Severity:** Smoke
|
||||
**Surface:** Window lifecycle
|
||||
**Applies to:** All rows
|
||||
**Issues:** [PR #451](https://github.com/aaddrick/claude-desktop-debian/pull/451)
|
||||
|
||||
**Steps:**
|
||||
1. Launch the app. Click the window close (X) button.
|
||||
2. Confirm app process is still running (`pgrep -af claude-desktop`).
|
||||
3. Click the tray icon (or invoke Quick Entry) → window restores.
|
||||
4. Quit explicitly via tray menu or `Ctrl+Q`.
|
||||
|
||||
**Expected:** Close button hides main window to tray, doesn't quit. App keeps running. Tray-click restores. Explicit Quit ends the process.
|
||||
|
||||
**Diagnostics on failure:** `pgrep -af claude-desktop` after close, launcher log, screenshot of any dialog.
|
||||
|
||||
**References:** [PR #451](https://github.com/aaddrick/claude-desktop-debian/pull/451)
|
||||
**Code anchors:** Upstream Linux quit-on-last-close at `build-reference/app-extracted/.vite/build/index.js:525550-525552` (`hA.app.on("window-all-closed", () => { Zr || Ap() })` — `Zr` is darwin). Wrapper interception at `scripts/frame-fix-wrapper.js:178-185` (`this.on('close', e => { if (!result.app._quittingIntentionally && !this.isDestroyed()) { e.preventDefault(); this.hide() } })`) and `scripts/frame-fix-wrapper.js:370-374` (`app.on('before-quit', () => { app._quittingIntentionally = true })` — arms the bypass for tray-Quit / `Ctrl+Q` / SIGTERM). `CLOSE_TO_TRAY` gate (Linux + `CLAUDE_QUIT_ON_CLOSE !== '1'`) at `scripts/frame-fix-wrapper.js:49-51`. Tray Quit menu item `mnt()` `click: rde` at `index.js:515166`; `function rde()` at `index.js:515306-515308` calls `Ap(!1)`.
|
||||
|
||||
## S08 — Tray icon doesn't duplicate after `nativeTheme` update
|
||||
|
||||
**Severity:** Should
|
||||
**Surface:** Tray (KDE)
|
||||
**Applies to:** KDE-W, KDE-X
|
||||
**Issues:** [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md)
|
||||
|
||||
**Steps:**
|
||||
1. Launch the app on KDE.
|
||||
2. Toggle system theme (light ↔ dark).
|
||||
3. Observe the tray for ~10 seconds.
|
||||
|
||||
**Expected:** Tray icon updates in place via `setImage` + `setContextMenu`. SNI service stays registered — no de-register / re-register churn that would leave a duplicate icon visible until KDE garbage-collects.
|
||||
|
||||
**Diagnostics on failure:** SNI watcher state before/after theme switch (see [runbook](../runbook.md#tray--dbus-state-kde)), launcher log, `journalctl --user -u plasma-plasmashell -n 50`.
|
||||
|
||||
**References:** [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md). Mitigated upstream — the in-place fast-path is the current behavior.
|
||||
**Code anchors:** Upstream destroy+recreate slow-path at `build-reference/app-extracted/.vite/build/index.js:525643` (`qh && (qh.destroy(), (qh = null))`) followed immediately by `new hA.Tray(...)` at `:525645` and `setContextMenu(mnt())` at `:525653` — the SNI re-register that races on KDE. Fast-path injection in `scripts/patches/tray.sh` `patch_tray_inplace_update()` (lines 95-231): extracts `tray_var` / `menu_func` / `path_var` / `enabled_var` dynamically, then injects `if (TRAY && ENABLED !== false) { TRAY.setImage(EL.nativeImage.createFromPath(PATH)); process.platform !== "darwin" && TRAY.setContextMenu(MENU()); return }` before the destroy block. Idempotency marker at `tray.sh:174-180` keys on the post-rename `setImage(...nativeImage.createFromPath(PATH_VAR))` literal. Mutex + 250 ms DBus settle delay (the prior mitigation, kept for the legitimate slow-path entries) at `tray.sh:48-60`.
|
||||
|
||||
## S13 — Hybrid topbar shim survives Omarchy's Ozone-Wayland env exports
|
||||
|
||||
**Severity:** Critical (for Omarchy users)
|
||||
**Surface:** In-app topbar (hybrid mode) under Omarchy env
|
||||
**Applies to:** Hypr-O
|
||||
**Issues:** [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538)
|
||||
|
||||
**Steps:**
|
||||
1. On OmarchyOS, export Omarchy's session-wide env (`ELECTRON_OZONE_PLATFORM_HINT=wayland`, `OZONE_PLATFORM=wayland`, `GDK_BACKEND=wayland,x11,*`, `QT_QPA_PLATFORM=wayland;xcb`, `MOZ_ENABLE_WAYLAND=1`, `SDL_VIDEODRIVER=wayland,x11`).
|
||||
2. Launch a PR #538 build.
|
||||
3. Click each of the five topbar buttons.
|
||||
|
||||
**Expected:** The hybrid-mode topbar shim (`scripts/wco-shim.js`) loads in time to spoof the UA before claude.ai's `isWindows()` check fires. All five topbar buttons render and click.
|
||||
|
||||
**Diagnostics on failure:** Full session env, launcher log, `--doctor`, screenshot, video (per @lukedev45's bug report on PR #538), DevTools console for shim-load errors.
|
||||
|
||||
**Currently:** Reproduces partial render on OmarchyOS Hyprland per [@lukedev45](https://github.com/lukedev45)'s video on [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538). @aaddrick attempted local repro on KDE Plasma + Wayland with the same env vars and could not reproduce; root cause TBD pending diagnostic capture from a broken run.
|
||||
|
||||
**References:** [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538), [`docs/learnings/linux-topbar-shim.md`](../../learnings/linux-topbar-shim.md)
|
||||
**Code anchors:** Shim is inlined at the top of `mainView.js` (the BrowserView preload), not loaded via `require` — see the rationale at `scripts/patches/wco-shim.sh:23-40` ("Sandboxed preloads can only require a fixed allowlist of modules…"). The injection prepends `scripts/wco-shim.js` source at the start of `app.asar.contents/.vite/build/mainView.js` so the UA override fires before the bundle's `isWindows()` regex (`/(win32|win64|windows|wince)/i`) ever runs in the page main world (`scripts/wco-shim.js:184-191`). The shim's IIFE no-ops on non-Linux at `wco-shim.js:29` and on `CLAUDE_TITLEBAR_STYLE === 'native'` at `wco-shim.js:30-32`, so the only env-export interaction with `OZONE_PLATFORM` etc. is via Chromium's own platform plumbing — none of those exports are read by the shim itself, which makes the partial-render repro on Omarchy mysterious to static analysis.
|
||||
179
docs/testing/matrix.md
Normal file
179
docs/testing/matrix.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Test Status Matrix
|
||||
|
||||
*Last updated: 2026-04-30 · Tested against: claude-desktop 1.4758.0 (project varies per row)*
|
||||
|
||||
This is the live dashboard. Update this file (and only this file) when status changes. For the test specs themselves, see [`cases/`](./cases/). For orientation, see [`README.md`](./README.md).
|
||||
|
||||
Status legend: `✓` pass · `✗` fail · `🔧` mitigated · `?` untested · `-` N/A. Cells include linked issue/PR numbers when relevant.
|
||||
|
||||
## Cross-environment matrix (T-series)
|
||||
|
||||
| Test | KDE-W | KDE-X | GNOME | Ubu | Sway | i3 | Niri | Hypr-O | Hypr-N |
|
||||
|------|-------|-------|-------|-----|------|----|------|--------|--------|
|
||||
| [T01](./cases/launch.md#t01--app-launch) | ✓ | ? | ? | ? | ? | ? | ? | ? | ✓ |
|
||||
| [T02](./cases/launch.md#t02--doctor-health-check) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T03](./cases/tray-and-window-chrome.md#t03--tray-icon-present) | ✓ | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T04](./cases/tray-and-window-chrome.md#t04--window-decorations-draw) | ✓ | ? | ? | ? | ? | ? | ? | ? | ✓ |
|
||||
| [T05](./cases/shortcuts-and-input.md#t05--url-handler-opens-claudeai-links-in-app) | ? | ? | ? | ? | ✗ | ? | ? | ? | ? |
|
||||
| [T06](./cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) | ✓ | ✓ | ✗ [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) | 🔧 [#406](https://github.com/aaddrick/claude-desktop-debian/pull/406) | ? | ? | ✗ | ? | ? |
|
||||
| [T07](./cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) | ? | ? | ? | ? | ? | ? | ? | ✗ [#538](https://github.com/aaddrick/claude-desktop-debian/pull/538) | ✓ |
|
||||
| [T08](./cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close) | ✓ | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T09](./cases/platform-integration.md#t09--autostart-via-xdg) | ✓ | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T10](./cases/platform-integration.md#t10--cowork-integration) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T11](./cases/extensibility.md#t11--plugin-install-anthropic--partners) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T12](./cases/platform-integration.md#t12--webgl-warn-only) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T13](./cases/launch.md#t13--doctor-reports-correct-package-format) | ✗ | ✗ | ✗ | ? | ✗ | ✗ | ✗ | ? | ? |
|
||||
| [T14](./cases/launch.md#t14--multi-instance-behavior) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T15](./cases/code-tab-foundations.md#t15--sign-in-completes-via-browser-handoff) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T16](./cases/code-tab-foundations.md#t16--code-tab-loads) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T17](./cases/code-tab-foundations.md#t17--folder-picker-opens) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T18](./cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [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) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T23](./cases/code-tab-handoff.md#t23--desktop-notifications-fire) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T24](./cases/code-tab-handoff.md#t24--open-in-external-editor) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T25](./cases/code-tab-handoff.md#t25--show-in-files-file-manager) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [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) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T29](./cases/code-tab-workflow.md#t29--worktree-isolation) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T30](./cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T31](./cases/code-tab-workflow.md#t31--side-chat-opens) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T32](./cases/code-tab-workflow.md#t32--slash-command-menu) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T33](./cases/extensibility.md#t33--plugin-browser) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T34](./cases/code-tab-handoff.md#t34--connector-oauth-round-trip) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T35](./cases/extensibility.md#t35--mcp-server-config-picked-up) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T36](./cases/extensibility.md#t36--hooks-fire) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T37](./cases/extensibility.md#t37--claudemd-memory-loads) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T38](./cases/code-tab-handoff.md#t38--continue-in-ide) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T39](./cases/code-tab-handoff.md#t39--desktop-cli-handoff-graceful-na) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## Environment-specific status
|
||||
|
||||
### Ubuntu / DEB
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S01](./cases/distribution.md#s01--appimage-launches-without-manual-libfuse2t64-install) | AppImage launches without manual `libfuse2t64` install | ✗ | Workaround documented; not yet filed |
|
||||
| [S02](./cases/distribution.md#s02--xdg_current_desktopubuntu-gnome-doesnt-break-de-detection) | `XDG_CURRENT_DESKTOP=ubuntu:GNOME` doesn't break DE detection | ? | — |
|
||||
| [S03](./cases/distribution.md#s03--deb-install-via-apt-pulls-all-required-runtime-deps) | DEB install via APT pulls all required runtime deps | ? | — |
|
||||
|
||||
### Fedora / RPM
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S04](./cases/distribution.md#s04--rpm-install-via-dnf-pulls-all-required-runtime-deps) | RPM install via DNF pulls all required runtime deps | ? | — |
|
||||
| [S05](./cases/distribution.md#s05--doctor-recognises-dnf-installed-package-doesnt-false-flag-as-appimage) | Doctor recognises dnf-installed package (no AppImage false-flag) | ✗ | Affects KDE-W, KDE-X, GNOME, Sway, i3, Niri (T13) |
|
||||
|
||||
### Wayland-native (wlroots)
|
||||
|
||||
Applies to: Sway, Niri, Hypr-O, Hypr-N (any session running native Wayland rather than XWayland).
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S06](./cases/shortcuts-and-input.md#s06--url-handler-doesnt-segfault-on-native-wayland) | URL handler doesn't segfault on native Wayland | ✗ on Sway | Captured; not yet filed |
|
||||
| [S07](./cases/shortcuts-and-input.md#s07--claude_use_wayland1-opt-in-path-works-without-crashing) | `CLAUDE_USE_WAYLAND=1` opt-in path works | ? | [#228](https://github.com/aaddrick/claude-desktop-debian/pull/228), [#232](https://github.com/aaddrick/claude-desktop-debian/pull/232) |
|
||||
|
||||
### KDE
|
||||
|
||||
Applies to: KDE-W, KDE-X.
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S08](./cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update) | Tray icon doesn't duplicate after `nativeTheme` update | 🔧 | [`tray-rebuild-race.md`](../learnings/tray-rebuild-race.md) |
|
||||
| [S09](./cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate) | Quick window patch runs only on KDE | ✓ | [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406) |
|
||||
| [S10](./cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) | Quick Entry popup is transparent | ? | [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370), [#223](https://github.com/aaddrick/claude-desktop-debian/issues/223) |
|
||||
|
||||
### GNOME
|
||||
|
||||
Applies to: GNOME, Ubu (Ubuntu's GNOME), and any other mutter session.
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S11](./cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab) | Quick Entry shortcut fires from any focus | ✗ on GNOME, 🔧 on Ubu | [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406) |
|
||||
| [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) | `--enable-features=GlobalShortcutsPortal` wired up | ? | [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) |
|
||||
|
||||
### Omarchy
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S13](./cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports) | Hybrid topbar shim survives Omarchy's Ozone-Wayland env exports | ✗ | [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538) |
|
||||
|
||||
### Niri
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S14](./cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri) | Global shortcuts via XDG portal work on Niri | ✗ | Captured; not yet filed |
|
||||
|
||||
### AppImage
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S15](./cases/distribution.md#s15--appimage-extraction---appimage-extract-works-as-documented-fallback) | AppImage extraction (`--appimage-extract`) works as fallback | ? | — |
|
||||
| [S16](./cases/distribution.md#s16--appimage-mount-cleans-up-on-app-exit) | AppImage mount cleans up on app exit | ? | — |
|
||||
|
||||
### Linux launcher / `.desktop` env handling
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S17](./cases/platform-integration.md#s17--app-launched-from-desktop-inherits-shell-path) | App launched from `.desktop` inherits shell `PATH` | ? | — |
|
||||
| [S18](./cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot) | Local environment editor persists across reboot | ? | — |
|
||||
| [S19](./cases/routines.md#s19--claude_config_dir-redirects-scheduled-task-storage) | `CLAUDE_CONFIG_DIR` redirects scheduled-task storage | ? | — |
|
||||
|
||||
### Idle-sleep / suspend
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S20](./cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend) | "Keep computer awake" inhibits idle suspend | ? | — |
|
||||
| [S21](./cases/routines.md#s21--lid-close-still-suspends-per-os-policy) | Lid-close still suspends per OS policy | ? | — |
|
||||
|
||||
### Computer Use (Linux: out-of-scope per upstream)
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S22](./cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux) | Computer-use toggle is absent or visibly disabled | ? | — |
|
||||
| [S23](./cases/platform-integration.md#s23--dispatch-spawned-sessions-dont-soft-lock-on-a-never-approvable-computer-use-prompt) | Dispatch sessions don't soft-lock on never-approvable prompt | ? | — |
|
||||
|
||||
### Dispatch
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S24](./cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) | Dispatch-spawned Code session appears with badge + notification | ? | — |
|
||||
| [S25](./cases/platform-integration.md#s25--mobile-pairing-survives-linux-session-restart) | Mobile pairing survives Linux session restart | ? | — |
|
||||
|
||||
### Auto-update vs. system package manager
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S26](./cases/distribution.md#s26--auto-update-is-disabled-when-installed-via-apt--dnf) | Auto-update is disabled when installed via `apt` / `dnf` | ? | — |
|
||||
|
||||
### Plugin / worktree storage
|
||||
|
||||
| ID | Test | Status | Notes |
|
||||
|----|------|--------|-------|
|
||||
| [S27](./cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths) | Plugins install per-user, not into system paths | ? | — |
|
||||
| [S28](./cases/extensibility.md#s28--worktree-creation-surfaces-clear-error-on-read-only-mounts) | Worktree creation surfaces clear error on read-only mounts | ? | — |
|
||||
|
||||
## Known failures rollup
|
||||
|
||||
Tests currently `✗` somewhere — investigation priority order:
|
||||
|
||||
| Test | Failing on | Root cause |
|
||||
|------|------------|------------|
|
||||
| [T05 / S06](./cases/shortcuts-and-input.md#s06--url-handler-doesnt-segfault-on-native-wayland) | Sway | URL handler subprocess SIGSEGV on native Wayland — `Failed to connect to Wayland display` |
|
||||
| [T06 / S11](./cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab) | GNOME | mutter doesn't honour XWayland-side key grab |
|
||||
| [T06 / S14](./cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri) | Niri | `BindShortcuts` returns error code 5 |
|
||||
| [T07 / S13](./cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports) | Hypr-O | Hybrid topbar shim partial render under Omarchy's Ozone-Wayland env exports |
|
||||
| [T13 / S05](./cases/launch.md#t13--doctor-reports-correct-package-format) | every Fedora row | Doctor only checks dpkg, false-flags every dnf install as AppImage |
|
||||
| [S01](./cases/distribution.md#s01--appimage-launches-without-manual-libfuse2t64-install) | Ubuntu 24.04 | AppImage requires `libfuse2t64`; not auto-pulled |
|
||||
|
||||
## Notes on the current state
|
||||
|
||||
- Most cells are `?` because every captured VM in the recent test session ran the **released** build (`dnf install` / `apt install` / current AppImage), which predates [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538). Topbar verification (T07) on the VM rows specifically requires a branch build deployed before any cell can flip from `?`.
|
||||
- KDE-W status reflects @aaddrick's daily-driver host (Nobara KDE Plasma Wayland) where multiple features have been in continuous use.
|
||||
- Hypr-N status reflects @typedrat's report on [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538) ("Working great on NixOS with Hyprland").
|
||||
- Hypr-O status reflects @lukedev45's broken-case report on [PR #538](https://github.com/aaddrick/claude-desktop-debian/pull/538) (partial render, root cause unconfirmed but Omarchy-env-specific — see [S13](./cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports)).
|
||||
- T13 is `✗` on every Fedora row because the dpkg false-flag is a deterministic property of the doctor script, not a per-environment failure mode. It will flip to `✓` everywhere once the doctor learns to detect rpm/dnf installs.
|
||||
- T15–T39 are derived from upstream Claude Code Desktop docs (`code.claude.com/docs/en/desktop*`) — features whose Linux behavior is officially undocumented (the docs explicitly state "Linux is not supported" for the Code tab). All cells start as `?` because the upstream Code-tab feature surface has not been systematically exercised on the patched Linux build.
|
||||
118
docs/testing/quick-entry-closeout.md
Normal file
118
docs/testing/quick-entry-closeout.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Quick Entry — Upstream Contract + Test Index
|
||||
|
||||
Reference doc for the Quick Entry surface. Two halves:
|
||||
|
||||
- [§ 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.
|
||||
|
||||
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
|
||||
|
||||
Read this before reading the test list. Several `QE-*` rows test things upstream does not actually promise — those tests are still valuable as black-box behavior checks, but the calibration of "expected" matters.
|
||||
|
||||
Source for everything below: `build-reference/app-extracted/.vite/build/index.js`. Symbol names (`h1`, `ut`, `Ko`, `ynt`, `nde`, `g3A`, `u7A`) drift between releases — anchor on shape, not name.
|
||||
|
||||
### What upstream promises
|
||||
|
||||
- **Global shortcut** registered via Electron `globalShortcut.register()` (`:499416`). No app-focus gate — fires regardless of which app is focused.
|
||||
- **Popup is lazily created** on first shortcut press (`if (!Ko || ...) Ko = new BrowserWindow(...)` near `:515375`). The popup `BrowserWindow` is constructed on demand, not at app startup. This is what makes QE-4 (closed-to-tray) work.
|
||||
- **Position memory:** popup position persists across invocations via `an.get("quickWindowPosition")` (`:515491-515526`), keyed on monitor label + resolution. If the original monitor is gone, falls back to primary display.
|
||||
- **Submit always creates a NEW chat session** when no `chatId` is provided (`ynt(e)` at `:515546`). Quick Entry never appends to an existing conversation.
|
||||
- **Click-outside dismiss** is wired in the main process via the popup `blur` handler (`Ko.on("blur", () => g3A(null))` at `:515465`).
|
||||
- **Popup survives main-window close.** If the user closes the main window via the X button (not full quit), `!ut || ut.isDestroyed()` guards at `:515595` skip the `show()/focus()` calls; the popup itself remains functional.
|
||||
- **Window construction** sets `transparent: true`, `backgroundColor: "#00000000"`, `frame: false`, `alwaysOnTop: true` (level `"pop-up-menu"`), `skipTaskbar: true`, `resizable: false`, `show: false` (`:515375-515397`). `hasShadow: Zr` and `type: Zr ? "panel" : void 0` are macOS-only (`Zr === process.platform === "darwin"`).
|
||||
|
||||
### What upstream does NOT promise
|
||||
|
||||
- **Workspace migration.** No `setVisibleOnAllWorkspaces()`, no `moveTop()`, no `setWorkspace()` is called anywhere in the Quick Entry submit path. Whether the main window comes to the user's current workspace or stays on its own is purely a compositor decision driven by `mainWin.show()` + `mainWin.focus()`. **Linux/Wayland behavior here is not part of the upstream feature spec.**
|
||||
- **Restore from minimized.** No `restore()` call in the submit path. `show()` un-minimizes on most WMs; whether it does on a given Wayland compositor is up to that compositor.
|
||||
- **Multi-monitor placement on cursor / focused display.** Upstream uses last-saved position or primary display, never "where the user is right now."
|
||||
- **Multi-window targeting.** All `show`/`focus` calls go through `ut` (the main window). If the user has multiple windows, behavior is undefined.
|
||||
- **Popup re-creation if its `BrowserWindow` is destroyed.** Upstream does not re-construct `Ko` after destroy — it's only created on first shortcut press.
|
||||
- **Compositor-aware behavior.** Upstream has no concept of "GNOME vs KDE vs wlroots." Anywhere our patches branch on `XDG_CURRENT_DESKTOP`, that's our project compensating for compositor-specific Electron breakage, not implementing an upstream-defined contract.
|
||||
|
||||
### Edge case: fullscreen main window
|
||||
|
||||
`:525287-525290` reads (paraphrased): *"if `ut` exists and `ut.isFullScreen()` is true, focus `ut` and call `ide()`; else show the Quick Entry popup."* So if the main window is fullscreen when the shortcut fires, **the popup does not appear** — the shortcut focuses the main window instead. QE-1 needs this caveat.
|
||||
|
||||
### Edge case: `h1()` is a *don't-show-if-already-focused* optimization
|
||||
|
||||
The visibility-check function (`h1()` at `:105164-105171`) is upstream's mechanism for "don't redundantly call `show()` if the main window is already focused." Sound design. The reason it's broken on Linux is Electron's `BrowserWindow.isFocused()` returning stale-true after `hide()` on Linux backends — i.e., **the patch we apply is fixing a Linux-Electron bug, not diverging from upstream intent.** Once `isFocused()` returns honest values on Linux, the patch could be retired.
|
||||
|
||||
## Test list
|
||||
|
||||
Each item is a single check. Severity tier matches the existing scaffolding (Critical / Should / Smoke). Existing test ID in parentheses — `(new)` means this item should be added to [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md) before this sweep is reproducible by anyone else.
|
||||
|
||||
### Shortcut activation — covers #404
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-1 | Smoke | App focused (not fullscreen), press shortcut | Popup appears. **Edge case from upstream design:** if main window is fullscreen, the shortcut focuses main and runs `ide()` instead of showing the popup (`:525287-525290`). Test this fullscreen variant separately as QE-1b — popup should *not* appear. | [S34](./cases/shortcuts-and-input.md#s34--quick-entry-shortcut-focuses-fullscreen-main-window-instead-of-showing-popup) (QE-1b only) |
|
||||
| QE-2 | Critical | Other app focused, press shortcut | Popup appears | [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) |
|
||||
| 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) |
|
||||
|
||||
### Submit → main window — covers #393
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-7 | Smoke | Main window visible, submit prompt from QE | Popup closes; main window navigates to a **new** chat session (not appended to current chat — `ynt(e)` at `:515546` always creates new). | [S31](./cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state) |
|
||||
| QE-8 | Critical | Main window minimized, submit | **Upstream calls `show() + focus()` only — no `restore()`.** Whether the WM un-minimizes is compositor-dependent. Test as black-box: record whether the new chat is reachable to the user (window comes back to view, OR user has to click tray/dock to see it). Both outcomes are upstream-acceptable; only "new chat created but unreachable" is a regression. | [S31](./cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state) |
|
||||
| QE-9 | Critical | Main window hidden-to-tray (after [T08](./cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close)), submit | Same as QE-8 — `show()` should re-map a hidden window on most compositors, but upstream doesn't guarantee it. The new chat must be reachable; the path to reach it (auto vs tray-click) is compositor-dependent. | [S31](./cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state) |
|
||||
| QE-10 | Should | Main window on different workspace, submit | **Upstream has no workspace logic** (no `setVisibleOnAllWorkspaces`, no `moveTop`). Outcome is whatever the compositor decides on `show()` + `focus()`. Record observed behavior per row; do not treat any single outcome as the "right" one. | [S31](./cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state) |
|
||||
| QE-11 | Critical | **GNOME-specific (Andrej730 repro):** App in tray, *not* present in Dash/dock, submit | Main window opens. The codebase doesn't reason about Dash presence — this is purely a compositor-observed state. The underlying failure is `BrowserWindow.isFocused()` returning stale-true on GNOME mutter, which causes the patched (KDE) code path's `h1() || ut.show()` chain to short-circuit before `show()`. Test as a black-box repro. | [S32](./cases/shortcuts-and-input.md#s32--quick-entry-submit-on-gnome-mutter-doesnt-trip-electron-stale-isfocused) |
|
||||
| QE-12 | Should | App in tray, *also* present in Dash/dock, submit | Main window opens (this state should not trip the stale-focus bug, but verify) | [S32](./cases/shortcuts-and-input.md#s32--quick-entry-submit-on-gnome-mutter-doesnt-trip-electron-stale-isfocused) |
|
||||
| QE-13 | Smoke | Submit prompt with 1-2 chars (`hi`) | Upstream silently drops. The actual gate is `> 2` chars at `index.js:515530, 515533` — anything 3+ submits. So `hi` (2) drops, `hel` (3) submits. Document, do not fix. | — |
|
||||
|
||||
### Visual / window appearance — covers #370
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-14 | Should | Inspect popup background | Transparent; no opaque square frame visible behind the rounded UI. **Note:** upstream already sets `transparent: true` and `backgroundColor: "#00000000"` (`:515380, :515383`), so the #370 triage-bot suggestion to "try setting backgroundColor to transparent" is moot — those are already in place. The Electron 41.0.4 regression is at the CSD/shadow rendering layer below those flags, not at the option-passing layer. | [S10](./cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | — |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | — |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | — |
|
||||
| QE-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
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-19 | Critical | **All rows.** Extract the installed `app.asar` (`npx asar extract /usr/lib/claude-desktop/app.asar /tmp/inspect-installed`) and grep the bundled JS for the KDE gate string injected by the patch: `grep -c 'XDG_CURRENT_DESKTOP' /tmp/inspect-installed/.vite/build/index.js`. The patch (`scripts/patches/quick-window.sh:34-35, 117-118`) injects `(process.env.XDG_CURRENT_DESKTOP\|\|"").toLowerCase().includes("kde")` — that string is the runtime fingerprint. Note: the `Patched quick window` / `WARNING: No quick entry show() calls patched` lines from the patch are **build-time stdout** (not in `launcher.log`); check the build log if you built locally. | Bundled JS contains the KDE gate string (patch ran at build time). The patch ships in every build; the KDE-vs-non-KDE branch is decided at runtime by the env-var check. **Runtime gate effectiveness is verified implicitly by QE-7 through QE-12 passing on KDE and the unpatched-equivalent path running on non-KDE.** | [S09](./cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate) |
|
||||
|
||||
### Input behavior smoke — catches collateral breakage
|
||||
|
||||
| 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. | — |
|
||||
|
||||
### Popup placement & lifecycle — upstream contract sanity
|
||||
|
||||
These verify upstream-promised behaviors that aren't directly broken by #393/#404/#370 but live in the same surface area. Failures here would indicate a separate regression — file a new issue rather than folding it into the close-out trio.
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-22 | Should | Invoke Quick Entry. Note popup position. Dismiss (Esc). Quit Claude Desktop entirely (`pkill -f app.asar` after closing the main window, or via tray → Quit). Re-launch. Invoke Quick Entry. | Popup reappears at the same monitor + position as before the restart. Upstream persists position via `an.get("quickWindowPosition")` (`:515491-515526`), keyed on monitor label + resolution. Position must survive a full app restart, not just dismiss/re-invoke. | [S35](./cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts) |
|
||||
| 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) |
|
||||
|
||||
## Scaffold integration
|
||||
|
||||
The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
|
||||
| Case | Title | Backs |
|
||||
|------|-------|-------|
|
||||
| [S29](./cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity) | Popup created lazily on first shortcut press (closed-to-tray sanity) | QE-4 |
|
||||
| [S30](./cases/shortcuts-and-input.md#s30--quick-entry-shortcut-becomes-a-no-op-after-full-app-exit) | Shortcut becomes no-op after full app exit | QE-5 |
|
||||
| [S31](./cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state) | Submit makes the new chat reachable from any main-window state | QE-7 through QE-10 |
|
||||
| [S32](./cases/shortcuts-and-input.md#s32--quick-entry-submit-on-gnome-mutter-doesnt-trip-electron-stale-isfocused) | Submit on GNOME mutter doesn't trip Electron stale-`isFocused()` | QE-11, QE-12 |
|
||||
| [S33](./cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version) | Transparent rendering tracked against bundled Electron version | QE-18 |
|
||||
| [S34](./cases/shortcuts-and-input.md#s34--quick-entry-shortcut-focuses-fullscreen-main-window-instead-of-showing-popup) | Shortcut focuses fullscreen main instead of showing popup | QE-1b |
|
||||
| [S35](./cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts) | Popup position persisted across invocations and across app restarts | QE-22 |
|
||||
| [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) | Popup falls back to primary display when saved monitor is gone | QE-23 |
|
||||
| [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) | Popup remains functional after main window destroy | QE-24 |
|
||||
|
||||
QE-13, QE-15, QE-16, QE-17, and QE-21 are visual / input checks with no formal S-ID — run them by eye against [§ Upstream design intent](#upstream-design-intent).
|
||||
340
docs/testing/runbook.md
Normal file
340
docs/testing/runbook.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Testing Runbook
|
||||
|
||||
*Last updated: 2026-05-03*
|
||||
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
|
||||
## When to sweep
|
||||
|
||||
| Trigger | Scope | Rows |
|
||||
|---------|-------|------|
|
||||
| Release tag (`vX.Y.Z+claude...`) | Smoke set | KDE-W + Hypr-N (or Sway) |
|
||||
| Release tag, monthly | Smoke + Critical | All active rows |
|
||||
| Upstream Claude Desktop bump | Smoke set + [grounding sweep](#grounding-sweep) | KDE-W + one wlroots row |
|
||||
| PR touching `scripts/patches/*.sh` | Tests in the affected surface (use surface tags in cases files) | KDE-W minimum |
|
||||
| Bug report citing an env | The relevant test on the reporter's row | Just that row |
|
||||
|
||||
## Setup: VM matrix
|
||||
|
||||
Each non-host row in [`matrix.md`](./matrix.md) is a QEMU/KVM guest. Standard config:
|
||||
|
||||
- 4 GB RAM, 2 vCPU minimum
|
||||
- virtio-gpu **with** `gl=on` (3D acceleration). On hybrid GPU hosts, pin `rendernode=/dev/dri/renderD129` (AMD); avoid renderD128 (NVIDIA, EGL init fails on aaddrick's laptop)
|
||||
- 32 GB qcow2 disk
|
||||
- Bridged networking
|
||||
- Virgil 3D enabled where possible (helps WebGL detection in T12)
|
||||
|
||||
ISOs / images per row:
|
||||
|
||||
| Row | Source |
|
||||
|-----|--------|
|
||||
| Fedora 43 (KDE-W, KDE-X, GNOME, Sway, i3, Niri) | https://fedoraproject.org/spins/ for KDE/GNOME, https://fedoraproject.org/sericea/ for Sway, manual install for i3/Niri |
|
||||
| Ubuntu 24.04 (Ubu) | https://ubuntu.com/download/desktop |
|
||||
| OmarchyOS (Hypr-O) | https://omarchy.org |
|
||||
| NixOS (Hypr-N) | https://nixos.org/download with Hyprland module |
|
||||
|
||||
For the host (KDE-W), test against Nobara directly — no VM needed.
|
||||
|
||||
## Setup: building the install candidate
|
||||
|
||||
```bash
|
||||
# Build from the branch under test
|
||||
./build.sh --build appimage --clean no
|
||||
./build.sh --build deb --clean no
|
||||
./build.sh --build rpm --clean no
|
||||
|
||||
# Or pull from CI artifacts for a tagged release
|
||||
gh run download <RUN_ID> -n claude-desktop-deb-amd64
|
||||
gh run download <RUN_ID> -n claude-desktop-rpm-amd64
|
||||
gh run download <RUN_ID> -n claude-desktop-appimage-amd64
|
||||
```
|
||||
|
||||
Drop the resulting `.deb` / `.rpm` / `.AppImage` into a shared folder mounted into each guest, or `scp` per-guest.
|
||||
|
||||
## Running a sweep: the standard loop
|
||||
|
||||
For each test in scope:
|
||||
|
||||
1. **Read the test spec** in `cases/<surface>.md` (or `ui/<surface>.md` for UI checklists). Note the `Severity`, `Steps`, and `Expected` sections.
|
||||
2. **Execute the steps** as described.
|
||||
3. **Compare against Expected.** Mark internally as `✓`, `✗`, `🔧`, or `?` (untested if you couldn't run it for env reasons; `-` if N/A).
|
||||
4. **On `✗`**: capture the diagnostics from the test's `Diagnostics on failure` block (see [diagnostic capture](#diagnostic-capture) below). File an issue if one isn't already linked.
|
||||
5. **Update [`matrix.md`](./matrix.md)** in a single PR per row per sweep, titled `test: <ROW> sweep YYYY-MM-DD`.
|
||||
|
||||
## Diagnostic capture
|
||||
|
||||
Standard captures referenced from test `Diagnostics on failure` blocks:
|
||||
|
||||
### `--doctor` output
|
||||
|
||||
```bash
|
||||
claude-desktop --doctor 2>&1 | tee /tmp/doctor.txt
|
||||
```
|
||||
|
||||
Or for AppImage:
|
||||
|
||||
```bash
|
||||
./claude-desktop-*.AppImage --doctor 2>&1 | tee /tmp/doctor.txt
|
||||
```
|
||||
|
||||
### Launcher log
|
||||
|
||||
```bash
|
||||
cat ~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
Truncate and re-run if the file is stale:
|
||||
|
||||
```bash
|
||||
: > ~/.cache/claude-desktop-debian/launcher.log
|
||||
claude-desktop 2>&1 | tee -a ~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
### Session env
|
||||
|
||||
```bash
|
||||
echo "XDG_SESSION_TYPE=$XDG_SESSION_TYPE"
|
||||
echo "XDG_CURRENT_DESKTOP=$XDG_CURRENT_DESKTOP"
|
||||
echo "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||
echo "DISPLAY=$DISPLAY"
|
||||
echo "GDK_BACKEND=$GDK_BACKEND"
|
||||
echo "QT_QPA_PLATFORM=$QT_QPA_PLATFORM"
|
||||
echo "OZONE_PLATFORM=$OZONE_PLATFORM"
|
||||
echo "ELECTRON_OZONE_PLATFORM_HINT=$ELECTRON_OZONE_PLATFORM_HINT"
|
||||
```
|
||||
|
||||
### Tray / DBus state (KDE)
|
||||
|
||||
```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 connection
|
||||
gdbus call --session --dest=org.freedesktop.DBus \
|
||||
--object-path=/org/freedesktop/DBus \
|
||||
--method=org.freedesktop.DBus.GetConnectionUnixProcessID ":1.XXXX"
|
||||
```
|
||||
|
||||
### Portal availability (Wayland)
|
||||
|
||||
```bash
|
||||
systemctl --user status xdg-desktop-portal
|
||||
busctl --user tree org.freedesktop.portal.Desktop
|
||||
```
|
||||
|
||||
### Suspend inhibitors
|
||||
|
||||
```bash
|
||||
systemd-inhibit --list
|
||||
```
|
||||
|
||||
### App version
|
||||
|
||||
```bash
|
||||
claude-desktop --version
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
gh variable get REPO_VERSION
|
||||
```
|
||||
|
||||
Always include the upstream version + project version in the issue body and the matrix-update commit message.
|
||||
|
||||
## Filing failures
|
||||
|
||||
Issue title format: `[<row>] <T## or S##>: <one-line symptom>`
|
||||
|
||||
Issue body template:
|
||||
|
||||
```markdown
|
||||
**Test:** [T17 — Folder picker opens](./docs/testing/cases/code-tab-foundations.md#t17--folder-picker-opens)
|
||||
**Environment:** GNOME (Fedora 43, Wayland)
|
||||
**Project version:** v1.3.23+claude1.4758.0
|
||||
**Upstream version:** 1.4758.0
|
||||
|
||||
## Steps
|
||||
<paste from test spec>
|
||||
|
||||
## Expected
|
||||
<paste from test spec>
|
||||
|
||||
## Actual
|
||||
<observed behavior>
|
||||
|
||||
## Diagnostics
|
||||
<--doctor output, launcher log, session env, anything else from the test's Diagnostics block>
|
||||
|
||||
## Notes
|
||||
<any hypotheses, related PRs, recent regressions>
|
||||
```
|
||||
|
||||
Link the issue back into [`matrix.md`](./matrix.md) on the affected cell using the standard format: `✗ #NNN`.
|
||||
|
||||
## Updating the matrix
|
||||
|
||||
One PR per sweep per row. Bundle every status change for that row into a single commit so the matrix history reads as a sequence of sweep events, not individual cell flips.
|
||||
|
||||
Commit message template:
|
||||
|
||||
```
|
||||
test(<row>): sweep <YYYY-MM-DD> — <project_version>+claude<upstream_version>
|
||||
|
||||
- T01 ? → ✓
|
||||
- T03 ? → ✓
|
||||
- T05 ? → ✗ (filed #NNN)
|
||||
- T17 ? → ✓
|
||||
- ...
|
||||
```
|
||||
|
||||
If the same sweep also turned up new tests worth adding, those go in a separate commit before the status update so the diff stays focused.
|
||||
|
||||
## Severity guidance for new tests
|
||||
|
||||
When adding a test to `cases/` or `ui/`, pick severity using these heuristics:
|
||||
|
||||
| Tier | Pick when | Example |
|
||||
|------|-----------|---------|
|
||||
| Smoke | First-launch experience; if this fails the app is unusable for normal users | T01 (app launch), T03 (tray), T16 (Code tab loads) |
|
||||
| Critical | Feature is documented in upstream docs **and** breaks core workflows when broken | T22 (PR monitoring), T34 (connector OAuth), T17 (folder picker) |
|
||||
| Should | Quality-of-life or documented edge case; users hit it but have a workaround | T28 (catch-up after suspend), S26 (auto-update vs apt) |
|
||||
| Could | Niche, env-specific, or graceful-degradation checks | T39 (`/desktop` CLI N/A), S22 (computer-use toggle absent on Linux) |
|
||||
|
||||
When in doubt, file as **Should**. Smoke and Critical mean release gates — be conservative about adding gates.
|
||||
|
||||
## Adding a new test
|
||||
|
||||
1. Pick the right surface file in `cases/` (or create one with prior buy-in if no existing surface fits — don't sprinkle new files lightly).
|
||||
2. Use the next free ID: highest `T##` + 1 for cross-env, highest `S##` + 1 for env-specific. Don't reuse retired IDs.
|
||||
3. Follow the standard structure: `**Severity:**`, `**Surface:**`, `**Applies to:**`, `**Steps:**`, `**Expected:**`, `**Diagnostics on failure:**`, `**References:**`.
|
||||
4. Add the row to [`matrix.md`](./matrix.md) with all-`?` initial state.
|
||||
5. Mention the new test in the PR description so reviewers know to read the spec.
|
||||
|
||||
For UI checklist additions, append rows to the relevant `ui/<surface>.md` table. UI rows don't need `T##` / `S##` IDs — the surface file + element name is the identity.
|
||||
|
||||
## Automated runs
|
||||
|
||||
The harness at [`tools/test-harness/`](../../tools/test-harness/) drives any
|
||||
test with a `runner:` field. As of 2026-04-30, that's T01, T03, T04, T17.
|
||||
|
||||
### Invoking a sweep
|
||||
|
||||
```sh
|
||||
cd tools/test-harness
|
||||
npm install # first time only
|
||||
ROW=KDE-W ./orchestrator/sweep.sh
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `results/results-${ROW}-${DATE}/junit.xml` — the JUnit summary (one
|
||||
testsuite per `.spec.ts` file, with the test's annotations preserved as
|
||||
metadata).
|
||||
- `results/results-${ROW}-${DATE}/test-output/<test>/` — per-test
|
||||
attachments (screenshots, launcher log, session env, frame extents,
|
||||
click-attempt diagnostics, etc.). Captured on every run, not just on
|
||||
failure (Decision 7).
|
||||
- `results/results-${ROW}-${DATE}/html/` — Playwright's HTML report.
|
||||
- `results/results-${ROW}-${DATE}.tar.zst` — bundled artifact for
|
||||
off-machine inspection (when `zstd` is available).
|
||||
|
||||
`sweep.sh` prints a summary line at the end:
|
||||
|
||||
```
|
||||
summary: tests=4 failures=0 errors=0 skipped=1
|
||||
```
|
||||
|
||||
### Translating results to the matrix
|
||||
|
||||
JUnit `<failure>` → `✗`, `<error>` (harness broke) → `?`, `<skipped>` →
|
||||
`-` (when intentionally not applicable) or stays `?` (when the test
|
||||
couldn't reach an assertion — common case for renderer tests that need
|
||||
sign-in or selectors that haven't been tuned). For now this mapping is
|
||||
manual: open `junit.xml`, update `matrix.md` cells, commit. A
|
||||
`render-matrix.sh` to do this automatically is on the to-do list.
|
||||
|
||||
### Coexistence with manual tests
|
||||
|
||||
Tests without a `runner:` continue to flow through the manual loop above.
|
||||
The matrix doesn't distinguish automated from manual cells — a `✓` is a
|
||||
`✓` regardless of how it was produced. The `runner:` field on each case
|
||||
makes the source-of-truth explicit per-test.
|
||||
|
||||
### Path through the CDP auth gate (why this works)
|
||||
|
||||
The shipped Electron exits if `--remote-debugging-port` is on argv
|
||||
without a valid `CLAUDE_CDP_AUTH` token. Both `_electron.launch()` and
|
||||
`chromium.connectOverCDP()` inject that flag. The harness sidesteps the
|
||||
gate by spawning Electron clean and attaching the Node inspector via
|
||||
`SIGUSR1` at runtime — same code path as `Developer → Enable Main
|
||||
Process Debugger`. From there, main-process JS evaluation reaches the
|
||||
renderer through `webContents.executeJavaScript()`. Full writeup:
|
||||
[`automation.md`](./automation.md#the-cdp-auth-gate-and-the-runtime-attach-workaround-that-beats-it).
|
||||
|
||||
### Wayland-mode sweep
|
||||
|
||||
Default backend is X11-via-XWayland (matches `launcher-common.sh`'s
|
||||
default). To sweep the suite under native Wayland, set
|
||||
`CLAUDE_HARNESS_USE_WAYLAND=1`:
|
||||
|
||||
```sh
|
||||
CLAUDE_HARNESS_USE_WAYLAND=1 ROW=KDE-W ./orchestrator/sweep.sh
|
||||
```
|
||||
|
||||
Every `launchClaude()` swaps to the Wayland flag set
|
||||
(`--ozone-platform=wayland` + WaylandWindowDecorations / IME / text-
|
||||
input-version=3, mirroring `scripts/launcher-common.sh:132-139`) and
|
||||
exports `CLAUDE_USE_WAYLAND=1` + `GDK_BACKEND=wayland` into the spawn
|
||||
env. Per-launch overrides via `launchClaude({ extraEnv })` still win,
|
||||
so a single test can opt back to X11 inside a Wayland-mode sweep.
|
||||
|
||||
Caveat: T04 (`_NET_FRAME_EXTENTS` xprop check) only works under
|
||||
XWayland — native-Wayland sessions have no X11 client list, so T04
|
||||
will skip with a "no X11 client list" diagnostic.
|
||||
|
||||
## Grounding sweep
|
||||
|
||||
Separate from the test sweep. Where the test sweep verifies *upstream
|
||||
Linux compat behavior* against case specs, the grounding sweep
|
||||
verifies *the specs themselves* against upstream behavior — making
|
||||
sure the Steps and Expected fields haven't bit-rotted past what the
|
||||
shipped build actually does. Run on every upstream `CLAUDE_DESKTOP_VERSION`
|
||||
bump.
|
||||
|
||||
### Static pass
|
||||
|
||||
For each file under [`cases/`](./cases/), confirm every test's
|
||||
`**Code anchors:**` field still resolves and the Steps/Expected match
|
||||
behavior. The convention is documented in
|
||||
[`cases/README.md`](./cases/README.md#anchor-scope) — anchors are
|
||||
either upstream code (`build-reference/app-extracted/.vite/build/`),
|
||||
wrapper scripts (`scripts/`), v7 walker inventory, or out-of-scope
|
||||
(CLI binary, server-rendered SPA).
|
||||
|
||||
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.
|
||||
|
||||
### Runtime pass
|
||||
|
||||
Run [`tools/test-harness/grounding-probe.ts`](../../tools/test-harness/grounding-probe.ts)
|
||||
against the live build:
|
||||
|
||||
```sh
|
||||
cd tools/test-harness
|
||||
npm run grounding-probe -- --launch --include-synthetic \
|
||||
--out ../../docs/testing/cases-grounding-runtime.json
|
||||
```
|
||||
|
||||
Captures runtime state for tests where static greps can't disambiguate
|
||||
(IPC handler registry, `globalShortcut.isRegistered()` for known
|
||||
accelerators, `app.getLoginItemSettings()`, `safeStorage`,
|
||||
`autoUpdater.getFeedURL()`, SNI tray registration, AX-tree fingerprint
|
||||
of whatever's on screen). Output is keyed by test ID — diff against
|
||||
the previous version's capture to spot drift the static pass missed.
|
||||
|
||||
Surfaces inside modals or popups (T22 PR toolbar, T26 preset list,
|
||||
T31 side chat, T32 slash menu) need the surface open at probe time.
|
||||
Open the relevant view in the running app before re-running with
|
||||
`--port 9229` (attach mode).
|
||||
471
docs/troubleshooting.md
Normal file
471
docs/troubleshooting.md
Normal file
@@ -0,0 +1,471 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs a series of checks and prints pass/fail results with
|
||||
suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Input method | IBus/GTK immodule sanity (ibus-gtk3 installed, cache fresh, XWayland routing note) |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
| Desktop entry | `.desktop` file presence |
|
||||
| Disk space | Free space on config partition |
|
||||
| Log file | Log file size |
|
||||
|
||||
Example output:
|
||||
```
|
||||
Claude Desktop Diagnostics
|
||||
================================
|
||||
|
||||
[PASS] Installed version: 1.1.4498-1.3.15
|
||||
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
|
||||
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
|
||||
[PASS] Chrome sandbox: permissions OK
|
||||
[PASS] SingletonLock: no lock file (OK)
|
||||
[PASS] MCP config: valid JSON
|
||||
[PASS] Node.js: v22.14.0
|
||||
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
|
||||
[PASS] Disk space: 632284MB free
|
||||
[PASS] Log file: 1352KB
|
||||
|
||||
All checks passed.
|
||||
```
|
||||
|
||||
When opening an issue, include the output of `--doctor` to help with diagnosis.
|
||||
|
||||
## Application Logs
|
||||
|
||||
Runtime logs are available at:
|
||||
```
|
||||
~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Window Scaling Issues
|
||||
|
||||
If the window doesn't scale correctly on first launch:
|
||||
1. Right-click the Claude Desktop tray icon
|
||||
2. Select "Quit" (do not force quit)
|
||||
3. Restart the application
|
||||
|
||||
This allows the application to save display settings properly.
|
||||
|
||||
### Global Hotkey Not Working (Wayland)
|
||||
|
||||
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
|
||||
|
||||
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
|
||||
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
|
||||
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
|
||||
|
||||
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
|
||||
|
||||
See [configuration.md](configuration.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
|
||||
|
||||
### Keyboard Input Doesn't Work (IBus / GTK Input Method)
|
||||
|
||||
If typing into the chat does nothing, characters get swallowed, or
|
||||
dead-key sequences (e.g. ``` `e ``` → `è`) don't compose, your GTK
|
||||
input module integration with the Electron-bundled GTK is broken.
|
||||
Common symptoms:
|
||||
|
||||
- No characters appear when typing into any text field
|
||||
- The first keystroke after focus is dropped, subsequent ones work
|
||||
- CJK input methods (IBus, Fcitx) not engaging
|
||||
- Compose key / dead-key sequences silently drop
|
||||
|
||||
**First step: run `claude-desktop --doctor`.** It checks for the
|
||||
common misconfigurations and prints fix commands inline:
|
||||
|
||||
- `ibus-gtk3` package missing while `GTK_IM_MODULE=ibus`
|
||||
- GTK immodules cache stale (the active module isn't listed by
|
||||
`gtk-query-immodules-3.0`)
|
||||
- XWayland session routing IBus through XIM (lossy for some IMEs —
|
||||
set `CLAUDE_USE_WAYLAND=1` to use native Wayland IME)
|
||||
- Active value of `CLAUDE_GTK_IM_MODULE` if you've set the override
|
||||
|
||||
If `--doctor` is clean but input still misbehaves, switch the
|
||||
launcher to a different GTK input module. Set `CLAUDE_GTK_IM_MODULE`
|
||||
and Claude Desktop will propagate it as `GTK_IM_MODULE` to Electron
|
||||
at startup:
|
||||
|
||||
```bash
|
||||
# Bypass IBus entirely — uses the X Input Method (XIM) protocol
|
||||
CLAUDE_GTK_IM_MODULE=xim claude-desktop
|
||||
|
||||
# To make it persistent, export it from your shell profile:
|
||||
# echo 'export CLAUDE_GTK_IM_MODULE=xim' >> ~/.profile
|
||||
```
|
||||
|
||||
Valid values: anything your GTK installation supports (`xim`, `ibus`,
|
||||
`fcitx`, `simple`, etc.). When the override is active, the launcher
|
||||
logs a line to `~/.cache/claude-desktop-debian/launcher.log`:
|
||||
|
||||
```
|
||||
GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)
|
||||
```
|
||||
|
||||
**Trade-off:** `xim` is the lowest-common-denominator input module
|
||||
and does not support advanced IME features like CJK candidate
|
||||
windows or rich compose-key sequences. Only reach for it if your
|
||||
real input method (IBus/Fcitx) is broken; if you depend on CJK or
|
||||
compose, prefer fixing the IBus/Fcitx integration instead.
|
||||
|
||||
### Repeated Electron Crashes / GPU Process FATAL ([#583](https://github.com/aaddrick/claude-desktop-debian/issues/583))
|
||||
|
||||
If Claude Desktop crashes repeatedly on launch or shortly after,
|
||||
the most common cause on Linux is the Chromium GPU process hitting
|
||||
a FATAL exhaustion path. `claude-desktop --doctor` surfaces this
|
||||
when `systemd-coredump` shows 3+ Electron crashes in the last 7
|
||||
days, pointing at this issue.
|
||||
|
||||
Two ways to disable hardware acceleration as a workaround:
|
||||
|
||||
1. **In-app:** Settings → toggle hardware acceleration off →
|
||||
restart Claude Desktop. Persists in the upstream config.
|
||||
2. **Env var (headless / persists across reinstalls):** set
|
||||
`CLAUDE_DISABLE_GPU=1` in the environment before launching.
|
||||
|
||||
```bash
|
||||
# One-off:
|
||||
CLAUDE_DISABLE_GPU=1 claude-desktop
|
||||
|
||||
# Persistent (shell profile):
|
||||
echo 'export CLAUDE_DISABLE_GPU=1' >> ~/.profile
|
||||
```
|
||||
|
||||
When `CLAUDE_DISABLE_GPU=1` is set, the launcher passes
|
||||
`--disable-gpu --disable-software-rasterizer` to Electron (see
|
||||
`scripts/launcher-common.sh`). This is the same pair of flags
|
||||
applied automatically inside XRDP sessions, where software
|
||||
rendering is required regardless. Either signal is sufficient —
|
||||
the launcher won't stack duplicate flags.
|
||||
|
||||
**When to prefer which:** the in-app toggle is friendlier if you
|
||||
can reach Settings without the app crashing. Reach for
|
||||
`CLAUDE_DISABLE_GPU=1` when the app crashes before you can open
|
||||
Settings, when running in environments with no GPU available
|
||||
(XRDP, headless CI smoke tests, some VMs), or when you want the
|
||||
behavior to persist across reinstalls and config resets.
|
||||
|
||||
Tracking issue: [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583).
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
|
||||
For enhanced security, consider:
|
||||
- Using the .deb package instead
|
||||
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
|
||||
- Using Gear Lever's integrated AppImage management for better isolation
|
||||
|
||||
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
|
||||
|
||||
Ubuntu 24.04 ships with `apparmor_restrict_unprivileged_userns=1`
|
||||
by default, which blocks the unprivileged user namespaces that
|
||||
Cowork's bubblewrap sandbox relies on. Symptoms:
|
||||
|
||||
- `claude-desktop --doctor` reports `bubblewrap: sandbox probe failed`
|
||||
with `Operation not permitted` in stderr.
|
||||
- `~/.config/Claude/logs/cowork_vm_daemon.log` contains
|
||||
`bwrap is installed but cannot create a user namespace`.
|
||||
- Cowork sessions hang at "Starting VM..." or loop on reconnect.
|
||||
|
||||
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
|
||||
setup, requires sudo):
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/bwrap>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
||||
```
|
||||
|
||||
After applying the profile, run `claude-desktop --doctor` — the
|
||||
bubblewrap probe should pass, and Cowork should start without
|
||||
falling back to host-direct.
|
||||
|
||||
**Security note:** this grants `/usr/bin/bwrap` the unconfined
|
||||
profile plus the `userns` capability. It matches the behavior
|
||||
bwrap had on Ubuntu 22.04 and earlier, and on most other distros,
|
||||
but is a system-wide change that affects every program invoking
|
||||
`/usr/bin/bwrap` (not just Claude Desktop). Review the profile
|
||||
against your threat model before applying.
|
||||
|
||||
Credit: this workaround was contributed by
|
||||
[@hfyeh](https://github.com/hfyeh) in
|
||||
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
|
||||
|
||||
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
|
||||
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
|
||||
|
||||
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
|
||||
|
||||
```bash
|
||||
COWORK_VM_BACKEND=bwrap claude-desktop
|
||||
```
|
||||
|
||||
See [configuration.md](configuration.md#cowork-backend) for how to make this permanent.
|
||||
|
||||
### Cowork: virtiofsd not found (Fedora/RHEL)
|
||||
|
||||
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
|
||||
outside `$PATH`. The `--doctor` check detects it there automatically and will
|
||||
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
|
||||
resolves it through `$PATH` only.
|
||||
|
||||
**Fix:** Create a symlink so the KVM backend can find it at runtime:
|
||||
|
||||
```bash
|
||||
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||
```
|
||||
|
||||
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
|
||||
|
||||
### Cowork: cross-device link error on Fedora tmpfs /tmp
|
||||
|
||||
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
|
||||
|
||||
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/Claude/tmp
|
||||
TMPDIR=~/.config/Claude/tmp claude-desktop
|
||||
```
|
||||
|
||||
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
|
||||
|
||||
### Cowork: ENAMETOOLONG on encrypted home (eCryptfs)
|
||||
|
||||
Cowork sessions can fail with an opaque `ENAMETOOLONG` error when
|
||||
`$HOME` is on a filesystem with a short filename limit. The common
|
||||
case is **eCryptfs** — the legacy "encrypted home" option on older
|
||||
Ubuntu and Linux Mint installs, which caps individual filenames at
|
||||
143 chars because of filename-encryption overhead. Standard
|
||||
filesystems (ext4, btrfs, xfs, zfs) cap at 255 chars and are fine.
|
||||
|
||||
**Why it happens:** Claude Code creates one directory per session
|
||||
under `~/.claude/projects/`, named after the sanitized host CWD. For
|
||||
cowork sessions the host CWD is the deeply nested outputs dir under
|
||||
`~/.config/Claude/local-agent-mode-sessions/<accountId>/<orgId>/local_<uuid>/outputs`,
|
||||
which sanitizes to ~180 chars — fits ext4 but exceeds the eCryptfs
|
||||
143-char ceiling.
|
||||
|
||||
**Diagnosis:** `claude-desktop --doctor` detects this automatically
|
||||
and emits a `[WARN] Filename limit: NAME_MAX=143…` line, plus an
|
||||
eCryptfs-specific hint when the filesystem type matches. You can
|
||||
also check by hand:
|
||||
|
||||
```bash
|
||||
df -T $HOME # look for type "ecryptfs"
|
||||
getconf NAME_MAX $HOME # eCryptfs reports 143; ext4 reports 255
|
||||
```
|
||||
|
||||
**Workaround:** move Claude's data onto a separate LUKS-encrypted
|
||||
ext4 volume (NAME_MAX = 255) and symlink the original paths back.
|
||||
`~/.claude/` is the critical one — that's where Claude Code creates
|
||||
the long-named per-session dirs that overflow the limit — and
|
||||
`~/.config/Claude/` plus `~/.cache/claude-desktop-debian/` are
|
||||
relocated alongside it so all Claude state lives on the same volume.
|
||||
This keeps the data encrypted at rest while sidestepping the
|
||||
eCryptfs filename-length cap.
|
||||
|
||||
```bash
|
||||
# 1. Create a 2 GB LUKS container
|
||||
sudo dd if=/dev/urandom of=/opt/claude-secure.img bs=1M count=2048 \
|
||||
status=progress
|
||||
sudo cryptsetup luksFormat /opt/claude-secure.img
|
||||
sudo cryptsetup open /opt/claude-secure.img claude-secure
|
||||
sudo mkfs.ext4 /dev/mapper/claude-secure
|
||||
|
||||
# 2. Mount and move Claude's data in
|
||||
sudo mkdir -p /mnt/claude-secure
|
||||
sudo mount /dev/mapper/claude-secure /mnt/claude-secure
|
||||
sudo chown "$USER:$USER" /mnt/claude-secure
|
||||
|
||||
mv ~/.config/Claude /mnt/claude-secure/Claude-config
|
||||
mv ~/.cache/claude-desktop-debian /mnt/claude-secure/claude-cache
|
||||
# ~/.claude may not exist yet on a fresh install — create the target
|
||||
# either way so the symlink below resolves.
|
||||
if [ -e ~/.claude ]; then
|
||||
mv ~/.claude /mnt/claude-secure/claude-home
|
||||
else
|
||||
mkdir -p /mnt/claude-secure/claude-home
|
||||
fi
|
||||
|
||||
ln -s /mnt/claude-secure/Claude-config ~/.config/Claude
|
||||
ln -s /mnt/claude-secure/claude-cache ~/.cache/claude-desktop-debian
|
||||
ln -s /mnt/claude-secure/claude-home ~/.claude
|
||||
|
||||
# 3. Verify the filename limit and the symlinks
|
||||
getconf NAME_MAX /mnt/claude-secure # should print 255
|
||||
mountpoint /mnt/claude-secure # confirms the volume is mounted
|
||||
readlink ~/.claude # /mnt/claude-secure/claude-home
|
||||
readlink ~/.config/Claude # /mnt/claude-secure/Claude-config
|
||||
```
|
||||
|
||||
**If you've set `CLAUDE_CONFIG_DIR`** (or otherwise reconfigured
|
||||
Claude Code to use a directory other than `~/.claude/`), the
|
||||
`~/.claude` symlink above doesn't apply — adapt the path to wherever
|
||||
your Claude Code config actually lives. The constraint is the same:
|
||||
the directory tree where Claude Code creates per-session project
|
||||
dirs must sit on a filesystem with `NAME_MAX` ≥ ~200.
|
||||
|
||||
**Auto-mount at login** with `pam_mount` so the volume unlocks
|
||||
without a manual `cryptsetup open`:
|
||||
|
||||
```bash
|
||||
sudo apt install libpam-mount
|
||||
```
|
||||
|
||||
Add a `<volume>` entry to `/etc/security/pam_mount.conf.xml`
|
||||
(replace `YOUR_USERNAME` with your login name):
|
||||
|
||||
```xml
|
||||
<volume user="YOUR_USERNAME" fstype="crypt"
|
||||
path="/opt/claude-secure.img"
|
||||
mountpoint="/mnt/claude-secure"
|
||||
options="" />
|
||||
```
|
||||
|
||||
`libpam-mount` registers itself with `/etc/pam.d/common-auth` and
|
||||
`/etc/pam.d/common-session` automatically on install.
|
||||
|
||||
**Notes:**
|
||||
- Tested on Linux Mint with LightDM as the display manager.
|
||||
- **LUKS passphrase tradeoff:** for `pam_mount` to unlock silently
|
||||
at login the LUKS passphrase must match your login password. That
|
||||
means one compromise unlocks both your session and the encrypted
|
||||
volume — equivalent to the threat surface eCryptfs already had,
|
||||
but worth a deliberate choice. Use a distinct LUKS passphrase if
|
||||
you'd rather be prompted on each unlock.
|
||||
- **Confidentiality posture vs eCryptfs.** The LUKS image lives at
|
||||
`/opt/claude-secure.img`, outside `$HOME` and outside whatever
|
||||
encryption envelope eCryptfs gives you. If `pam_mount` ever fails
|
||||
silently — wrong passphrase, mount race at login, profile error —
|
||||
Claude won't start (the symlink targets won't exist), so writes
|
||||
fail loudly rather than landing on plaintext disk. Verify with
|
||||
`mountpoint /mnt/claude-secure` after login if you're unsure.
|
||||
- 2 GB is a conservative starting size; the Claude config
|
||||
directory can exceed 500 MB once cowork session history
|
||||
accumulates. Resize if needed.
|
||||
- This is a system-wide change that affects login flow — review
|
||||
the pam_mount config against your threat model before applying.
|
||||
|
||||
Credit: reported with detailed `--doctor` output by
|
||||
[@michelsfun](https://github.com/michelsfun); LUKS-volume workaround
|
||||
contributed by [@proffalken](https://github.com/proffalken) in
|
||||
[#590](https://github.com/aaddrick/claude-desktop-debian/issues/590).
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
|
||||
|
||||
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
|
||||
|
||||
1. Close Claude Desktop completely
|
||||
2. Edit `~/.config/Claude/config.json`
|
||||
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
|
||||
4. Save the file and restart Claude Desktop
|
||||
5. Log in again when prompted
|
||||
|
||||
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### For APT repository installations (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
|
||||
# Remove the repository and GPG key
|
||||
sudo rm /etc/apt/sources.list.d/claude-desktop.list
|
||||
sudo rm /usr/share/keyrings/claude-desktop.gpg
|
||||
```
|
||||
|
||||
### For DNF repository installations (Fedora/RHEL)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
|
||||
# Remove the repository
|
||||
sudo rm /etc/yum.repos.d/claude-desktop.repo
|
||||
```
|
||||
|
||||
### For AUR installations (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -R claude-desktop-appimage
|
||||
|
||||
# Or using paru
|
||||
paru -R claude-desktop-appimage
|
||||
|
||||
# Or using pacman directly
|
||||
sudo pacman -R claude-desktop-appimage
|
||||
```
|
||||
|
||||
### For .deb packages (manual install)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
# Or: sudo dpkg -r claude-desktop
|
||||
|
||||
# Remove package and configuration
|
||||
sudo dpkg -P claude-desktop
|
||||
```
|
||||
|
||||
### For .rpm packages
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
# Or: sudo rpm -e claude-desktop
|
||||
```
|
||||
|
||||
### For AppImages
|
||||
|
||||
1. Delete the `.AppImage` file
|
||||
2. Remove the `.desktop` file from `~/.local/share/applications/`
|
||||
3. If using Gear Lever, use its uninstall option
|
||||
|
||||
### Remove user configuration (all formats)
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/Claude
|
||||
```
|
||||
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": 1779536132,
|
||||
"narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
|
||||
"rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456",
|
||||
"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.8555.2";
|
||||
|
||||
srcs = {
|
||||
x86_64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-5hnHvTtnRqcwfr7+UJv+RHoUOu2X5sf2Zmd7Nqa2ulQ=";
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-GrV+iMhkUc8ZnRVo11Hat/4p5L36Wj8DX9sVuHLHo1I=";
|
||||
};
|
||||
aarch64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-v33l1sASVC/q331cqnenLfzqGyRRLpptKOAEukrioR0=";
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-PDGaWaWbML/rhvcbbfgIkcXJg0BPEuRk9L4XVM1NLJQ=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,6 +124,7 @@ stdenvNoCC.mkDerivation {
|
||||
# Copy the ELF binary — MUST be a real copy (not symlink) so that
|
||||
# /proc/self/exe resolves to our tree
|
||||
cp ${electronDir}/electron $electron_tree/electron
|
||||
chmod +x $electron_tree/electron
|
||||
|
||||
# Symlink everything else from electron-unwrapped
|
||||
for item in ${electronDir}/*; do
|
||||
@@ -245,6 +246,7 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start (NixOS) ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -263,12 +265,10 @@ build_electron_args 'nix'
|
||||
# Add app path
|
||||
electron_args+=("$app_path")
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
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
|
||||
exec "$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
LAUNCHER
|
||||
# Substitute placeholders — electron_exec points to our custom
|
||||
# wrapper (which sets GTK/GIO env then execs our merged binary)
|
||||
|
||||
29
scripts/cowork-patch-markers.tsv
Normal file
29
scripts/cowork-patch-markers.tsv
Normal file
@@ -0,0 +1,29 @@
|
||||
# Cowork patch markers — single source of truth.
|
||||
#
|
||||
# Format:
|
||||
# <name><TAB><pcre_pattern><TAB><sample>
|
||||
# Lines starting with '#' and blank lines are ignored.
|
||||
#
|
||||
# Each row names a post-patch fingerprint of patch_cowork_linux() in
|
||||
# scripts/patches/cowork.sh. Both verify-patches.sh and
|
||||
# tests/verify-patches.bats consume this file, so adding a marker
|
||||
# here adds it to the runtime check and the test matrix at the same
|
||||
# time.
|
||||
#
|
||||
# Columns:
|
||||
# name — kebab-case id; surfaces in verify output and BATS names.
|
||||
# pattern — PCRE matched against the shipped index.js by `grep -P`.
|
||||
# sample — concrete string the pattern matches; BATS uses it to
|
||||
# build positive and per-marker negative fixtures.
|
||||
#
|
||||
# The 9 markers below correspond 1:1 with the smoke-test set defined
|
||||
# in issue #559 (PR #555 retrofit, deliverable D6).
|
||||
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
|
||||
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
|
||||
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
|
||||
empty-linux-bundle-manifest linux:\{x64:\[\],arm64:\[\]\} ,linux:{x64:[],arm64:[]}
|
||||
getdownloadstatus-suppression getDownloadStatus\(\)\{return process\.platform==="linux"\?[\w$]+\.NotDownloaded getDownloadStatus(){return process.platform==="linux"?Z.NotDownloaded
|
||||
econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED" (n.code==="ENOENT"||process.platform==="linux"&&n.code==="ECONNREFUSED")
|
||||
cowork-daemon-pid global\.__coworkDaemonPid global.__coworkDaemonPid=_c.pid
|
||||
cowork-linux-daemon-shutdown cowork-linux-daemon-shutdown name:"cowork-linux-daemon-shutdown"
|
||||
sharedcwdpath-threadthrough sharedCwdPath:this\.sessions\.get\( sharedCwdPath:this.sessions.get(t)?.userSelectedFolders?.[0]
|
||||
|
Can't render this file because it contains an unexpected character in line 21 and column 39.
|
@@ -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
|
||||
#
|
||||
@@ -71,12 +72,110 @@ _cowork_pkg_hint() {
|
||||
arch) pkg='qemu-full' ;;
|
||||
esac
|
||||
;;
|
||||
ibus-gtk3)
|
||||
# Arch ships the GTK3 immodule as part of the main ibus
|
||||
# package; Debian/Ubuntu and Fedora split it out.
|
||||
case "$distro" in
|
||||
arch) pkg='ibus' ;;
|
||||
*) pkg='ibus-gtk3' ;;
|
||||
esac
|
||||
;;
|
||||
*) pkg="$tool" ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$pkg_cmd $pkg"
|
||||
}
|
||||
|
||||
# Return 0 if the named package is installed, 1 otherwise. Returns 2
|
||||
# (treated as "unknown") when no recognized package manager is
|
||||
# available — callers should not warn in that case to avoid false
|
||||
# positives on unsupported distros.
|
||||
_pkg_installed() {
|
||||
local distro="$1"
|
||||
local pkg="$2"
|
||||
case "$distro" in
|
||||
debian|ubuntu)
|
||||
command -v dpkg-query &>/dev/null || return 2
|
||||
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
|
||||
| grep -q 'install ok installed'
|
||||
;;
|
||||
fedora)
|
||||
command -v rpm &>/dev/null || return 2
|
||||
rpm -q "$pkg" &>/dev/null
|
||||
;;
|
||||
arch)
|
||||
command -v pacman &>/dev/null || return 2
|
||||
pacman -Q "$pkg" &>/dev/null
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Diagnose IBus / GTK input-method misconfigurations that break
|
||||
# keyboard input in the chat (#550). Surfaces:
|
||||
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
|
||||
# - XWayland-with-IBus routing note: on a Wayland session Electron
|
||||
# defaults to XWayland (preserves global hotkeys), which forces
|
||||
# the IBus path through XIM — a known weak link for some IMEs.
|
||||
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
|
||||
# - GTK immodules cache stale: active module not listed by
|
||||
# gtk-query-immodules-3.0 (--update-cache fixes it)
|
||||
#
|
||||
# Usage: _doctor_check_im_modules <distro_id>
|
||||
_doctor_check_im_modules() {
|
||||
local distro="$1"
|
||||
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
|
||||
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
|
||||
"(overrides GTK_IM_MODULE for Electron)"
|
||||
fi
|
||||
|
||||
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
|
||||
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
|
||||
_info \
|
||||
'IME note: Wayland session, Electron via XWayland —' \
|
||||
'IBus path goes through XIM (lossy for some IMEs).'
|
||||
_info \
|
||||
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
|
||||
'(loses global hotkeys).'
|
||||
fi
|
||||
|
||||
# Nothing further to check without an active IM module.
|
||||
[[ -n $active_im ]] || return 0
|
||||
|
||||
# ibus-gtk3 package check — only when the active module is ibus.
|
||||
# rc=1 means definitely missing (warn); rc=2 means unsupported
|
||||
# distro / no package manager (skip silently to avoid false
|
||||
# negatives). On warn, return early — `apt install` refreshes
|
||||
# the immodules cache, so the cache check below would be noise.
|
||||
if [[ $active_im == 'ibus' ]]; then
|
||||
_pkg_installed "$distro" ibus-gtk3
|
||||
case $? in
|
||||
1)
|
||||
_warn \
|
||||
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
|
||||
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
|
||||
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
|
||||
# means GTK 3 isn't in use — skip silently rather than warn.
|
||||
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
|
||||
|
||||
if ! gtk-query-immodules-3.0 2>/dev/null \
|
||||
| grep -q "\"$active_im\""; then
|
||||
_warn \
|
||||
"GTK immodules: '$active_im' not listed by" \
|
||||
"gtk-query-immodules-3.0 (cache may be stale)"
|
||||
_info \
|
||||
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
|
||||
fi
|
||||
}
|
||||
|
||||
# Read the version string from the version file beside an Electron binary.
|
||||
# Prints the raw version string, or nothing if unavailable.
|
||||
_electron_version() {
|
||||
@@ -338,6 +437,147 @@ JSEOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Diagnose short-filename-limit filesystems that break cowork session
|
||||
# initialization. Claude Code creates a per-session directory under
|
||||
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
|
||||
# sessions that flattens to ~180 chars (the host CWD is the deeply
|
||||
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
|
||||
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
|
||||
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
|
||||
# overhead — that mkdir fails with ENAMETOOLONG and the session never
|
||||
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
|
||||
# #590.
|
||||
_doctor_check_filename_limit() {
|
||||
# Walk up from ~/.claude/projects to the first dir that exists so
|
||||
# getconf has something to query on a fresh install where the tree
|
||||
# hasn't been created yet. $HOME is the floor — stop there rather
|
||||
# than crossing into /.
|
||||
local probe_dir="$HOME/.claude/projects"
|
||||
while [[ ! -d $probe_dir ]]; do
|
||||
probe_dir=$(dirname "$probe_dir")
|
||||
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
|
||||
done
|
||||
[[ -d $probe_dir ]] || return 0
|
||||
|
||||
local name_max
|
||||
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
|
||||
[[ $name_max =~ ^[0-9]+$ ]] || return 0
|
||||
|
||||
((name_max >= 200)) && return 0
|
||||
|
||||
_warn "Filename limit: NAME_MAX=$name_max on $probe_dir (< 200)"
|
||||
_info \
|
||||
'Cowork sessions create project-dir names up to ~180 chars' \
|
||||
'under ~/.claude/projects/; short limits cause ENAMETOOLONG'
|
||||
_info 'when Claude Code initializes a session inside cowork (#590).'
|
||||
|
||||
local fs_type
|
||||
fs_type=$(df --output=fstype "$probe_dir" 2>/dev/null \
|
||||
| awk 'NR==2 {print $1}')
|
||||
if [[ $fs_type == 'ecryptfs' ]]; then
|
||||
_info \
|
||||
'Detected eCryptfs (legacy Ubuntu/Mint encrypted home,' \
|
||||
'NAME_MAX=143 due to filename-encryption overhead).'
|
||||
_info \
|
||||
'Workaround: move ~/.config/Claude onto a separate' \
|
||||
'LUKS-encrypted ext4 volume (NAME_MAX=255) and symlink it'
|
||||
_info \
|
||||
'back. See docs/troubleshooting.md "Cowork: ENAMETOOLONG' \
|
||||
'on encrypted home (eCryptfs)" for the worked steps.'
|
||||
fi
|
||||
}
|
||||
|
||||
# Surface a warning when systemd-coredump shows N+ recent Electron
|
||||
# crashes. The most common cause on Linux is the GPU process FATAL
|
||||
# exhaustion tracked in #583 — workaround for affected users is the
|
||||
# upstream Settings → disable hardware acceleration toggle, or
|
||||
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
|
||||
#
|
||||
# Arguments: $1 = electron path (e.g.,
|
||||
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
|
||||
# Used to filter results to claude-desktop's electron when possible;
|
||||
# falls back to all-electron crashes when the path doesn't match
|
||||
# (e.g., AppImage mount paths are transient).
|
||||
_doctor_check_recent_crashes() {
|
||||
local electron_path="${1:-}"
|
||||
command -v coredumpctl &>/dev/null || return 0
|
||||
|
||||
# `coredumpctl list electron` filters by COMM=electron. If the
|
||||
# exact electron_path matches any entry's EXE column, prefer that
|
||||
# tighter count; otherwise fall back to all-electron entries.
|
||||
local listing total_count path_count
|
||||
listing=$(coredumpctl list electron \
|
||||
--since='7 days ago' --no-pager 2>/dev/null) || return 0
|
||||
[[ -n $listing ]] || return 0
|
||||
|
||||
# Drop the header line; count remaining entries.
|
||||
# Assumes `coredumpctl list electron`'s COMM=electron filter
|
||||
# excludes `-- Reboot --` separator rows from the listing (true
|
||||
# on systemd as of writing). The path-matched branch below uses
|
||||
# index($0, p) so it's unaffected even if that ever changes;
|
||||
# revisit this total-count branch if a future systemd version
|
||||
# starts leaking reboot markers into per-COMM listings.
|
||||
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
|
||||
((total_count == 0)) && return 0
|
||||
|
||||
if [[ -n $electron_path ]]; then
|
||||
path_count=$(awk -v p="$electron_path" \
|
||||
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
|
||||
else
|
||||
path_count=0
|
||||
fi
|
||||
|
||||
# Use the path-matched count when available; else the unfiltered
|
||||
# count with a footnote so the user knows it may include other
|
||||
# Electron apps (Slack, VSCode, etc.).
|
||||
local count footnote=''
|
||||
if ((path_count > 0)); then
|
||||
count=$path_count
|
||||
else
|
||||
count=$total_count
|
||||
footnote=' (some entries may be from other Electron apps)'
|
||||
fi
|
||||
|
||||
# Threshold tuned against the #583 repro (~10 crashes over 7 days
|
||||
# on the affected laptop); a noisy session typically clears 3 in a
|
||||
# week, so 3 is the floor for "worth surfacing the workaround".
|
||||
if ((count >= 3)); then
|
||||
_warn "Recent Electron crashes: $count in last 7 days$footnote"
|
||||
_info \
|
||||
'Most common cause: Chromium GPU process FATAL (#583).' \
|
||||
'Try one of:'
|
||||
_info ' Settings → toggle hardware acceleration off → restart'
|
||||
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
|
||||
_info \
|
||||
'Tracking:' \
|
||||
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
|
||||
elif ((count > 0)); then
|
||||
_info "Recent Electron crashes: $count in last 7 days$footnote"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report the active Chromium password-store backend.
|
||||
#
|
||||
# Calls _detect_password_store() (defined in launcher-common.sh, which
|
||||
# sources this file) to surface what keyring Electron will use for
|
||||
# safeStorage / cookie encryption. 'basic' is valid but means tokens
|
||||
# rely on filesystem permissions alone, so we note it for visibility.
|
||||
# Never fails — basic is an intentional fallback, not an error.
|
||||
_doctor_check_password_store() {
|
||||
local store
|
||||
store=$(_detect_password_store)
|
||||
_pass "Password store: $store"
|
||||
if [[ $store == 'basic' ]]; then
|
||||
_info \
|
||||
' → using fixed-key fallback;' \
|
||||
'tokens are protected by filesystem permissions only'
|
||||
fi
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
_info \
|
||||
" → overridden by CLAUDE_PASSWORD_STORE=${CLAUDE_PASSWORD_STORE}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run all diagnostic checks and print results
|
||||
# Arguments: $1 = electron path (optional, for package-specific checks)
|
||||
run_doctor() {
|
||||
@@ -345,6 +585,11 @@ run_doctor() {
|
||||
local _doctor_failures=0
|
||||
_doctor_colors
|
||||
|
||||
# Distro ID is shared between the IM-module check (#550) and the
|
||||
# Cowork Mode section further down. Resolve once.
|
||||
local _distro_id
|
||||
_distro_id=$(_cowork_distro_id)
|
||||
|
||||
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
|
||||
echo '================================'
|
||||
echo
|
||||
@@ -381,6 +626,9 @@ run_doctor() {
|
||||
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
|
||||
fi
|
||||
|
||||
# -- Input method (IBus / GTK) --
|
||||
_doctor_check_im_modules "$_distro_id"
|
||||
|
||||
# -- Menu bar mode --
|
||||
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
|
||||
if [[ -n $menu_bar_mode ]]; then
|
||||
@@ -429,6 +677,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).
|
||||
@@ -502,6 +758,9 @@ run_doctor() {
|
||||
_pass 'SingletonLock: no lock file (OK)'
|
||||
fi
|
||||
|
||||
# -- Password store --
|
||||
_doctor_check_password_store
|
||||
|
||||
# -- MCP config --
|
||||
local mcp_config="$config_dir/claude_desktop_config.json"
|
||||
if [[ -f $mcp_config ]]; then
|
||||
@@ -589,10 +848,6 @@ print(len(servers))
|
||||
echo -e "${_bold}Cowork Mode${_reset}"
|
||||
echo '----------------'
|
||||
|
||||
# Detect distro for package hints
|
||||
local _distro_id
|
||||
_distro_id=$(_cowork_distro_id)
|
||||
|
||||
# Determine whether bwrap is the active backend (for severity
|
||||
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
|
||||
# bwrap is active unless the user has overridden to KVM or host.
|
||||
@@ -641,7 +896,7 @@ print(len(servers))
|
||||
' Common on Ubuntu 24.04+ where AppArmor sets' \
|
||||
'apparmor_restrict_unprivileged_userns=1'
|
||||
_info \
|
||||
' by default. See docs/TROUBLESHOOTING.md' \
|
||||
' by default. See docs/troubleshooting.md' \
|
||||
'"Cowork on Ubuntu 24.04"'
|
||||
_info ' for the AppArmor profile fix.'
|
||||
fi
|
||||
@@ -785,6 +1040,10 @@ print(len(servers))
|
||||
# Custom bwrap mount configuration
|
||||
_doctor_check_bwrap_mounts
|
||||
|
||||
# Short NAME_MAX on the host's ~/.claude tree (eCryptfs etc.)
|
||||
# blocks cowork session init with ENAMETOOLONG — see #590.
|
||||
_doctor_check_filename_limit
|
||||
|
||||
# -- Orphaned cowork daemon --
|
||||
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
|
||||
# above: a live UI is an Electron main process on app.asar that is
|
||||
@@ -821,6 +1080,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 matches --class= and desktopName so DEs group
|
||||
// an autostarted window with user-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=claude-desktop
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,41 @@ log_message() {
|
||||
echo "$1" >> "$log_file"
|
||||
}
|
||||
|
||||
# Log the session/IME environment vars that drive display and input
|
||||
# decisions, so bug reports include enough context to reason about
|
||||
# them without round-trip env-dump requests (#548).
|
||||
#
|
||||
# Emits one block:
|
||||
# env={
|
||||
# KEY=value
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
# Empty or unset values are emitted as `KEY=` so absence is
|
||||
# unambiguous (vs. silently omitted). Caller must run setup_logging
|
||||
# first.
|
||||
log_session_env() {
|
||||
local key
|
||||
log_message 'env={'
|
||||
for key in \
|
||||
XDG_SESSION_TYPE \
|
||||
WAYLAND_DISPLAY \
|
||||
DISPLAY \
|
||||
XDG_CURRENT_DESKTOP \
|
||||
GTK_IM_MODULE \
|
||||
XMODIFIERS \
|
||||
QT_IM_MODULE \
|
||||
CLAUDE_USE_WAYLAND \
|
||||
CLAUDE_TITLEBAR_STYLE \
|
||||
CLAUDE_PASSWORD_STORE \
|
||||
CLAUDE_GTK_IM_MODULE \
|
||||
CLAUDE_DISABLE_GPU
|
||||
do
|
||||
log_message " $key=${!key:-}"
|
||||
done
|
||||
log_message '}'
|
||||
}
|
||||
|
||||
# Detect display backend (Wayland vs X11)
|
||||
# Sets: is_wayland, use_x11_on_wayland
|
||||
detect_display_backend() {
|
||||
@@ -67,6 +102,56 @@ _resolve_titlebar_style() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Determine the best available Chromium password-store backend.
|
||||
#
|
||||
# Electron's safeStorage API and Chromium's cookie encryption both rely
|
||||
# on the OS credential store selected by --password-store. Without a
|
||||
# working store safeStorage.isEncryptionAvailable() returns false, OAuth
|
||||
# tokens are silently discarded on exit, and users must re-authenticate
|
||||
# on every launch (Cookies file stays 0 bytes). Fixes: #593
|
||||
#
|
||||
# Detection order (first match wins):
|
||||
# CLAUDE_PASSWORD_STORE env var — explicit user override
|
||||
# kwallet6 — KDE Plasma 6 keyring
|
||||
# gnome-libsecret — GNOME Keyring / libsecret bridge
|
||||
# basic — fixed internal key (always works)
|
||||
#
|
||||
# With 'basic' the stored data is encrypted with a fixed key. Tokens
|
||||
# remain protected by Linux filesystem permissions on ~/.config/Claude/.
|
||||
#
|
||||
# Assumes a D-Bus session bus is available; this is true for any
|
||||
# graphical login session.
|
||||
_detect_password_store() {
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
echo "$CLAUDE_PASSWORD_STORE"
|
||||
return
|
||||
fi
|
||||
|
||||
# kwallet6: KDE Plasma 6 keyring
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.kde.kwalletd6 \
|
||||
/modules/kwalletd6 \
|
||||
org.kde.KWallet.isEnabled 2>/dev/null \
|
||||
| grep -q 'boolean true'
|
||||
then
|
||||
echo 'kwallet6'
|
||||
return
|
||||
fi
|
||||
|
||||
# gnome-libsecret: GNOME Keyring, KWallet 5 compat bridge, etc.
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.freedesktop.secrets \
|
||||
/org/freedesktop/secrets \
|
||||
org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1
|
||||
then
|
||||
echo 'gnome-libsecret'
|
||||
return
|
||||
fi
|
||||
|
||||
# No keyring accessible — fall back to fixed-key provider.
|
||||
echo 'basic'
|
||||
}
|
||||
|
||||
# Build Electron arguments array based on display backend
|
||||
# Requires: is_wayland, use_x11_on_wayland to be set
|
||||
# (call detect_display_backend first)
|
||||
@@ -96,6 +181,26 @@ build_electron_args() {
|
||||
electron_args+=('--disable-features=CustomTitlebar')
|
||||
fi
|
||||
|
||||
# Explicitly set the X11 WM_CLASS to match StartupWMClass in the
|
||||
# .desktop file and the Wayland app_id from desktopName. Without
|
||||
# this, Electron may derive an unpredictable class name, which
|
||||
# breaks taskbar grouping and can trigger crashes in third-party
|
||||
# GNOME extensions that filter by WM_CLASS. Ref: #635, #561
|
||||
electron_args+=('--class=claude-desktop')
|
||||
|
||||
# Chromium's safeStorage API and cookie encryption both require a
|
||||
# system keyring selected by --password-store. Without an explicit
|
||||
# value, Electron may silently report encryption unavailable even
|
||||
# when a keyring daemon is running, discarding OAuth tokens on exit
|
||||
# and forcing re-authentication on every launch. We probe for the
|
||||
# best available store at startup and pass it before the app path
|
||||
# so Chromium treats it as a Chromium flag (args after the app
|
||||
# path go to the renderer, not Chromium). Fixes: #593
|
||||
local pw_store
|
||||
pw_store=$(_detect_password_store)
|
||||
electron_args+=("--password-store=${pw_store}")
|
||||
log_message "Password store: ${pw_store}"
|
||||
|
||||
# Remote XRDP sessions lack GPU acceleration and render a blank
|
||||
# window when GPU compositing is enabled. Detect via XRDP_SESSION
|
||||
# (set by xrdp's session init) and loginctl session Type. We do
|
||||
@@ -107,10 +212,24 @@ build_electron_args() {
|
||||
loginctl show-session "$XDG_SESSION_ID" \
|
||||
-p Type --value 2>/dev/null
|
||||
)
|
||||
# Track GPU-disable decision so XRDP and CLAUDE_DISABLE_GPU don't
|
||||
# stack duplicate flags. Either signal is sufficient.
|
||||
local _disable_gpu=false
|
||||
if [[ -n ${XRDP_SESSION:-} || $rdp_session_type == xrdp ]]; then
|
||||
electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
_disable_gpu=true
|
||||
log_message 'XRDP session detected - GPU compositing disabled'
|
||||
fi
|
||||
# CLAUDE_DISABLE_GPU=1: opt-in workaround for users hitting the
|
||||
# Chromium GPU process FATAL exhaustion (#583). The same upstream
|
||||
# behaviour is reachable via Settings → disable hardware
|
||||
# acceleration; this lets users persist it via the env without
|
||||
# having to reach the Settings UI through repeated crashes.
|
||||
if [[ ${CLAUDE_DISABLE_GPU:-} == '1' ]]; then
|
||||
_disable_gpu=true
|
||||
log_message 'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
|
||||
fi
|
||||
[[ $_disable_gpu == true ]] \
|
||||
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
|
||||
# X11 session - no special flags needed
|
||||
if [[ $is_wayland != true ]]; then
|
||||
@@ -282,6 +401,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
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
|
||||
@@ -98,6 +98,7 @@ log_message '--- Claude Desktop AppImage Start ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_message "APPDIR: $appdir"
|
||||
log_session_env
|
||||
|
||||
# Path to the bundled Electron executable and app
|
||||
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
|
||||
@@ -132,7 +133,7 @@ Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Comment=Claude Desktop for Linux
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=claude-desktop
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop
|
||||
EOF
|
||||
|
||||
@@ -84,7 +84,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=claude-desktop
|
||||
EOF
|
||||
echo 'Desktop entry created'
|
||||
|
||||
@@ -114,6 +114,7 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -165,13 +166,10 @@ 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 (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
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
|
||||
exec "\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
EOF
|
||||
chmod +x "$install_dir/bin/claude-desktop" || exit 1
|
||||
echo 'Launcher script created'
|
||||
@@ -205,7 +203,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
|
||||
|
||||
@@ -68,7 +68,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=claude-desktop
|
||||
EOF
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
@@ -97,6 +97,7 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -148,13 +149,10 @@ 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 (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
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
|
||||
exec "\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
EOF
|
||||
chmod +x "$staging_dir/claude-desktop"
|
||||
|
||||
@@ -228,21 +226,18 @@ install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/appli
|
||||
# Install launcher script
|
||||
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
|
||||
|
||||
# Set the chrome-sandbox suid bit in the buildroot so the /usr/lib
|
||||
# directory walk in %files records 4755 in the payload (preserves #539
|
||||
# without the "File listed twice" warning #609 — see %files block).
|
||||
chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
%post
|
||||
# Update desktop database for MIME types
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox
|
||||
SANDBOX_PATH="/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox"
|
||||
if [ -f "\$SANDBOX_PATH" ]; then
|
||||
echo "Setting chrome-sandbox permissions..."
|
||||
chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox"
|
||||
chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox"
|
||||
fi
|
||||
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)
|
||||
@@ -257,14 +252,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,21 @@ EOFENTRY
|
||||
|
||||
# Update package.json
|
||||
echo 'Modifying package.json to load frame fix and add node-pty...'
|
||||
local desktop_name='claude-desktop.desktop'
|
||||
if [[ ${build_format:-} == 'appimage' ]]; then
|
||||
desktop_name='io.github.aaddrick.claude-desktop-debian.desktop'
|
||||
fi
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = require('./app.asar.contents/package.json');
|
||||
pkg.originalMain = pkg.main;
|
||||
pkg.main = 'frame-fix-entry.js';
|
||||
pkg.desktopName = process.argv[1];
|
||||
pkg.optionalDependencies = pkg.optionalDependencies || {};
|
||||
pkg.optionalDependencies['node-pty'] = '^1.0.0';
|
||||
fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2));
|
||||
console.log('Updated package.json: main entry and node-pty dependency');
|
||||
"
|
||||
console.log('Updated package.json: main entry, desktopName, and node-pty dependency');
|
||||
" "$desktop_name"
|
||||
|
||||
# Create stub native module
|
||||
echo 'Creating stub native module...'
|
||||
@@ -87,9 +92,17 @@ 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
|
||||
|
||||
# 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 +110,13 @@ 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
|
||||
|
||||
# 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'
|
||||
|
||||
142
scripts/patches/config.sh
Normal file
142
scripts/patches/config.sh
Normal file
@@ -0,0 +1,142 @@
|
||||
#===============================================================================
|
||||
# Config-related patches: preserve externally-added mcpServers across config
|
||||
# writes, and guard addTrustedFolder against .asar paths.
|
||||
#
|
||||
# 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
|
||||
|
||||
local folder_param
|
||||
folder_param=$(grep -oP \
|
||||
'LocalAgentModeSessions\.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 = 'LocalAgentModeSessions.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 '##############################################################'
|
||||
}
|
||||
@@ -9,6 +9,95 @@
|
||||
# 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_cowork_linux() {
|
||||
echo 'Patching Cowork mode for Linux...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
@@ -51,7 +140,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 +156,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 +180,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 +198,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 +221,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 +236,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 +315,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 +368,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'
|
||||
: '_globalLastSpawn';
|
||||
// Cooldown in ms — long enough to avoid fork storms, short enough
|
||||
// that the retry loop can re-spawn after a mid-session daemon death.
|
||||
const autoLaunch =
|
||||
'process.platform==="linux"&&' +
|
||||
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
|
||||
'&&(' + spawnGuard + '=Date.now(),' +
|
||||
'(()=>{try{' +
|
||||
'const _p=require("path"),_fs=require("fs");' +
|
||||
'const _d=_p.join(process.resourcesPath,' +
|
||||
'"app.asar.unpacked","' + svcPath + '");' +
|
||||
'if(_fs.existsSync(_d)){' +
|
||||
// Open daemon log for append; fall back to ignoring stdio.
|
||||
'let _stdio="ignore";' +
|
||||
'try{' +
|
||||
'const _ld=_p.join(process.env.HOME||"/tmp",' +
|
||||
'".config/Claude/logs");' +
|
||||
'_fs.mkdirSync(_ld,{recursive:true});' +
|
||||
'const _fd=_fs.openSync(' +
|
||||
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
|
||||
'_stdio=["ignore",_fd,_fd,"ipc"]' +
|
||||
'}catch(_){}' +
|
||||
'const _c=require("child_process").fork(_d,[],' +
|
||||
'{detached:true,stdio:_stdio,env:{...process.env,' +
|
||||
'ELECTRON_RUN_AS_NODE:"1"}});' +
|
||||
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
|
||||
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
|
||||
code = code.substring(0, retryAbsIdx) +
|
||||
autoLaunch + code.substring(retryAbsIdx);
|
||||
console.log(' Added service daemon auto-launch on Linux');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find retry delay for auto-launch patch');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find VM service error string for auto-launch');
|
||||
@@ -375,7 +485,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 +531,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 +540,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 +575,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 +700,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 +782,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 +807,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 +815,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 +852,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 +912,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,8 +21,12 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray function: $tray_func"
|
||||
|
||||
# Escape `$` for PCRE / sed -E patterns where it would otherwise act
|
||||
# as an end-of-line anchor. Minifier emits identifiers like `i$A`.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
|
||||
tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"\}\);let \K[\$\w]+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"$index_js")
|
||||
if [[ -z $tray_var ]]; then
|
||||
echo 'Failed to extract tray variable name' >&2
|
||||
@@ -31,30 +35,26 @@ 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
|
||||
# Idempotent: upstream may already ship the function as `async`
|
||||
# (1.8089.1 does). Re-applying the sed would produce
|
||||
# `async async function`, which then breaks downstream patches that
|
||||
# match `(?:async )?function NAME`.
|
||||
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
|
||||
sed -i -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
fi
|
||||
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" \
|
||||
sed -i -E "s/async\s+function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
|
||||
"$index_js"
|
||||
echo " Added 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
|
||||
@@ -69,9 +69,12 @@ patch_tray_menu_handler() {
|
||||
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
|
||||
"$index_js"
|
||||
sed -i -E \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"s/\(([[:alnum:]_\$]+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"$index_js"
|
||||
echo ' Added startup delay check (3 second window)'
|
||||
if ! grep -q "Date.now()-_trayStartTime>3e3" "$index_js"; then
|
||||
echo 'WARNING: Startup delay conditional not injected' >&2
|
||||
fi
|
||||
fi
|
||||
echo '##############################################################'
|
||||
}
|
||||
@@ -81,9 +84,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,17 +101,19 @@ patch_tray_inplace_update() {
|
||||
|
||||
# Re-extract the tray variable name — `patch_tray_menu_handler`
|
||||
# declares it `local` so it's not visible here. Same grep pattern.
|
||||
local tray_func local_tray_var tray_var_re
|
||||
local tray_func tray_func_re local_tray_var tray_var_re
|
||||
local menu_func path_var enabled_var enabled_count
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo ' Could not find tray function — skipping'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
# Escape `$` for PCRE patterns; matches the `tray_var_re` trick below.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
local_tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"\}\);let \K[\$\w]+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"$index_js")
|
||||
if [[ -z $local_tray_var ]]; then
|
||||
echo ' Could not extract tray variable name — skipping'
|
||||
@@ -119,7 +124,7 @@ patch_tray_inplace_update() {
|
||||
|
||||
tray_var_re="${local_tray_var//\$/\\$}"
|
||||
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\(\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_func ]]; then
|
||||
echo ' Could not extract menu function name — skipping'
|
||||
@@ -134,7 +139,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 +153,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 +162,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 +241,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.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='1ab57e88c86451cf199d1568d751dab7fe29e4bdfa5a3f035fdb15b872c7a352'
|
||||
architecture='amd64'
|
||||
claude_exe_filename='Claude-Setup-x64.exe'
|
||||
echo 'Configured for amd64 (x86_64) build.'
|
||||
;;
|
||||
aarch64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='bf7de5d6c012542feadf7d5caa77a72dfcea1b24512e9a6d28e004ba4ae2a11d'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='3c319a59a59b30bfeb86f71b6df80891c5c983404f12e464f4be1754cd4d2c94'
|
||||
architecture='arm64'
|
||||
claude_exe_filename='Claude-Setup-arm64.exe'
|
||||
echo 'Configured for arm64 (aarch64) build.'
|
||||
|
||||
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
|
||||
445
tests/doctor.bats
Normal file
445
tests/doctor.bats
Normal file
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# doctor.bats
|
||||
# Tests for diagnostic helpers in scripts/doctor.sh
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
export HOME="$TEST_TMP/home"
|
||||
export XDG_CACHE_HOME="$TEST_TMP/cache"
|
||||
export XDG_CONFIG_HOME="$TEST_TMP/config"
|
||||
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
|
||||
|
||||
# Clear all input/display vars to avoid host-state leakage
|
||||
unset DISPLAY
|
||||
unset WAYLAND_DISPLAY
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset GTK_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
|
||||
# shellcheck source=scripts/doctor.sh
|
||||
source "$SCRIPT_DIR/../scripts/doctor.sh"
|
||||
|
||||
_doctor_colors
|
||||
_doctor_failures=0
|
||||
|
||||
# Default _pkg_installed to "unknown" (rc=2) so tests don't have
|
||||
# to stub it unless they're exercising the package-check branch.
|
||||
# Override in-test for rc=0 (installed) or rc=1 (missing).
|
||||
_pkg_installed() { return 2; }
|
||||
|
||||
# Default stub for _detect_password_store (defined in
|
||||
# launcher-common.sh, not sourced here). Tests that exercise
|
||||
# _doctor_check_password_store override this in-test if needed.
|
||||
_detect_password_store() { echo 'basic'; }
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Make `command -v gtk-query-immodules-3.0` report "not found" so the
|
||||
# immodules cache check is skipped. Used by tests that aren't
|
||||
# exercising the cache branch but reach it because no earlier gate
|
||||
# fires. `command -v` finds bash functions too, so just unsetting a
|
||||
# stub function isn't enough — we shadow `command` itself.
|
||||
_skip_gtk_query() {
|
||||
command() {
|
||||
if [[ $1 == '-v' && $2 == 'gtk-query-immodules-3.0' ]]; then
|
||||
return 1
|
||||
fi
|
||||
builtin command "$@"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _cowork_pkg_hint: ibus-gtk3 mapping (#550)
|
||||
# =============================================================================
|
||||
|
||||
@test "_cowork_pkg_hint: debian maps ibus-gtk3 to ibus-gtk3 via apt" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint debian ibus-gtk3)
|
||||
[[ $result == "sudo apt install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: fedora maps ibus-gtk3 to ibus-gtk3 via dnf" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint fedora ibus-gtk3)
|
||||
[[ $result == "sudo dnf install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: arch maps ibus-gtk3 to ibus (bundled)" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint arch ibus-gtk3)
|
||||
[[ $result == "sudo pacman -S ibus" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: CLAUDE_GTK_IM_MODULE override visibility
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits override line when CLAUDE_GTK_IM_MODULE set" {
|
||||
# CLAUDE_GTK_IM_MODULE makes active_im non-empty, so we'd reach
|
||||
# the cache check — skip it to keep this test focused.
|
||||
_skip_gtk_query
|
||||
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'CLAUDE_GTK_IM_MODULE=xim'* ]]
|
||||
[[ $output == *'overrides GTK_IM_MODULE for Electron'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no override line when CLAUDE_GTK_IM_MODULE unset" {
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'CLAUDE_GTK_IM_MODULE'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: XWayland-with-IBus routing note
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits XWayland note when wayland session and CLAUDE_USE_WAYLAND unset" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
# CLAUDE_USE_WAYLAND deliberately unset
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'XWayland'* ]]
|
||||
[[ $output == *'CLAUDE_USE_WAYLAND=1'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note when CLAUDE_USE_WAYLAND=1" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note on X11 session" {
|
||||
XDG_SESSION_TYPE='x11'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: ibus-gtk3 package check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when ibus selected but ibus-gtk3 missing" {
|
||||
# Package not installed (rc=1, definitive answer)
|
||||
_pkg_installed() { return 1; }
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'ibus-gtk3 is not installed'* ]]
|
||||
[[ $output == *'sudo apt install ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when ibus selected and ibus-gtk3 present" {
|
||||
# Package installed (rc=0); cache lists ibus.
|
||||
_pkg_installed() { return 0; }
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"ibus" "IBus" "ibus" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning when active module isn't ibus" {
|
||||
# Even with rc=1 for ibus-gtk3, the package check should be
|
||||
# skipped entirely when GTK_IM_MODULE isn't ibus.
|
||||
_pkg_installed() { return 1; }
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning on unsupported distro (rc=2)" {
|
||||
# Default _pkg_installed (rc=2) — no warning even with ibus.
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules unknown
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: immodules cache check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when GTK_IM_MODULE not in immodules cache" {
|
||||
# gtk-query-immodules-3.0 lists xim but not fcitx
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *"'fcitx' not listed"* ]]
|
||||
[[ $output == *'gtk-query-immodules-3.0 --update-cache'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when active module is in cache" {
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: skips cache check when gtk-query-immodules-3.0 missing" {
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'cache may be stale'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: CLAUDE_GTK_IM_MODULE takes precedence as active module" {
|
||||
# Cache lists xim but not ibus. CLAUDE_GTK_IM_MODULE=xim should
|
||||
# win over GTK_IM_MODULE=ibus, so no cache warning fires.
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no checks fire when no IM module selected" {
|
||||
# Neither GTK_IM_MODULE nor CLAUDE_GTK_IM_MODULE set — function
|
||||
# should return early before the package or cache checks.
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_recent_crashes: GPU FATAL crash counter (#583)
|
||||
# =============================================================================
|
||||
|
||||
# Install a coredumpctl shim. $1 is the coredumpctl-list-style
|
||||
# multi-line output to emit (header + entry rows). The shim ignores
|
||||
# its arguments — tests don't exercise the filter syntax.
|
||||
_install_coredumpctl_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/coredumpctl" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
$1
|
||||
OUT
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/coredumpctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: no coredumpctl on PATH — silent" {
|
||||
# Force coredumpctl off PATH so the helper short-circuits.
|
||||
# Restore PATH before returning so teardown's rm works.
|
||||
local saved_path="$PATH"
|
||||
export PATH="/no-such-dir-for-test"
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
export PATH="$saved_path"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: zero crashes — silent" {
|
||||
# Listing has the header line only, no entry rows.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 1 crash — info line, no warn" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 3+ crashes — warn + #583 pointer" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M
|
||||
Mon 2026-05-04 07:44:48 EDT 930532 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 22.8M
|
||||
Sun 2026-05-03 14:34:10 EDT 567221 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 12.4M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'Recent Electron crashes: 3'* ]]
|
||||
[[ $output == *'CLAUDE_DISABLE_GPU=1'* ]]
|
||||
[[ $output == *'/issues/583'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: path mismatch falls back with footnote" {
|
||||
# Three crashes from a DIFFERENT electron binary (e.g., Slack).
|
||||
# Caller passes claude-desktop's electron path, which doesn't
|
||||
# match — helper falls back to total count and adds the footnote
|
||||
# so the user knows the count may be cross-app.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 09:00:00 EDT 200001 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-05 09:00:00 EDT 200002 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-04 09:00:00 EDT 200003 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: empty electron_path falls back" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
# Caller didn't pass an electron_path — helper still counts and
|
||||
# emits the info line based on the unfiltered total.
|
||||
run _doctor_check_recent_crashes ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_filename_limit: NAME_MAX probe + eCryptfs hint (#590)
|
||||
# =============================================================================
|
||||
|
||||
# Install a getconf shim that emits $1 on stdout. Empty $1 → shim exits 1
|
||||
# so callers can test the "getconf failed" path.
|
||||
_install_getconf_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local value="$1"
|
||||
if [[ -z $value ]]; then
|
||||
cat > "$TEST_TMP/bin/getconf" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/getconf" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
echo ${value}
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/getconf"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
# Install a df shim that emits a single-column fstype listing matching
|
||||
# the `df --output=fstype` shape the helper relies on. Empty $1 → shim
|
||||
# exits 1 so callers can test the "df failed" path.
|
||||
_install_df_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local fstype="$1"
|
||||
if [[ -z $fstype ]]; then
|
||||
cat > "$TEST_TMP/bin/df" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/df" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
Type
|
||||
${fstype}
|
||||
OUT
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/df"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when NAME_MAX >= 200" {
|
||||
_install_getconf_shim '255'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: warns when NAME_MAX < 200" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ext4'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'#590'* ]]
|
||||
# Non-ecryptfs fs: no LUKS hint
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: eCryptfs adds LUKS workaround hint" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ecryptfs'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'eCryptfs'* ]]
|
||||
[[ $output == *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent on non-numeric getconf output" {
|
||||
_install_getconf_shim 'undefined'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when getconf fails" {
|
||||
_install_getconf_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: df failure suppresses eCryptfs hint, keeps warn" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_password_store: output contains 'Password store:' with a valid backend" {
|
||||
# setup() already stubs _detect_password_store to return 'basic'.
|
||||
run _doctor_check_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Password store:'* ]]
|
||||
[[ $output == *'basic'* ]]
|
||||
}
|
||||
@@ -18,6 +18,35 @@ has_electron_arg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Install a dbus-send stub at the front of PATH.
|
||||
# kwallet6 — echoes 'boolean true', exits 0 (kwallet6 detectable)
|
||||
# secrets-ok — fails for kwalletd6 dest, succeeds for all other dests
|
||||
# fail — always exits 1 with no output (no keyring accessible)
|
||||
_stub_dbus_send() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
case "${1:-fail}" in
|
||||
kwallet6)
|
||||
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
echo 'boolean true'
|
||||
STUB
|
||||
;;
|
||||
secrets-ok)
|
||||
cat > "$TEST_TMP/bin/dbus-send" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
[[ "$*" == *kwalletd6* ]] && exit 1
|
||||
exit 0
|
||||
STUB
|
||||
;;
|
||||
*)
|
||||
printf '#!/usr/bin/env bash\nexit 1\n' \
|
||||
> "$TEST_TMP/bin/dbus-send"
|
||||
;;
|
||||
esac
|
||||
chmod +x "$TEST_TMP/bin/dbus-send"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
@@ -35,10 +64,17 @@ setup() {
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset NIRI_SOCKET
|
||||
unset XDG_CURRENT_DESKTOP
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_MENU_BAR
|
||||
unset CLAUDE_TITLEBAR_STYLE
|
||||
unset COWORK_VM_BACKEND
|
||||
unset ELECTRON_USE_SYSTEM_TITLE_BAR
|
||||
unset GTK_IM_MODULE
|
||||
unset XMODIFIERS
|
||||
unset QT_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
@@ -86,6 +122,70 @@ teardown() {
|
||||
[[ "${lines[1]}" == "test message two" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# log_session_env
|
||||
# =============================================================================
|
||||
|
||||
@test "log_session_env: emits env={ ... } block with all required keys" {
|
||||
setup_logging
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
WAYLAND_DISPLAY='wayland-0'
|
||||
DISPLAY=':0'
|
||||
XDG_CURRENT_DESKTOP='KDE'
|
||||
GTK_IM_MODULE='ibus'
|
||||
XMODIFIERS='@im=ibus'
|
||||
QT_IM_MODULE='ibus'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
CLAUDE_TITLEBAR_STYLE='hybrid'
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
CLAUDE_DISABLE_GPU='1'
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match locks block structure (open/close braces on
|
||||
# their own lines) and per-key formatting in one pass.
|
||||
[[ "${lines[0]}" == 'env={' ]]
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=wayland' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=wayland-0' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=:0' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=KDE' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=@im=ibus' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=1' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=hybrid' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=basic' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=xim' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=1' ]]
|
||||
[[ "${lines[13]}" == '}' ]]
|
||||
}
|
||||
|
||||
@test "log_session_env: unset/empty values render as 'KEY=' (no value)" {
|
||||
setup_logging
|
||||
# All vars unset by setup() except this one, which exercises the
|
||||
# empty-string branch (must be indistinguishable from unset).
|
||||
GTK_IM_MODULE=''
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match proves the line ends right after '=' — a
|
||||
# substring like *'KEY='* would also match 'KEY=value'.
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=' ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# check_display
|
||||
# =============================================================================
|
||||
@@ -293,6 +393,48 @@ teardown() {
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set propagates to GTK_IM_MODULE" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
# Override is logged so users can verify it took effect
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set logs <unset> when GTK_IM_MODULE was unset" {
|
||||
setup_logging
|
||||
# GTK_IM_MODULE unset by setup()
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: <unset> -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE unset leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
# CLAUDE_GTK_IM_MODULE unset by setup()
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
# No override line should appear in the log
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE empty leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE=''
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _resolve_titlebar_style
|
||||
# =============================================================================
|
||||
@@ -587,3 +729,40 @@ s.close()
|
||||
result=$(_electron_version "$TEST_TMP/electron/electron") || true
|
||||
[[ -z $result ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _detect_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_detect_password_store: CLAUDE_PASSWORD_STORE env var wins without calling dbus-send" {
|
||||
CLAUDE_PASSWORD_STORE='mystore'
|
||||
# Stub dbus-send to fail — the early-return path must not reach it.
|
||||
_stub_dbus_send fail
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'mystore' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to kwallet6 when kwallet6 dbus-send call succeeds" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send kwallet6
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'kwallet6' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to gnome-libsecret when kwallet6 fails but secrets ping succeeds" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send secrets-ok
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'gnome-libsecret' ]]
|
||||
}
|
||||
|
||||
@test "_detect_password_store: falls back to basic when both dbus-send calls fail" {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
_stub_dbus_send fail
|
||||
run _detect_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == 'basic' ]]
|
||||
}
|
||||
|
||||
144
tests/launcher-disable-gpu.bats
Normal file
144
tests/launcher-disable-gpu.bats
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# launcher-disable-gpu.bats
|
||||
# Tests for the CLAUDE_DISABLE_GPU env var handling in
|
||||
# build_electron_args (scripts/launcher-common.sh). The var is an
|
||||
# opt-in workaround for the Chromium GPU process FATAL exhaustion
|
||||
# tracked in #583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
|
||||
# --disable-software-rasterizer; co-occurrence with XRDP must not
|
||||
# stack duplicate flags.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
LAUNCHER_COMMON="${SCRIPT_DIR}/../scripts/launcher-common.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
# loginctl shim — same pattern as launcher-xrdp-detection.bats.
|
||||
# Defaults to a non-XRDP session so CLAUDE_DISABLE_GPU is the
|
||||
# only signal in play unless a test overrides MOCK_LOGINCTL_TYPE.
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/loginctl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${MOCK_LOGINCTL_TYPE:-x11}"
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/loginctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
|
||||
log_file="$TEST_TMP/launcher.log"
|
||||
: > "$log_file"
|
||||
|
||||
unset CLAUDE_DISABLE_GPU
|
||||
unset XRDP_SESSION
|
||||
unset XDG_SESSION_ID
|
||||
unset MOCK_LOGINCTL_TYPE
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$LAUNCHER_COMMON"
|
||||
|
||||
is_wayland=false
|
||||
use_x11_on_wayland=true
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n ${TEST_TMP:-} && -d $TEST_TMP ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
args_contain() {
|
||||
local needle="$1"
|
||||
local arg
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
args_count() {
|
||||
local needle="$1"
|
||||
local arg count=0
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && ((count++))
|
||||
done
|
||||
printf '%d' "$count"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CLAUDE_DISABLE_GPU=1 — flags must be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: CLAUDE_DISABLE_GPU=1 adds flags + logs message" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Co-occurrence with XRDP — no duplicate flags
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: with XRDP_SESSION, flags added exactly once (no dup)" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
export XRDP_SESSION=1
|
||||
export XDG_SESSION_ID=5
|
||||
export MOCK_LOGINCTL_TYPE=xrdp
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
[[ "$(args_count '--disable-gpu')" -eq 1 ]]
|
||||
[[ "$(args_count '--disable-software-rasterizer')" -eq 1 ]]
|
||||
# Both signals should still log (independent diagnostic value),
|
||||
# but only one set of flags should reach electron_args.
|
||||
grep -q 'XRDP session detected' "$log_file"
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Off-states — flags must NOT be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: unset — flags NOT added" {
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
run args_contain '--disable-software-rasterizer'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: empty string — flags NOT added" {
|
||||
export CLAUDE_DISABLE_GPU=''
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =0 — flags NOT added (only literal '1' opts in)" {
|
||||
export CLAUDE_DISABLE_GPU=0
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =true — flags NOT added (no boolean aliases)" {
|
||||
# Documents the strict equality check. If we ever add aliases,
|
||||
# update this test to match. Strict-only matches the existing
|
||||
# CLAUDE_USE_WAYLAND pattern.
|
||||
export CLAUDE_DISABLE_GPU=true
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
116
tests/test-artifact-appimage.sh
Normal file → Executable file
116
tests/test-artifact-appimage.sh
Normal file → Executable file
@@ -7,6 +7,19 @@ 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.
|
||||
_cleanup() {
|
||||
if [[ -n ${launch_pid:-} ]]; then
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null
|
||||
fi
|
||||
[[ -n ${cache_root:-} ]] && rm -rf "$cache_root"
|
||||
[[ -n ${xvfb_log:-} ]] && rm -rf "$xvfb_log"
|
||||
[[ -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 +107,108 @@ assert_contains "$appdir/AppRun" 'build_electron_args' \
|
||||
|
||||
# --- App contents (asar) ---
|
||||
resources_dir="$appdir/usr/lib/node_modules/electron/dist/resources"
|
||||
validate_app_contents "$resources_dir"
|
||||
validate_app_contents "$resources_dir" "${component_id}.desktop"
|
||||
|
||||
# --- Doctor smoke test ---
|
||||
# Some --doctor checks fail in CI (no display, etc.); we only care that
|
||||
# the script itself didn't crash via signal or exec failure (>=127).
|
||||
doctor_exit=0
|
||||
"$appimage_file" --doctor >/dev/null 2>&1 || doctor_exit=$?
|
||||
if [[ $doctor_exit -lt 127 ]]; then
|
||||
pass "--doctor runs without crashing (exit: $doctor_exit)"
|
||||
else
|
||||
fail "--doctor crashed (exit: $doctor_exit)"
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# Catches startup-only regressions (asar/frame-fix-wrapper syntax errors)
|
||||
# that pure structure checks miss.
|
||||
#
|
||||
# Scope: main-process startup failures only. GPU/renderer-process
|
||||
# crashes (e.g. #583-class) leave the main process alive and pass
|
||||
# this check — Xvfb has no GPU, so Electron falls back to SwiftShader
|
||||
# and the GPU-crash path isn't exercised here.
|
||||
if command -v xvfb-run &>/dev/null \
|
||||
&& command -v dbus-run-session &>/dev/null \
|
||||
&& command -v setsid &>/dev/null; then
|
||||
|
||||
# XDG_CACHE_HOME redirect so the test owns the launcher log.
|
||||
cache_root=$(mktemp -d)
|
||||
export XDG_CACHE_HOME="$cache_root"
|
||||
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
|
||||
|
||||
# setsid puts xvfb-run + Xvfb + dbus + AppRun + electron in a fresh
|
||||
# process group; xvfb-run's EXIT trap alone leaves Xvfb behind on
|
||||
# TERM, so we need kill -- -PGID below.
|
||||
# AppRun redirects electron's stdout/stderr into launcher_log;
|
||||
# xvfb_log captures xvfb-run's own stderr.
|
||||
xvfb_log=$(mktemp)
|
||||
setsid xvfb-run -a -s '-screen 0 1280x720x24' \
|
||||
dbus-run-session -- "$appimage_file" \
|
||||
>"$xvfb_log" 2>&1 &
|
||||
launch_pid=$!
|
||||
|
||||
# Wait up to 30s for the frame-fix readiness marker, or early
|
||||
# process death. The marker is the last log line emitted by
|
||||
# scripts/frame-fix-wrapper.js after all patches are installed,
|
||||
# so reaching it means main-process startup finished without
|
||||
# crashing. Replaces a flat 10s sleep that was both slow on
|
||||
# healthy startups and a flake risk on noisy runners.
|
||||
readiness_marker='[Frame Fix] Patches built successfully'
|
||||
readiness_timeout=30
|
||||
deadline=$((SECONDS + readiness_timeout))
|
||||
saw_marker=0
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -f $launcher_log ]] \
|
||||
&& grep -qF "$readiness_marker" \
|
||||
"$launcher_log"; then
|
||||
saw_marker=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$launch_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ((saw_marker == 1)); then
|
||||
pass "AppImage reached ready state under Xvfb"
|
||||
else
|
||||
if kill -0 "$launch_pid" 2>/dev/null; then
|
||||
fail "AppImage did not reach ready state within ${readiness_timeout}s"
|
||||
else
|
||||
wait "$launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
fail "AppImage exited before reaching ready state (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
|
||||
fi
|
||||
|
||||
# Negative PID targets the process group.
|
||||
kill -TERM -- "-$launch_pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null || true
|
||||
wait "$launch_pid" 2>/dev/null || true
|
||||
# Sweep any electron child that escaped the group (e.g. zygote).
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null || true
|
||||
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
unset XDG_CACHE_HOME
|
||||
else
|
||||
# Match the codebase convention (test-artifact-common.sh
|
||||
# validate_app_contents): tool absence is a skip, not a failure.
|
||||
# Loud failure on missing tools belongs at the workflow layer.
|
||||
pass "Skipping launch smoke test (xvfb-run/dbus-run-session/setsid missing)"
|
||||
fi
|
||||
|
||||
# --- Cleanup ---
|
||||
rm -rf "$extract_dir"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -41,9 +41,14 @@ electron_path='/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
assert_file_exists "$electron_path"
|
||||
assert_executable "$electron_path"
|
||||
|
||||
# chrome-sandbox
|
||||
assert_file_exists \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
# chrome-sandbox: setuid bit must be set by the rpm spec's %files
|
||||
# %attr(4755, ...) entry, not by a %post chmod (#539). The check
|
||||
# guards against any regression that strips the suid bit — including
|
||||
# (but not limited to) reverting to a %post chmod, which silently
|
||||
# no-ops if the scriptlet is skipped (--noscripts, layered images).
|
||||
chrome_sandbox='/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
assert_file_exists "$chrome_sandbox"
|
||||
assert_setuid "$chrome_sandbox"
|
||||
|
||||
# --- Desktop entry validation ---
|
||||
desktop_file='/usr/share/applications/claude-desktop.desktop'
|
||||
|
||||
163
tests/verify-patches.bats
Normal file
163
tests/verify-patches.bats
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# verify-patches.bats
|
||||
# Tests for scripts/verify-patches.sh — the post-build static grep
|
||||
# that confirms patch markers (default: cowork, issue #559 D6 / PR
|
||||
# #555) are present in the shipped index.js.
|
||||
#
|
||||
# Both these tests and the verify script consume the marker list from
|
||||
# scripts/cowork-patch-markers.tsv, so adding a marker there
|
||||
# automatically expands the test matrix below.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
VERIFY_SH="$SCRIPT_DIR/../scripts/verify-patches.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
# Source the verify script in library mode and reuse its
|
||||
# parser, so a TSV format change can't desync the two consumers.
|
||||
# shellcheck source-path=SCRIPTDIR/.. source=scripts/verify-patches.sh
|
||||
source "$VERIFY_SH"
|
||||
load_markers
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "${TEST_TMP:-}" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Build a fixture index.js containing every sample. If $1 is given,
|
||||
# the marker with that name is omitted (used to drive the missing-
|
||||
# marker negative tests).
|
||||
write_fixture() {
|
||||
local omit="${1:-}"
|
||||
local fixture="$TEST_TMP/index.js"
|
||||
: > "$fixture"
|
||||
local i
|
||||
for i in "${!marker_names[@]}"; do
|
||||
if [[ ${marker_names[$i]} != "$omit" ]]; then
|
||||
printf '%s\n' "${marker_samples[$i]}" >> "$fixture"
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Marker file integrity
|
||||
# =============================================================================
|
||||
|
||||
@test "markers file: every regex matches its sample" {
|
||||
local i
|
||||
for i in "${!marker_names[@]}"; do
|
||||
run grep -qP -- "${marker_patterns[$i]}" \
|
||||
<(printf '%s\n' "${marker_samples[$i]}")
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "regex did not match own sample: ${marker_names[$i]}"
|
||||
echo "pattern: ${marker_patterns[$i]}"
|
||||
echo "sample: ${marker_samples[$i]}"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
@test "markers file: at least 9 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 9 ]] || {
|
||||
echo "expected >= 9 markers, got ${#marker_names[@]}"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Positive path: full fixture passes
|
||||
# =============================================================================
|
||||
|
||||
@test "verify: exits 0 when every marker present" {
|
||||
local fixture
|
||||
fixture="$(write_fixture)"
|
||||
|
||||
run "$VERIFY_SH" "$fixture"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo 'verify rejected a fully-marked fixture'
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
run grep -c 'OK ' <<< "$output"
|
||||
[[ "$output" -eq "${#marker_names[@]}" ]] || {
|
||||
echo "expected ${#marker_names[@]} OK lines, got: $output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Negative path: per-marker missing fixture
|
||||
# =============================================================================
|
||||
|
||||
@test "verify: exits 2 and names the missing marker (each)" {
|
||||
local name fixture failures=0
|
||||
for name in "${marker_names[@]}"; do
|
||||
fixture="$(write_fixture "$name")"
|
||||
|
||||
run "$VERIFY_SH" "$fixture"
|
||||
if [[ "$status" -ne 2 ]]; then
|
||||
echo "missing $name should exit 2, got $status"
|
||||
echo "$output"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if ! grep -q "$name" <<< "$output"; then
|
||||
echo "missing $name not named in output"
|
||||
echo "$output"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
[[ "$failures" -eq 0 ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Input shapes
|
||||
# =============================================================================
|
||||
|
||||
@test "verify: accepts a directory containing the asar layout" {
|
||||
local layout="$TEST_TMP/staging/app.asar.contents/.vite/build"
|
||||
mkdir -p "$layout"
|
||||
: > "$layout/index.js"
|
||||
local sample
|
||||
for sample in "${marker_samples[@]}"; do
|
||||
printf '%s\n' "$sample" >> "$layout/index.js"
|
||||
done
|
||||
|
||||
run "$VERIFY_SH" "$TEST_TMP/staging"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo 'verify rejected directory-shaped input'
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "verify: rejects missing path with exit 1" {
|
||||
run "$VERIFY_SH" "$TEST_TMP/does-not-exist.js"
|
||||
[[ "$status" -eq 1 ]]
|
||||
[[ "$output" == *'not found'* ]]
|
||||
}
|
||||
|
||||
@test "verify: rejects directory without expected layout" {
|
||||
mkdir -p "$TEST_TMP/empty"
|
||||
run "$VERIFY_SH" "$TEST_TMP/empty"
|
||||
[[ "$status" -eq 1 ]]
|
||||
}
|
||||
|
||||
@test "verify: prints usage on no args and exits 1" {
|
||||
run "$VERIFY_SH"
|
||||
[[ "$status" -eq 1 ]]
|
||||
[[ "$output" == *'Usage:'* ]]
|
||||
}
|
||||
|
||||
@test "verify: --help prints usage and exits 0" {
|
||||
run "$VERIFY_SH" --help
|
||||
[[ "$status" -eq 0 ]]
|
||||
[[ "$output" == *'Usage:'* ]]
|
||||
}
|
||||
5
tools/test-harness/.gitignore
vendored
Normal file
5
tools/test-harness/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
results/
|
||||
*.log
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
474
tools/test-harness/README.md
Normal file
474
tools/test-harness/README.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Linux Compatibility Test Harness
|
||||
|
||||
In-VM (or on-host) Playwright + DBus runner for the test cases under
|
||||
[`docs/testing/cases/`](../../docs/testing/cases/). See
|
||||
[`docs/testing/automation.md`](../../docs/testing/automation.md) for the
|
||||
architecture, decisions, and rationale.
|
||||
|
||||
## Status
|
||||
|
||||
Seventy-four specs wired (36 cross-env T-tests, 33 env-specific S-tests,
|
||||
5 H-prefix harness self-tests).
|
||||
|
||||
| Test | What it checks | Layer |
|
||||
|------|----------------|-------|
|
||||
| [T01](../../docs/testing/cases/launch.md#t01--app-launch) | X11 window with our pid appears within 15s; title matches `/claude/i` | L2 (xprop) |
|
||||
| [T02](../../docs/testing/cases/launch.md#t02--doctor-health-check) | `claude-desktop --doctor` exits 0 | spawn probe |
|
||||
| [T03](../../docs/testing/cases/tray-and-window-chrome.md#t03--tray-icon-present) | A `StatusNotifierItem` is registered by the claude-desktop pid AND exactly one (no rebuild-race duplicates) | L2 (DBus) |
|
||||
| [T04](../../docs/testing/cases/tray-and-window-chrome.md#t04--window-decorations-draw) | Window has `_NET_FRAME_EXTENTS` (sum > 0) and a "Claude" title | L2 (xprop) |
|
||||
| [T05](../../docs/testing/cases/shortcuts-and-input.md#t05--claude-url-handler) | `xdg-open 'claude://...'` delivers via `app.on('second-instance')` to the running app | spawn + L1 hook |
|
||||
| [T06](../../docs/testing/cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) | `globalShortcut.isRegistered('Ctrl+Alt+Space')` returns true after `mainVisible` | L1 |
|
||||
| [T07](../../docs/testing/cases/tray-and-window-chrome.md#t07--in-app-topbar) | Five topbar buttons render with non-zero rects (uses `seedFromHost` for hermetic auth) | L1 + DOM |
|
||||
| [T08](../../docs/testing/cases/tray-and-window-chrome.md#t08--close-x-hides-to-tray) | `win.close()` fires the wrapper interceptor; window hidden, proc alive | L1 |
|
||||
| [T09](../../docs/testing/cases/platform-integration.md#t09--autostart-via-xdg) | `setLoginItemSettings({ openAtLogin })` writes/removes `$XDG_CONFIG_HOME/autostart/claude-desktop.desktop` | L1 + filesystem |
|
||||
| [T10](../../docs/testing/cases/platform-integration.md#t10--cowork-integration) | After H04-style spawn detection, `kill -9` the daemon and confirm a *different* pid respawns within ~20s (Patch 6 cooldown + retry) | pgrep delta + spawn delta |
|
||||
| [T11](../../docs/testing/cases/extensibility.md#t11--plugin-install) | Plugin-install code path fingerprints present in bundled `index.js` | file probe |
|
||||
| [T11_runtime](../../docs/testing/cases/extensibility.md#t11--plugin-install) | After `seedFromHost` + `userLoaded`, the install-flow eipc surface (`installPlugin`, `uninstallPlugin`, `updatePlugin`, `listInstalledPlugins`, `LocalPlugins/getPlugins` — five-suffix presence probe) is registered on the claude.ai webContents AND BOTH read-side handlers across the two impl objects are callable through the renderer-side wrapper: `CustomPlugins/listInstalledPlugins([])` returns array shape (drives Manage plugins panel), `LocalPlugins/getPlugins()` returns array shape (reads `~/.claude/plugins/installed_plugins.json` per case-doc :465822) — Tier 2 reframe of T11 (case-doc anchor :507181) | L1 (eipc registry + invoke) |
|
||||
| [T12](../../docs/testing/cases/platform-integration.md#t12--webgl-warn-only) | `app.getGPUFeatureStatus()` returns a populated object; renderer reached visible | L1 |
|
||||
| [T13](../../docs/testing/cases/launch.md#t13--doctor-reports-correct-package-format) | `--doctor` does not false-flag rpm/deb installs as missing-dpkg AppImage | spawn + stdout grep |
|
||||
| [T14a](../../docs/testing/cases/launch.md#t14--multi-instance-behavior) | `requestSingleInstanceLock` + `'second-instance'` strings in bundled `index.js` (file probe) | file probe |
|
||||
| [T14b](../../docs/testing/cases/launch.md#t14--multi-instance-behavior) | Second invocation under same isolation exits cleanly; primary pid stays alive (runtime probe) | spawn delta + pgrep |
|
||||
| [T16](../../docs/testing/cases/code-tab-foundations.md#t16--code-tab-loads) | After `seedFromHost` + `userLoaded`, `CodeTab.activate()` resolves and ≥1 compact pill renders (env pill = Code-body mounted) | L1 + AX-tree |
|
||||
| [T17](../../docs/testing/cases/code-tab-foundations.md#t17--folder-picker-opens) | After `seedFromHost` + `userLoaded`, Code df-pill → env pill → Local → Select folder → Open folder triggers `dialog.showOpenDialog` (mock installed via `installOpenDialogMock`); skips cleanly when host has no signed-in Claude config | L1 + AX-tree |
|
||||
| [T18](../../docs/testing/cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt) | Bundled `mainView.js` preload contains the path-resolution bridge fingerprints: `getPathForFile` (2× — property key + the `webUtils.getPathForFile(` call, both at case-doc :9267), `webUtils`, `filePickers`, and the `claudeAppSettings` `contextBridge.exposeInMainWorld` namespace (case-doc :9552) — pins the load-bearing wiring without faking OS-level XDND drag (xdotool can't put file URIs on the X11 selection; Wayland needs per-compositor IPC + libei) | file probe |
|
||||
| [T19](../../docs/testing/cases/code-tab-foundations.md#t19--integrated-terminal) | After `seedFromHost` + `userLoaded`, the integrated-terminal eipc surface (`startShellPty`, `writeShellPty`, `stopShellPty`, `resizeShellPty`, `getShellPtyBuffer` — five-suffix presence probe) is registered on the claude.ai webContents AND the foundational `LocalSessions/getAll` returns array shape (Tier 2 reframe of the case-doc T19 case; case-doc anchors are write-side `startShellPty` etc. so reframe asserts the FULL terminal IPC surface registers + a stateless read-side surrogate is invocable) | L1 (eipc registry + invoke) |
|
||||
| [T20](../../docs/testing/cases/code-tab-foundations.md#t20--file-pane-opens-and-saves) | After `seedFromHost` + `userLoaded`, the file-pane eipc surface (`readSessionFile`, `writeSessionFile`, `pickSessionFile` — three-suffix presence probe) is registered on the claude.ai webContents AND the foundational `LocalSessions/getAll` returns array shape (Tier 2 reframe of the case-doc T20 case; the case-doc's `readSessionFile` anchor is read-side but needs (sessionId, path) args not constructible from a fresh isolation, so the registration probe + foundational `getAll` invocation is the strongest non-destructive Tier 2 layer) | L1 (eipc registry + invoke) |
|
||||
| [T21](../../docs/testing/cases/code-tab-workflow.md#t21--dev-server-preview-pane) | After `seedFromHost` + `userLoaded`, the preview-pane eipc surface (`getConfiguredServices`, `startFromConfig`, `stopServer`, `getAutoVerify`, `capturePreviewScreenshot` — five-suffix presence probe) is registered on the claude.ai webContents AND BOTH case-doc-anchored read-side handlers are callable through the renderer-side wrapper: `getConfiguredServices(cwd)` returns array shape, `getAutoVerify(cwd)` returns boolean shape (Tier 2 reframe of the case-doc T21 case; cwd validator is `typeof cwd === 'string'` only, smoke-tested session 11) | L1 (eipc registry + invoke) |
|
||||
| [T22](../../docs/testing/cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) | Bundled `index.js` contains `LocalSessions_$_getPrChecks` eipc channel name *and* `gh CLI not found in PATH` Linux-fallthrough throw site (Tier 1 fingerprint) | file probe |
|
||||
| [T22b](../../docs/testing/cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) | After `seedFromHost` + `userLoaded`, the `LocalSessions_$_getPrChecks` eipc handler is registered on the claude.ai webContents (`webContents.ipc._invokeHandlers` — Tier 2 runtime probe sibling of T22, strictly stronger than the bundle-string fingerprint) | L1 (eipc registry) |
|
||||
| [T23](../../docs/testing/cases/code-tab-handoff.md#t23--desktop-notifications-fire) | Firing `new Notification({title})` from main reaches the session bus's `org.freedesktop.Notifications.Notify` (observed via `dbus-monitor`) | L1 + DBus subprocess |
|
||||
| [T24](../../docs/testing/cases/code-tab-handoff.md#t24--open-in-external-editor) | After `installOpenExternalMock` mirroring T25's pattern, `evalInMain` calls `shell.openExternal('vscode://file/...')`; mock records the URL verbatim, no real editor launch | L1 (mocked egress) |
|
||||
| [T25](../../docs/testing/cases/code-tab-handoff.md#t25--show-in-files--file-manager) | After `installShowItemInFolderMock` mirroring T17's dialog-mock pattern, `evalInMain` calls `shell.showItemInFolder(<synthetic path>)`; mock records the call verbatim, no throw — no host side effect | L1 (mocked egress) |
|
||||
| [T26](../../docs/testing/cases/routines.md#t26--routines-page-renders) | After `seedFromHost` + `userLoaded`, click "Routines" sidebar AX button; assert "New routine" / "All" / "Calendar" anchor renders | L1 + AX-tree |
|
||||
| [T27](../../docs/testing/cases/routines.md#t27--scheduled-task-fires-and-notifies) | After `seedFromHost` + `userLoaded`, both Cowork and CCD `getAllScheduledTasks` eipc handlers are registered AND callable through the renderer-side wrapper, returning array shape — Tier 2 reframe of the case-doc T27 case | L1 (eipc invoke) |
|
||||
| [T30](../../docs/testing/cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) | Bundled `index.js` colocates the auto-archive sweep cadence (`300*1e3` ≤ `3600*1e3` ≤ `AutoArchiveEngine`) with the `ccAutoArchiveOnPrClose` gate key (single-regex multi-string fingerprint) | file probe |
|
||||
| [T31](../../docs/testing/cases/code-tab-workflow.md#t31--side-chat-opens) | Bundled `index.js` contains all three side-chat eipc channel names (`startSideChat`, `sendSideChatMessage`, `stopSideChat`) — load-bearing trio | file probe |
|
||||
| [T31b](../../docs/testing/cases/code-tab-workflow.md#t31--side-chat-opens) | After `seedFromHost` + `userLoaded`, all three side-chat eipc handlers (`startSideChat`, `sendSideChatMessage`, `stopSideChat`) are registered on the claude.ai webContents — load-bearing trio (Tier 2 runtime sibling of T31) | L1 (eipc registry) |
|
||||
| [T32](../../docs/testing/cases/code-tab-workflow.md#t32--slash-command-menu) | Bundled `index.js` contains `LocalSessions_$_getSupportedCommands` eipc channel + `slashCommands` schema field | file probe |
|
||||
| [T33](../../docs/testing/cases/extensibility.md#t33--plugin-browser) | Bundled `index.js` contains `CustomPlugins_$_listMarketplaces` and `CustomPlugins_$_listAvailablePlugins` eipc channel names (browser populate flow) | file probe |
|
||||
| [T33b](../../docs/testing/cases/extensibility.md#t33--plugin-browser) | After `seedFromHost` + `userLoaded`, both plugin-browser eipc handlers (`listMarketplaces`, `listAvailablePlugins`) are registered on the claude.ai webContents — load-bearing pair (Tier 2 runtime sibling of T33) | L1 (eipc registry) |
|
||||
| [T33c](../../docs/testing/cases/extensibility.md#t33--plugin-browser) | After `seedFromHost` + `userLoaded`, both plugin-browser eipc handlers (`listMarketplaces`, `listAvailablePlugins`) are callable through the renderer-side wrapper with `args = [[]]` (empty `egressAllowedDomains`), each returning array shape — Tier 2 invocation upgrade of T33b, strictly stronger than registration alone | L1 (eipc invoke) |
|
||||
| [T35](../../docs/testing/cases/extensibility.md#t35--mcp-server-config-picked-up) | Bundled `index.js` contains the four-needle MCP-config separation fingerprint: `claude_desktop_config.json` (chat-tab path), `.claude.json` + `.mcp.json` (Code-tab loaders), `"user","project","local"` (settingSources triple Code-session passes to the agent SDK) — pins per-tab separation without launch | file probe |
|
||||
| [T35b](../../docs/testing/cases/extensibility.md#t35--mcp-server-config-picked-up) | After `seedFromHost` + `userLoaded`, the `claude.settings/MCP/getMcpServersConfig` eipc handler is registered AND callable through the renderer-side wrapper, returning a non-array object (Tier 2 runtime sibling of T35, strictly stronger than the bundle-string fingerprint) | L1 (eipc invoke) |
|
||||
| [T36](../../docs/testing/cases/extensibility.md#t36--hooks-fire) | Bundled `index.js` contains the hooks runtime fingerprint: `hook_started` / `hook_progress` / `hook_response` (single-occurrence Verbose-transcript runtime emits) plus `PreToolUse` / `UserPromptSubmit` registry tokens — pins the runtime hook-fire path the case-doc Verbose-transcript claim hangs on | file probe |
|
||||
| [T37](../../docs/testing/cases/extensibility.md#t37--claudemd-memory-loads) | Bundled `index.js` contains `[GlobalMemory] Copied CLAUDE.md` log line + `CLAUDE.md` filename literal + `CLAUDE_CONFIG_DIR` env-var token (memory-loading wiring) | file probe |
|
||||
| [T37b](../../docs/testing/cases/extensibility.md#t37--claudemd-memory-loads) | After `seedFromHost` + `userLoaded`, the `claude.web/CoworkMemory/readGlobalMemory` eipc handler is registered AND callable through the renderer-side wrapper, returning the documented `string \| null` shape (Tier 2 runtime sibling of T37) | L1 (eipc invoke) |
|
||||
| [T38](../../docs/testing/cases/code-tab-handoff.md#t38--continue-in-ide) | Bundled `index.js` contains `LocalSessions_$_openInEditor` eipc channel name (Tier 1 fingerprint) | file probe |
|
||||
| [T38b](../../docs/testing/cases/code-tab-handoff.md#t38--continue-in-ide) | After `seedFromHost` + `userLoaded`, the `LocalSessions_$_openInEditor` eipc handler is registered on the claude.ai webContents (Tier 2 runtime sibling of T38) | L1 (eipc registry) |
|
||||
| H01 | CDP auth gate exits with code 1 when spawned with `--remote-debugging-port` and no `CLAUDE_CDP_AUTH` token | spawn probe |
|
||||
| H02 | `frame-fix-wrapper.js` + `frame-fix-entry.js` injected into `app.asar` (Proxy + main-field reference) | file probe |
|
||||
| H03 | Build-pipeline patch fingerprints all present in `app.asar` (KDE gate, frame-fix inject, tray, cowork, claude-code) | file probe |
|
||||
| H04 | cowork daemon spawns under app and exits with app — soft-skips on rows where it isn't gated to spawn | pgrep delta |
|
||||
| H05 | UI-drift canary against the AX-tree fingerprint walker (requires `CLAUDE_TEST_USE_HOST_CONFIG=1`) | L1 (AX) |
|
||||
| [S01](../../docs/testing/cases/distribution.md#s01--appimage-launches-without-manual-libfuse2t64) | AppImage launches without `libfuse.so.2` complaint (skips on non-AppImage rows) | spawn + stderr grep |
|
||||
| [S02](../../docs/testing/cases/distribution.md#s02--xdg_current_desktopubuntugnome-prefix-form-doesnt-break-de-detection) | No strict `==` equality against `XDG_CURRENT_DESKTOP` in launcher / patches (regression detector) | source-tree probe |
|
||||
| [S03](../../docs/testing/cases/distribution.md#s03--deb-install-pulls-runtime-deps) | `dpkg-query Depends:` field non-empty (currently fails as upstream-contract regression detector) | dpkg-query |
|
||||
| [S04](../../docs/testing/cases/distribution.md#s04--rpm-install-pulls-runtime-deps) | `rpm -qR` has at least one non-`rpmlib(...)` requirement (currently fails per #autoreqprov off) | rpm -qR |
|
||||
| [S05](../../docs/testing/cases/distribution.md#s05--doctor-recognises-dnf-installed-package-doesnt-false-flag-as-appimage) | Doctor does not false-flag rpm-installed package (skips when `rpm -qf` doesn't claim the binary) | spawn + stdout grep |
|
||||
| [S07](../../docs/testing/cases/shortcuts-and-input.md#s07--claude_use_waylandvar) | Under `CLAUDE_HARNESS_USE_WAYLAND=1`, spawned Electron has `--ozone-platform=wayland` on argv | argv probe |
|
||||
| [S08](../../docs/testing/cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update) | `setImage`-based in-place fast-path injected by `tray.sh` (KDE-only, file probe) | file probe |
|
||||
| [S09](../../docs/testing/cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate) | KDE-gate string present in bundled `index.js` (patch ran at build) | file probe |
|
||||
| [S10](../../docs/testing/cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) | KDE-W only — popup runtime `getBackgroundColor() === '#00000000'` after Quick Entry opens (regression-detector against electron#50213 if bundled Electron in 41.0.4-bisect-window) | L1 + ydotool |
|
||||
| [S11](../../docs/testing/cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab) | GNOME-X / Ubu-X only (X11-side regression detector) — spawn xterm marker, `xdotool windowfocus` to it, verify `_NET_ACTIVE_WINDOW` shifted, fire `Ctrl+Alt+Space` via ydotool, assert popup visible. Wayland-side mutter regression (#404) is a primitive gap — needs Wayland-native focus injection (libei) | L1 + xdotool focus + ydotool shortcut |
|
||||
| S12 | `--enable-features=GlobalShortcutsPortal` in Electron argv (GNOME-W only — currently a known-failing regression detector) | argv probe |
|
||||
| [S14](../../docs/testing/cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri) | Niri only — spawn `foot` marker, `niri msg action focus-window` to it, verify `niri msg --json focused-window` shifted, fire `Ctrl+Alt+Space` via ydotool, assert popup visible. Currently known-failing detector for the Niri portal `BindShortcuts` path (parallels S12's GNOME-W detector) | L1 + niri msg focus + ydotool shortcut |
|
||||
| [S15](../../docs/testing/cases/distribution.md#s15--appimage-extraction---appimage-extract-works-as-documented-fallback) | `--appimage-extract` exits 0; `squashfs-root/AppRun --version` runs without FUSE error | spawn + filesystem |
|
||||
| [S16](../../docs/testing/cases/distribution.md#s16--appimage-mount-cleans-up-on-app-exit) | `mount(8)` shows new `.mount_claude` while app is up; gone within 10s of close | mount delta |
|
||||
| [S17](../../docs/testing/cases/platform-integration.md#s17--app-launched-from-desktop-inherits-shell-path) | Shell-path-worker overlays user's login-shell PATH onto a deliberately-scrubbed env | L1 + utilityProcess |
|
||||
| [S19](../../docs/testing/cases/routines.md#s19--claude_config_dir-redirects-scheduled-task-storage) | `extraEnv: { CLAUDE_CONFIG_DIR }` reaches main-process `process.env`; `cE()`-equivalent resolves under the override path | L1 + extraEnv |
|
||||
| [S21](../../docs/testing/cases/routines.md#s21--lid-close-still-suspends-per-os-policy) | No `handle-lid-switch` / `HandleLidSwitch` strings in bundle (lid policy deferred to OS) | asar absence probe |
|
||||
| [S22](../../docs/testing/cases/platform-integration.md#s22--computer-use-toggle-absent-or-visibly-disabled-on-linux) | `new Set(["darwin","win32"])` platform gate present; no 2-element Set pairing linux (file-probe form) | asar regex |
|
||||
| [S25](../../docs/testing/cases/platform-integration.md#s25--mobile-pairing-survives-linux-session-restart) | `safeStorage.encryptString → file → app restart → file → safeStorage.decryptString` round-trips the same plaintext (skips when `isEncryptionAvailable === false`) | L1 + shared isolation handle |
|
||||
| [S26](../../docs/testing/cases/distribution.md#s26--auto-update-is-disabled-when-installed-via-aptdnf) | `setFeedURL` present + project suppression marker present (currently fails — gated on #567) | asar fingerprint |
|
||||
| [S27](../../docs/testing/cases/extensibility.md#s27--plugins-install-per-user) | `installed_plugins.json` + homedir resolver present; no `*/plugins` system paths in bundle | asar fingerprint |
|
||||
| [S28](../../docs/testing/cases/extensibility.md#s28--worktree-creation-surfaces-clear-error-on-read-only-mounts) | Bundled `index.js` contains the worktree permission classifier expression (`"Permission denied" \|\| "Access is denied" \|\| "could not lock config file" → "permission-denied"`) plus the `Failed to create git worktree:` log line | asar fingerprint |
|
||||
| [S29](../../docs/testing/cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity) | Popup opens when main is hidden-to-tray (lazy-create sanity) | L1 |
|
||||
| [S30](../../docs/testing/cases/shortcuts-and-input.md#s30--quick-entry-shortcut-becomes-a-no-op-after-full-app-exit) | No new claude-desktop pid spawns after post-exit shortcut press | pgrep delta + ydotool |
|
||||
| [S31](../../docs/testing/cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state) | Submit reaches new chat from visible / minimized / hidden-to-tray (QE-7/8/9) | L1 + ydotool |
|
||||
| S32 | GNOME mutter stale-`isFocused()` regression (GNOME-W/Ubu-W only — known-failing today) | L1 + ydotool |
|
||||
| [S33](../../docs/testing/cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version) | Captures bundled Electron version against the #370 / electron#50213 bisect threshold | file read |
|
||||
| [S34](../../docs/testing/cases/shortcuts-and-input.md#s34--quick-entry-shortcut-focuses-fullscreen-main-window-instead-of-showing-popup) | Popup does **not** appear when main is fullscreen (upstream contract) | L1 + ydotool |
|
||||
| [S35](../../docs/testing/cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts) | Popup position persists across invocations *and* across app restart (two-launch test) | L1 + shared isolation handle + ydotool |
|
||||
| S36 | Multi-monitor fallback — skip-on-single-monitor with documented `fixme` for the disconnect orchestration | display probe |
|
||||
| S37 | Main-window destroy unreachable on Linux per close-to-tray override — documented skip | — |
|
||||
|
||||
These specs exercise the substrate primitives in `lib/`: `xprop`
|
||||
shell-outs (T01, T04), `dbus-next` (T03), `dbus-monitor` subprocess
|
||||
eavesdrop (T23), Node-inspector runtime-attach
|
||||
(T07/T16/T17/T26/S10/S29-S35/T05-T14b L1 specs), `app.asar` content reads
|
||||
(S08/S09/S21/S22/S26/S27/S28/T11/T14a/T18/T22/T30/T31/T32/T33/T35/T36/T37/T38/H02/H03/S33 — mostly `index.js`; T18 reads `mainView.js`),
|
||||
`/proc/$pid/cmdline` reads (S07/S12), pgrep-based pid deltas
|
||||
(T10/T14b/H04/S16/S30), `mount(8)` parsing (S16), source-tree probes
|
||||
against `scripts/launcher-common.sh` (S02), `dpkg-query` / `rpm -qR` /
|
||||
`rpm -qf` calls (S03/S04/S05/T13), `safeStorage.encryptString`
|
||||
round-trip across two launches (S25), `extraEnv` precedence over
|
||||
isolation env (S19), the `lib/electron-mocks.ts` mock-then-call
|
||||
helpers — `installOpenDialogMock` (T17), `installShowItemInFolderMock`
|
||||
(T25), `installOpenExternalMock` (T24) — the `lib/input.ts`
|
||||
focus-shifter (`focusOtherWindow` + `spawnMarkerWindow` for S11; X11
|
||||
only — `WaylandFocusUnavailable` thrown on native Wayland) and its
|
||||
Niri-native sibling `lib/input-niri.ts` (`niri msg --json` for the
|
||||
focus-injection + readback chain, `foot --title` for the marker
|
||||
window; `NiriIpcUnavailable` thrown off-Niri; consumed by S14), the
|
||||
`lib/eipc.ts` registry walker (`getEipcChannels` /
|
||||
`waitForEipcChannel` / `waitForEipcChannels` against
|
||||
`webContents.ipc._invokeHandlers`; opaque on the UUID, suffix-matched
|
||||
against case-doc anchors; consumed by T19 / T20 / T22b / T31b / T33b /
|
||||
T38b) plus its session 8 invoke surface (`invokeEipcChannel` — calls
|
||||
a registered handler through the renderer-side wrapper at
|
||||
`window['claude.<scope>'].<Iface>.<method>`; consumed by T19 / T20 /
|
||||
T27 / T33c / T35b / T37b), the `lib/ax.ts` AX-tree substrate
|
||||
(`snapshotAx` for one-shot reads + `waitForAxNode` / `waitForAxNodes`
|
||||
for predicate-based polling, plus re-exports of `RawElement` /
|
||||
`AxNode` / `axTreeToSnapshot` / `waitForAxTreeStable` from
|
||||
`explore/walker.ts` so consumers stay inside `lib/`; threshold-
|
||||
driven extraction in session 13 once T26 had to duplicate the
|
||||
formerly-private `snapshotAx` from `claudeai.ts`; consumed by
|
||||
`claudeai.ts` page-objects + T26; session 14 migrated `activateTab`
|
||||
from a one-shot snapshot to `waitForAxNode` polling — fixes the
|
||||
T16 `no AX-tree button with accessibleName="Code" found` failure
|
||||
mode where the Code button hadn't rendered yet at click time —
|
||||
and converted `CodeTab.activate`'s post-click `findCompactPills`
|
||||
retry loop to `waitForAxNodes`) — and the
|
||||
`createIsolation({ seedFromHost: true })` primitive that lets login-
|
||||
required tests run hermetically against a copy of the host's signed-
|
||||
in auth state (T07, T11_runtime, T16, T17, T19, T20, T21, T22b, T26,
|
||||
T27, T31b, T33b, T33c, T35b, T37b, T38b — session 15 migrated T17
|
||||
from the legacy `CLAUDE_TEST_USE_HOST_CONFIG=1` / `isolation: null`
|
||||
shape to `seedFromHost`, fixing a pre-existing 60s spec-timeout
|
||||
flake where the unauth'd default isolation polled `userLoaded` past
|
||||
Playwright's spec budget; session 16 verified the migration end-to-
|
||||
end — `seedFromHost` clones the host's signed-in config,
|
||||
`waitForReady('userLoaded')` resolves to a post-login URL, and the
|
||||
session-14 `CodeTab.activate({ timeout: 15_000 })` succeeds; T17
|
||||
now reaches a NEW failure mode at the next chain step
|
||||
(`openFolderPicker` after `selectLocal`, `Select folder…` pill
|
||||
doesn't render on `/epitaxy` workspace route — likely needs `/new`
|
||||
context, deferred for a future session).
|
||||
|
||||
Note on eipc channels: the `LocalSessions_$_*` and `CustomPlugins_$_*`
|
||||
channel names referenced in the case-doc Code anchors don't register
|
||||
through Electron's *global* `ipcMain.handle()` registry (which only
|
||||
carries 3 chat-tab MCP-bridge handlers). They DO register through
|
||||
Electron's stdlib `IpcMainImpl` — just on the per-`webContents` IPC
|
||||
scope (`webContents.ipc._invokeHandlers`, Electron 17+) rather than
|
||||
the global one. The framing is
|
||||
`$eipc_message$_<UUID>_$_<scope>_$_<iface>_$_<method>` (UUID stable
|
||||
across builds at `c0eed8c9-…`); 117 `LocalSessions_*` + 16
|
||||
`CustomPlugins_*` + 50+ other interfaces register on the claude.ai
|
||||
webContents. T22 / T31 / T33 / T38 ship as Tier 1 fingerprints
|
||||
against the bundled channel-name strings; T22b / T31b / T33b / T38b
|
||||
are the runtime registry-presence siblings (strictly stronger,
|
||||
require `seedFromHost`). T27 / T33c / T35b / T37b go one step
|
||||
further — they invoke the resolved handlers through the renderer-
|
||||
side wrapper at `window['claude.<scope>'].<Iface>.<method>`. T19 /
|
||||
T20 are first-runtime-probe siblings of case-doc tests whose anchors
|
||||
are write-side handlers (`startShellPty` / `writeSessionFile`); they
|
||||
ship a five-suffix / three-suffix registration probe over the
|
||||
case-doc-anchored write-side surface plus a single foundational
|
||||
read-side `LocalSessions/getAll` invocation as the read-side
|
||||
surrogate (case-doc connection: integrated terminal and file pane
|
||||
both bind to LocalSessions; `getAll` proves the LocalSessions impl
|
||||
object is reachable through the renderer wrapper). T21 and
|
||||
T11_runtime extend the dual-invocation pattern: when a case-doc has
|
||||
read-side anchors with resolvable arg shapes, invoke the case-doc-
|
||||
anchored handlers directly rather than through a foundational
|
||||
surrogate (T21: `getConfiguredServices` array + `getAutoVerify`
|
||||
boolean on a single Launch impl object; T11_runtime: cross-impl-
|
||||
object dual invocation — `CustomPlugins/listInstalledPlugins` array
|
||||
+ `LocalPlugins/getPlugins` array — proves the install plumbing
|
||||
crosses both interfaces intact, strictly stronger than single-
|
||||
interface coverage). All wrapper
|
||||
invocations use the wrapper exposed by `mainView.js` via
|
||||
`contextBridge.exposeInMainWorld` after a top-frame + origin gate
|
||||
(`Qc()`: claude.ai / claude.com / preview.* / localhost). Calling
|
||||
through the wrapper carries an honest `senderFrame` for the inlined
|
||||
`le()` / `Vi()` per-handler origin gate, so the test surface matches
|
||||
real attack surface. T33c also
|
||||
demonstrates the schema-rev path: when invocation rejects with
|
||||
`Argument "<name>" at position N ... failed to pass validation`,
|
||||
the verbatim rejection string is the cheapest grep target back to
|
||||
the inline hand-rolled validator block (bundle bytes 5013601 /
|
||||
5018821 for the two CustomPlugins methods). See `lib/eipc.ts` for
|
||||
both surfaces.
|
||||
|
||||
Per-row pass/skip counts depend on which sweep runs against the row.
|
||||
The Quick Entry runners (S29-S35) all share the same primitive set
|
||||
(`installInterceptor()` + `openAndWaitReady()` + scenario-specific
|
||||
state setup).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
On the host or VM running the sweep:
|
||||
|
||||
- Node.js ≥ 20
|
||||
- `claude-desktop` installed (deb / rpm / AppImage), reachable via `claude-desktop` on `PATH` or `CLAUDE_DESKTOP_LAUNCHER` env var
|
||||
- `xprop` (for L2 window queries — `dnf install xorg-x11-utils` on Fedora; `apt install x11-utils` on Debian/Ubuntu)
|
||||
- `zstd` (optional — used to bundle results)
|
||||
|
||||
### Quick Entry runners (S29–S37, future QE-*)
|
||||
|
||||
Quick Entry tests inject the OS-level shortcut via `ydotool` /
|
||||
`/dev/uinput`. One-time setup per host or VM:
|
||||
|
||||
```sh
|
||||
# Install the binary + daemon
|
||||
sudo dnf install -y ydotool # or: sudo apt install ydotool
|
||||
|
||||
# Make ydotoold's socket world-writable so the test runner reaches it
|
||||
sudo mkdir -p /etc/systemd/system/ydotool.service.d
|
||||
sudo tee /etc/systemd/system/ydotool.service.d/override.conf <<'EOF'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/ydotoold --socket-perm=0666
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now ydotool.service
|
||||
```
|
||||
|
||||
After this, `ydotool key 29:1 29:0` (Ctrl tap) should exit 0. The
|
||||
runner sets `YDOTOOL_SOCKET=/tmp/.ydotool_socket` automatically;
|
||||
override the env var if your daemon binds elsewhere.
|
||||
|
||||
ydotool **cannot** drive portal-grabbed shortcuts (kernel uinput
|
||||
events vs compositor portal grabs) — those tests stay manual until
|
||||
libei adoption broadens. See [`docs/testing/automation.md`](../../docs/testing/automation.md#input-injection--ydotool-now-libei-next).
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cd tools/test-harness
|
||||
npm install
|
||||
```
|
||||
|
||||
`package-lock.json` is gitignored for now; commit it once the dep set is settled.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
# All four tests against the locally installed claude-desktop
|
||||
ROW=KDE-W ./orchestrator/sweep.sh
|
||||
|
||||
# Single test
|
||||
npx playwright test src/runners/T01_app_launch.spec.ts
|
||||
|
||||
# Headed (watch the app launch in front of you)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run the full suite under native Wayland instead of X11/XWayland
|
||||
CLAUDE_HARNESS_USE_WAYLAND=1 npm test
|
||||
|
||||
# Grounding probe — dump runtime state for the case-doc grounding sweep
|
||||
npm run grounding-probe -- --launch --include-synthetic \
|
||||
--out ../../docs/testing/cases-grounding-runtime.json
|
||||
```
|
||||
|
||||
Results land at `results/results-${ROW}-${DATE}/`:
|
||||
|
||||
```
|
||||
results/results-KDE-W-20260430T143000Z/
|
||||
├── junit.xml # JUnit summary (matrix-regen input)
|
||||
├── html/ # Playwright HTML report
|
||||
└── test-output/ # Per-test attachments (screenshots, logs, etc.)
|
||||
```
|
||||
|
||||
A bundled `results-${ROW}-${DATE}.tar.zst` sits next to the dir if `zstd`
|
||||
is installed.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `ROW` | `KDE-W` | Matrix row label, propagated into the bundle name and per-test annotations. Drives `skipUnlessRow()` in spec files |
|
||||
| `CLAUDE_DESKTOP_LAUNCHER` | `claude-desktop` (PATH lookup) | Path to the launcher / Electron binary Playwright spawns |
|
||||
| `CLAUDE_DESKTOP_ELECTRON` | probed | Override the resolved Electron binary path (skips deb/rpm install probing) |
|
||||
| `CLAUDE_DESKTOP_APP_ASAR` | probed | Override the resolved `app.asar` path |
|
||||
| `CLAUDE_TEST_USE_HOST_CONFIG` | unset | When `1`, opt out of per-test isolation and use the host's real `~/.config/Claude`. Required for tests that need a signed-in claude.ai (S31, future submit-side QE runners). **Side effect:** these tests write to your real account — chats / settings persist |
|
||||
| `CLAUDE_HARNESS_USE_WAYLAND` | unset | When `1`, every runner spawns Electron with the native-Wayland backend (`--ozone-platform=wayland` + sibling flags from `launcher-common.sh`) instead of the default X11-via-XWayland. `CLAUDE_USE_WAYLAND=1` is also exported into the spawn env for in-app paths that read it. Per-launch overrides via `launchClaude({ extraEnv })` still win |
|
||||
| `YDOTOOL_SOCKET` | `/tmp/.ydotool_socket` | Path to the `ydotoold` socket. Override only if the daemon binds elsewhere |
|
||||
| `OUTPUT_DIR` | `./results` | Where bundles land |
|
||||
| `RESULTS_DIR` | per-run derived | Single-run output dir (set by `sweep.sh`; usually you don't set this manually) |
|
||||
|
||||
### Per-test isolation default
|
||||
|
||||
`launchClaude()` creates a fresh `XDG_CONFIG_HOME` / `CLAUDE_CONFIG_DIR`
|
||||
under `$TMPDIR/claude-test-*` for every launch and removes it on
|
||||
`close()`. This is the default to prevent state leaks between tests
|
||||
(SingletonLock collisions, persisted Quick Entry positions, etc. —
|
||||
see Decision 1 in [`docs/testing/automation.md`](../../docs/testing/automation.md)).
|
||||
Three escape hatches:
|
||||
|
||||
- **`launchClaude()`** — default, fresh per-launch isolation.
|
||||
- **`launchClaude({ isolation })`** — pass a shared `Isolation` handle
|
||||
to launch the same app twice with persistent state (e.g. S35
|
||||
position-memory across restart).
|
||||
- **`launchClaude({ isolation: null })`** — opt out entirely; share
|
||||
the host's `~/.config/Claude`. Used by tests gated on
|
||||
`CLAUDE_TEST_USE_HOST_CONFIG` for signed-in claude.ai access.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tools/test-harness/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── playwright.config.ts
|
||||
├── src/
|
||||
│ ├── lib/ # shared helpers
|
||||
│ │ ├── electron.ts # spawn + isolation + inspector attach
|
||||
│ │ ├── inspector.ts # Node-inspector RPC client (SIGUSR1 path)
|
||||
│ │ ├── dbus.ts # dbus-next session-bus + helpers
|
||||
│ │ ├── sni.ts # StatusNotifierWatcher / Item
|
||||
│ │ ├── wm.ts # xprop wrappers (X11 + XWayland)
|
||||
│ │ ├── env.ts # XDG_CURRENT_DESKTOP / SESSION_TYPE branching
|
||||
│ │ ├── row.ts # skipUnlessRow / skipOnRow primitives
|
||||
│ │ ├── isolation.ts # per-test XDG_CONFIG_HOME sandbox
|
||||
│ │ ├── argv.ts # /proc/$pid/cmdline reader + flag check
|
||||
│ │ ├── asar.ts # in-place app.asar reads (no temp extract)
|
||||
│ │ ├── quickentry.ts # Quick Entry domain wrapper (popup, MainWindow, ydotool)
|
||||
│ │ ├── claudeai.ts # claude.ai renderer UI domain (CodeTab, dialog mock, atoms)
|
||||
│ │ ├── electron-mocks.ts # mock-then-call helpers (dialog/showItemInFolder/openExternal)
|
||||
│ │ ├── input.ts # focus-shifter primitive (X11 only — xdotool + xprop verify; spawnMarkerWindow xterm)
|
||||
│ │ ├── input-niri.ts # focus-shifter primitive (Niri only — niri msg --json verify; spawnMarkerWindow foot)
|
||||
│ │ ├── eipc.ts # eipc-channel registry walker (per-webContents IPC scope; suffix-matched, UUID-opaque)
|
||||
│ │ ├── retry.ts # poll-until-true with timeout
|
||||
│ │ └── diagnostics.ts # launcher log, --doctor, session env
|
||||
│ └── runners/ # one .spec.ts per test ID
|
||||
│ ├── T01_app_launch.spec.ts
|
||||
│ ├── T03_tray_icon_present.spec.ts
|
||||
│ ├── T04_window_decorations.spec.ts
|
||||
│ ├── T17_folder_picker.spec.ts
|
||||
│ ├── S09_quick_window_patch_only_kde.spec.ts
|
||||
│ ├── S12_global_shortcuts_portal_flag.spec.ts
|
||||
│ ├── S29_quick_entry_lazy_create_closed_to_tray.spec.ts
|
||||
│ ├── S30_quick_entry_noop_after_app_exit.spec.ts
|
||||
│ ├── S31_quick_entry_submit_reaches_new_chat.spec.ts
|
||||
│ ├── S32_quick_entry_submit_gnome_stale_isfocused.spec.ts
|
||||
│ ├── S33_electron_version_capture.spec.ts
|
||||
│ ├── S34_shortcut_focuses_fullscreen_main.spec.ts
|
||||
│ ├── S35_quick_entry_position_persisted_across_restarts.spec.ts
|
||||
│ ├── S36_quick_entry_fallback_to_primary_display.spec.ts
|
||||
│ ├── S37_quick_entry_popup_after_main_destroy.spec.ts
|
||||
│ ├── H01_cdp_gate_canary.spec.ts
|
||||
│ ├── H02_frame_fix_wrapper_present.spec.ts
|
||||
│ ├── H03_patch_fingerprints.spec.ts
|
||||
│ └── H04_cowork_daemon_lifecycle.spec.ts
|
||||
├── probe.ts # one-off renderer-DOM probe (debugger on :9229)
|
||||
├── grounding-probe.ts # case-grounding runtime capture (see "Grounding probe" below)
|
||||
└── orchestrator/
|
||||
└── sweep.sh # row-aware harness invocation
|
||||
```
|
||||
|
||||
H-prefix specs are harness self-tests — they validate the harness's
|
||||
preconditions and the build pipeline's invariants (CDP gate alive,
|
||||
patches landed, daemon lifecycle clean). Cheap, run in <1s each
|
||||
except H04 which launches the app.
|
||||
|
||||
## How L1 testing works (the SIGUSR1 path)
|
||||
|
||||
The shipped Electron has a CDP auth gate that exits the app whenever
|
||||
`--remote-debugging-port` or `--remote-debugging-pipe` is on argv and a
|
||||
valid `CLAUDE_CDP_AUTH` token isn't in env. Both Playwright's
|
||||
`_electron.launch()` and `chromium.connectOverCDP()` inject the gated
|
||||
flag, so both are blocked.
|
||||
|
||||
The gate doesn't check `--inspect` or runtime `SIGUSR1`, which is the
|
||||
same code path as the in-app `Developer → Enable Main Process Debugger`
|
||||
menu item. So:
|
||||
|
||||
1. `launchClaude()` spawns Electron with no debug-port flags (gate
|
||||
asleep) and waits for the X11 window.
|
||||
2. `app.attachInspector()` sends `SIGUSR1` to the pid; Node's inspector
|
||||
opens on port 9229.
|
||||
3. `lib/inspector.ts` connects via WebSocket and exposes
|
||||
`evalInMain(body)` and `evalInRenderer(urlFilter, js)` for tests.
|
||||
|
||||
From the inspector you can:
|
||||
- Drive the renderer via `webContents.executeJavaScript()`
|
||||
- Install main-process mocks (e.g. `dialog.showOpenDialog` for T17)
|
||||
- Inspect any Electron API state
|
||||
|
||||
Two gotchas worth knowing:
|
||||
|
||||
- `BrowserWindow.getAllWindows()` returns 0 because frame-fix-wrapper
|
||||
substitutes the BrowserWindow class. Use `webContents.getAllWebContents()`
|
||||
instead — works correctly and includes both the shell window and the
|
||||
embedded claude.ai BrowserView.
|
||||
- `Runtime.evaluate` with `awaitPromise: true` returns empty objects for
|
||||
awaited Promise resolutions. `inspector.evalInMain<T>()` returns
|
||||
`JSON.stringify(value)` from the IIFE and parses on the caller side
|
||||
to dodge this.
|
||||
|
||||
Full writeup with rationale and tradeoffs:
|
||||
[`docs/testing/automation.md` "The CDP auth gate"](../../docs/testing/automation.md#the-cdp-auth-gate-and-the-runtime-attach-workaround-that-beats-it).
|
||||
|
||||
## Grounding probe
|
||||
|
||||
`grounding-probe.ts` is a separate entry-point — not a Playwright spec —
|
||||
that connects to a live Claude Desktop and dumps the runtime state
|
||||
backing the load-bearing claims in
|
||||
[`docs/testing/cases/`](../../docs/testing/cases/). It exists because
|
||||
static grep against the 546k-line beautified bundle has known blind
|
||||
spots (lazy `import()`s, dynamic handler tables, conditional wiring),
|
||||
and some claims (S26 autoUpdater gate, S20 powerSaveBlocker path) can
|
||||
only be verified at runtime.
|
||||
|
||||
```sh
|
||||
# Self-contained: launchClaude() + capture + tear down
|
||||
npm run grounding-probe -- --launch
|
||||
|
||||
# Plus the one synthetic probe (powerSaveBlocker start+stop)
|
||||
npm run grounding-probe -- --launch --include-synthetic
|
||||
|
||||
# Attach to an already-running app (manual --inspect=9229 setup)
|
||||
npm run grounding-probe -- --port 9229 --out /tmp/probe.json
|
||||
```
|
||||
|
||||
Output is keyed by test ID — see the file's header comment for the
|
||||
full table. Diff captures across upstream version bumps to spot
|
||||
behavior drift the static sweep would miss. Surfaces inside modals
|
||||
or popups (T22 PR toolbar, T26 preset list, T31 side chat, T32 slash
|
||||
menu) need the surface open at probe time — the AX-tree fingerprint
|
||||
is a snapshot of what's currently on screen.
|
||||
|
||||
## Known limitations
|
||||
- **T04** uses `xprop` (no `xdotool` dependency — walks `_NET_CLIENT_LIST` + `_NET_WM_PID`). Works on X11 native and KDE Wayland (XWayland), **not** on native-Wayland sessions where the app is running through Ozone-Wayland directly. Per Decision 6, project default is X11; native-Wayland window-state queries are deferred until those tests get added.
|
||||
- **T17** is shallow — it intercepts `dialog.showOpenDialog` at the Electron main process level. The integration question "does Claude make the right *portal* call?" is a v2 concern; portal-level mocking via `dbus-next` is sketched in [`docs/testing/automation.md`](../../docs/testing/automation.md) but requires displacing the running portal service or running under `dbus-run-session`.
|
||||
- **`render-matrix.sh`** isn't here yet. `sweep.sh` prints a summary; the `matrix.md` regen step from JUnit is the next addition.
|
||||
- **No CI wrapper.** Decision 4: the harness is invocable from CI but sweeps run from the dev box for the first ~20 tests.
|
||||
|
||||
## Adding a test
|
||||
|
||||
1. Pick the `T##` / `S##` from [`docs/testing/cases/`](../../docs/testing/cases/).
|
||||
2. Drop `src/runners/T##_short_name.spec.ts`. Use the existing five as templates — match the layer (L1 / L2) to the test's assertion shape.
|
||||
3. First line of the test body: `skipUnlessRow(testInfo, ['KDE-W', ...])`. JUnit `<skipped>` → matrix `-`, never `✗` for a row that doesn't apply.
|
||||
4. Tag the test with `severity` and `surface` annotations so the JUnit output carries them.
|
||||
5. Capture diagnostics via `testInfo.attach()` — these become Decision 7 "always-on" captures regardless of pass/fail. For tests that need richer state on failure, wrap your scenarios in a results-collector and attach a single JSON dump (S31's pattern).
|
||||
6. No fixed `sleep`s. Use `retryUntil` or Playwright's auto-wait.
|
||||
|
||||
### Hooking Electron — read this before reaching for `BrowserWindow`
|
||||
|
||||
`scripts/frame-fix-wrapper.js` returns the `electron` module wrapped
|
||||
in a `Proxy` whose `get` trap returns a closure-captured
|
||||
`PatchedBrowserWindow`. **Constructor-level wraps don't work** — your
|
||||
`electron.BrowserWindow = WrappedCtor` write lands on the underlying
|
||||
module but the Proxy keeps returning `PatchedBrowserWindow` on
|
||||
read, so the wrap is bypassed. The reliable hook is at the
|
||||
**prototype-method level**:
|
||||
|
||||
```ts
|
||||
// in inspector.evalInMain(...)
|
||||
const proto = electron.BrowserWindow.prototype;
|
||||
const orig = proto.loadFile;
|
||||
proto.loadFile = function(filePath, ...rest) {
|
||||
// record `this` + filePath; identify popups by filePath suffix
|
||||
return orig.call(this, filePath, ...rest);
|
||||
};
|
||||
```
|
||||
|
||||
This captures every instance regardless of subclass identity.
|
||||
Construction-time options (`transparent: true`, `frame: false`,
|
||||
etc.) aren't observable through this hook — use runtime
|
||||
equivalents instead (`getBackgroundColor()`, `getContentBounds()
|
||||
vs getBounds()`, `isAlwaysOnTop()`). `lib/quickentry.ts` is the
|
||||
worked example.
|
||||
309
tools/test-harness/eipc-registry-probe.ts
Normal file
309
tools/test-harness/eipc-registry-probe.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Probe to verify whether the eipc channel registry (LocalSessions_$_*,
|
||||
// CustomPlugins_$_*) is reachable from main via webContents.ipc._invokeHandlers
|
||||
// instead of the empty-on-this-build globalThis.ipcMain._invokeHandlers.
|
||||
//
|
||||
// Run from tools/test-harness against a running claude-desktop with the
|
||||
// main-process debugger enabled (Developer → Enable Main Process Debugger
|
||||
// in the app menu, or `claude-desktop` was launched with --inspect):
|
||||
// npx tsx eipc-registry-probe.ts
|
||||
//
|
||||
// Useful states to probe (re-run to compare):
|
||||
// * fresh launch — whichever tab opens by default
|
||||
// * /epitaxy with a Code session open
|
||||
// * /chats with a chat thread open
|
||||
// * cowork tab loaded
|
||||
// The per-interface breakdown surfaces which interfaces register lazily
|
||||
// vs eagerly — useful for designing the lib/eipc.ts primitive's wait
|
||||
// semantics.
|
||||
//
|
||||
// Non-destructive — read-only enumeration of handler keys. Doesn't invoke
|
||||
// anything, doesn't register anything, doesn't mutate state.
|
||||
|
||||
import { InspectorClient } from './src/lib/inspector.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
interface InterfaceCount {
|
||||
scope: string;
|
||||
iface: string;
|
||||
count: number;
|
||||
sampleMethods: string[];
|
||||
}
|
||||
|
||||
interface PerWcReport {
|
||||
id: number;
|
||||
url: string;
|
||||
type: string;
|
||||
hasIpc: boolean;
|
||||
hasInvokeHandlers: boolean;
|
||||
totalHandlers: number;
|
||||
framedCount: number;
|
||||
unframedCount: number;
|
||||
scopes: string[];
|
||||
byInterface: InterfaceCount[];
|
||||
unframedSample: string[];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const client = await InspectorClient.connect(9229);
|
||||
|
||||
// Confirm globalThis.ipcMain._invokeHandlers is empty (or near-empty)
|
||||
// — that's session 3's finding and we want it on the record alongside
|
||||
// the per-wc reading for contrast.
|
||||
const ipcMainReport = await client.evalInMain<{
|
||||
hasIpcMain: boolean;
|
||||
ipcMainKeys: string[];
|
||||
ipcMainCount: number;
|
||||
}>(`
|
||||
const electron = process.mainModule.require('electron');
|
||||
const ipcMain = electron.ipcMain;
|
||||
const map = ipcMain && ipcMain._invokeHandlers;
|
||||
if (!map) {
|
||||
return { hasIpcMain: !!ipcMain, ipcMainKeys: [], ipcMainCount: 0 };
|
||||
}
|
||||
const keys = (typeof map.keys === 'function')
|
||||
? Array.from(map.keys())
|
||||
: Object.keys(map);
|
||||
return {
|
||||
hasIpcMain: true,
|
||||
ipcMainKeys: keys,
|
||||
ipcMainCount: keys.length,
|
||||
};
|
||||
`);
|
||||
|
||||
// Per-webContents enumeration with full framing parse:
|
||||
// $eipc_message$_<UUID>_$_<scope>_$_<interface>_$_<method>
|
||||
// Scope examples: claude.settings, claude.web, claude.app_internal.
|
||||
// Interface examples: GlobalShortcut, LocalSessions, CustomPlugins.
|
||||
// We group by scope.iface to show which feature areas are populated
|
||||
// on each webContents — what registers eagerly vs on-tab-load.
|
||||
const perWcReports = await client.evalInMain<PerWcReport[]>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
const re = /^\\$eipc_message\\$_[0-9a-f-]+_\\$_([^_]+(?:\\.[^_]+)*)_\\$_([^_]+)_\\$_(.+)$/;
|
||||
const all = webContents.getAllWebContents();
|
||||
const out = [];
|
||||
for (const w of all) {
|
||||
const ipc = w.ipc;
|
||||
const invokeMap = ipc && ipc._invokeHandlers;
|
||||
let keys = [];
|
||||
let hasInvokeHandlers = false;
|
||||
if (invokeMap) {
|
||||
hasInvokeHandlers = true;
|
||||
if (typeof invokeMap.keys === 'function') {
|
||||
keys = Array.from(invokeMap.keys());
|
||||
} else {
|
||||
keys = Object.keys(invokeMap);
|
||||
}
|
||||
}
|
||||
const groups = new Map();
|
||||
const scopes = new Set();
|
||||
let framedCount = 0;
|
||||
let unframedCount = 0;
|
||||
const unframedSample = [];
|
||||
for (const k of keys) {
|
||||
const m = re.exec(k);
|
||||
if (!m) {
|
||||
unframedCount++;
|
||||
if (unframedSample.length < 8) unframedSample.push(k);
|
||||
continue;
|
||||
}
|
||||
framedCount++;
|
||||
const scope = m[1];
|
||||
const iface = m[2];
|
||||
const method = m[3];
|
||||
scopes.add(scope);
|
||||
const groupKey = scope + '/' + iface;
|
||||
let g = groups.get(groupKey);
|
||||
if (!g) {
|
||||
g = { scope, iface, count: 0, sampleMethods: [] };
|
||||
groups.set(groupKey, g);
|
||||
}
|
||||
g.count++;
|
||||
if (g.sampleMethods.length < 4) g.sampleMethods.push(method);
|
||||
}
|
||||
const byInterface = Array.from(groups.values())
|
||||
.sort((a, b) => b.count - a.count);
|
||||
out.push({
|
||||
id: w.id,
|
||||
url: w.getURL(),
|
||||
type: w.getType ? w.getType() : 'unknown',
|
||||
hasIpc: !!ipc,
|
||||
hasInvokeHandlers,
|
||||
totalHandlers: keys.length,
|
||||
framedCount,
|
||||
unframedCount,
|
||||
scopes: Array.from(scopes).sort(),
|
||||
byInterface,
|
||||
unframedSample,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
`);
|
||||
|
||||
// For each case-doc anchored channel, find which webContents (if any)
|
||||
// hosts it. The framing prefix `$eipc_message$_<UUID>_$_claude.web_$_`
|
||||
// is build-stable per session 2's T38 finding, so we match by suffix.
|
||||
const expected = [
|
||||
// T22 — gh PR check monitoring
|
||||
'LocalSessions_$_getPrChecks',
|
||||
// T31 — side chat trio
|
||||
'LocalSessions_$_startSideChat',
|
||||
'LocalSessions_$_sendSideChatMessage',
|
||||
'LocalSessions_$_stopSideChat',
|
||||
// T33 — plugin browser
|
||||
'CustomPlugins_$_listMarketplaces',
|
||||
'CustomPlugins_$_listAvailablePlugins',
|
||||
// T38 — Continue in IDE
|
||||
'LocalSessions_$_openInEditor',
|
||||
];
|
||||
|
||||
const expectedReport = await client.evalInMain<
|
||||
Array<{ suffix: string; foundOn: number[]; matchedKeys: string[] }>
|
||||
>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
const expected = ${JSON.stringify(expected)};
|
||||
const all = webContents.getAllWebContents();
|
||||
const out = [];
|
||||
for (const suffix of expected) {
|
||||
const foundOn = [];
|
||||
const matchedKeys = [];
|
||||
for (const w of all) {
|
||||
const ipc = w.ipc;
|
||||
const invokeMap = ipc && ipc._invokeHandlers;
|
||||
if (!invokeMap) continue;
|
||||
const keys = (typeof invokeMap.keys === 'function')
|
||||
? Array.from(invokeMap.keys())
|
||||
: Object.keys(invokeMap);
|
||||
for (const k of keys) {
|
||||
if (k.endsWith(suffix)) {
|
||||
if (!foundOn.includes(w.id)) foundOn.push(w.id);
|
||||
if (!matchedKeys.includes(k)) matchedKeys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ suffix, foundOn, matchedKeys });
|
||||
}
|
||||
return out;
|
||||
`);
|
||||
|
||||
// Snapshot the framing UUID(s) — useful to confirm build-stability
|
||||
// across the per-wc registries (session 2 noted it as build-stable
|
||||
// `c0eed8c9-...`).
|
||||
const framingReport = await client.evalInMain<{
|
||||
uuidsSeen: string[];
|
||||
samplesPerUuid: Record<string, string[]>;
|
||||
}>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
const re = /^\\$eipc_message\\$_([0-9a-f-]+)_\\$_/;
|
||||
const uuidsSeen = new Set();
|
||||
const samples = {};
|
||||
for (const w of webContents.getAllWebContents()) {
|
||||
const ipc = w.ipc;
|
||||
const invokeMap = ipc && ipc._invokeHandlers;
|
||||
if (!invokeMap) continue;
|
||||
const keys = (typeof invokeMap.keys === 'function')
|
||||
? Array.from(invokeMap.keys())
|
||||
: Object.keys(invokeMap);
|
||||
for (const k of keys) {
|
||||
const m = re.exec(k);
|
||||
if (!m) continue;
|
||||
const uuid = m[1];
|
||||
uuidsSeen.add(uuid);
|
||||
if (!samples[uuid]) samples[uuid] = [];
|
||||
if (samples[uuid].length < 3) samples[uuid].push(k);
|
||||
}
|
||||
}
|
||||
return {
|
||||
uuidsSeen: Array.from(uuidsSeen),
|
||||
samplesPerUuid: samples,
|
||||
};
|
||||
`);
|
||||
|
||||
console.log('=== globalThis.ipcMain._invokeHandlers (session 3 baseline) ===');
|
||||
console.log(JSON.stringify(ipcMainReport, null, 2));
|
||||
|
||||
console.log('\n=== Per-webContents IPC registries ===');
|
||||
console.log(JSON.stringify(perWcReports, null, 2));
|
||||
|
||||
console.log('\n=== Expected case-doc-anchored channel resolution ===');
|
||||
console.log(JSON.stringify(expectedReport, null, 2));
|
||||
|
||||
console.log('\n=== Framing UUID(s) observed ===');
|
||||
console.log(JSON.stringify(framingReport, null, 2));
|
||||
|
||||
// Cross-webContents per-interface deltas — useful when comparing
|
||||
// "fresh launch" vs "after navigating to /epitaxy" vs "after opening
|
||||
// cowork tab". Lists every (scope, iface) seen anywhere with the
|
||||
// per-wc breakdown of which has it.
|
||||
const interfaceAcrossWcs = (() => {
|
||||
const matrix = new Map<string, Map<number, number>>();
|
||||
for (const wc of perWcReports) {
|
||||
for (const g of wc.byInterface) {
|
||||
const key = `${g.scope}/${g.iface}`;
|
||||
let row = matrix.get(key);
|
||||
if (!row) {
|
||||
row = new Map();
|
||||
matrix.set(key, row);
|
||||
}
|
||||
row.set(wc.id, g.count);
|
||||
}
|
||||
}
|
||||
const out: Array<{
|
||||
interfaceKey: string;
|
||||
perWc: Record<string, number>;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const [key, row] of matrix) {
|
||||
const perWc: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const [wcId, count] of row) {
|
||||
perWc[`wc${wcId}`] = count;
|
||||
total += count;
|
||||
}
|
||||
out.push({ interfaceKey: key, perWc, total });
|
||||
}
|
||||
out.sort((a, b) => b.total - a.total);
|
||||
return out;
|
||||
})();
|
||||
|
||||
console.log('\n=== Interface presence across webContents ===');
|
||||
console.log(JSON.stringify(interfaceAcrossWcs, null, 2));
|
||||
|
||||
const totalAll = perWcReports.reduce((a, r) => a + r.totalHandlers, 0);
|
||||
const totalFramed = perWcReports.reduce((a, r) => a + r.framedCount, 0);
|
||||
const totalUnframed = perWcReports.reduce((a, r) => a + r.unframedCount, 0);
|
||||
const expectedFound = expectedReport.filter((e) => e.foundOn.length > 0).length;
|
||||
const totalDistinctInterfaces = new Set(
|
||||
perWcReports.flatMap((r) => r.byInterface.map((g) => `${g.scope}/${g.iface}`)),
|
||||
).size;
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(JSON.stringify({
|
||||
webContentsCount: perWcReports.length,
|
||||
webContentsUrls: perWcReports.map((r) => `wc${r.id}: ${r.url}`),
|
||||
ipcMainHandlerCount: ipcMainReport.ipcMainCount,
|
||||
perWcTotalHandlerCount: totalAll,
|
||||
perWcFramedCount: totalFramed,
|
||||
perWcUnframedCount: totalUnframed,
|
||||
distinctInterfacesAcrossAllWcs: totalDistinctInterfaces,
|
||||
expectedSuffixesFound: `${expectedFound} / ${expected.length}`,
|
||||
framingUuidsObserved: framingReport.uuidsSeen.length,
|
||||
}, null, 2));
|
||||
|
||||
const out = {
|
||||
ipcMainReport,
|
||||
perWcReports,
|
||||
expectedReport,
|
||||
framingReport,
|
||||
interfaceAcrossWcs,
|
||||
};
|
||||
writeFileSync('/tmp/eipc-registry-probe.json', JSON.stringify(out, null, 2));
|
||||
console.log('\nFull dump → /tmp/eipc-registry-probe.json');
|
||||
|
||||
client.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
468
tools/test-harness/grounding-probe.ts
Normal file
468
tools/test-harness/grounding-probe.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
// Grounding probe — dumps Claude Desktop runtime state that backs the
|
||||
// load-bearing claims in docs/testing/cases/. Output is keyed by
|
||||
// test-ID so the next grounding sweep can diff captures across
|
||||
// upstream versions.
|
||||
//
|
||||
// Two modes:
|
||||
// - attach (default): connect to an already-running app on port 9229
|
||||
// (manual `--inspect=9229` run, or a launchClaude() instance that
|
||||
// called attachInspector()).
|
||||
// - --launch: spin up a fresh isolated instance via launchClaude(),
|
||||
// capture, tear down. Self-contained — usable in CI.
|
||||
//
|
||||
// Mostly read-only; --include-synthetic enables short-lived state
|
||||
// changes (powerSaveBlocker start+stop) to close API-only gaps.
|
||||
//
|
||||
// Captures, keyed by test ID:
|
||||
// T01 app metadata, webContents count
|
||||
// T03 SNI / tray registration via DBus (KDE StatusNotifierWatcher)
|
||||
// T06 globalShortcut.isRegistered() for known accelerators
|
||||
// T09 app.getLoginItemSettings()
|
||||
// T22 AX fingerprint (PR toolbar — open the surface before probing)
|
||||
// T23 Notification.isSupported()
|
||||
// T24 IPC channels matching /external|editor|openIn/i
|
||||
// T26 AX fingerprint (Routines page — open before probing)
|
||||
// T31 AX fingerprint (side chat — open before probing)
|
||||
// T32 AX fingerprint (slash menu — type "/" before probing)
|
||||
// T38 IPC channels matching /external|editor|openIn/i (editor handoff)
|
||||
// S18 safeStorage.isEncryptionAvailable() + backend
|
||||
// S20 powerSaveBlocker (gated by --include-synthetic)
|
||||
// S22 process.platform (Computer Use gate)
|
||||
// S25 safeStorage (cowork trusted-device token)
|
||||
// S26 autoUpdater.getFeedURL() — empirical answer to the structural-
|
||||
// open claim that static analysis couldn't resolve
|
||||
//
|
||||
// Usage:
|
||||
// cd tools/test-harness
|
||||
// npx tsx grounding-probe.ts # attach :9229
|
||||
// npx tsx grounding-probe.ts --launch # self-contained
|
||||
// npx tsx grounding-probe.ts --launch --include-synthetic
|
||||
// npx tsx grounding-probe.ts --out ../../docs/testing/cases-grounding-runtime.json
|
||||
// npx tsx grounding-probe.ts --port 9229 --out path/to/file.json
|
||||
//
|
||||
// Extending: add a section in capture() with a `client.evalInMain`
|
||||
// dump targeting whatever runtime state your new test cares about,
|
||||
// then map the result into `tests[<id>]`.
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { InspectorClient } from './src/lib/inspector.js';
|
||||
import { launchClaude } from './src/lib/electron.js';
|
||||
// dbus-next is loaded lazily inside captureSni() — importing here would
|
||||
// pull in a session-bus connection on environments without one (CI
|
||||
// containers, sshfs, etc.) and break the probe before it ever runs.
|
||||
|
||||
// Accelerators we expect to be registered on Linux. T06 = Quick Entry
|
||||
// default. S31/S32 — fullscreen + cmd-K dispatch. Extend per case docs.
|
||||
const KNOWN_ACCELERATORS = [
|
||||
'Alt+Space',
|
||||
'Ctrl+Alt+Space',
|
||||
'CommandOrControl+Shift+L',
|
||||
];
|
||||
|
||||
interface AxFingerprintNode {
|
||||
role: string;
|
||||
name: string;
|
||||
hasPopup: boolean;
|
||||
}
|
||||
|
||||
interface GroundingCapture {
|
||||
capturedAt: string;
|
||||
appVersion: string;
|
||||
appPath: string;
|
||||
isPackaged: boolean;
|
||||
platform: string;
|
||||
// Cross-test corpus — useful as a denormalized source the per-test
|
||||
// entries reference by index/key. Keep these flat so jq queries
|
||||
// don't need to walk a nested tree.
|
||||
ipcInvokeChannels: string[];
|
||||
ipcOnChannels: string[];
|
||||
webContents: Array<{ id: number; url: string; type: string }>;
|
||||
// Reduced AX tree of the current claude.ai webContents, shared by
|
||||
// every test entry that names a renderer-side surface. Stored once
|
||||
// at the top level rather than copied per-test — diff stability
|
||||
// matters more than per-test isolation here.
|
||||
axFingerprint: AxFingerprintNode[];
|
||||
// Per-test bag — extend as new probes land. Each entry is the
|
||||
// runtime state the test's load-bearing claim depends on, in a
|
||||
// shape that's easy to diff across captures. Renderer-side tests
|
||||
// reference $.axFingerprint via { axFingerprintRef: true }.
|
||||
tests: Record<string, unknown>;
|
||||
// Probe-level diagnostics — what we tried and couldn't capture.
|
||||
// Surfaced so the grounding sweep can flag uncovered surfaces.
|
||||
gaps: string[];
|
||||
}
|
||||
|
||||
interface CaptureOptions {
|
||||
includeSynthetic: boolean;
|
||||
}
|
||||
|
||||
async function capture(
|
||||
client: InspectorClient,
|
||||
opts: CaptureOptions,
|
||||
): Promise<GroundingCapture> {
|
||||
const gaps: string[] = [];
|
||||
|
||||
// App metadata — every test references at least one of these.
|
||||
const appMeta = await client.evalInMain<{
|
||||
appVersion: string;
|
||||
appPath: string;
|
||||
isPackaged: boolean;
|
||||
appReady: boolean;
|
||||
platform: string;
|
||||
}>(`
|
||||
const { app } = process.mainModule.require('electron');
|
||||
return {
|
||||
appVersion: app.getVersion(),
|
||||
appPath: app.getAppPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
appReady: app.isReady(),
|
||||
platform: process.platform,
|
||||
};
|
||||
`);
|
||||
|
||||
// IPC handler registry. Every claude.web_* channel registers via
|
||||
// ipcMain.handle() (invoke side) or ipcMain.on() (fire-and-forget).
|
||||
// Private API — surfaces shift across Electron versions; tolerate
|
||||
// both shapes.
|
||||
const ipc = await client.evalInMain<{ invoke: string[]; on: string[] }>(`
|
||||
const { ipcMain } = process.mainModule.require('electron');
|
||||
const invoke = ipcMain._invokeHandlers
|
||||
? Array.from(ipcMain._invokeHandlers.keys())
|
||||
: [];
|
||||
const on = ipcMain.eventNames ? ipcMain.eventNames().map(String) : [];
|
||||
return { invoke, on };
|
||||
`);
|
||||
|
||||
// WebContents inventory — proves which BrowserViews / BrowserWindows
|
||||
// exist at probe time. Note: BrowserWindow.getAllWindows() returns
|
||||
// 0 because frame-fix-wrapper substitutes the class (see
|
||||
// inspector.ts header comment) — webContents registry stays intact.
|
||||
const webContents = await client.evalInMain<
|
||||
Array<{ id: number; url: string; type: string }>
|
||||
>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
return webContents.getAllWebContents().map(w => ({
|
||||
id: w.id,
|
||||
url: w.getURL(),
|
||||
type: w.getType ? w.getType() : 'unknown',
|
||||
}));
|
||||
`);
|
||||
|
||||
// Global shortcuts — T06, S31/S32 reference these. isRegistered()
|
||||
// is the canonical runtime probe; matches the case-doc claim about
|
||||
// what's bound at startup.
|
||||
const accelerators = await client.evalInMain<
|
||||
Array<{ accelerator: string; registered: boolean }>
|
||||
>(`
|
||||
const { globalShortcut } = process.mainModule.require('electron');
|
||||
const list = ${JSON.stringify(KNOWN_ACCELERATORS)};
|
||||
return list.map(a => ({
|
||||
accelerator: a,
|
||||
registered: globalShortcut.isRegistered(a),
|
||||
}));
|
||||
`);
|
||||
|
||||
// Autostart resolution — T09. On Linux Electron's openAtLogin is a
|
||||
// documented no-op; our wrapper installs an XDG Autostart shim
|
||||
// (frame-fix-wrapper.js:376). The empirical check confirms which
|
||||
// path is active.
|
||||
const loginItems = await client.evalInMain<{
|
||||
openAtLogin: boolean;
|
||||
wasOpenedAtLogin?: boolean;
|
||||
executableWillLaunchAtLogin?: boolean;
|
||||
}>(`
|
||||
const { app } = process.mainModule.require('electron');
|
||||
return app.getLoginItemSettings();
|
||||
`);
|
||||
|
||||
// safeStorage — S18 (env-config encryption) + S25 (cowork trusted-
|
||||
// device token). Linux backend is libsecret; availability gates
|
||||
// whether tokens persist or stall.
|
||||
const safeStorage = await client.evalInMain<{
|
||||
available: boolean;
|
||||
backend: string;
|
||||
}>(`
|
||||
const { safeStorage } = process.mainModule.require('electron');
|
||||
let backend = 'unknown';
|
||||
try {
|
||||
if (safeStorage.getSelectedStorageBackend) {
|
||||
backend = safeStorage.getSelectedStorageBackend();
|
||||
}
|
||||
} catch (_) { /* older Electron — backend not exposed */ }
|
||||
return {
|
||||
available: safeStorage.isEncryptionAvailable(),
|
||||
backend,
|
||||
};
|
||||
`);
|
||||
|
||||
// autoUpdater feedURL — S26. The case doc claims the gate is open
|
||||
// by construction (lii() returns true on Linux when packaged).
|
||||
// Accidental coverage from Electron's Linux autoUpdater being
|
||||
// unimplemented saves us from real download attempts. This probe
|
||||
// puts that on the record empirically.
|
||||
const autoUpdater = await client.evalInMain<{
|
||||
feedURL: string | null;
|
||||
feedURLError: string | null;
|
||||
}>(`
|
||||
const { autoUpdater } = process.mainModule.require('electron');
|
||||
let feedURL = null, feedURLError = null;
|
||||
try {
|
||||
feedURL = autoUpdater.getFeedURL ? autoUpdater.getFeedURL() : null;
|
||||
} catch (e) {
|
||||
feedURLError = String(e && e.message);
|
||||
}
|
||||
return { feedURL, feedURLError };
|
||||
`);
|
||||
|
||||
// Tray — T03. We can't enumerate Tray instances via public API,
|
||||
// but we can confirm Notification support is alive (T23 prerequisite).
|
||||
const notifications = await client.evalInMain<{ supported: boolean }>(`
|
||||
const { Notification } = process.mainModule.require('electron');
|
||||
return { supported: Notification.isSupported() };
|
||||
`);
|
||||
|
||||
// Powermonitor / suspend inhibit — S20. powerSaveBlocker has no
|
||||
// public enumeration API. Synthetic probe (gated behind
|
||||
// --include-synthetic) starts a blocker, reads isStarted, stops
|
||||
// immediately. Brief inhibit (~ms) is harmless; what we get back
|
||||
// is empirical proof the API path is alive on this host. Doesn't
|
||||
// verify the case-doc claim that `keepAwakeEnabled` setting toggles
|
||||
// trigger this — that requires correlating settings IO with the
|
||||
// `PhA` Set at index.js:241897, which depends on minified-name
|
||||
// stability and is left to the next sweep.
|
||||
let powerSaveBlocker: {
|
||||
apiAvailable: boolean;
|
||||
startWorks: boolean;
|
||||
idType: string;
|
||||
probeError: string | null;
|
||||
} | null = null;
|
||||
if (opts.includeSynthetic) {
|
||||
powerSaveBlocker = await client.evalInMain(`
|
||||
const { powerSaveBlocker } = process.mainModule.require('electron');
|
||||
let id = null, started = false, probeError = null;
|
||||
try {
|
||||
id = powerSaveBlocker.start('prevent-app-suspension');
|
||||
started = powerSaveBlocker.isStarted(id);
|
||||
} catch (e) {
|
||||
probeError = String(e && e.message);
|
||||
} finally {
|
||||
if (id !== null) {
|
||||
try { powerSaveBlocker.stop(id); } catch (_) {}
|
||||
}
|
||||
}
|
||||
return {
|
||||
apiAvailable: true,
|
||||
startWorks: started,
|
||||
idType: typeof id,
|
||||
probeError,
|
||||
};
|
||||
`);
|
||||
} else {
|
||||
gaps.push(
|
||||
'S20: powerSaveBlocker not probed (skip-synthetic). ' +
|
||||
'Re-run with --include-synthetic to confirm API path.',
|
||||
);
|
||||
}
|
||||
|
||||
// Editor handoff scheme registry — T24/T38. Static case anchor
|
||||
// (`Mtt` at index.js:463902) names the registry; variable is
|
||||
// minified, so we identify by IPC handler name pattern instead.
|
||||
// The case doc claims schemes vscode/cursor/zed/windsurf are wired
|
||||
// up on Linux (xcode is darwin-only). The IPC channel that calls
|
||||
// `shell.openExternal('<scheme>://file/<encoded-path>:<line>')`
|
||||
// will be one of these matches.
|
||||
const editorIpcChannels = [
|
||||
...ipc.invoke.filter((c) => /external|editor|openIn/i.test(c)),
|
||||
...ipc.on.filter((c) => /external|editor|openIn/i.test(c)),
|
||||
];
|
||||
|
||||
// Renderer AX fingerprint — T22/T26/T31/T32. `getAccessibleTree`
|
||||
// snapshots whatever's *currently on screen*. To anchor surfaces
|
||||
// inside modals/popups (preset list, slash menu, side chat, PR
|
||||
// toolbar), open the surface in the running app before probe time.
|
||||
// Reduced form (role+name+hasPopup) keeps the output grep-able and
|
||||
// avoids re-shipping ui-inventory.json's full schema.
|
||||
const claudeAi = webContents.find((w) => w.url.includes('claude.ai'));
|
||||
let axFingerprint: AxFingerprintNode[] = [];
|
||||
if (claudeAi) {
|
||||
try {
|
||||
const tree = await client.getAccessibleTree('claude.ai');
|
||||
axFingerprint = tree
|
||||
.filter((n) => !n.ignored && n.role && n.name)
|
||||
.map((n) => ({
|
||||
role: n.role!.value,
|
||||
name: n.name!.value,
|
||||
hasPopup: !!n.properties?.find((p) => p.name === 'haspopup'),
|
||||
}))
|
||||
.filter((n) => n.name.length > 0);
|
||||
} catch (e) {
|
||||
gaps.push(
|
||||
`renderer-ax: getAccessibleTree threw: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
gaps.push(
|
||||
'renderer-ax: no claude.ai webContents at probe time. ' +
|
||||
'Sign in to the app before re-running to capture renderer state.',
|
||||
);
|
||||
}
|
||||
|
||||
// Tray / SNI registration — T03. Linux tray icons register against
|
||||
// org.kde.StatusNotifierWatcher (KDE protocol used by GNOME's
|
||||
// AppIndicator extension too). We can attribute an SNI item to the
|
||||
// app's pid via `findItemByPid`. Lazily imported because dbus-next
|
||||
// connects on first call to getSessionBus(), and we want
|
||||
// non-DBus environments to still get a partial probe rather than
|
||||
// hard-fail.
|
||||
const ourPid = await client.evalInMain<number>('return process.pid;');
|
||||
let sni: {
|
||||
ourPid: number;
|
||||
registeredItem: { service: string; objectPath: string } | null;
|
||||
probeError: string | null;
|
||||
} = { ourPid, registeredItem: null, probeError: null };
|
||||
try {
|
||||
const sniLib = await import('./src/lib/sni.js');
|
||||
const dbusLib = await import('./src/lib/dbus.js');
|
||||
try {
|
||||
sni.registeredItem = await sniLib.findItemByPid(ourPid);
|
||||
} finally {
|
||||
await dbusLib.disconnectBus();
|
||||
}
|
||||
} catch (e) {
|
||||
sni.probeError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// T22 PR toolbar / T31 side chat / T32 slash menu — these surfaces
|
||||
// are now captured if the user has the relevant view open at probe
|
||||
// time (see `axFingerprint` above). Empty fingerprint at idle is
|
||||
// expected; flag here only if the renderer was reachable but the
|
||||
// captured tree was empty (which would suggest the AX walker hit
|
||||
// a permission gate or was disabled).
|
||||
if (claudeAi && axFingerprint.length === 0) {
|
||||
gaps.push(
|
||||
'renderer-ax: claude.ai webContents present but AX tree empty. ' +
|
||||
'Either Accessibility was not enabled or the page is mid-load.',
|
||||
);
|
||||
}
|
||||
gaps.push(
|
||||
'T39 /desktop: lives in the upstream `claude` CLI binary, not the ' +
|
||||
'Electron asar — not reachable from this probe.',
|
||||
);
|
||||
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
appVersion: appMeta.appVersion,
|
||||
appPath: appMeta.appPath,
|
||||
isPackaged: appMeta.isPackaged,
|
||||
platform: appMeta.platform,
|
||||
ipcInvokeChannels: ipc.invoke,
|
||||
ipcOnChannels: ipc.on,
|
||||
webContents,
|
||||
axFingerprint,
|
||||
tests: {
|
||||
T01: { appReady: appMeta.appReady, webContentsCount: webContents.length },
|
||||
T03: sni,
|
||||
T06: { accelerators },
|
||||
T09: loginItems,
|
||||
T22: { axFingerprintRef: true, count: axFingerprint.length },
|
||||
T23: notifications,
|
||||
T24: { editorIpcChannels },
|
||||
T26: { axFingerprintRef: true, count: axFingerprint.length },
|
||||
T31: { axFingerprintRef: true, count: axFingerprint.length },
|
||||
T32: { axFingerprintRef: true, count: axFingerprint.length },
|
||||
T38: { editorIpcChannels },
|
||||
S18: safeStorage,
|
||||
S20: powerSaveBlocker,
|
||||
S22: {
|
||||
platform: appMeta.platform,
|
||||
expectedDisabledOnLinux: appMeta.platform === 'linux',
|
||||
},
|
||||
S25: safeStorage,
|
||||
S26: {
|
||||
...autoUpdater,
|
||||
isPackaged: appMeta.isPackaged,
|
||||
platform: appMeta.platform,
|
||||
note: 'Gate is structurally open; saved by Electron autoUpdater being unimplemented on Linux.',
|
||||
},
|
||||
},
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
port: number;
|
||||
out: string;
|
||||
launch: boolean;
|
||||
includeSynthetic: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const flags = new Set<string>();
|
||||
const args = new Map<string, string>();
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (!tok || !tok.startsWith('--')) continue;
|
||||
const key = tok.replace(/^--/, '');
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(key, next);
|
||||
i++;
|
||||
} else {
|
||||
flags.add(key);
|
||||
}
|
||||
}
|
||||
return {
|
||||
port: Number(args.get('port') ?? 9229),
|
||||
out: args.get('out') ?? '/tmp/grounding-probe.json',
|
||||
launch: flags.has('launch'),
|
||||
includeSynthetic: flags.has('include-synthetic'),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const parsed = parseArgs(process.argv);
|
||||
const { out, launch, includeSynthetic } = parsed;
|
||||
|
||||
let client: InspectorClient;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
if (launch) {
|
||||
// Self-contained: fresh isolation per run, tear down on exit.
|
||||
// 'mainVisible' is the lowest level that gives us the inspector
|
||||
// without waiting on claude.ai network load. Sufficient for
|
||||
// every probe in capture() — none touch renderer DOM.
|
||||
const app = await launchClaude();
|
||||
const ready = await app.waitForReady('mainVisible');
|
||||
client = ready.inspector;
|
||||
cleanup = async () => {
|
||||
client.close();
|
||||
await app.close();
|
||||
};
|
||||
} else {
|
||||
client = await InspectorClient.connect(parsed.port);
|
||||
cleanup = async () => {
|
||||
client.close();
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await capture(client, { includeSynthetic });
|
||||
writeFileSync(out, JSON.stringify(result, null, 2));
|
||||
console.log(
|
||||
`grounding-probe: wrote ${out} ` +
|
||||
`(${result.ipcInvokeChannels.length} invoke channels, ` +
|
||||
`${result.webContents.length} webContents, ` +
|
||||
`${result.axFingerprint.length} ax nodes, ` +
|
||||
`${result.gaps.length} gaps` +
|
||||
`${launch ? ', --launch' : ''}` +
|
||||
`${includeSynthetic ? ', synthetic' : ''})`,
|
||||
);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('grounding-probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
108
tools/test-harness/orchestrator/sweep.sh
Executable file
108
tools/test-harness/orchestrator/sweep.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# sweep.sh — run a test sweep for a row.
|
||||
#
|
||||
# Usage:
|
||||
# ROW=KDE-W ./orchestrator/sweep.sh
|
||||
# CLAUDE_DESKTOP_LAUNCHER=/usr/bin/claude-desktop ROW=KDE-W ./orchestrator/sweep.sh
|
||||
#
|
||||
# Output bundle layout:
|
||||
# results/results-${ROW}-${DATE}/
|
||||
# ├── junit.xml
|
||||
# ├── html/ (Playwright HTML report)
|
||||
# └── test-output/ (per-test attachments)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly script_dir
|
||||
harness_dir="$(dirname "$script_dir")"
|
||||
readonly harness_dir
|
||||
|
||||
readonly row="${ROW:-KDE-W}"
|
||||
date_str="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
readonly date_str
|
||||
readonly bundle_id="results-${row}-${date_str}"
|
||||
readonly results_root="${OUTPUT_DIR:-${harness_dir}/results}"
|
||||
readonly bundle_dir="${results_root}/${bundle_id}"
|
||||
|
||||
mkdir -p "$bundle_dir"
|
||||
|
||||
cd "$harness_dir" || exit 1
|
||||
|
||||
# Backend banner. CLAUDE_HARNESS_USE_WAYLAND=1 flips every runner from
|
||||
# the default X11/XWayland backend to native Wayland — see the
|
||||
# "Environment variables" table in tools/test-harness/README.md.
|
||||
if [[ "${CLAUDE_HARNESS_USE_WAYLAND:-}" == '1' ]]; then
|
||||
printf 'sweep: native Wayland backend (CLAUDE_HARNESS_USE_WAYLAND=1)\n' >&2
|
||||
fi
|
||||
|
||||
# Fast-fail prereq checks — only matter when the sweep includes
|
||||
# Quick Entry runners (S31, future S29/S30/S32/S34/S35/S37 +
|
||||
# T06 / QE-* additions). Skip with QE_PREREQ_CHECK=0 if running
|
||||
# a sweep that excludes those.
|
||||
if [[ "${QE_PREREQ_CHECK:-1}" == "1" ]]; then
|
||||
if ! command -v ydotool >/dev/null 2>&1; then
|
||||
printf 'sweep: ydotool not on PATH — Quick Entry runners will skip.\n' >&2
|
||||
printf ' install: dnf install ydotool / apt install ydotool\n' >&2
|
||||
printf ' to suppress this check: QE_PREREQ_CHECK=0\n' >&2
|
||||
fi
|
||||
socket="${YDOTOOL_SOCKET:-/tmp/.ydotool_socket}"
|
||||
if [[ ! -S "$socket" ]]; then
|
||||
printf 'sweep: ydotoold socket missing at %s — daemon not running.\n' \
|
||||
"$socket" >&2
|
||||
printf ' start: sudo systemctl start ydotool.service\n' >&2
|
||||
printf ' see tools/test-harness/README.md "Quick Entry runners" for one-time setup\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
ROW="$row" \
|
||||
RESULTS_DIR="$bundle_dir" \
|
||||
npx playwright test
|
||||
rc=$?
|
||||
|
||||
# Bundle into tar.zst for orchestrator pickup. Best-effort — keep the
|
||||
# uncompressed dir even if zstd is unavailable.
|
||||
if command -v zstd >/dev/null 2>&1; then
|
||||
tar --zstd -cf "${results_root}/${bundle_id}.tar.zst" \
|
||||
-C "$results_root" "$bundle_id" 2>/dev/null \
|
||||
&& printf 'bundle: %s/%s.tar.zst\n' "$results_root" "$bundle_id"
|
||||
fi
|
||||
|
||||
printf 'row=%s exit=%d dir=%s\n' "$row" "$rc" "$bundle_dir"
|
||||
|
||||
# Quick summary if junit.xml landed. Prefer Node so we sum across all
|
||||
# <testsuite> elements (grep+head only saw the first suite, undercounting
|
||||
# multi-suite reports). Fall back to the legacy grep path when node isn't
|
||||
# on PATH so the harness stays usable on minimal images.
|
||||
if [[ -f "${bundle_dir}/junit.xml" ]]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
read -r tests failures errors skipped \
|
||||
< <(node -e "$(cat <<'EOF'
|
||||
const fs = require('fs');
|
||||
const xml = fs.readFileSync(process.argv[1], 'utf8');
|
||||
const sumAttr = (a) => Array.from(
|
||||
xml.matchAll(new RegExp(`<testsuite[^>]*\\b${a}="(\\d+)"`, 'g'))
|
||||
).reduce((s, m) => s + parseInt(m[1], 10), 0);
|
||||
console.log([
|
||||
sumAttr('tests'), sumAttr('failures'),
|
||||
sumAttr('errors'), sumAttr('skipped'),
|
||||
].join(' '));
|
||||
EOF
|
||||
)" "${bundle_dir}/junit.xml")
|
||||
printf 'summary: tests=%s failures=%s errors=%s skipped=%s\n' \
|
||||
"$tests" "$failures" "$errors" "$skipped"
|
||||
elif command -v grep >/dev/null 2>&1; then
|
||||
tests="$(grep -oP 'tests="\K\d+' "${bundle_dir}/junit.xml" \
|
||||
| head -1 || printf '?')"
|
||||
failures="$(grep -oP 'failures="\K\d+' "${bundle_dir}/junit.xml" \
|
||||
| head -1 || printf '?')"
|
||||
errors="$(grep -oP 'errors="\K\d+' "${bundle_dir}/junit.xml" \
|
||||
| head -1 || printf '?')"
|
||||
skipped="$(grep -oP 'skipped="\K\d+' "${bundle_dir}/junit.xml" \
|
||||
| head -1 || printf '?')"
|
||||
printf 'summary: tests=%s failures=%s errors=%s skipped=%s\n' \
|
||||
"$tests" "$failures" "$errors" "$skipped"
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$rc"
|
||||
26
tools/test-harness/package.json
Normal file
26
tools/test-harness/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "claude-desktop-debian-test-harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Linux compatibility test harness for claude-desktop-debian",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"sweep": "bash orchestrator/sweep.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"grounding-probe": "npx tsx grounding-probe.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^20.16.0",
|
||||
"playwright": "^1.48.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.10",
|
||||
"dbus-next": "^0.10.2"
|
||||
}
|
||||
}
|
||||
25
tools/test-harness/playwright.config.ts
Normal file
25
tools/test-harness/playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/// <reference types="node" />
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const resultsDir = process.env.RESULTS_DIR ?? './results/local';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src/runners',
|
||||
testMatch: /.*\.spec\.ts$/,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
forbidOnly: !!process.env.CI,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
outputDir: `${resultsDir}/test-output`,
|
||||
reporter: [
|
||||
['list'],
|
||||
['junit', { outputFile: `${resultsDir}/junit.xml` }],
|
||||
['html', { outputFolder: `${resultsDir}/html`, open: 'never' }],
|
||||
],
|
||||
use: {
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
});
|
||||
163
tools/test-harness/probe.ts
Normal file
163
tools/test-harness/probe.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// Standalone probe that connects to a running claude-desktop with the
|
||||
// main process debugger enabled (port 9229) and dumps renderer-DOM
|
||||
// shapes useful for designing reusable abstractions in lib/claudeai.ts.
|
||||
//
|
||||
// Run from tools/test-harness:
|
||||
// npx tsx probe.ts
|
||||
//
|
||||
// Non-destructive — observes only, doesn't click anything.
|
||||
|
||||
import { InspectorClient } from './src/lib/inspector.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
async function main() {
|
||||
const client = await InspectorClient.connect(9229);
|
||||
|
||||
const webContentsList = await client.evalInMain<
|
||||
Array<{ id: number; url: string; type: string }>
|
||||
>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
return webContents.getAllWebContents().map(w => ({
|
||||
id: w.id,
|
||||
url: w.getURL(),
|
||||
type: w.getType ? w.getType() : 'unknown',
|
||||
}));
|
||||
`);
|
||||
|
||||
const target = webContentsList.find((w) => w.url.includes('claude.ai'));
|
||||
if (!target) {
|
||||
console.error('No claude.ai webContents — open the app to a logged-in state first.');
|
||||
console.error('webContents observed:', webContentsList);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('=== webContents ===');
|
||||
console.log(JSON.stringify(webContentsList, null, 2));
|
||||
console.log('Targeting:', target.url, `(id=${target.id})`);
|
||||
|
||||
// All "pill"-shape buttons on the page.
|
||||
const pills = await client.evalInRenderer<{
|
||||
dfPills: Array<{ ariaLabel: string | null; text: string; visible: boolean; classSig: string }>;
|
||||
menuButtons: Array<{
|
||||
ariaLabel: string | null;
|
||||
text: string;
|
||||
expanded: boolean;
|
||||
truncateMaxW: string | null;
|
||||
classSig: string;
|
||||
}>;
|
||||
summary: { totalButtons: number; ariaHaspopupMenu: number; dfPills: number };
|
||||
}>(
|
||||
'claude.ai',
|
||||
`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const dfPills = buttons
|
||||
.filter(b => /\\bdf-pill\\b/.test(b.className))
|
||||
.map(b => ({
|
||||
ariaLabel: b.getAttribute('aria-label'),
|
||||
text: (b.textContent || '').trim().slice(0, 80),
|
||||
visible: !!b.getClientRects().length,
|
||||
classSig: b.className.slice(0, 120),
|
||||
}));
|
||||
const menuButtons = buttons
|
||||
.filter(b => b.getAttribute('aria-haspopup') === 'menu')
|
||||
.map(b => {
|
||||
const truncSpan = b.querySelector('span.truncate');
|
||||
const maxW = truncSpan
|
||||
? (truncSpan.className.match(/max-w-\\[[^\\]]+\\]/) || [null])[0]
|
||||
: null;
|
||||
return {
|
||||
ariaLabel: b.getAttribute('aria-label'),
|
||||
text: (b.textContent || '').trim().slice(0, 80),
|
||||
expanded: b.getAttribute('aria-expanded') === 'true',
|
||||
truncateMaxW: maxW,
|
||||
classSig: b.className.slice(0, 120),
|
||||
};
|
||||
});
|
||||
return {
|
||||
dfPills,
|
||||
menuButtons,
|
||||
summary: {
|
||||
totalButtons: buttons.length,
|
||||
ariaHaspopupMenu: menuButtons.length,
|
||||
dfPills: dfPills.length,
|
||||
},
|
||||
};
|
||||
})()
|
||||
`,
|
||||
);
|
||||
|
||||
console.log('\n=== Pills summary ===');
|
||||
console.log(JSON.stringify(pills.summary, null, 2));
|
||||
|
||||
console.log('\n=== df-pill buttons ===');
|
||||
console.log(JSON.stringify(pills.dfPills, null, 2));
|
||||
|
||||
console.log('\n=== aria-haspopup=menu buttons (sample) ===');
|
||||
console.log(JSON.stringify(pills.menuButtons.slice(0, 10), null, 2));
|
||||
|
||||
// Currently open menu (if any) — items, structure.
|
||||
const openMenu = await client.evalInRenderer<{
|
||||
menuPresent: boolean;
|
||||
ariaLabelledBy: string | null;
|
||||
items: Array<{ role: string; text: string; ariaChecked: string | null; disabled: boolean }>;
|
||||
} | null>(
|
||||
'claude.ai',
|
||||
`
|
||||
(() => {
|
||||
const menu = document.querySelector('[role=menu][data-open]') || document.querySelector('[role=menu]');
|
||||
if (!menu) return null;
|
||||
const items = Array.from(menu.querySelectorAll('[role=menuitem], [role=menuitemradio], [role=menuitemcheckbox]'))
|
||||
.map(el => ({
|
||||
role: el.getAttribute('role') || '',
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
ariaChecked: el.getAttribute('aria-checked'),
|
||||
disabled: el.hasAttribute('data-disabled') || el.getAttribute('aria-disabled') === 'true',
|
||||
}));
|
||||
return {
|
||||
menuPresent: true,
|
||||
ariaLabelledBy: menu.getAttribute('aria-labelledby'),
|
||||
items,
|
||||
};
|
||||
})()
|
||||
`,
|
||||
);
|
||||
|
||||
console.log('\n=== Currently open menu ===');
|
||||
console.log(openMenu ? JSON.stringify(openMenu, null, 2) : 'no menu open');
|
||||
|
||||
// URL and basic page state.
|
||||
const pageState = await client.evalInRenderer<{
|
||||
url: string;
|
||||
title: string;
|
||||
readyState: string;
|
||||
hasComposer: boolean;
|
||||
hasSidebar: boolean;
|
||||
}>(
|
||||
'claude.ai',
|
||||
`
|
||||
(() => ({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
hasComposer: !!document.querySelector('[data-testid*=composer], textarea[placeholder*=Reply], textarea[placeholder*=Message]'),
|
||||
hasSidebar: !!document.querySelector('nav, [role=navigation]'),
|
||||
}))()
|
||||
`,
|
||||
);
|
||||
|
||||
console.log('\n=== Page state ===');
|
||||
console.log(JSON.stringify(pageState, null, 2));
|
||||
|
||||
const out = { webContentsList, pills, openMenu, pageState };
|
||||
writeFileSync('/tmp/claude-probe.json', JSON.stringify(out, null, 2));
|
||||
console.log('\nFull dump → /tmp/claude-probe.json');
|
||||
|
||||
client.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
44
tools/test-harness/src/lib/argv.ts
Normal file
44
tools/test-harness/src/lib/argv.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Read a process's argv from /proc/<pid>/cmdline.
|
||||
//
|
||||
// /proc/<pid>/cmdline is a single string of NUL-separated args (no
|
||||
// trailing NUL on most kernels; trim defensively). Used by QE-6 / S12
|
||||
// to verify the launcher appended the right Electron flags, and by
|
||||
// future flag-presence tests (Decision 6 Wayland-default Smoke, S07
|
||||
// CLAUDE_USE_WAYLAND, etc.).
|
||||
//
|
||||
// readPidArgv returns null if the process is gone — callers usually
|
||||
// want to retry until the pid stabilizes.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
export async function readPidArgv(pid: number): Promise<string[] | null> {
|
||||
try {
|
||||
const raw = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
||||
// Strip trailing NUL if present, then split. Empty argv is
|
||||
// theoretically possible (kernel threads); preserve it.
|
||||
const trimmed = raw.endsWith('\0') ? raw.slice(0, -1) : raw;
|
||||
return trimmed.length === 0 ? [] : trimmed.split('\0');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function argvHasFlag(argv: string[], flag: string): boolean {
|
||||
// Matches `--enable-features=GlobalShortcutsPortal` (full equality)
|
||||
// and `--enable-features` (bare flag, value in next argv slot).
|
||||
// Substring match handles `--enable-features=Foo,Bar` correctly when
|
||||
// flag is `--enable-features=Foo`.
|
||||
for (const arg of argv) {
|
||||
if (arg === flag) return true;
|
||||
if (arg.startsWith(`${flag}=`)) return true;
|
||||
// Comma-separated --enable-features value: match any subkey.
|
||||
if (flag.includes('=')) {
|
||||
const [key, val] = flag.split('=', 2);
|
||||
if (arg.startsWith(`${key}=`)) {
|
||||
const values = arg.slice(key!.length + 1).split(',');
|
||||
if (values.includes(val!)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
55
tools/test-harness/src/lib/asar.ts
Normal file
55
tools/test-harness/src/lib/asar.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Read files out of the installed app.asar without on-disk extraction.
|
||||
//
|
||||
// Used by QE-19 / S09 (verify the KDE-gate string is in the bundled
|
||||
// JS) and by future patch-sanity tests for tray.sh / cowork.sh /
|
||||
// claude-code.sh patches. Reading via @electron/asar avoids the
|
||||
// `npx asar extract /tmp/inspect-installed` dance — same outcome, no
|
||||
// temp tree, JSON-grepable from inside a TS spec.
|
||||
//
|
||||
// Path resolution mirrors lib/electron.ts:resolveInstall(): respect
|
||||
// CLAUDE_DESKTOP_APP_ASAR if set, otherwise probe the deb and rpm
|
||||
// install locations.
|
||||
|
||||
import { extractFile, listPackage } from '@electron/asar';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const DEFAULT_ASAR_PATHS = [
|
||||
'/usr/lib/claude-desktop/app.asar',
|
||||
'/opt/Claude/resources/app.asar',
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar',
|
||||
'/opt/Claude/node_modules/electron/dist/resources/app.asar',
|
||||
];
|
||||
|
||||
export function resolveAsarPath(): string {
|
||||
const env = process.env.CLAUDE_DESKTOP_APP_ASAR;
|
||||
if (env) return env;
|
||||
for (const candidate of DEFAULT_ASAR_PATHS) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(
|
||||
'Could not locate app.asar. Set CLAUDE_DESKTOP_APP_ASAR or install ' +
|
||||
'the deb/rpm package.',
|
||||
);
|
||||
}
|
||||
|
||||
export function readAsarFile(filename: string, asarPath?: string): string {
|
||||
const archive = asarPath ?? resolveAsarPath();
|
||||
const buf = extractFile(archive, filename);
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
|
||||
export function asarContains(
|
||||
filename: string,
|
||||
needle: string | RegExp,
|
||||
asarPath?: string,
|
||||
): boolean {
|
||||
const contents = readAsarFile(filename, asarPath);
|
||||
return typeof needle === 'string'
|
||||
? contents.includes(needle)
|
||||
: needle.test(contents);
|
||||
}
|
||||
|
||||
export function listAsar(asarPath?: string): string[] {
|
||||
const archive = asarPath ?? resolveAsarPath();
|
||||
return listPackage(archive, { isPack: false });
|
||||
}
|
||||
440
tools/test-harness/src/lib/ax.ts
Normal file
440
tools/test-harness/src/lib/ax.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
// AX-tree loading + traversal primitives — shared substrate for any
|
||||
// test that reads from Chromium's accessibility tree.
|
||||
//
|
||||
// Why this exists
|
||||
// ---------------
|
||||
// Sessions 1-12 grew two parallel AX consumers without consolidating
|
||||
// the loading shape:
|
||||
//
|
||||
// 1. `lib/claudeai.ts` page-objects (CodeTab.activate, openPill,
|
||||
// clickMenuItem, findCompactPills) carry a private `snapshotAx`
|
||||
// that gates on `waitForAxTreeStable` then calls
|
||||
// `inspector.getAccessibleTree('claude.ai')` and converts via
|
||||
// `axTreeToSnapshot`. Every page-object that polls for a node
|
||||
// rolls its own retryUntil/while loop around that helper.
|
||||
//
|
||||
// 2. `src/runners/T26_routines_page_renders.spec.ts` re-implemented
|
||||
// the same `snapshotAx` shape inline because the claudeai.ts
|
||||
// version isn't exported. Its leading comment explicitly noted
|
||||
// this was "premature abstraction" at 1 consumer; with 2 it is
|
||||
// threshold-driven extraction.
|
||||
//
|
||||
// Plus the user reports recurring flake in tests that use the AX tree:
|
||||
// queries fire before the relevant subtree is mounted, and individual
|
||||
// specs each pick their own retryUntil budget. The proposed
|
||||
// `waitForAxNode` primitive collapses the snapshot+find+retry shape
|
||||
// into one helper with a single tunable budget per consumer, reducing
|
||||
// both the surface area for budget drift and the duplication.
|
||||
//
|
||||
// What this primitive does
|
||||
// ------------------------
|
||||
// - `snapshotAx(inspector, opts)` — single AX tree read with the
|
||||
// stability gate. Replaces the duplicated implementations in
|
||||
// `claudeai.ts` (private) and `T26_routines_page_renders.spec.ts`
|
||||
// (inlined). `opts.fast` skips the stability gate for inside-poll
|
||||
// callers (matches the existing claudeai.ts contract).
|
||||
// - `waitForAxNode(inspector, predicate, opts)` — repeatedly snapshot
|
||||
// the AX tree and return the first element matching `predicate`,
|
||||
// subject to a timeout. Built against the loops in `CodeTab.activate`
|
||||
// (poll for compact pills), `openPill` (poll for menu items),
|
||||
// `clickMenuItem` (poll for matching menuitem), and T26's pre/post-
|
||||
// click anchor scans. The predicate carries the discrimination
|
||||
// logic the caller already had inline; the primitive owns the
|
||||
// stability-gate + retry loop.
|
||||
// - Owns the AX-snapshot substrate: `RawElement`, `axTreeToSnapshot`,
|
||||
// and `waitForAxTreeStable`. These are the runner-facing surface for
|
||||
// converting Chromium's `Accessibility.getFullAXTree` output into
|
||||
// a flat snapshot the page-objects and specs can search.
|
||||
//
|
||||
// Scope boundaries
|
||||
// ----------------
|
||||
// This is NOT a "wait for surface rendered" registry. The plan-doc
|
||||
// proposal mentioned `waitForRenderedSurface(client, surfaceKey)`
|
||||
// with a registry of named surface anchors — that's still
|
||||
// speculative (no consumer asks for it). When a third consumer
|
||||
// emerges that already knows it wants a named surface anchor (e.g.
|
||||
// "the Code tab body has mounted"), promote the relevant claudeai.ts
|
||||
// page-object into a registry entry. Today, `waitForAxNode` with a
|
||||
// predicate covers every observed callsite.
|
||||
//
|
||||
// This is also NOT a CSS-querySelector primitive. T07 polls the DOM
|
||||
// via `document.querySelector('[data-testid=...]')` for the topbar;
|
||||
// that's a different abstraction (DOM, not AX) with no extraction
|
||||
// signal yet — leave it inline in T07 until a second consumer
|
||||
// surfaces.
|
||||
|
||||
import type { AxNode, InspectorClient } from './inspector.js';
|
||||
import { retryUntil, sleep } from './retry.js';
|
||||
|
||||
export type { AxNode } from './inspector.js';
|
||||
|
||||
// Outermost-to-innermost AX ancestor chain. `walkLandmarkAncestors`
|
||||
// (in lib/claudeai.ts) filters this to the landmark / grouping subset
|
||||
// for fingerprint paths.
|
||||
interface RawAncestor {
|
||||
role: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface RawElement {
|
||||
// Per-element data sourced from Chromium's accessibility tree.
|
||||
// `computedRole` is `AxNode.role.value` — the platform-computed role
|
||||
// rather than the tag-derived one, so `<button role="link">` is a
|
||||
// link.
|
||||
computedRole: string;
|
||||
// Accessible name as the AX tree computed it. Single source of
|
||||
// truth for the leaf's identity — there is no separate aria-label
|
||||
// / text-content fallback.
|
||||
accessibleName: string | null;
|
||||
// `!ignored` from the AX tree. The walker filters ignored nodes
|
||||
// out at snapshot construction time, so this is always true post-
|
||||
// filter; kept on the type so resolver-side code can still gate
|
||||
// on it without special-casing AX-derived inputs.
|
||||
visible: boolean;
|
||||
// Any landmark dialog / alertdialog ancestor in the AX path.
|
||||
insideModalDialog: boolean;
|
||||
// Outermost-to-innermost AX ancestor chain (excluding the element
|
||||
// itself and any ignored nodes).
|
||||
ancestors: RawAncestor[];
|
||||
// Among the parent AX node's non-ignored children that share this
|
||||
// element's computed role, where does it sit and how many siblings
|
||||
// of that role exist?
|
||||
siblingPosition: number;
|
||||
siblingTotal: number;
|
||||
// `AxNode.backendDOMNodeId`. Required for the click path
|
||||
// (`DOM.resolveNode` → `Runtime.callFunctionOn`); null only on AX
|
||||
// nodes that don't back a DOM element (which won't reach this
|
||||
// list, since interactive ARIA roles always do).
|
||||
backendDOMNodeId: number | null;
|
||||
// AX `haspopup` token (`<button aria-haspopup="menu">` →
|
||||
// `'menu'`). null when the property is absent or its value is the
|
||||
// literal string `'false'`. Surfaced for claudeai.ts page-objects,
|
||||
// which use it to discriminate menu triggers from ordinary action
|
||||
// buttons that happen to share an accessible name.
|
||||
hasPopup: string | null;
|
||||
}
|
||||
|
||||
// Roles we treat as "interactive leaves" — emitted to the snapshot
|
||||
// and used as queue seeds. Expressed in AX-role terms so
|
||||
// `<button role="link">` shows up as `link`, which is what AX reports.
|
||||
const INTERACTIVE_AX_ROLES = new Set<string>([
|
||||
'button',
|
||||
'link',
|
||||
'menuitem',
|
||||
'menuitemradio',
|
||||
'menuitemcheckbox',
|
||||
'tab',
|
||||
'option',
|
||||
]);
|
||||
|
||||
// Roles that indicate a dialog ancestor; any such ancestor flips
|
||||
// `insideModalDialog`.
|
||||
const DIALOG_AX_ROLES = new Set<string>(['dialog', 'alertdialog']);
|
||||
|
||||
// Pull the AX `hasPopup` token out of `node.properties[]`. CDP
|
||||
// exposes it as `{ name: 'hasPopup', value: { type: 'token', value:
|
||||
// 'menu' } }` on supporting elements (note the camelCase — the
|
||||
// underlying ARIA attribute is `aria-haspopup` lowercase, but
|
||||
// Chromium's AXProperty name is `hasPopup`). Absent properties array,
|
||||
// missing entry, or the literal string `'false'` all collapse to
|
||||
// `null` so consumers don't have to special-case those.
|
||||
function readHasPopup(node: AxNode): string | null {
|
||||
const props = node.properties;
|
||||
if (!Array.isArray(props)) return null;
|
||||
for (const p of props) {
|
||||
if (p?.name !== 'hasPopup') continue;
|
||||
const v = p.value?.value;
|
||||
if (typeof v !== 'string') return null;
|
||||
if (v === '' || v === 'false') return null;
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `axTreeToSnapshot` adapts CDP's `Accessibility.getFullAXTree`
|
||||
// output into the RawElement shape the rest of the harness consumes.
|
||||
// Filtering rules:
|
||||
// - `ignored` nodes are dropped from emission and from sibling
|
||||
// counts (they're not exposed to assistive tech and we don't want
|
||||
// to drill into them either). Their children remain visible to
|
||||
// the ancestor walk via the raw tree links.
|
||||
// - Only nodes whose `role.value` is in `INTERACTIVE_AX_ROLES` get
|
||||
// emitted as elements. Everything else (RootWebArea, generics,
|
||||
// paragraphs) shows up only as ancestors.
|
||||
export function axTreeToSnapshot(nodes: AxNode[]): RawElement[] {
|
||||
const byId = new Map<string, AxNode>();
|
||||
for (const n of nodes) byId.set(n.nodeId, n);
|
||||
|
||||
const childrenById = new Map<string, AxNode[]>();
|
||||
for (const n of nodes) {
|
||||
if (n.parentId === undefined) continue;
|
||||
let arr = childrenById.get(n.parentId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
childrenById.set(n.parentId, arr);
|
||||
}
|
||||
arr.push(n);
|
||||
}
|
||||
|
||||
const ancestorName = (n: AxNode): string | null => {
|
||||
const v = n.name?.value;
|
||||
return v && v.trim().length > 0 ? v : null;
|
||||
};
|
||||
|
||||
const out: RawElement[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.ignored === true) continue;
|
||||
const role = node.role?.value;
|
||||
if (!role || !INTERACTIVE_AX_ROLES.has(role)) continue;
|
||||
|
||||
const accessibleName = ancestorName(node);
|
||||
|
||||
const ancestors: RawAncestor[] = [];
|
||||
let modal = false;
|
||||
{
|
||||
let pid = node.parentId;
|
||||
while (pid !== undefined) {
|
||||
const p = byId.get(pid);
|
||||
if (!p) break;
|
||||
if (p.ignored !== true) {
|
||||
const arole = p.role?.value ?? null;
|
||||
ancestors.push({ role: arole, name: ancestorName(p) });
|
||||
if (arole && DIALOG_AX_ROLES.has(arole)) modal = true;
|
||||
}
|
||||
pid = p.parentId;
|
||||
}
|
||||
}
|
||||
ancestors.reverse();
|
||||
|
||||
let siblingPosition = 0;
|
||||
let siblingTotal = 1;
|
||||
if (node.parentId !== undefined) {
|
||||
const sibs = (childrenById.get(node.parentId) ?? []).filter(
|
||||
(c) => c.ignored !== true && c.role?.value === role,
|
||||
);
|
||||
const idx = sibs.indexOf(node);
|
||||
if (idx >= 0) {
|
||||
siblingPosition = idx;
|
||||
siblingTotal = Math.max(sibs.length, 1);
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
computedRole: role,
|
||||
accessibleName,
|
||||
visible: true,
|
||||
insideModalDialog: modal,
|
||||
ancestors,
|
||||
siblingPosition,
|
||||
siblingTotal,
|
||||
backendDOMNodeId: node.backendDOMNodeId ?? null,
|
||||
hasPopup: readHasPopup(node),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wait for the AX tree to stop growing/shrinking — two consecutive
|
||||
// reads at the same node count means Chromium has finished computing
|
||||
// the accessibility tree for the current DOM. Used by the seed phase
|
||||
// because:
|
||||
// 1. `Accessibility.enable` is implicit on the first
|
||||
// `getFullAXTree` call, and the very first tree is often a
|
||||
// partial computation.
|
||||
// 2. claude.ai's SPA mounts ~5–8s after the renderer signals
|
||||
// `claudeAi` ready; a snapshot taken too early reliably sees an
|
||||
// empty surface.
|
||||
// Cheap to call (≥800ms when already stable, on the order of seconds
|
||||
// when not).
|
||||
export async function waitForAxTreeStable(
|
||||
inspector: InspectorClient,
|
||||
opts: { timeoutMs?: number; pollMs?: number; minNodes?: number } = {},
|
||||
): Promise<number> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30000;
|
||||
const pollMs = opts.pollMs ?? 400;
|
||||
const minNodes = opts.minNodes ?? 1;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let prevSize = -1;
|
||||
let stableReads = 0;
|
||||
let lastSize = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const nodes = await inspector.getAccessibleTree('claude.ai');
|
||||
lastSize = nodes.length;
|
||||
if (lastSize === prevSize && lastSize >= minNodes) {
|
||||
stableReads += 1;
|
||||
if (stableReads >= 2) return lastSize;
|
||||
} else {
|
||||
stableReads = 0;
|
||||
prevSize = lastSize;
|
||||
}
|
||||
if (Date.now() < deadline) await sleep(pollMs);
|
||||
}
|
||||
return lastSize;
|
||||
}
|
||||
|
||||
|
||||
export interface SnapshotAxOptions {
|
||||
// Skip the upfront `waitForAxTreeStable` gate. Default false —
|
||||
// i.e. callers gate by default. Pass true inside polling loops
|
||||
// where the gate fights the loop: each iteration would block
|
||||
// waiting for "no node-count change" even when the change we're
|
||||
// polling for is exactly the AX tree updating.
|
||||
//
|
||||
// `waitForAxNode` itself uses fast=true on every iteration after
|
||||
// gating once at the start; consumers calling `snapshotAx` from
|
||||
// inside a hand-rolled loop should do the same.
|
||||
fast?: boolean;
|
||||
// AX-stability gate budget when `fast` is false. Default 10000ms
|
||||
// — matches the existing claudeai.ts/T26 inline implementations.
|
||||
// Increase for cold-cache cases on slow machines.
|
||||
stabilityTimeoutMs?: number;
|
||||
// Renderer URL filter for `inspector.getAccessibleTree`. Default
|
||||
// 'claude.ai'. Tests against a different webContents (find_in_page,
|
||||
// main_window) can override but the AX tree on those is much
|
||||
// simpler — `claude.ai` is the only one current consumers care
|
||||
// about.
|
||||
urlFilter?: string;
|
||||
}
|
||||
|
||||
// Single AX-tree read, returning the walker's flat RawElement[]
|
||||
// snapshot. Identical contract to the private `snapshotAx` formerly in
|
||||
// `claudeai.ts` and the inlined one formerly in T26 — extracted here
|
||||
// so both consumers share an implementation.
|
||||
//
|
||||
// Cost: ~800ms when the stability gate hits "stable" on the first
|
||||
// pair of reads (interior-loop fast=true callers skip this); a few
|
||||
// seconds on cold-cache. The AX tree itself is comparatively cheap
|
||||
// to fetch and convert (~50-100ms).
|
||||
export async function snapshotAx(
|
||||
inspector: InspectorClient,
|
||||
opts: SnapshotAxOptions = {},
|
||||
): Promise<RawElement[]> {
|
||||
if (!opts.fast) {
|
||||
await waitForAxTreeStable(inspector, {
|
||||
minNodes: 1,
|
||||
timeoutMs: opts.stabilityTimeoutMs ?? 10_000,
|
||||
});
|
||||
}
|
||||
const url = opts.urlFilter ?? 'claude.ai';
|
||||
const nodes: AxNode[] = await inspector.getAccessibleTree(url);
|
||||
return axTreeToSnapshot(nodes);
|
||||
}
|
||||
|
||||
export interface WaitForAxNodeOptions {
|
||||
// Total budget for the polling loop. Default 5000ms — matches the
|
||||
// claudeai.ts / T26 callsites that the primitive replaces. Override
|
||||
// upward for cold-cache or post-click cases (T26 uses 10s post-
|
||||
// click; CodeTab.activate uses 5s default but T16 passes 15s).
|
||||
timeoutMs?: number;
|
||||
// Per-iteration interval. Default 200ms — matches the existing
|
||||
// inline retryUntil({ interval: 200 }) calls. The AX tree fetch
|
||||
// itself dominates the loop cost; a shorter interval gives no
|
||||
// throughput benefit and a longer one delays the resolution.
|
||||
intervalMs?: number;
|
||||
// Renderer URL filter passed through to `snapshotAx`. Default
|
||||
// 'claude.ai'.
|
||||
urlFilter?: string;
|
||||
// Whether to gate on `waitForAxTreeStable` once before entering
|
||||
// the poll loop. Default true. When the caller has just mutated
|
||||
// the page (e.g. clicked a button and is waiting for the
|
||||
// resulting menu to render) the upfront stability gate is what
|
||||
// keeps the first iteration from racing the in-flight render.
|
||||
// After the upfront gate, every iteration uses fast=true so the
|
||||
// loop iterates without re-blocking on stability.
|
||||
stabilityGate?: boolean;
|
||||
// AX-stability gate budget for the upfront `waitForAxTreeStable`
|
||||
// when `stabilityGate` is true. Default 5000ms. Independent from
|
||||
// the outer poll budget — the gate is a hard precondition, not
|
||||
// part of the find loop.
|
||||
stabilityTimeoutMs?: number;
|
||||
}
|
||||
|
||||
// Poll the AX tree until the predicate matches a node, or the budget
|
||||
// runs out. Returns the matched RawElement on success, null on
|
||||
// timeout.
|
||||
//
|
||||
// The predicate runs over RawElement (the walker-snapshot shape) so
|
||||
// callers can use the same `el.computedRole === 'button' &&
|
||||
// el.accessibleName === 'Code'` form they already have inline. The
|
||||
// helper does NOT click the matched node — callers receive the
|
||||
// RawElement and can pass `el.backendDOMNodeId` to
|
||||
// `inspector.clickByBackendNodeId` if a click follows. Keeping click
|
||||
// out of the find primitive lets composite consumers (e.g. "find then
|
||||
// click then poll for the menu") chain cleanly.
|
||||
//
|
||||
// On timeout, returns null. Callers that want a hard fail with a
|
||||
// diagnostic should pattern-match `if (!found) throw new Error(...)`
|
||||
// — the primitive doesn't throw because some specs surface
|
||||
// missing-node as a clean fail with a JSON snapshot attachment
|
||||
// rather than an uncaught timeout.
|
||||
//
|
||||
// The `name` param is purely for diagnostic message hygiene if a
|
||||
// consumer wraps a throw around the null return — it's appended to
|
||||
// the implicit "looking for a node matching <predicate>" so failure
|
||||
// logs read meaningfully. Optional; pass an empty string to suppress.
|
||||
export async function waitForAxNode(
|
||||
inspector: InspectorClient,
|
||||
predicate: (el: RawElement) => boolean,
|
||||
opts: WaitForAxNodeOptions = {},
|
||||
): Promise<RawElement | null> {
|
||||
const stabilityGate = opts.stabilityGate ?? true;
|
||||
if (stabilityGate) {
|
||||
await waitForAxTreeStable(inspector, {
|
||||
minNodes: 1,
|
||||
timeoutMs: opts.stabilityTimeoutMs ?? 5_000,
|
||||
});
|
||||
}
|
||||
return retryUntil(
|
||||
async () => {
|
||||
const elements = await snapshotAx(inspector, {
|
||||
fast: true,
|
||||
urlFilter: opts.urlFilter,
|
||||
});
|
||||
return elements.find(predicate) ?? null;
|
||||
},
|
||||
{
|
||||
timeout: opts.timeoutMs ?? 5_000,
|
||||
interval: opts.intervalMs ?? 200,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Same shape as `waitForAxNode` but returns every match rather than
|
||||
// the first. Useful for consumers that want to enumerate all menu
|
||||
// items or all compact pills after a stability point — the
|
||||
// findCompactPills caller in claudeai.ts is a one-shot snapshot
|
||||
// today, but if a consumer needs to wait for "at least one compact
|
||||
// pill" plus enumerate the resulting set, this avoids a second
|
||||
// round-trip.
|
||||
//
|
||||
// Returns the (possibly empty) array on success, null on timeout
|
||||
// when no element ever matched. A successful call with zero matches
|
||||
// is impossible by construction — the loop only resolves once the
|
||||
// post-filter array is non-empty.
|
||||
export async function waitForAxNodes(
|
||||
inspector: InspectorClient,
|
||||
predicate: (el: RawElement) => boolean,
|
||||
opts: WaitForAxNodeOptions = {},
|
||||
): Promise<RawElement[] | null> {
|
||||
const stabilityGate = opts.stabilityGate ?? true;
|
||||
if (stabilityGate) {
|
||||
await waitForAxTreeStable(inspector, {
|
||||
minNodes: 1,
|
||||
timeoutMs: opts.stabilityTimeoutMs ?? 5_000,
|
||||
});
|
||||
}
|
||||
return retryUntil(
|
||||
async () => {
|
||||
const elements = await snapshotAx(inspector, {
|
||||
fast: true,
|
||||
urlFilter: opts.urlFilter,
|
||||
});
|
||||
const matches = elements.filter(predicate);
|
||||
return matches.length > 0 ? matches : null;
|
||||
},
|
||||
{
|
||||
timeout: opts.timeoutMs ?? 5_000,
|
||||
interval: opts.intervalMs ?? 200,
|
||||
},
|
||||
);
|
||||
}
|
||||
397
tools/test-harness/src/lib/claudeai.ts
Normal file
397
tools/test-harness/src/lib/claudeai.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
// claude.ai renderer-UI domain wrapper — single point of coupling to
|
||||
// upstream's accessibility tree for tests that drive the renderer.
|
||||
//
|
||||
// Why centralize: claude.ai's UI ships from a different release train
|
||||
// than the Electron shell, so any cross-spec drift would be an N-file
|
||||
// fix. Confining the discovery here means the rest of the harness can
|
||||
// speak in domain verbs (`activate('Code')`, `openEnvPill()`, …) and
|
||||
// we only retune one file when upstream drifts.
|
||||
//
|
||||
// Discovery substrate is Chromium's accessibility tree
|
||||
// (`Accessibility.getFullAXTree` over CDP), shared with the v7 walker.
|
||||
// Reading from AX rather than the DOM means the page-objects survive
|
||||
// tailwind class regeneration and React-tree restructuring as long as
|
||||
// the platform-computed role + accessible name + ancestor landmarks
|
||||
// stay stable. See docs/learnings/test-harness-ax-tree-walker.md for
|
||||
// the gotchas (AX-enable async lag, post-click stability gating, list
|
||||
// virtualization).
|
||||
//
|
||||
// Discrimination shapes used:
|
||||
// - Top-level tabs: `role: 'button'` whose accessibleName matches
|
||||
// the literal tab label ('Chat' | 'Cowork' | 'Code'). The
|
||||
// `df-pill` tailwind anchor and `aria-label` selector are gone —
|
||||
// the AX-computed name is the durable contract.
|
||||
// - Compact pills (the env pill on Code, the "Select folder…" pill
|
||||
// after Local is chosen): `role: 'button'` with
|
||||
// `hasPopup === 'menu'`, scoped away from the cowork sidebar by
|
||||
// filtering out per-row `^More options for ` triggers. The visible
|
||||
// label is the button's accessibleName.
|
||||
// - Menu items: any of `menuitem` / `menuitemradio` /
|
||||
// `menuitemcheckbox` (collected as MENU_ITEM_ROLES below).
|
||||
|
||||
import type { InspectorClient } from './inspector.js';
|
||||
import {
|
||||
snapshotAx,
|
||||
waitForAxNode,
|
||||
waitForAxNodes,
|
||||
waitForAxTreeStable,
|
||||
} from './ax.js';
|
||||
import { retryUntil, sleep } from './retry.js';
|
||||
|
||||
// All three CDP-exposed menu-item variants. Caller code wants to treat
|
||||
// them uniformly — radios and checkboxes are still "items in an open
|
||||
// menu the user can pick".
|
||||
const MENU_ITEM_ROLES = new Set<string>([
|
||||
'menuitem',
|
||||
'menuitemradio',
|
||||
'menuitemcheckbox',
|
||||
]);
|
||||
|
||||
// AccessibleName patterns that indicate a per-row trigger button on
|
||||
// the cowork sidebar (~70+ of them on a busy account). They share the
|
||||
// same `hasPopup: 'menu'` signal as the compact pills we actually
|
||||
// want, so excluding them by name is the load-bearing discriminator.
|
||||
const ROW_MORE_OPTIONS_RE = /^More options for /;
|
||||
|
||||
// `snapshotAx` and the stability gate are now in `lib/ax.ts` —
|
||||
// extracted there in session 13 once T26 had to redefine the same
|
||||
// helper inline (two consumers = threshold-driven extraction). Page-
|
||||
// objects below import via the lib aliases; consumers outside this
|
||||
// file should reach for `lib/ax.ts` directly rather than re-importing
|
||||
// through `lib/claudeai.ts`.
|
||||
|
||||
// One of the three top-level pills. Click is fire-and-forget — the
|
||||
// router rerenders the tab body inline (no URL change on Code), so
|
||||
// callers must poll for whatever signal indicates *their* next step is
|
||||
// ready (e.g. CodeTab.activate polls for the env pill).
|
||||
//
|
||||
// AX-tree match: `role: 'button'` with the literal tab name as the
|
||||
// accessible name. The visible label and aria-label happen to coincide
|
||||
// today, and the AX-computed name follows the same cascade — pinning
|
||||
// to the name keeps the page-object durable across the tailwind
|
||||
// regenerations that motivated the migration.
|
||||
//
|
||||
// Pre-click polling budget. Up to session 13, this was a one-shot
|
||||
// snapshot — if the tab button hadn't rendered yet when activateTab
|
||||
// was called, the function returned `{ clicked: false }` immediately.
|
||||
// Session 13's `waitForAxNode` substrate makes "wait for the button to
|
||||
// appear" a one-line shape-only change. Default 5000ms matches the
|
||||
// `lib/ax.ts` defaults; callers that previously relied on the no-retry
|
||||
// shape pass `timeout: 0` (e.g. via `waitForAxNode`'s timeoutMs) to
|
||||
// keep the old behaviour, though no caller currently does so. T16
|
||||
// passes 15s through `CodeTab.activate({ timeout })` — that budget is
|
||||
// still spent on the post-click pill poll; the pre-click click budget
|
||||
// is independent.
|
||||
export async function activateTab(
|
||||
inspector: InspectorClient,
|
||||
name: 'Chat' | 'Cowork' | 'Code',
|
||||
opts: { timeout?: number } = {},
|
||||
): Promise<{ clicked: boolean }> {
|
||||
const target = await waitForAxNode(
|
||||
inspector,
|
||||
(el) =>
|
||||
el.computedRole === 'button' && el.accessibleName === name,
|
||||
{ timeoutMs: opts.timeout ?? 5_000 },
|
||||
);
|
||||
if (!target || target.backendDOMNodeId === null) {
|
||||
return { clicked: false };
|
||||
}
|
||||
await inspector.clickByBackendNodeId('claude.ai', target.backendDOMNodeId);
|
||||
return { clicked: true };
|
||||
}
|
||||
|
||||
// A "compact pill" — the React component used by both the env pill and
|
||||
// the "Select folder…" pill. AX shape: `role: 'button'` with
|
||||
// `hasPopup === 'menu'`, scoped away from cowork sidebar row triggers
|
||||
// (`/^More options for /`). The tailwind `max-w-[Npx]` field used to
|
||||
// be carried as a diagnostic in v6; that signal isn't in the AX tree
|
||||
// (and it was tailwind-specific, exactly the kind of thing the
|
||||
// migration was meant to drop), so it's gone — callers only used it
|
||||
// in error messages.
|
||||
export interface CompactPill {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export async function findCompactPills(
|
||||
inspector: InspectorClient,
|
||||
): Promise<CompactPill[]> {
|
||||
const elements = await snapshotAx(inspector);
|
||||
return elements
|
||||
.filter(
|
||||
(el) =>
|
||||
el.computedRole === 'button' &&
|
||||
el.hasPopup === 'menu' &&
|
||||
el.accessibleName !== null &&
|
||||
el.accessibleName.length > 0 &&
|
||||
!ROW_MORE_OPTIONS_RE.test(el.accessibleName),
|
||||
)
|
||||
.map((el) => ({ text: el.accessibleName as string }));
|
||||
}
|
||||
|
||||
// Open a compact pill whose accessibleName matches `labelPattern`.
|
||||
// Discrimination: `role: 'button'` AND `hasPopup === 'menu'` AND the
|
||||
// AX-computed name passes the regex. The hasPopup gate is what stops
|
||||
// us trial-clicking action buttons that happen to share text with a
|
||||
// pill — the pill always carries an aria-haspopup contract (it opens
|
||||
// a popover) while a same-named action button does not.
|
||||
//
|
||||
// Polls the AX tree post-click for the menu to render (any role in
|
||||
// MENU_ITEM_ROLES). Returns the rendered menu item names so the caller
|
||||
// can validate without a second snapshot round-trip.
|
||||
export async function openPill(
|
||||
inspector: InspectorClient,
|
||||
labelPattern: RegExp,
|
||||
opts: { timeout?: number } = {},
|
||||
): Promise<{ opened: boolean; items: string[] }> {
|
||||
const timeout = opts.timeout ?? 5000;
|
||||
const elements = await snapshotAx(inspector);
|
||||
const target = elements.find(
|
||||
(el) =>
|
||||
el.computedRole === 'button' &&
|
||||
el.hasPopup === 'menu' &&
|
||||
el.accessibleName !== null &&
|
||||
labelPattern.test(el.accessibleName),
|
||||
);
|
||||
if (!target || target.backendDOMNodeId === null) {
|
||||
return { opened: false, items: [] };
|
||||
}
|
||||
await inspector.clickByBackendNodeId('claude.ai', target.backendDOMNodeId);
|
||||
// Menu render is async and the AX tree lags DOM by hundreds of ms
|
||||
// (see docs/learnings/test-harness-ax-tree-walker.md §1). Gate
|
||||
// once on stability post-click, then poll fast — re-gating on every
|
||||
// iteration would burn 800ms+ each cycle waiting for "no change"
|
||||
// when what we want is "menuitems appear".
|
||||
await waitForAxTreeStable(inspector, { minNodes: 1, timeoutMs: 5_000 });
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const post = await snapshotAx(inspector, { fast: true });
|
||||
const items = post.filter((el) => MENU_ITEM_ROLES.has(el.computedRole));
|
||||
if (items.length > 0) {
|
||||
return {
|
||||
opened: true,
|
||||
items: items.map((el) => (el.accessibleName ?? '').slice(0, 80)),
|
||||
};
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return { opened: false, items: [] };
|
||||
}
|
||||
|
||||
// Click any menuitem (any of MENU_ITEM_ROLES) whose accessibleName
|
||||
// matches `textPattern`. Caller opens the menu first. Polls the AX
|
||||
// snapshot — menu render is async and the AX tree lags DOM by
|
||||
// hundreds of ms.
|
||||
//
|
||||
// Returns the matched item's text and the full item list at the time
|
||||
// of the match — the second is useful for diagnostics when `clicked`
|
||||
// is null.
|
||||
export async function clickMenuItem(
|
||||
inspector: InspectorClient,
|
||||
textPattern: RegExp,
|
||||
opts: { timeout?: number } = {},
|
||||
): Promise<{ clicked: string | null; items: string[] }> {
|
||||
const timeout = opts.timeout ?? 1500;
|
||||
// Caller has just opened a menu — gate once on stability so the
|
||||
// first iteration sees the populated tree, then poll fast for the
|
||||
// match. Same shape as openPill's post-click handling.
|
||||
await waitForAxTreeStable(inspector, { minNodes: 1, timeoutMs: 5_000 });
|
||||
const deadline = Date.now() + timeout;
|
||||
let lastItemNames: string[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
const elements = await snapshotAx(inspector, { fast: true });
|
||||
const items = elements.filter((el) =>
|
||||
MENU_ITEM_ROLES.has(el.computedRole),
|
||||
);
|
||||
lastItemNames = items.map((el) => (el.accessibleName ?? '').slice(0, 80));
|
||||
const match = items.find(
|
||||
(el) =>
|
||||
el.accessibleName !== null && textPattern.test(el.accessibleName),
|
||||
);
|
||||
if (match && match.backendDOMNodeId !== null) {
|
||||
const text = (match.accessibleName ?? '').slice(0, 80);
|
||||
await inspector.clickByBackendNodeId(
|
||||
'claude.ai',
|
||||
match.backendDOMNodeId,
|
||||
);
|
||||
return { clicked: text, items: lastItemNames };
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return { clicked: null, items: lastItemNames };
|
||||
}
|
||||
|
||||
// Dispatch an Escape keydown to the document. Used by openEnvPill's
|
||||
// trial-click loop to dismiss the menu when the wrong pill was hit.
|
||||
// We dispatch on document because the popover trigger may not have
|
||||
// retained focus.
|
||||
export async function pressEscape(inspector: InspectorClient): Promise<void> {
|
||||
await inspector.evalInRenderer<null>(
|
||||
'claude.ai',
|
||||
`(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', code: 'Escape', keyCode: 27, which: 27,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
return null;
|
||||
})()`,
|
||||
);
|
||||
}
|
||||
|
||||
// Code tab domain operations. Instance-shaped (carries the inspector)
|
||||
// to match QuickEntry / MainWindow in quickentry.ts.
|
||||
//
|
||||
// Only valid after the renderer has loaded a logged-in claude.ai page;
|
||||
// callers should `app.waitForReady('userLoaded')` first. activate()
|
||||
// itself doesn't repeat that check — it would just fail to find the
|
||||
// Code button on /login, which surfaces as a clear error.
|
||||
export class CodeTab {
|
||||
constructor(private readonly inspector: InspectorClient) {}
|
||||
|
||||
// Click the Code tab, then poll up to `timeout` for at least one
|
||||
// compact pill to render. The env pill rendering is the cheapest
|
||||
// signal that the Code-tab body has mounted and is interactive —
|
||||
// the URL doesn't change (route stays `/new` etc.), so we can't
|
||||
// anchor on navigation. Throws on miss with the candidate count for
|
||||
// triage.
|
||||
//
|
||||
// Session 14 migration: the pre-click `activateTab` call now polls
|
||||
// up to `opts.timeout` for the Code button itself to appear (was a
|
||||
// one-shot snapshot prior — the T16 failure mode). Same budget
|
||||
// covers both phases; in practice the click resolves in well under
|
||||
// a second when the Code button is present, so the post-click pill
|
||||
// poll inherits the bulk of the budget.
|
||||
async activate(opts: { timeout?: number } = {}): Promise<void> {
|
||||
const timeout = opts.timeout ?? 5000;
|
||||
const result = await activateTab(this.inspector, 'Code', { timeout });
|
||||
if (!result.clicked) {
|
||||
throw new Error(
|
||||
'CodeTab.activate: no AX-tree button with accessibleName="Code" found',
|
||||
);
|
||||
}
|
||||
// Post-click: poll the AX tree for at least one compact pill.
|
||||
// `waitForAxNodes` carries the snapshot+filter+sleep loop
|
||||
// formerly hand-rolled here, with the same per-iteration cadence
|
||||
// (200ms) and overall budget. Predicate matches `findCompactPills`
|
||||
// — `role: 'button'` + `hasPopup: 'menu'` + non-empty
|
||||
// accessibleName + not a per-row "More options for X" trigger.
|
||||
const ready = await waitForAxNodes(
|
||||
this.inspector,
|
||||
(el) =>
|
||||
el.computedRole === 'button' &&
|
||||
el.hasPopup === 'menu' &&
|
||||
el.accessibleName !== null &&
|
||||
el.accessibleName.length > 0 &&
|
||||
!ROW_MORE_OPTIONS_RE.test(el.accessibleName),
|
||||
{ timeoutMs: timeout, intervalMs: 200 },
|
||||
);
|
||||
if (!ready) {
|
||||
throw new Error(
|
||||
`CodeTab.activate: no compact pill rendered within ${timeout}ms ` +
|
||||
`after clicking Code — tab body may not have mounted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Open the env pill (the compact pill whose menu contains a `^Local`
|
||||
// menuitemradio). Trial-click strategy: for each compact pill, try
|
||||
// opening it and check for the Local item. If absent, dismiss with
|
||||
// Escape and try the next. Necessary because nothing in the DOM
|
||||
// distinguishes the env pill from a future second compact pill at
|
||||
// rest — only the menu contents disambiguate.
|
||||
//
|
||||
// Returns the matched pill's label text and the rendered menu
|
||||
// items. Throws if no candidate yields a Local-bearing menu.
|
||||
async openEnvPill(): Promise<{ pillText: string; items: string[] }> {
|
||||
const pills = await findCompactPills(this.inspector);
|
||||
if (pills.length === 0) {
|
||||
throw new Error(
|
||||
'CodeTab.openEnvPill: no compact pills on the page — ' +
|
||||
'did you call activate() first?',
|
||||
);
|
||||
}
|
||||
// Iterate by label rather than DOM index so we can use openPill
|
||||
// with an exact-text anchor — avoids re-querying ordinals after
|
||||
// each Escape (the DOM may shift).
|
||||
for (const pill of pills) {
|
||||
const labelRe = new RegExp(`^${escapeRegExp(pill.text)}$`);
|
||||
const opened = await openPill(this.inspector, labelRe, { timeout: 1500 });
|
||||
if (!opened.opened) continue;
|
||||
const hasLocal = opened.items.some((t) => /^Local\b/.test(t));
|
||||
if (hasLocal) {
|
||||
return { pillText: pill.text, items: opened.items };
|
||||
}
|
||||
await pressEscape(this.inspector);
|
||||
// Brief settle so the next openPill doesn't race the popover
|
||||
// teardown. 150ms matches the original T17 implementation.
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error(
|
||||
`CodeTab.openEnvPill: probed ${pills.length} compact pill(s), ` +
|
||||
`none yielded a menu containing /^Local\\b/`,
|
||||
);
|
||||
}
|
||||
|
||||
// Click the `^Local` menuitemradio inside the (already-open) env-pill
|
||||
// menu. textContent reads "Local, environment settings, right arrow"
|
||||
// because of the SR-only suffix; we anchor on /^Local\b/.
|
||||
async selectLocal(): Promise<void> {
|
||||
const result = await clickMenuItem(this.inspector, /^Local\b/);
|
||||
if (!result.clicked) {
|
||||
throw new Error(
|
||||
`CodeTab.selectLocal: no /^Local\\b/ item in the open menu. ` +
|
||||
`Items: ${JSON.stringify(result.items)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Full chain: open env pill → Local → wait for the "Select folder…"
|
||||
// pill to render → open it → click "Open folder…". After this
|
||||
// resolves, dialog.showOpenDialog has been invoked (the caller
|
||||
// installs the mock first and polls getOpenDialogCalls to confirm).
|
||||
//
|
||||
// Each step throws on its own miss with enough metadata to tell
|
||||
// which selector decayed; the caller can wrap the whole chain in
|
||||
// try/catch for partial-state attachment.
|
||||
async openFolderPicker(): Promise<void> {
|
||||
await this.openEnvPill();
|
||||
await this.selectLocal();
|
||||
// The Select-folder pill renders after Local is chosen. Same
|
||||
// CompactPill shape — anchor on the leading "Select folder"
|
||||
// text. 4s budget matches the T17 wait that proved sufficient
|
||||
// in practice on KDE-W.
|
||||
const selectOpened = await retryUntil(
|
||||
async () => {
|
||||
const r = await openPill(this.inspector, /^Select folder/, {
|
||||
timeout: 1000,
|
||||
});
|
||||
return r.opened ? r : null;
|
||||
},
|
||||
{ timeout: 4000, interval: 200 },
|
||||
);
|
||||
if (!selectOpened) {
|
||||
throw new Error(
|
||||
'CodeTab.openFolderPicker: "Select folder…" pill did not ' +
|
||||
'open within 4s after Local was clicked',
|
||||
);
|
||||
}
|
||||
// The Select-folder menu has a "Recent" group (radios — clicking
|
||||
// reuses the past path silently, no dialog) followed by
|
||||
// "Open folder…" (menuitem — fires the picker). Click the
|
||||
// menuitem variant explicitly; clickMenuItem matches all
|
||||
// menuitem* roles, so the leading-text anchor is what
|
||||
// disambiguates here.
|
||||
const openClicked = await clickMenuItem(this.inspector, /^Open folder/);
|
||||
if (!openClicked.clicked) {
|
||||
throw new Error(
|
||||
`CodeTab.openFolderPicker: no /^Open folder/ menuitem in ` +
|
||||
`the Select-folder menu. Items: ${JSON.stringify(openClicked.items)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard "escape regex special chars in a literal string" helper.
|
||||
// Used to build an exact-match RegExp from a captured pill label.
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
40
tools/test-harness/src/lib/dbus.ts
Normal file
40
tools/test-harness/src/lib/dbus.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { sessionBus, type MessageBus, type ClientInterface } from 'dbus-next';
|
||||
|
||||
let cached: MessageBus | null = null;
|
||||
|
||||
export function getSessionBus(): MessageBus {
|
||||
if (!cached) {
|
||||
cached = sessionBus();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function disconnectBus(): Promise<void> {
|
||||
if (cached) {
|
||||
cached.disconnect();
|
||||
cached = null;
|
||||
}
|
||||
}
|
||||
|
||||
// dbus-next exposes interface methods as dynamic properties typed loosely. Cast
|
||||
// at the call site rather than re-typing every D-Bus interface we touch.
|
||||
type DynamicMethod = (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
export function method(iface: ClientInterface, name: string): DynamicMethod {
|
||||
const fn = (iface as unknown as Record<string, DynamicMethod | undefined>)[name];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`D-Bus method ${name} not found on interface`);
|
||||
}
|
||||
return fn.bind(iface);
|
||||
}
|
||||
|
||||
export async function getConnectionPid(connectionName: string): Promise<number> {
|
||||
const bus = getSessionBus();
|
||||
const proxy = await bus.getProxyObject(
|
||||
'org.freedesktop.DBus',
|
||||
'/org/freedesktop/DBus',
|
||||
);
|
||||
const iface = proxy.getInterface('org.freedesktop.DBus');
|
||||
const result = await method(iface, 'GetConnectionUnixProcessID')(connectionName);
|
||||
return result as number;
|
||||
}
|
||||
65
tools/test-harness/src/lib/diagnostics.ts
Normal file
65
tools/test-harness/src/lib/diagnostics.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const LAUNCHER_LOG = join(
|
||||
homedir(),
|
||||
'.cache/claude-desktop-debian/launcher.log',
|
||||
);
|
||||
|
||||
export async function readLauncherLog(): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(LAUNCHER_LOG, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DoctorResult {
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export async function runDoctor(launcher?: string): Promise<DoctorResult> {
|
||||
const bin = launcher ?? process.env.CLAUDE_DESKTOP_LAUNCHER ?? 'claude-desktop';
|
||||
try {
|
||||
const { stdout, stderr } = await exec(bin, ['--doctor'], { timeout: 15_000 });
|
||||
return {
|
||||
output: `${stdout}\n${stderr}`.trim(),
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (err) {
|
||||
// --doctor may exit non-zero if checks fail; still return the output
|
||||
// and the actual exit code so T02/T13/S05 can assert against it.
|
||||
const e = err as { stdout?: string; stderr?: string; code?: number };
|
||||
const combined = `${e.stdout ?? ''}\n${e.stderr ?? ''}`.trim();
|
||||
return {
|
||||
output: combined,
|
||||
exitCode: typeof e.code === 'number' ? e.code : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function captureSessionEnv(): Record<string, string> {
|
||||
const keys = [
|
||||
'XDG_SESSION_TYPE',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'GDK_BACKEND',
|
||||
'QT_QPA_PLATFORM',
|
||||
'OZONE_PLATFORM',
|
||||
'ELECTRON_OZONE_PLATFORM_HINT',
|
||||
'CLAUDE_DESKTOP_LAUNCHER',
|
||||
];
|
||||
const out: Record<string, string> = {};
|
||||
for (const k of keys) {
|
||||
const v = process.env[k];
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
413
tools/test-harness/src/lib/eipc.ts
Normal file
413
tools/test-harness/src/lib/eipc.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
// "eipc" channel-registry primitive — runtime discovery of the custom
|
||||
// `$eipc_message$_<UUID>_$_<scope>_$_<iface>_$_<method>` handlers
|
||||
// registered on each per-webContents IPC scope.
|
||||
//
|
||||
// Why this exists
|
||||
// ---------------
|
||||
// Sessions 2-6 of the runner-implementation work treated the eipc
|
||||
// registry as unreachable from main: the standard Electron
|
||||
// `ipcMain._invokeHandlers` map only carries 3 chat-tab MCP-bridge
|
||||
// handlers (`list-mcp-servers`, `connect-to-mcp-server`,
|
||||
// `request-open-mcp-settings`); the 700+ `claude.web_$_*` /
|
||||
// `claude.settings_$_*` etc. channels were assumed to be closure-
|
||||
// local. Session 3's `globalThis` walk came up empty, which kept
|
||||
// T22/T31/T33/T38 stuck as Tier 1 asar fingerprints rather than
|
||||
// runtime registry probes.
|
||||
//
|
||||
// Session 7 found the missing piece: handlers DO go through
|
||||
// Electron's stdlib `IpcMainImpl` — just not the GLOBAL `ipcMain`
|
||||
// instance. Each `webContents` has its own `webContents.ipc` (per-
|
||||
// `WebContents` IPC scope, introduced in Electron 17+), and that's
|
||||
// where every `e.ipc.handle("$eipc_message$_..._$_<scope>_$_<iface>_$_<method>", fn)`
|
||||
// call lands. Verified empirically against a debugger-attached
|
||||
// running Claude:
|
||||
// - find_in_page wc: 78 handlers (settings/find-in-page only)
|
||||
// - main_window wc: 79 handlers (settings/title-bar only)
|
||||
// - claude.ai wc: 490 handlers (full surface — including
|
||||
// 117 LocalSessions, 16 CustomPlugins)
|
||||
// - global ipcMain: 3 handlers (the chat-tab MCP-bridge trio)
|
||||
//
|
||||
// All `claude.web_$_*` interfaces (LocalSessions, CustomPlugins,
|
||||
// CoworkSpaces, CoworkArtifacts, CoworkMemory, ClaudeCode, etc.)
|
||||
// register on the claude.ai webContents. They're sticky across route
|
||||
// changes — once registered (during webContents init), they don't
|
||||
// deregister when the user navigates between /chats and /epitaxy.
|
||||
// So the wait-for-channel poll just needs claude.ai to be alive +
|
||||
// finished initial handler registration, NOT a specific route.
|
||||
//
|
||||
// What this primitive does
|
||||
// ------------------------
|
||||
// Read-only enumeration via `getEipcChannels` / `findEipcChannel` /
|
||||
// `waitForEipcChannel(s)`. Handler PRESENCE checks (T22b / T31b / T33b
|
||||
// / T38b) — that's strictly stronger than the asar fingerprint (a
|
||||
// handler registered at runtime is a handler that actually wired up,
|
||||
// not just a string in the bundle).
|
||||
//
|
||||
// Plus `invokeEipcChannel` (session 8 addition) — calls a registered
|
||||
// handler through the renderer-side wrapper at `window['claude.<scope>']
|
||||
// .<Iface>.<method>(...args)`. The wrapper is exposed by `mainView.js`
|
||||
// preload via `contextBridge.exposeInMainWorld` after a frame + origin
|
||||
// gate (top-level frame, origin in `{claude.ai, claude.com,
|
||||
// preview.claude.ai, preview.claude.com, localhost}`). Because the
|
||||
// `inspector.evalInRenderer('claude.ai', ...)` path runs inside the
|
||||
// claude.ai renderer, the wrapper is present and the synthesized
|
||||
// `IpcMainInvokeEvent` carries an honest `senderFrame` — the alternative
|
||||
// of pulling the function out of `_invokeHandlers` and synthesizing a
|
||||
// fake event with `senderFrame.url = 'https://claude.ai/'` works (the
|
||||
// gates are duck-typed structural checks) but spoofs a security-relevant
|
||||
// claim. Going through the wrapper keeps the test surface aligned with
|
||||
// real attack surface.
|
||||
//
|
||||
// `invokeEipcChannel` is read-by-default but doesn't enforce a
|
||||
// read-only allowlist — the safety property is that consumers pass
|
||||
// case-doc-anchored suffixes verbatim, which limits the blast radius
|
||||
// to whatever the case doc said the test should poke. Don't pass
|
||||
// `start*` / `set*` / `write*` / `run*` / `openIn*` suffixes; those
|
||||
// mutate user state.
|
||||
//
|
||||
// Framing opacity
|
||||
// ---------------
|
||||
// The `$eipc_message$_<UUID>_$_<scope>_$_<iface>_$_<method>` framing
|
||||
// has been UUID-stable across builds (session 2 noted
|
||||
// `c0eed8c9-c94a-4931-8cc3-3a08694e9863`; session 7 confirmed it's
|
||||
// still that, single UUID across all 647 per-wc handlers). The
|
||||
// primitive does not pin the UUID — match by suffix so a future
|
||||
// build that rotates the UUID doesn't silently break every consuming
|
||||
// spec. Suffix matching is also what the case-doc anchors use
|
||||
// (`LocalSessions_$_getPrChecks` etc.), so consumers can pass the
|
||||
// case-doc string verbatim.
|
||||
|
||||
import { retryUntil } from './retry.js';
|
||||
import type { InspectorClient } from './inspector.js';
|
||||
|
||||
// One handler entry on a webContents. `suffix` is the part after the
|
||||
// UUID — `<scope>_$_<iface>_$_<method>` — useful for dedup / display.
|
||||
// `fullKey` is the full registry key including the framing prefix and
|
||||
// UUID, kept for diagnostic attachments where the raw form matters
|
||||
// (drift detection, regression triage). `webContentsId` lets a caller
|
||||
// disambiguate when a future scope registers the same suffix on
|
||||
// multiple webContents (today only `claude.settings/*` does this and
|
||||
// every wc gets the same set; non-issue for current consumers).
|
||||
export interface EipcChannel {
|
||||
suffix: string;
|
||||
fullKey: string;
|
||||
webContentsId: number;
|
||||
webContentsUrl: string;
|
||||
}
|
||||
|
||||
export interface GetEipcChannelsOptions {
|
||||
// Substring match on `webContents.getURL()`. Default: 'claude.ai'.
|
||||
// Pass an empty string to enumerate every webContents.
|
||||
urlFilter?: string;
|
||||
// Optional scope filter — e.g. 'claude.web' to drop settings-
|
||||
// scope handlers. Matched against the segment immediately after
|
||||
// the UUID. Empty / undefined returns all scopes.
|
||||
scope?: string;
|
||||
// Optional interface filter — e.g. 'LocalSessions'. Matched
|
||||
// against the segment after the scope. Empty / undefined returns
|
||||
// all interfaces.
|
||||
iface?: string;
|
||||
}
|
||||
|
||||
// Internal: shape returned by the inspector eval below. Kept private
|
||||
// so the `EipcChannel` interface above is the public type contract.
|
||||
interface RawEntry {
|
||||
wcId: number;
|
||||
wcUrl: string;
|
||||
fullKey: string;
|
||||
}
|
||||
|
||||
// Enumerate every eipc-framed handler key registered on every matching
|
||||
// webContents. The UUID is opaque to the caller — only the suffix
|
||||
// (`<scope>_$_<iface>_$_<method>`) is exposed via the EipcChannel
|
||||
// type. Filtering by `scope` / `iface` happens after the inspector
|
||||
// eval (the eval keeps its filter set minimal so a single eval call
|
||||
// covers every consumer's needs).
|
||||
//
|
||||
// Returns an empty array when no matching webContents exists (e.g.
|
||||
// the spec called this before claude.ai loaded). Callers that need
|
||||
// a "wait until present" semantic should use `waitForEipcChannel`
|
||||
// instead.
|
||||
export async function getEipcChannels(
|
||||
inspector: InspectorClient,
|
||||
opts: GetEipcChannelsOptions = {},
|
||||
): Promise<EipcChannel[]> {
|
||||
const urlFilter = opts.urlFilter ?? 'claude.ai';
|
||||
const raw = await inspector.evalInMain<RawEntry[]>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
const urlFilter = ${JSON.stringify(urlFilter)};
|
||||
const out = [];
|
||||
for (const wc of webContents.getAllWebContents()) {
|
||||
const url = wc.getURL();
|
||||
if (urlFilter && !url.includes(urlFilter)) continue;
|
||||
const ipc = wc.ipc;
|
||||
const map = ipc && ipc._invokeHandlers;
|
||||
if (!map) continue;
|
||||
const keys = (typeof map.keys === 'function')
|
||||
? Array.from(map.keys())
|
||||
: Object.keys(map);
|
||||
for (const k of keys) {
|
||||
out.push({ wcId: wc.id, wcUrl: url, fullKey: k });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
`);
|
||||
|
||||
// Match the framing prefix and capture the suffix. Anything that
|
||||
// doesn't match (e.g. a non-eipc handler that snuck onto a wc
|
||||
// scope) gets filtered out — only eipc-framed entries are part of
|
||||
// this primitive's contract.
|
||||
const re = /^\$eipc_message\$_[0-9a-f-]+_\$_(.+)$/;
|
||||
const out: EipcChannel[] = [];
|
||||
for (const entry of raw) {
|
||||
const m = re.exec(entry.fullKey);
|
||||
if (!m) continue;
|
||||
const suffix = m[1]!;
|
||||
if (opts.scope) {
|
||||
// Suffix shape: `<scope>_$_<iface>_$_<method>`. Anchor at
|
||||
// the start so 'claude.web' matches but 'web' doesn't
|
||||
// match `claude.settings` etc.
|
||||
if (!suffix.startsWith(`${opts.scope}_$_`)) continue;
|
||||
}
|
||||
if (opts.iface) {
|
||||
// Interface segment is after the scope — search for
|
||||
// `_$_<iface>_$_` in the suffix. Anchored separators
|
||||
// avoid accidentally matching a method name that happens
|
||||
// to contain the iface string.
|
||||
if (!suffix.includes(`_$_${opts.iface}_$_`)) continue;
|
||||
}
|
||||
out.push({
|
||||
suffix,
|
||||
fullKey: entry.fullKey,
|
||||
webContentsId: entry.wcId,
|
||||
webContentsUrl: entry.wcUrl,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface FindEipcChannelOptions {
|
||||
// Substring match on `webContents.getURL()`. Default: 'claude.ai'.
|
||||
urlFilter?: string;
|
||||
}
|
||||
|
||||
// Locate the first registered handler whose suffix ends with
|
||||
// `caseDocSuffix`. Designed so callers can pass the case-doc-anchored
|
||||
// string verbatim — e.g. `LocalSessions_$_getPrChecks`. Returns null
|
||||
// when no match exists (caller decides whether to fail, skip, or
|
||||
// retry).
|
||||
//
|
||||
// This is a synchronous one-shot; for the populate-on-init wait, use
|
||||
// `waitForEipcChannel` — it wraps this in a retryUntil.
|
||||
export async function findEipcChannel(
|
||||
inspector: InspectorClient,
|
||||
caseDocSuffix: string,
|
||||
opts: FindEipcChannelOptions = {},
|
||||
): Promise<EipcChannel | null> {
|
||||
const channels = await getEipcChannels(inspector, {
|
||||
urlFilter: opts.urlFilter,
|
||||
});
|
||||
for (const ch of channels) {
|
||||
if (ch.suffix.endsWith(caseDocSuffix)) return ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface WaitForEipcChannelOptions {
|
||||
urlFilter?: string;
|
||||
// Total budget for the poll. Default 15s — the claude.ai
|
||||
// webContents' initial handler registration completes within a
|
||||
// second of `userLoaded` on the dev box, so 15s leaves wide
|
||||
// margin for slow-cache cases.
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
// Poll until the named channel is registered, or the budget runs out.
|
||||
// Use this when the spec just reached `waitForReady('userLoaded')` —
|
||||
// the claude.ai webContents may exist but its handlers might not have
|
||||
// finished registering yet. The poll is cheap (one inspector eval per
|
||||
// tick + a string scan) so the default interval can be aggressive.
|
||||
//
|
||||
// Returns the EipcChannel on success, null on timeout. Callers that
|
||||
// want a hard fail on timeout should `expect(channel, '...').not.toBeNull()`
|
||||
// — the primitive doesn't throw because some specs want to surface
|
||||
// missing-handler as a clean fail with diagnostics rather than an
|
||||
// uncaught timeout.
|
||||
export async function waitForEipcChannel(
|
||||
inspector: InspectorClient,
|
||||
caseDocSuffix: string,
|
||||
opts: WaitForEipcChannelOptions = {},
|
||||
): Promise<EipcChannel | null> {
|
||||
return retryUntil(
|
||||
() => findEipcChannel(inspector, caseDocSuffix, opts),
|
||||
{
|
||||
timeout: opts.timeoutMs ?? 15_000,
|
||||
interval: opts.intervalMs ?? 250,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience: resolve a list of case-doc suffixes in one round-trip.
|
||||
// Returns a Map keyed by the input suffix so callers can iterate the
|
||||
// expected list and report per-suffix presence. Missing suffixes have
|
||||
// `null` values.
|
||||
//
|
||||
// Single inspector call by design — the `getEipcChannels` cost is
|
||||
// dominated by the eval round-trip, not the in-process filtering, so
|
||||
// batching is strictly cheaper than N calls to `findEipcChannel`.
|
||||
export async function findEipcChannels(
|
||||
inspector: InspectorClient,
|
||||
caseDocSuffixes: readonly string[],
|
||||
opts: FindEipcChannelOptions = {},
|
||||
): Promise<Map<string, EipcChannel | null>> {
|
||||
const channels = await getEipcChannels(inspector, {
|
||||
urlFilter: opts.urlFilter,
|
||||
});
|
||||
const out = new Map<string, EipcChannel | null>();
|
||||
for (const suffix of caseDocSuffixes) {
|
||||
const hit = channels.find((c) => c.suffix.endsWith(suffix));
|
||||
out.set(suffix, hit ?? null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wait until ALL of the listed suffixes are registered, or the budget
|
||||
// runs out. Useful for trios like T31's side-chat (start/send/stop) —
|
||||
// the trio is load-bearing as a unit; partial registration is a fail.
|
||||
//
|
||||
// Returns the resolved Map on full success. On timeout, returns the
|
||||
// last-observed Map (some entries may be null) so callers can surface
|
||||
// the partial state in their diagnostic attachment before failing.
|
||||
export async function waitForEipcChannels(
|
||||
inspector: InspectorClient,
|
||||
caseDocSuffixes: readonly string[],
|
||||
opts: WaitForEipcChannelOptions = {},
|
||||
): Promise<Map<string, EipcChannel | null>> {
|
||||
let lastSnapshot = new Map<string, EipcChannel | null>();
|
||||
const result = await retryUntil(
|
||||
async () => {
|
||||
const snap = await findEipcChannels(
|
||||
inspector,
|
||||
caseDocSuffixes,
|
||||
opts,
|
||||
);
|
||||
lastSnapshot = snap;
|
||||
for (const v of snap.values()) if (v === null) return null;
|
||||
return snap;
|
||||
},
|
||||
{
|
||||
timeout: opts.timeoutMs ?? 15_000,
|
||||
interval: opts.intervalMs ?? 250,
|
||||
},
|
||||
);
|
||||
return result ?? lastSnapshot;
|
||||
}
|
||||
|
||||
export interface InvokeEipcChannelOptions {
|
||||
// Renderer URL filter. Default 'claude.ai' — the only webContents
|
||||
// whose origin passes the wrapper-exposure gate (`Qc()` in
|
||||
// `mainView.js`: `https://claude.ai`, `https://claude.com`,
|
||||
// preview.*, localhost). The `find_in_page` and `main_window`
|
||||
// webContents register `claude.settings/*` handlers in their
|
||||
// per-wc IPC scope but their renderers run from `file://`, so
|
||||
// `window['claude.settings']` is never exposed there and invocation
|
||||
// through them would need a different (main-side, fake-event)
|
||||
// approach not implemented in this primitive.
|
||||
urlFilter?: string;
|
||||
// Inspector eval timeout. Default = InspectorClient.defaultTimeoutMs
|
||||
// (30s). Read-only handlers like `getMcpServersConfig` /
|
||||
// `readGlobalMemory` / `getAllScheduledTasks` return well within
|
||||
// 1s on a warm app; the 30s budget is for cold-cache cases.
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// Invoke an eipc handler through the renderer-side wrapper at
|
||||
// `window['claude.<scope>'].<Iface>.<method>(...args)`. The suffix is
|
||||
// resolved against the per-wc registry first (same matching rules as
|
||||
// `findEipcChannel` — accepts both fully-qualified
|
||||
// `claude.web_$_LocalSessions_$_getPrChecks` and the more concise
|
||||
// `LocalSessions_$_getPrChecks`) and the scope/iface/method triplet is
|
||||
// pulled from the resolved full suffix.
|
||||
//
|
||||
// Why through the renderer wrapper, not a direct main-side call:
|
||||
// handlers register via `e.ipc.handle(framedName, async (event, args)
|
||||
// => { if (!le(event)) throw ...; return A.<method>(args); })` — the
|
||||
// origin gate is inlined at registration time (variants `le`/`Vi`/`mm`
|
||||
// in the bundle, all duck-typed structural checks against
|
||||
// `event.senderFrame.url` and `event.senderFrame.parent === null`).
|
||||
// Pulling the function out of `_invokeHandlers` and calling it with a
|
||||
// synthesized event whose `senderFrame.url` is `'https://claude.ai/'`
|
||||
// works (the gate is structural, not `instanceof`-checked) but spoofs
|
||||
// the gate's security claim. The wrapper IS at claude.ai, so the
|
||||
// synthesized event carries an honest senderFrame and the test surface
|
||||
// matches real attack surface.
|
||||
//
|
||||
// Errors:
|
||||
// - "no handler registered with suffix": the registry walk returned
|
||||
// nothing matching. Same shape as `findEipcChannel` returning null;
|
||||
// waitForEipcChannel first if your spec needs the populate-on-init
|
||||
// poll.
|
||||
// - "eipc namespace missing in renderer: claude.<scope>": the wrapper
|
||||
// isn't exposed on this renderer. Either the urlFilter selected a
|
||||
// webContents whose origin failed `Qc()`, or the build flipped the
|
||||
// scope's exposure gate. Check `evalInRenderer(urlFilter,
|
||||
// 'Object.keys(window).filter(k => k.startsWith("claude."))')`.
|
||||
// - String-form rejection from the renderer eval: the gate / arg-
|
||||
// validator / result-validator inside the handler closure rejected.
|
||||
// The framed channel name appears in the error message — use it to
|
||||
// pinpoint which handler rejected.
|
||||
//
|
||||
// Args are JSON-marshaled into the renderer eval. Return value is
|
||||
// JSON-deserialized via `evalInRenderer`'s `executeJavaScript` path.
|
||||
// Non-JSON-serializable handler returns (Date, Buffer, circular refs)
|
||||
// would mangle through this primitive — none of the current Tier 2
|
||||
// case-doc consumers return such shapes; flag if a future one does.
|
||||
export async function invokeEipcChannel<T = unknown>(
|
||||
inspector: InspectorClient,
|
||||
caseDocSuffix: string,
|
||||
args: readonly unknown[] = [],
|
||||
opts: InvokeEipcChannelOptions = {},
|
||||
): Promise<T> {
|
||||
const urlFilter = opts.urlFilter ?? 'claude.ai';
|
||||
const channel = await findEipcChannel(inspector, caseDocSuffix, {
|
||||
urlFilter,
|
||||
});
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`invokeEipcChannel: no handler registered with suffix ` +
|
||||
`'${caseDocSuffix}' on a webContents matching ` +
|
||||
`'${urlFilter}'`,
|
||||
);
|
||||
}
|
||||
// Full suffix is `<scope>_$_<iface>_$_<method>`. Scope contains a
|
||||
// dot (e.g. claude.web) but the `_$_` separator is unambiguous —
|
||||
// a 3-part split gives [scope, iface, method] cleanly.
|
||||
const parts = channel.suffix.split('_$_');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(
|
||||
`invokeEipcChannel: bad suffix shape '${channel.suffix}' ` +
|
||||
`(expected '<scope>_$_<iface>_$_<method>')`,
|
||||
);
|
||||
}
|
||||
const [scope, iface, method] = parts;
|
||||
const argsJson = JSON.stringify(args);
|
||||
const js = `(async () => {
|
||||
const ns = window[${JSON.stringify(scope)}];
|
||||
if (!ns) throw new Error(
|
||||
'eipc namespace missing in renderer: ' + ${JSON.stringify(scope)}
|
||||
);
|
||||
const ifaceObj = ns[${JSON.stringify(iface)}];
|
||||
if (!ifaceObj) throw new Error(
|
||||
'eipc interface missing: ' + ${JSON.stringify(iface)} +
|
||||
' (under ' + ${JSON.stringify(scope)} + ')'
|
||||
);
|
||||
const fn = ifaceObj[${JSON.stringify(method)}];
|
||||
if (typeof fn !== 'function') throw new Error(
|
||||
'eipc method not a function: ' + ${JSON.stringify(method)} +
|
||||
' (under ' + ${JSON.stringify(scope)} + '.' + ${JSON.stringify(iface)} + ')'
|
||||
);
|
||||
return await fn.apply(ifaceObj, ${argsJson});
|
||||
})()`;
|
||||
return inspector.evalInRenderer<T>(urlFilter, js, opts.timeoutMs);
|
||||
}
|
||||
206
tools/test-harness/src/lib/electron-mocks.ts
Normal file
206
tools/test-harness/src/lib/electron-mocks.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// Mock-then-call helpers for side-effecting Electron module APIs.
|
||||
//
|
||||
// Tests that exercise an Electron egress whose real invocation would
|
||||
// touch the host system (open a file manager, launch an editor, show a
|
||||
// dialog) install a recorder mock first, then invoke the API via
|
||||
// `inspector.evalInMain` and assert against the recorded calls. The
|
||||
// pattern strengthens "didn't throw" probes into "the egress was
|
||||
// reached + the args flowed through verbatim", with no host side
|
||||
// effect.
|
||||
//
|
||||
// Each helper:
|
||||
// - is idempotent within an Electron lifecycle (guarded by a
|
||||
// globalThis flag so re-installation in retry loops is a no-op),
|
||||
// - records `{ ts, ...args }` into a globalThis call list,
|
||||
// - returns a value matching the real API's documented contract
|
||||
// (void / Promise<boolean> / canned dialog result).
|
||||
//
|
||||
// The companion `get*Calls()` reader returns `[]` if the mock was
|
||||
// never installed (rather than throwing) so pre-install reads in
|
||||
// retry loops are cheap.
|
||||
//
|
||||
// Extracted from `lib/claudeai.ts` once the third helper landed
|
||||
// (T17 dialog → T25 showItemInFolder → T24 openExternal). These
|
||||
// helpers are not claude.ai-domain — they're generic Electron module
|
||||
// patches — so the extraction keeps `claudeai.ts` focused on the AX-
|
||||
// tree page-objects and gives future mock-then-call tests an obvious
|
||||
// home to add to.
|
||||
//
|
||||
// Caller pattern: see `runners/T17_folder_picker.spec.ts`,
|
||||
// `runners/T25_show_item_in_folder_no_throw.spec.ts`,
|
||||
// `runners/T24_open_in_editor_no_throw.spec.ts`.
|
||||
|
||||
import type { InspectorClient } from './inspector.js';
|
||||
|
||||
// ----- dialog.showOpenDialog -----------------------------------------
|
||||
|
||||
// Replace dialog.showOpenDialog with a mock that records every call
|
||||
// and returns a canned result. Idempotent — re-installing within the
|
||||
// same Electron lifecycle is a no-op (guarded by
|
||||
// globalThis.__claudeAiDialogMockInstalled). Mirrors the shape of
|
||||
// QuickEntry.installInterceptor (quickentry.ts:86) so callers across
|
||||
// libs feel consistent.
|
||||
//
|
||||
// The first BrowserWindow positional arg is optional in Electron's
|
||||
// API, so the mock handles both `showOpenDialog(opts)` and
|
||||
// `showOpenDialog(window, opts)` shapes.
|
||||
export async function installOpenDialogMock(
|
||||
inspector: InspectorClient,
|
||||
cannedResult: { canceled: boolean; filePaths: string[] } = {
|
||||
canceled: false,
|
||||
filePaths: ['/tmp/claude-test-folder'],
|
||||
},
|
||||
): Promise<void> {
|
||||
const canned = JSON.stringify(cannedResult);
|
||||
await inspector.evalInMain<null>(`
|
||||
if (globalThis.__claudeAiDialogMockInstalled) return null;
|
||||
const { dialog } = process.mainModule.require('electron');
|
||||
globalThis.__claudeAiDialogCalls = [];
|
||||
const original = dialog.showOpenDialog.bind(dialog);
|
||||
dialog.showOpenDialog = async function(...args) {
|
||||
const browserWindowArg = args[0]
|
||||
&& typeof args[0] === 'object'
|
||||
&& args[0].constructor
|
||||
&& args[0].constructor.name === 'BrowserWindow';
|
||||
const opts = browserWindowArg ? args[1] : args[0];
|
||||
globalThis.__claudeAiDialogCalls.push({
|
||||
ts: Date.now(),
|
||||
nargs: args.length,
|
||||
title: opts && opts.title,
|
||||
properties: opts && opts.properties,
|
||||
});
|
||||
return ${canned};
|
||||
};
|
||||
void original;
|
||||
globalThis.__claudeAiDialogMockInstalled = true;
|
||||
return null;
|
||||
`);
|
||||
}
|
||||
|
||||
export interface OpenDialogCall {
|
||||
ts: number;
|
||||
nargs: number;
|
||||
title?: string;
|
||||
properties?: string[];
|
||||
}
|
||||
|
||||
// Read the recorded call list. Returns [] if the mock was never
|
||||
// installed (rather than throwing) — pre-install reads in retry
|
||||
// loops stay cheap.
|
||||
export async function getOpenDialogCalls(
|
||||
inspector: InspectorClient,
|
||||
): Promise<OpenDialogCall[]> {
|
||||
return await inspector.evalInMain<OpenDialogCall[]>(
|
||||
`return globalThis.__claudeAiDialogCalls || []`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- shell.showItemInFolder ----------------------------------------
|
||||
|
||||
// Replace electron.shell.showItemInFolder with a mock that records
|
||||
// every call without performing the underlying DBus FileManager1 /
|
||||
// xdg-open dispatch. Same idempotency-flag pattern as
|
||||
// installOpenDialogMock.
|
||||
//
|
||||
// Why mock vs. invoke real: `showItemInFolder` is fire-and-forget on
|
||||
// Linux (returns void, no success signal). Invoking it for real opens
|
||||
// the host's actual file manager — fine in a click-chain test, but
|
||||
// disruptive when the assertion is just "the JS-level call is
|
||||
// reachable + accepts a path arg + the IPC layer terminates here".
|
||||
// The mock keeps the same assertion shape with no host side effect.
|
||||
export async function installShowItemInFolderMock(
|
||||
inspector: InspectorClient,
|
||||
): Promise<void> {
|
||||
await inspector.evalInMain<null>(`
|
||||
if (globalThis.__claudeAiShowItemMockInstalled) return null;
|
||||
const { shell } = process.mainModule.require('electron');
|
||||
globalThis.__claudeAiShowItemCalls = [];
|
||||
const original = shell.showItemInFolder.bind(shell);
|
||||
shell.showItemInFolder = function(fullPath) {
|
||||
globalThis.__claudeAiShowItemCalls.push({
|
||||
ts: Date.now(),
|
||||
path: typeof fullPath === 'string' ? fullPath : String(fullPath),
|
||||
});
|
||||
// Return undefined like the real method — callers don't
|
||||
// inspect the return value.
|
||||
};
|
||||
void original;
|
||||
globalThis.__claudeAiShowItemMockInstalled = true;
|
||||
return null;
|
||||
`);
|
||||
}
|
||||
|
||||
export interface ShowItemInFolderCall {
|
||||
ts: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function getShowItemInFolderCalls(
|
||||
inspector: InspectorClient,
|
||||
): Promise<ShowItemInFolderCall[]> {
|
||||
return await inspector.evalInMain<ShowItemInFolderCall[]>(
|
||||
`return globalThis.__claudeAiShowItemCalls || []`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----- shell.openExternal --------------------------------------------
|
||||
|
||||
// Replace electron.shell.openExternal with a mock that records every
|
||||
// call without performing the underlying xdg-open / scheme-handler
|
||||
// dispatch. Same idempotency-flag pattern as installOpenDialogMock /
|
||||
// installShowItemInFolderMock.
|
||||
//
|
||||
// Why mock vs. invoke real: `shell.openExternal` is the single egress
|
||||
// for all URL-scheme handoffs (browser, OAuth callback, editor URL
|
||||
// schemes like `vscode://file/<path>`). Invoking it for real on a
|
||||
// host with the matching scheme handler installed launches the target
|
||||
// app (e.g. a full VS Code window) — fine in a click-chain test,
|
||||
// disruptive when the assertion is just "the JS-level call is
|
||||
// reachable + the URL flowed through verbatim". The mock keeps the
|
||||
// same assertion shape with no host side effect.
|
||||
//
|
||||
// Unlike `showItemInFolder`, `openExternal` returns `Promise<boolean>`
|
||||
// (true on success, false otherwise — see Electron docs), so the mock
|
||||
// must return a resolved Promise with the canned boolean rather than
|
||||
// undefined, otherwise callers that `await` the result would observe
|
||||
// `undefined` instead of the documented contract.
|
||||
export async function installOpenExternalMock(
|
||||
inspector: InspectorClient,
|
||||
cannedResult: boolean = true,
|
||||
): Promise<void> {
|
||||
const canned = JSON.stringify(cannedResult);
|
||||
await inspector.evalInMain<null>(`
|
||||
if (globalThis.__claudeAiOpenExternalMockInstalled) return null;
|
||||
const { shell } = process.mainModule.require('electron');
|
||||
globalThis.__claudeAiOpenExternalCalls = [];
|
||||
const original = shell.openExternal.bind(shell);
|
||||
shell.openExternal = async function(url, options) {
|
||||
globalThis.__claudeAiOpenExternalCalls.push({
|
||||
ts: Date.now(),
|
||||
url: typeof url === 'string' ? url : String(url),
|
||||
options: options,
|
||||
});
|
||||
// Return a resolved Promise<boolean> like the real method —
|
||||
// callers that await the result expect the documented
|
||||
// contract (true on success, false otherwise).
|
||||
return ${canned};
|
||||
};
|
||||
void original;
|
||||
globalThis.__claudeAiOpenExternalMockInstalled = true;
|
||||
return null;
|
||||
`);
|
||||
}
|
||||
|
||||
export interface OpenExternalCall {
|
||||
ts: number;
|
||||
url: string;
|
||||
options?: unknown;
|
||||
}
|
||||
|
||||
export async function getOpenExternalCalls(
|
||||
inspector: InspectorClient,
|
||||
): Promise<OpenExternalCall[]> {
|
||||
return await inspector.evalInMain<OpenExternalCall[]>(
|
||||
`return globalThis.__claudeAiOpenExternalCalls || []`,
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user