mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-06-29 05:35:23 +03:00
Compare commits
65 Commits
fix/656-tr
...
docs/compa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9528c25e95 | ||
|
|
d12c491470 | ||
|
|
0a1f8071e9 | ||
|
|
14ccb61596 | ||
|
|
af8a60bdb1 | ||
|
|
8b556f2997 | ||
|
|
865c147916 | ||
|
|
113329f91f | ||
|
|
3d47f33ccb | ||
|
|
a8093a8e11 | ||
|
|
23285d3d5a | ||
|
|
22bd68d5b2 | ||
|
|
3ea677f563 | ||
|
|
4c9a2ac951 | ||
|
|
cd1ad67f9a | ||
|
|
8dd4a3229c | ||
|
|
6a3c8319e0 | ||
|
|
0bbb54d1b4 | ||
|
|
7ffd73add1 | ||
|
|
0daceb1e30 | ||
|
|
b9697c2d1e | ||
|
|
e038768daa | ||
|
|
34e9077dd2 | ||
|
|
88f3bd5941 | ||
|
|
d5e1edc11b | ||
|
|
9e561c0c49 | ||
|
|
aa139be763 | ||
|
|
ee7b35ff86 | ||
|
|
549bf4281a | ||
|
|
ce2e5325d3 | ||
|
|
86385848d0 | ||
|
|
fb5189fe45 | ||
|
|
1f5702bc7b | ||
|
|
11ab62afcd | ||
|
|
bebe83d194 | ||
|
|
61245bcc81 | ||
|
|
2ca35610ec | ||
|
|
4d29cf83fa | ||
|
|
af3c31b511 | ||
|
|
b3baa8ad8f | ||
|
|
ade75d748d | ||
|
|
66d390ccec | ||
|
|
5957c8212b | ||
|
|
cb20fde797 | ||
|
|
c76f7e62da | ||
|
|
5ae25247ef | ||
|
|
e13660993b | ||
|
|
7715952c3f | ||
|
|
2f308c868c | ||
|
|
3ed5dfa84c | ||
|
|
5d7fda521f | ||
|
|
04cd879d11 | ||
|
|
9e72ebb3e0 | ||
|
|
3d3653f51d | ||
|
|
7d4b819a2d | ||
|
|
e92ca9895a | ||
|
|
bf9082067a | ||
|
|
c97d9eb64e | ||
|
|
bfc0c0378e | ||
|
|
d5d7081b35 | ||
|
|
46f6dcdb9d | ||
|
|
f8ba761c2e | ||
|
|
47de8bff7d | ||
|
|
28fc6e29a2 | ||
|
|
ff3dd3c64e |
@@ -658,7 +658,7 @@ Bash scripts in this project are located in:
|
||||
- `.claude/hooks/` - Session lifecycle hooks (build tool installation, linting, PR simplification)
|
||||
|
||||
When writing scripts for this project:
|
||||
- Follow the style guide in `docs/styleguides/bash_styleguide.md` (enforced by shellcheck)
|
||||
- Follow the style guide in `STYLEGUIDE.md` (enforced by shellcheck)
|
||||
- Use existing modular scripts in `scripts/` as patterns for build logic
|
||||
- Reference `build.sh` for architecture detection and package orchestration patterns
|
||||
- Test scripts work on both amd64 and arm64 architectures where applicable
|
||||
|
||||
@@ -6,7 +6,7 @@ model: opus
|
||||
|
||||
You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions.
|
||||
|
||||
**Reference**: Follow the [Bash Style Guide](../../docs/styleguides/bash_styleguide.md)
|
||||
**Reference**: Follow the [Bash Style Guide](../../STYLEGUIDE.md)
|
||||
|
||||
You will analyze recently modified code and apply refinements that:
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Only launch delegates for domains that have changed files in the PR. All domain
|
||||
|
||||
| Changed Files | Agent | What to Ask |
|
||||
|---|---|---|
|
||||
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against `docs/styleguides/bash_styleguide.md` and CLAUDE.md conventions. Report issues with suggested fixes. |
|
||||
| Shell scripts in `scripts/` | `cdd-code-simplifier` | Review against STYLEGUIDE.md and CLAUDE.md conventions. Report issues with suggested fixes. |
|
||||
| JS files in `scripts/` | `electron-linux-specialist` | Review for Electron API correctness, error handling, cross-DE robustness (GNOME, KDE, Xfce, Cinnamon). Note: frame-fix-entry.js is generated by build.sh. |
|
||||
| sed patterns in `build.sh` | `patch-engineer` | Check whitespace tolerance, idempotency guards, dynamic extraction error checks, match specificity, `-E` flag usage. Minified names change between releases — must use regex. |
|
||||
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Check format constraints (RPM version hyphens, AppImage --no-sandbox, deb permissions), cross-format consistency, desktop integration. |
|
||||
@@ -202,12 +202,12 @@ claude-desktop-debian/
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── resources/ # Desktop entries, icons
|
||||
├── CLAUDE.md # Project conventions
|
||||
└── docs/styleguides/bash_styleguide.md # Bash style guide (formerly STYLEGUIDE.md at root)
|
||||
└── STYLEGUIDE.md # Bash style guide
|
||||
# Note: frame-fix-entry.js is generated by build.sh, not a standalone file
|
||||
```
|
||||
|
||||
### Key Conventions
|
||||
- Shell: follows `docs/styleguides/bash_styleguide.md` strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
|
||||
- Shell: follows STYLEGUIDE.md strictly (tabs, 80-char lines, `[[ ]]`, lowercase vars)
|
||||
- JS in scripts/: standalone files using Electron APIs (not minified)
|
||||
- JS in build.sh: sed patterns against minified source (must use regex)
|
||||
- Attribution: reviews end with `Written by Claude <model> via [Claude Code](...)`
|
||||
@@ -222,7 +222,7 @@ claude-desktop-debian/
|
||||
|
||||
| File Type | Delegate To | Focus Area |
|
||||
|-----------|------------|------------|
|
||||
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | `docs/styleguides/bash_styleguide.md` compliance, clarity |
|
||||
| Shell scripts (`scripts/*.sh`) | `cdd-code-simplifier` | STYLEGUIDE.md compliance, clarity |
|
||||
| JS files (`scripts/*.js`) | `electron-linux-specialist` | Electron APIs, cross-DE compatibility |
|
||||
| sed patterns in `build.sh` | `patch-engineer` | Regex robustness, idempotency, extraction |
|
||||
| Packaging scripts (`build-*-package.sh`) | `packaging-specialist` | Format constraints, cross-format consistency |
|
||||
|
||||
@@ -24,7 +24,7 @@ You are a senior Electron and Linux desktop integration specialist with deep exp
|
||||
- **Menu Bar Management**: Hiding/showing menu bars on Linux, `autoHideMenuBar`, `setMenuBarVisibility`, and `Menu.setApplicationMenu` interception.
|
||||
|
||||
**Not in scope** (defer to other agents):
|
||||
- Shell script style and `docs/styleguides/bash_styleguide.md` compliance (defer to `cdd-code-simplifier`)
|
||||
- Shell script style and STYLEGUIDE.md compliance (defer to `cdd-code-simplifier`)
|
||||
- PR review orchestration (defer to `code-reviewer`)
|
||||
- CI/CD workflow YAML and release automation
|
||||
- Debian/RPM package metadata and control files
|
||||
@@ -241,7 +241,7 @@ The `code-reviewer` agent delegates JavaScript file reviews (files in `scripts/`
|
||||
|
||||
This agent provides Electron domain expertise; `cdd-code-simplifier` handles shell style:
|
||||
- This agent specifies WHAT Electron flags/env vars/APIs to use
|
||||
- `cdd-code-simplifier` ensures the shell code implementing them follows `docs/styleguides/bash_styleguide.md`
|
||||
- `cdd-code-simplifier` ensures the shell code implementing them follows STYLEGUIDE.md
|
||||
|
||||
### Providing Guidance on Patches
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ claude-desktop-debian/
|
||||
claude-native-stub.js # Native module replacement
|
||||
.github/workflows/ # CI/CD (defer to ci-workflow-architect)
|
||||
CLAUDE.md # Project conventions
|
||||
docs/styleguides/bash_styleguide.md # Bash style guide
|
||||
STYLEGUIDE.md # Bash style guide
|
||||
```
|
||||
|
||||
### Version String Flow
|
||||
|
||||
@@ -102,7 +102,7 @@ claude-desktop-debian/
|
||||
│ ├── frame-fix-entry.js # Generated entry point (by build.sh)
|
||||
│ └── claude-native-stub.js # Native module replacement
|
||||
├── CLAUDE.md # Project conventions
|
||||
└── docs/styleguides/bash_styleguide.md # Bash style guide
|
||||
└── STYLEGUIDE.md # Bash style guide
|
||||
```
|
||||
|
||||
### Key Files
|
||||
@@ -127,11 +127,11 @@ The electron module variable name changes every release. This extraction finds i
|
||||
|
||||
```bash
|
||||
# Primary: find the variable assigned from require("electron")
|
||||
electron_var=$(grep -oP '\b[$\w]+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
|
||||
electron_var=$(grep -oP '\b\w+(?=\s*=\s*require\("electron"\))' "$index_js" | head -1)
|
||||
|
||||
# Fallback: find it from Tray usage if require pattern doesn't match
|
||||
if [[ -z $electron_var ]]; then
|
||||
electron_var=$(grep -oP '(?<=new )[$\w]+(?=\.Tray\b)' "$index_js" | head -1)
|
||||
electron_var=$(grep -oP '(?<=new )\w+(?=\.Tray\b)' "$index_js" | head -1)
|
||||
fi
|
||||
|
||||
# Always validate
|
||||
@@ -149,13 +149,13 @@ Three connected extractions, each depending on the previous:
|
||||
|
||||
```bash
|
||||
# Step 1: Find the tray rebuild function name from event handler
|
||||
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' "$index_js")
|
||||
tray_func=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
|
||||
# Step 2: Find the tray variable using the function name as anchor
|
||||
tray_var=$(grep -oP "\}\);let \K[\$\w]+(?==null;(?:async )?function ${tray_func})" "$index_js")
|
||||
tray_var=$(grep -oP "\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" "$index_js")
|
||||
|
||||
# Step 3: Find the first const inside the function for insertion point
|
||||
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K[\$\w]+(?==)" "$index_js" | head -1)
|
||||
first_const=$(grep -oP "async function ${tray_func}\(\)\{.*?const \K\w+(?==)" "$index_js" | head -1)
|
||||
```
|
||||
|
||||
Each uses a stable string literal as anchor and captures the adjacent minified name.
|
||||
@@ -206,7 +206,7 @@ Note: `e.hide()` uses a minified variable name `e`, but this is safe because it'
|
||||
```bash
|
||||
# Find all variables used with .nativeTheme that aren't the correct electron var
|
||||
mapfile -t wrong_refs < <(
|
||||
grep -oP '\b[$\w]+(?=\.nativeTheme)' "$index_js" \
|
||||
grep -oP '\b\w+(?=\.nativeTheme)' "$index_js" \
|
||||
| sort -u \
|
||||
| grep -v "^${electron_var}$" || true
|
||||
)
|
||||
@@ -288,7 +288,7 @@ When writing a new patch or modifying an existing one:
|
||||
|
||||
## SHELL STYLE NOTES
|
||||
|
||||
Follow the project's [Bash Style Guide](../../docs/styleguides/bash_styleguide.md) for all shell code:
|
||||
Follow the project's [Bash Style Guide](../../STYLEGUIDE.md) for all shell code:
|
||||
|
||||
- Tabs for indentation
|
||||
- Lines under 80 characters (exception: long regex patterns and URLs)
|
||||
|
||||
@@ -14,7 +14,7 @@ You are NOT a code quality reviewer. You do not evaluate:
|
||||
- Performance characteristics
|
||||
- Best practices or design patterns
|
||||
- Test coverage or test quality
|
||||
- Shell script conventions (`docs/styleguides/bash_styleguide.md` compliance)
|
||||
- Shell script conventions (STYLEGUIDE.md compliance)
|
||||
- Minified JS regex pattern quality
|
||||
|
||||
Those concerns belong to the `code-reviewer` agent, which runs in parallel with you.
|
||||
@@ -235,7 +235,7 @@ Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
|
||||
|
||||
Leave these concerns to the `code-reviewer` agent:
|
||||
- Code quality, style, and formatting
|
||||
- Shell script `docs/styleguides/bash_styleguide.md` compliance
|
||||
- Shell script STYLEGUIDE.md compliance
|
||||
- Regex pattern quality in sed commands
|
||||
- Performance implications
|
||||
- Security vulnerabilities
|
||||
|
||||
@@ -759,7 +759,7 @@ IMPORTANT SCOPE CONSTRAINT: This is for issue #$ISSUE_NUMBER. Only simplify code
|
||||
|
||||
If no relevant files were modified as part of this issue's implementation, make no changes and report 'No changes to simplify'.
|
||||
|
||||
Simplify code for clarity and consistency without changing functionality. Follow docs/styleguides/bash_styleguide.md conventions for shell scripts.
|
||||
Simplify code for clarity and consistency without changing functionality. Follow STYLEGUIDE.md conventions for shell scripts.
|
||||
Output a summary of changes made."
|
||||
|
||||
local simplify_result
|
||||
|
||||
@@ -107,7 +107,7 @@ Before writing the agent, gather domain knowledge and project context:
|
||||
Glob: "scripts/*.sh"
|
||||
Glob: ".github/workflows/*.yml"
|
||||
Grep: "function.*\(\)" # in shell scripts
|
||||
Read: "CLAUDE.md", "README.md", "docs/styleguides/bash_styleguide.md"
|
||||
Read: "CLAUDE.md", "README.md", "STYLEGUIDE.md"
|
||||
|
||||
# Find existing agent patterns
|
||||
Glob: ".claude/agents/*.md"
|
||||
|
||||
@@ -2,8 +2,5 @@
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,.codespellrc
|
||||
check-hidden = true
|
||||
# ignore-regex =
|
||||
# openIn — substring of `openInEditor` IPC channel name (upstream).
|
||||
# YHe — minified function identifier in build-reference anchor.
|
||||
# hel — three-char literal in QE-13 example ("hel (3) submits").
|
||||
ignore-words-list = openIn,YHe,hel
|
||||
# ignore-regex =
|
||||
# ignore-words-list =
|
||||
|
||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -62,12 +62,7 @@
|
||||
# ---- Docs & style ----
|
||||
/README.md @aaddrick
|
||||
/CLAUDE.md @aaddrick
|
||||
/AGENTS.md @aaddrick
|
||||
/CONTRIBUTING.md @aaddrick
|
||||
/CHANGELOG.md @aaddrick
|
||||
/RELEASING.md @aaddrick
|
||||
/SECURITY.md @aaddrick
|
||||
/docs/styleguides/ @aaddrick
|
||||
/STYLEGUIDE.md @aaddrick
|
||||
/docs/ @aaddrick
|
||||
|
||||
# ---- Testing & release quality ----
|
||||
@@ -81,9 +76,9 @@
|
||||
/.github/workflows/tests.yml @sabiut
|
||||
|
||||
# Shared review — either owner can approve.
|
||||
# troubleshooting.md is mostly the --doctor user-facing guide; lint
|
||||
# TROUBLESHOOTING is mostly the --doctor user-facing guide; lint
|
||||
# touches everything, so either maintainer can sign off.
|
||||
/docs/troubleshooting.md @aaddrick @sabiut
|
||||
/docs/TROUBLESHOOTING.md @aaddrick @sabiut
|
||||
/.github/workflows/shellcheck.yml @aaddrick @sabiut
|
||||
|
||||
#===============================================================================
|
||||
|
||||
23
.github/workflows/build-amd64.yml
vendored
23
.github/workflows/build-amd64.yml
vendored
@@ -49,29 +49,6 @@ jobs:
|
||||
fi
|
||||
./build.sh ${{ inputs.build_flags }} $TAG_FLAG
|
||||
|
||||
# Static-grep the shipped asar for the cowork patch markers
|
||||
# defined in scripts/cowork-patch-markers.tsv (issue #559 D6,
|
||||
# PR #555). Pinned to amd64-deb because the patched JS is
|
||||
# identical across formats, so one verification per CI run is
|
||||
# sufficient — no need to duplicate across the matrix.
|
||||
- name: Verify cowork patches in shipped asar
|
||||
if: inputs.artifact_suffix == 'deb'
|
||||
run: |
|
||||
deb_file=$(find . -maxdepth 1 -name 'claude-desktop_*amd64.deb' \
|
||||
-print -quit)
|
||||
if [[ -z "$deb_file" ]]; then
|
||||
echo "verify-patches: no .deb artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
extract_dir=$(mktemp -d)
|
||||
dpkg-deb -x "$deb_file" "$extract_dir"
|
||||
asar_path=$(find "$extract_dir" -name app.asar -print -quit)
|
||||
if [[ -z "$asar_path" ]]; then
|
||||
echo "verify-patches: app.asar not found in deb" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/verify-patches.sh "$asar_path"
|
||||
|
||||
- name: Upload AMD64 Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -674,7 +674,6 @@ jobs:
|
||||
'gpgcheck=1' \
|
||||
'repo_gpgcheck=1' \
|
||||
'gpgkey=https://pkg.claude-desktop-debian.dev/KEY.gpg' \
|
||||
'metadata_expire=1h' \
|
||||
> rpm/claude-desktop.repo
|
||||
|
||||
- name: Re-upload signed RPMs to GitHub Release
|
||||
|
||||
59
.github/workflows/issue-triage-v2.yml
vendored
59
.github/workflows/issue-triage-v2.yml
vendored
@@ -215,7 +215,6 @@ jobs:
|
||||
if: steps.classify.outputs.classification == 'bug' || steps.classify.outputs.classification == 'enhancement'
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
FIRST_PASS: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/classify-doublecheck-bug-vs-enhancement.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -250,7 +249,7 @@ jobs:
|
||||
printf '%s' "${structured}" \
|
||||
> /tmp/triage/classification-doublecheck.json
|
||||
|
||||
first_pass="${FIRST_PASS}"
|
||||
first_pass="${{ steps.classify.outputs.classification }}"
|
||||
verdict=$(jq -r '.verdict' \
|
||||
/tmp/triage/classification-doublecheck.json)
|
||||
|
||||
@@ -272,14 +271,10 @@ jobs:
|
||||
# classifier entirely.
|
||||
- name: Decide route
|
||||
id: route
|
||||
env:
|
||||
SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
DISAGREED: ${{ steps.doublecheck.outputs.disagreed }}
|
||||
run: |
|
||||
suspicious="${SUSPICIOUS}"
|
||||
classification="${CLASSIFICATION}"
|
||||
disagreed="${DISAGREED}"
|
||||
suspicious="${{ steps.suspicious.outputs.suspicious }}"
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
disagreed="${{ steps.doublecheck.outputs.disagreed }}"
|
||||
|
||||
if [[ "${suspicious}" == "true" ]]; then
|
||||
echo "route=deferral" >> "$GITHUB_OUTPUT"
|
||||
@@ -489,7 +484,6 @@ jobs:
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/investigate.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -536,7 +530,7 @@ jobs:
|
||||
# the PR. The reporter named a culprit; the diff is a
|
||||
# primary input for Stage 4 because the defect site is
|
||||
# almost always inside the named PR's changed files.
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
echo "## Regression context (PR named by reporter)"
|
||||
echo ""
|
||||
reg_title=$(jq -r '.title' /tmp/triage/regression-of.json)
|
||||
@@ -769,7 +763,6 @@ jobs:
|
||||
|| steps.dup_fetch.outputs.dup_fetched == 'true')
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/review.json)
|
||||
@@ -874,7 +867,7 @@ jobs:
|
||||
# regression_of diff block — only when Stage 3b validated.
|
||||
# Lets the reviewer check whether a finding's citation
|
||||
# actually lands inside the named PR's changed files.
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
echo "## regression_of PR diff (reporter-named culprit)"
|
||||
echo ""
|
||||
reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json)
|
||||
@@ -1034,37 +1027,25 @@ jobs:
|
||||
# low-confidence cause).
|
||||
- name: Decide comment variant
|
||||
id: decide
|
||||
env:
|
||||
ROUTE: ${{ steps.route.outputs.route }}
|
||||
DEFERRAL_REASON_ID: ${{ steps.route.outputs.deferral_reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
FETCH_OK: ${{ steps.fetch.outputs.fetch_ok }}
|
||||
INVEST_OK: ${{ steps.investigate.outputs.investigate_ok }}
|
||||
DRIFT: ${{ steps.drift.outputs.drift_detected }}
|
||||
REVIEW_OK: ${{ steps.review.outputs.review_ok }}
|
||||
FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }}
|
||||
KEPT: ${{ steps.filter.outputs.review_findings_kept }}
|
||||
AVG: ${{ steps.filter.outputs.review_avg_confidence }}
|
||||
DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }}
|
||||
run: |
|
||||
route="${ROUTE}"
|
||||
route="${{ steps.route.outputs.route }}"
|
||||
|
||||
if [[ "${route}" == "deferral" ]]; then
|
||||
echo "variant=8b" >> "$GITHUB_OUTPUT"
|
||||
echo "reason_id=${DEFERRAL_REASON_ID}" \
|
||||
echo "reason_id=${{ steps.route.outputs.deferral_reason_id }}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
classification="${CLASSIFICATION}"
|
||||
fetch_ok="${FETCH_OK}"
|
||||
invest_ok="${INVEST_OK}"
|
||||
drift="${DRIFT}"
|
||||
review_ok="${REVIEW_OK}"
|
||||
findings_passed="${FINDINGS_PASSED}"
|
||||
kept="${KEPT}"
|
||||
avg="${AVG}"
|
||||
dup_rating="${DUP_RATING}"
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
fetch_ok="${{ steps.fetch.outputs.fetch_ok }}"
|
||||
invest_ok="${{ steps.investigate.outputs.investigate_ok }}"
|
||||
drift="${{ steps.drift.outputs.drift_detected }}"
|
||||
review_ok="${{ steps.review.outputs.review_ok }}"
|
||||
findings_passed="${{ steps.validate.outputs.findings_passed }}"
|
||||
kept="${{ steps.filter.outputs.review_findings_kept }}"
|
||||
avg="${{ steps.filter.outputs.review_avg_confidence }}"
|
||||
dup_rating="${{ steps.filter.outputs.duplicate_of_rating }}"
|
||||
|
||||
# Shared gates that apply to every investigate route.
|
||||
if [[ "${fetch_ok}" != "true" ]]; then
|
||||
@@ -1754,11 +1735,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REASON_ID: ${{ steps.decide.outputs.reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
VARIANT: ${{ steps.decide.outputs.variant }}
|
||||
run: |
|
||||
classification="${CLASSIFICATION}"
|
||||
variant="${VARIANT}"
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
variant="${{ steps.decide.outputs.variant }}"
|
||||
|
||||
if [[ "${variant}" == "8a" ]]; then
|
||||
triage_label="triage: investigated"
|
||||
|
||||
3
.github/workflows/test-artifacts.yml
vendored
3
.github/workflows/test-artifacts.yml
vendored
@@ -44,8 +44,7 @@ jobs:
|
||||
if: matrix.format != 'rpm'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y file libfuse2 nodejs npm \
|
||||
xvfb dbus-x11 procps
|
||||
sudo apt-get install -y file libfuse2 nodejs npm
|
||||
|
||||
- name: Run artifact tests
|
||||
run: |
|
||||
|
||||
6
.github/workflows/test-flags.yml
vendored
6
.github/workflows/test-flags.yml
vendored
@@ -4,12 +4,6 @@ on:
|
||||
workflow_call: # Make this workflow reusable
|
||||
workflow_dispatch: # Allows manual triggering for testing
|
||||
|
||||
concurrency:
|
||||
group: test-flags-${{ github.ref }}
|
||||
# Matches ci.yml: queue rather than cancel, so a reusable invocation
|
||||
# from an in-flight CI run isn't killed mid-flight on the next push.
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test-flags:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -24,13 +24,6 @@ Thumbs.db
|
||||
# Test build output
|
||||
test-build/
|
||||
|
||||
# Playwright stray output — the harness writes to
|
||||
# tools/test-harness/results/ per playwright.config.ts, but Playwright
|
||||
# also drops a default `test-results/.last-run.json` next to the cwd
|
||||
# it's invoked from. Ignore it at the repo root so an accidental run
|
||||
# from here doesn't dirty the tree.
|
||||
test-results/
|
||||
|
||||
# Reference files for source inspection
|
||||
build-reference/
|
||||
|
||||
@@ -40,3 +33,7 @@ result-*
|
||||
|
||||
# Wrangler (Cloudflare Worker dev/deploy cache)
|
||||
worker/.wrangler/
|
||||
|
||||
# UI snapshots — captured renderer state, intentionally ignored to avoid
|
||||
# diff churn. See docs/testing/ui-snapshots/README.md.
|
||||
docs/testing/ui-snapshots/*.json
|
||||
|
||||
493
AGENTS.md
493
AGENTS.md
@@ -1,492 +1,13 @@
|
||||
# AGENTS.md
|
||||
|
||||
<!--
|
||||
This file is read by AI tools that support the agents.md vendor-neutral
|
||||
standard. The content below is duplicated in CLAUDE.md (read by Claude
|
||||
Code) so that contributors using either receive the same instructions
|
||||
without needing to cross-reference. Keep CLAUDE.md and AGENTS.md
|
||||
byte-identical below the H1 title (the sync-policy comment above is the
|
||||
one place they intentionally differ) — if you edit one, edit the other.
|
||||
-->
|
||||
All project instructions, conventions, and development guidelines are maintained in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Required reading
|
||||
Strictly follow the rules defined there.
|
||||
|
||||
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
|
||||
## Project Tooling
|
||||
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
|
||||
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
|
||||
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
|
||||
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
|
||||
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
|
||||
Subagent definitions, skills, and orchestration scripts live in [`.claude/`](.claude/):
|
||||
|
||||
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
|
||||
|
||||
## Learnings
|
||||
|
||||
The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical knowledge from debugging and fixing issues — things that aren't obvious from reading the code or docs alone. Consult these before working on related areas. Add new entries when you discover something non-obvious that would save future contributors (human or AI) significant time.
|
||||
|
||||
- [`nix.md`](docs/learnings/nix.md) — NixOS packaging, Electron resource path resolution, testing without NixOS
|
||||
- [`cowork-vm-daemon.md`](docs/learnings/cowork-vm-daemon.md) — Cowork VM daemon lifecycle, respawn logic, crash diagnosis
|
||||
- [`plugin-install.md`](docs/learnings/plugin-install.md) — Anthropic & Partners plugin install flow, gate logic, backend endpoints, and DevTools recipes
|
||||
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
|
||||
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
|
||||
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
|
||||
|
||||
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
|
||||
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
|
||||
- Single quotes for literals, double quotes for expansions
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
|
||||
1. **Fix the code** - Correct the underlying issue rather than suppressing the warning
|
||||
2. **Disable directives are a last resort** - Only use `# shellcheck disable=SCXXXX` when:
|
||||
- The warning is a false positive
|
||||
- The pattern is intentional and unavoidable
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
|
||||
- Use `gh` CLI for all GitHub interactions
|
||||
- Create branches based on issue numbers: `fix/123-description` or `feature/123-description`
|
||||
- Reference issues in commits and PRs with `#123` or `Fixes #123`
|
||||
- After creating a PR, add a comment to the related issue with a summary and link to the PR
|
||||
|
||||
### Investigating Issues
|
||||
|
||||
For older issues, review the state of the code when the issue was raised - it may have already been addressed:
|
||||
|
||||
```bash
|
||||
# Get issue creation date
|
||||
gh issue view 123 --json createdAt
|
||||
|
||||
# Find the commit just before the issue was created
|
||||
git log --oneline --until="2025-08-23T08:48:35Z" -1
|
||||
|
||||
# View a file at that point in time
|
||||
git show <commit>:path/to/file.sh
|
||||
|
||||
# Search for relevant changes since the issue was created
|
||||
git log --oneline --after="2025-08-23" -- path/to/file.sh
|
||||
|
||||
# View a specific commit that may have fixed the issue
|
||||
git show <commit>
|
||||
```
|
||||
|
||||
This helps identify if the issue was already fixed, and allows referencing the specific commit in the response.
|
||||
|
||||
### Attribution
|
||||
|
||||
**For PR descriptions**, include full attribution:
|
||||
|
||||
```
|
||||
---
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
|
||||
<XX>% AI / <YY>% Human
|
||||
Claude: <what AI did>
|
||||
Human: <what human did>
|
||||
```
|
||||
|
||||
- Use the actual model name (e.g., `Claude Opus 4.5`, `Claude Sonnet 4`)
|
||||
- The percentage split should honestly reflect the contribution balance for that specific work
|
||||
- This provides a trackable record of AI-assisted development over time
|
||||
|
||||
**For issues and comments**, use simplified attribution:
|
||||
|
||||
```
|
||||
---
|
||||
Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
|
||||
```
|
||||
|
||||
**For commits**, include a Co-Authored-By trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Claude <claude@anthropic.com>
|
||||
```
|
||||
|
||||
### Contributor Credits
|
||||
|
||||
The README Acknowledgments section credits external contributors in chronological order (by merge date or fix date). Update it when:
|
||||
|
||||
1. **Merging an external PR** — Add the author to the Acknowledgments list with a link to their GitHub profile and a brief description of their contribution.
|
||||
2. **Implementing a fix suggested in an issue** — If an issue author (or commenter) provided a concrete fix, workaround, code snippet, or detailed technical analysis that was directly used, credit them too.
|
||||
|
||||
Contributors are listed in chronological order: inspirational projects first (k3d3, emsi, leobuskin), then contributors ordered by when their contribution was merged or implemented.
|
||||
|
||||
## Working with Minified JavaScript
|
||||
|
||||
### Important Guidelines
|
||||
|
||||
1. **Always use regex patterns** when modifying the source JavaScript. Patches live in `scripts/patches/*.sh` (one file per subsystem: `tray.sh`, `cowork.sh`, `claude-code.sh`, etc.); `build.sh` is only an orchestrator that sources them. Variable and function names are minified and **change between releases**.
|
||||
|
||||
2. **The beautified code in `build-reference/` has different spacing** than the actual minified code in the app. Patterns must handle both:
|
||||
- Minified: `oe.nativeTheme.on("updated",()=>{`
|
||||
- Beautified: `oe.nativeTheme.on("updated", () => {`
|
||||
|
||||
3. **Use `-E` flag with sed** for extended regex support when patterns need grouping or alternation.
|
||||
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
```bash
|
||||
# Bad: assumes no spaces
|
||||
sed -i 's/oe.nativeTheme.on("updated",()=>{/...'
|
||||
|
||||
# Good: handles optional whitespace
|
||||
sed -i -E 's/(oe\.nativeTheme\.on\(\s*"updated"\s*,\s*\(\)\s*=>\s*\{)/...'
|
||||
```
|
||||
|
||||
### Reference Files
|
||||
|
||||
- `build-reference/app-extracted/` - Extracted and beautified source for analysis
|
||||
- `build-reference/tray-icons/` - Tray icon assets for reference
|
||||
|
||||
## Frame Fix Wrapper
|
||||
|
||||
The app uses a wrapper system to intercept and fix Electron behavior for Linux:
|
||||
|
||||
- **`frame-fix-wrapper.js`** - Intercepts `require('electron')` to patch BrowserWindow defaults (e.g., `frame: true` for proper window decorations on Linux)
|
||||
- **`frame-fix-entry.js`** - Entry point that loads the wrapper before the main app
|
||||
|
||||
These are injected by `scripts/patches/app-asar.sh` (inside `patch_app_asar`) and referenced in `package.json`'s `main` field. The wrapper pattern allows fixing Electron behavior without modifying the minified app code directly.
|
||||
|
||||
## Setting Up build-reference
|
||||
|
||||
If `build-reference/` is missing or you need to inspect source for a new version, follow these steps to download, extract, and beautify the source code.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Install required tools
|
||||
sudo apt install p7zip-full wget nodejs npm
|
||||
|
||||
# Install asar and prettier globally (or use npx)
|
||||
npm install -g @electron/asar prettier
|
||||
```
|
||||
|
||||
### Step 1: Download the Windows Installer
|
||||
|
||||
The Windows installer contains the app.asar which has the full Electron app source.
|
||||
|
||||
```bash
|
||||
# Create working directory
|
||||
mkdir -p build-reference && cd build-reference
|
||||
|
||||
# Download URL pattern (update version as needed):
|
||||
# x64: https://downloads.claude.ai/releases/win32/x64/VERSION/Claude-COMMIT.exe
|
||||
# arm64: https://downloads.claude.ai/releases/win32/arm64/VERSION/Claude-COMMIT.exe
|
||||
|
||||
# Example for version 1.1.381:
|
||||
wget -O Claude-Setup-x64.exe "https://downloads.claude.ai/releases/win32/x64/1.1.381/Claude-c2a39e9c82f5a4d51f511f53f532afd276312731.exe"
|
||||
```
|
||||
|
||||
### Step 2: Extract the Installer
|
||||
|
||||
```bash
|
||||
# Extract the exe (it's a 7z archive)
|
||||
7z x -y Claude-Setup-x64.exe -o"exe-contents"
|
||||
|
||||
# Find and extract the nupkg
|
||||
cd exe-contents
|
||||
NUPKG=$(find . -name "AnthropicClaude-*.nupkg" | head -1)
|
||||
7z x -y "$NUPKG" -o"nupkg-contents"
|
||||
cd ..
|
||||
|
||||
# Copy out the important files
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/app.asar .
|
||||
cp -a exe-contents/nupkg-contents/lib/net45/resources/app.asar.unpacked .
|
||||
|
||||
# Optional: copy tray icons for reference
|
||||
mkdir -p tray-icons
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/*.png tray-icons/ 2>/dev/null || true
|
||||
cp exe-contents/nupkg-contents/lib/net45/resources/*.ico tray-icons/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
### Step 3: Extract app.asar
|
||||
|
||||
```bash
|
||||
# Extract the asar archive
|
||||
asar extract app.asar app-extracted
|
||||
```
|
||||
|
||||
### Step 4: Beautify the JavaScript Files
|
||||
|
||||
The extracted JS files are minified. Use prettier to make them readable:
|
||||
|
||||
```bash
|
||||
# Beautify all JS files in the build directory
|
||||
npx prettier --write "app-extracted/.vite/build/*.js"
|
||||
|
||||
# Or beautify specific files
|
||||
npx prettier --write app-extracted/.vite/build/index.js
|
||||
npx prettier --write app-extracted/.vite/build/mainWindow.js
|
||||
```
|
||||
|
||||
### Step 5: Clean Up (Optional)
|
||||
|
||||
```bash
|
||||
# Remove intermediate files, keep only what's needed for reference
|
||||
rm -rf exe-contents
|
||||
rm Claude-Setup-x64.exe
|
||||
rm -rf app.asar app.asar.unpacked # Keep only app-extracted
|
||||
```
|
||||
|
||||
### Final Structure
|
||||
|
||||
```
|
||||
build-reference/
|
||||
├── app-extracted/
|
||||
│ ├── .vite/
|
||||
│ │ ├── build/
|
||||
│ │ │ ├── index.js # Main process (beautified)
|
||||
│ │ │ ├── mainWindow.js # Main window preload
|
||||
│ │ │ ├── mainView.js # Main view preload
|
||||
│ │ │ └── ...
|
||||
│ │ └── renderer/
|
||||
│ │ └── ...
|
||||
│ ├── node_modules/
|
||||
│ │ └── @ant/claude-native/ # Native bindings (stubs)
|
||||
│ └── package.json
|
||||
├── tray-icons/
|
||||
│ ├── TrayIconTemplate.png # Black icon (for light panels)
|
||||
│ ├── TrayIconTemplate-Dark.png # White icon (for dark panels)
|
||||
│ └── ...
|
||||
└── nupkg-contents/ # Optional: full extracted nupkg
|
||||
```
|
||||
|
||||
## Adding New Package Formats or Repositories
|
||||
|
||||
When adding support for new distribution formats (e.g., RPM, Flatpak, Snap) or package repositories, follow these guidelines to avoid iterative debugging in CI.
|
||||
|
||||
### Research Before Implementing
|
||||
|
||||
1. **Understand the target system's constraints** - Each package format has specific rules:
|
||||
- Version string formats (e.g., RPM cannot have hyphens in Version field)
|
||||
- Required metadata fields
|
||||
- Signing requirements and tools
|
||||
|
||||
2. **Search for existing CI implementations** - Look for "GitHub Actions [format] signing" or similar. Existing workflows reveal required flags, environment setup, and common pitfalls.
|
||||
|
||||
3. **Check tool behavior in non-interactive environments** - CI has no TTY. Tools like GPG need flags like `--batch` and `--yes` to work without prompts.
|
||||
|
||||
### Consider Concurrency
|
||||
|
||||
1. **Multiple jobs writing to the same branch will race** - If APT and DNF repos both push to `gh-pages`, add:
|
||||
- Job dependencies (`needs: [other-job]`), or
|
||||
- Retry loops with `git pull --rebase` before push
|
||||
|
||||
2. **External processes may also modify branches** - GitHub Pages deployment runs automatically and can cause push conflicts.
|
||||
|
||||
### Test the Full Pipeline
|
||||
|
||||
1. **Test CI steps locally first** - Run the signing/packaging commands manually to catch errors before committing.
|
||||
|
||||
2. **Use a test tag for new infrastructure** - Create a non-release tag to validate the full CI pipeline before merging to main.
|
||||
|
||||
3. **Verify the end-user experience** - After CI succeeds, actually test the install commands from the README on a clean system.
|
||||
|
||||
### Common CI Pitfalls
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| GPG "cannot open /dev/tty" | Add `--batch` flag |
|
||||
| GPG "File exists" error | Add `--yes` flag to overwrite |
|
||||
| Push rejected (ref changed) | Add `git pull --rebase` before push, with retry loop |
|
||||
| Version format invalid | Research target format's version constraints upfront |
|
||||
| Signing key not found | Ensure key is imported before signing step, check key ID output |
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Triggering Builds
|
||||
|
||||
```bash
|
||||
# Trigger CI on a branch
|
||||
gh workflow run CI --ref branch-name
|
||||
|
||||
# Watch the run
|
||||
gh run watch RUN_ID
|
||||
|
||||
# Download artifacts
|
||||
gh run download RUN_ID -n artifact-name
|
||||
```
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
- `claude-desktop-VERSION-amd64.deb` - Debian package for x86_64
|
||||
- `claude-desktop-VERSION-amd64.AppImage` - AppImage for x86_64
|
||||
- `claude-desktop-VERSION-arm64.deb` - Debian package for ARM64
|
||||
- `claude-desktop-VERSION-arm64.AppImage` - AppImage for ARM64
|
||||
- `result/` - Nix build output (symlink, gitignored)
|
||||
|
||||
## Distribution
|
||||
|
||||
APT and DNF binaries are fronted by a Cloudflare Worker at `pkg.claude-desktop-debian.dev`. Metadata (`InRelease`, `Packages`, `KEY.gpg`, `repodata/*`) passes through to the `gh-pages` branch; binary requests (`/pool/.../*.deb`, `/rpm/*/*.rpm`) get 302'd to the corresponding GitHub Release asset. This keeps `.deb` / `.rpm` files out of `gh-pages` entirely, so they never hit GitHub's 100 MB per-file push cap.
|
||||
|
||||
Key files:
|
||||
- `worker/src/worker.js` — Worker source
|
||||
- `worker/wrangler.toml` — Worker config (route, `custom_domain = true`)
|
||||
- `.github/workflows/deploy-worker.yml` — deploys on push to `main` when `worker/**` changes
|
||||
- `.github/workflows/apt-repo-heartbeat.yml` — daily chain validation, auto-opens tracking issue on failure
|
||||
- `update-apt-repo` and `update-dnf-repo` jobs in `.github/workflows/ci.yml` — gate a strip step on Worker liveness, so binaries are removed from the local pool tree before push
|
||||
|
||||
Repo secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`. Token scoped to the "Edit Cloudflare Workers" template.
|
||||
|
||||
Full details including the redirect chain, the http-scheme-downgrade gotcha, credential ownership, and heartbeat failure runbook: [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md).
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Build
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no
|
||||
```
|
||||
|
||||
### Nix Build
|
||||
|
||||
```bash
|
||||
nix build .#claude-desktop
|
||||
nix build .#claude-desktop-fhs
|
||||
```
|
||||
|
||||
### Testing AppImage
|
||||
|
||||
```bash
|
||||
# Run with logging
|
||||
./test-build/claude-desktop-*.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
### Inspecting the Running App's Code
|
||||
|
||||
```bash
|
||||
# Find the mounted AppImage path
|
||||
mount | grep claude
|
||||
# Example: /tmp/.mount_claudeXXXXXX
|
||||
|
||||
# Extract the running app's asar for inspection
|
||||
npx asar extract /tmp/.mount_claudeXXXXXX/usr/lib/node_modules/electron/dist/resources/app.asar /tmp/claude-inspect
|
||||
|
||||
# Search for patterns in the extracted code
|
||||
grep -n "pattern" /tmp/claude-inspect/.vite/build/index.js
|
||||
```
|
||||
|
||||
### Checking DBus/Tray Status
|
||||
|
||||
```bash
|
||||
# List registered tray icons
|
||||
gdbus call --session --dest=org.kde.StatusNotifierWatcher \
|
||||
--object-path=/StatusNotifierWatcher \
|
||||
--method=org.freedesktop.DBus.Properties.Get \
|
||||
org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems
|
||||
|
||||
# Find which process owns a DBus connection
|
||||
gdbus call --session --dest=org.freedesktop.DBus \
|
||||
--object-path=/org/freedesktop/DBus \
|
||||
--method=org.freedesktop.DBus.GetConnectionUnixProcessID ":1.XXXX"
|
||||
```
|
||||
|
||||
### Log Locations
|
||||
|
||||
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
|
||||
- App logs: `~/.config/Claude/logs/`
|
||||
- Run with logging: `./app.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log`
|
||||
|
||||
## Useful Locations
|
||||
|
||||
- App data: `~/.config/Claude/`
|
||||
- Logs: `~/.config/Claude/logs/`
|
||||
- SingletonLock: `~/.config/Claude/SingletonLock`
|
||||
- Launcher log: `~/.cache/claude-desktop-debian/launcher.log`
|
||||
|
||||
## Versioning
|
||||
|
||||
Release versions are managed via two GitHub Actions repository variables (not files):
|
||||
|
||||
- **`REPO_VERSION`** - The project's own version (e.g., `1.3.23`). Bump this manually via `gh variable set REPO_VERSION --body "X.Y.Z"` when shipping project changes.
|
||||
- **`CLAUDE_DESKTOP_VERSION`** - The upstream Claude Desktop version (e.g., `1.1.8629`). Updated automatically by the `check-claude-version` workflow when a new upstream release is detected.
|
||||
|
||||
### Tag format
|
||||
|
||||
Tags follow the pattern `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}`, e.g., `v1.3.23+claude1.1.7714`. Pushing a tag triggers the CI release build.
|
||||
|
||||
```bash
|
||||
# Check current values
|
||||
gh variable get REPO_VERSION
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
|
||||
# Bump repo version and tag a release
|
||||
gh variable set REPO_VERSION --body "1.3.24"
|
||||
git tag "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
git push origin "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
```
|
||||
|
||||
When upstream Claude Desktop updates, the `check-claude-version` workflow automatically updates `CLAUDE_DESKTOP_VERSION`, patches the URLs in `scripts/setup/detect-host.sh`, and creates a new tag — no manual intervention needed.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **`.zsync` files** - Used for delta updates, can be ignored/deleted
|
||||
- **AppImage mount points** - Running AppImages mount to `/tmp/.mount_claude*`; check with `mount | grep claude`
|
||||
- **Killing the app** - Must kill all electron child processes, not just the main one:
|
||||
```bash
|
||||
pkill -9 -f "mount_claude"
|
||||
```
|
||||
- **SingletonLock** - If app won't start, check for stale lock: `~/.config/Claude/SingletonLock`
|
||||
- **Node version** - Build requires Node.js; the script downloads its own if needed
|
||||
- **Nix hashes** - When Claude Desktop version changes, both the URLs in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix` (version, URLs, SRI hashes) must be updated. The CI handles this automatically.
|
||||
- **Claude Desktop version** - A GitHub Action automatically updates the `CLAUDE_DESKTOP_VERSION` repo variable and the URLs in `scripts/setup/detect-host.sh` on main when a new version is detected. Before committing `scripts/setup/detect-host.sh`, ensure your branch has the latest URLs:
|
||||
```bash
|
||||
# Check repo variable (source of truth)
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
|
||||
# Check current version in the detect_architecture case statement
|
||||
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
|
||||
|
||||
# If outdated, pull URLs from main branch
|
||||
gh api repos/aaddrick/claude-desktop-debian/contents/scripts/setup/detect-host.sh?ref=main \
|
||||
--jq '.content' | base64 -d | grep -E "claude_download_url="
|
||||
```
|
||||
Update both amd64 and arm64 URLs in `detect_architecture()` to match main
|
||||
- `.claude/agents/` - Specialized subagent definitions for the Task tool
|
||||
- `.claude/skills/` - User-invocable skills (slash commands)
|
||||
- `.claude/scripts/` - Orchestration scripts that chain multiple Claude CLI calls
|
||||
|
||||
280
CHANGELOG.md
280
CHANGELOG.md
@@ -1,280 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `aaddrick/claude-desktop-debian` are documented in this file.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — semantic versioning applies to `REPO_VERSION`; upstream Claude Desktop bumps (the `+claude{X.Y.Z}` suffix on the tag) are tracked separately by the `check-claude-version` workflow.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
<!-- Updated automatically by check-claude-version; will be current at release time. -->
|
||||
|
||||
## [v2.0.15] — 2026-05-27
|
||||
|
||||
Tracks upstream Claude Desktop 1.9255.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `StartupWMClass` aligned to `Claude` to match what Electron actually advertises via `productName`. The v2.0.14 value `claude-desktop` was silently ignored by Electron, causing orphan windows and duplicate gear icons on GNOME/KDE. Value centralized from 6 hardcoded locations to one source of truth in `build.sh`, with build-time substitution and a `productName` assertion guard. ([#655](https://github.com/aaddrick/claude-desktop-debian/pull/655), fixes [#652](https://github.com/aaddrick/claude-desktop-debian/issues/652))
|
||||
|
||||
## [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
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -1,26 +1,5 @@
|
||||
# Claude Desktop Debian - Development Notes
|
||||
|
||||
<!--
|
||||
This file is read by Claude Code. The content below is duplicated in
|
||||
AGENTS.md (read by other AI tools per the agents.md standard) so that
|
||||
contributors using either receive the same instructions without needing
|
||||
to cross-reference. Keep CLAUDE.md and AGENTS.md byte-identical below
|
||||
the H1 title (the sync-policy comment above is the one place they
|
||||
intentionally differ) — if you edit one, edit the other.
|
||||
-->
|
||||
|
||||
## Required reading
|
||||
|
||||
These documents are the source of truth. If anything in this file conflicts with them, they win. Read them before opening a non-trivial issue or PR.
|
||||
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — what we accept, what goes upstream, subsystem owners, AI-attribution policy.
|
||||
- [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md) — shell-script conventions (forked from YSAP). Tabs, 80 cols, `[[ ]]`, no `set -e`, no `eval`.
|
||||
- [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) — page anatomy, naming, antipatterns for the `docs/` tree.
|
||||
- [`docs/index.md`](docs/index.md) — entry point for the rest of the repo docs.
|
||||
- [`SECURITY.md`](SECURITY.md) — vulnerability reporting; what's in scope vs. upstream.
|
||||
|
||||
This file is a fast reference for the highest-leverage rules and the project's accumulated archaeology. New policy goes in the style guides or CONTRIBUTING.md.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
|
||||
@@ -38,11 +17,10 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
All shell scripts in this project must follow the [Bash Style Guide](docs/styleguides/bash_styleguide.md). Key points:
|
||||
All shell scripts in this project must follow the [Bash Style Guide](STYLEGUIDE.md). Key points:
|
||||
|
||||
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
|
||||
- Use `[[ ]]` for conditionals, `$(...)` for command substitution
|
||||
@@ -50,16 +28,6 @@ All shell scripts in this project must follow the [Bash Style Guide](docs/styleg
|
||||
- Lowercase variables; UPPERCASE only for constants/exports
|
||||
- Use `local` in functions, avoid `set -e` and `eval`
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Don't `set -e`.** It interacts badly with `$(...)` capture and function return values, and the project has historically debugged enough silent exits to settle the question. Check status explicitly: `cmd || handle_err`.
|
||||
- **Don't `eval`.** Use arrays for argv composition (`cmd "${args[@]}"`). `eval` defeats every parser and is a permanent SC2046 magnet.
|
||||
- **Don't use POSIX `[ ... ]`.** Always `[[ ... ]]`. POSIX `[` mis-parses unquoted expansions in ways `[[` does not.
|
||||
- **Don't backtick.** Always `$(...)`. Backticks don't nest cleanly and conflict with markdown when patches are pasted into PR comments.
|
||||
- **Don't hardcode the work directory.** Scripts that operate during a build use `$work_dir` (set by `build.sh`). A hardcoded path silently breaks the AppImage build, which runs in a different layout from the deb/rpm builds.
|
||||
- **Don't wrap commands in `if cmd; then true; else false; fi`-style scaffolding.** Just `cmd` — the exit code is already there.
|
||||
- **Don't append to a baseline file to silence `shellcheck`.** Fix the underlying issue. If a warning is genuinely a false positive, use a per-line `# shellcheck disable=SCXXXX` with a comment explaining why.
|
||||
|
||||
### Linting
|
||||
|
||||
Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `actionlint` before pushing. When lint issues are found:
|
||||
@@ -71,16 +39,6 @@ Shell scripts are checked with `shellcheck` and GitHub Actions workflows with `a
|
||||
- Always add a comment explaining why the disable is needed
|
||||
3. **Run `/lint` to check manually** - Use this skill to check for issues before pushing
|
||||
|
||||
## Docs
|
||||
|
||||
- **One declarative sentence then a code block or list at the top of every page.** No "In this guide we will explore…" preamble. See [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md).
|
||||
- **Lowercase kebab-case filenames** for everything in `docs/`. Order belongs in [`docs/index.md`](docs/index.md), not filenames or numeric prefixes.
|
||||
- **Real domain nouns over `foo`/`bar`** in walkthroughs. The project vocabulary is `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`, `the doctor surface`.
|
||||
- **Subsystem deep-dives go under [`docs/learnings/`](docs/learnings/).** Surfacing knowledge there beats burying it in commit messages or in patch-script comments. Add an entry when you discover something non-obvious that would save the next contributor significant time.
|
||||
- **Decisions go in [`docs/decisions.md`](docs/decisions.md) (ADR format).** Don't relitigate a settled direction inside a how-to page; link the decision instead.
|
||||
- **Troubleshooting headings are the literal symptom**, not editorialized prose. `## Black screen on Fedora KDE under Wayland`, not `## Troubles with Wayland`. Search ranks headings.
|
||||
- **CHANGELOG follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).** Bullets grouped under Added / Fixed / Changed / Deprecated / Removed / Security; one bullet per change; PR link for the deep dive; inline **BREAKING** prefix for breaking changes. See [`CHANGELOG.md`](CHANGELOG.md) for the current state and [`RELEASING.md`](RELEASING.md) for when entries get promoted from `[Unreleased]`.
|
||||
|
||||
## GitHub Workflow
|
||||
|
||||
### General Approach
|
||||
@@ -167,7 +125,7 @@ Contributors are listed in chronological order: inspirational projects first (k3
|
||||
4. **Extract variable names dynamically** rather than hardcoding them. Shared extraction helpers live in `scripts/patches/_common.sh`. Example:
|
||||
```bash
|
||||
# Extract function name from a known pattern
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K[$\w]+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' app.asar.contents/.vite/build/index.js)
|
||||
```
|
||||
|
||||
5. **Handle optional whitespace** in regex patterns:
|
||||
|
||||
161
CONTRIBUTING.md
161
CONTRIBUTING.md
@@ -1,161 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Before you start
|
||||
|
||||
A few minutes here saves a round-trip later. Match your task to the right channel:
|
||||
|
||||
- **Found a bug?** File an [issue](https://github.com/aaddrick/claude-desktop-debian/issues/new/choose)
|
||||
with the bug template. Paste full `claude-desktop --doctor` output;
|
||||
include distro, DE, and session type (Wayland/X11). See
|
||||
[Filing an issue](#filing-an-issue).
|
||||
- **Have a fix in hand?** PRs that fix existing behaviour, restore parity
|
||||
with Windows/macOS, or improve packaging are always welcome. Open the
|
||||
PR; an issue isn't strictly required if the fix is small.
|
||||
- **Want to add a new feature?** Open a [discussion](https://github.com/aaddrick/claude-desktop-debian/discussions)
|
||||
or an issue first. We're a repackager; most net-new behaviour is
|
||||
declined by default — see [What we accept](#what-we-accept).
|
||||
- **Security concern?** Don't file a public issue. Use
|
||||
[SECURITY.md](SECURITY.md) — GitHub Security Advisories route to
|
||||
@aaddrick privately.
|
||||
|
||||
## Where to find what
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md): conventions, build, patches, attribution.
|
||||
- [AGENTS.md](AGENTS.md): vendor-neutral mirror of CLAUDE.md for non-Claude AI tools.
|
||||
- [docs/index.md](docs/index.md): full docs entry point.
|
||||
- [docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md):
|
||||
bash style ([style.ysap.sh](https://style.ysap.sh)). Tabs, 80 cols, `[[ ]]`, no `set -e`.
|
||||
- [docs/styleguides/docs_styleguide.md](docs/styleguides/docs_styleguide.md):
|
||||
page anatomy and naming if you're adding a doc.
|
||||
- [docs/learnings/](docs/learnings/): subsystem deep-dives. Read the
|
||||
relevant entry first.
|
||||
- [docs/building.md](docs/building.md): local build setup.
|
||||
- [docs/decisions.md](docs/decisions.md): architectural choices (ADR format).
|
||||
- [CHANGELOG.md](CHANGELOG.md): release-grouped history from v2.0.0 onward.
|
||||
- [RELEASING.md](RELEASING.md): how a release ships (tag-driven CI).
|
||||
- [SECURITY.md](SECURITY.md): private vulnerability reporting.
|
||||
- [.github/CODEOWNERS](.github/CODEOWNERS): auto-review routing.
|
||||
|
||||
## What we accept
|
||||
|
||||
We're a repackager, not a fork. Net-new feature PRs default to no: we'd
|
||||
own that behaviour across every re-minified upstream release.
|
||||
Exception: parity patches for Windows features broken on Linux
|
||||
(input methods, tray on Wayland/X11, frame defaults). Always welcome:
|
||||
|
||||
- Bug fixes against existing behaviour.
|
||||
- Parity patches bringing Linux closer to the Windows build.
|
||||
- Packaging, distribution, launcher fixes.
|
||||
- Docs, tests, CI improvements.
|
||||
|
||||
## What goes upstream, not here
|
||||
|
||||
We patch the binary blob; we don't fix application logic inside it.
|
||||
If the bug reproduces on Windows, file at
|
||||
[anthropics/claude-code](https://github.com/anthropics/claude-code).
|
||||
In-app `/bug` and `/feedback` are inert.
|
||||
|
||||
| File here | File upstream |
|
||||
|----------------------------------------|-------------------------------------|
|
||||
| `apt update` errors, install failures | Plugin install fails on all OSes |
|
||||
| Tray icon missing on KDE Wayland | Conversation rendering glitch |
|
||||
| AppImage won't launch on distro X | MCP server connection drops |
|
||||
| `--doctor` reports wrong diagnosis | Account / login flow broken |
|
||||
|
||||
## Filing an issue
|
||||
|
||||
1. Use the issue template, not freeform.
|
||||
2. Paste full `./build.sh --doctor` (or `claude-desktop --doctor`)
|
||||
output. Most-skipped step.
|
||||
3. Include distro, DE, session type (Wayland/X11). Most Linux-only
|
||||
bugs trace to one of these.
|
||||
4. Reproduce on a clean config: move `~/.config/Claude` aside, relaunch.
|
||||
Stale config causes false positives.
|
||||
|
||||
## Patches against upstream
|
||||
|
||||
Patches live in `scripts/patches/*.sh`, one per subsystem; `build.sh`
|
||||
sources them. Before writing or editing one, read [the
|
||||
patching-minified-js learnings doc][pmj]: anchor selection, capture,
|
||||
idempotency, beautified-vs-minified gap. Short form: CLAUDE.md §
|
||||
Working with Minified JavaScript.
|
||||
|
||||
Priority rule: a broken-patch upstream release beats feature work.
|
||||
|
||||
## Subsystem owners
|
||||
|
||||
CODEOWNERS auto-requests reviews; this list is for human discoverability.
|
||||
|
||||
- **@aaddrick**: default. Build, non-Cowork patches, desktop, packaging, docs.
|
||||
- **@sabiut**: `tests/`, `scripts/doctor.sh`, test workflows.
|
||||
- **@RayCharlizard**: Cowork (`scripts/patches/cowork.sh`,
|
||||
`scripts/cowork-vm-service.js`, `tests/cowork-*.bats`).
|
||||
- **@typedrat**: Nix (`flake.nix`, `flake.lock`, `/nix/`).
|
||||
|
||||
## Before submitting a PR
|
||||
|
||||
- Run `/lint` (or `shellcheck` + `actionlint`). See CLAUDE.md § Linting.
|
||||
- Local build: `./build.sh --build appimage --clean no`. Catches
|
||||
patch failures unit tests miss.
|
||||
- Branch: `fix/123-description` or `feature/123-description`.
|
||||
- PR body links the issue: `Fixes #123` or `Refs #123`.
|
||||
- AI-assisted? Add the attribution block (next section).
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
AI-assisted PRs accepted with disclosure. PR descriptions:
|
||||
|
||||
```
|
||||
---
|
||||
Generated with [Claude Code](https://claude.ai/code)
|
||||
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
|
||||
XX% AI / YY% Human
|
||||
Claude: <what AI did>
|
||||
Human: <what human did>
|
||||
```
|
||||
|
||||
Real model name (e.g., "Claude Opus 4.7"). Honest split.
|
||||
Breakdown lines make the ratio auditable against the diff.
|
||||
|
||||
Commits: `Co-Authored-By: Claude <claude@anthropic.com>`.
|
||||
|
||||
Issues/comments:
|
||||
`Written by Claude <model-name> via [Claude Code](https://claude.ai/code)`.
|
||||
|
||||
## Conventions in this file
|
||||
|
||||
### Patch-script regexes
|
||||
|
||||
Two rules apply to regexes that target the minified upstream bundle.
|
||||
|
||||
**Identifier captures use `[$\w]+`, not `\w+`.** Upstream's minifier
|
||||
emits `$` inside JS identifiers (`C$i`, `g$i`, `i$A`). `\w` is
|
||||
`[A-Za-z0-9_]` and does not match `$`, so a `\w+` capture against
|
||||
`$e` returns the suffix `e` instead of the whole identifier. PR #555
|
||||
and PR #627 closed two cohorts of patches with this exact bug. The
|
||||
learnings doc has the full background and the canonical character
|
||||
class is `[$\w]+` (the equivalent `[\w$]+` is fine; either form
|
||||
matches the same set, the order is convention only).
|
||||
|
||||
**Intent comments accompany whitespace-tolerant patterns.** When a
|
||||
patch regex uses `\s*` or `[ \t]*` between tokens, add a one-line
|
||||
intent comment with whitespace stripped so the matched shape stays
|
||||
readable:
|
||||
|
||||
```js
|
||||
// Intent: VAR.code==="ENOENT"
|
||||
const enoentRe = /([$\w]+)\.code\s*===\s*"ENOENT"/g;
|
||||
```
|
||||
|
||||
Apply both rules to new patches and to existing regexes when you're
|
||||
editing them for other reasons. No churn PRs. Background:
|
||||
[the patching-minified-js learnings doc][pmj].
|
||||
|
||||
[pmj]: docs/learnings/patching-minified-js.md
|
||||
|
||||
### Markdown prose wrapping
|
||||
|
||||
Wrap prose at ~80 chars, matching the bash column rule in
|
||||
[docs/styleguides/bash_styleguide.md](docs/styleguides/bash_styleguide.md).
|
||||
Tables, code blocks, URLs, alt text may exceed when breaking hurts
|
||||
readability.
|
||||
29
README.md
29
README.md
@@ -4,8 +4,6 @@ This project provides build scripts to run Claude Desktop natively on Linux syst
|
||||
|
||||
**Note:** This is an unofficial build script. For official support, please visit [Anthropic's website](https://www.anthropic.com). For issues with the build script or Linux implementation, please [open an issue](https://github.com/aaddrick/claude-desktop-debian/issues) in this repository.
|
||||
|
||||
**Documentation:** Full docs at [`docs/index.md`](docs/index.md). Release history in [`CHANGELOG.md`](CHANGELOG.md). Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports: [`SECURITY.md`](SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ APT migration notice (April 2026)**
|
||||
@@ -137,7 +135,7 @@ Download the latest `.deb`, `.rpm`, or `.AppImage` from the [Releases page](http
|
||||
|
||||
### Building from Source
|
||||
|
||||
See [docs/building.md](docs/building.md) for detailed build instructions.
|
||||
See [docs/BUILDING.md](docs/BUILDING.md) for detailed build instructions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -146,13 +144,13 @@ Model Context Protocol settings are stored in:
|
||||
~/.config/Claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
For additional configuration options including environment variables and Wayland support, see [docs/configuration.md](docs/configuration.md).
|
||||
For additional configuration options including environment variables and Wayland support, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `claude-desktop --doctor` for built-in diagnostics that check common issues (display server, sandbox permissions, MCP config, stale locks, and more). It also reports cowork mode readiness — which isolation backend will be used, and which dependencies (KVM, QEMU, vsock, socat, virtiofsd, bubblewrap) are installed or missing.
|
||||
|
||||
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/troubleshooting.md](docs/troubleshooting.md).
|
||||
For additional troubleshooting, uninstallation instructions, and log locations, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -187,7 +185,6 @@ Special thanks to:
|
||||
- Version update contributions
|
||||
- Close-to-tray on Linux to keep in-app schedulers, MCP servers, and the tray icon alive across window close
|
||||
- "Run on startup" persistence on Linux via XDG Autostart, fixing the toggle that would silently revert
|
||||
- In-place package upgrade detection that watches `app.asar` for dpkg/rpm replacement and offers a click-to-restart notification, fixing the Quick Entry / About / Ctrl+Q symptom cluster from a running v(N) main process loading v(N+1) renderer assets (#564)
|
||||
- **[mathys-lopinto](https://github.com/mathys-lopinto)**
|
||||
- AUR package
|
||||
- Automated deployment
|
||||
@@ -201,9 +198,6 @@ Special thanks to:
|
||||
- `--doctor` diagnostic command
|
||||
- SHA-256 checksum validation for downloads
|
||||
- Post-build integration tests for deb, rpm, and AppImage artifacts
|
||||
- `tests.yml` CI workflow that runs the 186-test BATS suite on push and PR — the suite was inert in CI before this (#520)
|
||||
- Isolating `cleanup_stale_cowork_socket` BATS from host `pgrep` state so the test passes on developer machines running Claude Desktop (#533, #534)
|
||||
- Headless launch and `--doctor` smoke tests for the AppImage artifact, catching runtime regressions (frame-fix-wrapper syntax errors, asar patch breakage, `main` field mismatches) that the structural test missed (#592)
|
||||
- **[milog1994](https://github.com/milog1994)**
|
||||
- Popup detection
|
||||
- Functional stubs
|
||||
@@ -233,7 +227,6 @@ Special thanks to:
|
||||
- node-pty derivation
|
||||
- CI auto-update
|
||||
- Fixing the flake package scoping regression
|
||||
- Fixing the NixOS electron binary not being marked executable (#431, #581)
|
||||
- **[cbonnissent](https://github.com/cbonnissent)**
|
||||
- Reverse-engineering the Cowork VM guest RPC protocol
|
||||
- Fixing the KVM startup blocker
|
||||
@@ -249,8 +242,6 @@ Special thanks to:
|
||||
- Detailed analysis of the self-referential `.mcpb-cache` symlink ELOOP bug
|
||||
- Fixing auto-memory path translation on HostBackend
|
||||
- Fixing the `ion-dist` static asset copy for the `app://` protocol handler
|
||||
- `--doctor` diagnostic that detects the Ubuntu 24.04 AppArmor `apparmor_restrict_unprivileged_userns=1` block on bwrap, instead of letting it silently fall through to a hanging KVM probe (#351, #434)
|
||||
- Documenting the upstream MCP double-spawn root-cause analysis in `docs/learnings/mcp-double-spawn.md` (#526, #527)
|
||||
- **[reinthal](https://github.com/reinthal)** for fixing the NixOS build breakage caused by the nixpkgs `nodePackages` removal
|
||||
- **[gianluca-peri](https://github.com/gianluca-peri)**
|
||||
- Reporting the GNOME quit accessibility issue
|
||||
@@ -268,20 +259,6 @@ Special thanks to:
|
||||
- **[zabka](https://github.com/zabka)** for identifying that `cowork-vm-service.js` was never auto-spawned on Linux and contributing a systemd-unit workaround that scoped the daemon auto-launch fix (#445)
|
||||
- **[sirfaber](https://github.com/sirfaber)** for fixing the `$`-in-minified-identifier breakage of cowork Patch 2b (vm module assignment) and Patch 6 step 2 (retry-delay auto-launch) on Claude Desktop 1.5354.0 (#555)
|
||||
- **[ProfFlow](https://github.com/ProfFlow)** for re-fixing the RPM repodata signing regression by appending `!` to the keyid passed to `gpg --default-key`, forcing `repomd.xml` to be signed by the primary key instead of the auto-selected signing subkey (#566)
|
||||
- **[jslatten](https://github.com/jslatten)** for fixing the KDE Plasma Wayland launcher-grouping bug by setting `pkg.desktopName` in the packaged `app.asar`'s `package.json`, format-conditional so deb/rpm get `claude-desktop.desktop` and AppImage gets `io.github.aaddrick.claude-desktop-debian.desktop` (#562)
|
||||
- **[JoshuaVlantis](https://github.com/JoshuaVlantis)**
|
||||
- RPM `chrome-sandbox` SUID via `%attr(4755, ...)` instead of a `%post` chmod scriptlet so the bit survives `--noscripts` and layered images (#539)
|
||||
- `autoUpdater` no-op Proxy on Linux that defends against future feed activation, with a thenable allowlist masking `then`/`catch`/`finally`/`Symbol.toPrimitive`/`Symbol.iterator` to `undefined` (#567)
|
||||
- Failing loudly on `npm install node-pty` failures instead of silently shipping the upstream Windows binaries, plus auto-installing `gcc`/`g++`/`make`/`python3` on minimal build environments (#401)
|
||||
- Silencing the RPM "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install`, with thorough investigation of four `%exclude`-based alternatives (#610)
|
||||
- Cleaning upstream Windows binaries from node-pty before staging the Linux build, preventing PE32+ orphans in the packaged asar (#597)
|
||||
- **[Hayao0819](https://github.com/Hayao0819)** for diagnosing the upstream `titleBarStyle:""` → `titleBarStyle:"hiddenInset"` migration that broke the About window render on GNOME/X11 and contributing the `isPopupWindow()` match extension (#481, #489)
|
||||
- **[michelsfun](https://github.com/michelsfun)** for reporting the cowork `ENAMETOOLONG` failure on eCryptfs-encrypted home directories with detailed `--doctor` output that pinpointed the short-NAME_MAX filesystem as the cause (#590)
|
||||
- **[proffalken](https://github.com/proffalken)** for the LUKS-volume + `pam_mount` workaround documented in `docs/troubleshooting.md`, restoring cowork support on legacy eCryptfs-encrypted home directories (#590)
|
||||
- **[phelps-matthew](https://github.com/phelps-matthew)** for fixing `CLAUDE_QUIT_ON_CLOSE=1` to actively quit via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux, with thorough root cause analysis and alternatives evaluation (#624, #623)
|
||||
- **[dubreal](https://github.com/dubreal)** for `--password-store` keyring detection that probes D-Bus for kwallet6 / gnome-libsecret at startup, fixing session persistence on KDE Plasma and other desktops where Electron's `safeStorage` was unavailable (#611, #593)
|
||||
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for detecting missing electron binaries after Node 24's `extract-zip` silently no-ops, with an `unzip` fallback that recovers from the `@electron/get` cache (#631, #584)
|
||||
- **[tkrag](https://github.com/tkrag)** for diagnosing and fixing the X11 window-raise-on-hover bug under sloppy/focus-follows-mouse WMs, tracing the upstream `webContents.focus()` → `_NET_ACTIVE_WINDOW` path through three iterations of review (#589, #416)
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
80
RELEASING.md
80
RELEASING.md
@@ -1,80 +0,0 @@
|
||||
# Releasing
|
||||
|
||||
This project ships through tag-driven CI. A tag of the form `v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}` on `main` triggers the release job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml), which builds for both architectures, attaches the artifacts to a GitHub Release, and updates the APT, DNF, and AUR repositories.
|
||||
|
||||
There are two flavors of release:
|
||||
|
||||
- **Upstream-tracking retag.** A `check-claude-version` workflow runs daily, detects new Claude Desktop releases, bumps the `CLAUDE_DESKTOP_VERSION` repo variable, patches URLs and SRI hashes in `scripts/setup/detect-host.sh` and `nix/claude-desktop.nix`, and pushes a new tag with the same `REPO_VERSION` and a new `+claude{X.Y.Z}` suffix. **No human action required.** These do not get CHANGELOG entries — they're tracked in the tag suffix.
|
||||
- **Project release.** You bumped `REPO_VERSION` because you shipped project changes. Follow the checklist below.
|
||||
|
||||
## Pre-release checklist
|
||||
|
||||
1. **CI is green on `main`.** All required workflows (CI, tests, shellcheck, codespell) passed on the commit you're about to tag.
|
||||
|
||||
```bash
|
||||
gh run list --branch main --limit 5
|
||||
```
|
||||
|
||||
2. **`CHANGELOG.md` is updated.** The `[Unreleased]` section now reflects what you're about to ship. Move it under a new `[v{REPO_VERSION}]` heading with today's date.
|
||||
|
||||
3. **Local tests pass.**
|
||||
|
||||
```bash
|
||||
bats tests/
|
||||
shellcheck scripts/**/*.sh build.sh
|
||||
```
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md#linting) for the canonical lint command.
|
||||
|
||||
4. **AppImage artifact boots on a clean system.** The `test-artifacts.yml` reusable workflow already runs a `--doctor` smoke test against each format in CI (#592), but if you've touched the launcher or patch surface, build locally and confirm:
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no
|
||||
./test-build/claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
5. **The version variables are in sync.**
|
||||
|
||||
```bash
|
||||
gh variable get REPO_VERSION
|
||||
gh variable get CLAUDE_DESKTOP_VERSION
|
||||
grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1
|
||||
```
|
||||
|
||||
The grep value should match the `CLAUDE_DESKTOP_VERSION` variable. If not, pull the latest URLs from `main` — the `check-claude-version` workflow may have updated them on `main` without rebasing your branch ([`CLAUDE.md`](CLAUDE.md#common-gotchas) has the recipe).
|
||||
|
||||
## Bumping and tagging
|
||||
|
||||
```bash
|
||||
# 1. Bump the project version (this is a GitHub Actions variable, not a file).
|
||||
gh variable set REPO_VERSION --body "2.0.13"
|
||||
|
||||
# 2. Tag with both versions in the tag name.
|
||||
git tag "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
|
||||
# 3. Push the tag — this is what kicks off the release build.
|
||||
git push origin "v2.0.13+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
|
||||
```
|
||||
|
||||
The `REPO_VERSION` variable bump can happen before or after the tag push; CI reads neither directly. The variable exists so future workflow runs know the current project version.
|
||||
|
||||
## What CI does on tag push
|
||||
|
||||
The [`release`](.github/workflows/ci.yml) job in `ci.yml` is gated on `startsWith(github.ref, 'refs/tags/v')`. After `test-flags`, `build-amd64`, `build-arm64`, and `test-artifacts` pass:
|
||||
|
||||
1. Downloads all nine assets (six packages -- amd64 + arm64, each in deb/rpm/AppImage -- plus two `.zsync` delta files and a `reference-source.tar.gz`).
|
||||
2. Pulls release notes from the separate [`aaddrick/claude-desktop-versions`](https://github.com/aaddrick/claude-desktop-versions) repo if available; falls back to the autogenerated changelog otherwise.
|
||||
3. Creates the GitHub Release and attaches the nine assets.
|
||||
4. Hands off to `update-apt-repo`, `update-dnf-repo`, and `update-aur-repo`, which publish to the Cloudflare-fronted package repos ([`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) for the redirect chain).
|
||||
|
||||
## After the release lands
|
||||
|
||||
- **Verify the Release page.** Nine assets attached, sizes look right, release notes rendered.
|
||||
- **Smoke-test one artifact.** Download the AppImage and run `--doctor` against it.
|
||||
- **Watch `apt-repo-heartbeat`.** The next daily run validates the redirect chain end-to-end. If it opens a tracking issue, walk the chain in [`docs/learnings/apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md#heartbeat-failure-runbook).
|
||||
|
||||
## If something goes wrong mid-release
|
||||
|
||||
- **Build fails.** Push the fix to `main`, then re-tag with a new `+claude` suffix (or a `+rebuild.N` suffix if upstream hasn't moved). The original tag stays — releases are append-only.
|
||||
- **A bad release shipped.** Mark the GitHub Release as a pre-release / draft and ship a follow-up. Don't delete artifacts that may already be cached by the APT/DNF Worker.
|
||||
- **The `check-claude-version` workflow conflicts with your local branch.** Pull URL changes from `main` before pushing your tag — the workflow autobumps `scripts/setup/detect-host.sh` between your work and your tag.
|
||||
35
SECURITY.md
35
SECURITY.md
@@ -1,35 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
Report suspected vulnerabilities privately via [GitHub Security Advisories](https://github.com/aaddrick/claude-desktop-debian/security/advisories/new). Do not open a public issue or post details in Discussions.
|
||||
|
||||
## Scope
|
||||
|
||||
This project repackages an upstream Electron app. The boundary matters:
|
||||
|
||||
**In scope** — things this repo ships:
|
||||
|
||||
- Patches in `scripts/patches/*.sh`
|
||||
- Packaging scripts in `scripts/packaging/`
|
||||
- The launcher (`scripts/launcher-common.sh`) and the `claude-desktop --doctor` surface
|
||||
- CI workflows under `.github/workflows/`
|
||||
- The APT/DNF Cloudflare Worker under `worker/`
|
||||
- The frame-fix wrapper and any other JS we inject into `app.asar`
|
||||
|
||||
**Out of scope** — file upstream:
|
||||
|
||||
- Vulnerabilities in the Claude Desktop application itself, the Anthropic API, or the claude.ai web app. Those go to Anthropic's support / disclosure channels — not here. This project can't fix them and shouldn't be the public record.
|
||||
|
||||
## What to include in a report
|
||||
|
||||
- Reproducer: commands, environment, distro / desktop / session type
|
||||
- Output of `claude-desktop --doctor` if relevant
|
||||
- Affected version(s) — `git describe --tags` or the release tag you installed from
|
||||
- Any related upstream CVEs or advisories you found while investigating
|
||||
|
||||
## Response
|
||||
|
||||
GitHub Advisories notify @aaddrick. Acknowledgement is usually within a few days. Fix turnaround depends on the surface — packaging-layer bugs are usually fast; patches against minified upstream JS may need to wait for a tractable anchor in a future upstream release.
|
||||
|
||||
## Disclosure history
|
||||
|
||||
Past privacy-sensitive fixes (e.g., issue-triage bot scoping, log redaction in `--doctor` output) landed through the normal PR flow with public history; there have been no embargoed disclosures to date. If that changes, this section gets entries with the advisory ID, the affected versions, and the fix.
|
||||
8
build.sh
8
build.sh
@@ -36,8 +36,6 @@ final_output_path=''
|
||||
|
||||
# Package metadata (constants)
|
||||
readonly PACKAGE_NAME='claude-desktop'
|
||||
readonly WM_CLASS='Claude'
|
||||
export WM_CLASS
|
||||
readonly MAINTAINER='Claude Desktop Linux Maintainers'
|
||||
readonly DESCRIPTION='Claude Desktop for Linux'
|
||||
|
||||
@@ -62,12 +60,8 @@ source "$script_dir/scripts/patches/quick-window.sh"
|
||||
source "$script_dir/scripts/patches/claude-code.sh"
|
||||
# shellcheck source=scripts/patches/cowork.sh
|
||||
source "$script_dir/scripts/patches/cowork.sh"
|
||||
# shellcheck source=scripts/patches/org-plugins.sh
|
||||
source "$script_dir/scripts/patches/org-plugins.sh"
|
||||
# shellcheck source=scripts/patches/wco-shim.sh
|
||||
source "$script_dir/scripts/patches/wco-shim.sh"
|
||||
# shellcheck source=scripts/patches/config.sh
|
||||
source "$script_dir/scripts/patches/config.sh"
|
||||
# shellcheck source=scripts/staging/electron.sh
|
||||
source "$script_dir/scripts/staging/electron.sh"
|
||||
# shellcheck source=scripts/staging/icons.sh
|
||||
@@ -161,7 +155,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;Network;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=$WM_CLASS
|
||||
StartupWMClass=Claude
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop (AppImage)
|
||||
EOF
|
||||
|
||||
@@ -41,17 +41,6 @@ The build script automatically detects your distribution and selects the appropr
|
||||
| Arch Linux | `.AppImage` (via AUR) | yay/paru |
|
||||
| Other | `.AppImage` | - |
|
||||
|
||||
## Build Environment Variables
|
||||
|
||||
The build pulls the Electron prebuilt binary from `github.com/electron/electron/releases` via `@electron/get`. Two upstream environment variables let you redirect that fetch:
|
||||
|
||||
- `ELECTRON_MIRROR` — base URL to fetch Electron releases from instead of GitHub. Useful for mirrors or local proxies. Example: `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/`.
|
||||
- `ELECTRON_CUSTOM_DIR` — overrides the path segment after the mirror. Defaults to `v{version}`.
|
||||
|
||||
The cache location is fixed at `~/.cache/electron/` (resolved by `@electron/get` via `envPaths`) and is reused across builds. `ELECTRON_CACHE` is **not** read by `@electron/get` — set `ELECTRON_MIRROR` if you need to avoid the public CDN.
|
||||
|
||||
The pinned Electron version lives in `scripts/setup/dependencies.sh` (`electron_version`) and must match `build-reference/app-extracted/package.json` — the upstream Claude Desktop `app.asar` is built against a specific Electron major and running a different one is unsupported.
|
||||
|
||||
## Installing the Built Package
|
||||
|
||||
### For .deb packages (Debian/Ubuntu)
|
||||
259
docs/TROUBLESHOOTING.md
Normal file
259
docs/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,259 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs 10 checks and prints pass/fail results with suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
| Desktop entry | `.desktop` file presence |
|
||||
| Disk space | Free space on config partition |
|
||||
| Log file | Log file size |
|
||||
|
||||
Example output:
|
||||
```
|
||||
Claude Desktop Diagnostics
|
||||
================================
|
||||
|
||||
[PASS] Installed version: 1.1.4498-1.3.15
|
||||
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
|
||||
[PASS] Electron: found at /usr/lib/claude-desktop/node_modules/electron/dist/electron
|
||||
[PASS] Chrome sandbox: permissions OK
|
||||
[PASS] SingletonLock: no lock file (OK)
|
||||
[PASS] MCP config: valid JSON
|
||||
[PASS] Node.js: v22.14.0
|
||||
[PASS] Desktop entry: /usr/share/applications/claude-desktop.desktop
|
||||
[PASS] Disk space: 632284MB free
|
||||
[PASS] Log file: 1352KB
|
||||
|
||||
All checks passed.
|
||||
```
|
||||
|
||||
When opening an issue, include the output of `--doctor` to help with diagnosis.
|
||||
|
||||
## Application Logs
|
||||
|
||||
Runtime logs are available at:
|
||||
```
|
||||
~/.cache/claude-desktop-debian/launcher.log
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Window Scaling Issues
|
||||
|
||||
If the window doesn't scale correctly on first launch:
|
||||
1. Right-click the Claude Desktop tray icon
|
||||
2. Select "Quit" (do not force quit)
|
||||
3. Restart the application
|
||||
|
||||
This allows the application to save display settings properly.
|
||||
|
||||
### Global Hotkey Not Working (Wayland)
|
||||
|
||||
If the global hotkey (Ctrl+Alt+Space) doesn't work, ensure you're not running in native Wayland mode:
|
||||
|
||||
1. Check your logs at `~/.cache/claude-desktop-debian/launcher.log`
|
||||
2. Look for "Using X11 backend via XWayland" - this means hotkeys should work
|
||||
3. If you see "Using native Wayland backend", unset `CLAUDE_USE_WAYLAND` or ensure it's not set to `1`
|
||||
|
||||
**Note:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal.
|
||||
|
||||
See [CONFIGURATION.md](CONFIGURATION.md) for more details on the `CLAUDE_USE_WAYLAND` environment variable.
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
|
||||
For enhanced security, consider:
|
||||
- Using the .deb package instead
|
||||
- Running the AppImage within a separate sandbox (e.g., bubblewrap)
|
||||
- Using Gear Lever's integrated AppImage management for better isolation
|
||||
|
||||
### Cowork on Ubuntu 24.04+ (AppArmor Blocks User Namespaces)
|
||||
|
||||
Ubuntu 24.04 ships with `apparmor_restrict_unprivileged_userns=1`
|
||||
by default, which blocks the unprivileged user namespaces that
|
||||
Cowork's bubblewrap sandbox relies on. Symptoms:
|
||||
|
||||
- `claude-desktop --doctor` reports `bubblewrap: sandbox probe failed`
|
||||
with `Operation not permitted` in stderr.
|
||||
- `~/.config/Claude/logs/cowork_vm_daemon.log` contains
|
||||
`bwrap is installed but cannot create a user namespace`.
|
||||
- Cowork sessions hang at "Starting VM..." or loop on reconnect.
|
||||
|
||||
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
|
||||
setup, requires sudo):
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/bwrap>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap
|
||||
```
|
||||
|
||||
After applying the profile, run `claude-desktop --doctor` — the
|
||||
bubblewrap probe should pass, and Cowork should start without
|
||||
falling back to host-direct.
|
||||
|
||||
**Security note:** this grants `/usr/bin/bwrap` the unconfined
|
||||
profile plus the `userns` capability. It matches the behavior
|
||||
bwrap had on Ubuntu 22.04 and earlier, and on most other distros,
|
||||
but is a system-wide change that affects every program invoking
|
||||
`/usr/bin/bwrap` (not just Claude Desktop). Review the profile
|
||||
against your threat model before applying.
|
||||
|
||||
Credit: this workaround was contributed by
|
||||
[@hfyeh](https://github.com/hfyeh) in
|
||||
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
If Cowork fails with a VM timeout, the KVM backend is selected but the guest VM cannot connect back to the host via vsock within the timeout window. Common causes:
|
||||
|
||||
1. **First-boot initialization** — the guest VM may take longer than 60 seconds on first launch
|
||||
2. **vsock driver issues** — the host may be missing the `vhost_vsock` module (`sudo modprobe vhost_vsock`), or the guest initrd may lack `vmw_vsock_virtio_transport`
|
||||
|
||||
**Fix:** Force the bubblewrap backend, which provides namespace-level isolation without a VM:
|
||||
|
||||
```bash
|
||||
COWORK_VM_BACKEND=bwrap claude-desktop
|
||||
```
|
||||
|
||||
See [CONFIGURATION.md](CONFIGURATION.md#cowork-backend) for how to make this permanent.
|
||||
|
||||
### Cowork: virtiofsd not found (Fedora/RHEL)
|
||||
|
||||
On Fedora and RHEL, `virtiofsd` installs to `/usr/libexec/virtiofsd` which is
|
||||
outside `$PATH`. The `--doctor` check detects it there automatically and will
|
||||
show `[PASS]`, but the KVM backend spawns `virtiofsd` by name at runtime and
|
||||
resolves it through `$PATH` only.
|
||||
|
||||
**Fix:** Create a symlink so the KVM backend can find it at runtime:
|
||||
|
||||
```bash
|
||||
sudo ln -s /usr/libexec/virtiofsd /usr/local/bin/virtiofsd
|
||||
```
|
||||
|
||||
On Debian/Ubuntu, the same issue can occur with `/usr/lib/qemu/virtiofsd`.
|
||||
|
||||
### Cowork: cross-device link error on Fedora tmpfs /tmp
|
||||
|
||||
On Fedora, `/tmp` is a tmpfs by default. VM bundle downloads may fail with `EXDEV: cross-device link not permitted` when moving files from `/tmp` to `~/.config/Claude/`.
|
||||
|
||||
**Fix:** Set `TMPDIR` to a directory on the same filesystem:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/Claude/tmp
|
||||
TMPDIR=~/.config/Claude/tmp claude-desktop
|
||||
```
|
||||
|
||||
Or add `TMPDIR=%h/.config/Claude/tmp` to the `Exec=` line in your `.desktop` file.
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
If you encounter recurring "API Error: 401" messages after periods of inactivity, the cached OAuth token may need to be cleared. This is an upstream application issue reported in [#156](https://github.com/aaddrick/claude-desktop-debian/issues/156).
|
||||
|
||||
To fix manually (credit: [MrEdwards007](https://github.com/MrEdwards007)):
|
||||
|
||||
1. Close Claude Desktop completely
|
||||
2. Edit `~/.config/Claude/config.json`
|
||||
3. Remove the line containing `"oauth:tokenCache"` (and any trailing comma if needed)
|
||||
4. Save the file and restart Claude Desktop
|
||||
5. Log in again when prompted
|
||||
|
||||
A scripted solution is also available at the bottom of [this comment](https://github.com/aaddrick/claude-desktop-debian/issues/156#issuecomment-2682547498).
|
||||
|
||||
## Uninstallation
|
||||
|
||||
### For APT repository installations (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
|
||||
# Remove the repository and GPG key
|
||||
sudo rm /etc/apt/sources.list.d/claude-desktop.list
|
||||
sudo rm /usr/share/keyrings/claude-desktop.gpg
|
||||
```
|
||||
|
||||
### For DNF repository installations (Fedora/RHEL)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
|
||||
# Remove the repository
|
||||
sudo rm /etc/yum.repos.d/claude-desktop.repo
|
||||
```
|
||||
|
||||
### For AUR installations (Arch Linux)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -R claude-desktop-appimage
|
||||
|
||||
# Or using paru
|
||||
paru -R claude-desktop-appimage
|
||||
|
||||
# Or using pacman directly
|
||||
sudo pacman -R claude-desktop-appimage
|
||||
```
|
||||
|
||||
### For .deb packages (manual install)
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo apt remove claude-desktop
|
||||
# Or: sudo dpkg -r claude-desktop
|
||||
|
||||
# Remove package and configuration
|
||||
sudo dpkg -P claude-desktop
|
||||
```
|
||||
|
||||
### For .rpm packages
|
||||
|
||||
```bash
|
||||
# Remove package
|
||||
sudo dnf remove claude-desktop
|
||||
# Or: sudo rpm -e claude-desktop
|
||||
```
|
||||
|
||||
### For AppImages
|
||||
|
||||
1. Delete the `.AppImage` file
|
||||
2. Remove the `.desktop` file from `~/.local/share/applications/`
|
||||
3. If using Gear Lever, use its uninstall option
|
||||
|
||||
### Remove user configuration (all formats)
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/Claude
|
||||
```
|
||||
@@ -1,65 +0,0 @@
|
||||
# Documentation
|
||||
|
||||
Linux packaging, patching, and operations docs for the [Claude Desktop for Debian](../README.md) project. The README is the storefront; this is the manual.
|
||||
|
||||
```bash
|
||||
# If you're here because something broke:
|
||||
claude-desktop --doctor
|
||||
# Then check troubleshooting.md below.
|
||||
```
|
||||
|
||||
## Installation & building
|
||||
|
||||
- [**Building from source**](building.md) — `./build.sh`, format flags, the Electron mirror env vars
|
||||
- [**Configuration**](configuration.md) — MCP config file locations, env vars, where state lives
|
||||
- [**Troubleshooting**](troubleshooting.md) — symptom-keyed fixes, `--doctor` warning index
|
||||
|
||||
## Project direction
|
||||
|
||||
- [**Decision log**](decisions.md) — ADR-format record of what we ship and (more importantly) what we won't
|
||||
- [**Releasing**](../RELEASING.md) — pre-release checklist, tag recipe, what CI does on tag push
|
||||
- [**Changelog**](../CHANGELOG.md) — `v2.0.0` onward, grouped by REPO_VERSION
|
||||
|
||||
## How the patches work — subsystem deep-dives
|
||||
|
||||
Hard-won knowledge from debugging real bugs. Consult before working on the related subsystem; add a new entry when you discover something non-obvious that would save the next contributor (human or AI) significant time.
|
||||
|
||||
- [**Patching minified JavaScript**](learnings/patching-minified-js.md) — anchor selection, the `\w` vs `$` capture trap, beautified false-negatives, idempotency guards
|
||||
- [**APT/DNF Worker architecture**](learnings/apt-worker-architecture.md) — Cloudflare Worker + GitHub Releases redirect chain, credential ownership, heartbeat runbook
|
||||
- [**Nix packaging**](learnings/nix.md) — NixOS specifics, Electron resource path resolution, testing without NixOS
|
||||
- [**Linux topbar shim**](learnings/linux-topbar-shim.md) — why the in-app topbar is missing on Linux and the four gates that hide it
|
||||
- [**Tray rebuild race**](learnings/tray-rebuild-race.md) — KDE SNI re-registration race; the in-place `setImage`/`setContextMenu` fast path
|
||||
- [**Plugin install flow**](learnings/plugin-install.md) — Anthropic & Partners plugin gate logic and DevTools recipes
|
||||
- [**Cowork VM daemon**](learnings/cowork-vm-daemon.md) — lifecycle, respawn logic, crash diagnosis
|
||||
- [**MCP double-spawn**](learnings/mcp-double-spawn.md) — why stdio MCPs spawn twice with chat + Code/Agent panels open
|
||||
- [**Test harness — Electron hooks**](learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps get bypassed by the frame-fix Proxy
|
||||
- [**Test harness — AX-tree walker**](learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker
|
||||
|
||||
## Testing
|
||||
|
||||
- [**Testing overview**](testing/README.md) — what we test and how it's organized
|
||||
- [**Test runbook**](testing/runbook.md) — running tests locally
|
||||
- [**Test matrix**](testing/matrix.md) — what runs on what distro / format
|
||||
- [**Test automation**](testing/automation.md) — CI workflow shape
|
||||
- [**Quick-entry closeout**](testing/quick-entry-closeout.md) — the Quick Entry test runner
|
||||
|
||||
## Operations
|
||||
|
||||
- [**Issue triage bot**](issue-triage/README.md) — how the GitHub Actions issue-triage workflow works
|
||||
- [**Upstream bug reports**](upstream-reports/) — bugs we've filed against the upstream Electron app
|
||||
|
||||
## Style guides
|
||||
|
||||
- [**Bash style guide**](styleguides/bash_styleguide.md) — the project's shell-script conventions (forked from YSAP)
|
||||
- [**Docs style guide**](styleguides/docs_styleguide.md) — how to write and organize docs (start here if you're adding a page)
|
||||
|
||||
## Contributing
|
||||
|
||||
- [**CONTRIBUTING.md**](../CONTRIBUTING.md) — what we accept, what goes upstream, AI-attribution policy
|
||||
- [**CLAUDE.md**](../CLAUDE.md) — instructions for AI coding assistants (and a useful project archaeology read for humans)
|
||||
- [**AGENTS.md**](../AGENTS.md) — vendor-neutral mirror of `CLAUDE.md` for non-Claude AI tools
|
||||
- [**SECURITY.md**](../SECURITY.md) — private vulnerability reporting
|
||||
|
||||
## Cowork-Linux handover (historical)
|
||||
|
||||
- [**Cowork-Linux handover**](cowork-linux-handover.md) — record of the original cowork Linux work, kept for the historical context. Day-to-day cowork docs live in [`learnings/cowork-vm-daemon.md`](learnings/cowork-vm-daemon.md).
|
||||
@@ -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,67 +37,46 @@ external services with single-connection contracts, etc.
|
||||
|
||||
## Root Cause (Upstream)
|
||||
|
||||
Multiple session managers live inside Electron main, each
|
||||
holding its own MCP coordinator state with its own registry. The
|
||||
two that spawn stdio MCPs from `claude_desktop_config.json` and
|
||||
trigger this bug:
|
||||
Two parallel session managers live inside Electron main, each
|
||||
holding an independent Claude Agent SDK `query`:
|
||||
|
||||
| Manager class | IPC namespace | Coordinator | Logs prefix |
|
||||
|--------------------------|------------------------------------------|-----------------|-------------|
|
||||
| `LocalSessions` | `claude.web_$_LocalSessions_$_*` | `n2t("ccd")` | `[CCD]` |
|
||||
| `LocalAgentModeSessions` | `claude.web_$_LocalAgentModeSessions_$_*`| `n2t("cowork")` | `[LAM]` |
|
||||
|
||||
A third coordinator class — `SshMcpServerManager` — follows the
|
||||
same per-coordinator-registry pattern but uses an SSH transport
|
||||
and doesn't contribute to the local-node double-spawn. Its
|
||||
existence does say something about the design intent: per-
|
||||
coordinator isolated state appears to be a deliberate
|
||||
architectural pattern, not a one-off oversight.
|
||||
|
||||
The logs prefixes are what to grep `~/.config/Claude/logs/` for to
|
||||
confirm a session is hitting both coordinators (and therefore this
|
||||
bug specifically).
|
||||
|
||||
Each coordinator dedups **within its own scope**: CCD's launch
|
||||
function serializes per server name through a promise queue and
|
||||
shuts down any prior entry before respawn; LAM's
|
||||
`getOrCreateConnection` reuses connected entries from its own
|
||||
`connections` Map. The double-spawn is strictly **cross-
|
||||
coordinator** — one process per coordinator that has the server
|
||||
in its config.
|
||||
|
||||
In current versions (verified against `1.5354.0`) both
|
||||
coordinators route their transport creation through a shared
|
||||
Claude Desktop-side factory, but the factory itself doesn't
|
||||
dedupe and the per-coordinator registries above it aren't
|
||||
unified.
|
||||
Each `query` holds its own SDK transport. The transport's
|
||||
`spawnLocalProcess` (`Du.spawn`) launches stdio MCPs **without
|
||||
consulting the global registry** that *would* dedupe them
|
||||
(`hZ` map, accessed via `oUt(serverName)` /
|
||||
`launchMcpServer`). That registry is only used for the
|
||||
"internal" cowork in-process MessageChannelMain path.
|
||||
|
||||
Net result: 2 coordinators × N configured MCPs = 2N processes.
|
||||
|
||||
### Symbol drift
|
||||
|
||||
Minified symbols rename across upstream releases. Issue
|
||||
[#546](https://github.com/aaddrick/claude-desktop-debian/issues/546)
|
||||
maintains the current symbol mappings (verified against
|
||||
`1.5354.0`) plus extraction regexes that work against both
|
||||
minified and beautified bundles.
|
||||
Symbol names (`n2t`, `hZ`, `oUt`, `LocalSessions`,
|
||||
`LocalAgentModeSessions`) are minified and **will rename across
|
||||
upstream releases**.
|
||||
|
||||
## Status
|
||||
|
||||
**Upstream Claude Desktop bug. Not patchable in this repo.** The
|
||||
proximate cause is in Claude Desktop's session manager wiring. A
|
||||
real fix needs either:
|
||||
**Upstream Claude Desktop bug. Not patchable in this repo.** A
|
||||
fix would require either:
|
||||
|
||||
- LAM proxying its MCP traffic through CCD's existing connection
|
||||
(so only one coordinator owns the spawn), or
|
||||
- A multiplexing wrapper transport that lets one spawned stdio
|
||||
child serve multiple SDK clients via demuxing.
|
||||
- Routing the SDK stdio transport through `oUt`/`hZ` (the
|
||||
existing serialized-per-name registry), or
|
||||
- Sharing one MCP-server registry between the `ccd` and
|
||||
`cowork` coordinators.
|
||||
|
||||
Stdio MCP is 1:1 at the protocol layer — one stdin/stdout pair,
|
||||
one transport, one SDK client. Sharing one process across
|
||||
coordinators requires real engineering, not a sed patch on
|
||||
minified code, and exceeds this repo's "minimal Linux-compat
|
||||
patches only" charter.
|
||||
Both live inside the closed-source SDK transport / session
|
||||
manager wiring. Regex-matching the minified symbols from
|
||||
`scripts/patches/` would be fragile against release-to-release
|
||||
renames and exceeds this repo's "minimal Linux-compat patches
|
||||
only" charter.
|
||||
|
||||
## What's Already Verified Clean
|
||||
|
||||
@@ -139,15 +118,13 @@ The reporter's `baro-voyager` MCP shipped both in commit
|
||||
|
||||
- **Primary:** in-app feedback (Help → Send Feedback) or
|
||||
`support@anthropic.com`. The duplication happens in
|
||||
closed-source Desktop main, in the per-coordinator registry
|
||||
wiring.
|
||||
- **Secondary:** an issue on
|
||||
closed-source Desktop main.
|
||||
- **Secondary:** an SDK-transport-flavored issue on
|
||||
[`anthropics/claude-agent-sdk-typescript`](https://github.com/anthropics/claude-agent-sdk-typescript)
|
||||
is defensible only if it advocates for a shared-transport /
|
||||
multiplex primitive that would make this kind of bug
|
||||
structurally harder. The SDK's spawn implementation is doing
|
||||
what it's told — the bug is one layer up, in Claude Desktop
|
||||
calling spawn from two separate coordinators.
|
||||
is defensible — the spawn path goes through the **Claude Agent
|
||||
SDK's** `query` transport (`spawnLocalProcess` / `Du.spawn`),
|
||||
which is shared surface area. Reference the missing `hZ`
|
||||
consultation explicitly.
|
||||
|
||||
The embedded Claude Code CLI subprocess inside Claude Desktop is
|
||||
**not** the cause — it receives `--mcp-config` only when the
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
# Patching minified JavaScript
|
||||
|
||||
Hard-won lessons from maintaining a long-lived patch suite against an
|
||||
actively re-minified upstream. Each section names a failure mode and
|
||||
the fix.
|
||||
|
||||
The verification recipes below use claude-desktop-debian-specific
|
||||
incantations (Claude-Setup.exe, nupkg extraction, `build.sh
|
||||
--build appimage`); substitute your own project's fetch/extract/build
|
||||
commands as needed.
|
||||
|
||||
## Capturing identifiers: `\w` doesn't match `$`
|
||||
|
||||
JS identifiers allow `$` and `_`; minifiers freely emit names like
|
||||
`$e`, `C$i`, `g$x`. The character class `\w` is `[A-Za-z0-9_]` — it
|
||||
does not match `$`. A `(\w+)` against `$e` captures the suffix `e`
|
||||
and returns a name that doesn't exist in the file. The failure is
|
||||
silent: regex matches, downstream sed runs against a truncated name,
|
||||
asar ships broken JS. Three recurrences (PRs #253, #421, #555) before
|
||||
the convention stuck.
|
||||
|
||||
Use `[$\w]+` (repo convention; `[\w$]+` is equivalent). Strict
|
||||
superset of `\w+`, so pre-`$` versions still match. Live at
|
||||
`cowork.sh:484-502`:
|
||||
|
||||
```bash
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
```
|
||||
|
||||
## The beautified false-negative trap
|
||||
|
||||
Testing a regex against `build-reference/` is not verification. The
|
||||
beautified copy has whitespace the regex doesn't account for.
|
||||
|
||||
During PR #555, both `\w+` and `[\w$]+` tested false against the
|
||||
beautified file. Shipped minified bytes:
|
||||
|
||||
```js
|
||||
await new Promise(n=>setTimeout(n,g$x))
|
||||
```
|
||||
|
||||
Beautified copy:
|
||||
|
||||
```js
|
||||
await new Promise((n) => setTimeout(n, g$x))
|
||||
```
|
||||
|
||||
`await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)` fails
|
||||
the beautified version on the parens and spaces around `=>`. Always
|
||||
close the loop against shipped bytes.
|
||||
|
||||
## Whitespace tolerance: `\s*` vs `[ \t]*`
|
||||
|
||||
`\s` matches newlines. A `\s*`-padded pattern is a license to span
|
||||
across structural boundaries the original line layout meant to
|
||||
keep apart — usually fine on minified bytes (no newlines to span),
|
||||
much looser on beautified.
|
||||
|
||||
Use `[ \t]*` when the intent is "spaces but stay on this line."
|
||||
Reserve `\s*` for crossing structural boundaries on purpose. The
|
||||
existing `cowork.sh` patches mix both — `\s*` where the surrounding
|
||||
context is bounded enough that newline-spanning is harmless, and
|
||||
literal token sequences (`",b:` etc.) when stricter adjacency is
|
||||
required.
|
||||
|
||||
## Replacement-string escaping: `\1`, `&`, `$1`
|
||||
|
||||
A regex can match correctly and still produce corrupted output
|
||||
because the *replacement string* has its own metacharacters. Match
|
||||
debugging shows green; the asar still ships broken bytes. Three
|
||||
flavors:
|
||||
|
||||
**sed `&`** — the entire match. `sed 's/foo/&_suffix/'` is fine
|
||||
(`foo_suffix`). `sed 's/foo/literal_&_dollar/'` accidentally
|
||||
interpolates the match (`literal_foo_dollar`). Escape with `\&` if
|
||||
you want a literal ampersand:
|
||||
|
||||
```bash
|
||||
sed 's/foo/literal_\&_dollar/' # → literal_&_dollar
|
||||
```
|
||||
|
||||
**sed `\1`** — backreferences in the replacement. These work as
|
||||
expected in BRE/ERE. The footgun is the *pattern* side: in BRE, `$`
|
||||
is the end-of-line anchor, so a literal `$` in the search pattern
|
||||
needs `\$`. `_common.sh:25` does exactly this for `electron_var`,
|
||||
which can be `$e` on newer upstream:
|
||||
|
||||
```bash
|
||||
electron_var_re="${electron_var//\$/\\$}"
|
||||
```
|
||||
|
||||
That escaping is for the sed *pattern*, not its replacement.
|
||||
|
||||
**JS `String.prototype.replace`: `$1`, `$&`, `$$`** — the JS
|
||||
replacement DSL is its own thing. `$&` is the whole match; `$1..$9`
|
||||
are capture groups; `$$` is a literal `$`. Plain `$` followed by an
|
||||
unrelated char is left alone, but `$&` and `$N` get interpolated:
|
||||
|
||||
```js
|
||||
code.replace(/foo/g, '$cost') // → '$cost' (safe, no special)
|
||||
code.replace(/foo/g, '$&_x') // → 'foo_x' ($& = match)
|
||||
code.replace(/foo/g, '$$cost') // → '$cost' (escaped)
|
||||
```
|
||||
|
||||
If the replacement is an injected JS snippet that happens to
|
||||
contain `$1` or `$&` (template literals, jQuery, regex source), JS
|
||||
will eat them. Use `$$` to escape, or build the string with
|
||||
concatenation so `$` never sits next to a digit or `&`.
|
||||
|
||||
## Idempotency: a re-run must be byte-identical
|
||||
|
||||
Without it, CI re-runs and partial builds layer mutations until
|
||||
something breaks visibly. Three patterns:
|
||||
|
||||
**Re-key the guard to post-rename names.** `tray.sh:174-180` keys its
|
||||
fast-path guard on the post-rename
|
||||
`${tray_var}.setImage(${electron_var}.nativeImage.createFromPath(${path_var}))`
|
||||
sequence, so the second run recognizes its own first-run output.
|
||||
|
||||
**Negative lookbehind, inline.** `cowork.sh:102-106` — the
|
||||
`(?<!...)` prevents a second match against text the first run
|
||||
already wrapped:
|
||||
|
||||
```js
|
||||
const logRe = new RegExp(
|
||||
'(?<!\\|\\|process\\.platform==="linux"\\))' +
|
||||
win32Var.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
||||
'(\\s*\\?\\s*"vmClient \\(TypeScript\\)")'
|
||||
);
|
||||
```
|
||||
|
||||
**Explicit `code.includes(...)` check.** `cowork.sh:227-230`
|
||||
separates "anchor missing" from "already applied" in the build log:
|
||||
|
||||
```js
|
||||
} else if (code.includes(
|
||||
'getDownloadStatus(){return process.platform==="linux"?'
|
||||
)) {
|
||||
console.log(' Cowork auto-nav suppression already applied');
|
||||
}
|
||||
```
|
||||
|
||||
PR #436 verified by running the patch twice and diffing the output.
|
||||
|
||||
## Anchor selection: prefer literals over identifiers
|
||||
|
||||
The above sections cover making a patch work on first run. This one
|
||||
covers keeping it working release after release. A patch can apply
|
||||
cleanly today and silently no-op next month.
|
||||
|
||||
Minified identifiers churn every release. Developer strings —
|
||||
property names, log messages, IPC channel names — survive
|
||||
minification untouched (true for the upstream bundler used here; a
|
||||
`--mangle-props` build would invalidate property-name anchors).
|
||||
Anchor on those. A hardcoded minified name silently no-ops the next
|
||||
release; the build log still says "patched."
|
||||
|
||||
Three patterns from the suite:
|
||||
|
||||
- **Quick-window (PR #390, fixing #144).** Original patch:
|
||||
`s/e.hide()/e.blur(),e.hide()/`. When `e` became `Sa`, it no-oped.
|
||||
The rewrite anchors on `"pop-up-menu"` (`quick-window.sh:17`), the
|
||||
`isWindowFocused` property name (`quick-window.sh:60`), and the
|
||||
`[QuickEntry]` log strings (`quick-window.sh:88-91`).
|
||||
- **Cowork spawn (PR #436).** Anchored on `,VAR.mountConda)`
|
||||
(`cowork.sh:741`) — unique to the 12-arg call path, absent from the
|
||||
10-arg one-shot. Asserts match count is exactly 1 and bails
|
||||
otherwise (`cowork.sh:744`), so a future second caller surfaces
|
||||
immediately.
|
||||
- **Tray (PR #515).** `tray.sh:16` uses the literal `"menuBarEnabled"`
|
||||
as a *position anchor*, then captures the surrounding minified
|
||||
identifier (`\K\w+(?=\(\)\})`) as the actual patch target. Two
|
||||
stages: stable literal → derived identifier. Every other tray name
|
||||
chains off that single dynamic extraction.
|
||||
|
||||
The lesson is about finding stable points to anchor on, not about
|
||||
what gets patched. The patch target is usually a minified identifier;
|
||||
the *anchor* should be a developer string nearby.
|
||||
|
||||
## Multi-site coordinated patches: surface partial application
|
||||
|
||||
Site 1 patches, site 2 misses, the asar ships half-wired. The
|
||||
pattern: each sub-patch sets a per-site boolean flag on success,
|
||||
then a single named WARNING fires if any flag is false:
|
||||
|
||||
```js
|
||||
if (!siteADone || !siteBDone) {
|
||||
console.log(' WARNING: <ticket> partial — siteA=' + siteADone +
|
||||
' siteB=' + siteBDone + '; <fallback consequence>');
|
||||
}
|
||||
```
|
||||
|
||||
CI greps the build log for `WARNING:` and fails the build. That
|
||||
catches the half-patched state even when individual sub-patches each
|
||||
log "applied." See `cowork.sh:759-763` for a real instance —
|
||||
three-site `sharedCwdPath` forwarding, daemon fallback if any site
|
||||
misses.
|
||||
|
||||
## Disambiguating non-unique anchors: lastIndexOf over indexOf
|
||||
|
||||
A string anchor can appear in source maps, dead exports, or
|
||||
chunk-merged duplicates alongside the live code. `indexOf` returns
|
||||
the first; that may be wrong.
|
||||
|
||||
`cowork.sh:264` uses `lastIndexOf(serviceErrorStr)` to bias toward
|
||||
appended code. On 1.5354.0 the string occurs once, so the change is
|
||||
a no-op there — the defense is for a future upstream that
|
||||
reintroduces the string in onboarding text or sample data far from
|
||||
the live retry-loop site.
|
||||
|
||||
When neither side is reliable, narrow the search region first.
|
||||
`cowork.sh:269-276` does this for the ENOENT check, scanning only a
|
||||
300-character window before the error string.
|
||||
|
||||
## Verifying a hypothesis before shipping a fix
|
||||
|
||||
Pull the pinned URL and SHA from `scripts/setup/detect-host.sh`,
|
||||
download, verify hash, extract without beautifying, and test the
|
||||
regex against the minified bytes:
|
||||
|
||||
```bash
|
||||
url=$(grep -oP "claude_download_url='\K[^']+" \
|
||||
scripts/setup/detect-host.sh | head -1)
|
||||
expected=$(grep -oP "claude_exe_sha256='\K[^']+" \
|
||||
scripts/setup/detect-host.sh | head -1)
|
||||
mkdir -p /tmp/verify && cd /tmp/verify
|
||||
wget -q -O Claude-Setup.exe "$url"
|
||||
echo "$expected Claude-Setup.exe" | sha256sum -c -
|
||||
|
||||
7z x -y Claude-Setup.exe -o exe
|
||||
nupkg=$(find exe -name 'AnthropicClaude-*.nupkg' | head -1)
|
||||
7z x -y "$nupkg" -o nupkg
|
||||
npx asar extract nupkg/lib/net45/resources/app.asar app
|
||||
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const code = fs.readFileSync(
|
||||
"app/.vite/build/index.js", "utf8");
|
||||
const re = /await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/;
|
||||
const m = code.match(re);
|
||||
console.log(m ? `MATCH: ${m[0]}` : "NO MATCH");
|
||||
'
|
||||
```
|
||||
|
||||
`NO MATCH` means the regex is wrong. Verifying the SHA defends against
|
||||
stale URL pinning or server-side binary swap.
|
||||
|
||||
## End-to-end verification (post-build)
|
||||
|
||||
Four layers: build log, syntactic validity, asar markers, runtime.
|
||||
|
||||
1. Check the patch-count line:
|
||||
|
||||
```bash
|
||||
./build.sh --build appimage --clean no 2>&1 | tee build.log
|
||||
grep -E 'Applied [0-9]+ cowork patches' build.log
|
||||
```
|
||||
|
||||
Healthy 1.5354.0 build: `Applied 12 cowork patches`. A lower
|
||||
number, or any `WARNING:` in the cowork section, is a half-patched
|
||||
asar.
|
||||
|
||||
2. `node --check` on the patched `index.js` — catches malformed
|
||||
replacements that serialize but don't parse (PR #436 used this in
|
||||
dry-run validation):
|
||||
|
||||
```bash
|
||||
node --check test-build/.../app.asar.contents/.vite/build/index.js
|
||||
```
|
||||
|
||||
3. Static-grep the shipped asar for the 9 cowork markers from PR
|
||||
#555. `scripts/verify-patches.sh` automates this (issue #559 D6)
|
||||
and runs in CI on every `amd64-deb` build via the
|
||||
`Verify cowork patches in shipped asar` step in
|
||||
`.github/workflows/build-amd64.yml`. Reusable for non-cowork patch
|
||||
sets — pass any same-shape TSV as the second arg.
|
||||
|
||||
4. Launch the AppImage and check runtime state:
|
||||
|
||||
```bash
|
||||
tail -20 ~/.config/Claude/logs/cowork_vm_daemon.log
|
||||
ls -la "${XDG_RUNTIME_DIR}/cowork-vm-service.sock"
|
||||
ss -lpx | grep cowork-vm-service.sock
|
||||
```
|
||||
|
||||
Daemon log should have `lifecycle startup` and `lifecycle
|
||||
listening`; socket should exist and be owned by the
|
||||
`cowork-vm-service.js` process listed by `ss`.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tray-rebuild-race.md` "Resilience to minifier churn" — prior art
|
||||
for dynamic extraction across a six-variable patch site and the
|
||||
post-rename idempotency-guard pattern.
|
||||
- `plugin-install.md` "Getting the Minified Source for Any Shipped
|
||||
Version" — the `reference-source.tar.gz` release asset gives
|
||||
beautified asar contents of any prior version for diffing. Useful
|
||||
for spotting when an identifier renamed and which version did it.
|
||||
@@ -1,144 +0,0 @@
|
||||
[< Back to docs index](../index.md)
|
||||
|
||||
# Docs Style Guide
|
||||
|
||||
How docs are organized and written in this repo. The patterns here come from a survey of well-organized open-source docs (Spatie, Filament, laravel-docs, earendil-works/pi) plus what's worked in this project's own `docs/` tree. If you're adding a page, read the **Page anatomy** section before you start.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Flat `docs/`**, **lowercase kebab-case** filenames (`troubleshooting.md`, not `TROUBLESHOOTING.md`; `building.md`, not `BUILDING.md`). Order belongs in this index, not filenames.
|
||||
- One entry point: **[`docs/index.md`](../index.md)**. It's the GitHub-browsable landing page and the link target from every other doc.
|
||||
- **Subdirectories only when a topic grows past ~5 pages.** Current subdirs:
|
||||
- [`docs/learnings/`](../learnings/) — subsystem deep-dives. Promoted out of the top level once there were >3.
|
||||
- [`docs/testing/`](../testing/) — test harness docs.
|
||||
- [`docs/issue-triage/`](../issue-triage/) — the issue-triage bot config and prompts.
|
||||
- [`docs/upstream-reports/`](../upstream-reports/) — bug reports filed against upstream that we keep alongside the patch.
|
||||
- `docs/styleguides/` — meta-docs about how to write docs and shell scripts.
|
||||
- **`docs/images/`** for screenshots and diagrams. Never scatter `.png`s next to `.md`s.
|
||||
- **Repo-root auxiliary files stay at the root** so GitHub auto-detects them: `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, `LICENSE-*`, `RELEASING.md`, `CLAUDE.md`, `AGENTS.md`. Don't move them under `docs/`.
|
||||
|
||||
## Page anatomy
|
||||
|
||||
Three skeletons recur across well-organized docs in this project. Pick one before starting a page.
|
||||
|
||||
### Setup / how-to page
|
||||
|
||||
Used for: `building.md`, `configuration.md`, `releasing.md` (in the root).
|
||||
|
||||
```
|
||||
<one declarative sentence: what this page is for>
|
||||
<one code block showing the minimum working command>
|
||||
## Prerequisites -> short list; assume Linux + git unless stated
|
||||
## <Step 1> -> one short paragraph + code block
|
||||
## <Step 2>
|
||||
## Common variations -> distro-specific or flag-specific quirks
|
||||
## Troubleshooting -> link out to troubleshooting.md, don't duplicate
|
||||
```
|
||||
|
||||
Open with the minimum command, not the prerequisites table. Readers skim to the code block first.
|
||||
|
||||
### Troubleshooting / FAQ page
|
||||
|
||||
Used for: `troubleshooting.md`.
|
||||
|
||||
```
|
||||
<one declarative sentence: what kind of problem this page solves>
|
||||
## <Symptom or error message verbatim> -> one ### Fix per symptom, with a code block
|
||||
## <Next symptom>
|
||||
```
|
||||
|
||||
The headings are **the symptom users type into search.** Don't editorialize ("Troubles with Wayland" is wrong — `## Black screen on Fedora KDE under Wayland` is right). One `### Fix` per `##`. If a symptom needs explanation, prose goes under the fix, not in the heading.
|
||||
|
||||
### Subsystem deep-dive (a "learning")
|
||||
|
||||
Used for: everything in `docs/learnings/`.
|
||||
|
||||
```
|
||||
<one paragraph: what subsystem this covers, when it runs, why it's non-obvious>
|
||||
**Source files:** bullet list of GitHub links to the relevant source
|
||||
## Overview -> 2–3 paragraphs of context
|
||||
## <Mechanic> -> for each non-trivial mechanic, prose + diagram only when state transitions need one
|
||||
## <Failure mode> -> for each known failure, repro + diagnosis + fix path
|
||||
## References -> issues, PRs, upstream bugs, useful commits
|
||||
```
|
||||
|
||||
Deep-dives can be long — `apt-worker-architecture.md` and `patching-minified-js.md` are >10 kB and that's fine. They serve repeat readers (future you, future contributors) hunting for a specific fact, not first-timers.
|
||||
|
||||
### Decision record (ADR)
|
||||
|
||||
Used for: entries in `docs/decisions.md`.
|
||||
|
||||
```
|
||||
## D-NNN — <short title>
|
||||
- **Status:** Accepted / Superseded / Proposed
|
||||
- **Decided:** YYYY-MM-DD
|
||||
- **Owner:** @handle
|
||||
- **Stakeholders:** ...
|
||||
### Context -> what triggered the decision
|
||||
### Decision -> the call in one or two sentences
|
||||
### Rationale -> bullets
|
||||
### Consequences -> what was accepted, what's now out of bounds
|
||||
### Alternatives Considered
|
||||
### References
|
||||
```
|
||||
|
||||
See [`decisions.md`](../decisions.md) for the live record. Don't delete superseded decisions — mark them and link forward.
|
||||
|
||||
## Content rules
|
||||
|
||||
1. **Open every page with one declarative sentence, then a code block or list.** No "In this guide we will explore…" preamble. If the page is in the root (not behind `[< Back to ...]`), the first line under the H1 is that sentence.
|
||||
2. **Imperative, second-person, present tense.** "Run the build." Not "users may wish to consider running the build."
|
||||
3. **Domain nouns.** This is a packaging project — use `patches`, `the launcher`, `the worker`, `app.asar`, `the minified bundle`, `the asar archive`. Don't say `foo`/`bar` in end-to-end recipes. Placeholders are tolerable in basic-usage; in walkthroughs they kill comprehension.
|
||||
4. **Real PR / issue / commit references over hand-waving.** "Fixed in [#475](https://github.com/aaddrick/claude-desktop-debian/pull/475)" beats "fixed in a recent PR." `git log --grep` works on links; not on adjectives.
|
||||
5. **Defaults first, then the override.** "The build auto-detects your distro. To force a format, pass `--build appimage`."
|
||||
6. **Warnings in alert blocks**, not paragraphs: `> [!NOTE]`, `> [!WARNING]`, `> [!TIP]`. GitHub renders them; reading them isn't optional.
|
||||
7. **Source-file blocks on deep-dives.** Bulleted GitHub links to the actual files. Don't bury source references in prose.
|
||||
8. **Cross-link liberally.** Every page should link to 2–4 others. `docs/index.md` should link to every page in `docs/`.
|
||||
9. **One file per topic.** Don't paste the same config block into three pages. Show it once in `configuration.md`; excerpt subsections elsewhere with a link back.
|
||||
10. **Rationale lives in `decisions.md` or a learning**, not sprinkled through feature docs. If you find yourself writing "we did this because…" in a how-to page, that paragraph belongs in `learnings/<topic>.md` or `decisions.md`.
|
||||
|
||||
## Patterns worth stealing
|
||||
|
||||
- **Comparison tables for near-synonyms.** When something has overlapping siblings (deb vs. rpm vs. AppImage vs. nix; Wayland vs. XWayland; SUID sandbox vs. user namespaces), a `| feature | A | B | C |` table beats three prose paragraphs.
|
||||
- **"Source files" block at the top of deep-dives.** See [`docs/learnings/apt-worker-architecture.md`](../learnings/apt-worker-architecture.md) for the canonical example.
|
||||
- **`[< Back to <parent>]` link at the top of subpages.** GitHub doesn't render breadcrumbs; this is the manual equivalent. Use it on pages inside subdirectories.
|
||||
- **Verbatim error messages as `##` headings in `troubleshooting.md`.** Users land via search; search hits the heading.
|
||||
|
||||
## Antipatterns
|
||||
|
||||
- **Duplicating quickstart in three places.** README is pitch + install one-liner + link to docs. Real install lives in `building.md`, and only there.
|
||||
- **`docs/` without an `index.md`.** GitHub renders an alphabetical file list and contributors get lost.
|
||||
- **Uppercase / SHOUTY filenames** (`TROUBLESHOOTING.md`). Hard to type, looks dated, inconsistent with `docs/learnings/*.md`. Lowercase kebab-case throughout.
|
||||
- **Numbered prefixes** (`01-introduction.md`). Order belongs in `index.md`. Renumbering rots cross-links.
|
||||
- **Free-form FAQ prose** ("Q: How do I…? A: Well, you might…"). Use `## <error message>` → `### Fix` → code instead. Search ranks headings, not paragraphs.
|
||||
- **One page past ~30 kB that isn't a reference/deep-dive.** Promote to a subdirectory or split. CLAUDE.md is the exception — it's an archaeology document, not a how-to.
|
||||
- **Inline "this changed in v2.0.7" annotations** scattered through current docs. Version notes belong in `CHANGELOG.md`.
|
||||
- **Code blocks without a "when to use this" sentence above them.** Turns docs into a man-page dump.
|
||||
- **Hiding `CONTRIBUTING.md` or `SECURITY.md` under `docs/`.** GitHub stops auto-detecting them.
|
||||
|
||||
## Page-size honesty
|
||||
|
||||
Length should track topic depth, not editorial consistency.
|
||||
|
||||
| Size | When |
|
||||
|---|---|
|
||||
| <500 B | Single config snippet + 2 sentences. Stub pages and redirects. |
|
||||
| 1.5–3 kB | Platform notes, single-flag install variants |
|
||||
| 3–8 kB | Standard how-to and setup pages |
|
||||
| 10–17 kB | Major how-to pages, learnings |
|
||||
| 17–25 kB | Deep-dive learnings with diagrams |
|
||||
| >30 kB | Smell. Either it's a reference page (rare in this repo), or it should split. |
|
||||
|
||||
Pages can be five sentences. **Don't pad short topics.**
|
||||
|
||||
## What stays in README vs. moves into `docs/`
|
||||
|
||||
| In README | In `docs/` |
|
||||
|---|---|
|
||||
| Elevator pitch (1–3 sentences) | Full prose docs |
|
||||
| Installation one-liners per package format | Complete build / configuration walkthroughs |
|
||||
| Link to `docs/index.md` | Everything else |
|
||||
| Acknowledgments (contributor credits) | — |
|
||||
| License + sponsor links | — |
|
||||
|
||||
The README is the project's storefront. `docs/` is the manual. Once a topic exists in `docs/`, the README links out — don't duplicate.
|
||||
@@ -11,6 +11,7 @@ This directory holds the manual test plan for the Linux fork of Claude Desktop.
|
||||
| [`matrix.md`](./matrix.md) | **The dashboard.** Cross-environment results table + per-section env-specific status snapshots. Single source of truth for test status. |
|
||||
| [`runbook.md`](./runbook.md) | How to run a sweep: VM setup, diagnostic capture, status update workflow, severity guidance. |
|
||||
| [`cases/`](./cases/) | Functional test specs grouped by feature surface. Stable IDs: `T###` cross-env, `S###` env-specific. |
|
||||
| [`ui/`](./ui/) | UI element inventory. Per-surface checklists — every interactive element with expected state. |
|
||||
|
||||
## Environment key
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ tests, which anti-patterns to design against, and what to build first.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The 67 tests in [`cases/`](./cases/) already have stable IDs and
|
||||
standardized bodies. That structure is unusually friendly to
|
||||
automation — but only if the harness is shaped to match the corpus,
|
||||
rather than the other way around. Three things make that non-trivial:
|
||||
The 67 tests in [`cases/`](./cases/) plus the 10 surfaces in [`ui/`](./ui/)
|
||||
already have stable IDs, standardized bodies, and per-element checklists. That
|
||||
structure is unusually friendly to automation — but only if the harness is
|
||||
shaped to match the corpus, rather than the other way around. Three things
|
||||
make that non-trivial:
|
||||
|
||||
1. The tests aren't homogeneous. Some are pure-renderer (Code tab), some are
|
||||
native-OS-level (tray, autostart, URL handler), some are visual/UX checks
|
||||
@@ -39,7 +40,7 @@ rather than the other way around. Three things make that non-trivial:
|
||||
| 1 | **Single language: TypeScript.** Every runner is `.ts`; OS tools are shelled out via `child_process` and wrapped as TS helpers. Python only as a last-resort escape hatch for AT-SPI cases that resist portal mocking. | Playwright Electron is JS-native (post-Spectron); `dbus-next` covers DBus end-to-end; portal mocking removes the dogtail dependency for most native-dialog tests. Three-language overhead doesn't pay back. |
|
||||
| 2 | **Harness location: `tools/test-harness/`.** Sibling to `scripts/`. | Keeps `docs/testing/` documentation-only; matches the project's existing `tools/` / `scripts/` split. |
|
||||
| 3 | **VM images: Packer for imperative distros + Nix flake for `Hypr-N`.** | Packer builds golden snapshots that boot fast and rebuild as code; Nix flake handles NixOS natively without a second wrapper. Vagrant's per-boot provisioning model is the wrong tradeoff for hermetic per-test snapshots. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invocable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 4 | **No CI infrastructure initially.** Harness is invokable from CI (orchestrator is a bash script with `ROW`, `ARTIFACT`, `OUTPUT_DIR` env vars), but sweeps run manually from the dev box for the first ~20 tests. CI wrapper comes after there's signal on which tests are stable enough to run unattended. | Avoids weeks of GHA / nested-KVM debugging for tests that aren't ready to be unattended. The bash orchestrator is the same code either way. |
|
||||
| 5 | **Selectors: semantic locators only (`getByRole`, `getByLabel`, `getByText`).** No CSS classes against minified renderer output. No proactive `data-testid` injection patch. Escalate per-test only when a specific test proves unstable: first ask upstream for a stable `data-testid`; only carry an `app-asar.sh` patch if upstream declines. | Building selector-injection infrastructure up front is a guess at where rot will happen. Modern React apps usually have enough ARIA roles and visible text for `getByRole`/`getByText` to be durable. Measure before patching. |
|
||||
| 6 | **X11-default verification is Smoke. Wayland-native characterization is Should.** Add a Smoke test asserting the launcher log shows X11/XWayland selected on each row (the project's release-gate behavior). Add per-row Should tests characterizing what happens if Electron's default Wayland selection is allowed — these are informational, not release-gating. | The project chose X11 default because portal `GlobalShortcuts` coverage is patchy. The new Wayland-default tests exist to map that landscape, not to gate releases on it. |
|
||||
| 7 | **Diagnostic retention: last 10 greens + all reds, on `main` only.** Captures `--doctor`, launcher log, screenshot every run. Reds retained indefinitely; greens rotate. | Cheap regression-bisect baseline; bounded storage; reds are the things you actually need to look at six weeks later. |
|
||||
@@ -52,7 +53,7 @@ bucket maps to a different shape of TS code (not a different language):
|
||||
|
||||
| Layer | What it covers | Implementation |
|
||||
|-------|----------------|----------------|
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **L1 — Renderer** | Code tab, plugin install, settings, prompt area, slash menu, side chat, most of `ui/code-tab-panes.md`, `prompt-area.md`, `settings.md` | `playwright-electron` (`_electron.launch()`) directly |
|
||||
| **L2 — Native / OS** | Tray (DBus), window decorations, URL handler (`xdg-open`), autostart, `--doctor`, multi-instance, hide-to-tray, native file picker (T17) | TS + `dbus-next` for DBus; `child_process` shell-outs wrapped as TS helpers (`xprop`, `wlr-randr`, `swaymsg`, `niri msg`, `pgrep`, `ydotool`); `dbus-next`-driven portal mocking for native-dialog tests |
|
||||
| **L3 — Manual** | "Icon is crisp on HiDPI", drag-and-drop feel, T28 catch-up after suspend (real wall-clock), subjective UX checks | Human eyes; capture in [`runbook.md`](./runbook.md) sweep loop |
|
||||
|
||||
|
||||
347
docs/testing/cases-grounding-prompt.md
Normal file
347
docs/testing/cases-grounding-prompt.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# docs/testing/cases grounding sweep — implementation prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
orchestration depends on the exact directives below.
|
||||
|
||||
---
|
||||
|
||||
## Prompt to paste
|
||||
|
||||
You're picking up after the v7 walker, U01 wire-up, and the
|
||||
`claudeai.ts` AX-tree migration all landed. The page-objects are
|
||||
stable against the live renderer (T17_folder_picker passes on
|
||||
KDE-W). The next workstream is **grounding the case docs in
|
||||
`docs/testing/cases/` against actual upstream behavior**.
|
||||
|
||||
The cases were written from outside-in — observed user-visible
|
||||
flows, expected outcomes, diagnostic captures. Many describe
|
||||
behavior the test author *believed* exists in upstream Claude
|
||||
Desktop, but no one has cross-checked each Step / Expected against
|
||||
the actual extracted source. Your job is to spawn one subagent per
|
||||
case file, have each one read the case + grep the build-reference
|
||||
extract for the relevant feature, and report what's accurate, what's
|
||||
stale, and what's missing — then make in-place adjustments to the
|
||||
case files so each one is grounded in concrete code anchors before
|
||||
the next sweep cycle.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order. They're the substrate the subagents will pull
|
||||
from.
|
||||
|
||||
- `docs/testing/cases/README.md` — the case-doc structure (severity,
|
||||
surface, applies-to, steps, expected, diagnostics, references).
|
||||
The "Standard test body" template at the bottom is the contract
|
||||
every case currently follows.
|
||||
- `docs/testing/matrix.md` — live Pass/Fail/Pending matrix per row.
|
||||
Tells you which cases have a runner and which are still
|
||||
human-execution-only.
|
||||
- `build-reference/app-extracted/.vite/build/` — the extracted +
|
||||
beautified Claude Desktop source. ~14 files; `index.js` is the
|
||||
main process (~546k lines after beautification), `mainView.js` /
|
||||
`mainWindow.js` / `quickWindow.js` are renderer preloads,
|
||||
`coworkArtifact.js` is the cowork BrowserView preload,
|
||||
`buddy.js` is the supervisor, etc. **This is the ground truth.**
|
||||
- `tools/test-harness/src/runners/` — existing runners that *do*
|
||||
have working selectors / event hooks. Sometimes the runner has
|
||||
more accurate code anchors than the case doc.
|
||||
- `CLAUDE.md` (project root) — project conventions, attribution
|
||||
format, commit style. Don't violate.
|
||||
|
||||
### Case files in scope
|
||||
|
||||
Eleven files plus the README. One subagent per file:
|
||||
|
||||
| File | Tests covered |
|
||||
|---|---|
|
||||
| `code-tab-foundations.md` | T15-T20 |
|
||||
| `code-tab-handoff.md` | T23-T25, T34, T38, T39 |
|
||||
| `code-tab-workflow.md` | T21-T22, T29-T32 |
|
||||
| `distribution.md` | S01-S05, S15, S16, S26 |
|
||||
| `extensibility.md` | T11, T33, T35-T37, S27, S28 |
|
||||
| `launch.md` | T01, T02, T13, T14 |
|
||||
| `platform-integration.md` | T09, T10, T12, S17, S18, S22-S25 |
|
||||
| `routines.md` | T26-T28, S19-S21 |
|
||||
| `shortcuts-and-input.md` | T05, T06, S06-S14, S29-S37 |
|
||||
| `tray-and-window-chrome.md` | T03, T04, T07, T08, S08, S13 |
|
||||
|
||||
### Why this iteration
|
||||
|
||||
Several cases have been silently bit-rotting against upstream
|
||||
changes — a Step says "click the X menu" but X was renamed two
|
||||
upstream versions ago, or an Expected references a behavior the
|
||||
team shipped behind a feature flag that's now off by default. When
|
||||
the sweep runs against a row that's stale, the failure looks like a
|
||||
Linux compatibility issue but is actually a doc-vs-upstream drift.
|
||||
Grounding the cases against the actual extracted source closes
|
||||
that gap and makes future sweeps interpretable.
|
||||
|
||||
This isn't a one-time correctness pass — it's a cycle. After every
|
||||
upstream version bump (`CLAUDE_DESKTOP_VERSION` rolls in
|
||||
`scripts/setup/detect-host.sh`), the grounding can drift again.
|
||||
Optimise for **leaving concrete code-anchor breadcrumbs** in each
|
||||
case so the next grounding pass is fast.
|
||||
|
||||
### Repo conventions
|
||||
|
||||
- Tabs for indentation in code; markdown is space-indented as the
|
||||
existing files do it.
|
||||
- Markdown lines wrap at ~80 chars unless they're tables or links
|
||||
that don't break naturally.
|
||||
- Don't commit. The user reviews and commits.
|
||||
- Don't run the host Claude Desktop. The user runs it. Read from
|
||||
`build-reference/` instead — that's already extracted +
|
||||
beautified specifically so you don't have to attach to a live
|
||||
app to verify behavior.
|
||||
|
||||
### Code anchors
|
||||
|
||||
- `build-reference/app-extracted/.vite/build/index.js` — main
|
||||
process. Every IPC channel registration, window-management
|
||||
decision, app-lifecycle hook, tray-menu construction, autostart
|
||||
toggle, dialog invocation, and protocol handler lives here.
|
||||
- `build-reference/app-extracted/.vite/build/quickWindow.js` —
|
||||
Quick Entry preload + window setup.
|
||||
- `build-reference/app-extracted/.vite/build/mainWindow.js` —
|
||||
main shell BrowserWindow preload (claude.ai is loaded into a
|
||||
child BrowserView; this preload runs in the shell frame).
|
||||
- `build-reference/app-extracted/.vite/build/mainView.js` —
|
||||
preload running inside the claude.ai BrowserView itself.
|
||||
- `build-reference/app-extracted/.vite/build/coworkArtifact.js` —
|
||||
preload running inside cowork's iframe-shaped artifact view.
|
||||
- `build-reference/app-extracted/.vite/build/buddy.js` — supervisor
|
||||
process (the daemon that respawns the cowork worker; see
|
||||
`docs/learnings/cowork-vm-daemon.md`).
|
||||
- `build-reference/app-extracted/package.json` — declared main /
|
||||
preloads, electron version, native deps. Quick reference for
|
||||
whether a feature is wired up at all.
|
||||
|
||||
### Phases
|
||||
|
||||
#### Phase 0 — calibration
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — should pass; if
|
||||
not, stop and report.
|
||||
2. Read `docs/testing/cases/README.md` end-to-end and one full case
|
||||
file (suggest `launch.md` — small, four tests, easy
|
||||
surface-area). Confirm you understand the case-doc contract
|
||||
before fanning out.
|
||||
3. Pick T01 (App launch) as a calibration case. Manually grep
|
||||
`build-reference/app-extracted/.vite/build/index.js` for the
|
||||
launcher-log / backend-selection logic referenced in T01's
|
||||
Expected. Confirm you can read the beautified source and locate
|
||||
the relevant code. Report the anchor (`index.js:N-M`) so the
|
||||
user knows the workflow is sound before you fan out.
|
||||
|
||||
If Phase 0 surfaces a problem (build-reference stale relative to
|
||||
the case doc, calibration anchor not findable, README structure
|
||||
unclear), stop and report. Don't fan out subagents against an
|
||||
unverified workflow.
|
||||
|
||||
#### Phase 1 — fan-out
|
||||
|
||||
Spawn one subagent per case file (eleven total). Use
|
||||
`subagent_type: 'general-purpose'`. Send them in **parallel** —
|
||||
they're independent. Keep the prompt to each subagent
|
||||
self-contained; the subagent has no context from this conversation.
|
||||
|
||||
Per-subagent prompt template (fill in the case file path):
|
||||
|
||||
```
|
||||
You're grounding ONE test-case file in
|
||||
docs/testing/cases/<FILE>.md against the extracted Claude Desktop
|
||||
source at build-reference/app-extracted/.vite/build/.
|
||||
|
||||
Read these first:
|
||||
- docs/testing/cases/README.md (case-doc contract)
|
||||
- docs/testing/cases/<FILE>.md (your case file)
|
||||
- CLAUDE.md (project conventions)
|
||||
|
||||
For each test in the file:
|
||||
|
||||
1. Read the test's Steps + Expected.
|
||||
2. Identify the load-bearing claim — the upstream behavior the
|
||||
test depends on (an IPC channel, a tray-menu item, a
|
||||
dialog.showOpenDialog call, a globalShortcut.register, a
|
||||
nativeTheme listener, etc.).
|
||||
3. Grep build-reference/app-extracted/.vite/build/ for that claim.
|
||||
Use ripgrep / grep -E. The code is beautified but minified
|
||||
variable names — anchor on string literals, IPC channel names,
|
||||
menu labels, event names, not variable identifiers.
|
||||
4. Classify the result:
|
||||
- **Grounded** — claim verified, anchor found. Append a
|
||||
`**Code anchors:** <file>:<line>` line to the test body
|
||||
directly under the existing References field.
|
||||
- **Drifted** — feature exists but the case's Steps or Expected
|
||||
don't match what's actually shipping. Edit the case to
|
||||
match upstream behavior. Note what changed.
|
||||
- **Missing** — feature isn't in the build at all (deprecated,
|
||||
never shipped, behind unset flag). Mark the test with a
|
||||
prepended block:
|
||||
`> **⚠ Missing in build 1.5354.0** — <one-line note>. Re-verify after next upstream bump.`
|
||||
- **Ambiguous** — claim could be one of several upstream code
|
||||
paths and you can't disambiguate from the case alone. Don't
|
||||
edit; report under "Open questions".
|
||||
|
||||
Per-test, prefer concrete code anchors over wordy explanations.
|
||||
The next person reading this case should see exactly where
|
||||
upstream implements the feature.
|
||||
|
||||
Constraints:
|
||||
- Don't fabricate anchors. If you can't find it, mark Missing or
|
||||
Ambiguous — never invent a `index.js:12345` reference.
|
||||
- Don't restructure the case files. Keep the existing template
|
||||
(Severity / Surface / Applies to / Issues / Steps / Expected /
|
||||
Diagnostics / References). Only add code anchors and edit
|
||||
Steps/Expected for drift.
|
||||
- Don't expand scope. If you notice an unrelated bug or missing
|
||||
test, note it under "Open questions" — don't fix it inline.
|
||||
- Don't run the host Claude Desktop. Read from build-reference/
|
||||
only.
|
||||
|
||||
Report shape (~300-500 words):
|
||||
|
||||
## <FILE>.md grounding
|
||||
|
||||
- Tests reviewed: N
|
||||
- Grounded: N
|
||||
- Drifted (edited): N (one-line per: <test-id> — <what changed>)
|
||||
- Missing (marked): N (one-line per: <test-id> — <what's gone>)
|
||||
- Ambiguous (flagged): N (one-line per: <test-id> — <why>)
|
||||
|
||||
### Code anchor highlights
|
||||
- <test-id>: <file>:<line> — <what the anchor proves>
|
||||
|
||||
### Open questions
|
||||
- ...
|
||||
|
||||
### Files touched
|
||||
- docs/testing/cases/<FILE>.md
|
||||
```
|
||||
|
||||
Keep the report tight. The orchestrator reads eleven of these and
|
||||
synthesizes.
|
||||
|
||||
#### Phase 2 — synthesis
|
||||
|
||||
Once all eleven subagents return:
|
||||
|
||||
1. Aggregate per-classification counts across all files. Big
|
||||
numbers in any column are signals:
|
||||
- Lots of **Drifted** → upstream had a recent feature shuffle;
|
||||
the team should know.
|
||||
- Lots of **Missing** → either the case doc was written
|
||||
speculatively or upstream removed features without telling.
|
||||
- Lots of **Ambiguous** → the case-doc template needs a
|
||||
"Implementation hint" field so future grounding has a
|
||||
starting point.
|
||||
2. Cross-check: did any subagent edit the same anchor differently?
|
||||
(Unlikely since each owns one file, but worth a sanity pass.)
|
||||
3. Check that `git diff docs/testing/cases/` matches what the
|
||||
subagents reported. If a subagent claimed Drifted but didn't
|
||||
write to disk, surface it.
|
||||
4. Build the user-facing summary (see "Final report format" below).
|
||||
|
||||
Don't make the user re-read the eleven subagent reports — give
|
||||
them the synthesised view + the per-file links.
|
||||
|
||||
### Self-correction loop
|
||||
|
||||
After Phase 1 returns:
|
||||
|
||||
1. If any subagent failed (no report, error, hit token limit),
|
||||
re-spawn just that one with a tighter scope (e.g. "process
|
||||
tests T15-T17 only, not the full file").
|
||||
2. If a subagent's report claims edits but `git diff` shows no
|
||||
changes, the subagent silently dropped the writes — re-spawn
|
||||
with explicit instruction to use the Edit tool.
|
||||
3. If two subagents flag the same upstream code path with
|
||||
contradictory claims (one says Grounded, one says Missing),
|
||||
re-read the source yourself and adjudicate.
|
||||
|
||||
Cap re-spawns at **2 per file** — past that, mark the file as
|
||||
"needs human review" in the final report and move on.
|
||||
|
||||
### Termination conditions
|
||||
|
||||
Stop and write a final report when one of:
|
||||
|
||||
1. **All eleven files grounded.** Per-file classification counts +
|
||||
diff stat. Done.
|
||||
2. **Hit the re-spawn cap on 3+ files.** Stop, write up which
|
||||
files are blocked, what each blocker looks like.
|
||||
3. **Build-reference is stale.** If multiple subagents report
|
||||
"Missing" against features the user knows shipped, the
|
||||
extract may be out of date — verify the version
|
||||
(`build-reference/app-extracted/package.json` `version` field
|
||||
vs `CLAUDE_DESKTOP_VERSION` repo variable) before continuing.
|
||||
|
||||
### What you should NOT do
|
||||
|
||||
- Don't commit. The user reviews everything.
|
||||
- Don't restructure the case-doc template. Eleven files, one
|
||||
shape — keep it that way.
|
||||
- Don't add new tests. Grounding is a verify-and-anchor pass, not
|
||||
a coverage expansion.
|
||||
- Don't run the host Claude Desktop. The build-reference extract
|
||||
exists specifically so you don't have to attach to a live app.
|
||||
- Don't edit anything outside `docs/testing/cases/`. If you find
|
||||
a runner discrepancy (case says "click X", runner clicks "Y"),
|
||||
flag it under Open questions; don't edit the runner.
|
||||
- Don't invent anchors. If the grep doesn't find the literal,
|
||||
classify Missing or Ambiguous — never write a fictional
|
||||
`index.js:12345` reference.
|
||||
|
||||
### Final report format
|
||||
|
||||
```markdown
|
||||
## Cases grounding summary
|
||||
|
||||
- Files reviewed: 11 / 11
|
||||
- Tests reviewed: N (sum across all files)
|
||||
- Grounded: N (with code anchors added)
|
||||
- Drifted (edited): N
|
||||
- Missing (marked): N
|
||||
- Ambiguous: N
|
||||
- Files needing
|
||||
human review: N
|
||||
|
||||
## Per-file breakdown
|
||||
|
||||
| File | Reviewed | Grounded | Drifted | Missing | Ambiguous |
|
||||
|---|---|---|---|---|---|
|
||||
| code-tab-foundations.md | ... | ... | ... | ... | ... |
|
||||
| ... | | | | | |
|
||||
|
||||
## Notable findings
|
||||
- <test-id>: <one-line significance>
|
||||
- ...
|
||||
|
||||
## Open questions
|
||||
- ...
|
||||
|
||||
## Files touched
|
||||
git status output (only docs/testing/cases/*.md should appear)
|
||||
|
||||
## Diff summary
|
||||
git diff --stat docs/testing/cases/
|
||||
```
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Subagents are launched in parallel via a single message with
|
||||
multiple Agent tool calls. Don't serialize them — Phase 1 takes
|
||||
~15 minutes serial, ~3 minutes parallel.
|
||||
- Each subagent's Edit calls land directly in the working tree.
|
||||
No merge conflicts because each owns one file.
|
||||
- The build-reference `index.js` is 546k lines. Subagents should
|
||||
use `grep -nE` with anchored string literals, not full reads.
|
||||
Recommended grep pattern style:
|
||||
`grep -nE 'globalShortcut\.register\([^)]*' build-reference/app-extracted/.vite/build/index.js`
|
||||
- If a subagent needs to verify a renderer-side claim (DOM event
|
||||
flow, React component shape), the relevant preload is in
|
||||
`mainView.js` / `mainWindow.js`. Don't grep `index.js` for
|
||||
renderer-only behavior.
|
||||
|
||||
Begin with Phase 0. Don't fan out until calibration succeeds.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Functional Test Cases
|
||||
|
||||
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md).
|
||||
Test specifications grouped by feature surface. For live status, see [`../matrix.md`](../matrix.md). For sweep workflow, see [`../runbook.md`](../runbook.md). For the UI element inventory, see [`../ui/`](../ui/).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
|
||||
**Diagnostics on failure:** `xrandr` (X11) / `wlr-randr` (wlroots) output before and after disconnect, captured popup coordinates, screenshot.
|
||||
|
||||
**Skip when:** Single-monitor VM or host. Skip with `-` in the dashboard.
|
||||
**Skip when:** Single-monitor VM or host. Not part of the [§ Mandatory matrix](../quick-entry-closeout.md#mandatory-matrix); skip with `-` in the dashboard.
|
||||
|
||||
**References:** upstream `index.js:515502`
|
||||
**Code anchors:** build-reference/app-extracted/.vite/build/index.js:515502 (`return cHn();` early-return when no saved position), 515523-515527 (`cHn()` centres popup on `screen.getPrimaryDisplay()` workArea), 515514-515515 (`label`-only match fallback before primary-display fallback).
|
||||
|
||||
322
docs/testing/claudeai-lib-ax-migration-prompt.md
Normal file
322
docs/testing/claudeai-lib-ax-migration-prompt.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# lib/claudeai.ts AX-tree migration — implementation prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
self-correction loop depends on the exact directives below.
|
||||
|
||||
---
|
||||
|
||||
## Prompt to paste
|
||||
|
||||
You're picking up after the v7 fingerprint walker + U01 wire-up
|
||||
landed. Walker, resolver, and U01 are all on the AX-tree substrate.
|
||||
The page-object library `tools/test-harness/src/lib/claudeai.ts` is
|
||||
still on the old substrate — `document.querySelector` against
|
||||
minified-tailwind class shapes (`button[aria-haspopup="menu"]` +
|
||||
`span.truncate.max-w-[Npx]`) — and that's where every claude.ai UI
|
||||
spec couples to upstream's React DOM. Your job is to migrate the
|
||||
brittle CSS-shape walks in `claudeai.ts` to AX-tree resolution using
|
||||
the v7 walker primitives, run the H/S spec families that consume
|
||||
them, and iterate until those specs pass without DOM-shape coupling.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order. They contain the design, the gotchas, and the
|
||||
runtime contract — the prompt below assumes them as background.
|
||||
|
||||
- `docs/testing/fingerprint-v7-plan.md` — design contract for the v7
|
||||
fingerprint, kind-strictness matrix, resolver fallback chain. Skim
|
||||
the "Capture algorithm" and "Resolver / fallback chain" sections;
|
||||
the migration consumes the same primitives.
|
||||
- `docs/learnings/test-harness-ax-tree-walker.md` — the five
|
||||
non-obvious AX-tree traps (AX-enable async lag, navigateTo no-op,
|
||||
flat dialog>button[] lists, more-options shape, sidebar
|
||||
virtualization). All apply here too — `lib/claudeai.ts` calls run
|
||||
inside the same renderer the walker drives.
|
||||
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
|
||||
~340 lines, eight functions plus two classes (`CodeTab`,
|
||||
`LocalEnvPill`). Every public function is a discovery walk against
|
||||
`evalInRenderer` with `document.querySelectorAll`.
|
||||
|
||||
### Why this iteration
|
||||
|
||||
Per the v7 plan's design goal §2 "Resilient to cosmetic drift" —
|
||||
upstream regenerates tailwind class signatures on rebuild
|
||||
(`max-w-[Npx]`, `df-pill`-style atoms), so `claudeai.ts`'s CSS-shape
|
||||
walks break on any minor UI rebuild even when the AX-computed role
|
||||
and accessible name are stable. The U01 wire-up confirmed the AX
|
||||
tree is a usable substrate end-to-end (~7s/test, 89/90 stable across
|
||||
two consecutive sweeps). Pulling `claudeai.ts` onto the same
|
||||
substrate eliminates the recurring "tailwind regen breaks H05/S31
|
||||
again" failure mode.
|
||||
|
||||
Acceptance per the plan: H05 + S29-S37 + T-prefix specs that consume
|
||||
`claudeai.ts` keep passing on the same account, with zero new
|
||||
flakes. Migration is mechanical (replace the eval-string walks with
|
||||
AX-tree queries) and the existing tests are the contract.
|
||||
|
||||
### Repo conventions
|
||||
|
||||
- Tabs for indentation, lines under 80 chars, single quotes for
|
||||
literals, TypeScript strict mode (`tools/test-harness/tsconfig.json`
|
||||
enforces it).
|
||||
- Comments only when the WHY is non-obvious — write the `because:`
|
||||
clause, not the `that:` clause.
|
||||
- No backward-compatibility shims. If a function's signature needs
|
||||
to change, change every caller. Don't keep both code paths.
|
||||
- Don't commit. The user reviews and commits.
|
||||
|
||||
### Code anchors
|
||||
|
||||
- `tools/test-harness/explore/walker.ts` — exports the primitives
|
||||
you'll consume:
|
||||
- `findByFingerprint(inspector, fingerprint, kind)` — full
|
||||
resolver with strictness gating + relaxed-scope fallback.
|
||||
Overkill for one-shot lookups against the live renderer.
|
||||
- `queryAccessibleTree(elements, query)` — pure filter, used at
|
||||
capture and resolve time. Takes a `RawElement[]` snapshot and
|
||||
an `AxQuery` (ariaPath + leaf criteria). What you'll likely
|
||||
wrap.
|
||||
- `axTreeToSnapshot(nodes)` — converts CDP `AxNode[]` to the
|
||||
walker's `RawElement[]` shape. Drops ignored nodes.
|
||||
- `walkLandmarkAncestors(raw)` — emits the AriaStep[] for an
|
||||
element. Useful if a method needs to disambiguate by landmark.
|
||||
- `waitForAxTreeStable(inspector, opts)` — gating primitive used
|
||||
by walker + U01. Use `{ minNodes: 1, timeoutMs: 10000 }` for
|
||||
post-click reads (matches `snapshotSurface`'s default).
|
||||
- `tools/test-harness/src/lib/inspector.ts` — `getAccessibleTree`
|
||||
fetches the raw CDP tree filtered to the claude.ai webContents.
|
||||
- `tools/test-harness/src/lib/claudeai.ts` — the migration target.
|
||||
Read the file-header comment first; it documents the discovery
|
||||
strategy you're replacing.
|
||||
- `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`,
|
||||
`S31_quick_entry_submit_reaches_new_chat.spec.ts`,
|
||||
`S32_quick_entry_submit_gnome_stale_isfocused.spec.ts` — primary
|
||||
consumers of the methods being migrated.
|
||||
|
||||
### Phases
|
||||
|
||||
#### Phase A — spike on one method
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — must pass before
|
||||
doing anything.
|
||||
2. Pick `openPill(inspector, labelPattern, opts)` as the spike.
|
||||
It's the most CSS-shape-coupled method and exercises the
|
||||
menu-render polling pattern the rest of `claudeai.ts` reuses.
|
||||
3. Replace its body with an AX-tree query:
|
||||
- Fetch the AX tree (`inspector.getAccessibleTree('claude.ai')`),
|
||||
convert via `axTreeToSnapshot`.
|
||||
- Filter to elements with `computedRole === 'button'` and
|
||||
accessibleName matching `labelPattern`.
|
||||
- For each candidate, compute its parent landmark via
|
||||
`walkLandmarkAncestors`. The compact-pill discriminator —
|
||||
"has a `span.truncate.max-w-[Npx]` child" — needs an AX
|
||||
analogue. Most likely: parent is `toolbar` / `group` and the
|
||||
element has `aria-haspopup === 'menu'` (exposed in AX as
|
||||
`hasPopup` property; check whether `RawElement` carries it
|
||||
and extend if needed).
|
||||
- Click via `inspector.clickByBackendNodeId(raw.backendDOMNodeId)`.
|
||||
- Poll for menu items via AX role match (`menuitem`,
|
||||
`menuitemradio`, `menuitemcheckbox`).
|
||||
4. Run H05 against your branch (`./node_modules/.bin/playwright
|
||||
test src/runners/H05_ui_drift_check.spec.ts`). H05 doesn't
|
||||
directly call `openPill` but exercises the same renderer state;
|
||||
if H05 regresses your AX walk is wrong.
|
||||
5. Run S31 (`./node_modules/.bin/playwright test
|
||||
src/runners/S31_quick_entry_submit_reaches_new_chat.spec.ts`).
|
||||
This calls `openPill` indirectly via `CodeTab.activate` →
|
||||
`findCompactPills`.
|
||||
6. If both pass, the AX substrate works for at least one method.
|
||||
Commit the shape mentally (don't `git commit` — the user does
|
||||
that). If either fails, the spike is in trouble; re-read the
|
||||
AX-tree learnings doc for traps you missed and fix the
|
||||
primitive before expanding.
|
||||
|
||||
#### Phase B — migrate the rest
|
||||
|
||||
For each remaining function in `claudeai.ts`, port the discovery
|
||||
walk to AX:
|
||||
|
||||
- `activateTab(inspector, name)` — `button` with
|
||||
`accessibleName === name` under root or banner landmark. Existing
|
||||
`aria-label="X"` selector → AX `name` literal match.
|
||||
- `findCompactPills(inspector)` — list of buttons with
|
||||
`hasPopup === 'menu'` AND inner `span.truncate.max-w-[…]` text
|
||||
child. AX equivalent: button role + hasPopup + a child
|
||||
`genericContainer` (or whatever AX exposes for `<span>`) carrying
|
||||
the visible text. Returns `{text, maxW, expanded}` today —
|
||||
`maxW` is a tailwind artifact and should be dropped from the AX
|
||||
shape (callers don't use it for matching, just for diagnostics;
|
||||
keep a placeholder or remove from the type).
|
||||
- `clickMenuItem(inspector, textPattern, opts)` — element with
|
||||
role in `{menuitem, menuitemradio, menuitemcheckbox}` and
|
||||
accessibleName matching `textPattern`. The CSS attribute selector
|
||||
has an AX direct equivalent.
|
||||
- `pressEscape(inspector)` — keep as-is. It's a keydown dispatch,
|
||||
not a discovery walk.
|
||||
- `CodeTab.activate(opts)` — calls `activateTab` + polls
|
||||
`findCompactPills`. Migrates by transitivity.
|
||||
- `LocalEnvPill` — read its body to enumerate callers.
|
||||
|
||||
After each migration:
|
||||
1. `npm run typecheck` — must pass.
|
||||
2. `npx tsx explore/walker.ts` — selfTest must pass (you may have
|
||||
touched walker.ts to expose new primitives).
|
||||
3. Run the affected spec(s).
|
||||
|
||||
#### Phase C — full sweep
|
||||
|
||||
1. Run all H/S/T runners that consume `claudeai.ts`:
|
||||
- H05 (UI drift)
|
||||
- S31 (Code-tab submit)
|
||||
- S32 (GNOME stale isFocused)
|
||||
- any T-prefix that uses `installOpenDialogMock` or `pressEscape`
|
||||
2. Tally pass/fail. The post-migration baseline must equal the
|
||||
pre-migration baseline, modulo flakes characterized in
|
||||
`docs/learnings/test-harness-ax-tree-walker.md`.
|
||||
|
||||
Cap iterations at **5 sweep cycles** total (spike + 4 fix-rerun
|
||||
cycles) — past that, stop and report.
|
||||
|
||||
##### Failure classes
|
||||
|
||||
1. **AX-shape mismatch.** Element has the CSS shape the old code
|
||||
relied on but a different AX role/name than expected. Fix:
|
||||
probe the AX tree for the actual shape (use
|
||||
`inspector.getAccessibleTree('claude.ai')` interactively from a
|
||||
one-shot script), update the AX query.
|
||||
2. **Missing AX property exposure.** `hasPopup`, `expanded`, etc.
|
||||
may not be in `RawElement` today (the walker only reads role,
|
||||
name, ancestors, sibling info). Extend `RawElement` and
|
||||
`axTreeToSnapshot` to expose what the migration needs. Update
|
||||
walker.ts selfTest if you change the snapshot shape.
|
||||
3. **Race against menu render.** Old code polled
|
||||
`document.querySelectorAll('[role=menuitem]')` every 50ms. AX
|
||||
tree updates lag DOM by hundreds of ms; bake a
|
||||
`waitForAxTreeStable({ minNodes: 1 })` between click and
|
||||
menuitem fetch instead of a short DOM poll.
|
||||
4. **Tailwind-class diagnostic loss.** `findCompactPills` returns
|
||||
`maxW` which callers use only in error messages. If the
|
||||
AX-only return shape drops `maxW`, error messages get less
|
||||
informative — accept it, don't reintroduce DOM walks just for
|
||||
diagnostics. Keep the `maxW` field optional/null in the type.
|
||||
|
||||
##### What "fix" means
|
||||
|
||||
A fix is one of:
|
||||
- A code change in `claudeai.ts`, `walker.ts`, or `inspector.ts`.
|
||||
- A targeted extension of `RawElement` / `axTreeToSnapshot` to
|
||||
expose an AX property the migration needs.
|
||||
|
||||
Not a fix:
|
||||
- `// eslint-disable-next-line` / `// @ts-ignore` / `as unknown as ...`.
|
||||
- Keeping the old `document.querySelector` walk as a fallback.
|
||||
- Adding an AX walk that wraps a CSS walk that wraps an AX walk.
|
||||
|
||||
### Self-correction loop (general protocol)
|
||||
|
||||
After each phase's specific loop:
|
||||
|
||||
1. If `npm run typecheck` reports errors, fix root causes — no
|
||||
`// @ts-ignore`, no `any`, no `as unknown as ...`.
|
||||
2. If `npx tsx explore/walker.ts` (selfTest) fails, the change broke
|
||||
an algorithmic invariant. Don't relax the test; fix the change.
|
||||
3. **Cap fix attempts per problem class at 3.** After 3 attempts
|
||||
on the same class without progress, stop and report.
|
||||
4. Mark Phase complete only when every step in that Phase passes
|
||||
cleanly.
|
||||
|
||||
### Termination conditions
|
||||
|
||||
Stop and write a final report when one of:
|
||||
|
||||
1. **Migration is clean.** All `claudeai.ts` methods on AX
|
||||
substrate, all consuming specs pass at the pre-migration
|
||||
baseline. Report final pass tallies + diff stat.
|
||||
2. **Hit the 5-sweep cap.** Report what's done, what's blocked,
|
||||
and what each remaining failure looks like.
|
||||
3. **Hit the 3-attempt cap on a non-trivial issue.** Report
|
||||
attempts, why each failed, what's blocked.
|
||||
4. **AX exposure gap.** A claude.ai surface uses a property the AX
|
||||
tree doesn't expose (e.g., custom `data-state` attributes
|
||||
without a corresponding ARIA reflection). Stop, document the
|
||||
gap, ask the user before adding a hybrid AX+DOM walk.
|
||||
|
||||
### What you should NOT do
|
||||
|
||||
- Don't commit. The user reviews everything.
|
||||
- Don't keep both substrates. The migration is atomic per method:
|
||||
CSS walk out, AX walk in. No fallback chains.
|
||||
- Don't add new abstractions in `claudeai.ts` that aren't required
|
||||
by the migration. The file's shape (one function per UI verb) is
|
||||
load-bearing for callers — don't introduce a `PageObject` base
|
||||
class or a generic AX builder.
|
||||
- Don't run the host Claude Desktop. The user runs it. The H/S
|
||||
specs use `launchClaude` with `seedFromHost` or `null` isolation
|
||||
per spec — confirm with the user before any sweep.
|
||||
- Don't widen `RawElement` speculatively. Only add fields the
|
||||
migration consumes. Each new field bloats every snapshot.
|
||||
- Don't drill into a single-method workaround that other methods
|
||||
would have to duplicate. If a fix wants to live in a helper,
|
||||
put it next to `queryAccessibleTree` in `walker.ts`.
|
||||
|
||||
### Final report format
|
||||
|
||||
```markdown
|
||||
## Migration summary
|
||||
|
||||
- Functions migrated: N / N
|
||||
- Walker.ts changes: <one-line summary>
|
||||
- Inspector.ts changes: <one-line summary or none>
|
||||
- H/S/T specs run: N
|
||||
- H/S/T specs passed: N
|
||||
- New flakes introduced: N (description)
|
||||
|
||||
## Iteration log
|
||||
|
||||
### Spike — openPill
|
||||
- Result: ...
|
||||
- AX shape used: ...
|
||||
- Issues hit: ...
|
||||
|
||||
### Phase B — remaining methods
|
||||
- One block per method ...
|
||||
|
||||
### Phase C — full sweep
|
||||
- Per-spec pass/fail tally
|
||||
- Diff against pre-migration baseline
|
||||
|
||||
## Open issues
|
||||
- ...
|
||||
|
||||
## Files touched
|
||||
git status output
|
||||
|
||||
## Diff for review
|
||||
git diff --stat output
|
||||
```
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Background runs: use `Bash run_in_background: true` for any
|
||||
multi-spec sweep, and `Monitor` with a tight grep filter
|
||||
(`✓|✘|Error|FAIL|EXIT=`) to stream events. Stop the monitor when
|
||||
the run completes.
|
||||
- Check for leftover Electron processes between runs
|
||||
(`pgrep -af '/usr/lib/claude-desktop/node_modules/electron'`)
|
||||
and stale tmpdirs (`ls /tmp/claude-test-*`) — clean both up if
|
||||
the prior run errored before teardown.
|
||||
- The U01 wire-up landed two `walker.ts` fixes that are part of
|
||||
the substrate you're inheriting:
|
||||
1. `findByFingerprint`: strictness gate also defers to
|
||||
`fingerprint.classification === 'instance'` for degenerate
|
||||
fingerprints.
|
||||
2. `redrivePath`: navigates to startUrl when current URL drifted;
|
||||
reloads only when already at startUrl.
|
||||
Both are live in the working tree (or just-merged main,
|
||||
depending on when this prompt fires).
|
||||
|
||||
Begin with Phase A. Read `claudeai.ts` end-to-end first — in
|
||||
particular the file-header discovery comment (lines 1-31) and the
|
||||
`openPill` body (lines 162-202) — so you understand what the
|
||||
existing CSS-shape walks are anchoring on before you replace them.
|
||||
218
docs/testing/claudeai-ui-map.md
Normal file
218
docs/testing/claudeai-ui-map.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# claude.ai UI Map
|
||||
|
||||
*Last updated: 2026-05-02*
|
||||
|
||||
This file is the index from "UI surface" → "test-harness abstraction." It
|
||||
answers: *which renderer surface does each Layer-2 helper cover, and where
|
||||
are the gaps?* For human-readable behavior and visual specs of each surface
|
||||
(what each button looks like, what each menu does), see [`ui/`](./ui/).
|
||||
For the architectural rationale and growth strategy of the wrapper, see
|
||||
[`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md).
|
||||
|
||||
A `✓` marker means the helper exists today, with a `file:line` reference
|
||||
into [`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts).
|
||||
A `TODO` marker is a planned helper — when a third test needs the same
|
||||
shape, promote it from inline `evalInRenderer` to a top-level helper or
|
||||
page-object method (see plan Phase 3).
|
||||
|
||||
## Top-level routes
|
||||
|
||||
- `/new` — chat composer page (default landing for signed-in users)
|
||||
- `/chat/<uuid>` — open chat session
|
||||
- `/epitaxy` — Code tab landing
|
||||
- `/projects/<id>` — project view
|
||||
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
|
||||
|
||||
The Code df-pill click does **not** change the URL — the router rerenders
|
||||
the tab body inline. Helpers must poll for body-mount signals (e.g. a
|
||||
compact pill rendering) rather than waiting on navigation.
|
||||
|
||||
## Surfaces by tab
|
||||
|
||||
### Chat (df-pill "Chat", route /new)
|
||||
|
||||
UI reference: [`ui/prompt-area.md`](./ui/prompt-area.md),
|
||||
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Composer textarea — TODO `ChatTab.composer()`
|
||||
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
|
||||
— TODO `ChatTab.openAttachMenu()`
|
||||
- Slash menu (triggered by typing `/`) — TODO `ChatTab.openSlashMenu()`
|
||||
- Model picker — TODO `ChatTab.openModelPicker()`
|
||||
- Permission mode picker — TODO `ChatTab.openPermissionPicker()`
|
||||
- Effort picker — TODO
|
||||
- Send button — TODO `ChatTab.send()`
|
||||
- Stop button (replaces Send while responding) — TODO `ChatTab.stop()`
|
||||
- Attachment chip / drag-drop overlay — TODO
|
||||
- Usage ring — TODO
|
||||
|
||||
### Cowork (df-pill "Cowork")
|
||||
|
||||
UI reference: see ghost-icon row in
|
||||
[`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md). No
|
||||
dedicated surface doc yet — the ghost icon is the canonical "topbar shim
|
||||
alive" indicator and the tab body itself is largely undocumented at the
|
||||
time of writing.
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Workspace list — TODO `CoworkTab.listWorkspaces()`
|
||||
- Environment switcher — TODO `CoworkTab.switchEnvironment()`
|
||||
- Dispatch state indicator — TODO
|
||||
|
||||
### Code (df-pill "Code", route /epitaxy)
|
||||
|
||||
UI reference: [`ui/code-tab-panes.md`](./ui/code-tab-panes.md),
|
||||
[`ui/sidebar.md`](./ui/sidebar.md),
|
||||
[`ui/prompt-area.md`](./ui/prompt-area.md).
|
||||
|
||||
- df-pill activation — `lib/claudeai.ts:activateTab` (:44) ✓
|
||||
- Tab activation + body-mount wait — `lib/claudeai.ts:CodeTab.activate` (:285) ✓
|
||||
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill` (:317) ✓
|
||||
- Local env selection — `lib/claudeai.ts:CodeTab.selectLocal` (:350) ✓
|
||||
- Select-folder pill (rendered after Local) — used internally by
|
||||
`lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
|
||||
- Folder picker dialog (full chain) — `lib/claudeai.ts:CodeTab.openFolderPicker` (:368) ✓
|
||||
- Folder picker dialog mock + assertion — `lib/claudeai.ts:installOpenDialogMock`
|
||||
(:70) ✓ + `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
|
||||
- File tree (left panel) — TODO `CodeTab.fileTree()`
|
||||
- Editor pane — TODO `CodeTab.editor()`
|
||||
- Diff pane — TODO `CodeTab.openDiff()`
|
||||
- Preview pane — TODO `CodeTab.openPreview()`
|
||||
- Integrated terminal — TODO `CodeTab.openTerminal()`
|
||||
- Tasks / subagent / plan panes — TODO
|
||||
- Side-chat — TODO `CodeTab.openSideChat()`
|
||||
- Recent-folder selection (radio in Select-folder menu) — TODO
|
||||
|
||||
## Surfaces independent of tab
|
||||
|
||||
### Sidebar
|
||||
|
||||
UI reference: [`ui/sidebar.md`](./ui/sidebar.md).
|
||||
|
||||
- Search overlay (topbar Search icon) — TODO `SidebarNav.search()`
|
||||
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
|
||||
- "More options" per row — TODO `SidebarNav.rowContextMenu(uuid)`
|
||||
- "+ New session" button — TODO `SidebarNav.newSession()`
|
||||
- Routines link — TODO `SidebarNav.openRoutines()`
|
||||
- Customize link — TODO `SidebarNav.openCustomize()`
|
||||
- Status / project / environment filters — TODO
|
||||
- Group-by control — TODO
|
||||
- Collapse toggle — TODO
|
||||
|
||||
### Window chrome / topbar (in-app hybrid)
|
||||
|
||||
UI reference: [`ui/window-chrome-and-tabs.md`](./ui/window-chrome-and-tabs.md).
|
||||
|
||||
- Hamburger menu — TODO `Topbar.openHamburger()`
|
||||
- Sidebar toggle — TODO `Topbar.toggleSidebar()`
|
||||
- Back / forward arrows — TODO
|
||||
- Cowork ghost icon (topbar-alive sentinel) — TODO `Topbar.coworkGhostPresent()`
|
||||
|
||||
### Native dialogs
|
||||
|
||||
- File / folder picker mock — `lib/claudeai.ts:installOpenDialogMock` (:70) ✓
|
||||
- File / folder picker call inspection — `lib/claudeai.ts:getOpenDialogCalls` (:113) ✓
|
||||
- Message box / confirm — TODO `installShowMessageBoxMock`
|
||||
- Save dialog — TODO `installShowSaveDialogMock`
|
||||
|
||||
### Menus / popovers
|
||||
|
||||
- Compact-pill discovery — `lib/claudeai.ts:findCompactPills` (:130) ✓
|
||||
- Compact-pill open + menu read — `lib/claudeai.ts:openPill` (:162) ✓
|
||||
- Click any menuitem by text regex — `lib/claudeai.ts:clickMenuItem` (:210) ✓
|
||||
- Dismiss popover via Escape — `lib/claudeai.ts:pressEscape` (:256) ✓
|
||||
- Modal dismiss / confirm — TODO `Modal.dismiss()` / `Modal.confirm()`
|
||||
- Toast / status — TODO `waitForToast(regex)`
|
||||
- Right-click context menus (sidebar row, etc.) — TODO `openContextMenu(target)`
|
||||
|
||||
### Settings
|
||||
|
||||
UI reference: [`ui/settings.md`](./ui/settings.md).
|
||||
|
||||
- Open Settings — TODO `Settings.open()`
|
||||
- Hotkey rebind — TODO `Settings.rebindHotkey(action, chord)`
|
||||
- Theme toggle — TODO `Settings.setTheme('dark' | 'light' | 'auto')`
|
||||
- Account / sign-out — TODO `Settings.signOut()`
|
||||
- Computer-use toggle (absent on Linux per S22) — TODO
|
||||
- Keep-computer-awake toggle (per S20) — TODO
|
||||
|
||||
### Routines page
|
||||
|
||||
UI reference: [`ui/routines-page.md`](./ui/routines-page.md).
|
||||
|
||||
- Routines list — TODO `RoutinesPage.list()`
|
||||
- New-routine form — TODO `RoutinesPage.create(spec)`
|
||||
- Routine detail page — TODO `RoutinesPage.open(id)`
|
||||
|
||||
### Connectors and plugins
|
||||
|
||||
UI reference: [`ui/connectors-and-plugins.md`](./ui/connectors-and-plugins.md).
|
||||
|
||||
- Connector picker — TODO `ConnectorPicker.open()`
|
||||
- Connector list / status — TODO
|
||||
- Plugin browser — TODO `PluginBrowser.open()`
|
||||
- Plugin install (Anthropic & Partners flow) — TODO `PluginBrowser.install(slug)`
|
||||
- Plugin manager (installed list) — TODO
|
||||
|
||||
### Quick Entry popup
|
||||
|
||||
UI reference: [`ui/quick-entry.md`](./ui/quick-entry.md). Note: the
|
||||
Quick Entry harness lives in [`quickentry.ts`](../../tools/test-harness/src/lib/quickentry.ts),
|
||||
not `claudeai.ts`. The `installOpenDialogMock` shape here intentionally
|
||||
mirrors `QuickEntry.installInterceptor` (quickentry.ts:86) — keep them
|
||||
aligned when extending either.
|
||||
|
||||
- Open Quick Entry (global shortcut) — covered by `lib/quickentry.ts`
|
||||
- Compose + send — covered by `lib/quickentry.ts`
|
||||
- Closeout cases (S29–S37) — covered by `lib/quickentry.ts`
|
||||
|
||||
### Notifications
|
||||
|
||||
UI reference: [`ui/notifications.md`](./ui/notifications.md). libnotify
|
||||
rendering is environmental — likely stays a manual checklist rather than
|
||||
a renderer-side helper. No `claudeai.ts` coverage planned.
|
||||
|
||||
### Tray
|
||||
|
||||
UI reference: [`ui/tray.md`](./ui/tray.md). Tray is owned by the main
|
||||
process / native bindings, not the renderer DOM — outside the scope of
|
||||
`claudeai.ts`. Covered by separate tests (T03, S08).
|
||||
|
||||
## Atoms inventory
|
||||
|
||||
Stable structural patterns the lib already anchors on. See the
|
||||
discovery comment at the top of
|
||||
[`tools/test-harness/src/lib/claudeai.ts`](../../tools/test-harness/src/lib/claudeai.ts)
|
||||
for why each is shape-matched rather than class-matched.
|
||||
|
||||
| Atom | Fingerprint | Helper |
|
||||
|---|---|---|
|
||||
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` (:44) |
|
||||
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills` (:130), `openPill` (:162) |
|
||||
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` (:210) |
|
||||
| Escape dismiss | `document.dispatchEvent(KeyboardEvent('keydown', Escape))` | `pressEscape` (:256) |
|
||||
| Electron `dialog.showOpenDialog` | main-process IPC | `installOpenDialogMock` (:70), `getOpenDialogCalls` (:113) |
|
||||
|
||||
Atoms not yet abstracted (when a third test needs the same shape,
|
||||
promote to a top-level helper):
|
||||
|
||||
| Atom | Probable fingerprint | Status |
|
||||
|---|---|---|
|
||||
| modal | `[role=dialog]` | not seen yet |
|
||||
| toast | `[role=status][aria-live]` | not seen yet |
|
||||
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
|
||||
| chat composer | textarea / contenteditable in composer container | not abstracted |
|
||||
| right-click context menu | `[role=menu]` triggered by `contextmenu` event | not abstracted |
|
||||
| Electron `dialog.showMessageBox` | main-process IPC | not abstracted |
|
||||
| Electron `dialog.showSaveDialog` | main-process IPC | not abstracted |
|
||||
| settings panel section | route-anchored container in Settings tab | not abstracted |
|
||||
|
||||
## See also
|
||||
|
||||
- [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) —
|
||||
governing plan and phase rollout
|
||||
- [`automation.md`](./automation.md) — harness architecture and the
|
||||
SIGUSR1 / runtime-attach pattern
|
||||
- [`ui/`](./ui/) — per-surface visual / behavior specs
|
||||
- [`cases/`](./cases/) — functional test specs (T## / S##)
|
||||
415
docs/testing/claudeai-ui-mapping-plan.md
Normal file
415
docs/testing/claudeai-ui-mapping-plan.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# claude.ai UI Mapping Plan
|
||||
|
||||
This is an executable plan for systematically mapping claude.ai's
|
||||
renderer UI into reusable test-harness abstractions. It can be picked
|
||||
up by a fresh session — start at "Phase 1" and walk down.
|
||||
|
||||
## Where we are
|
||||
|
||||
The harness already has one worked example: `tools/test-harness/src/lib/claudeai.ts`
|
||||
exports a `CodeTab` class plus atom helpers (`activateTab`,
|
||||
`installOpenDialogMock`, `findCompactPills`, `openPill`, `clickMenuItem`,
|
||||
`pressEscape`). `T17_folder_picker.spec.ts` is its only consumer
|
||||
today — drives the chain `Code df-pill → env pill → Local → Select
|
||||
folder → Open folder` and asserts `dialog.showOpenDialog` fires.
|
||||
|
||||
Discovery evidence captured by `tools/test-harness/probe.ts` (run
|
||||
against a live debugger on port 9229):
|
||||
|
||||
- df-pill is a stable atom — exactly 3 instances on Code-tab page
|
||||
(`Chat`, `Cowork`, `Code`), all with `class*="df-pill"` and
|
||||
matching `aria-label`.
|
||||
- compact-pill is a stable atom — `button[aria-haspopup=menu]` with
|
||||
a `span.truncate.max-w-[Npx]` child. Env pill uses 200px,
|
||||
Select-folder pill uses 160px. Same Tailwind class signature; we
|
||||
anchor on structure, not classes.
|
||||
- 80 `button[aria-haspopup=menu]` total on a Code-tab page; only the
|
||||
2 with the truncate fingerprint are pills, the other 78 are sidebar
|
||||
"More options" buttons.
|
||||
|
||||
Pattern proven: discovery-by-shape in the lib layer, page-object
|
||||
classes per major UI surface, specs use the lib. This doc covers
|
||||
how to extend that pattern across the rest of claude.ai.
|
||||
|
||||
## Strategy: three layers
|
||||
|
||||
**Layer 1 — atoms.** Generic helpers around stable structural
|
||||
patterns. Live in `lib/claudeai.ts`. Built once, reused everywhere.
|
||||
Examples already there: compact-pill, df-pill, menu, dialog mock.
|
||||
|
||||
**Layer 2 — page objects.** Domain classes per major UI surface
|
||||
(CodeTab, ChatTab, Settings, etc.). Compose atoms. Built per test
|
||||
demand — premature otherwise. CodeTab is the template.
|
||||
|
||||
**Layer 3 — discovery tooling.** Standalone scripts that connect to
|
||||
a running debugger and let humans + agents explore the renderer.
|
||||
`probe.ts` is the seed; this doc grows it into a small CLI.
|
||||
|
||||
The thing to avoid: comprehensively mapping the UI upfront. Even
|
||||
with a recording tool, that burns time on surfaces no test will
|
||||
exercise for months. Lazy + bookmark-the-shape wins.
|
||||
|
||||
## Phase 1 — Tooling foundation
|
||||
|
||||
**Goal:** turn `probe.ts` into a proper exploration CLI under
|
||||
`tools/test-harness/explore/`, with snapshot + diff capability that
|
||||
catches UI drift before tests do.
|
||||
|
||||
**Deliverables:**
|
||||
|
||||
- `tools/test-harness/explore/explore.ts` — entry point with
|
||||
subcommands.
|
||||
- `tools/test-harness/explore/snapshot.ts` — capture renderer state.
|
||||
- `tools/test-harness/explore/diff.ts` — compare two snapshots.
|
||||
- `tools/test-harness/explore/find.ts` — search for elements.
|
||||
- `docs/testing/ui-snapshots/` — directory for captured snapshots
|
||||
(gitignore the file contents but commit the directory + a README).
|
||||
- `tools/test-harness/package.json` — add scripts:
|
||||
`npm run explore`, `npm run explore:snapshot <name>`, etc.
|
||||
|
||||
**Subcommand spec:**
|
||||
|
||||
```
|
||||
npx tsx explore/explore.ts # full snapshot to stdout
|
||||
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
|
||||
npx tsx explore/explore.ts menu # currently-open menu structure
|
||||
npx tsx explore/explore.ts snapshot <name> # write to docs/testing/ui-snapshots/<name>.json
|
||||
npx tsx explore/explore.ts diff <a> <b> # diff two snapshots — flags renamed/removed
|
||||
npx tsx explore/explore.ts find <regex> # search renderer for matching text/aria-label
|
||||
```
|
||||
|
||||
Snapshot shape (per file):
|
||||
|
||||
```json
|
||||
{
|
||||
"capturedAt": "2026-05-02T17:30:00Z",
|
||||
"claudeAiUrl": "https://claude.ai/epitaxy",
|
||||
"appVersion": "1.1.7714",
|
||||
"dfPills": [...],
|
||||
"compactPills": [...],
|
||||
"ariaLabeledButtons": [...],
|
||||
"openMenu": null,
|
||||
"modals": [...]
|
||||
}
|
||||
```
|
||||
|
||||
`diff` should flag: removed elements (selector → no match), changed
|
||||
text/aria-label, new elements (informational, not a failure). Output
|
||||
human-readable + a `--json` flag for machine consumption.
|
||||
|
||||
**How to dispatch this work:**
|
||||
|
||||
Single agent, `general-purpose`. Brief:
|
||||
|
||||
> Build the explore CLI under `tools/test-harness/explore/`. Read
|
||||
> `tools/test-harness/probe.ts` as the seed implementation. Match the
|
||||
> existing project style (tabs, multi-line `//` why-blocks, terse).
|
||||
> Reuse `src/lib/inspector.ts` (`InspectorClient.connect(9229)`) for
|
||||
> the debugger connection. Subcommands as specified in
|
||||
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 1. Do not delete
|
||||
> probe.ts — leave it as a one-off; it can be removed in a follow-up.
|
||||
> Typecheck with `npx tsc --noEmit` (no test runs). Add npm scripts
|
||||
> to `package.json`. Add a thin README in
|
||||
> `docs/testing/ui-snapshots/README.md` explaining how to capture +
|
||||
> compare snapshots.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- `npx tsx explore/explore.ts pills` against a running debugger lists
|
||||
the 3 df-pills and 2 compact-pills (or whatever's on screen).
|
||||
- `explore/explore.ts snapshot baseline-code-tab` writes a JSON file.
|
||||
- `explore/explore.ts diff baseline-code-tab baseline-code-tab`
|
||||
reports zero diffs.
|
||||
- Typecheck green.
|
||||
|
||||
## Phase 2 — UI map document
|
||||
|
||||
**Goal:** maintain a living markdown index of every reachable UI
|
||||
surface, the navigation path to reach it, and which Layer-2 class
|
||||
covers it (or `TODO` if none yet).
|
||||
|
||||
**Deliverable:** `docs/testing/claudeai-ui-map.md`.
|
||||
|
||||
**Initial content** (populate from what's known today, leave gaps
|
||||
marked TODO):
|
||||
|
||||
```markdown
|
||||
# claude.ai UI Map
|
||||
|
||||
Source of truth for "where does each UI surface live, and which
|
||||
test-harness abstraction covers it." Update as new abstractions are
|
||||
added.
|
||||
|
||||
## Top-level routes
|
||||
|
||||
- `/new` — chat composer page (default landing for signed-in users)
|
||||
- `/chat/<uuid>` — open chat session
|
||||
- `/epitaxy` — Code tab landing
|
||||
- `/projects/<id>` — project view
|
||||
- `/login`, `/auth/*` — pre-login routes (test harness skips here)
|
||||
|
||||
## Surfaces by tab
|
||||
|
||||
### Chat (df-pill "Chat", route /new)
|
||||
- Composer textarea — TODO `ChatTab.composer()`
|
||||
- "+" submenu (Add files / Add to project / Skills / Connectors / ...)
|
||||
— TODO `ChatTab.openAttachMenu()`
|
||||
- Model selector — TODO
|
||||
- Stop / regenerate — TODO
|
||||
|
||||
### Cowork (df-pill "Cowork")
|
||||
- Workspace list — TODO
|
||||
- Environment switcher — TODO
|
||||
|
||||
### Code (df-pill "Code", route /epitaxy)
|
||||
- Env pill (Local / Cloud / SSH) — `lib/claudeai.ts:CodeTab.openEnvPill()` ✓
|
||||
- Select folder pill — `lib/claudeai.ts:CodeTab` (used internally by
|
||||
`openFolderPicker`) ✓
|
||||
- Folder picker dialog — `lib/claudeai.ts:installOpenDialogMock` ✓
|
||||
- File tree (left panel) — TODO
|
||||
- Editor pane — TODO
|
||||
|
||||
## Surfaces independent of tab
|
||||
|
||||
### Sidebar
|
||||
- Search — TODO `SidebarNav.search()`
|
||||
- Recent conversations — TODO `SidebarNav.openRecent(idx | uuid)`
|
||||
- "More options" per row — TODO
|
||||
- New session button — TODO
|
||||
|
||||
### Native dialogs
|
||||
- File / folder picker — `lib/claudeai.ts:installOpenDialogMock` ✓
|
||||
- Message box / confirm — TODO `installShowMessageBoxMock`
|
||||
- Save dialog — TODO `installShowSaveDialogMock`
|
||||
|
||||
### Menus / popovers
|
||||
- Generic menu open + click — `lib/claudeai.ts:openPill` /
|
||||
`clickMenuItem` ✓
|
||||
- Modal — TODO `Modal.dismiss() / Modal.confirm()`
|
||||
- Toast / status — TODO `waitForToast(regex)`
|
||||
|
||||
### Settings
|
||||
- Hotkey rebind — TODO
|
||||
- Theme toggle — TODO
|
||||
- Account / sign-out — TODO
|
||||
|
||||
## Atoms inventory
|
||||
|
||||
Stable structural patterns the lib already anchors on:
|
||||
|
||||
| Atom | Fingerprint | Helper |
|
||||
|---|---|---|
|
||||
| df-pill | `button[aria-label][class*="df-pill"]` | `activateTab(name)` |
|
||||
| compact-pill | `button[aria-haspopup=menu] > span.truncate.max-w-[*]` | `findCompactPills`, `openPill` |
|
||||
| menu / menuitem | `[role=menu] [role=menuitem*]` | `clickMenuItem(regex)` |
|
||||
|
||||
Atoms not yet abstracted (when a third test needs the same shape,
|
||||
promote to a top-level helper):
|
||||
|
||||
| Atom | Probable fingerprint | Status |
|
||||
|---|---|---|
|
||||
| modal | `[role=dialog]` | not seen yet |
|
||||
| toast | `[role=status][aria-live]` | not seen yet |
|
||||
| sidebar nav row | `[class*="df-row"] [aria-label]` | seen, not abstracted |
|
||||
| chat composer | textarea/contenteditable in composer container | not abstracted |
|
||||
```
|
||||
|
||||
**How to dispatch this work:**
|
||||
|
||||
A claude-code-guide or general-purpose agent can write the initial
|
||||
file. Single message:
|
||||
|
||||
> Create `docs/testing/claudeai-ui-map.md` matching the structure in
|
||||
> `docs/testing/claudeai-ui-mapping-plan.md` Phase 2. Pull TODO
|
||||
> entries from the planned ChatTab/Settings/etc. surfaces. Mark
|
||||
> existing helpers from `tools/test-harness/src/lib/claudeai.ts`
|
||||
> with ✓ and the file:line. Don't run any tests.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- File exists with all top-level routes documented.
|
||||
- Every existing `lib/claudeai.ts` export is referenced ✓.
|
||||
- Every planned surface from this plan has a TODO entry.
|
||||
|
||||
## Phase 3 — Page objects per test demand
|
||||
|
||||
**Goal:** add new Layer-2 classes (ChatTab, Settings, etc.) when the
|
||||
first test needs them. Don't speculate.
|
||||
|
||||
**Template:** `tools/test-harness/src/lib/claudeai.ts:CodeTab`. Match
|
||||
its shape:
|
||||
|
||||
- Instance class taking `inspector: InspectorClient` in constructor.
|
||||
- Public methods are either single-step (`openEnvPill`,
|
||||
`selectLocal`) or multi-step convenience (`openFolderPicker`).
|
||||
- Discovery by shape, not Tailwind classes.
|
||||
- Multi-line `//` why-block at top of class explaining what UI
|
||||
surface it covers and the discovery strategy.
|
||||
- Failures throw with enough context for the spec to attach to
|
||||
`testInfo.attach()`.
|
||||
|
||||
**Workflow per new page object:**
|
||||
|
||||
1. Identify which test motivates the new class. Don't build
|
||||
speculatively.
|
||||
2. Run `explore.ts snapshot <name>` against a live debugger on the
|
||||
target UI surface. Commit the snapshot under
|
||||
`docs/testing/ui-snapshots/`.
|
||||
3. Inspect the snapshot — pick stable structural fingerprints, not
|
||||
Tailwind classes.
|
||||
4. Write the class in `lib/claudeai.ts`. If the file gets large
|
||||
(>1500 lines), split per-tab into separate files
|
||||
(`lib/claudeai/code-tab.ts`, `lib/claudeai/chat-tab.ts`, with
|
||||
`lib/claudeai.ts` as the barrel).
|
||||
5. Update `docs/testing/claudeai-ui-map.md` — replace the TODO with
|
||||
the class name + ✓.
|
||||
6. Add the spec that uses it.
|
||||
7. Run typecheck. Don't run tests until everything's wired.
|
||||
|
||||
**Don't pull out yet:**
|
||||
|
||||
- Single-consumer methods. If only one spec calls
|
||||
`Settings.toggleDarkMode()`, the inline implementation is fine.
|
||||
Promote to its own method when a second consumer arrives.
|
||||
- Generic primitives that haven't repeated three times. Three is
|
||||
the threshold for "this is an atom" — two could still be
|
||||
coincidence.
|
||||
|
||||
## Phase 4 — Atom promotion
|
||||
|
||||
**Goal:** keep the atom layer (Layer 1) growing in step with the
|
||||
page-object layer (Layer 2).
|
||||
|
||||
**Rule:** when a discovery pattern (CSS selector + JS predicate)
|
||||
appears in 3 different page objects, promote it to a top-level
|
||||
helper in `lib/claudeai.ts`.
|
||||
|
||||
**Examples of likely promotions in the next 6 months:**
|
||||
|
||||
- `findModal()` / `dismissModal()` — every page object that opens a
|
||||
confirmation modal will need this.
|
||||
- `waitForToast(regex, timeout)` — error and success toasts are
|
||||
pervasive.
|
||||
- `installShowMessageBoxMock(inspector, response)` — for native
|
||||
confirm dialogs.
|
||||
- `clickNavRow(label)` — sidebar interactions.
|
||||
|
||||
**Process:**
|
||||
|
||||
1. Notice the third occurrence of the same pattern.
|
||||
2. Move the inline implementation up to a top-level export.
|
||||
3. Replace the three call sites with calls to the new export.
|
||||
4. Add an entry to the atoms inventory in `claudeai-ui-map.md`.
|
||||
|
||||
## Phase 5 — Drift detection
|
||||
|
||||
**Goal:** catch UI changes that break selectors *before* a sweep
|
||||
fails — fast, automatic, runs on every harness invocation.
|
||||
|
||||
**Deliverable:** `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`.
|
||||
|
||||
**Design:**
|
||||
|
||||
- Loads each `*.json` file from `docs/testing/ui-snapshots/`.
|
||||
- Connects to a running app via the existing `launchClaude` +
|
||||
`attachInspector` flow (NOT against an externally-running app —
|
||||
the harness must be self-contained).
|
||||
- For each snapshot, navigates to the captured URL (if not already
|
||||
there), then asserts each captured selector still resolves to an
|
||||
element with the same text/aria-label.
|
||||
- Failures are *attachments*, not full failures — the spec passes
|
||||
if ≥80% of snapshots match, surfaces the diffs as warnings. Hard
|
||||
threshold can be tightened later. Goal is "tell me what drifted,"
|
||||
not "block CI on every minor renderer change."
|
||||
|
||||
**How to dispatch:**
|
||||
|
||||
Single agent, after Phases 1–2 are done. Brief:
|
||||
|
||||
> Create `tools/test-harness/src/runners/H05_ui_drift_check.spec.ts`
|
||||
> per the design in `docs/testing/claudeai-ui-mapping-plan.md`
|
||||
> Phase 5. Read each `*.json` under `docs/testing/ui-snapshots/`,
|
||||
> drive the renderer to the captured URL, assert each captured
|
||||
> element selector still matches. Surface diffs via
|
||||
> `testInfo.attach`. Pass if ≥80% match. Severity Should, surface
|
||||
> "claude.ai UI drift detection". Typecheck only.
|
||||
|
||||
**Exit criteria:**
|
||||
|
||||
- Runs cleanly against current renderer state (all snapshots match).
|
||||
- Returns ≤200ms per snapshot.
|
||||
- Skip with a clear message when no signed-in host config available
|
||||
(most snapshots will be of post-login surfaces).
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **Phase 1 (tooling)** — ~2 hours, single agent. Foundation for
|
||||
everything else.
|
||||
2. **Phase 2 (UI map doc)** — ~30 min, single agent. Cheap,
|
||||
self-documenting.
|
||||
3. **Phase 3 (page objects)** — incremental, per test need.
|
||||
4. **Phase 4 (atom promotion)** — opportunistic, no scheduled work.
|
||||
5. **Phase 5 (drift detection)** — once Phase 1 is done and a few
|
||||
snapshots exist.
|
||||
|
||||
Phases 1 and 2 are independent and can run in parallel.
|
||||
|
||||
## Today's starting state (reference)
|
||||
|
||||
What's already in place as of session-end:
|
||||
|
||||
```
|
||||
tools/test-harness/
|
||||
├── probe.ts # one-off probe (Phase 1 seed)
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── claudeai.ts # CodeTab + atoms (NEW today)
|
||||
│ │ ├── electron.ts # SIGINT cleanup, lastExitInfo
|
||||
│ │ ├── inspector.ts # idempotent close()
|
||||
│ │ ├── quickentry.ts # disk-read getStoredPosition
|
||||
│ │ └── ... (unchanged)
|
||||
│ └── runners/
|
||||
│ ├── H01_cdp_gate_canary.spec.ts # NEW
|
||||
│ ├── H02_frame_fix_wrapper_present.spec.ts # NEW
|
||||
│ ├── H03_patch_fingerprints.spec.ts # NEW
|
||||
│ ├── H04_cowork_daemon_lifecycle.spec.ts # NEW
|
||||
│ ├── T17_folder_picker.spec.ts # refactored to lib/claudeai.ts
|
||||
│ ├── _investigate_t17_urls.spec.ts # one-off, can be deleted
|
||||
│ └── ... (T01/T03/T04, S09/S12, S29-S37)
|
||||
├── orchestrator/sweep.sh # multi-suite JUnit parser
|
||||
└── playwright.config.ts # CI-gated retries + forbidOnly
|
||||
```
|
||||
|
||||
**Pending cleanup** (covered in a final commit, not part of this plan):
|
||||
|
||||
- Delete `_investigate_t17_urls.spec.ts` — investigation served.
|
||||
- Delete `probe.ts` once `explore/` lands and supersedes it.
|
||||
- Update `tools/test-harness/README.md` Status table — T17 from
|
||||
"selector-tuning pending" to passing on KDE-W.
|
||||
|
||||
**Useful commands for a fresh session:**
|
||||
|
||||
```sh
|
||||
cd /home/aaddrick/source/claude-desktop-debian/tools/test-harness
|
||||
|
||||
# Typecheck (must pass after every edit)
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run a single spec
|
||||
ROW=KDE-W CLAUDE_TEST_USE_HOST_CONFIG=1 npx playwright test \
|
||||
src/runners/T17_folder_picker.spec.ts --reporter=list
|
||||
|
||||
# Full sweep
|
||||
ROW=KDE-W CLAUDE_TEST_USE_HOST_CONFIG=1 ./orchestrator/sweep.sh
|
||||
|
||||
# Probe a running app (requires main process debugger enabled)
|
||||
npx tsx probe.ts
|
||||
|
||||
# Kill stale instances before launch
|
||||
pkill -9 -f claude-desktop; pkill -9 -f mount_claude
|
||||
```
|
||||
|
||||
**Before starting Phase 1:** open Claude Desktop, enable
|
||||
`Developer → Enable Main Process Debugger` from the menu, navigate
|
||||
to a known UI state. Then run `npx tsx probe.ts` to confirm the
|
||||
inspector is reachable on port 9229.
|
||||
490
docs/testing/fingerprint-v7-plan.md
Normal file
490
docs/testing/fingerprint-v7-plan.md
Normal file
@@ -0,0 +1,490 @@
|
||||
# Fingerprint v7 Plan — Contextual, Account-Portable Identification
|
||||
|
||||
This is an executable plan for the v6 → v7 migration of the inventory
|
||||
fingerprint shape used by `tools/test-harness/explore/walker.ts` and
|
||||
`tools/test-harness/src/runners/U01_ui_visibility.spec.ts`. It can be
|
||||
picked up by a fresh session — start at "Phase 1" and walk down.
|
||||
|
||||
## Where we are
|
||||
|
||||
`docs/testing/ui-inventory.json` v6 (captured 2026-05-03 against app
|
||||
1.5354.0, 383 entries) records each interactive element with a
|
||||
fingerprint of this shape:
|
||||
|
||||
```ts
|
||||
fingerprint: {
|
||||
selector: 'button[aria-label="Search"]',
|
||||
ariaLabel: 'Search',
|
||||
role: null,
|
||||
tagName: 'BUTTON',
|
||||
textContent: null,
|
||||
}
|
||||
```
|
||||
|
||||
`U01` resolves entries by handing the `selector` field to Playwright.
|
||||
The current scheme has three load-bearing failure modes:
|
||||
|
||||
1. **Account-specific names baked into selectors and IDs.** Entries
|
||||
like `root.button.awaaddrick-max` (the user's plan badge,
|
||||
`button:has-text("AWAaddrick·Max")`) hardcode the walker-author's
|
||||
username + plan tier. Any contributor running U01 against their
|
||||
own auth fails this entry on selector match — the element is
|
||||
structurally present, just labeled differently.
|
||||
2. **Instance text in selectors of "stable" entries.** Search-result
|
||||
options, recent-conversations buttons, and pinned conversations
|
||||
carry titles like "Fine-tuning diffusion models with reinforcement
|
||||
learning" in their selectors. These are inherently per-account; the
|
||||
`kind: instance` taxonomy already exists to handle them, but the
|
||||
selector still encodes the literal title, so the v6 capture
|
||||
couldn't actually leverage `instance` semantics.
|
||||
3. **Selector brittleness under cosmetic redesigns.** `button:has-text(...)`
|
||||
selectors break under any label change. `button[aria-label="..."]`
|
||||
selectors break under any aria-label rewrite (which the upstream
|
||||
team does for accessibility audits without warning). Neither
|
||||
strategy carries enough redundancy to recover when one signal drifts.
|
||||
|
||||
The reconciliation doc (`ui-inventory-reconciliation.md`) flags these
|
||||
as "Walker coverage gap" and "Account-state-dependent" categories,
|
||||
and the U01 brief lists per-user inventory regeneration as "a
|
||||
separate workstream." This is that workstream.
|
||||
|
||||
## Design goals
|
||||
|
||||
In priority order:
|
||||
|
||||
1. **Account-portable.** A v7 inventory walked against User A's
|
||||
account matches against User B's renderer for any entry whose
|
||||
target element is structurally present in both accounts. Entries
|
||||
that genuinely don't exist in B's account fall back to the existing
|
||||
"skip if absent" semantics (`kind: instance` + ancestor-presence
|
||||
check).
|
||||
2. **Resilient to cosmetic drift.** Label changes, aria-label
|
||||
rewrites, minified-class churn, and CSS rewrites must not
|
||||
invalidate the fingerprint when the element's semantic role and
|
||||
structural position survive.
|
||||
3. **Surface drift before failure.** Soft drift (primary aria-path
|
||||
missed, relaxed-scope match recovered) attaches a warning to the
|
||||
test rather than passing silently. Hard drift (no strategy
|
||||
resolves) fails as today. The sweep gains a third state:
|
||||
`passed-with-drift`.
|
||||
4. **Atomic cutover, not gradual migration.** v7 walker, v7 inventory
|
||||
schema, and v7 resolver land together. The committed v6 inventory
|
||||
gets invalidated the moment v7 walker ships; no parallel-emit
|
||||
compatibility window, no `legacy` selector fallback in the
|
||||
resolver. Two systems are worse than one.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- Pixel-level visual diff. Separate concern; H05 is the right shape.
|
||||
- AI / embedding-based matching. Out of scope for a Linux repackager.
|
||||
- Behavioral fingerprints (click-and-verify-effect). Too expensive at
|
||||
383 entries.
|
||||
|
||||
## v7 schema
|
||||
|
||||
```ts
|
||||
interface FingerprintV7 {
|
||||
// Primary: accessibility-tree path from nearest landmark down to
|
||||
// the leaf. Each step carries (role, optional name).
|
||||
ariaPath: AriaStep[];
|
||||
|
||||
// The element itself. Drops `name` entirely when role + ariaPath
|
||||
// suffice for uniqueness on the captured surface.
|
||||
leaf: {
|
||||
role: string; // "button", "link", "menuitem", ...
|
||||
name: NameMatcher | null;
|
||||
siblingIndex: SiblingIndex | null;
|
||||
};
|
||||
|
||||
// Stability classification — drives how strictly the resolver
|
||||
// matches. See "Kind-strictness matrix" below. Distinct from the
|
||||
// existing `kind` field (persistent / structural / menu / instance)
|
||||
// which captures *lifecycle*, not *match strictness*.
|
||||
classification: 'stable' | 'positional' | 'instance';
|
||||
}
|
||||
|
||||
interface AriaStep {
|
||||
role: string; // landmark / region / grouping role
|
||||
name: NameMatcher | null; // optional — only included when needed
|
||||
}
|
||||
|
||||
type NameMatcher =
|
||||
| { kind: 'literal'; value: string } // "Search", "Cowork"
|
||||
| { kind: 'pattern'; regex: string }; // "\\w+·(Free|Pro|Max|...)"
|
||||
|
||||
interface SiblingIndex {
|
||||
role: string; // role of siblings being indexed
|
||||
position: number; // 0-based
|
||||
total: number; // total siblings of that role at capture
|
||||
}
|
||||
```
|
||||
|
||||
## Capture algorithm
|
||||
|
||||
Run during walker.ts's element emission, after the surface has settled.
|
||||
|
||||
```text
|
||||
captureFingerprint(element, surface):
|
||||
ariaPath = walkLandmarkAncestors(element)
|
||||
// Stop at <body>; emit a step for each role in
|
||||
// {banner, main, navigation, region, complementary,
|
||||
// contentinfo, search, form, toolbar, menu, menubar,
|
||||
// listbox, list, dialog, tablist, tabpanel, group}
|
||||
// with grouping role plus optional accessible name.
|
||||
|
||||
role = element.role
|
||||
name = element.accessibleName
|
||||
|
||||
// Step 1: try uniqueness without the name.
|
||||
matches = surface.queryAccessibleTree({
|
||||
ariaPath,
|
||||
leaf: { role }
|
||||
})
|
||||
if matches.length == 1:
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
|
||||
classification: 'stable' }
|
||||
|
||||
// Step 2: still too broad — try the name as a discriminator,
|
||||
// shaping it if it looks instance-specific.
|
||||
classification = classifyName(name, surface)
|
||||
if classification != 'instance':
|
||||
nameMatcher = (classification == 'positional')
|
||||
? null
|
||||
: (looksInstanceShaped(name)
|
||||
? { kind: 'pattern', regex: shapeOfName(name) }
|
||||
: { kind: 'literal', value: name })
|
||||
matches = surface.queryAccessibleTree({
|
||||
ariaPath, leaf: { role, name: nameMatcher }
|
||||
})
|
||||
if matches.length == 1:
|
||||
return { ariaPath, leaf: { role, name: nameMatcher,
|
||||
siblingIndex: null },
|
||||
classification }
|
||||
|
||||
// Step 3: still ambiguous — fall through to sibling position.
|
||||
siblings = element.parent.childrenWithRole(role)
|
||||
if siblings.length > 1:
|
||||
siblingIndex = {
|
||||
role,
|
||||
position: siblings.indexOf(element),
|
||||
total: siblings.length
|
||||
}
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex },
|
||||
classification: 'positional' }
|
||||
|
||||
// Step 4: instance — assert ≥1 match within ariaPath.
|
||||
return { ariaPath, leaf: { role, name: null, siblingIndex: null },
|
||||
classification: 'instance' }
|
||||
```
|
||||
|
||||
`queryAccessibleTree` should hit `Accessibility.getFullAXTree` over
|
||||
CDP, not the DOM. The accessibility tree is what screen readers see
|
||||
and what the platform APIs query — it's the substrate that aria
|
||||
roles and accessible names actually live in.
|
||||
|
||||
## Name classifier
|
||||
|
||||
`classifyName(name, surface)` decides whether a name is `stable`,
|
||||
`instance`, or `positional` (no usable name). Heuristics in priority
|
||||
order:
|
||||
|
||||
```text
|
||||
1. Empty / whitespace name → 'positional'
|
||||
2. Element is a list-row child → 'instance' (handled by ancestor
|
||||
role: option/listitem inside listbox/list)
|
||||
3. Name matches a known
|
||||
instance-shape regex → 'instance' (record as pattern)
|
||||
4. Name is in the corpus of
|
||||
"stable UI vocabulary" → 'stable'
|
||||
5. Default → 'stable' but flag for review
|
||||
```
|
||||
|
||||
### Known instance-shape regexes
|
||||
|
||||
| Regex | Example match | Shape recorded |
|
||||
|---|---|---|
|
||||
| `/^.+·(Free\|Pro\|Max\|Team\|Enterprise)$/` | `AWAaddrick·Max` | `\\w+·<PLAN>` |
|
||||
| `/^Opus \d/` `/^Sonnet \d/` `/^Haiku \d/` | `Opus 4.7Adaptive` | model-name passthrough (stable across users, just versioned) |
|
||||
| `/\d{1,3}%$/` | `Usage: plan 11%` | `Usage: plan \d+%` |
|
||||
| `/Today\|Yesterday\|\d+ (day\|hour\|minute)s? ago/` | `Today+12` | `<RELATIVE-DATE>(\\+\d+)?` |
|
||||
| `/^\d+\.\d+ \w+/` | `1.5 GB` | `\d+\.\d+ \w+` |
|
||||
| `/@\w+/` | `@aaddrick` | `@\w+` (treat as user-handle) |
|
||||
| `/[A-Z][a-z]+ [A-Z][a-z]+ [a-z]/` (3+ word title-case) | `Fine-tuning diffusion models...` | treat as `'instance'`, no pattern |
|
||||
|
||||
These regexes live in a registry that's part of the v7 capture
|
||||
config. Adding a new shape is a one-file change; the registry should
|
||||
be ordered (first match wins) so specific patterns take precedence
|
||||
over general ones.
|
||||
|
||||
### Building the stable UI vocabulary
|
||||
|
||||
After the walker finishes the BFS, run a second pass:
|
||||
|
||||
1. Collect every `accessibleName` from every captured element.
|
||||
2. Bucket by `kind` (existing taxonomy).
|
||||
3. Names appearing in 3+ entries with `kind: persistent` or
|
||||
`kind: structural`, across 2+ surfaces, are **stable**.
|
||||
4. Names appearing in only 1 entry with `kind: persistent`/`structural`
|
||||
are **suspect** — flag for human triage during reconciliation.
|
||||
5. Names in `kind: instance` entries are excluded from the corpus
|
||||
entirely.
|
||||
|
||||
Commit the resulting vocabulary list to
|
||||
`docs/testing/ui-vocabulary.json` so future walks can use it without
|
||||
re-deriving. Refresh the vocabulary on each major upstream release.
|
||||
|
||||
## Kind-strictness matrix
|
||||
|
||||
The existing `kind` field (`persistent` / `structural` / `menu` /
|
||||
`instance`) tunes how strictly the resolver matches at runtime,
|
||||
independently from the capture-time `classification`:
|
||||
|
||||
| kind | aria-path required | name required | siblingIndex strict | assertion |
|
||||
|---|---|---|---|---|
|
||||
| `persistent` | yes (deepest scope) | matcher must hit if present | yes | exactly 1 match |
|
||||
| `structural` | yes (or 1 step shallower) | matcher OR position | flexible (±1 ok) | exactly 1 match |
|
||||
| `menu` | yes, scoped to transient menu surface | literal text fallback ok | n/a | ≥1 match |
|
||||
| `instance` | yes (closest list/listbox ancestor) | ignored | ignored | ≥1 match within scope |
|
||||
|
||||
Examples:
|
||||
|
||||
- `root.button.search` → `kind: persistent`, `classification: stable`,
|
||||
`name: null` (unique by ariaPath alone). Strict 1-match assertion.
|
||||
- `root.button.awaaddrick-max` → `kind: persistent`, `classification: stable`,
|
||||
`name: { kind: 'pattern', regex: '\\w+·(Free|Pro|Max|...)' }`.
|
||||
Plan-shape pattern; user-portable.
|
||||
- `root.button.search.option.untitled-conversationtoday+12` →
|
||||
`kind: instance`, `classification: instance`, no name, scoped to
|
||||
search-results listbox. Assert ≥1 option in listbox.
|
||||
- `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` →
|
||||
`kind: instance`, scoped to pinned-conversations list. Assert ≥1
|
||||
button in pinned list.
|
||||
|
||||
## Resolver / fallback chain
|
||||
|
||||
In `findByFingerprint`:
|
||||
|
||||
```text
|
||||
resolve(fp):
|
||||
// Strategy 1 — primary: full aria-tree path
|
||||
result = tryAriaTreeMatch(fp.ariaPath, fp.leaf, fp.kind)
|
||||
if result.matched: return { found: true, strategy: 'aria-tree' }
|
||||
|
||||
// Strategy 2 — relaxed aria scope (drop deepest landmark step
|
||||
// in the path; keep the rest). Catches the common case where the
|
||||
// upstream team adds or removes one container layer.
|
||||
if fp.ariaPath.length > 1:
|
||||
result = tryAriaTreeMatch(fp.ariaPath.slice(0, -1), fp.leaf, fp.kind)
|
||||
if result.matched: return {
|
||||
found: true, strategy: 'aria-tree-relaxed', drift: 'scope-shifted'
|
||||
}
|
||||
|
||||
return { found: false, strategy: null }
|
||||
```
|
||||
|
||||
When `drift` is set, attach a soft warning to the Playwright test
|
||||
without failing it:
|
||||
|
||||
```ts
|
||||
testInfo.attach('drift-warning', {
|
||||
body: JSON.stringify({
|
||||
entryId: entry.id,
|
||||
expected: fp.ariaPath,
|
||||
matchedVia: result.strategy,
|
||||
drift: result.drift,
|
||||
note: 'primary aria-tree match failed; recovered via fallback. ' +
|
||||
'Re-walk inventory before drift compounds.',
|
||||
}, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
```
|
||||
|
||||
CI exposes `drift-warning` as a separate counter alongside pass /
|
||||
fail. Sweep summary becomes `383 passed, 12 with drift, 0 failed`.
|
||||
|
||||
## Migration plan
|
||||
|
||||
The cutover is atomic — no parallel-emit window. Walker, schema, and
|
||||
resolver all flip from v6 to v7 in the same merge. The committed v6
|
||||
inventory becomes invalid; first action after merge is a re-walk.
|
||||
|
||||
### Phase 1 — vocabulary scaffold (pre-walker)
|
||||
|
||||
The name classifier needs a stable-UI vocabulary corpus to
|
||||
disambiguate suspect names from known-stable copy. Build it from the
|
||||
existing v6 inventory before the walker rewrite:
|
||||
|
||||
1. Iterate `docs/testing/ui-inventory.json` v6.
|
||||
2. Names appearing in 3+ entries with `kind: persistent` or
|
||||
`kind: structural`, across 2+ surfaces, are **stable**.
|
||||
3. Names matching any registry regex (plan badge, model version,
|
||||
percentage, relative date, user handle) are **instance-shaped**.
|
||||
4. Names appearing in only 1 entry, not matching a regex, not in
|
||||
`kind: instance` — flag for human triage.
|
||||
5. Commit the resulting corpus to `docs/testing/ui-vocabulary.json`.
|
||||
|
||||
The corpus survives the walker rewrite — it's keyed on names, not on
|
||||
v6 schema specifics.
|
||||
|
||||
### Phase 2 — walker rewrite
|
||||
|
||||
1. Add `Accessibility.getFullAXTree` query to walker's surface-settle
|
||||
step (or AX subtree at target node if full-tree latency is
|
||||
unacceptable; see open questions).
|
||||
2. Implement `walkLandmarkAncestors`, `queryAccessibleTree`,
|
||||
`captureFingerprint` per the algorithm above.
|
||||
3. Implement the name classifier consuming `ui-vocabulary.json` and
|
||||
the instance-shape registry.
|
||||
4. Replace v6 fingerprint emit with v7. Inventory schema header bumps
|
||||
to `walkerVersion: 7`; v6 readers will fail loudly rather than
|
||||
silently mis-resolve.
|
||||
5. Walker passes that fail to compute a v7 fingerprint (AX query
|
||||
error, accessible-name-computation failure) emit the entry with
|
||||
`classification: 'positional'` and `name: null`, scoped to its
|
||||
ariaPath. Uncaptured fingerprints are not silently dropped — they
|
||||
become positional entries with explicit looseness.
|
||||
|
||||
Acceptance: a walk against the v6-author's account produces v7
|
||||
fingerprints for ≥98% of the surfaces v6 captured. ≥80% have
|
||||
`classification: 'stable'`; the rest split between `'positional'` and
|
||||
`'instance'`.
|
||||
|
||||
#### Live-walk shakedown (post-Phase 2)
|
||||
|
||||
The first end-to-end walks against the running renderer surfaced five
|
||||
real bugs the synthetic selfTest couldn't see. All landed in
|
||||
`walker.ts` / `name-classifier.ts` / `inspector.ts`:
|
||||
|
||||
1. **AX-tree settle gate.** `Accessibility.enable` populates the tree
|
||||
asynchronously; the existing `waitForStable` (1.5s ceiling on
|
||||
DOM-mutation quiescence) returned long before claude.ai's React
|
||||
tree mounted. Seed snapshots came back with 4 AX nodes (just the
|
||||
`RootWebArea` + a generic shell) and the walker emitted zero
|
||||
entries. Fix: `waitForAxTreeStable(inspector, { minNodes: 20 })`
|
||||
polls `getFullAXTree` until two consecutive reads return the same
|
||||
node count. Called once before the seed snapshot and once after
|
||||
each `navigateTo` in `redrivePath`. Baked into every
|
||||
`snapshotSurface` call too (with `minNodes: 1`) so post-click
|
||||
reads don't race the React update.
|
||||
2. **`reloadPage` in `redrivePath`.** `navigateTo(url)` short-circuits
|
||||
when `currentUrl === url`, but every BFS pop re-navigates to
|
||||
`startUrl`, so any state a prior drill left behind (open dialog,
|
||||
expanded sidebar, scrolled focus) carried into the next redrive
|
||||
and contaminated `clickById`'s snapshot. Replaced the redrive's
|
||||
initial `navigateTo` with `location.reload()` to discard the
|
||||
React tree.
|
||||
3. **List-row sibling-count heuristic.** The plan's `isListRowChild`
|
||||
check requires `option/listitem` inside `listbox/list`. claude.ai
|
||||
exposes the marketplace dialog as `dialog > button[]` with no
|
||||
list role at all (~80 cards) and the cowork sidebar as
|
||||
`complementary > button[]` (72 sessions). Without a heuristic,
|
||||
each row literal-matches by name and emits as a separate stable
|
||||
entry. Extension: `LIST_ROW_ROLES` includes `button`,
|
||||
`LIST_ANCESTOR_ROLES` includes `group`, AND `siblingTotal >= 15`
|
||||
on its own qualifies regardless of ancestor role. Step 3
|
||||
(positional fallback) also gates on `!isListRowChild` so list
|
||||
rows fall through to step 4's `instance` collapse instead of
|
||||
fragmenting into per-index positionals.
|
||||
4. **Two new instance shapes** in `name-classifier.ts`:
|
||||
`cowork-session` matches status-prefixed session titles
|
||||
(`^(Idle|Ready|Working|Awaiting input|Pull request merged|Done|Failed|Cancelled)\s`)
|
||||
and `row-more-options` matches per-row triggers
|
||||
(`^More options for `). Both ordered before `long-title` so the
|
||||
pattern wins over the no-pattern instance fallback.
|
||||
5. **Lookup-failure threshold bump** 25 → 75. Sidebar virtualization
|
||||
means the AX tree exposes a slightly different subset of cowork
|
||||
sessions on each fresh load; redrives accumulate
|
||||
"no element matches" misses in a row that aren't a real wedge.
|
||||
The timeout counter (5 strikes) still gates against actual
|
||||
renderer hangs.
|
||||
|
||||
Result on the AX migration's first clean walk
|
||||
(`startUrl: claude.ai/epitaxy`, account: aaddrick, app 1.5354.0):
|
||||
**90 entries** (37 persistent / 37 structural / 8 dialog / 8
|
||||
instance), 6 denylisted, 23 non-fatal lookup misses. The marketplace
|
||||
dialog folded to a single `button-instance+704`; the cowork sidebar
|
||||
to `button-instance+72`; search history to `option-instance+25`.
|
||||
Acceptance criteria from §Phase 2 met (≥98% structural overlap is
|
||||
trivially true on a re-walk; ≥80% stable hit at 75/90 ≈ 83%).
|
||||
|
||||
### Phase 3 — resolver rewrite (U01 + walker.ts findByFingerprint)
|
||||
|
||||
1. Replace `findByFingerprint` body with the two-strategy chain
|
||||
(primary aria-tree, relaxed-scope fallback). Drop the v6
|
||||
selector code path entirely.
|
||||
2. `gen-render-specs.ts` regenerates U01 from the v7 inventory; per-
|
||||
entry test bodies consume `entry.fingerprint` (now v7-shaped)
|
||||
directly.
|
||||
3. Add the `drift-warning` attachment shape to U01's test runner.
|
||||
4. Run U01 against the v7 inventory captured in Phase 2; baseline
|
||||
drift counts.
|
||||
|
||||
Acceptance: U01 against a fresh walker pass produces 0 drift
|
||||
warnings on the same account, fails 0 entries. Drift warnings only
|
||||
appear when actually-drifted elements are encountered.
|
||||
|
||||
### Phase 4 — account-portability validation
|
||||
|
||||
1. A second contributor walks their own v7 inventory.
|
||||
2. Diff against the v6-author's v7 inventory: structural overlap
|
||||
should be ≥80% on `kind: persistent` and `kind: structural`
|
||||
entries (the cross-user-stable subset).
|
||||
3. Run the v6-author's inventory's U01 against the second
|
||||
contributor's renderer (with `seedFromHost` lifting their auth).
|
||||
4. Expect ≥80% pass on the cross-user-stable subset; `kind: instance`
|
||||
entries pass via the ancestor-presence check.
|
||||
|
||||
This is the actual goal. If account-portability hits, the inventory
|
||||
is no longer a "my-account snapshot" but a true render contract.
|
||||
|
||||
## Open questions
|
||||
|
||||
### Resolved
|
||||
|
||||
- **CDP `Accessibility.getFullAXTree` cost.** Not a bottleneck. The
|
||||
signed-in `claude.ai/epitaxy` surface returns a 817-node tree;
|
||||
`waitForAxTreeStable` settles in <1s once Chromium has populated
|
||||
it. The cold-load gate dominates total latency, not per-call
|
||||
overhead. Plan B (subtree queries at the target node) is unused.
|
||||
- **Role overrides.** Confirmed working. `Skip to content` on
|
||||
claude.ai is captured as `link` (its AX-computed role) regardless
|
||||
of the underlying tag — a class of mismatch the v6 DOM walker
|
||||
silently got wrong.
|
||||
- **`account-bound` kind.** Not needed. The combination of
|
||||
shape-patterned name matchers (plan badge, cowork session) +
|
||||
the sibling-count list heuristic + persistent collapse handles
|
||||
every account-shaped element observed in the first clean walk.
|
||||
Re-evaluate if a future surface exposes account state without
|
||||
one of those signals.
|
||||
|
||||
### Open
|
||||
|
||||
- **Accessible-name computation parity.** Chrome's AX-tree-computed
|
||||
name should match what Playwright's `getByRole({ name })` matches
|
||||
at resolution time, but they're independent implementations of
|
||||
the ARIA name-computation spec. Validate at Phase 3 acceptance
|
||||
with a sample of 50 entries — capture vs resolve should agree.
|
||||
- **Stale vocabulary across releases.** When upstream renames
|
||||
"Cowork" to "Workspaces" (hypothetical), the corpus needs to
|
||||
update. Should vocabulary be re-derived automatically on each walk
|
||||
(cheap, drift-following) or pinned to a committed version (stable,
|
||||
manual updates)? Provisionally: re-derive on walk, commit the
|
||||
derived corpus alongside the inventory so reconciliation can diff
|
||||
vocabulary changes.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tools/test-harness/explore/walker.ts` — capture site
|
||||
- `tools/test-harness/explore/walk-isolated.ts` — driver that runs
|
||||
the walk inside the test-harness `launchClaude` + `seedFromHost`
|
||||
isolation path (use this rather than `explore walk` to avoid
|
||||
mutating the host profile)
|
||||
- `tools/test-harness/explore/gen-render-specs.ts` — emits U01 from
|
||||
inventory; needs to consume v7 fingerprints
|
||||
- `tools/test-harness/src/runners/U01_ui_visibility.spec.ts` —
|
||||
resolver consumer
|
||||
- `tools/test-harness/src/lib/inspector.ts` — `getAccessibleTree`
|
||||
+ `clickByBackendNodeId` for the AX-driven capture/click pair
|
||||
- `docs/testing/ui-inventory-reconciliation.md` — current v6 reconciliation
|
||||
- `docs/testing/claudeai-ui-mapping-plan.md` — broader UI mapping
|
||||
strategy this fits inside
|
||||
@@ -50,6 +50,14 @@ Status legend: `✓` pass · `✗` fail · `🔧` mitigated · `?` untested · `
|
||||
| [T38](./cases/code-tab-handoff.md#t38--continue-in-ide) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
| [T39](./cases/code-tab-handoff.md#t39--desktop-cli-handoff-graceful-na) | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## UI visibility (U-series)
|
||||
|
||||
Auto-generated render attestation: each entry in [`ui-inventory.json`](./ui-inventory.json) is asserted to mount with its recorded fingerprint on each platform. The single matrix cell aggregates every inventory entry — pass means every entry rendered, fail means at least one didn't (per-entry diagnostics in the JUnit attachments). Regenerate the spec with `npm run gen:render-specs` after re-walking. See [`claudeai-ui-mapping-plan.md`](./claudeai-ui-mapping-plan.md) for the discovery + walker design.
|
||||
|
||||
| Test | KDE-W | KDE-X | GNOME | Ubu | Sway | i3 | Niri | Hypr-O | Hypr-N |
|
||||
|------|-------|-------|-------|-----|------|----|------|--------|--------|
|
||||
| [U01](../tools/test-harness/src/runners/U01_ui_visibility.spec.ts) — UI visibility | ? | ? | ? | ? | ? | ? | ? | ? | ? |
|
||||
|
||||
## Environment-specific status
|
||||
|
||||
### Ubuntu / DEB
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# Quick Entry — Upstream Contract + Test Index
|
||||
# Quick Entry Closeout — Test Plan
|
||||
|
||||
Reference doc for the Quick Entry surface. Two halves:
|
||||
Focused sweep plan for closing the three open Quick Entry issues:
|
||||
|
||||
- [§ Upstream design intent](#upstream-design-intent) documents what upstream Quick Entry promises vs. doesn't, with code anchors into `build-reference/app-extracted/.vite/build/index.js`. Treat as the authoritative answer when triaging whether a Quick Entry behavior is a Linux compat regression (our problem) or upstream-by-design (not our problem).
|
||||
- [§ Test list](#test-list) enumerates the QE-N items as conceptual checks and maps each to the concrete S-N / T-N case that backs it. Spec headnotes (S09, S12, S31, S37) cite specific QE-N IDs by anchor; [§ Scaffold integration](#scaffold-integration) is the authoritative QE-N → S-N table.
|
||||
- [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393) — Submit doesn't open the main window (Ubuntu 24.04 GNOME and friends). Mitigated by [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)'s KDE-only gate; root cause is `BrowserWindow.isFocused()` returning stale-true on Linux Electron.
|
||||
- [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — Shortcut doesn't fire from unfocused state on Fedora 43 GNOME. mutter no longer honours XWayland-side key grabs. Fix path: wire `--enable-features=GlobalShortcutsPortal` into the launcher on GNOME Wayland.
|
||||
- [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370) — Opaque square frame behind the transparent Quick Entry popup on KDE Wayland. Bisected to Electron 41.0.4 (electron/electron#50213); upstream regression. Workarounds in `frame-fix-wrapper.js` not yet attempted.
|
||||
|
||||
The QE-N items originated in the close-out sweep for [#393](https://github.com/aaddrick/claude-desktop-debian/issues/393), [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), and [#370](https://github.com/aaddrick/claude-desktop-debian/issues/370). The sweep has run; what remains is the upstream-contract reference + the test-index mapping.
|
||||
This doc is a **sweep plan**, not a test catalog. Test bodies and diagnostics live in [`cases/`](./cases/); the live status dashboard lives in [`matrix.md`](./matrix.md). The 21 `QE-*` items below map to existing `T*` / `S*` IDs where possible, and call out gaps to add as new `S*` cases.
|
||||
|
||||
## Goal
|
||||
|
||||
Pass all `QE-*` items in [§ Test list](#test-list) on every row in [§ Mandatory matrix](#mandatory-matrix). When that holds, all three issues are closeable (or, for #370, demonstrably blocked on upstream Electron with reproducible evidence).
|
||||
|
||||
## Upstream design intent
|
||||
|
||||
@@ -72,9 +77,9 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-14 | Should | Inspect popup background | Transparent; no opaque square frame visible behind the rounded UI. **Note:** upstream already sets `transparent: true` and `backgroundColor: "#00000000"` (`:515380, :515383`), so the #370 triage-bot suggestion to "try setting backgroundColor to transparent" is moot — those are already in place. The Electron 41.0.4 regression is at the CSD/shadow rendering layer below those flags, not at the option-passing layer. | [S10](./cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | — |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | — |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | — |
|
||||
| QE-15 | Smoke | Inspect popup chrome | No titlebar, no close/min/max buttons (frameless) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-16 | Smoke | Inspect popup edges | Drop shadow + rounded corners render (compositor-dependent — note where missing) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-17 | Smoke | Open popup, then click on another window | Popup stays above (always-on-top) | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
| QE-18 | Should | `electron --version` against the running app's bundled binary; record version in matrix | When > 41.0.4 ships and #370 still reproduces, the upstream-regression hypothesis is wrong | [S33](./cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version) |
|
||||
|
||||
### Patch-application sanity — regression prevention
|
||||
@@ -87,7 +92,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
|
||||
| ID | Severity | Step | Expected | Existing |
|
||||
|----|----------|------|----------|----------|
|
||||
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | — |
|
||||
| QE-21 | Smoke | In popup: `Esc` dismisses; click-outside dismisses; `Shift+Enter` inserts newline; `Enter` submits | All four behave as labelled. **Implementation notes for diagnostics:** click-outside is wired in the **main process** via the popup's `blur` handler (`:515465`). `Esc` / `Enter` / `Shift+Enter` are **renderer-side** (not visible in `index.js`); they go through IPC to `requestDismiss()` (`:515409`) and `requestDismissWithPayload()`. If a dismiss key fails, isolate which side is broken before reporting. | [`ui/quick-entry.md`](./ui/quick-entry.md) |
|
||||
|
||||
### Popup placement & lifecycle — upstream contract sanity
|
||||
|
||||
@@ -99,9 +104,91 @@ These verify upstream-promised behaviors that aren't directly broken by #393/#40
|
||||
| QE-23 | Smoke | **Multi-monitor required.** With an external monitor connected, invoke Quick Entry on the external monitor — let the position be saved (trigger QE-22's persistence path). Disconnect the external monitor (libvirt: `virsh detach-device` for the second display, or unplug the host monitor passing through). Invoke Quick Entry. | Popup falls back to the primary display via `cHn()` (`:515502`). Does **not** appear at off-screen coordinates. Skip this row in single-monitor VMs. | [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) |
|
||||
| QE-24 | Should | Launch app, focus main window, then **destroy** the main window without quitting the app. On this project the X button hide-to-tray override means the standard close path won't destroy `ut`; force the destroy via a) DevTools console (`Cmd+Opt+I` / `Ctrl+Shift+I` → `require('electron').remote.getCurrentWindow().destroy()` if exposed), or b) accept that this case is unreachable on Linux without a code change and skip. After destroy, invoke Quick Entry, type, submit. | Popup remains functional (lazy-recreation on shortcut press; the `!ut \|\| ut.isDestroyed()` guard at `:515595` skips the show/focus block but does not crash). New chat creation may not have a window to surface in — if app remains running with no main window, this is the "popup outlives main" path upstream guarantees. **If unreachable on Linux, mark this row N/A and document why.** | [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) |
|
||||
|
||||
## Mandatory matrix
|
||||
|
||||
The five rows below are the must-pass set to close all three issues. Display server is the **session selected at login** — KDE and GNOME both let you choose Wayland vs Xorg from the greeter.
|
||||
|
||||
| Row | Distro | DE | Display server | Closes / verifies | Reporter |
|
||||
|-----|--------|----|--------------:|-------------------|----------|
|
||||
| **GNOME-W** | Fedora 43 Workstation | GNOME 49.x | Wayland | #404 (S11/S12), #393 (QE-11/QE-12) | @gianluca-peri (#404), @Andrej730 (#393 root cause) |
|
||||
| **Ubu-W** | Ubuntu 24.04 LTS | GNOME (Ubuntu) | Wayland | #393 close-out (post-#406 gate). Also catches the `XDG_CURRENT_DESKTOP=ubuntu:GNOME` quirk (S02) | @Andrej730 |
|
||||
| **KDE-W** | Fedora 43 KDE *or* Nobara 43 KDE | Plasma 6 | Wayland | #370 (S10), QE-19 patch sanity, daily-driver regression baseline | @noctuum (#370), aaddrick |
|
||||
| **GNOME-X** | Ubuntu 24.04 (GNOME on Xorg session at greeter) | GNOME | Xorg | Differentiates whether #404 is mutter-as-compositor or mutter-XWayland-grabs specifically. **Note:** Fedora 43 GNOME may not ship an X11 session anymore (GNOME 49 deprecation); use Ubuntu's GNOME-on-Xorg session instead. | — |
|
||||
| **KDE-X** | Fedora 43 KDE (Plasma X11 session at greeter) | Plasma 6 | Xorg | Catches kwin-X11 specifics; regression baseline for the historic working path | — |
|
||||
|
||||
## Strongly recommended
|
||||
|
||||
Catches generalization gaps but not blocking close-out.
|
||||
|
||||
| Row | Distro | DE | Display server | Why |
|
||||
|-----|--------|----|--------------:|------|
|
||||
| **COSMIC** | popOS 24.04 (COSMIC alpha) | COSMIC | Wayland | @davidsmorais reported #393 there; not covered by KDE or GNOME branches |
|
||||
| **Ubu-X** | Ubuntu 24.04 (GNOME on Xorg) | GNOME | Xorg | Already counted under GNOME-X above. Listed here too because the Ubuntu install base is large — counts as its own row in the dashboard |
|
||||
|
||||
## Optional
|
||||
|
||||
Tracked under different bugs ([S06](./cases/shortcuts-and-input.md#s06--url-handler-doesnt-segfault-on-native-wayland), [S14](./cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) — skip unless closing those in the same sweep.
|
||||
|
||||
| Row | DE | Tracked under |
|
||||
|-----|----|--------------:|
|
||||
| Sway | wlroots | S06 |
|
||||
| Niri | wlroots | S14 |
|
||||
| Hypr-N (Omarchy) | wlroots | per @typedrat |
|
||||
| Hypr-O | Hyprland Xorg | per @typedrat |
|
||||
| i3 | Xorg | matrix |
|
||||
|
||||
## VM inventory
|
||||
|
||||
Existing host: `~/vms/` (libvirt, qcow2 images on a separate root-owned dir). Per-VM creation scripts in `~/vms/scripts/`. Per-VM test protocol in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md).
|
||||
|
||||
### Have
|
||||
|
||||
| Row | VM image | Status |
|
||||
|-----|----------|--------|
|
||||
| GNOME-W | `claude-fedora43-gnome.qcow2` | Ready |
|
||||
| Ubu-W | `claude-ubuntu-2404.qcow2` | Ready |
|
||||
| KDE-W | `claude-fedora43-kde.qcow2` | Ready (Nobara KDE on the bare-metal host is the alternative) |
|
||||
| GNOME-X | `claude-ubuntu-2404.qcow2` | Ready (use the GNOME-on-Xorg session at the greeter — same VM as Ubu-W) |
|
||||
| KDE-X | `claude-fedora43-kde.qcow2` | Ready (use the Plasma X11 session at the greeter — same VM as KDE-W) |
|
||||
|
||||
### Need to add for full mandatory + recommended coverage
|
||||
|
||||
| Row | What | Why |
|
||||
|-----|------|-----|
|
||||
| **COSMIC** | popOS 24.04 (COSMIC alpha) ISO + `~/vms/scripts/create-popos-cosmic.sh` | Davidsmorais's #393 environment; otherwise unrepresented |
|
||||
|
||||
### Need to add only if closing optional rows in the same sweep
|
||||
|
||||
| Row | What | Use existing | Why |
|
||||
|-----|------|--------------|-----|
|
||||
| Niri | Fedora-Niri-Live ISO + `~/vms/scripts/create-fedora-niri.sh` | — | S14 (`BindShortcuts` error 5) |
|
||||
| Hypr-N | Possibly already covered by `claude-omarchy` | `claude-omarchy.qcow2` | Omarchy is a Hypr-N variant; may not exercise stock Hyprland |
|
||||
| Sway | `claude-fedora43-sway.qcow2` | Existing | S06 URL handler segfault |
|
||||
| i3 | `claude-fedora43-i3.qcow2` | Existing | Coverage only |
|
||||
|
||||
## Minimum viable kill-set
|
||||
|
||||
If the goal is the smallest pass that justifies closing all three issues:
|
||||
|
||||
- **GNOME-W** — must pass QE-2/3/4/6/7/8/9/11 → closes #404, half of #393.
|
||||
- **Ubu-W** — must pass QE-7/8/9/11 → closes other half of #393.
|
||||
- **KDE-W** — must pass QE-7/8/9 + QE-14 + QE-19 → closes #370 (or punts upstream with QE-18 evidence) and confirms the gated patch path still works.
|
||||
|
||||
(QE-20 has been folded into QE-19 — the patch ships in every build, so a single bundled-JS check covers both KDE and non-KDE rows.)
|
||||
|
||||
Three VMs, ~21 items per row, one full sweep ≈ 90 minutes if the visual checks are batched.
|
||||
|
||||
## Per-row pass criteria
|
||||
|
||||
| Issue | Closeable when |
|
||||
|-------|----------------|
|
||||
| #393 | QE-7 through QE-12 pass on **GNOME-W**, **Ubu-W**, and **KDE-W**. QE-19 confirms the patch was applied at build (KDE gate string present). If QE-11 fails on GNOME-W, the KDE-only gate is preserved as a permanent fix; otherwise the patch can be widened. |
|
||||
| #404 | QE-2 and QE-3 pass on **GNOME-W**. QE-6 confirms the launcher actually appended `--enable-features=GlobalShortcutsPortal` on GNOME Wayland (S12). |
|
||||
| #370 | QE-14 passes on **KDE-W**. **OR** QE-18 records an Electron version > 41.0.4 in the bundled binary and QE-14 still fails — at that point the upstream-regression hypothesis is wrong and we re-investigate. |
|
||||
|
||||
## Scaffold integration
|
||||
|
||||
The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
This sweep is fully wired into the existing test scaffold. The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases in [`cases/shortcuts-and-input.md`](./cases/shortcuts-and-input.md):
|
||||
|
||||
| Case | Title | Backs |
|
||||
|------|-------|-------|
|
||||
@@ -115,4 +202,24 @@ The `QE-*` items in [§ Test list](#test-list) map onto formal `S##` test cases
|
||||
| [S36](./cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone) | Popup falls back to primary display when saved monitor is gone | QE-23 |
|
||||
| [S37](./cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy) | Popup remains functional after main window destroy | QE-24 |
|
||||
|
||||
QE-13, QE-15, QE-16, QE-17, and QE-21 are visual / input checks with no formal S-ID — run them by eye against [§ Upstream design intent](#upstream-design-intent).
|
||||
UI-element-level checks for QE-14 through QE-17 and QE-21 live in [`ui/quick-entry.md`](./ui/quick-entry.md), which has been refined against the upstream evidence captured in [§ Upstream design intent](#upstream-design-intent).
|
||||
|
||||
(QE-13, QE-21 don't need their own S-IDs — they're documentation items / already covered by `ui/quick-entry.md`.)
|
||||
|
||||
## Sweep mechanics
|
||||
|
||||
Per-row procedure (one full pass):
|
||||
|
||||
1. Boot VM. Confirm session at greeter matches the row (Wayland vs Xorg, correct DE).
|
||||
2. Install the latest build:
|
||||
- DEB: `sudo apt install ./claude-desktop_*.deb`
|
||||
- RPM: `sudo dnf install ./claude-desktop-*.rpm`
|
||||
3. Capture environment baseline: `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP`, `gnome-shell --version` or `kwin --version`, `electron --version` (for QE-18).
|
||||
4. Launch app. Wait for main window. Run QE-21 input smoke first to catch obvious breakage early.
|
||||
5. Run shortcut tests (QE-1 → QE-6) in order. Each run, scrape `~/.cache/claude-desktop-debian/launcher.log` and `pgrep -af claude-desktop` argv.
|
||||
6. Run submit tests (QE-7 → QE-13). For each window-state precondition, set the state, then trigger Quick Entry, then submit.
|
||||
7. Run visual checks (QE-14 → QE-18). Screenshot QE-14 to attach to #370 if still failing.
|
||||
8. Run patch sanity (QE-19 / QE-20).
|
||||
9. Update [`matrix.md`](./matrix.md) status cells. Save logs under a row-tagged subdirectory: `~/vms/collected/<row>-<date>/`.
|
||||
|
||||
For the deeper #393 bisect (isolating which half of PR #390 regresses GNOME), see the two-variant build instructions in [`~/vms/README.md`](file:///home/aaddrick/vms/README.md) — build a blur-only and a vis-only variant, run QE-7 through QE-11 on each on **Ubu-W** and **GNOME-W**, gate the offending half rather than the whole patch.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Last updated: 2026-05-03*
|
||||
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
How to run a test sweep, capture diagnostics, file failures, and update [`matrix.md`](./matrix.md). For the test specs themselves, see [`cases/`](./cases/) and [`ui/`](./ui/). For the automation harness, see [`automation.md`](./automation.md) and [`tools/test-harness/`](../../tools/test-harness/). For the grounding sweep workflow (verify case docs against the live build), see [Grounding sweep](#grounding-sweep) below.
|
||||
|
||||
## When to sweep
|
||||
|
||||
@@ -315,6 +315,9 @@ When a test drifts, edit Steps/Expected in place. When a feature is
|
||||
gone from the build, prepend
|
||||
`> **⚠ Missing in build X.Y.Z** — <note>. Re-verify after next
|
||||
upstream bump.` under the test heading.
|
||||
[`cases-grounding-prompt.md`](./cases-grounding-prompt.md) is the
|
||||
fan-out prompt the last sweep used — paste verbatim into a fresh
|
||||
session to repeat the workflow.
|
||||
|
||||
### Runtime pass
|
||||
|
||||
|
||||
238
docs/testing/runner-implementation-followup-prompt.md
Normal file
238
docs/testing/runner-implementation-followup-prompt.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# test-harness runner implementation — session 17 prompt
|
||||
|
||||
This file is meant to be **copied verbatim into a fresh Claude Code
|
||||
session** as the initial user message. Don't paraphrase it; the
|
||||
orchestration depends on the exact directives below.
|
||||
|
||||
> **ORCHESTRATION STOPPED AFTER SESSION 16.** This prompt is rotated
|
||||
> for completeness only. **Session 17 will NOT run automatically** —
|
||||
> the autonomous orchestration was halted at the end of session 16
|
||||
> after coverage stalled at 74/76 (97%) for four consecutive sessions
|
||||
> (13, 14, 15, 16). To resume, the user must manually trigger another
|
||||
> orchestration run AND meet at least one of these preconditions:
|
||||
>
|
||||
> 1. **Real signed-in Claude Desktop running with `--inspect=9229`**
|
||||
> on the dev box (debugger-attached, signed in, NOT a leaked test
|
||||
> isolation). This unblocks Categories A (operon-mode probe) and
|
||||
> B (Tier 3 read-only reframes that need auth-bearing renderer
|
||||
> state).
|
||||
> 2. **A real claude.ai account fixture for write-side state.** The
|
||||
> remaining 2 specs (matrix coverage 74/76 → 76/76) need real
|
||||
> write-side state (e.g. an installed plugin to exercise
|
||||
> `LocalPlugins.listSkillFiles`, or a deep-linked deferred install
|
||||
> intent for T11). The Tier 3 destructive constraint
|
||||
> (`Don't run destructive Tier 3 write-side tests`) explicitly
|
||||
> forbids the harness constructing this state itself.
|
||||
> 3. **Renderer-drift event** that requires re-anchoring page-objects
|
||||
> (e.g. claude.ai redesign breaks `findCompactPills`,
|
||||
> `clickMenuItem`, etc.). Triggers a defensive-migration session.
|
||||
> 4. **New IPC surface** added by upstream that the harness should
|
||||
> cover (e.g. a new `claude.web` interface, a new eipc method
|
||||
> that's case-doc-anchored).
|
||||
>
|
||||
> If none of those preconditions hold, the orchestration should NOT
|
||||
> resume — further sessions will produce documentation-only or
|
||||
> marginal output. The structural ceiling of the harness without
|
||||
> real-account fixtures is 74/76 (97%); we're already there.
|
||||
|
||||
You're picking up after session 16 of the test-harness runner
|
||||
implementation work. Session 16 was the final session of the
|
||||
sessions-13-to-16 orchestration run and produced: T17 verification
|
||||
(session-15 structural fix VERIFIED — bare 60s timeout gone, new
|
||||
failure mode at `openFolderPicker` post-`selectLocal` classified as
|
||||
renderer-state-dependent and deferred), schema-rev for
|
||||
`listRemotePluginsPage` / `listSkillFiles` (both schemas resolved by
|
||||
bundle inspection — neither shipped as a Tier 2 invocation because
|
||||
`listRemotePluginsPage` is not anchored in any case doc, and
|
||||
`listSkillFiles` needs Tier 3 destructive setup). NO coverage gain.
|
||||
Plan-doc updated. Followup-prompt rotated with the STOP flag (this
|
||||
document).
|
||||
|
||||
The plan doc at
|
||||
[`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
|
||||
captures the tier classification and execution-time reclassifications.
|
||||
Its "Status (post-execution)" section is the source of truth for
|
||||
what's done and what's deferred — read **session 16** first, then
|
||||
**session 15**, **session 14**, **session 13**, **session 12**,
|
||||
**session 11**, **session 10**, **session 9**, **session 8**,
|
||||
**session 7**, **session 6**, **session 5**, **session 4**, **session
|
||||
3**, **session 2**, then **session 1** sub-sections.
|
||||
|
||||
This session is a continuation, not a restart. Start by reading the
|
||||
plan doc's status sections AND verifying at least one of the
|
||||
preconditions above holds. If none hold, STOP and report; don't try
|
||||
to fan out.
|
||||
|
||||
### Session 16 final findings (key context for any session-17 attempt)
|
||||
|
||||
1. **T17's session-15 structural fix VERIFIED.** Bare 60s timeout is
|
||||
gone. `seedFromHost` clones the host's signed-in config,
|
||||
`waitForReady('userLoaded')` resolves to a post-login URL
|
||||
(`https://claude.ai/epitaxy` on the dev box), the dialog mock
|
||||
installs, and `CodeTab.activate({ timeout: 15_000 })` (session 14
|
||||
migration) succeeds first try.
|
||||
2. **T17's NEW failure mode is renderer-state-dependent, not AX.**
|
||||
After `selectLocal()` clicks the Local menuitem, the Select-folder
|
||||
pill never appears within 4s. The URL during the run was
|
||||
`/epitaxy` — the user's workspace route. The folder-picker UI
|
||||
may only render on `/new` (or a fresh project), not on a workspace
|
||||
already containing files. To unblock: navigate to `/new`
|
||||
post-userLoaded BEFORE `openFolderPicker()`. NOT shipped session
|
||||
16 — needs a careful navigation primitive that doesn't break
|
||||
existing seedFromHost specs.
|
||||
3. **`openPill` / `clickMenuItem` migration STILL parked.** Session
|
||||
16's T17 trace confirmed the env-pill open + Local click both
|
||||
succeeded, ruling out the AX-polling-loop hypothesis once and for
|
||||
all. Don't migrate those speculatively.
|
||||
4. **Schema-rev resolved both deferred validators.**
|
||||
`CustomPlugins.listRemotePluginsPage(limit: number, offset:
|
||||
number)`. `LocalPlugins.listSkillFiles(pluginId: string,
|
||||
skillName: string, pluginContext?: opaque)`. Neither shipped as a
|
||||
Tier 2 invocation: `listRemotePluginsPage` is not anchored in any
|
||||
case doc; `listSkillFiles` needs Tier 3 destructive setup.
|
||||
5. **Coverage stalled at 74/76 (97%) for 4 consecutive sessions.**
|
||||
Sessions 13-16 net deliverables: 1 primitive, 1 AX migration, 1
|
||||
structural fix, 1 verification + 1 schema-rev investigation.
|
||||
Without real-account fixtures, the harness's structural ceiling
|
||||
is 74/76. The remaining 2 specs need real-account write-side
|
||||
state.
|
||||
|
||||
### What a future session 17 might attempt (only if preconditions hold)
|
||||
|
||||
If precondition 1 (real signed-in debugger-attached Claude) holds:
|
||||
|
||||
- **Operon-mode probe** (Category A from sessions 13-16). Run
|
||||
`eipc-registry-probe.ts` against the user's Claude with operon mode
|
||||
toggled on/off, capture the diff in registered channels. May
|
||||
surface a new case-doc-coverable handler.
|
||||
- **Schema-rev smoke-test** for the session-16-resolved schemas
|
||||
against the live debugger. `listRemotePluginsPage(limit: 10,
|
||||
offset: 0)` should return an array shape; `listSkillFiles('some-
|
||||
installed-plugin', 'some-skill')` would test the LocalPlugins
|
||||
handler's auth path.
|
||||
|
||||
If precondition 2 (real-account write-side fixture) holds:
|
||||
|
||||
- **T11 runtime invocation.** With an installed plugin in
|
||||
`~/.claude/plugins/`, the post-install state can be probed via
|
||||
`listSkillFiles` and the slash-menu skills would assert the
|
||||
case-doc claim "skills appear in the slash menu" (T11 step 3).
|
||||
- **T17 navigation fix.** Add a `/new` navigation primitive to
|
||||
`claudeai.ts`'s `CodeTab` so `openFolderPicker` works on a fresh
|
||||
project route. Verify T17 reaches the dialog mock fired assertion.
|
||||
|
||||
If precondition 3 or 4 holds:
|
||||
|
||||
- **Defensive page-object refactor.** Re-snapshot the AX tree at the
|
||||
Customize panel and Plugin browser modal, refresh case-doc
|
||||
inventory anchors, migrate any decayed selectors.
|
||||
|
||||
### Termination signal interpretation
|
||||
|
||||
If session 17 is triggered without any precondition met, the right
|
||||
move is the same as session 16's STOP recommendation: write a one-
|
||||
paragraph "preconditions not met, no work shipped" plan-doc update
|
||||
and terminate. Don't burn a session on documentation-only output.
|
||||
|
||||
### Constraints to respect (unchanged from sessions 1-16)
|
||||
|
||||
- Use `seedFromHost: true` for any auth-required spec — never
|
||||
`CLAUDE_TEST_USE_HOST_CONFIG=1` / `isolation: null` (legacy shape
|
||||
removed in session 15).
|
||||
- eipc handlers register on `webContents.ipc._invokeHandlers`, NOT
|
||||
global `ipcMain._invokeHandlers`. Use `lib/eipc.ts`.
|
||||
- For arg validator schema-rev: smoke-test first, fall back to
|
||||
bundle-grep on the rejection literal.
|
||||
- For AX-tree consumers: use `lib/ax.ts` (`snapshotAx` /
|
||||
`waitForAxNode` / `waitForAxNodes`).
|
||||
- For call-site migrations to `waitForAxNode`: keep per-spec retry
|
||||
budgets matching existing tuning.
|
||||
- `lib/input.ts` is X11-only. `lib/input-niri.ts` is Niri-only. CDP
|
||||
auth gate is alive (runtime SIGUSR1 attach, never Playwright
|
||||
`_electron.launch()`). BrowserWindow Proxy gotcha — use
|
||||
`webContents.getAllWebContents()`. `skipUnlessRow()` always first.
|
||||
- No fixed sleeps. `retryUntil` from `lib/retry.ts`, Playwright
|
||||
auto-wait, or `waitForAxNode` from `lib/ax.ts`.
|
||||
- Diagnostics on every run via `testInfo.attach()`. Tag with
|
||||
`severity:` and `surface:` annotations.
|
||||
- Tabs in TS, ~80-char wrap.
|
||||
- Don't break existing runners. H01-H05 are the canaries.
|
||||
- `npm run typecheck` must stay clean.
|
||||
- Don't run destructive Tier 3 write-side tests.
|
||||
|
||||
### Authoritative reference
|
||||
|
||||
Read these in order before fanning out:
|
||||
|
||||
- [`docs/testing/runner-implementation-plan.md`](runner-implementation-plan.md)
|
||||
— tier classification + status sections.
|
||||
- [`tools/test-harness/README.md`](../../tools/test-harness/README.md)
|
||||
— runner conventions, the 74-spec inventory, primitives in
|
||||
`lib/`, isolation defaults.
|
||||
- [`docs/testing/cases/README.md`](cases/README.md) — case-doc
|
||||
structure and the four anchor scopes.
|
||||
- [`tools/test-harness/src/lib/`](../../tools/test-harness/src/lib/)
|
||||
— the existing primitives.
|
||||
- [`tools/test-harness/src/runners/`](../../tools/test-harness/src/runners/)
|
||||
— every existing spec is a template.
|
||||
|
||||
### Phase 0 — calibration (mandatory before fanning out)
|
||||
|
||||
1. `cd tools/test-harness && npm run typecheck` — should pass.
|
||||
2. Check debugger ATTACHMENT QUALITY (not just port). `ss -tln |
|
||||
grep ':9229'`. If port open, probe webContents via `evalInMain`:
|
||||
|
||||
```ts
|
||||
import { InspectorClient } from './src/lib/inspector.js';
|
||||
const client = await InspectorClient.connect(9229);
|
||||
const wcs = await client.evalInMain<unknown>(`
|
||||
const { webContents } = process.mainModule.require('electron');
|
||||
return webContents.getAllWebContents().map((w) => ({
|
||||
id: w.id, url: w.getURL(), title: w.getTitle(),
|
||||
}));
|
||||
`);
|
||||
console.log(wcs); client.close();
|
||||
```
|
||||
|
||||
If every URL is `/login` / `find_in_page` / `main_window`, treat
|
||||
as soft-blocked for auth-required investigations.
|
||||
3. Disambiguate running Claude processes. `pgrep -af
|
||||
"ozone-platform=x11.*app.asar"`; for each, inspect cmdline for
|
||||
`user-data-dir`. Real Claude has
|
||||
`~/.config/Claude` (or no user-data-dir flag); leaked test
|
||||
isolations have `/tmp/claude-test-*`.
|
||||
4. **Verify at least one precondition for resuming the orchestration
|
||||
holds.** If none hold, write a "no preconditions met" plan-doc
|
||||
update and STOP. Don't fan out.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- For the bundle-grep schema-rev pattern (sessions 9, 11, 12, 16
|
||||
precedents):
|
||||
|
||||
```bash
|
||||
cd tools/test-harness && node -e "
|
||||
const {extractFile} = require('@electron/asar');
|
||||
const buf = extractFile(
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar',
|
||||
'.vite/build/index.js'
|
||||
);
|
||||
const s = buf.toString('utf8');
|
||||
const idx = s.indexOf('<rejection-literal>');
|
||||
console.log(s.slice(Math.max(0, idx - 1500), idx + 500));
|
||||
"
|
||||
```
|
||||
|
||||
- For seedFromHost specs: host MUST have a signed-in Claude.
|
||||
`seedFromHost`'s host-claude-kill semantics will tear down any
|
||||
running Claude process — flag clearly in the report before
|
||||
invoking when the user's real Claude is running.
|
||||
|
||||
- For AX-tree polling: `lib/ax.ts`'s `waitForAxNode` /
|
||||
`waitForAxNodes` for predicate-based polling.
|
||||
|
||||
- The eipc-registry probe (`tools/test-harness/eipc-registry-probe.ts`)
|
||||
is the dedicated tool for inspecting per-wc IPC handler state.
|
||||
|
||||
Begin with Phase 0. Don't fan out until at least one of the
|
||||
preconditions for resuming the orchestration is verified to hold.
|
||||
2176
docs/testing/runner-implementation-plan.md
Normal file
2176
docs/testing/runner-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
597
docs/testing/ui-inventory-reconciliation.md
Normal file
597
docs/testing/ui-inventory-reconciliation.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# claude.ai UI Inventory Reconciliation
|
||||
|
||||
*Generated against [`ui-inventory.json`](./ui-inventory.json) v6 (captured 2026-05-03, app version 1.5354.0, 383 entries).*
|
||||
*Reconciled 2026-05-02.*
|
||||
|
||||
This file diffs the human-written claims in [`ui/`](./ui/) against the
|
||||
machine-captured ground-truth in [`ui-inventory.json`](./ui-inventory.json).
|
||||
|
||||
It is one-shot output meant to drive human cleanup of `ui/*.md` — re-run
|
||||
the reconciliation script (TODO: not yet built) after major walker passes.
|
||||
|
||||
## Reading this document
|
||||
|
||||
Three categories of finding per surface:
|
||||
|
||||
- **In docs but not in renderer** — the doc names an element that has no
|
||||
corresponding inventory entry. Possible causes (don't read this as "doc
|
||||
is wrong"; the walker covers a subset of reality):
|
||||
- **OS / window-manager element** — title bar, close/min/max buttons,
|
||||
drop shadow, resize edges. These are drawn by the compositor, not by
|
||||
claude.ai's renderer; the walker can't see them.
|
||||
- **Out of renderer scope** — tray menu, libnotify notifications, IME
|
||||
composition popups, Quick Entry popup window. These are main-process
|
||||
or DE-level surfaces that don't exist in the claude.ai DOM.
|
||||
- **Walker coverage gap** — Settings overlay, dialogs, deep Code-tab
|
||||
panes (terminal, file pane, diff). The walker drilled some surfaces
|
||||
but not others; absence here is "not yet observed" not "not present."
|
||||
- **Account-state-dependent** — features that don't appear on this
|
||||
user's plan (e.g. SSH connections panel, managed-settings rows,
|
||||
specific Code-tab pane types).
|
||||
- **Speculative** — doc was written from upstream behavior, not from a
|
||||
Linux build. May not actually render.
|
||||
- **In renderer but not in docs** — inventory captured an element that no
|
||||
doc row mentions. Either the doc is incomplete for that surface, or the
|
||||
element is tangential (search-results recency rows, instance-suffix
|
||||
duplicates with `#2`/`+5` markers).
|
||||
- **Fingerprint potentially drifted** — doc and inventory agree on the
|
||||
element but the doc's selector hint disagrees with the inventory's
|
||||
`fingerprint.selector`. Most `ui/*.md` rows use prose ("Top-left of
|
||||
topbar") rather than CSS selectors, so this category is small.
|
||||
|
||||
Human triage is what closes any of these. Don't auto-edit `ui/*.md`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Inventory entries (total) | 383 |
|
||||
| Inventory entries by kind | persistent 65 / structural 276 / menu 33 / instance 9 |
|
||||
| Inventory entries marked `denylisted: true` | 9 (Send×4, Install×4, Remove×1) |
|
||||
| `ui/*.md` files reconciled | 11 (10 surface files + README) |
|
||||
| `ui/*.md` rows reconciled (rough — multi-element rows complicate the count) | ~210 element rows across all 10 surface files |
|
||||
| Rows with confirmed inventory match | ~70 (~33%) |
|
||||
| Rows flagged "in docs but not in renderer" | ~140 (~67%) — heavily skewed by OS-frame, tray, notifications, deep Code panes, Settings, Quick Entry being out-of-renderer or under-walked |
|
||||
| Inventory entries with no `ui/*.md` mention | ~190 (~50%) — heavily skewed by per-conversation/per-skill/per-prompt-card structural rows that the docs treat as categories rather than enumerating |
|
||||
| Doc rows with explicit selectors that drift from inventory | 0 verified — `ui/*.md` rows almost never carry CSS selectors |
|
||||
|
||||
Match counts are approximate. `ui/*.md` rows often describe categories
|
||||
("Recent conversations," "Per-history-entry hover") that map to many
|
||||
inventory entries; the inventory in turn enumerates structural elements
|
||||
the docs intentionally don't list (every project skill button, every
|
||||
search result option). The reconciliation is a triage signal, not a
|
||||
metric.
|
||||
|
||||
## Per-surface breakdown
|
||||
|
||||
### `ui/window-chrome-and-tabs.md`
|
||||
|
||||
**Inventory surfaces likely covered:** none directly — OS window frame is
|
||||
drawn by the compositor; the in-app topbar elements live under `root` as
|
||||
`root.button.menu`, `root.button.collapse-sidebar`, `root.button.search`,
|
||||
`root.button.back`, `root.button.forward`. The "tab strip" maps to
|
||||
`root.button.chat`, `root.button.cowork`, `root.button.code`.
|
||||
|
||||
**Doc rows reconciled:** ~22
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Title bar | OS / window-manager |
|
||||
| Close button (X) | OS / window-manager |
|
||||
| Minimize button | OS / window-manager |
|
||||
| Maximize / restore button | OS / window-manager |
|
||||
| Resize edges | OS / window-manager |
|
||||
| Window menu (right-click titlebar) | OS / window-manager |
|
||||
| Cowork ghost icon | Walker captures `root.button.cowork` (the tab) but not the ghost-icon visual within the topbar shim |
|
||||
| Drag region (gaps between buttons) | Renders as empty space — not an actionable element |
|
||||
| Active tab indicator | Visual styling, not an actionable element |
|
||||
| Tab badges (unread / Dispatch) | None observed; user state at capture had no badges |
|
||||
| About dialog | Walker did not surface a dialog; About is reachable only from app/tray menu, both out of renderer scope |
|
||||
| App menu (macOS-style) | Doc itself notes this is N/A on Linux |
|
||||
| Update prompt | Conditional, not present at capture |
|
||||
| Crash report dialog | Conditional, not present at capture |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.menu` ("Menu", `aria-label="Menu"`) | This is the doc's "Hamburger menu" — renamed |
|
||||
| `root.button.collapse-sidebar` ("Collapse sidebar") | Doc has "Sidebar toggle"; arguably the same |
|
||||
| `root.button.search` ("Search") | Doc's "Search icon"; same |
|
||||
| `root.button.back` / `root.button.forward` | Doc's back/forward arrows; same |
|
||||
| `root.a.skip-to-content` ("Skip to content") | A11y skip link; not in doc |
|
||||
| `root.button.new-chat-n` ("New chat⌘N") | Topbar new-chat button; not in doc |
|
||||
| `root.button.pinned`, `root.button.recents`, `root.button.projects`, `root.button.artifacts`, `root.button.customize` | Sidebar nav buttons; doc covers some of these in `sidebar.md` not here |
|
||||
| `root.button.awaaddrick-max` ("AWAaddrick·Max") | User/plan badge in topbar; not in doc |
|
||||
| `root.button.get-apps-and-extensions` | Topbar shortcut to apps page; not in doc |
|
||||
| `root.tab.write` / `root.tab.learn` / `root.tab.code` / `root.tab.from-calendar` / `root.tab.from-gmail` | Quick-prompt-template tabs in the prompt area; doc covers Write/Learn/Code as Chat/Cowork/Code tabs but the inventory's `root.tab.code` is distinct from `root.button.code` |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc rows for this surface use Location prose only.
|
||||
|
||||
#### Notable cross-cut
|
||||
|
||||
The doc's "Chat / Cowork / Code" tab strip maps cleanly to
|
||||
`root.button.chat`, `root.button.cowork`, `root.button.code`. But the
|
||||
inventory also has `root.tab.code` (a `[role="tab"]`, not a button) which
|
||||
is a separate element — the prompt-area template strip — that the doc
|
||||
conflates with the main Chat/Cowork/Code switcher. Worth a human note.
|
||||
|
||||
---
|
||||
|
||||
### `ui/tray.md`
|
||||
|
||||
**Inventory surfaces covered:** none — the tray is a main-process Electron
|
||||
`Tray` object on the system SNI bus, not part of claude.ai's DOM.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Tray icon (light / dark theme) — main-process `Tray.setImage()`
|
||||
- Right-click menu items (Show/Hide, Quick Entry, Open at Login,
|
||||
Settings, About, Quit) — main-process `Menu.buildFromTemplate()`
|
||||
- Left-click / double-click / middle-click behaviors — main-process
|
||||
event handlers
|
||||
- Tooltip on hover, position, icon resolution, theme switch — SNI
|
||||
daemon and DE behavior
|
||||
|
||||
This entire file is correctly out of renderer scope; the walker is doing
|
||||
the right thing by not capturing any of it.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `ui/sidebar.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root` (sidebar lives in the root
|
||||
chrome on claude.ai). Note: the doc opens "Code Tab Sidebar" but the
|
||||
sidebar in the captured renderer is the global claude.ai sidebar, not a
|
||||
Code-tab-specific one. The Code-tab-specific session list is captured
|
||||
separately under `root.button.code.button.new-session-n` (60 entries).
|
||||
|
||||
**Doc rows reconciled:** ~18
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Filter: status / project / environment | Walker did not drill the filter dropdown |
|
||||
| Group-by control | Same — within Code-tab session list |
|
||||
| Session status indicator (idle/running/...) | Visual decoration on row, not an actionable element |
|
||||
| Project / branch label | Same |
|
||||
| Diff stats badge `+12 -1` | Conditional — no session at capture had pending diffs |
|
||||
| Dispatch badge | Conditional — no Dispatch-spawned session at capture |
|
||||
| Scheduled badge | Conditional — same |
|
||||
| Hover archive icon | Hover-revealed; walker captures static state |
|
||||
| Right-click context menu (Rename / Archive / etc.) | Walker does not synthesise right-clicks |
|
||||
| Sidebar resize handle | Visual / draggable, not an aria-labeled element |
|
||||
| Sidebar collapse toggle | Inventory has `root.button.collapse-sidebar` but doc treats it as a Code-tab element rather than chrome |
|
||||
| Scrollbar | OS / theme-rendered |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` cycling | Keyboard shortcut, not a UI element |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.fine-tuning-diffusion-models-with-reinforcement-learning` | A pinned recent conversation — sidebar content |
|
||||
| `root.button.more-options-for-fine-tuning-diffusion-models-with-reinforce` | Per-row menu trigger — doc mentions "right-click context menu" but inventory shows it's a discoverable button |
|
||||
| `root.button.how-to-use-claude` + `root.button.more-options-for-how-to-use-claude` | Same pattern |
|
||||
| `root.button.code.button.routines` | "Routines" link in Code-tab nav — doc's "Routines link" is here |
|
||||
| `root.button.code.button.more-navigation-items` | Likely the doc's "Customize / Routines" expander — not enumerated |
|
||||
| `root.button.code.button.filter` | The doc's "Filter: status" probably maps here |
|
||||
| `root.button.code.button.appearance` | Not in doc |
|
||||
| `root.button.code.button.show-5-more` | Pagination; not in doc |
|
||||
| `root.button.code.button.open-session-*` (5 entries) | Each is a single session row in the Code-tab list — the doc's "Per-session row" category |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc rows for this surface use Location prose only.
|
||||
|
||||
---
|
||||
|
||||
### `ui/prompt-area.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root` (top-level prompt area
|
||||
buttons), `root.button.add-files-connectors-and-more` (the `+` menu),
|
||||
`root.button.model-opus-4-7-adaptive` (model picker), and several deep
|
||||
sub-surfaces.
|
||||
|
||||
**Doc rows reconciled:** ~28
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Input field | The contenteditable / textarea itself isn't captured (no aria-label) |
|
||||
| Placeholder text | Not an interactive element |
|
||||
| Cursor caret / multi-line autosize / word wrap | Behavior, not element |
|
||||
| Paste plain text / paste image | Behavior |
|
||||
| `Enter` to send / `Shift+Enter` / `Esc` | Keyboard behavior |
|
||||
| IME composition | Not a renderer element |
|
||||
| Attachment button (left of input) | Not surfaced — possibly bundled into `root.button.add-files-connectors-and-more` |
|
||||
| File-attached chip | Conditional — no attachment at capture |
|
||||
| Multiple attachments / image preview / PDF preview | Conditional |
|
||||
| Drag-drop overlay | Conditional, only renders during drag |
|
||||
| `@filename` autocomplete | Conditional, only renders when typing `@` |
|
||||
| `+` button | Likely IS the `root.button.add-files-connectors-and-more` button — see below |
|
||||
| Slash menu (all rows: Built-in / Project skills / User skills / Plugin skills / filter / selection / `Esc`) | Walker did not type `/` to trigger the slash menu; no inventory entries |
|
||||
| Effort picker (`Cmd+Shift+E`) | Possibly inside `root.button.code.button.opus-4-7-1m-extra-high` — uncertain |
|
||||
| Stop button (replaces Send while responding) | Conditional — no in-flight response at capture |
|
||||
| Usage ring | Possibly `root.button.code.button.usage-plan-11` ("Usage: plan 11%") |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.press-and-hold-to-record` ("Press and hold to record") | Voice / dictation button in prompt area — doc has no voice input row |
|
||||
| `root.button.code.button.dictation-settings` | Dictation settings button |
|
||||
| `root.button.code.button.transcript-view-mode` | Transcript view toggle in prompt area |
|
||||
| `root.button.code.button.scroll-to-bottom` | Scroll-to-bottom affordance |
|
||||
| `root.button.code.button.accept-edits` | Permission-mode-related quick action |
|
||||
| `root.button.code.button.add` ("Add") | Likely the doc's `+` button, with a different label |
|
||||
| `root.button.code.button.usage-plan-11` ("Usage: plan 11%") | Probably the doc's "Usage ring" |
|
||||
| `root.button.code.button.opus-4-7-1m-extra-high` ("Opus 4.7 1M· Extra high") | Probably the doc's "Effort picker" |
|
||||
| All `root.button.add-files-connectors-and-more.menuitem.*` entries (Add files or photos / Add to project / Skills / Connectors / Plugins / Research / Web search / Use style) | The `+` menu contents — doc has Slash commands / Skills / Connectors / Plugins / Add plugin; inventory surfaces additional items the doc misses (Add files or photos, Add to project, Web search, Use style) |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.use-style.*` (8 entries: Normal / Learning / Concise / Explanatory / Formal / Create & edit styles / Research mode) | Style picker is a whole sub-surface the doc doesn't mention |
|
||||
| `root.button.model-opus-4-7-adaptive.menuitemradio.*` (Opus / Sonnet / Haiku / Adaptive thinking / More models) | Doc says "Sonnet, Opus, Haiku" — inventory adds Adaptive thinking + More models |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
| Doc claim | Inventory says |
|
||||
|-----------|----------------|
|
||||
| `+` button → opens menu of "Slash commands / Skills / Connectors / Plugins / Add plugin" | The corresponding inventory button is labeled "Add files, connectors, and more" with `aria-label="Add files, connectors, and more"`. Menu contents don't include "Slash commands" or "Add plugin" sub-entry — doc menu structure is partly speculative |
|
||||
|
||||
---
|
||||
|
||||
### `ui/code-tab-panes.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.code` (23 entries),
|
||||
`root.button.code.button.new-session-n` (60 entries) — but no per-pane
|
||||
sub-surfaces (no diff pane, no terminal pane, no preview pane, no file
|
||||
pane).
|
||||
|
||||
**Doc rows reconciled:** ~50
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Almost every Code-tab pane row is missing from the inventory. The walker
|
||||
landed in the Code-tab "New session" shell but did not open or drill any
|
||||
of the panes. Categories:
|
||||
|
||||
| Pane | Doc rows missing | Reason |
|
||||
|------|------------------|--------|
|
||||
| Pane chrome (header, drag/resize handles, close button, Views menu) | 5 rows | Walker coverage gap — no pane was open |
|
||||
| Diff pane | 9 rows (file list, diff content, line click, Cmd+Enter, Accept/Reject, Review code) | Walker coverage gap |
|
||||
| Preview pane | 11 rows | Walker coverage gap |
|
||||
| Terminal pane | 7 rows | Walker coverage gap (also: only renders for Local sessions) |
|
||||
| File pane | 7 rows | Walker coverage gap |
|
||||
| Tasks / subagent pane | 5 rows | Walker coverage gap |
|
||||
| Side chat overlay | 3 rows (trigger / content / close) | `root.button.code.button.close-side-chat` IS captured — the close button — but content isn't drilled |
|
||||
| CI status bar | 5 rows | Conditional — no PR open at capture |
|
||||
| View modes (Normal/Verbose/Summary) | 3 rows | Possibly behind `root.button.code.button.transcript-view-mode` — single inventory entry vs. 3 doc rows |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.code.button.local` ("Local") | Environment switcher chip — not in doc |
|
||||
| `root.button.code.button.select-folder` ("Select folder…") | Folder-picker entry — doc references this only via T17 cross-reference |
|
||||
| `root.button.code.button.send` (and `#2`, both denylisted) | Send button — doc has it under prompt-area, not panes |
|
||||
| `root.button.code.button.transcript-view-mode` | The doc's "Transcript view dropdown" — single inventory entry |
|
||||
| `root.button.code.button.opus-4-7-1m-extra-high` | Model selector inside Code-tab session shell |
|
||||
| `root.button.code.button.usage-plan-11` | Usage ring inside Code-tab session shell |
|
||||
| `root.button.code.button.accept-edits` ("Accept edits") | Permission-mode quick action — not in doc |
|
||||
| All 60 `root.button.code.button.new-session-n.button.open-session-*` and per-session entries | Doc covers the session list in `sidebar.md`, not here, so this isn't really a gap for `code-tab-panes.md` |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None — doc is prose-only.
|
||||
|
||||
---
|
||||
|
||||
### `ui/settings.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.settings` (only 1
|
||||
entry — "Settings" button itself), `root.button.awaaddrick-max.menuitem.settingsctrl`
|
||||
(the menu-item route to Settings, label "SettingsCtrl,").
|
||||
|
||||
**Doc rows reconciled:** ~28
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
The Settings page itself is essentially un-walked. Settings opens as an
|
||||
overlay/modal which the walker treated as a single button rather than
|
||||
drilling into. Every row in the doc beyond "Settings window opens" lacks
|
||||
a matching inventory entry:
|
||||
|
||||
| Doc section | Rows missing | Reason |
|
||||
|-------------|--------------|--------|
|
||||
| Settings root (close button, sidebar nav) | 3 rows | Walker coverage gap |
|
||||
| Desktop app → General (Computer use, Keep computer awake, Denied apps, Unhide apps, Theme picker) | 5 rows | Walker coverage gap; some rows account-state-dependent |
|
||||
| Desktop app → Account (name/email, plan badge, Sign out) | 3 rows | Walker coverage gap |
|
||||
| Claude Code (Worktree location, Branch prefix, Auto-archive toggle, Persist preview, Preview toggle, Bypass-permissions toggle, Auto mode availability) | 7 rows | Walker coverage gap |
|
||||
| Connectors page (list, per-connector entry, Manage, Disconnect, Add connector) | 5 rows | Walker coverage gap; partially covered by the in-session connectors menu |
|
||||
| SSH connections (list, Add SSH connection button, per-connection entry) | 3 rows | Walker coverage gap; account-state-dependent |
|
||||
| Keyboard shortcuts (list, value, Reset, Quick Entry shortcut) | 4 rows | Walker coverage gap |
|
||||
| Local environment editor (open, Add variable, Remove variable, Apply to dev servers) | 4 rows | Walker coverage gap; account-state-dependent |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.settings` ("Settings", `aria-label="Settings"`) | The button that opens Settings — confirmed in chrome |
|
||||
| `root.button.awaaddrick-max.menuitem.settingsctrl` ("SettingsCtrl,") | Settings menu item under the user/plan menu — alternate path |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None.
|
||||
|
||||
#### Walker coverage note
|
||||
|
||||
Settings is a known walker coverage gap (see preamble). This doc is
|
||||
substantively un-reconciled until a Settings drill pass lands.
|
||||
|
||||
---
|
||||
|
||||
### `ui/routines-page.md`
|
||||
|
||||
**Inventory surfaces likely covered:** none directly. Routines are
|
||||
reachable via `root.button.code.button.routines`, but the page itself
|
||||
isn't drilled.
|
||||
|
||||
**Doc rows reconciled:** ~26
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every doc row except the "Routines page link" itself is unmatched — the
|
||||
walker captured the entry point but did not open the Routines page.
|
||||
|
||||
| Doc section | Rows missing | Reason |
|
||||
|-------------|--------------|--------|
|
||||
| Routines list (header, New routine button, list, per-routine row, Run-now icon, Pause/resume, click row) | 7 rows | Walker coverage gap |
|
||||
| New routine form Local (Name, Description, Instructions, permission-mode picker, model picker, Working folder, Worktree toggle, Schedule preset, Time picker, Day picker, Save, Cancel, Folder-trust prompt) | 13 rows | Walker coverage gap |
|
||||
| New routine form Remote (Trigger type, Connectors picker, Network access controls) | 3 rows | Walker coverage gap; doc itself is partly speculative ("Per upstream docs") |
|
||||
| Routine detail (Run now, Active/Paused toggle, Edit, Delete, Review history, hover tooltip, Show more, Always allowed, Revoke approval) | 9 rows | Walker coverage gap |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.code.button.routines` ("Routines") | The entry-point link — doc's "Routines page link" |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
None.
|
||||
|
||||
---
|
||||
|
||||
### `ui/connectors-and-plugins.md`
|
||||
|
||||
**Inventory surfaces likely covered:** `root.button.add-files-connectors-and-more.menuitem.connectors`
|
||||
(the in-session connector picker, 5 entries), plus the deeper per-connector
|
||||
sub-surfaces under `.connectors.menuitemcheckbox.gmail.*` (15 entries).
|
||||
Plugin browser surfaces (`root.button.back.*`) cover Skills, Connectors,
|
||||
Add plugin, Typescript lsp, Php lsp, Playwright, Connectors, etc.
|
||||
|
||||
**Doc rows reconciled:** ~24
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
| Doc element | Reason class |
|
||||
|-------------|--------------|
|
||||
| Connectors menu — "Per-connector row" with status indicator | Inventory has Gmail and Google Calendar but not status decorations |
|
||||
| Empty state | Conditional — user has connectors configured |
|
||||
| Connector catalog (modal body, per-connector tile with logo/description) | Walker coverage gap — the Add-connector flow opens a modal that wasn't drilled |
|
||||
| OAuth in-app overlay | Conditional, not present at capture |
|
||||
| Permission consent screen | External (provider's UI) |
|
||||
| Callback completion | Behavior, not an element |
|
||||
| Custom connector entry point | Walker coverage gap |
|
||||
| Plugin browser modal (browser modal, marketplace selector, per-plugin tile, scope selector, install progress, success state, error state) | Walker captured plugin surfaces under `root.button.back.*` (Add plugin, Typescript lsp, Php lsp, Playwright) but not the modal anatomy |
|
||||
| Manage plugins (installed list, per-plugin row, Enable toggle, Plugin skills sub-list) | Walker coverage gap — no Manage-plugins surface drilled |
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
| Inventory entry | Notes |
|
||||
|-----------------|-------|
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors` ("Connectors", in-session menu) | Doc covers this — the in-session Connectors menu |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail` ("Gmail") | Per-connector row — doc "Per-connector row" category |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.google-calendar` ("Google Calendar") | Per-connector row — same |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.manage-connectors` ("Manage connectors") | Doc's "Manage connectors entry" |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.add-connector` ("Add connector") | Doc has "Add connector button" in Settings; inventory shows it also exists in the in-session menu |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitem.tool-accessload-tools-when-needed` ("Tool accessLoad tools when needed") | Per-connector tool-access setting — not in doc |
|
||||
| `root.button.back.a.skills` ("Skills") | Plugin browser — Skills tab |
|
||||
| `root.button.back.a.connectors` / `root.button.back.a.connectors#2` (both "Connectors") | Plugin browser — Connectors tab (instance suffix `#2` indicates duplicate detection) |
|
||||
| `root.button.back.button.add-plugin` ("Add plugin") | Plugin browser — Add plugin button |
|
||||
| `root.button.back.a.typescript-lsp` / `root.button.back.a.php-lsp` / `root.button.back.a.playwright` | Installed plugins — doc treats this as "Manage plugins → Per-plugin row," walker captures the actual plugin names |
|
||||
| `root.button.back.button.connect-your-appslet-claude-read-and-write-to-the-tools-you-` ("Connect your appsLet Claude read...") | Plugin browser landing pane CTA — not in doc |
|
||||
| `root.button.back.a.create-new-skillsteach-claude-your-processes-team-norms-and-` ("Create new skillsTeach Claude your processes, team norms, and expertise.") | Skills-creation CTA — not in doc |
|
||||
| `root.button.back.button.browse-pluginsadd-pre-built-knowledge-for-your-field` ("Browse pluginsAdd pre-built knowledge for your field.") | Browse-plugins CTA — not in doc |
|
||||
| `root.button.add-files-connectors-and-more.menuitem.connectors.menuitemcheckbox.gmail.button.develop-storytelling-frameworks` and 9 similar `.option`/`.button` pairs | Connector-suggested prompt cards. Walker captured these as a side-effect of drilling Gmail — they aren't a doc-targeted UI element |
|
||||
|
||||
#### Fingerprint potentially drifted
|
||||
|
||||
| Doc claim | Inventory says |
|
||||
|-----------|----------------|
|
||||
| `+` → **Connectors** opens "Connectors menu" | Inventory: button is "Add files, connectors, and more" not "+"; menu item is "Connectors". Functionally the same surface |
|
||||
|
||||
---
|
||||
|
||||
### `ui/quick-entry.md`
|
||||
|
||||
**Inventory surfaces covered:** none — Quick Entry is a separate
|
||||
`BrowserWindow` constructed in the main process (`index.js:515375`), not
|
||||
part of claude.ai's renderer. The walker started at `https://claude.ai/new`
|
||||
which never reaches it.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Window appearance (frame, background, rounded corners, drop shadow,
|
||||
position, always-on-top, lifecycle, persistence after main destroy) —
|
||||
main-process BrowserWindow construction
|
||||
- Input area (text input, placeholder, multi-line, Enter/Shift+Enter,
|
||||
Esc, click-outside, paste, IME) — popup renderer (separate from
|
||||
claude.ai)
|
||||
- Submit feedback (transition, loading, error) — popup renderer + IPC
|
||||
bridge
|
||||
|
||||
This entire file is correctly out of renderer scope. Doc rows are
|
||||
already heavily annotated with `index.js:515xxx` references to upstream
|
||||
main-process source — that's the right substrate.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
### `ui/notifications.md`
|
||||
|
||||
**Inventory surfaces covered:** none — notifications fire via libnotify
|
||||
on the `org.freedesktop.Notifications` DBus path; they are not DOM
|
||||
elements.
|
||||
|
||||
**Doc rows reconciled:** ~17
|
||||
|
||||
#### In docs but not in renderer
|
||||
|
||||
Every row, by design. Categories:
|
||||
|
||||
- Notification sources (Scheduled fires, Catch-up, CI status, PR merged,
|
||||
Dispatch handoff, Permission prompt) — main-process emitters
|
||||
- Per-notification anatomy (App identity, icon, title, body, actions,
|
||||
click target) — DBus payload
|
||||
- Per-DE rendering (KDE/GNOME/Mako/Dunst/swaync/Niri) — daemon behavior
|
||||
- Notification persistence (history, DND) — daemon behavior
|
||||
|
||||
This entire file is correctly out of renderer scope.
|
||||
|
||||
#### In renderer but not in docs
|
||||
|
||||
N/A — surface mismatch.
|
||||
|
||||
---
|
||||
|
||||
## Top-level findings
|
||||
|
||||
### Coverage by source-of-truth axis
|
||||
|
||||
- **OS-level / window-manager elements** (window-chrome rows for
|
||||
title bar, close/min/max, resize edges, drop shadow) — never going to
|
||||
appear in the renderer inventory. ~10 doc rows.
|
||||
- **Main-process Electron windows** (Quick Entry popup, About dialog,
|
||||
crash dialog, file pickers) — never going to appear in the renderer
|
||||
inventory. ~25 doc rows.
|
||||
- **Tray menu** (Show/Hide, Quick Entry, Settings, About, Quit, Open
|
||||
at Login) — main-process `Menu.buildFromTemplate()`. ~12 doc rows.
|
||||
- **libnotify notifications** — DBus, not DOM. ~17 doc rows.
|
||||
- **Walker coverage gaps** (Settings overlay, Routines page, plugin
|
||||
browser modal, all Code-tab panes, dialogs, slash menu, drag-drop
|
||||
overlay) — would appear if the walker drilled them. ~70 doc rows.
|
||||
- **Account-state-dependent surfaces** (CI bar, Dispatch badges, file
|
||||
attachments, SSH connections panel) — would appear in some sessions
|
||||
but didn't at capture. ~15 doc rows.
|
||||
- **Conditional / hover / behavior** (right-click context menus, hover
|
||||
archive icons, drag-drop overlays, tooltips) — wouldn't appear in a
|
||||
static walker pass even if the surface was visited. ~10 doc rows.
|
||||
|
||||
The combined explanation: roughly half of the "in docs but not in
|
||||
renderer" mismatches are unfixable (different source of truth), and
|
||||
roughly half are walker coverage gaps that future passes can close.
|
||||
|
||||
### Top 3 surfaces with the most "in docs but not in renderer" mismatches
|
||||
|
||||
These are likely candidates for speculative claims OR for un-walked
|
||||
surfaces. Treat as triage queue:
|
||||
|
||||
1. **`ui/code-tab-panes.md`** — ~50 unmatched rows. Almost entirely
|
||||
walker-coverage gap (the walker landed in the Code-tab shell but
|
||||
opened no panes). Until the walker drills diff/preview/terminal/file/
|
||||
tasks panes, this doc is un-reconcilable.
|
||||
2. **`ui/settings.md`** — ~28 unmatched rows. Settings opens as an
|
||||
overlay; walker captured only the Settings entry-point button. Needs
|
||||
targeted drill.
|
||||
3. **`ui/routines-page.md`** — ~26 unmatched rows. Same shape as
|
||||
Settings — entry-point captured, page contents unwalked.
|
||||
|
||||
### Top 3 surfaces with the most "in renderer but not in docs" surplus
|
||||
|
||||
These docs are most-incomplete relative to ground truth:
|
||||
|
||||
1. **`ui/sidebar.md`** — Inventory has 60+ Code-tab session-list entries
|
||||
under `root.button.code.button.new-session-n`. Doc treats sessions as
|
||||
a single category row. This is intentional doc behavior, but it means
|
||||
the doc doesn't help when reasoning about the actual structural
|
||||
buttons (Filter, Appearance, Routines, More navigation items, Show 5
|
||||
more, etc.) that the walker found.
|
||||
2. **`ui/prompt-area.md`** — Inventory has the entire Use-style picker
|
||||
sub-tree (Normal / Learning / Concise / Explanatory / Formal / Create
|
||||
& edit styles + 5 preset cards), the Press-and-hold-to-record voice
|
||||
button, dictation settings, transcript view mode, scroll-to-bottom,
|
||||
and the model picker's "Adaptive thinking" / "More models" entries —
|
||||
none of which the doc enumerates.
|
||||
3. **`ui/connectors-and-plugins.md`** — Inventory has the entire plugin
|
||||
browser sub-tree (`root.button.back.*` — 12 entries: Skills, Add
|
||||
plugin, Typescript lsp, Php lsp, Playwright, Browse plugins, Create
|
||||
new skills, Connect your apps, Connectors×2, Back to Claude, Select
|
||||
a folder), and connector-suggested prompt cards (10 entries under
|
||||
`.gmail.button.*`). Doc treats these surfaces at a higher level of
|
||||
abstraction.
|
||||
|
||||
## Acknowledged gaps in inventory itself
|
||||
|
||||
Not all inventory absences are doc errors. Known walker gaps as of v6:
|
||||
|
||||
- **Settings page deep content** — only the entry-point button
|
||||
(`root.button.settings`) and the menu shortcut
|
||||
(`...menuitem.settingsctrl`) captured. Settings opens as an overlay
|
||||
the walker did not drill.
|
||||
- **Dialogs** — 0 captured. claude.ai may not use `[role=dialog]` for
|
||||
most modals, or the walker's drill paths didn't reach them.
|
||||
- **Code tab panes** — only the Code-tab session shell was drilled;
|
||||
diff, preview, terminal, file, tasks, subagent, plan, side chat, CI
|
||||
bar are uncaptured.
|
||||
- **Routines page** — only the entry-point link was captured.
|
||||
- **Plugin browser modal anatomy** — surrounding list captured, the
|
||||
per-plugin install modal wasn't.
|
||||
- **Slash menu** — walker did not type `/` to trigger.
|
||||
- **Hover/right-click/drag-only affordances** — static walker; no
|
||||
context menus or drag-drop overlays.
|
||||
- **Quick Entry / Tray / Notifications** — out of renderer scope.
|
||||
|
||||
These are walker tickets, not bugs against the v6 capture.
|
||||
|
||||
## Triage suggestions for `ui/*.md` cleanup
|
||||
|
||||
Aimed at humans editing the docs. Ordered by impact:
|
||||
|
||||
1. **Mark out-of-renderer surfaces explicitly.** `ui/tray.md`,
|
||||
`ui/quick-entry.md`, `ui/notifications.md`, and the OS-frame section
|
||||
of `ui/window-chrome-and-tabs.md` already reference main-process
|
||||
source and DE behavior — add a header note that this surface
|
||||
intentionally doesn't appear in `ui-inventory.json`.
|
||||
2. **Annotate walker-coverage-gap surfaces.** `ui/code-tab-panes.md`,
|
||||
`ui/settings.md`, `ui/routines-page.md` — header note that the
|
||||
inventory does not yet drill these surfaces; rows reflect upstream
|
||||
behavior and are unverified in the renderer.
|
||||
3. **Add missing topbar/prompt-area elements** to `ui/window-chrome-and-tabs.md`
|
||||
and `ui/prompt-area.md` from the "In renderer but not in docs" lists.
|
||||
4. **Decide the doc/inventory boundary for sidebar session lists.** Doc
|
||||
treats sessions as a category; inventory enumerates each. Pick one
|
||||
shape and document it.
|
||||
5. **Flag speculative Linux-conditional rows** — `ui/settings.md` SSH
|
||||
connections, "Denied apps" / "Unhide apps when Claude finishes" for
|
||||
Computer Use — mark as "may not render on Linux; verify before
|
||||
assuming."
|
||||
3761
docs/testing/ui-inventory.json
Normal file
3761
docs/testing/ui-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
12
docs/testing/ui-inventory.meta.json
Normal file
12
docs/testing/ui-inventory.meta.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"capturedAt": "2026-05-03T07:13:20.024Z",
|
||||
"appVersion": "1.5354.0",
|
||||
"walkerVersion": "7",
|
||||
"startUrl": "https://claude.ai/epitaxy",
|
||||
"totalElements": 90,
|
||||
"deniedActions": 6,
|
||||
"partial": false,
|
||||
"isolation": "launchClaude (test-harness path)",
|
||||
"seededFromHost": true,
|
||||
"allowlistEntries": []
|
||||
}
|
||||
0
docs/testing/ui-snapshots/.gitkeep
Normal file
0
docs/testing/ui-snapshots/.gitkeep
Normal file
76
docs/testing/ui-snapshots/README.md
Normal file
76
docs/testing/ui-snapshots/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# UI snapshots
|
||||
|
||||
Captured renderer state for the `claude.ai` web view, taken via the
|
||||
`explore` CLI in [`tools/test-harness/explore/`](../../../tools/test-harness/explore/).
|
||||
Use these to detect upstream UI drift before it breaks the harness.
|
||||
|
||||
The snapshot JSON files themselves are gitignored
|
||||
(`docs/testing/ui-snapshots/*.json`) — they're noisy diffs and
|
||||
specific to the moment of capture. This directory is checked in so the
|
||||
path exists; the README + `.gitkeep` are the only tracked files.
|
||||
|
||||
## Capture
|
||||
|
||||
Requires a running `claude-desktop` build with the main-process
|
||||
debugger attached on port 9229 (Developer menu → Enable Main Process
|
||||
Debugger). Then, from `tools/test-harness/`:
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts snapshot baseline-code-tab
|
||||
# → wrote /…/docs/testing/ui-snapshots/baseline-code-tab.json
|
||||
```
|
||||
|
||||
Snapshot names are restricted to `[a-zA-Z0-9._-]`.
|
||||
|
||||
## Compare
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts diff baseline-code-tab after-feature-x
|
||||
```
|
||||
|
||||
Add `--json` for machine-readable output. Add `--exit-on-diff` to fail
|
||||
the process (exit code 3) when there are any entries — useful inside a
|
||||
CI guard.
|
||||
|
||||
`diff` arguments accept either a bare name (looked up in this dir,
|
||||
`.json` appended) or an explicit path.
|
||||
|
||||
### What counts as a diff
|
||||
|
||||
| Kind | Meaning |
|
||||
|-----------|---------------------------------------------------------|
|
||||
| `removed` | Element keyed in A absent from B (drift signal). |
|
||||
| `changed` | Same key, different visible text or structural detail. |
|
||||
| `added` | New key in B (informational only — surface gained). |
|
||||
|
||||
## Snapshot shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"capturedAt": "2026-05-02T17:30:00Z",
|
||||
"claudeAiUrl": "https://claude.ai/…",
|
||||
"appVersion": "1.1.7714", // from app.getVersion(), null on failure
|
||||
"pageState": { "url", "title", "readyState" },
|
||||
"dfPills": [ /* Chat / Cowork / Code top-level tabs */ ],
|
||||
"compactPills": [ /* env pill, Select-folder pill, … */ ],
|
||||
"ariaLabeledButtons":[ /* every <button[aria-label]>, capped at 200 */ ],
|
||||
"openMenu": { "ariaLabelledBy", "ariaLabel", "items": [...] },
|
||||
"modals": [ /* role=dialog with heading + buttons */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Discovery is by **structural shape**, never by minified Tailwind class
|
||||
names. See the why-block at the top of
|
||||
[`tools/test-harness/explore/snapshot.ts`](../../../tools/test-harness/explore/snapshot.ts)
|
||||
for the rationale.
|
||||
|
||||
## Other subcommands
|
||||
|
||||
```sh
|
||||
npx tsx explore/explore.ts # full snapshot to stdout
|
||||
npx tsx explore/explore.ts pills # df-pills + compact-pills + state
|
||||
npx tsx explore/explore.ts menu # currently-open menu (or null)
|
||||
npx tsx explore/explore.ts find <re> # regex search over text + aria-label
|
||||
```
|
||||
|
||||
`find` regex is case-insensitive by default.
|
||||
360
docs/testing/ui-vocabulary.json
Normal file
360
docs/testing/ui-vocabulary.json
Normal file
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"derivedAt": "2026-05-03T02:51:23.409Z",
|
||||
"sourceInventory": {
|
||||
"capturedAt": "2026-05-03T00:21:38.299Z",
|
||||
"appVersion": "1.5354.0",
|
||||
"walkerVersion": "6",
|
||||
"totalElements": 383
|
||||
},
|
||||
"stable": [
|
||||
"Accept edits",
|
||||
"Add",
|
||||
"Add connector",
|
||||
"Add files",
|
||||
"Add files or photosCtrl+U",
|
||||
"Add files, connectors, and more",
|
||||
"Add from GitHub",
|
||||
"Add to project",
|
||||
"All projects",
|
||||
"Appearance",
|
||||
"Ask",
|
||||
"Back",
|
||||
"Back to Claude",
|
||||
"Chat",
|
||||
"Clear active",
|
||||
"Close",
|
||||
"Close side chat",
|
||||
"Close suggestions",
|
||||
"Code",
|
||||
"Completed: See Claude workTry a quick task — Claude does it, you watch",
|
||||
"ConcisePreset",
|
||||
"Connectors",
|
||||
"Conversation ID reference",
|
||||
"Copy invite",
|
||||
"Cowork",
|
||||
"Create custom style",
|
||||
"Create engaging headlines",
|
||||
"Create presentation scripts",
|
||||
"Develop content templates",
|
||||
"Develop storytelling frameworks",
|
||||
"Dictation settings",
|
||||
"Dismiss checklist",
|
||||
"Dismiss guest pass",
|
||||
"Draft PR visibility on GitHub",
|
||||
"ELKO HRN-33 and HRN-31 manuals",
|
||||
"Edit Instructions",
|
||||
"Electron apps Linux users desperately want but can't have\nDespite Electron's cross-platform promise, several high-profil",
|
||||
"Expand sidebar",
|
||||
"ExplanatoryPreset",
|
||||
"Feedback submission",
|
||||
"Filter",
|
||||
"Fine-tuning diffusion models with reinforcement learning",
|
||||
"FormalPreset",
|
||||
"Forward",
|
||||
"From Calendar",
|
||||
"From Gmail",
|
||||
"Get apps and extensions",
|
||||
"Gmail",
|
||||
"Google Calendar",
|
||||
"How to use ClaudeAaddrick Williams",
|
||||
"Install",
|
||||
"Invalid session description",
|
||||
"Lamination plate position offsetsAaddrick Williams",
|
||||
"Learn",
|
||||
"Learn about styles",
|
||||
"Learn how to use Cowork safely",
|
||||
"Learn more about styles",
|
||||
"Learning",
|
||||
"LearningPreset",
|
||||
"Local",
|
||||
"Manage connectors",
|
||||
"Menu",
|
||||
"Model: Legacy Model",
|
||||
"Model: Opus 4.7 Adaptive",
|
||||
"Model: Sonnet 4.6 Adaptive",
|
||||
"More navigation items",
|
||||
"More options",
|
||||
"More options for Fine-tuning diffusion models with reinforcement learning",
|
||||
"More options for How to use Claude",
|
||||
"New artifact",
|
||||
"New project",
|
||||
"Open session Audit for elementary-data supply chain vulnerability",
|
||||
"Open session Find contact method for Claude Desktop issue",
|
||||
"Open session Plan automated testing strategy for desktop app",
|
||||
"Open session Test DNS query for Claude desktop package",
|
||||
"Open session for PR #552",
|
||||
"Pair your phoneSend tasks from your phone for Claude to run here",
|
||||
"Pin project",
|
||||
"Pinned",
|
||||
"Plugins",
|
||||
"Press and hold to record",
|
||||
"Recents",
|
||||
"Research",
|
||||
"Research mode",
|
||||
"Schedule a recurring taskGreat for reminders, reports, or regular check-ins",
|
||||
"Scroll to bottom",
|
||||
"Search",
|
||||
"Search projects",
|
||||
"Select folder…",
|
||||
"Send",
|
||||
"Settings",
|
||||
"Show 5 more",
|
||||
"Show more",
|
||||
"Skills",
|
||||
"Skip to content",
|
||||
"Sort by",
|
||||
"Start a task in Cowork",
|
||||
"Style: Formal",
|
||||
"Terms apply",
|
||||
"Test",
|
||||
"Testing and Quality Assurance",
|
||||
"Tool accessLoad tools when needed",
|
||||
"Transcript view mode",
|
||||
"Untitled",
|
||||
"Use style",
|
||||
"View all",
|
||||
"Web search",
|
||||
"West Central Schools provincial takeover investigation",
|
||||
"Work in a project",
|
||||
"Write",
|
||||
"Write something in the voice of my favorite historical figure",
|
||||
"Your artifactsYour artifacts",
|
||||
"about_tab.py, py, 60 lines",
|
||||
"New chat⌘N",
|
||||
"New session⌘N",
|
||||
"New task⌘N",
|
||||
"Artifacts",
|
||||
"Live artifacts",
|
||||
"Scheduled",
|
||||
"DispatchBeta",
|
||||
"Routines",
|
||||
"How to use Claude",
|
||||
"Projects",
|
||||
"Customize"
|
||||
],
|
||||
"instanceShapes": [
|
||||
{
|
||||
"id": "plan-badge",
|
||||
"regex": "^.+·(Free|Pro|Max|Team|Enterprise)[-\\s]*$",
|
||||
"flags": "u",
|
||||
"pattern": "\\w+·(Free|Pro|Max|Team|Enterprise)",
|
||||
"matchedNames": [
|
||||
"AWAaddrick·Max"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "opus-version",
|
||||
"regex": "^Opus \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Opus \\d",
|
||||
"matchedNames": [
|
||||
"Opus 4.7 1M· Extra high",
|
||||
"Opus 4.7Most capable for ambitious work"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sonnet-version",
|
||||
"regex": "^Sonnet \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Sonnet \\d",
|
||||
"matchedNames": [
|
||||
"Sonnet 4.6Most efficient for everyday tasks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "haiku-version",
|
||||
"regex": "^Haiku \\d",
|
||||
"flags": "",
|
||||
"pattern": "^Haiku \\d",
|
||||
"matchedNames": [
|
||||
"Haiku 4.5Fastest for quick answers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "percentage",
|
||||
"regex": "\\d{1,3}%$",
|
||||
"flags": "",
|
||||
"pattern": "\\d{1,3}%",
|
||||
"matchedNames": [
|
||||
"Usage: plan 11%"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "relative-date",
|
||||
"regex": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)",
|
||||
"flags": "",
|
||||
"pattern": "(Today|Yesterday|\\d+\\s(day|hour|minute|second|week|month|year)s?\\sago)(\\+\\d+)?",
|
||||
"matchedNames": [
|
||||
"Claude Desktop Debian1 year ago",
|
||||
"Draft PR visibility on GitHubYesterday",
|
||||
"ELKO HRN-33 and HRN-31 manualsYesterday",
|
||||
"Feedback submissionYesterday",
|
||||
"Find contact method for Claude Desktop issuePR #552 · Yesterday",
|
||||
"Review PR 555 for issue 558 fixToday",
|
||||
"Review and analyze issue 545Yesterday"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "size-with-unit",
|
||||
"regex": "^\\d+\\.\\d+\\s\\w+",
|
||||
"flags": "",
|
||||
"pattern": "^\\d+\\.\\d+\\s\\w+",
|
||||
"matchedNames": []
|
||||
},
|
||||
{
|
||||
"id": "user-handle",
|
||||
"regex": "@\\w+",
|
||||
"flags": "",
|
||||
"pattern": "@\\w+",
|
||||
"matchedNames": []
|
||||
},
|
||||
{
|
||||
"id": "long-title",
|
||||
"regex": "^[A-Z][a-z]+ [A-Z][a-z]+ [a-z]",
|
||||
"flags": "",
|
||||
"pattern": null,
|
||||
"matchedNames": [
|
||||
"Evaluate Terraform for infrastructure setup",
|
||||
"Host Obsidian library in second database"
|
||||
]
|
||||
}
|
||||
],
|
||||
"suspect": [
|
||||
"Adaptive thinkingThinks for more complex tasks",
|
||||
"Add build instructions and patch toggle option",
|
||||
"Add build instructions and quick menu patch toggle",
|
||||
"Add plugin",
|
||||
"Audit for elementary-data supply chain vulnerability",
|
||||
"Automate",
|
||||
"Browse pluginsAdd pre-built knowledge for your field.",
|
||||
"Build adversarial resume review platform MVP",
|
||||
"Change fonts to Lexend",
|
||||
"Check Quad9 DNS resolution for package domain",
|
||||
"Check flight map tile caching history",
|
||||
"Check for Trivy supply chain vulnerability",
|
||||
"Claude Desktop DebianAaddrick Williams",
|
||||
"Claude Desktop DebianEnter",
|
||||
"Claude is AI and can make mistakes. Please double-check responses.",
|
||||
"Claude prompting guide.md, md, 413 lines",
|
||||
"Clawdmartclawdmart.comClaudeCreate a shopping list, go on Chrome, and make an order",
|
||||
"Collapse sidebar",
|
||||
"Compare GPU options for gaming performance",
|
||||
"Concise",
|
||||
"Connect your appsLet Claude read and write to the tools you already use.",
|
||||
"Copy",
|
||||
"Create & edit styles",
|
||||
"Create new skillsTeach Claude your processes, team norms, and expertise.",
|
||||
"Create user documentation",
|
||||
"Customer Email",
|
||||
"Data",
|
||||
"Develop editorial guidelines",
|
||||
"Dispatch background conversation",
|
||||
"Download",
|
||||
"Draw",
|
||||
"Edit",
|
||||
"Educational Content",
|
||||
"Evaluate productization viability of methodology",
|
||||
"Explanatory",
|
||||
"Find contact method for Claude Desktop issue",
|
||||
"Fix Claude Desktop installation on Debian",
|
||||
"Formal",
|
||||
"Formulas",
|
||||
"Give negative feedback",
|
||||
"Give positive feedback",
|
||||
"Help me develop a unique voice for an audience",
|
||||
"Home",
|
||||
"How to use ClaudeAn example project that also doubles as a how-to guide for using Claude. Chat with it to learn more abo",
|
||||
"Identify tools for session start hook",
|
||||
"Insert",
|
||||
"Investigate GitHub Actions workflow failure",
|
||||
"Investigate GitHub issue 394 comment",
|
||||
"Investigate leaked crates.io API key",
|
||||
"Investigate leaked crates.io token in repository",
|
||||
"Lamination plate position offsetsAdjust existing code to just populate a table with original positions, new positions, a",
|
||||
"Marketing Blog Post",
|
||||
"More models",
|
||||
"More options for Claude Desktop Debian",
|
||||
"More options for Lamination plate position offsets",
|
||||
"My downloads folder is a mess! Can you clean it up?",
|
||||
"Normal",
|
||||
"Open",
|
||||
"Options",
|
||||
"Page Layout",
|
||||
"Php lsp",
|
||||
"Plan automated testing strategy for desktop app",
|
||||
"Playwright",
|
||||
"Product Review",
|
||||
"Read health data",
|
||||
"Retry",
|
||||
"Review",
|
||||
"Review PR 555 for issue 558 fix",
|
||||
"Review and address issue 88",
|
||||
"Review and analyze issue 545",
|
||||
"Review and close stale issues",
|
||||
"Review and investigate GitHub issue 445",
|
||||
"Review issue 156",
|
||||
"Review issue 172 and document related history",
|
||||
"Review issue 373",
|
||||
"Review last three repository commits",
|
||||
"Review path resolution issues and pull requests",
|
||||
"Review project issues and pull requests",
|
||||
"Review recent comments, issues, and pull requests",
|
||||
"Select a folder",
|
||||
"Share chat",
|
||||
"Short Story",
|
||||
"Start a new project",
|
||||
"Start return",
|
||||
"Style: Concise",
|
||||
"Style: Explanatory",
|
||||
"Style: Learning",
|
||||
"Test DNS lookup with Quad9 resolver",
|
||||
"Test DNS query for Claude desktop package",
|
||||
"Test path resolution",
|
||||
"Test startsession hook functionality",
|
||||
"Troubleshoot modem downstream connection issue",
|
||||
"Turn these receipts into an expense report",
|
||||
"Typescript lsp",
|
||||
"Unpin project",
|
||||
"Untitled, rename chat",
|
||||
"View",
|
||||
"Write case studies",
|
||||
"Write speech drafts",
|
||||
"analyze_project.py, py, 220 lines",
|
||||
"base_half_sheet.py, py, 32 lines",
|
||||
"changelog_viewer_component.py, py, 113 lines",
|
||||
"colors.py, py, 103 lines",
|
||||
"compensation.py, py, 50 lines",
|
||||
"components.py, py, 118 lines",
|
||||
"components.py, py, 119 lines",
|
||||
"config_reader.py, py, 120 lines",
|
||||
"contraction_tab.py, py, 105 lines",
|
||||
"contraction_tab.py, py, 82 lines",
|
||||
"conversions.py, py, 28 lines",
|
||||
"data_parser.py, py, 87 lines",
|
||||
"dialogs.py, py, 34 lines",
|
||||
"file_operations.py, py, 43 lines",
|
||||
"log.py, py, 140 lines",
|
||||
"log.py, py, 236 lines",
|
||||
"machines.ini, ini, 2 lines",
|
||||
"main.py, py, 203 lines",
|
||||
"main.py, py, 264 lines",
|
||||
"output_tab.py, py, 191 lines",
|
||||
"output_tab.py, py, 246 lines",
|
||||
"process_request.py, py, 632 lines",
|
||||
"processing_format.ini, ini, 2 lines",
|
||||
"setup_tab.py, py, 120 lines",
|
||||
"setup_tab.py, py, 177 lines",
|
||||
"sheet_dimensions.ini, ini, 3 lines",
|
||||
"version 0.1.0.md, md, 42 lines",
|
||||
"version 0.1.1.md, md, 31 lines",
|
||||
"version 0.1.2.md, md, 18 lines",
|
||||
"View all plans",
|
||||
"Get apps and extensions",
|
||||
"Gift Claude",
|
||||
"Language",
|
||||
"Get help",
|
||||
"Learn more",
|
||||
"Log out",
|
||||
"SettingsCtrl,"
|
||||
]
|
||||
}
|
||||
78
docs/testing/ui/README.md
Normal file
78
docs/testing/ui/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# UI Element Inventory
|
||||
|
||||
This directory holds per-surface UI checklists. Where [`../cases/`](../cases/) tests verify *behavior end-to-end*, files here verify *every UI element renders and responds* on Linux.
|
||||
|
||||
## Why a separate directory
|
||||
|
||||
A functional test like [T17 — Folder picker opens](../cases/code-tab-foundations.md#t17--folder-picker-opens) verifies the folder picker works. A UI checklist asks the smaller, more granular questions:
|
||||
|
||||
- Is the **Select folder** button visually present?
|
||||
- Does its hover state render?
|
||||
- Is the icon next to it the correct shape on a HiDPI screen?
|
||||
- Does it tab-focus correctly?
|
||||
- Does it have an accessible name (a11y)?
|
||||
|
||||
Functional tests catch "the feature broke." UI checklists catch "the feature works but looks wrong." Both matter on Linux because Electron under different DEs / display servers / GTK theme combinations produces visual artifacts that aren't behavioral failures.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Surface | Notes |
|
||||
|------|---------|-------|
|
||||
| [`window-chrome-and-tabs.md`](./window-chrome-and-tabs.md) | OS window frame + hybrid in-app topbar + Chat/Cowork/Code tabs | Crosses with [T04](../cases/tray-and-window-chrome.md#t04--window-decorations-draw), [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
|
||||
| [`tray.md`](./tray.md) | System tray icon + menu + theme variants | Crosses with [T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update) |
|
||||
| [`sidebar.md`](./sidebar.md) | Session sidebar in Code tab | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
| [`prompt-area.md`](./prompt-area.md) | Code-tab prompt input area | Crosses with [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
|
||||
| [`code-tab-panes.md`](./code-tab-panes.md) | Diff, preview, terminal, file, tasks, subagent, plan, side-chat | Crosses with [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens) |
|
||||
| [`settings.md`](./settings.md) | All Settings pages | Crosses with [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
|
||||
| [`routines-page.md`](./routines-page.md) | Routines list + new-routine form + detail page | Crosses with [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
|
||||
| [`connectors-and-plugins.md`](./connectors-and-plugins.md) | Connector picker, connector list, plugin browser, plugin manager | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| [`quick-entry.md`](./quick-entry.md) | Quick Entry popup window | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame) |
|
||||
| [`notifications.md`](./notifications.md) | libnotify rendering for all notification sources | Crosses with [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
|
||||
## Standard checklist row
|
||||
|
||||
Each UI file uses tables of the form:
|
||||
|
||||
| Element | Selector / location | Expected | Notes |
|
||||
|---------|---------------------|----------|-------|
|
||||
| Close button | Top-right of titlebar | Renders, hover state visible, click hides to tray (see T08) | KDE-W: ✓ |
|
||||
|
||||
Columns:
|
||||
|
||||
- **Element** — human-readable name.
|
||||
- **Selector / location** — DOM selector if known, otherwise plain-language pointer ("right-click menu, second item from top"). The selector column is what becomes a Playwright/CDP assertion when automation lands.
|
||||
- **Expected** — what the user should see / what should happen on click. Concise.
|
||||
- **Notes** — known issues, environment caveats, screenshot links.
|
||||
|
||||
## Sweep workflow
|
||||
|
||||
A UI sweep on a row:
|
||||
|
||||
1. Take a baseline screenshot of each surface (`scrot`, `gnome-screenshot`, `grim`, `flameshot`).
|
||||
2. Walk each table top-to-bottom. For each row, look at the element, click/hover/tab to it, compare against Expected.
|
||||
3. Mark anomalies in the **Notes** column or file an issue if the deviation is environment-specific.
|
||||
4. Save screenshots of any failure to a dated folder; reference them inline.
|
||||
|
||||
UI rows don't have stable IDs (`T##` / `S##`) — they're append-only checkpoints. When something becomes a regression candidate worth tracking long-term, promote it to a functional test in [`../cases/`](../cases/).
|
||||
|
||||
## Automation roadmap
|
||||
|
||||
Each UI checklist row is a candidate Playwright (via [Electron driver](https://playwright.dev/docs/api/class-electron)) or `xdotool` assertion:
|
||||
|
||||
```typescript
|
||||
// Playwright shape
|
||||
await page.locator('[data-testid="close-button"]').click()
|
||||
await expect(window).toBeHidden()
|
||||
```
|
||||
|
||||
Or for pure visual diffing:
|
||||
|
||||
```bash
|
||||
# scrot + perceptualdiff
|
||||
scrot -u baseline.png
|
||||
# ... interaction ...
|
||||
scrot -u current.png
|
||||
perceptualdiff baseline.png current.png
|
||||
```
|
||||
|
||||
The structure here is intentionally diff-friendly: rows are stable, tables are append-only, selectors live in their own column.
|
||||
114
docs/testing/ui/code-tab-panes.md
Normal file
114
docs/testing/ui/code-tab-panes.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# UI — Code Tab Panes
|
||||
|
||||
Drag-and-drop panes inside a Code-tab session: diff, preview, terminal, file editor, tasks, subagent, plan, side chat. Related functional tests: [T19](../cases/code-tab-foundations.md#t19--integrated-terminal), [T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves), [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane), [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh), [T31](../cases/code-tab-workflow.md#t31--side-chat-opens).
|
||||
|
||||
## Pane chrome (common)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Pane header | Top of pane | Shows pane title, drag handle, close button | — |
|
||||
| Drag handle | Pane header | Drag repositions the pane in the layout | — |
|
||||
| Resize handle | Edge between panes | Drag resizes; double-click resets | — |
|
||||
| Close pane button | Pane header right | `Cmd+\` or Ctrl+\\ shortcut equivalent | — |
|
||||
| Views menu | Session toolbar | Lists all openable panes; click to add | — |
|
||||
|
||||
## Diff pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Diff stats indicator | Chat / sidebar (entry point) | Shows `+12 -1` style. Click opens diff pane | — |
|
||||
| File list | Left side of pane | Lists changed files, click to navigate | — |
|
||||
| Diff content | Right side | Side-by-side or unified diff renders cleanly | Theme-aware (dark/light) |
|
||||
| Line click → comment box | Click any line | Opens inline comment input | — |
|
||||
| Comment submit (`Cmd+Enter` / `Ctrl+Enter`) | Press the shortcut after writing | Submits all comments at once | — |
|
||||
| Accept button | Per-file or per-hunk | Applies the change to disk | — |
|
||||
| Reject button | Per-file or per-hunk | Discards the change | — |
|
||||
| **Review code** button | Top-right of pane | Triggers Claude self-review of diff | — |
|
||||
|
||||
## Preview pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Preview dropdown | Session toolbar | Lists configured servers from `.claude/launch.json` | — |
|
||||
| **Start** action | Per-server entry | Launches the dev server | — |
|
||||
| **Stop** action | Per-server entry | Stops the dev server | — |
|
||||
| **Stop all servers** | Dropdown bottom | Stops every running server | — |
|
||||
| **Edit configuration** | Dropdown bottom | Opens `.claude/launch.json` in the file pane | — |
|
||||
| **Persist sessions** toggle | Dropdown | Persists cookies / localStorage across server restarts | — |
|
||||
| Embedded browser frame | Pane content | Renders the running app | Uses Electron `<webview>` or `BrowserView` |
|
||||
| URL bar / address | Top of pane | Shows current URL; editable | — |
|
||||
| Reload button | Top of pane | Reloads the embedded URL | — |
|
||||
| DevTools toggle | Top of pane (right) | Opens Electron DevTools for the embedded view | — |
|
||||
| Auto-verify screenshots | When Claude verifies a change | Brief overlay shows screenshot being captured | — |
|
||||
|
||||
## Terminal pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Terminal pane | Opened via `Ctrl+`` or Views menu | Bash/zsh/fish session in the working directory ([T19](../cases/code-tab-foundations.md#t19--integrated-terminal)) | Local sessions only |
|
||||
| Cursor | Inside terminal | Blinks; cursor shape per shell | — |
|
||||
| Resize | Drag pane edges | Terminal cols/rows update; `tput cols` reflects new width | SIGWINCH should fire |
|
||||
| Scrollback | Type many lines | Scrollable history; mouse scroll wheel works | — |
|
||||
| Color rendering | Run `ls --color=auto`, `tput colors` | 256-color or truecolor support; theme-aware | — |
|
||||
| Copy / paste | Select + `Ctrl+Shift+C` / `Ctrl+Shift+V` | Standard terminal-emulator shortcuts | — |
|
||||
| Working directory inheritance | Open pane in a session | Opens at the session's project folder | Confirm with `pwd` |
|
||||
|
||||
## File pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| File pane | Opened by clicking a file path | Shows file content, syntax-highlighted | — |
|
||||
| Save button | Pane toolbar | Writes current content to disk | — |
|
||||
| Path label | Pane header | Click copies absolute path | — |
|
||||
| On-disk-changed warning | If file changed externally after open | Banner with Override / Discard options ([T20](../cases/code-tab-foundations.md#t20--file-pane-opens-and-saves)) | — |
|
||||
| Discard button | When edits unsaved | Reverts to disk content | — |
|
||||
| Cursor / selection | Inside content | Renders correctly; multi-cursor not supported | — |
|
||||
| Find / replace | `Ctrl+F` | Opens find-in-file overlay | Verify scoped to current pane only |
|
||||
|
||||
## Tasks pane / subagent pane
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Tasks pane | Opened via Views menu | Lists subagents, background shell commands, workflows | — |
|
||||
| Task entry click | Click any task | Opens the subagent pane with output | — |
|
||||
| Stop task button | Per-task | Sends interrupt signal | — |
|
||||
| Task status indicator | Per-task | Running / Completed / Failed | — |
|
||||
| Output stream | Inside subagent pane | Live-updating stdout/stderr | — |
|
||||
|
||||
## Side chat overlay
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Side chat trigger | `Ctrl+;` or `/btw` in main prompt | Opens overlay attached to current session ([T31](../cases/code-tab-workflow.md#t31--side-chat-opens)) | — |
|
||||
| Side chat content | Overlay body | Reads main thread context; replies stay in side chat | — |
|
||||
| Close button | Overlay top-right | Closes side chat, returns focus to main session | — |
|
||||
|
||||
## CI status bar
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| CI status row | Below prompt area when PR open | Shows current check states | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
|
||||
| **Auto-fix** toggle | Top of CI bar | Toggles automatic check-failure fixes | — |
|
||||
| **Auto-merge** toggle | Top of CI bar | Toggles auto-merge on green | Requires GitHub repo setting |
|
||||
| Per-check entries | Each CI check | Shows pass / fail / pending state | Click to see logs |
|
||||
| CI completion notification | When all checks resolve | Desktop notification posted ([T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire)) | — |
|
||||
|
||||
## View modes
|
||||
|
||||
| Mode | Trigger | Expected | Notes |
|
||||
|------|---------|----------|-------|
|
||||
| Normal | Default; cycle via `Ctrl+O` | Tool calls collapsed into summaries, full text responses | — |
|
||||
| Verbose | Cycle via `Ctrl+O` | Every tool call, file read, intermediate step | Use for debugging |
|
||||
| Summary | Cycle via `Ctrl+O` | Only Claude's final responses + changes | Use when scanning many sessions |
|
||||
| Transcript view dropdown | Next to send button | Same as `Ctrl+O` | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Pane drag doesn't snap to layout zones | Layout engine state corruption; restart session | — |
|
||||
| Terminal cursor doesn't blink | `xterm-256color` not propagated; `TERM` env wrong | `echo $TERM` inside the pane |
|
||||
| File pane "Save" silently no-ops | Read-only filesystem ([S28](../cases/extensibility.md#s28--worktree-creation-surfaces-clear-error-on-read-only-mounts)); permissions wrong | `stat <file>` for ownership |
|
||||
| Preview pane embedded browser blank | Dev server didn't bind expected port; `autoPort` config | Check launcher log; `lsof -i :<port>` |
|
||||
| Auto-verify screenshots fail | Headless screenshot in embedded view broken on Wayland | Test on X11 row; report to upstream |
|
||||
| CI bar shows stale state | `gh` polling interval; rate-limited | `gh api rate_limit`; manual `gh pr checks <num>` |
|
||||
70
docs/testing/ui/connectors-and-plugins.md
Normal file
70
docs/testing/ui/connectors-and-plugins.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# UI — Connectors & Plugins
|
||||
|
||||
Connector picker, connectors list, plugin browser, plugin manager. Related functional tests: [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser), [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths).
|
||||
|
||||
## Connector picker (in-session)
|
||||
|
||||
Triggered by `+` → **Connectors** in the prompt area.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connectors menu | Opened from `+` button | Lists configured connectors + "Manage connectors" entry | — |
|
||||
| Per-connector row | Menu item | Name, status indicator (connected / not configured), action button | — |
|
||||
| **Manage connectors** entry | Bottom of menu | Opens Settings → Connectors | Crosses with [`settings.md`](./settings.md#connectors) |
|
||||
| Empty state | When no connectors configured | Helpful prompt with "Add connector" call to action | — |
|
||||
|
||||
## Connectors list (Settings → Connectors)
|
||||
|
||||
See [`settings.md`](./settings.md#connectors) for the surface.
|
||||
|
||||
## Add-connector flow
|
||||
|
||||
Triggered from the connector picker or Settings.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connector catalog | Modal body | Searchable list (Slack, GitHub, Linear, Notion, Google Calendar, etc.) | — |
|
||||
| Per-connector tile | Catalog entry | Logo, name, short description | — |
|
||||
| **Connect** button | Per tile | Initiates OAuth flow ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | Click → `xdg-open` to provider |
|
||||
| OAuth in-app overlay (if used) | Replaces system browser handoff in some flows | Embedded login pane | — |
|
||||
| Permission consent screen | OAuth provider side | Provider's UI; not under our control | — |
|
||||
| Callback completion | After OAuth completes | Returns to Claude Desktop, connector now in list | If the URL scheme handler is broken, user is stranded in browser |
|
||||
| Custom connector entry point | Catalog bottom | "Add custom connector via remote MCP" link | — |
|
||||
|
||||
## Plugin browser
|
||||
|
||||
Triggered by `+` → **Plugins** → **Add plugin**, or from sidebar **Customize** → **Plugins**.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Plugin browser modal | Opened from menu | Searchable marketplace catalog | — |
|
||||
| Marketplace selector | Top of modal | Default: Anthropic official; user-configured marketplaces also visible | — |
|
||||
| Per-plugin tile | Catalog body | Name, author, description, install count | — |
|
||||
| **Install** button | Per tile | Click installs to `~/.claude/plugins/` ([T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [S27](../cases/extensibility.md#s27--plugins-install-per-user-not-into-system-paths)) | — |
|
||||
| Plugin scope selector | Per install | User / Project / Local-only | — |
|
||||
| Install progress indicator | During install | Spinner + "Installing X..." text | — |
|
||||
| Install success state | After install | Confirmation; plugin now in **Manage plugins** | — |
|
||||
| Install error state | On failure | Error message identifying the cause (network, signature, conflict) | — |
|
||||
|
||||
## Manage plugins
|
||||
|
||||
Triggered by `+` → **Plugins** → **Manage plugins**.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Installed plugins list | Modal body | One row per installed plugin | — |
|
||||
| Per-plugin row | List item | Name, version, scope (User / Project / Local), enable toggle, uninstall button | — |
|
||||
| Enable toggle | Per row | Toggles plugin on/off without uninstall | — |
|
||||
| **Uninstall** button | Per row | Removes plugin files from `~/.claude/plugins/` | Confirmation expected |
|
||||
| Plugin skills sub-list | Expand row | Lists skills, agents, hooks, MCP servers, LSP configs the plugin contributes | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Connect OAuth doesn't return to app | Custom URI scheme not registered ([T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip)) | `xdg-mime query default x-scheme-handler/claude` |
|
||||
| Plugin browser empty | Marketplace fetch failed; offline | DevTools network panel |
|
||||
| Install progress stalls | Network / signature verification | Launcher log; check `~/.claude/plugins/.partial/` for incomplete downloads |
|
||||
| Plugin installed but skills don't appear | Slash menu cache stale; restart session | — |
|
||||
| Uninstall leaves files | Filesystem permissions; some plugin files owned by root | `find ~/.claude/plugins/ -not -user $USER` |
|
||||
| Connector "Connected" but tools fail | Token expired; backend refuses; needs reconnect | Disconnect → reconnect |
|
||||
59
docs/testing/ui/notifications.md
Normal file
59
docs/testing/ui/notifications.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# UI — Desktop Notifications
|
||||
|
||||
Notification rendering across DEs. The app dispatches notifications via `org.freedesktop.Notifications` (libnotify spec); each DE renders them differently. Related functional tests: [T23](../cases/code-tab-handoff.md#t23--desktop-notifications-fire), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
|
||||
|
||||
## Notification sources
|
||||
|
||||
The app posts notifications for the following events. Each should fire reliably on every supported DE.
|
||||
|
||||
| Source | Trigger | Expected text | Click action | Notes |
|
||||
|--------|---------|---------------|--------------|-------|
|
||||
| Scheduled task fires | When a routine starts a run | "Scheduled task `<name>` started" or similar | Focus the new session in sidebar | Crosses with [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies) |
|
||||
| Catch-up run | When a missed run starts after wake | "Catching up on `<name>`" + missed-time hint | Focus the catch-up session | Crosses with [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend) |
|
||||
| CI status change | When PR's CI state resolves | "CI passed for `<branch>`" or "CI failed: `<check>`" | Focus the session with CI bar | Crosses with [T22](../cases/code-tab-workflow.md#t22--pr-monitoring-via-gh) |
|
||||
| PR merged (auto-archive trigger) | When watched PR merges | "PR `<title>` merged. Session archived" | — | Crosses with [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge) |
|
||||
| Dispatch handoff | When a Dispatch task creates a Code session | "Dispatch session ready: `<task>`" | Focus the new Dispatch-badged session | Crosses with [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification) |
|
||||
| Permission prompt awaiting approval | When a session in Ask mode needs user approval | "Claude needs your approval" | Focus the awaiting session | Sessions in Ask mode stall until answered |
|
||||
|
||||
## Per-notification anatomy
|
||||
|
||||
Each notification should include:
|
||||
|
||||
| Element | Expected | Notes |
|
||||
|---------|----------|-------|
|
||||
| App identity | "Claude" or "Claude Desktop" as the source | DE-specific (Plasma shows the app name and icon prominently) |
|
||||
| Notification icon | App icon (theme-aware) | Should match the same icon set as the tray |
|
||||
| Title | Short event headline | One line, no truncation issues for typical lengths |
|
||||
| Body | One or two short lines of context | Wrap correctly for the DE's notification width |
|
||||
| Actions (if any) | Inline buttons (e.g. "Open", "Dismiss") | Some DEs show actions, some require expand |
|
||||
| Click target | Activates the relevant session/window | — |
|
||||
|
||||
## Per-DE rendering
|
||||
|
||||
| DE / daemon | Expected render | Caveats |
|
||||
|-------------|-----------------|---------|
|
||||
| KDE Plasma | KDE notification daemon (KNotifications); appears top-right by default; inline action buttons supported | — |
|
||||
| GNOME Shell | gnome-shell built-in; appears top-center; limited action support | — |
|
||||
| Mako (wlroots) | Stacked notifications top-right by default; supports actions if config allows | — |
|
||||
| Dunst | Lightweight; respects `~/.config/dunst/dunstrc`; actions via keybinds | — |
|
||||
| swaync (Sway) | Notification center + popups | — |
|
||||
| Niri | Compositor-provided; usually a portable daemon (mako, dunst) | — |
|
||||
|
||||
## Notification persistence
|
||||
|
||||
| Element | Expected | Notes |
|
||||
|---------|----------|-------|
|
||||
| Notification history | DE-dependent (KDE has notification panel; GNOME has Calendar drawer; mako/dunst can be configured) | Don't rely on persistence — assume fire-and-forget |
|
||||
| Do-not-disturb mode | Respect DE's DND state | If user has DND on, notifications shouldn't fire — verify the daemon honors this |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| No notifications appear | No daemon running; service not registered | `gdbus call --session --dest=org.freedesktop.Notifications --object-path=/org/freedesktop/Notifications --method=org.freedesktop.DBus.Introspectable.Introspect`; `notify-send "test"` from terminal |
|
||||
| Notification fires but no icon | Icon path resolution failed; theme strip | Inspect the dbus call body for `app_icon` value |
|
||||
| Click does nothing | Action handler IPC missed; window already focused | Click while main window is hidden — does it appear? |
|
||||
| Title/body cut off | DE truncation policy | Test with shorter strings to confirm content vs. layout |
|
||||
| Notifications fire even in DND | Daemon ignoring DND, or our app sets `urgency=critical` inappropriately | Check `urgency` hint in the dbus call |
|
||||
| Notification persists indefinitely | `expire_timeout=-1` (never) used inappropriately | Confirm timeout passed in the dbus call |
|
||||
| Per-source duplicates | Multiple subscribers to the same event | Diagnose by isolating one source at a time |
|
||||
76
docs/testing/ui/prompt-area.md
Normal file
76
docs/testing/ui/prompt-area.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# UI — Code Tab Prompt Area
|
||||
|
||||
The prompt input area is where users type messages, attach files, pick model and permission mode, and trigger send/stop. Related functional tests: [T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt), [T32](../cases/code-tab-workflow.md#t32--slash-command-menu).
|
||||
|
||||
## Text input
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Input field | Bottom center of session pane | Single-line on focus, expands to multi-line as user types | — |
|
||||
| Placeholder text | Empty state | Helpful hint ("Type to message Claude...") | — |
|
||||
| Cursor caret | Inside input | Blinks; visible against any background | — |
|
||||
| Multi-line autosize | Type a long message | Input grows up to a max height, then scrolls | — |
|
||||
| Word wrap | Long text | Wraps at field width without horizontal scroll | — |
|
||||
| Paste plain text | `Ctrl+V` after copying text | Inserts at cursor | — |
|
||||
| Paste image | `Ctrl+V` after copying an image | Attaches as file (see attachments below) | — |
|
||||
| `Enter` to send | Press Enter | Submits prompt | — |
|
||||
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | — |
|
||||
| `Esc` | Press Esc when prompt has content | DE-dependent; typically does nothing in input | — |
|
||||
| IME composition | Compose a CJK character | Composition UI renders correctly above the input | Fcitx5/IBus integration |
|
||||
|
||||
## Attachments
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Attachment button | Left of input (paperclip icon) | Click opens native file chooser | Wayland: portal-backed |
|
||||
| File-attached chip | Above or inside input | Shows filename + remove (X) button | — |
|
||||
| Multiple attachments | Attach 3+ files | Each shows as a separate chip; stacked if needed | — |
|
||||
| Image preview thumbnail | Image attachments | Shows small thumbnail | — |
|
||||
| PDF preview | PDF attachments | Shows generic PDF icon + filename | — |
|
||||
| Drag-drop overlay | Drag a file from file manager into the prompt | Overlay highlight indicates drop zone; release attaches ([T18](../cases/code-tab-foundations.md#t18--drag-and-drop-files-into-prompt)) | — |
|
||||
| `@filename` autocomplete | Type `@` in prompt | Dropdown shows matching project files | Local and SSH only |
|
||||
|
||||
## `+` menu (skills, plugins, connectors)
|
||||
|
||||
| Element | Position in menu | Expected | Notes |
|
||||
|---------|------------------|----------|-------|
|
||||
| `+` button | Adjacent to attachment button | Click opens menu | — |
|
||||
| **Slash commands** entry | Top of menu | Opens slash command picker (same as typing `/`) | Crosses with [T32](../cases/code-tab-workflow.md#t32--slash-command-menu) |
|
||||
| **Skills** entry | Mid-menu | Opens skill browser | — |
|
||||
| **Connectors** entry | Mid-menu | Opens connector picker / status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| **Plugins** entry | Mid-menu | Opens installed plugin list | Crosses with [T11](../cases/extensibility.md#t11--plugin-install-anthropic--partners), [T33](../cases/extensibility.md#t33--plugin-browser) |
|
||||
| **Add plugin** subentry | Under Plugins | Opens plugin browser | — |
|
||||
|
||||
## Slash menu (triggered by typing `/`)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Menu container | Above prompt input | Modal-like overlay, scrollable | — |
|
||||
| Built-in commands section | Top of list | Lists `/btw`, `/compact`, etc. | — |
|
||||
| Project skills section | Mid-list | Lists skills from `.claude/skills/` | — |
|
||||
| User skills section | Mid-list | Lists skills from `~/.claude/skills/` | — |
|
||||
| Plugin skills section | Bottom-list | Lists skills from installed plugins | — |
|
||||
| Filter by typing | Type after `/` | Narrows the list | — |
|
||||
| Selected item insertion | `Enter` or click | Inserts highlighted token in prompt | — |
|
||||
| `Esc` to dismiss | Press Esc | Closes menu, keeps `/` typed | — |
|
||||
|
||||
## Pickers next to send button
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Model picker | Right of input | Dropdown of Sonnet, Opus, Haiku (per current plan availability) | `Cmd+Shift+I` opens |
|
||||
| Permission mode picker | Right of input | Dropdown of Ask, Auto accept, Plan, Auto, Bypass | `Cmd+Shift+M` opens |
|
||||
| Effort picker (when applicable) | Right of input | Dropdown of effort levels for adaptive-reasoning models | `Cmd+Shift+E` opens |
|
||||
| Send button | Far right | Click submits prompt | — |
|
||||
| Stop button | Replaces Send while Claude responding | Click interrupts current response | `Esc` shortcut equivalent |
|
||||
| Usage ring | Adjacent to model picker | Shows context window usage + plan usage | Click for details |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Drag-drop overlay doesn't appear | Electron drag-drop event not firing on Wayland | Try X11 fallback to isolate |
|
||||
| `@filename` autocomplete returns empty | Project-folder access not granted; folder picker [T17](../cases/code-tab-foundations.md#t17--folder-picker-opens) failed silently | Verify env pill shows the right folder |
|
||||
| Slash menu shows wrong skills | Settings shared between desktop and CLI ([T36](../cases/extensibility.md#t36--hooks-fire), [T37](../cases/extensibility.md#t37--claudemd-memory-loads)) | Check `~/.claude/skills/` content vs what's listed |
|
||||
| Send button greyed out unexpectedly | Permission mode or model not loaded | Refresh; check model dropdown |
|
||||
| IME composition broken | Electron IME pipeline regression | Test with simpler Electron app |
|
||||
49
docs/testing/ui/quick-entry.md
Normal file
49
docs/testing/ui/quick-entry.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# UI — Quick Entry Popup
|
||||
|
||||
The Quick Entry popup is the global-shortcut-triggered prompt overlay. Related functional tests: [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S09](../cases/shortcuts-and-input.md#s09--quick-window-patch-runs-only-on-kde-post-406-gate), [S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame), [S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity), [S33](../cases/shortcuts-and-input.md#s33--quick-entry-transparent-rendering-tracked-against-bundled-electron-version), [S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone), [S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy).
|
||||
|
||||
## Window appearance
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Window frame | None (frameless popup) | No OS-titlebar; no close/min/max buttons | Upstream sets `frame: false` on the BrowserWindow (`index.js:515381`) |
|
||||
| Background | Behind prompt UI | Transparent (no opaque square frame visible) on KDE Plasma Wayland ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | Upstream already sets both `transparent: true` and `backgroundColor: "#00000000"` (`index.js:515380, 515383`). #370 regression is below the option-passing layer (Electron 41.0.4 CSD rework). KDE-W: pending; bug if opaque |
|
||||
| Rounded corners | Outer edge of UI | Visible | Compositor must support corner rounding via shaders / clip mask |
|
||||
| Drop shadow | Around popup | macOS-only at the Electron level; on Linux/Windows depends entirely on compositor | Upstream sets `hasShadow: Zr` where `Zr === process.platform === "darwin"` (`index.js:515384`). Linux is expected to render via compositor shadow support; wlroots without server-side decorations will not show one |
|
||||
| Position | Last-saved position, keyed on monitor; falls back to primary display if monitor is gone | Popup remembers its position across invocations and across app restarts ([S35](../cases/shortcuts-and-input.md#s35--quick-entry-popup-position-is-persisted-across-invocations-and-across-app-restarts), [S36](../cases/shortcuts-and-input.md#s36--quick-entry-popup-falls-back-to-primary-display-when-saved-monitor-is-gone)) | Upstream uses `an.get("quickWindowPosition")` (`index.js:515491-515526`) keyed on monitor label + resolution. Falls back to `cHn()` (`:515502`) when the saved monitor is gone. **Upstream does NOT place on cursor display or focused-window display** — it's last-position or primary, nothing else |
|
||||
| Always-on-top | Window manager hint | Stays above other windows | Upstream sets `alwaysOnTop: true` with level `"pop-up-menu"` (`index.js:515399`). On macOS this is per-app; on Linux compositors the level hint is interpreted variably |
|
||||
| Lifecycle | Lazy-created on first shortcut press | First shortcut press constructs the BrowserWindow; subsequent presses reuse it ([S29](../cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity)) | Upstream `if (!Ko \|\| ...) Ko = new BrowserWindow(...)` near `index.js:515375`. Means popup works in tray-only state with no main window mapped |
|
||||
| Persistence after main window destroy | Popup survives `mainWindow.destroy()` | Popup remains functional; submit guards skip show/focus when `ut` is destroyed ([S37](../cases/shortcuts-and-input.md#s37--quick-entry-popup-remains-functional-after-main-window-destroy)) | Upstream `!ut \|\| ut.isDestroyed()` guard at `index.js:515595`. Likely unreachable on this project due to hide-to-tray override of X button |
|
||||
|
||||
## Input area
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Text input field | Center of popup | Receives focus immediately on open; cursor blinks | — |
|
||||
| Placeholder text | Empty input state | Shows guidance like "Ask Claude anything..." | — |
|
||||
| Multi-line autosize | Type a long prompt | Input grows downward as text wraps; popup grows with it | — |
|
||||
| `Enter` to submit | Press Enter | Sends prompt, closes popup. Prompt must be > 2 chars trimmed (`index.js:515530, 515533`); 1-2 char prompts are silently dropped | Renderer-side keymap; reaches main process via IPC `requestDismissWithPayload()` (`:515409`) |
|
||||
| `Shift+Enter` for newline | Press Shift+Enter | Inserts newline, doesn't submit | Renderer-side |
|
||||
| `Esc` to dismiss | Press Esc | Closes popup without submitting | Renderer-side; reaches main process via IPC `requestDismiss()` (`:515409`) |
|
||||
| Click outside | Click outside the popup window | Closes popup without submitting | Wired in **main process** via the popup's `blur` handler (`Ko.on("blur", () => g3A(null))` at `index.js:515465`) |
|
||||
| Paste behavior | Paste rich text | Text-only paste; no HTML residue | — |
|
||||
| IME / dead-key composition | Type composed characters | Composition UI renders correctly above the input | Fcitx5/IBus integration is fragile under Electron |
|
||||
|
||||
## Submit feedback
|
||||
|
||||
| Element | Trigger | Expected | Notes |
|
||||
|---------|---------|----------|-------|
|
||||
| Submit transition | Press Enter | Popup closes; main window navigates to a **new** chat session ([S31](../cases/shortcuts-and-input.md#s31--quick-entry-submit-makes-the-new-chat-reachable-from-any-main-window-state)). Quick Entry never appends to existing chats — `ynt(e)` at `index.js:515546` always creates new | Upstream calls `mainWin.show()` + `mainWin.focus()` only — no `restore()`, no workspace migration. Behavior on minimized / hidden / cross-workspace main is compositor-dependent |
|
||||
| Loading indicator | While prompt is in flight | Brief spinner or fade-out — popup should not appear frozen | — |
|
||||
| Error state | Submit when offline / API error | Inline error message; popup stays open so user can retry | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| Popup doesn't appear when shortcut pressed | Global shortcut not registered ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused), [S11](../cases/shortcuts-and-input.md#s11--quick-entry-shortcut-fires-from-any-focus-on-wayland-mutter-xwayland-key-grab), [S14](../cases/shortcuts-and-input.md#s14--global-shortcuts-via-xdg-portal-work-on-niri)) | Launcher log; portal `BindShortcuts` outcome |
|
||||
| Opaque square frame visible behind UI | Transparent background not respected ([S10](../cases/shortcuts-and-input.md#s10--quick-entry-popup-is-transparent-no-opaque-square-frame)) | KDE compositor settings; BrowserWindow `transparent: true` arg |
|
||||
| Popup appears but input doesn't auto-focus | Focus stealing prevention by compositor; race in BrowserWindow `show()` + `focus()` | Wayland focus-request semantics; mutter is most strict |
|
||||
| IME composition cursor renders in wrong place | Electron IME integration bug | Try with simpler GTK app to isolate; report upstream Electron issue if reproducible |
|
||||
| Popup persists after submit | Close-on-submit IPC missed | Launcher log; DevTools console (if reachable on the popup window) |
|
||||
| Popup appears on wrong monitor / wrong workspace | Compositor places frameless windows differently | Test with `xdotool getactivewindow` (X11) before/after |
|
||||
72
docs/testing/ui/routines-page.md
Normal file
72
docs/testing/ui/routines-page.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# UI — Routines Page
|
||||
|
||||
The Routines page hosts the list of scheduled tasks (local and remote), the new-routine form, and per-routine detail views. Related functional tests: [T26](../cases/routines.md#t26--routines-page-renders), [T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies), [T28](../cases/routines.md#t28--scheduled-task-catch-up-after-suspend).
|
||||
|
||||
## Routines list
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Routines page link | Code-tab sidebar | Click opens the page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
|
||||
| Page header | Top of page | Title "Routines" + description | — |
|
||||
| **New routine** button | Top-right of page | Click shows Local / Remote selector | — |
|
||||
| Routines list | Page body | Lists all configured routines | — |
|
||||
| Per-routine row | List item | Name, schedule summary, last-run timestamp, status indicator | — |
|
||||
| Run-now icon | Per row, hover-revealed | Click triggers immediate run ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | — |
|
||||
| Pause / resume toggle | Per row | Pauses or resumes scheduled runs without deleting | — |
|
||||
| Click row | Per row | Opens routine detail page | — |
|
||||
|
||||
## New routine form (Local)
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Routine type selector | Top of form | Local / Remote tabs or radio | — |
|
||||
| **Name** field | Top of form | Required; converted to lowercase kebab-case for filesystem | — |
|
||||
| **Description** field | Below name | Optional one-liner shown in list | — |
|
||||
| **Instructions** textarea | Mid-form | Rich textarea for the prompt | — |
|
||||
| Permission mode picker | Within Instructions area | Same options as session: Ask, Auto accept, Plan, Auto, Bypass | — |
|
||||
| Model picker | Within Instructions area | Sonnet, Opus, Haiku per plan | — |
|
||||
| **Working folder** picker | Below Instructions | Required; opens native file chooser | If folder not yet trusted, app prompts to trust |
|
||||
| **Worktree** toggle | Below folder | When ON, each run gets its own isolated worktree | — |
|
||||
| **Schedule** preset | Bottom of form | Manual / Hourly / Daily / Weekdays / Weekly | — |
|
||||
| Time picker | Visible for Daily, Weekdays, Weekly | Defaults to 9:00 AM local | — |
|
||||
| Day picker | Visible for Weekly only | Day-of-week selector | — |
|
||||
| **Save** button | Bottom-right | Disabled until required fields filled | — |
|
||||
| **Cancel** button | Bottom-left | Discards form, returns to list | — |
|
||||
| Folder-trust prompt | Triggered when folder not trusted | Modal asking to trust the selected folder | Required before save |
|
||||
|
||||
## New routine form (Remote)
|
||||
|
||||
Per upstream docs, remote routines run on Anthropic-managed cloud infrastructure. The form has additional fields for connectors and trigger types (cron, API, GitHub event). On Linux, the Remote tab should function identically to other platforms.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Trigger type selector | Top of form | Schedule / API call / GitHub event | — |
|
||||
| Connectors picker | Per-routine basis (remote) | Configures connectors at routine creation | — |
|
||||
| Network access controls | If applicable | Tied to cloud environment config | — |
|
||||
|
||||
## Routine detail page
|
||||
|
||||
Per upstream docs.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Run now** button | Top of page | Starts the task immediately | — |
|
||||
| Status toggle (Active / Paused) | Top of page | Pauses or resumes without deleting | — |
|
||||
| **Edit** button | Top of page | Opens the same form populated with current values | — |
|
||||
| **Delete** button | Top of page (or footer) | Removes routine; archives all sessions it created | Confirmation dialog expected |
|
||||
| **Review history** section | Page body | Lists every past run with timestamp and status | — |
|
||||
| Per-history-entry hover | Hover skipped runs | Tooltip explains why skipped (asleep, prior run still running, other concurrent task) | — |
|
||||
| **Show more** button | Bottom of history | Loads older entries | — |
|
||||
| **Always allowed** panel | Page body | Lists tools auto-approved for this routine | — |
|
||||
| Revoke approval | Per-tool entry | Removes the auto-approval | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Folder-trust modal doesn't appear | Trust state cached incorrectly | Clear `~/.claude/trusted-folders` (or equivalent) and retry |
|
||||
| Save button never enables | Required fields validation regression | DevTools console |
|
||||
| Time picker truncates / clips | Modal sizing on small viewports | Resize Settings window to reproduce |
|
||||
| History tooltips don't render | Tooltip component regression | — |
|
||||
| Run-now does nothing | Task runner thread not started | Launcher log; `pgrep -af claude` for runner subprocess |
|
||||
| Routines page blank | Code-tab failure ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) cascading | Confirm Code tab itself loads first |
|
||||
87
docs/testing/ui/settings.md
Normal file
87
docs/testing/ui/settings.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# UI — Settings
|
||||
|
||||
The Settings window holds Desktop app preferences, Claude Code settings, connector management, and account controls. Related functional tests: [S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend), [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge).
|
||||
|
||||
## Settings root
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Settings window | Opened via app menu, tray menu, or in-app shortcut | Window opens with sidebar nav and content area | — |
|
||||
| Window close button | Top-right (or top-left on GNOME) | Closes settings; main app continues running | — |
|
||||
| Sidebar nav | Left of window | Lists every settings page | — |
|
||||
|
||||
## Desktop app → General
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Computer use** toggle | Top of page | Either absent on Linux, or rendered disabled with a "not supported on Linux" hint ([S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux)) | Critical: must not appear functional |
|
||||
| **Keep computer awake** toggle | Mid-page | Toggles `systemd-inhibit --what=idle:sleep` lock ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify with `systemd-inhibit --list` |
|
||||
| **Denied apps** list | Computer-use related | Likely absent on Linux (computer use unsupported) | — |
|
||||
| **Unhide apps when Claude finishes** toggle | Computer-use related | Likely absent on Linux | — |
|
||||
| Theme picker (if exposed) | Mid-page | System / Light / Dark | Tray icon should respond ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) |
|
||||
|
||||
## Desktop app → Account
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Account name / email | Top of page | Reflects signed-in identity | — |
|
||||
| Plan badge | Below name | Shows Pro / Max / Team / Enterprise | — |
|
||||
| Sign out button | Bottom of page | Signs out cleanly; subsequent launches show sign-in screen | — |
|
||||
|
||||
## Claude Code
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Worktree location** | Top of page | Default: `<project-root>/.claude/worktrees/`. Editable to a custom directory | Crosses with [T29](../cases/code-tab-workflow.md#t29--worktree-isolation) |
|
||||
| **Branch prefix** | Mid-page | Optional prefix prepended to every worktree branch | — |
|
||||
| **Auto-archive after PR merge or close** toggle | Mid-page | When ON, sessions archive on PR resolution ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | — |
|
||||
| **Persist preview sessions** toggle | Mid-page | Toggles cookies/localStorage persistence in Preview pane | Crosses with [T21](../cases/code-tab-workflow.md#t21--dev-server-preview-pane) |
|
||||
| **Preview** toggle | Mid-page | When OFF, preview pane and auto-verify are disabled | — |
|
||||
| **Allow bypass permissions mode** toggle | Mid-page | When ON, exposes Bypass mode in mode picker | Enterprise admins can disable |
|
||||
| **Auto** mode availability | Mid-page | Research preview; not on Pro plans | Per upstream docs |
|
||||
|
||||
## Connectors
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Connectors list | Page content | Lists connected services with status | Crosses with [T34](../cases/code-tab-handoff.md#t34--connector-oauth-round-trip) |
|
||||
| Per-connector entry | List row | Name, last-connected timestamp, manage / disconnect buttons | — |
|
||||
| **Manage** button | Per row | Opens connector-specific settings | — |
|
||||
| **Disconnect** button | Per row | Revokes access; connector becomes unusable in subsequent sessions | — |
|
||||
| **Add connector** button | Top of page | Opens the connector picker (same surface as `+ → Connectors`) | — |
|
||||
|
||||
## SSH connections
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| SSH connections list | Page content | Lists user-added + managed (read-only) connections | — |
|
||||
| **Add SSH connection** button | Top of page | Opens dialog with Name / SSH Host / SSH Port / Identity File fields | — |
|
||||
| Per-connection entry | List row | Edit / delete (user-added) or "Managed" badge (admin-distributed) | — |
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Shortcut list | Page content | Tabular list of all configurable shortcuts | — |
|
||||
| Shortcut value | Per row | Click to rebind; shows current binding | — |
|
||||
| Reset to default | Per row | Reverts to upstream default | — |
|
||||
| Quick Entry shortcut | Specifically called out | Default `Ctrl+Alt+Space`; rebind here | Crosses with [T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
|
||||
|
||||
## Local environment editor
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Env editor open | Environment dropdown → Local → gear icon | Opens encrypted env-var editor | Crosses with [S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot) |
|
||||
| Add variable | In editor | Name + value fields; save | — |
|
||||
| Remove variable | Per row | Deletes the variable | — |
|
||||
| **Apply to dev servers** indicator | Near save | Confirms vars also reach preview servers | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Computer-use toggle visible and toggleable on Linux | [S22](../cases/platform-integration.md#s22--computer-use-toggle-is-absent-or-visibly-disabled-on-linux) regression | File a bug; users will be misled |
|
||||
| Keep-computer-awake toggle has no effect | `systemd-inhibit` integration not wired ([S20](../cases/routines.md#s20--keep-computer-awake-inhibits-idle-suspend)) | Verify lock list before/after |
|
||||
| Worktree location field rejects valid paths | Path validation too strict; absolute vs `~`-prefixed | Check both forms |
|
||||
| SSH connection list missing managed entries | Managed-settings file not loaded; admin distribution failed | Confirm file exists at expected path |
|
||||
| Env editor not encrypting | Linux secret-store not wired ([S18](../cases/platform-integration.md#s18--local-environment-editor-persists-across-reboot)) | `secret-tool search`; `kwallet5-query` |
|
||||
55
docs/testing/ui/sidebar.md
Normal file
55
docs/testing/ui/sidebar.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# UI — Code Tab Sidebar
|
||||
|
||||
The sidebar lists Code-tab sessions, lets you filter, group, archive, and rename. Related functional tests: [T29](../cases/code-tab-workflow.md#t29--worktree-isolation), [T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge), [S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification).
|
||||
|
||||
## Top controls
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **+ New session** button | Top of sidebar | Click opens a new session against the currently selected env. `Ctrl+N` shortcut equivalent | — |
|
||||
| **Routines** link | Top of sidebar | Click opens the Routines page ([T26](../cases/routines.md#t26--routines-page-renders)) | — |
|
||||
| **Customize** link | Top of sidebar | Click opens connectors / skills / plugins manager | — |
|
||||
| Filter: status | Top of session list | Dropdown / tabs filter by Active / Archived / All | — |
|
||||
| Filter: project | Top of session list | Dropdown filters by project (multi-select) | — |
|
||||
| Filter: environment | Top of session list | Dropdown filters by Local / Remote / SSH / All | — |
|
||||
| Group-by control | Top of session list | Toggle between flat list and grouped-by-project | — |
|
||||
|
||||
## Session row
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Session title | Row content | Shows session name (auto-generated or user-renamed) | Click row → switches to that session |
|
||||
| Session status indicator | Left of title or as colored dot | Reflects state: idle, running, awaiting-approval, errored, archived | — |
|
||||
| Project / branch label | Below title | Shows project folder name + branch | — |
|
||||
| Diff stats badge (e.g. `+12 -1`) | Right of title | Visible when session has uncommitted changes | Click → opens diff view |
|
||||
| **Dispatch** badge | Top-right of row | Visible on Dispatch-spawned sessions ([S24](../cases/platform-integration.md#s24--dispatch-spawned-code-session-appears-with-badge-and-notification)) | — |
|
||||
| **Scheduled** badge | Top-right of row | Visible on scheduled-task-spawned sessions ([T27](../cases/routines.md#t27--scheduled-task-fires-and-notifies)) | Sessions group under "Scheduled" header |
|
||||
| Hover archive icon | Right side, on row hover | Click archives the session and removes its worktree | — |
|
||||
| Right-click context menu | Right-click on row | Standard menu: Rename, Archive, Open in Files, Copy path | — |
|
||||
| Active session highlight | Selected row | Visually distinct from inactive rows | — |
|
||||
|
||||
## Sidebar layout
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Sidebar resize handle | Right edge of sidebar | Drag to resize; double-click to reset width | — |
|
||||
| Sidebar collapse toggle | Top of sidebar (hamburger or arrow) | Collapse to icons-only or hide entirely | Crosses with topbar hamburger |
|
||||
| Scrollbar | Right edge when content exceeds height | Renders, drags work | Theme-aware |
|
||||
|
||||
## Cycling shortcuts
|
||||
|
||||
| Shortcut | Expected | Notes |
|
||||
|----------|----------|-------|
|
||||
| `Ctrl+Tab` | Cycle to next session | Per upstream docs |
|
||||
| `Ctrl+Shift+Tab` | Cycle to previous session | Per upstream docs |
|
||||
| `Cmd+Shift+]` / `Cmd+Shift+[` | Same as above on macOS | N/A on Linux unless rebound |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Notes |
|
||||
|---------|--------------|-------|
|
||||
| Sidebar doesn't render | Code tab failed to load ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | Check DevTools console |
|
||||
| Sessions appear but clicking does nothing | IPC between sidebar and session pane broken | Launcher log, DevTools console |
|
||||
| Hover archive icon never appears | CSS hover state mis-applied; touch device might be assumed | Inspect element; check pointer events |
|
||||
| Dispatch / Scheduled badges missing | Feature flag or state not reaching the renderer | Check session metadata in launcher log |
|
||||
| Auto-archive doesn't fire | Session-archive logic bug ([T30](../cases/code-tab-workflow.md#t30--auto-archive-on-pr-merge)) | Confirm setting enabled; check PR state via `gh pr view` |
|
||||
44
docs/testing/ui/tray.md
Normal file
44
docs/testing/ui/tray.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# UI — System Tray
|
||||
|
||||
Tray icon, menu, and theme variants. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests ([T03](../cases/tray-and-window-chrome.md#t03--tray-icon-present), [S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)).
|
||||
|
||||
## Tray icon
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Tray icon (light theme) | System tray / status area | Black icon (the "Template" variant) renders cleanly on a light tray | — |
|
||||
| Tray icon (dark theme) | System tray / status area | White icon (the "Template-Dark" variant) renders cleanly on a dark tray | — |
|
||||
| Theme switch | Trigger system theme change | Icon updates in place — no duplicate icons spawned ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | KDE-W ✓ via in-place fast-path |
|
||||
| Icon resolution / sharpness | Inspect at native scale | Icon is crisp, not pixelated. Check on HiDPI screens | — |
|
||||
| Position | Tray area | Appears among other SNI/tray icons | KDE Plasma sorts alphabetically by ID; adjusting position requires user config |
|
||||
| Tooltip on hover | Hover over icon | Shows "Claude" or app name | — |
|
||||
|
||||
## Right-click menu
|
||||
|
||||
| Element | Position in menu | Expected | Notes |
|
||||
|---------|------------------|----------|-------|
|
||||
| Show / Hide window | Top item | Toggles main window visibility | Label may change between "Show" and "Hide" based on state |
|
||||
| Quick Entry | Mid-menu | Opens Quick Entry popup ([T06](../cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused)) | — |
|
||||
| Open at Login (toggle) | Mid-menu | Reflects current XDG autostart state ([T09](../cases/platform-integration.md#t09--autostart-via-xdg)) | Toggle should write `~/.config/autostart/*.desktop` |
|
||||
| Settings | Mid-menu | Opens Settings window | — |
|
||||
| About | Bottom area | Opens About dialog | — |
|
||||
| Quit | Bottom item | Fully exits the app (no hide-to-tray) | — |
|
||||
| Menu separators | Between item groups | Render cleanly | — |
|
||||
|
||||
## Left-click behavior
|
||||
|
||||
| Element | Trigger | Expected | Notes |
|
||||
|---------|---------|----------|-------|
|
||||
| Single left-click | Click tray icon once | Toggles main window visibility | KDE-W ✓ |
|
||||
| Double left-click | Click twice quickly | DE-dependent; should not spawn duplicate windows | — |
|
||||
| Middle-click | Middle mouse button on tray icon | DE-dependent (no documented behavior); should not crash | — |
|
||||
|
||||
## Failure modes to watch for
|
||||
|
||||
| Symptom | Likely cause | Diagnose with |
|
||||
|---------|--------------|---------------|
|
||||
| Tray icon never appears | No SNI watcher (e.g. GNOME without AppIndicator extension); Electron fallback to legacy XEmbed not registered | `gdbus call ... org.kde.StatusNotifierWatcher` — see [runbook](../runbook.md#tray--dbus-state-kde) |
|
||||
| Two tray icons after theme switch | Tray rebuild race ([S08](../cases/tray-and-window-chrome.md#s08--tray-icon-doesnt-duplicate-after-nativetheme-update)) | SNI watcher state before/after; [`docs/learnings/tray-rebuild-race.md`](../../learnings/tray-rebuild-race.md) |
|
||||
| Icon renders as a generic placeholder | Icon path resolution failed; theme mismatch | Check Electron `Tray` constructor args; check `~/.cache/claude-desktop-debian/launcher.log` |
|
||||
| Menu items don't respond | IPC bridge to tray menu broken; main process busy | Click main window — does the rest of the app respond? `pgrep -af claude`; main process state |
|
||||
| Tray icon disappears after some time | Tray daemon restarted; Claude didn't re-register | KDE Plasma: restart `plasmashell`; observe whether icon comes back without restarting Claude |
|
||||
58
docs/testing/ui/window-chrome-and-tabs.md
Normal file
58
docs/testing/ui/window-chrome-and-tabs.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# UI — Window Chrome & Tabs
|
||||
|
||||
OS-level window frame plus the in-app tab strip and (PR #538) hybrid in-app topbar. See [`../cases/tray-and-window-chrome.md`](../cases/tray-and-window-chrome.md) for related functional tests.
|
||||
|
||||
## OS window frame
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Title bar | Top of window | Drawn by DE/compositor; shows app title; right-click opens window menu | KDE-W ✓; Hypr-N ✓ |
|
||||
| Close button (X) | Top-right (or top-left on GNOME) | Renders, hover state visible, click hides-to-tray ([T08](../cases/tray-and-window-chrome.md#t08--hide-to-tray-on-close)) | — |
|
||||
| Minimize button | Adjacent to close | Renders, hover state visible, click minimizes | — |
|
||||
| Maximize / restore button | Adjacent to minimize | Renders, hover state visible, click toggles maximize | — |
|
||||
| Resize edges (left, right, top, bottom, corners) | Window perimeter | Cursor changes to resize affordance on hover; drag resizes | Wlroots compositors may not show cursor change |
|
||||
| Window menu (right-click titlebar) | Right-click anywhere on titlebar | Standard window menu (Move, Resize, Close, Always on Top, etc.) | DE-dependent |
|
||||
|
||||
## Hybrid in-app topbar (PR #538 builds)
|
||||
|
||||
Sits below the OS frame in hybrid mode. Crosses with [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) and [S13](../cases/tray-and-window-chrome.md#s13--hybrid-topbar-shim-survives-omarchys-ozone-wayland-env-exports).
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Hamburger menu | Top-left of topbar | Renders, click opens sidebar | — |
|
||||
| Sidebar toggle | Adjacent to hamburger | Renders, click collapses/expands sidebar | — |
|
||||
| Search icon | Center-left | Renders, click opens search overlay | — |
|
||||
| Back arrow | Center | Renders, greyed out when no history; click navigates back | — |
|
||||
| Forward arrow | Adjacent to back | Same as back, but for forward history | — |
|
||||
| Cowork ghost icon | Right of nav arrows | Renders, click opens Cowork tab | The icon is the canonical "is the topbar shim alive" indicator |
|
||||
| Drag region (gaps between buttons) | Empty space between elements | Drag region behaves correctly — buttons remain clickable, no implicit drag region capturing button clicks | Critical: this is the regression mode in [T07](../cases/tray-and-window-chrome.md#t07--in-app-topbar-renders--clickable) |
|
||||
|
||||
## Tab strip (Chat / Cowork / Code)
|
||||
|
||||
Sits in the topbar (hybrid) or in the OS-frame area (legacy). Top center.
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| **Chat** tab | Left tab | Renders, click switches to Chat | — |
|
||||
| **Cowork** tab | Center tab | Renders, click switches to Cowork; ghost icon may indicate Dispatch state | — |
|
||||
| **Code** tab | Right tab | Renders, click switches to Code; on Linux, may show 403 / sign-in upsell ([T16](../cases/code-tab-foundations.md#t16--code-tab-loads)) | — |
|
||||
| Active tab indicator | Underline / fill on active tab | Visually distinct from inactive tabs | — |
|
||||
| Tab badges (e.g. unread count, Dispatch badge) | Top-right of each tab | Render when applicable, dismiss when state clears | — |
|
||||
|
||||
## Other window-level UI
|
||||
|
||||
| Element | Location | Expected | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| About dialog | App menu → About | Modal opens with app version, Electron version, license info; close button works | — |
|
||||
| App menu (macOS-style) | macOS only — N/A on Linux | Not present on Linux; menu items are in window menu instead | — |
|
||||
| Update prompt | Triggered by upstream update detection | On DEB/RPM, auto-update path is suppressed ([S26](../cases/distribution.md#s26--auto-update-is-disabled-when-installed-via-apt--dnf)). On AppImage, may surface a prompt | — |
|
||||
| Crash report dialog | Shown after a crash | Dialog explains what happened, offers to file an issue | Capture for Linux specifics — wording may reference macOS Console / Windows Event Viewer paths only |
|
||||
|
||||
## Display-server cross-cuts
|
||||
|
||||
| Concern | X11 | Wayland (mutter) | Wayland (KWin) | Wayland (wlroots) |
|
||||
|---------|-----|-------------------|----------------|---------------------|
|
||||
| HiDPI scaling | `--force-device-scale-factor=N` works | Auto via fractional scaling | Auto via fractional scaling | Auto where compositor supports it |
|
||||
| Drag-to-snap (Aero-style) | Works under most WMs | mutter snaps | KWin snaps | Compositor-dependent |
|
||||
| Always-on-top | Window menu | Window menu | Window menu | Compositor-dependent |
|
||||
| Cursor theme | Inherits from `gtk-cursor-theme-name` | Same | Same | Same |
|
||||
@@ -1,471 +0,0 @@
|
||||
[< Back to README](../README.md)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Built-in Diagnostics
|
||||
|
||||
Run the `--doctor` flag to check your system for common issues:
|
||||
|
||||
```bash
|
||||
# Deb install
|
||||
claude-desktop --doctor
|
||||
|
||||
# AppImage
|
||||
./claude-desktop-*.AppImage --doctor
|
||||
```
|
||||
|
||||
This runs a series of checks and prints pass/fail results with
|
||||
suggested fixes:
|
||||
|
||||
| Check | What it verifies |
|
||||
|-------|-----------------|
|
||||
| Installed version | Package version via dpkg |
|
||||
| Display server | Wayland/X11 detection and mode |
|
||||
| Input method | IBus/GTK immodule sanity (ibus-gtk3 installed, cache fresh, XWayland routing note) |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| 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
|
||||
```
|
||||
@@ -1,164 +0,0 @@
|
||||
# Upstream report draft: MCP double-spawn (issue #546)
|
||||
|
||||
This is the draft for the upstream bug report covering [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Filing target is `anthropics/claude-code` GitHub Issues, with an in-app `/bug` from Claude Desktop as a complement so the report ties to build telemetry.
|
||||
|
||||
## Template mismatch note
|
||||
|
||||
The `anthropics/claude-code` bug template is built for the Claude Code CLI, not Claude Desktop. Required fields like "Claude Code Version" and "Terminal/Shell" don't apply cleanly. Other Claude Desktop bug reports in the same repo work around this by putting `N/A — Claude Desktop <version>` in the version field and selecting `Other` for terminal (see #43705, #36319, #14807).
|
||||
|
||||
## Title
|
||||
|
||||
```
|
||||
[BUG] Claude Desktop 1.5354.0: stdio MCP servers double-spawn from independent CCD/LAM coordinator registries
|
||||
```
|
||||
|
||||
## Form fields
|
||||
|
||||
### Preflight Checklist
|
||||
|
||||
- [x] I have searched existing issues and this hasn't been reported yet
|
||||
- [x] This is a single bug report
|
||||
- [x] I am using the latest version of Claude Code
|
||||
|
||||
### What's Wrong?
|
||||
|
||||
I maintain [claude-desktop-debian](https://github.com/aaddrick/claude-desktop-debian) (~2,300 package downloads/day across the last 3 releases), which repackages the Windows Electron build for Linux. I was reading the MCP spawn path in 1.5354.0 and found that stdio MCP servers configured in `claude_desktop_config.json` get spawned twice when both the chat panel and Code/Agent panel are active.
|
||||
|
||||
The user-visible symptom is two `node` processes per MCP, both children of the Electron main PID. Killing one disconnects one panel and the other keeps working. They're independent client/server pairs with no failover between them.
|
||||
|
||||
The original symptom report came from @communitytranslations against an earlier build (tracked in our repo as #526). I went back and read the bundle to confirm the cause. What I found was different from what we'd previously documented.
|
||||
|
||||
CCD wraps the spawn path in a per-key promise queue keyed by server name. It shuts down any prior entry in its global registry Map before respawning. That's correct dedup within CCD. But LAM (`LocalMcpServerManager`) has its own `this.connections` Map and its own `getOrCreateConnection` path. It never consults CCD's registry.
|
||||
|
||||
CCD and LAM each maintain independent spawn lifecycle management. They each spawn their own copy of the same MCP server. The double-spawn is structural in the current architecture. Each coordinator legitimately holds its own connection.
|
||||
|
||||
There's also a third coordinator class, `SshMcpServerManager`, that follows the same per-coordinator-registry pattern. It uses an SSH transport, so it doesn't contribute to local-node double-spawn directly. Its existence suggests per-coordinator isolated state is a deliberate pattern, not a one-off.
|
||||
|
||||
Secondary bug worth flagging while you're in this code. The `child_process.spawn` wrapper does proper signal escalation (end stdin, wait 2s, SIGTERM, wait 2s, SIGKILL). The `utilityProcess.fork` wrapper doesn't. It sends `process.kill()` (default SIGTERM), waits 5s, then calls `kill()` again with the same default signal. No SIGKILL escalation. A built-in-node MCP server that ignores SIGTERM could leak as an orphaned utility process.
|
||||
|
||||
### What Should Happen?
|
||||
|
||||
One process per stdio MCP server entry in `claude_desktop_config.json`, regardless of how many panels are open. Resource-side that means no more 2x memory and 2x stdin/stdout traffic per server. User-side that means `ps` shows one entry per declared server.
|
||||
|
||||
The fix is architectural. CCD and LAM share a registry, or the local-spawn factory dedups at the transport layer, or LAM proxies through CCD when running in-process. Any of those would collapse the duplication.
|
||||
|
||||
### Error Messages/Logs
|
||||
|
||||
The user-facing log prefixes are stable across releases. Grep `~/.config/Claude/logs/` for:
|
||||
|
||||
```
|
||||
[CCD]
|
||||
[LAM]
|
||||
[LocalMcpServerManager]
|
||||
[SshMcpServerManager]
|
||||
```
|
||||
|
||||
For the spawn lifecycle specifically, look for:
|
||||
|
||||
```
|
||||
"Launching MCP Server: <name>" (CCD spawn entry)
|
||||
"Shutting down MCP Server: <name>" (CCD shutdown entry)
|
||||
"local-mcp-server-cleanup" (LAM cleanup path)
|
||||
```
|
||||
|
||||
Two of these per declared MCP server is the diagnostic signal.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Linux host running Claude Desktop at or near 1.5354.0
|
||||
2. Declare at least one stdio MCP server in `~/.config/Claude/claude_desktop_config.json`
|
||||
3. Open Claude Desktop, start a session, open the Code/Agent panel and let it initialize fully (the original report waited about 5 minutes)
|
||||
4. `ps -ef | grep <server-binary-name>`
|
||||
|
||||
Expected: 1 process per MCP. Actual: 2 processes per MCP, both children of the same Electron main PID.
|
||||
|
||||
### Claude Model
|
||||
|
||||
Not sure / Multiple models
|
||||
|
||||
### Is this a regression?
|
||||
|
||||
I don't know
|
||||
|
||||
### Last Working Version
|
||||
|
||||
(leave blank)
|
||||
|
||||
### Claude Code Version
|
||||
|
||||
```
|
||||
N/A — this is a Claude Desktop issue. Bundle version: 1.5354.0
|
||||
```
|
||||
|
||||
### Platform
|
||||
|
||||
Anthropic API
|
||||
|
||||
### Operating System
|
||||
|
||||
Ubuntu/Debian Linux
|
||||
|
||||
### Terminal/Shell
|
||||
|
||||
Other
|
||||
|
||||
### Additional Information
|
||||
|
||||
Bundle reference table for 1.5354.0. Symbols rename across releases, so each row has a stable string anchor for re-finding them.
|
||||
|
||||
| Role | Symbol in 1.5354.0 | Stable anchor |
|
||||
|---|---|---|
|
||||
| CCD spawn function | `BPt` | `"Launching MCP Server:"` |
|
||||
| CCD shutdown function | `CPt` | `"Shutting down MCP Server:"` |
|
||||
| CCD per-key promise queue | `dPt` | called by CCD spawn fn: `await dPt(e, async () => {...})` |
|
||||
| CCD server registry Map | `xX` | `.get()` immediately preceding the CCD shutdown log line |
|
||||
| Shared transport factory | `oPt` | `"built-in-node"` literal in factory body |
|
||||
| LAM manager class | `p0A` | `"[LocalMcpServerManager]"` or `"local-mcp-server-cleanup"` |
|
||||
| SSH manager class | `Rde` | `"[SshMcpServerManager]"` or `"ssh-mcp-server-cleanup"` |
|
||||
| `utilityProcess.fork` wrapper | `mFr` | constructed in shared factory's `built-in-node` branch |
|
||||
| `child_process.spawn` wrapper | `tFr` | constructed in shared factory's default branch |
|
||||
|
||||
Extraction commands (verified against 1.5354.0):
|
||||
|
||||
```bash
|
||||
cd build-reference/app-extracted/.vite/build
|
||||
|
||||
# CCD spawn function name
|
||||
grep -Pzo 'async function \K\w+(?=\(\w*\)\s*\{(?s).{0,800}?Launching MCP Server)' index.js | tr '\0' '\n'
|
||||
|
||||
# Shared transport factory (anchored on the unique 'built-in-node' string)
|
||||
grep -Pzo 'async function \K\w+(?=\([^)]*\)\s*\{(?s).{0,400}?built-in-node)' index.js | tr '\0' '\n'
|
||||
|
||||
# All coordinator classes following the per-coordinator-registry pattern
|
||||
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,300}?this\.connections\s*=\s*new Map)' index.js | tr '\0' '\n'
|
||||
|
||||
# LAM manager class specifically
|
||||
grep -Pzo 'class \K\w+(?=\s*\{(?s).{0,500}?local-mcp-server-cleanup)' index.js | tr '\0' '\n'
|
||||
```
|
||||
|
||||
Two questions where a one-line answer from the team would help us route this downstream:
|
||||
|
||||
1. Is per-coordinator isolated state intentional, or is it legacy drift from when each coordinator instantiated its transport inline?
|
||||
2. Is the recent extraction of the shared transport factory (`oPt`) the start of a dedup refactor, or incidental cleanup?
|
||||
|
||||
If (1) is "intentional," we'll point users at the lockfile workaround as the supported path. If (2) is "in progress," this report saves you the duplicate analysis work.
|
||||
|
||||
Full provenance: [aaddrick/claude-desktop-debian#546](https://github.com/aaddrick/claude-desktop-debian/issues/546). Related learnings doc updates: [#527](https://github.com/aaddrick/claude-desktop-debian/pull/527) and [#547](https://github.com/aaddrick/claude-desktop-debian/pull/547).
|
||||
|
||||
## Filing checklist
|
||||
|
||||
When you're ready to file:
|
||||
|
||||
1. Open https://github.com/anthropics/claude-code/issues/new?template=bug_report.yml
|
||||
2. Paste each section above into the matching form field
|
||||
3. Submit
|
||||
4. Drop the GitHub issue URL as a comment on [#546](https://github.com/aaddrick/claude-desktop-debian/issues/546) so the trail is bidirectional
|
||||
|
||||
Note: there is no in-app engineering bug-report path in Claude Desktop. `/bug` and `/feedback` are inert. The Help menu has "Get Support" (routes to the support chat, wrong queue for engineering) and "Troubleshooting" (self-diagnostic — useful for attaching `Copy Installation ID` or `Show Logs in File Manager` output to a GitHub issue, but not a reporting step on its own).
|
||||
|
||||
## Voice and authorship
|
||||
|
||||
Drafted using the [aaddrick-voice](https://github.com/aaddrick/written-voice-replication/blob/78f178dcf832943bcf1d5a65bf7627c3a20053a6/.claude/agents/aaddrick-voice.md) style profile against the form schema in `anthropics/claude-code/.github/ISSUE_TEMPLATE/bug_report.yml`.
|
||||
|
||||
---
|
||||
Written by Claude Opus 4.7 via [Claude Code](https://claude.ai/code)
|
||||
18
flake.lock
generated
18
flake.lock
generated
@@ -5,11 +5,11 @@
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"lastModified": 1775087534,
|
||||
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779536132,
|
||||
"narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=",
|
||||
"lastModified": 1776949667,
|
||||
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456",
|
||||
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"lastModified": 1774748309,
|
||||
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
}:
|
||||
let
|
||||
pname = "claude-desktop";
|
||||
version = "1.9255.0";
|
||||
version = "1.5354.0";
|
||||
|
||||
srcs = {
|
||||
x86_64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe";
|
||||
hash = "sha256-QiRhl0sR08hwn5MDlhMss9AdJ+kX8yrGxLmgd7y3cEs=";
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-5hnHvTtnRqcwfr7+UJv+RHoUOu2X5sf2Zmd7Nqa2ulQ=";
|
||||
};
|
||||
aarch64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe";
|
||||
hash = "sha256-HyCBdS793TGw9b7a43ZZs5w44zbRH4BBoaNpnqhhvbw=";
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe";
|
||||
hash = "sha256-v33l1sASVC/q331cqnenLfzqGyRRLpptKOAEukrioR0=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,7 +124,6 @@ stdenvNoCC.mkDerivation {
|
||||
# Copy the ELF binary — MUST be a real copy (not symlink) so that
|
||||
# /proc/self/exe resolves to our tree
|
||||
cp ${electronDir}/electron $electron_tree/electron
|
||||
chmod +x $electron_tree/electron
|
||||
|
||||
# Symlink everything else from electron-unwrapped
|
||||
for item in ${electronDir}/*; do
|
||||
@@ -246,7 +245,6 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start (NixOS) ---'
|
||||
log_message "Timestamp: $(date)"
|
||||
log_message "Arguments: $@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -265,10 +263,12 @@ build_electron_args 'nix'
|
||||
# Add app path
|
||||
electron_args+=("$app_path")
|
||||
|
||||
# Execute Electron (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
# Execute Electron
|
||||
log_message "Executing: $electron_exec ''${electron_args[*]} $*"
|
||||
exec "$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
"$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
exit_code=$?
|
||||
log_message "Electron exited with code: $exit_code"
|
||||
exit $exit_code
|
||||
LAUNCHER
|
||||
# Substitute placeholders — electron_exec points to our custom
|
||||
# wrapper (which sets GTK/GIO env then execs our merged binary)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Cowork patch markers — single source of truth.
|
||||
#
|
||||
# Format:
|
||||
# <name><TAB><pcre_pattern><TAB><sample>
|
||||
# Lines starting with '#' and blank lines are ignored.
|
||||
#
|
||||
# Each row names a post-patch fingerprint from the patch suite in
|
||||
# scripts/patches/. Both verify-patches.sh and tests/verify-patches.bats
|
||||
# consume this file, so adding a marker here adds it to the runtime
|
||||
# check and the test matrix at the same time.
|
||||
#
|
||||
# Columns:
|
||||
# name — kebab-case id; surfaces in verify output and BATS names.
|
||||
# pattern — PCRE matched against the shipped index.js by `grep -P`.
|
||||
# sample — concrete string the pattern matches; BATS uses it to
|
||||
# build positive and per-marker negative fixtures.
|
||||
#
|
||||
# The first 9 markers correspond to the smoke-test set defined in
|
||||
# issue #559 (PR #555 retrofit, deliverable D6). Additional markers
|
||||
# cover other critical patches (e.g., .asar guards).
|
||||
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
|
||||
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
|
||||
unix-socket-path process\.platform==="linux"\?\(process\.env\.XDG_RUNTIME_DIR\|\|"/tmp"\)\+"/cowork-vm-service\.sock" process.platform==="linux"?(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"
|
||||
empty-linux-bundle-manifest linux:\{x64:\[\],arm64:\[\]\} ,linux:{x64:[],arm64:[]}
|
||||
getdownloadstatus-suppression getDownloadStatus\(\)\{return process\.platform==="linux"\?[\w$]+\.NotDownloaded getDownloadStatus(){return process.platform==="linux"?Z.NotDownloaded
|
||||
econnrefused-on-linux process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED" (n.code==="ENOENT"||process.platform==="linux"&&n.code==="ECONNREFUSED")
|
||||
cowork-daemon-pid global\.__coworkDaemonPid global.__coworkDaemonPid=_c.pid
|
||||
cowork-linux-daemon-shutdown cowork-linux-daemon-shutdown name:"cowork-linux-daemon-shutdown"
|
||||
sharedcwdpath-threadthrough sharedCwdPath:this\.sessions\.get\( sharedCwdPath:this.sessions.get(t)?.userSelectedFolders?.[0]
|
||||
asar-adddir-filter \.filter\(_d=>!_d\.endsWith\("\.asar"\)\).*"--add-dir" .filter(_d=>!_d.endsWith(".asar")))Y.push("--add-dir"
|
||||
|
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,4 +1,3 @@
|
||||
# shellcheck shell=bash
|
||||
#===============================================================================
|
||||
# Doctor Diagnostics
|
||||
#
|
||||
@@ -72,110 +71,12 @@ _cowork_pkg_hint() {
|
||||
arch) pkg='qemu-full' ;;
|
||||
esac
|
||||
;;
|
||||
ibus-gtk3)
|
||||
# Arch ships the GTK3 immodule as part of the main ibus
|
||||
# package; Debian/Ubuntu and Fedora split it out.
|
||||
case "$distro" in
|
||||
arch) pkg='ibus' ;;
|
||||
*) pkg='ibus-gtk3' ;;
|
||||
esac
|
||||
;;
|
||||
*) pkg="$tool" ;;
|
||||
esac
|
||||
|
||||
printf '%s' "$pkg_cmd $pkg"
|
||||
}
|
||||
|
||||
# Return 0 if the named package is installed, 1 otherwise. Returns 2
|
||||
# (treated as "unknown") when no recognized package manager is
|
||||
# available — callers should not warn in that case to avoid false
|
||||
# positives on unsupported distros.
|
||||
_pkg_installed() {
|
||||
local distro="$1"
|
||||
local pkg="$2"
|
||||
case "$distro" in
|
||||
debian|ubuntu)
|
||||
command -v dpkg-query &>/dev/null || return 2
|
||||
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
|
||||
| grep -q 'install ok installed'
|
||||
;;
|
||||
fedora)
|
||||
command -v rpm &>/dev/null || return 2
|
||||
rpm -q "$pkg" &>/dev/null
|
||||
;;
|
||||
arch)
|
||||
command -v pacman &>/dev/null || return 2
|
||||
pacman -Q "$pkg" &>/dev/null
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Diagnose IBus / GTK input-method misconfigurations that break
|
||||
# keyboard input in the chat (#550). Surfaces:
|
||||
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
|
||||
# - XWayland-with-IBus routing note: on a Wayland session Electron
|
||||
# defaults to XWayland (preserves global hotkeys), which forces
|
||||
# the IBus path through XIM — a known weak link for some IMEs.
|
||||
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
|
||||
# - GTK immodules cache stale: active module not listed by
|
||||
# gtk-query-immodules-3.0 (--update-cache fixes it)
|
||||
#
|
||||
# Usage: _doctor_check_im_modules <distro_id>
|
||||
_doctor_check_im_modules() {
|
||||
local distro="$1"
|
||||
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
|
||||
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
|
||||
"(overrides GTK_IM_MODULE for Electron)"
|
||||
fi
|
||||
|
||||
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
|
||||
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
|
||||
_info \
|
||||
'IME note: Wayland session, Electron via XWayland —' \
|
||||
'IBus path goes through XIM (lossy for some IMEs).'
|
||||
_info \
|
||||
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
|
||||
'(loses global hotkeys).'
|
||||
fi
|
||||
|
||||
# Nothing further to check without an active IM module.
|
||||
[[ -n $active_im ]] || return 0
|
||||
|
||||
# ibus-gtk3 package check — only when the active module is ibus.
|
||||
# rc=1 means definitely missing (warn); rc=2 means unsupported
|
||||
# distro / no package manager (skip silently to avoid false
|
||||
# negatives). On warn, return early — `apt install` refreshes
|
||||
# the immodules cache, so the cache check below would be noise.
|
||||
if [[ $active_im == 'ibus' ]]; then
|
||||
_pkg_installed "$distro" ibus-gtk3
|
||||
case $? in
|
||||
1)
|
||||
_warn \
|
||||
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
|
||||
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
|
||||
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
|
||||
# means GTK 3 isn't in use — skip silently rather than warn.
|
||||
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
|
||||
|
||||
if ! gtk-query-immodules-3.0 2>/dev/null \
|
||||
| grep -q "\"$active_im\""; then
|
||||
_warn \
|
||||
"GTK immodules: '$active_im' not listed by" \
|
||||
"gtk-query-immodules-3.0 (cache may be stale)"
|
||||
_info \
|
||||
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
|
||||
fi
|
||||
}
|
||||
|
||||
# Read the version string from the version file beside an Electron binary.
|
||||
# Prints the raw version string, or nothing if unavailable.
|
||||
_electron_version() {
|
||||
@@ -437,147 +338,6 @@ JSEOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Diagnose short-filename-limit filesystems that break cowork session
|
||||
# initialization. Claude Code creates a per-session directory under
|
||||
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
|
||||
# sessions that flattens to ~180 chars (the host CWD is the deeply
|
||||
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
|
||||
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
|
||||
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
|
||||
# overhead — that mkdir fails with ENAMETOOLONG and the session never
|
||||
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
|
||||
# #590.
|
||||
_doctor_check_filename_limit() {
|
||||
# Walk up from ~/.claude/projects to the first dir that exists so
|
||||
# getconf has something to query on a fresh install where the tree
|
||||
# hasn't been created yet. $HOME is the floor — stop there rather
|
||||
# than crossing into /.
|
||||
local probe_dir="$HOME/.claude/projects"
|
||||
while [[ ! -d $probe_dir ]]; do
|
||||
probe_dir=$(dirname "$probe_dir")
|
||||
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
|
||||
done
|
||||
[[ -d $probe_dir ]] || return 0
|
||||
|
||||
local name_max
|
||||
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
|
||||
[[ $name_max =~ ^[0-9]+$ ]] || return 0
|
||||
|
||||
((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() {
|
||||
@@ -585,11 +345,6 @@ 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
|
||||
@@ -626,9 +381,6 @@ run_doctor() {
|
||||
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
|
||||
fi
|
||||
|
||||
# -- Input method (IBus / GTK) --
|
||||
_doctor_check_im_modules "$_distro_id"
|
||||
|
||||
# -- Menu bar mode --
|
||||
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
|
||||
if [[ -n $menu_bar_mode ]]; then
|
||||
@@ -677,14 +429,6 @@ run_doctor() {
|
||||
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
|
||||
fi
|
||||
|
||||
# -- Keep awake override --
|
||||
local keep_awake="${CLAUDE_KEEP_AWAKE:-}"
|
||||
if [[ $keep_awake == '0' ]]; then
|
||||
_pass 'Keep awake: suppressed (CLAUDE_KEEP_AWAKE=0)'
|
||||
elif [[ -n $keep_awake ]]; then
|
||||
_info "Keep awake: CLAUDE_KEEP_AWAKE=$keep_awake (default behavior)"
|
||||
fi
|
||||
|
||||
# -- Electron binary --
|
||||
# Version is read from the file next to the binary rather than
|
||||
# launching Electron, which can hang (see #371).
|
||||
@@ -758,9 +502,6 @@ run_doctor() {
|
||||
_pass 'SingletonLock: no lock file (OK)'
|
||||
fi
|
||||
|
||||
# -- Password store --
|
||||
_doctor_check_password_store
|
||||
|
||||
# -- MCP config --
|
||||
local mcp_config="$config_dir/claude_desktop_config.json"
|
||||
if [[ -f $mcp_config ]]; then
|
||||
@@ -848,6 +589,10 @@ 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.
|
||||
@@ -896,7 +641,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
|
||||
@@ -1040,10 +785,6 @@ 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
|
||||
@@ -1080,11 +821,6 @@ print(len(servers))
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Recent crashes --
|
||||
# Surfaces the GPU process FATAL pattern (#583) before users
|
||||
# notice the in-app "Claude crashed repeatedly" prompt.
|
||||
_doctor_check_recent_crashes "$electron_path"
|
||||
|
||||
# -- Log file --
|
||||
local log_path
|
||||
log_path="${XDG_CACHE_HOME:-$HOME/.cache}"
|
||||
|
||||
@@ -81,28 +81,15 @@ const CLOSE_TO_TRAY = process.platform === 'linux'
|
||||
&& process.env.CLAUDE_QUIT_ON_CLOSE !== '1';
|
||||
console.log(`[Frame Fix] Close-to-tray: ${CLOSE_TO_TRAY ? 'on' : 'off'}`);
|
||||
|
||||
// Power save blocker behavior, controlled by CLAUDE_KEEP_AWAKE env var:
|
||||
// unset / '1' - pass through with diagnostic logging
|
||||
// '0' - suppress powerSaveBlocker.start() calls entirely
|
||||
// Upstream's keepAwakeEnabled has no lifecycle management on Linux (the
|
||||
// darwin-only wake scheduler never runs), so the inhibitor fires at init
|
||||
// and never releases — preventing suspend and screensaver. See #605.
|
||||
const KEEP_AWAKE = process.env.CLAUDE_KEEP_AWAKE !== '0';
|
||||
console.log(`[Frame Fix] Keep awake: ${KEEP_AWAKE ? 'on (default)' : 'suppressed (CLAUDE_KEEP_AWAKE=0)'}`);
|
||||
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About).
|
||||
// Window kinds — see build-reference/app-extracted/.vite/build/index.js:
|
||||
// Quick Entry: titleBarStyle:"hidden", frame:false (caught early)
|
||||
// About: titleBarStyle:"hiddenInset", no minWidth, no parent
|
||||
// Main: titleBarStyle:"hidden", minWidth:600
|
||||
// Hardware Buddy: titleBarStyle:"hiddenInset", parent set (child modal — keep frame)
|
||||
// minWidth excludes Main; the `parent` key excludes Hardware Buddy. About
|
||||
// went from "" to "hiddenInset" upstream, so the test matches either.
|
||||
// Detect if a window intends to be frameless (popup/Quick Entry/About)
|
||||
// Quick Entry: titleBarStyle:"", skipTaskbar:true, transparent:true, resizable:false
|
||||
// About: titleBarStyle:"", skipTaskbar:true, resizable:false
|
||||
// Main: titleBarStyle:"", titleBarOverlay:false(linux), resizable (has minWidth)
|
||||
// The main window has minWidth set; popups do not.
|
||||
function isPopupWindow(options) {
|
||||
if (!options) return false;
|
||||
if (options.frame === false) return true;
|
||||
if ('parent' in options) return false;
|
||||
if ((options.titleBarStyle === '' || options.titleBarStyle === 'hiddenInset') && !options.minWidth) return true;
|
||||
if (options.titleBarStyle === '' && !options.minWidth) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -130,28 +117,6 @@ const LINUX_CSS = `
|
||||
}
|
||||
`;
|
||||
|
||||
// autoUpdater no-op: every property access returns a chainable function
|
||||
// so `.on(...).once(...).setFeedURL(...).checkForUpdates()` is harmless.
|
||||
// `getFeedURL` returns '' so any code that inspects the URL gets a
|
||||
// well-typed empty string rather than undefined. `then`/`catch`/`finally`
|
||||
// and `Symbol.toPrimitive`/`Symbol.iterator` resolve to `undefined` so the
|
||||
// Proxy is not mistaken for a thenable (which would call chainNoop as
|
||||
// `then(resolve, reject)` and never resolve — silent await hang) or
|
||||
// asked to coerce to a primitive. Writes land on the target but are
|
||||
// shadowed by the get-trap. Defined once and reused across all
|
||||
// require('electron') calls. Linux-only; macOS/Windows still see the
|
||||
// real autoUpdater. See #567.
|
||||
const autoUpdaterNoop = new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getFeedURL') return () => '';
|
||||
if (prop === 'then' || prop === 'catch' || prop === 'finally'
|
||||
|| prop === Symbol.toPrimitive || prop === Symbol.iterator) {
|
||||
return undefined;
|
||||
}
|
||||
return function chainNoop() { return autoUpdaterNoop; };
|
||||
},
|
||||
});
|
||||
|
||||
// Build the patched BrowserWindow class and Menu interceptor once,
|
||||
// on first require('electron'), then reuse via Proxy on every access.
|
||||
let PatchedBrowserWindow = null;
|
||||
@@ -187,7 +152,10 @@ Module.prototype.require = function(id) {
|
||||
} else if (TITLEBAR_STYLE === 'native') {
|
||||
// Main window, native mode: force system frame.
|
||||
options.frame = true;
|
||||
options.autoHideMenuBar = false;
|
||||
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
|
||||
// 'auto' (default): hidden, Alt toggles
|
||||
// 'visible'/'hidden': no Alt toggle
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
|
||||
@@ -217,7 +185,7 @@ Module.prototype.require = function(id) {
|
||||
// CSS rule still applying within the framed
|
||||
// window's content area.
|
||||
options.frame = true;
|
||||
options.autoHideMenuBar = false;
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
|
||||
@@ -252,22 +220,6 @@ Module.prototype.require = function(id) {
|
||||
this.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
// Track the most recent 'show' event timestamp on the
|
||||
// window. Read by the webContents.focus() guard below to
|
||||
// distinguish a genuine post-show activation (which must
|
||||
// pass through to send _NET_ACTIVE_WINDOW and actually
|
||||
// give the window WM focus) from a sloppy-focus
|
||||
// reassertion (which is what we want to skip). Required
|
||||
// because Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland — a freshly-restored
|
||||
// window reports focused=true even though the WM never
|
||||
// activated it, and skipping the focus() call leaves the
|
||||
// window visible-but-inert until the user clicks it.
|
||||
// See #416 review notes.
|
||||
this._lastShownAt = 0;
|
||||
this.on('show', () => { this._lastShownAt = Date.now(); });
|
||||
this.on('restore', () => { this._lastShownAt = Date.now(); });
|
||||
|
||||
// Inject CSS for Linux scrollbar styling
|
||||
this.webContents.on('did-finish-load', () => {
|
||||
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
|
||||
@@ -338,7 +290,8 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
|
||||
// In 'hidden' mode, suppress Alt toggle by re-hiding
|
||||
// on every show event.
|
||||
// on every show event. In 'auto' mode, let
|
||||
// autoHideMenuBar handle the toggle natively.
|
||||
if (MENU_BAR_MODE === 'hidden') {
|
||||
this.on('show', () => {
|
||||
this.setMenuBarVisibility(false);
|
||||
@@ -360,44 +313,6 @@ Module.prototype.require = function(id) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// CLAUDE_QUIT_ON_CLOSE=1: the bundled main-process code
|
||||
// (`.vite/build/index.js`) installs its own main-window
|
||||
// close listener that hardcodes `preventDefault()` +
|
||||
// `hide()` on every non-Windows platform, with no
|
||||
// setting or env var to disable it. The wrapper's
|
||||
// opt-out above only removes *this* file's hide handler;
|
||||
// the bundled one still runs, so without this branch
|
||||
// closing the window still leaves the app alive in the
|
||||
// tray (in-app schedulers / single-instance lock /
|
||||
// deleted-inode electron after dpkg upgrade-in-place).
|
||||
//
|
||||
// Approach: register a close listener that runs *first*
|
||||
// and calls app.quit(). app.quit() emits 'before-quit'
|
||||
// synchronously, which sets the bundled code's
|
||||
// "quitting in progress" flag. The bundled close
|
||||
// listener then runs second, sees that flag, and
|
||||
// short-circuits via its own `if (lC()) return;` guard
|
||||
// — so it never calls preventDefault, and the window
|
||||
// closes normally during the quit flow. We ride the
|
||||
// upstream's own quit-safety contract instead of trying
|
||||
// to remove or splice their listener; robust to any
|
||||
// refactor that preserves the quit-in-progress short-
|
||||
// circuit (which they need for Ctrl+Q / tray Quit /
|
||||
// SIGTERM anyway). Fixes: #623
|
||||
this.on('close', () => { result.app.quit(); });
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle state (auto mode). Tracked
|
||||
// per-window so chords spanning multiple webContents
|
||||
// (main window + BrowserView) share one state machine.
|
||||
// Reset on blur to avoid stale state after Alt-Tab.
|
||||
if (MENU_BAR_MODE === 'auto') {
|
||||
this._altMenuTracker = { pressed: false, chorded: false };
|
||||
this.on('blur', () => {
|
||||
this._altMenuTracker.pressed = false;
|
||||
this._altMenuTracker.chorded = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Directly set child view bounds to match content size.
|
||||
@@ -563,32 +478,11 @@ Module.prototype.require = function(id) {
|
||||
|
||||
// Intercept Menu.setApplicationMenu to hide menu bar on Linux.
|
||||
// In 'hidden' mode, force-hide after every menu update.
|
||||
// In 'auto' mode, only hide initially (the before-input-event
|
||||
// Alt-keyup handler manages toggle). Fixes: #321
|
||||
// In 'auto' mode, only hide initially (autoHideMenuBar handles
|
||||
// Alt toggle — re-hiding here would break that). Fixes: #321
|
||||
const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu);
|
||||
patchedSetApplicationMenu = function(menu) {
|
||||
console.log('[Frame Fix] Intercepting setApplicationMenu');
|
||||
|
||||
// Append a hidden View submenu with F11 fullscreen toggle.
|
||||
// Upstream has fullscreenable:true and persists isFullScreen
|
||||
// across sessions; macOS provides the green traffic-light
|
||||
// button; Linux has no equivalent OS-level trigger, so we
|
||||
// register an accelerator here. visible:false keeps it out
|
||||
// of the menu bar — it only registers the keybinding.
|
||||
// Fixes: #580
|
||||
if (process.platform === 'linux' && menu) {
|
||||
const { MenuItem, Menu: MenuClass } = electronModule;
|
||||
menu.append(new MenuItem({
|
||||
label: 'View',
|
||||
visible: false,
|
||||
submenu: MenuClass.buildFromTemplate([{
|
||||
label: 'Toggle Full Screen',
|
||||
role: 'togglefullscreen',
|
||||
accelerator: 'F11',
|
||||
}]),
|
||||
}));
|
||||
}
|
||||
|
||||
originalSetAppMenu(menu);
|
||||
if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') {
|
||||
for (const win of PatchedBrowserWindow.getAllWindows()) {
|
||||
@@ -641,105 +535,13 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
}
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type === 'keyDown' && input.control
|
||||
&& !input.alt && !input.shift && !input.meta
|
||||
&& (input.key === 'q' || input.key === 'Q')) {
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle (auto mode). Chromium's
|
||||
// autoHideMenuBar fires on keydown, grabbing focus
|
||||
// before Alt+Shift (language switch) or Alt+F4 can
|
||||
// complete. We suppress the keydown and toggle on
|
||||
// keyup only when Alt was released without any
|
||||
// intervening key. Fixes: #630
|
||||
if (MENU_BAR_MODE !== 'auto') return;
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return;
|
||||
const tracker = owner._altMenuTracker;
|
||||
if (!tracker) return;
|
||||
|
||||
if (input.key === 'Alt') {
|
||||
if (input.type === 'keyDown') {
|
||||
tracker.pressed = true;
|
||||
tracker.chorded = false;
|
||||
event.preventDefault();
|
||||
} else if (input.type === 'keyUp') {
|
||||
if (tracker.pressed && !tracker.chorded) {
|
||||
owner.setMenuBarVisibility(!owner.isMenuBarVisible());
|
||||
}
|
||||
tracker.pressed = false;
|
||||
}
|
||||
} else if (tracker.pressed && input.type === 'keyDown') {
|
||||
tracker.chorded = true;
|
||||
}
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (!input.control) return;
|
||||
if (input.alt || input.shift || input.meta) return;
|
||||
if (input.key !== 'q' && input.key !== 'Q') return;
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
});
|
||||
|
||||
// Suppress redundant webContents.focus() calls that would
|
||||
// re-trigger Chromium's X11Window::Activate() and send a
|
||||
// _NET_ACTIVE_WINDOW client message — EWMH defines that as
|
||||
// focus-AND-raise, so under sloppy / focus-follows-mouse
|
||||
// WMs (Cinnamon Muffin, Mutter, i3 with focus_follows_mouse)
|
||||
// every BrowserWindow 'focus' event causes a raise on
|
||||
// mouse-enter, undoing the user's "no auto-raise" config.
|
||||
// Tracks electron/electron#38184.
|
||||
//
|
||||
// Hooked at app.on('web-contents-created') so child views
|
||||
// are covered too — the BrowserWindow-class wrap only
|
||||
// touches the window's own webContents, but the upstream
|
||||
// call site lives on a child WebContentsView (the claude.ai
|
||||
// host view) whose webContents is a different object.
|
||||
//
|
||||
// Skip is gated on the *owning toplevel*'s isFocused(),
|
||||
// not the webContents'. wc.isFocused() returns false on a
|
||||
// freshly-attached child view even when the window is
|
||||
// focused — that's exactly the state on every sloppy hover,
|
||||
// so guarding on it would never skip and the raise loop
|
||||
// would continue.
|
||||
//
|
||||
// The post-'show' grace window is the second half of the
|
||||
// story. Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland (the same trap that
|
||||
// drives the KDE-only patches in scripts/patches/
|
||||
// quick-window.sh); a tray-restore hide → show then sees
|
||||
// ownerFocused=true and a naive guard would skip, leaving
|
||||
// the window visible-but-inert (no _NET_ACTIVE_WINDOW, no
|
||||
// keyboard focus until the user clicks). Within
|
||||
// SHOW_GRACE_MS of a 'show' event we pass through
|
||||
// unconditionally, so the post-restore activation actually
|
||||
// lands. 1000 ms covers the synchronous show → focus
|
||||
// sequence with margin for slow restores.
|
||||
//
|
||||
// Trade-off: in sloppy mode, hover-induced focus events
|
||||
// are SKIPped, which suppresses both the X11 raise (the
|
||||
// bug we're fixing) and the renderer-focus direction that
|
||||
// webContents.focus() would also do. Net effect: hover
|
||||
// gives WM focus (frame highlight) but renderer focus
|
||||
// doesn't follow until the user clicks. The Electron API
|
||||
// doesn't expose a renderer-focus-only path on X11, so
|
||||
// this is the best available trade against the constant-
|
||||
// raise UX. Genuine activations (no recent show + not
|
||||
// already focused) still go through end-to-end.
|
||||
//
|
||||
// Known: deferred setTimeout focus sites (e.g. find-bar
|
||||
// dismiss) outside the grace window may lose renderer-focus
|
||||
// direction on keyboard dismissal. See #416 review.
|
||||
//
|
||||
// Fixes: #416
|
||||
const SHOW_GRACE_MS = 1000;
|
||||
const origFocus = wc.focus.bind(wc);
|
||||
wc.focus = (...args) => {
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return origFocus(...args);
|
||||
if (!owner.isFocused()) return origFocus(...args);
|
||||
const shownAt = owner._lastShownAt || 0;
|
||||
if (Date.now() - shownAt < SHOW_GRACE_MS) {
|
||||
return origFocus(...args);
|
||||
}
|
||||
return;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -793,8 +595,9 @@ Module.prototype.require = function(id) {
|
||||
return { exec: 'claude-desktop', icon: 'claude-desktop' };
|
||||
};
|
||||
|
||||
// StartupWMClass derived from Electron's app.name (upstream
|
||||
// productName) so DEs group autostarted and launched instances.
|
||||
// StartupWMClass matches the value set by scripts/packaging/{deb,rpm}.sh
|
||||
// so DEs group an autostarted window with user-launched instances
|
||||
// under the same taskbar / dock entry.
|
||||
const buildAutostartContent = () => {
|
||||
const { exec, icon } = resolveAutostartTarget();
|
||||
return `[Desktop Entry]
|
||||
@@ -802,7 +605,7 @@ Type=Application
|
||||
Name=Claude
|
||||
Exec=${exec}
|
||||
Icon=${icon}
|
||||
StartupWMClass=${result.app.name}
|
||||
StartupWMClass=Claude
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`;
|
||||
@@ -851,74 +654,6 @@ X-GNOME-Autostart-enabled=true
|
||||
console.log('[Autostart] XDG Autostart shim installed');
|
||||
}
|
||||
|
||||
// Detect in-place package upgrade (dpkg/rpm rename-replace of
|
||||
// app.asar) and offer a restart, since post-swap window loads
|
||||
// mix v(N+1) HTML/assets with the v(N) IPC/preload still in
|
||||
// memory. AppImage and Nix are immune (immutable running file);
|
||||
// the watcher just no-ops there. Fixes: see PR #564.
|
||||
const armUpgradeWatcher = () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const fs = require('fs');
|
||||
const asarPath = path.join(process.resourcesPath, 'app.asar');
|
||||
let baseline;
|
||||
try { baseline = fs.statSync(asarPath); } catch { return; }
|
||||
|
||||
let notified = false;
|
||||
let debounceTimer = null;
|
||||
const promptRestart = () => {
|
||||
if (notified) return;
|
||||
let cur;
|
||||
try { cur = fs.statSync(asarPath); } catch { return; }
|
||||
// ino catches rename-replace; mtime catches in-place
|
||||
// rewrite. Either is sufficient on its own for dpkg/rpm,
|
||||
// but checking both keeps us honest against odd packagers.
|
||||
if (cur.ino === baseline.ino
|
||||
&& cur.mtimeMs === baseline.mtimeMs) return;
|
||||
notified = true;
|
||||
console.log('[Frame Fix] app.asar replaced — prompting restart');
|
||||
// whenReady() resolves immediately if already ready, so no
|
||||
// isReady() branch needed. Linux libnotify ignores
|
||||
// Notification.actions (macOS-only), so whole-notification
|
||||
// click is the only restart affordance.
|
||||
result.app.whenReady().then(() => {
|
||||
try {
|
||||
const n = new result.Notification({
|
||||
title: 'Claude Desktop has been updated',
|
||||
body: 'Click to restart and apply the update.',
|
||||
});
|
||||
n.on('click', () => {
|
||||
result.app.relaunch();
|
||||
result.app.quit();
|
||||
});
|
||||
n.show();
|
||||
} catch (err) {
|
||||
console.warn('[Frame Fix] Restart notification failed:',
|
||||
err.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Watch the parent dir, not the file: file-level fs.watch
|
||||
// loses the inode across rename-replace. Filename filter
|
||||
// ignores unrelated activity in the resources dir; 5s
|
||||
// debounce covers dpkg's .dpkg-new → rename dance and
|
||||
// similar multi-stage swaps in rpm/Nix.
|
||||
const watcher = fs.watch(path.dirname(asarPath),
|
||||
(_evt, filename) => {
|
||||
if (filename !== 'app.asar') return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(promptRestart, 5000);
|
||||
});
|
||||
// App's other handles drive process lifetime; the watcher
|
||||
// shouldn't keep the loop alive on its own.
|
||||
watcher.unref();
|
||||
console.log('[Frame Fix] Upgrade watcher armed:', asarPath);
|
||||
};
|
||||
try { armUpgradeWatcher(); } catch (err) {
|
||||
console.warn('[Frame Fix] Upgrade watcher failed to arm:',
|
||||
err.message);
|
||||
}
|
||||
|
||||
console.log('[Frame Fix] Patches built successfully');
|
||||
}
|
||||
|
||||
@@ -938,56 +673,6 @@ X-GNOME-Autostart-enabled=true
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'powerSaveBlocker' && process.platform === 'linux') {
|
||||
// Wrap powerSaveBlocker with logging and optional suppression
|
||||
const originalPSB = target.powerSaveBlocker;
|
||||
return new Proxy(originalPSB, {
|
||||
get(psTarget, psProp) {
|
||||
if (psProp === 'start') {
|
||||
return function(type) {
|
||||
if (!KEEP_AWAKE) {
|
||||
console.log(`[Power] powerSaveBlocker.start('${type}') suppressed (CLAUDE_KEEP_AWAKE=0)`);
|
||||
return -1;
|
||||
}
|
||||
const id = psTarget.start(type);
|
||||
console.log(`[Power] powerSaveBlocker.start('${type}') -> id=${id}`);
|
||||
return id;
|
||||
};
|
||||
}
|
||||
if (psProp === 'stop') {
|
||||
return function(id) {
|
||||
if (id < 0) return;
|
||||
console.log(`[Power] powerSaveBlocker.stop(${id})`);
|
||||
return psTarget.stop(id);
|
||||
};
|
||||
}
|
||||
if (psProp === 'isStarted') {
|
||||
return function(id) {
|
||||
if (id < 0) return false;
|
||||
return psTarget.isStarted(id);
|
||||
};
|
||||
}
|
||||
return Reflect.get(psTarget, psProp);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (prop === 'autoUpdater' && process.platform === 'linux') {
|
||||
// Force autoUpdater into a no-op on Linux. Upstream's bundled
|
||||
// app code sets a feed URL of api.anthropic.com/api/desktop/linux/...
|
||||
// when app.isPackaged is true (we set ELECTRON_FORCE_IS_PACKAGED=true
|
||||
// unconditionally). Today this is a happy accident: Electron's Linux
|
||||
// autoUpdater is unimplemented and logs "AutoUpdater is not supported
|
||||
// on Linux", so the calls no-op. If a future Electron implements it,
|
||||
// every install would start hitting that feed and would either 404
|
||||
// or — worse — receive content the install wasn't prepared for.
|
||||
// .deb/.rpm/AppImage updates flow through the OS package manager
|
||||
// (or AppImageUpdate); the Anthropic feed has no Linux artifacts.
|
||||
// We replace the entire autoUpdater object with a Proxy that
|
||||
// no-ops every method and returns chainable stubs for EventEmitter
|
||||
// calls so listener registration in the bundled code is harmless.
|
||||
// See #567.
|
||||
return autoUpdaterNoop;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
# Common launcher functions for Claude Desktop (AppImage and deb)
|
||||
# This file is sourced by both launchers to avoid code duplication
|
||||
|
||||
# WM_CLASS / StartupWMClass — must match upstream productName.
|
||||
# @@WM_CLASS@@ is replaced at build time; see build.sh.
|
||||
readonly WM_CLASS='@@WM_CLASS@@'
|
||||
|
||||
# Setup logging directory and file
|
||||
# Sets: log_dir, log_file
|
||||
setup_logging() {
|
||||
@@ -20,41 +16,6 @@ log_message() {
|
||||
echo "$1" >> "$log_file"
|
||||
}
|
||||
|
||||
# Log the session/IME environment vars that drive display and input
|
||||
# decisions, so bug reports include enough context to reason about
|
||||
# them without round-trip env-dump requests (#548).
|
||||
#
|
||||
# Emits one block:
|
||||
# env={
|
||||
# KEY=value
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
# Empty or unset values are emitted as `KEY=` so absence is
|
||||
# unambiguous (vs. silently omitted). Caller must run setup_logging
|
||||
# first.
|
||||
log_session_env() {
|
||||
local key
|
||||
log_message 'env={'
|
||||
for key in \
|
||||
XDG_SESSION_TYPE \
|
||||
WAYLAND_DISPLAY \
|
||||
DISPLAY \
|
||||
XDG_CURRENT_DESKTOP \
|
||||
GTK_IM_MODULE \
|
||||
XMODIFIERS \
|
||||
QT_IM_MODULE \
|
||||
CLAUDE_USE_WAYLAND \
|
||||
CLAUDE_TITLEBAR_STYLE \
|
||||
CLAUDE_PASSWORD_STORE \
|
||||
CLAUDE_GTK_IM_MODULE \
|
||||
CLAUDE_DISABLE_GPU
|
||||
do
|
||||
log_message " $key=${!key:-}"
|
||||
done
|
||||
log_message '}'
|
||||
}
|
||||
|
||||
# Detect display backend (Wayland vs X11)
|
||||
# Sets: is_wayland, use_x11_on_wayland
|
||||
detect_display_backend() {
|
||||
@@ -106,56 +67,6 @@ _resolve_titlebar_style() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Determine the best available Chromium password-store backend.
|
||||
#
|
||||
# Electron's safeStorage API and Chromium's cookie encryption both rely
|
||||
# on the OS credential store selected by --password-store. Without a
|
||||
# working store safeStorage.isEncryptionAvailable() returns false, OAuth
|
||||
# tokens are silently discarded on exit, and users must re-authenticate
|
||||
# on every launch (Cookies file stays 0 bytes). Fixes: #593
|
||||
#
|
||||
# Detection order (first match wins):
|
||||
# CLAUDE_PASSWORD_STORE env var — explicit user override
|
||||
# kwallet6 — KDE Plasma 6 keyring
|
||||
# gnome-libsecret — GNOME Keyring / libsecret bridge
|
||||
# basic — fixed internal key (always works)
|
||||
#
|
||||
# With 'basic' the stored data is encrypted with a fixed key. Tokens
|
||||
# remain protected by Linux filesystem permissions on ~/.config/Claude/.
|
||||
#
|
||||
# Assumes a D-Bus session bus is available; this is true for any
|
||||
# graphical login session.
|
||||
_detect_password_store() {
|
||||
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
||||
echo "$CLAUDE_PASSWORD_STORE"
|
||||
return
|
||||
fi
|
||||
|
||||
# kwallet6: KDE Plasma 6 keyring
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.kde.kwalletd6 \
|
||||
/modules/kwalletd6 \
|
||||
org.kde.KWallet.isEnabled 2>/dev/null \
|
||||
| grep -q 'boolean true'
|
||||
then
|
||||
echo 'kwallet6'
|
||||
return
|
||||
fi
|
||||
|
||||
# gnome-libsecret: GNOME Keyring, KWallet 5 compat bridge, etc.
|
||||
if dbus-send --session --print-reply --reply-timeout=1000 \
|
||||
--dest=org.freedesktop.secrets \
|
||||
/org/freedesktop/secrets \
|
||||
org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1
|
||||
then
|
||||
echo 'gnome-libsecret'
|
||||
return
|
||||
fi
|
||||
|
||||
# No keyring accessible — fall back to fixed-key provider.
|
||||
echo 'basic'
|
||||
}
|
||||
|
||||
# Build Electron arguments array based on display backend
|
||||
# Requires: is_wayland, use_x11_on_wayland to be set
|
||||
# (call detect_display_backend first)
|
||||
@@ -185,23 +96,6 @@ build_electron_args() {
|
||||
electron_args+=('--disable-features=CustomTitlebar')
|
||||
fi
|
||||
|
||||
# WM_CLASS must match the .desktop StartupWMClass and upstream's
|
||||
# productName. Ref: #647, #652
|
||||
electron_args+=("--class=$WM_CLASS")
|
||||
|
||||
# Chromium's safeStorage API and cookie encryption both require a
|
||||
# system keyring selected by --password-store. Without an explicit
|
||||
# value, Electron may silently report encryption unavailable even
|
||||
# when a keyring daemon is running, discarding OAuth tokens on exit
|
||||
# and forcing re-authentication on every launch. We probe for the
|
||||
# best available store at startup 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
|
||||
@@ -213,24 +107,10 @@ build_electron_args() {
|
||||
loginctl show-session "$XDG_SESSION_ID" \
|
||||
-p Type --value 2>/dev/null
|
||||
)
|
||||
# Track GPU-disable decision so XRDP and CLAUDE_DISABLE_GPU don't
|
||||
# stack duplicate flags. Either signal is sufficient.
|
||||
local _disable_gpu=false
|
||||
if [[ -n ${XRDP_SESSION:-} || $rdp_session_type == xrdp ]]; then
|
||||
_disable_gpu=true
|
||||
electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
log_message 'XRDP session detected - GPU compositing disabled'
|
||||
fi
|
||||
# CLAUDE_DISABLE_GPU=1: opt-in workaround for users hitting the
|
||||
# Chromium GPU process FATAL exhaustion (#583). The same upstream
|
||||
# behaviour is reachable via Settings → disable hardware
|
||||
# acceleration; this lets users persist it via the env without
|
||||
# having to reach the Settings UI through repeated crashes.
|
||||
if [[ ${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
|
||||
@@ -402,15 +282,6 @@ setup_electron_env() {
|
||||
if [[ $(_resolve_titlebar_style) != 'hidden' ]]; then
|
||||
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
|
||||
fi
|
||||
# CLAUDE_GTK_IM_MODULE: opt-in override for users hit by broken
|
||||
# IBus integration on Linux (#549). Propagated to GTK_IM_MODULE
|
||||
# so e.g. `xim` can be persisted without wrapping every launch.
|
||||
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
||||
local prev="${GTK_IM_MODULE:-<unset>}"
|
||||
export GTK_IM_MODULE="$CLAUDE_GTK_IM_MODULE"
|
||||
log_message \
|
||||
"GTK_IM_MODULE override: $prev -> $GTK_IM_MODULE (via CLAUDE_GTK_IM_MODULE)"
|
||||
fi
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
|
||||
@@ -48,7 +48,6 @@ echo 'Application files copied to Electron resources directory'
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "$appdir_path/usr/lib/claude-desktop" || exit 1
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$appdir_path/usr/lib/claude-desktop/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -99,7 +98,6 @@ 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"
|
||||
@@ -134,7 +132,7 @@ Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Comment=Claude Desktop for Linux
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=$WM_CLASS
|
||||
StartupWMClass=Claude
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop
|
||||
EOF
|
||||
|
||||
@@ -70,7 +70,6 @@ echo 'Application files copied to Electron resources directory'
|
||||
# at runtime, so both must live in the same directory)
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$install_dir/lib/$package_name/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -85,7 +84,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=$WM_CLASS
|
||||
StartupWMClass=Claude
|
||||
EOF
|
||||
echo 'Desktop entry created'
|
||||
|
||||
@@ -115,7 +114,6 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -167,10 +165,13 @@ 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 (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
# Execute Electron
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
exec "\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
EOF
|
||||
chmod +x "$install_dir/bin/claude-desktop" || exit 1
|
||||
echo 'Launcher script created'
|
||||
@@ -204,7 +205,7 @@ set -e
|
||||
|
||||
# Update desktop database for MIME types
|
||||
echo "Updating desktop database..."
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox if electron is installed globally
|
||||
# or locally packaged
|
||||
|
||||
@@ -68,7 +68,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=$WM_CLASS
|
||||
StartupWMClass=Claude
|
||||
EOF
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
@@ -97,7 +97,6 @@ cleanup_stale_cowork_socket
|
||||
log_message '--- Claude Desktop Launcher Start ---'
|
||||
log_message "Timestamp: \$(date)"
|
||||
log_message "Arguments: \$@"
|
||||
log_session_env
|
||||
|
||||
# Check for display
|
||||
if ! check_display; then
|
||||
@@ -149,10 +148,13 @@ 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 (exec replaces the shell process so signals
|
||||
# like SIGINT, SIGTERM, and SIGHUP reach Electron directly)
|
||||
# Execute Electron
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
exec "\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
EOF
|
||||
chmod +x "$staging_dir/claude-desktop"
|
||||
|
||||
@@ -218,7 +220,6 @@ cp -r $app_staging_dir/app.asar.unpacked %{buildroot}/usr/lib/$package_name/node
|
||||
# Copy shared launcher library (launcher-common.sh sources doctor.sh
|
||||
# at runtime, so both must live in the same directory)
|
||||
cp $(dirname "$script_dir")/launcher-common.sh %{buildroot}/usr/lib/$package_name/
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "%{buildroot}/usr/lib/$package_name/launcher-common.sh"
|
||||
cp $(dirname "$script_dir")/doctor.sh %{buildroot}/usr/lib/$package_name/
|
||||
|
||||
# Install desktop entry
|
||||
@@ -227,18 +228,21 @@ 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 2>&1 || true
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox
|
||||
SANDBOX_PATH="/usr/lib/$package_name/node_modules/electron/dist/chrome-sandbox"
|
||||
if [ -f "\$SANDBOX_PATH" ]; then
|
||||
echo "Setting chrome-sandbox permissions..."
|
||||
chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox"
|
||||
chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox"
|
||||
fi
|
||||
|
||||
%postun
|
||||
# Update desktop database after removal
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
|
||||
%files
|
||||
%defattr(-, root, root, 0755)
|
||||
@@ -253,26 +257,14 @@ echo 'RPM spec file created'
|
||||
# --- Build RPM Package ---
|
||||
echo 'Building RPM package...'
|
||||
|
||||
rpmbuild_log="$work_dir/rpmbuild.log"
|
||||
rpmbuild --define "_topdir $rpmbuild_dir" \
|
||||
if ! rpmbuild --define "_topdir $rpmbuild_dir" \
|
||||
--define "_rpmdir $work_dir" \
|
||||
--target "$rpm_arch" \
|
||||
-bb "$rpmbuild_dir/SPECS/$package_name.spec" 2>&1 |
|
||||
tee "$rpmbuild_log"
|
||||
if (( PIPESTATUS[0] != 0 )); then
|
||||
-bb "$rpmbuild_dir/SPECS/$package_name.spec"; then
|
||||
echo 'Failed to build RPM package' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Guard against re-introducing #609. The "File listed twice" warning
|
||||
# means %files has overlapping listings, and on modern rpmbuild any
|
||||
# %exclude workaround silently strips the file from the payload.
|
||||
if grep -qF 'File listed twice' "$rpmbuild_log"; then
|
||||
echo 'rpmbuild emitted "File listed twice" — %files has overlapping listings (see #609)' >&2
|
||||
grep -F 'File listed twice' "$rpmbuild_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find and move the built RPM (it will be in a subdirectory)
|
||||
rpm_file=$(find "$work_dir" -name "${package_name}-${rpm_version}*.rpm" -type f | head -n 1)
|
||||
if [[ -z $rpm_file ]]; then
|
||||
|
||||
@@ -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,32 +37,16 @@ EOFENTRY
|
||||
|
||||
# Update package.json
|
||||
echo 'Modifying package.json to load frame fix and add node-pty...'
|
||||
local desktop_name='claude-desktop.desktop'
|
||||
if [[ ${build_format:-} == 'appimage' ]]; then
|
||||
desktop_name='io.github.aaddrick.claude-desktop-debian.desktop'
|
||||
fi
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = require('./app.asar.contents/package.json');
|
||||
pkg.originalMain = pkg.main;
|
||||
pkg.main = 'frame-fix-entry.js';
|
||||
pkg.desktopName = process.argv[1];
|
||||
pkg.optionalDependencies = pkg.optionalDependencies || {};
|
||||
pkg.optionalDependencies['node-pty'] = '^1.0.0';
|
||||
fs.writeFileSync('./app.asar.contents/package.json', JSON.stringify(pkg, null, 2));
|
||||
console.log('Updated package.json: main entry, desktopName, and node-pty dependency');
|
||||
" "$desktop_name"
|
||||
|
||||
# Fail fast if upstream changed productName — a mismatch silently
|
||||
# breaks StartupWMClass in every .desktop file we ship.
|
||||
local product_name
|
||||
product_name=$(node -e \
|
||||
"console.log(require('./app.asar.contents/package.json').productName)")
|
||||
if [[ $product_name != "$WM_CLASS" ]]; then
|
||||
echo "Error: upstream productName '$product_name' != WM_CLASS" \
|
||||
"'$WM_CLASS' — update WM_CLASS in build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
console.log('Updated package.json: main entry and node-pty dependency');
|
||||
"
|
||||
|
||||
# Create stub native module
|
||||
echo 'Creating stub native module...'
|
||||
@@ -103,17 +87,9 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
|
||||
# Add Linux Claude Code support
|
||||
patch_linux_claude_code
|
||||
|
||||
# Reject .asar paths in the directory-check helper so Electron's
|
||||
# ASAR VFS shim doesn't misidentify app.asar as a folder and
|
||||
# trigger false Cowork dispatch (#383, #622, #632).
|
||||
patch_asar_path_filter
|
||||
|
||||
# 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
|
||||
@@ -121,17 +97,6 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
|
||||
# docs/learnings/linux-topbar-shim.md.
|
||||
patch_wco_shim
|
||||
|
||||
# Preserve externally-added mcpServers across config writes (#400)
|
||||
patch_config_write_merge
|
||||
|
||||
# Reject .asar paths in addTrustedFolder to reduce spurious config
|
||||
# writes that amplify the stale-cache overwrite bug (#400)
|
||||
patch_asar_trusted_folder_guard
|
||||
|
||||
# Filter .asar paths from --add-dir dispatch and session restore
|
||||
# so corrupted pre-#640 sessions cannot crash local agent mode (#649)
|
||||
patch_asar_additional_dirs_guard
|
||||
|
||||
# Copy cowork VM service daemon for Linux Cowork mode
|
||||
echo 'Installing cowork VM service daemon...'
|
||||
cp "$source_dir/scripts/cowork-vm-service.js" \
|
||||
|
||||
@@ -16,12 +16,12 @@ patch_linux_claude_code() {
|
||||
|
||||
# New format (Claude >= 1.1.3541): getHostPlatform includes arch detection for win32
|
||||
# Pattern: if(process.platform==="win32")return e==="arm64"?"win32-arm64":"win32-x64";throw new Error(...)
|
||||
if grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+[$\w]+\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+([[:alnum:]_$]+)\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
if grep -qP 'if\(process\.platform==="win32"\)return \w+==="arm64"\?"win32-arm64":"win32-x64";throw' "$index_js"; then
|
||||
sed -i -E 's/if\(process\.platform==="win32"\)return (\w+)==="arm64"\?"win32-arm64":"win32-x64";throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
echo 'Added linux claude code support (new arch-aware format)'
|
||||
# Old format (Claude <= 1.1.3363): no arch detection for win32
|
||||
elif grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
elif grep -q 'if(process.platform==="win32")return"win32-x64";' "$index_js"; then
|
||||
sed -i 's/if(process.platform==="win32")return"win32-x64";/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
echo 'Added linux claude code support (legacy format)'
|
||||
else
|
||||
echo 'Warning: Could not find getHostPlatform pattern to patch for Linux claude code support'
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
#===============================================================================
|
||||
# Config-related patches: preserve externally-added mcpServers across config
|
||||
# writes, guard addTrustedFolder against .asar paths, and filter .asar entries
|
||||
# from the --add-dir CLI dispatch and session restore.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: project_root
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_config_write_merge() {
|
||||
echo 'Patching config writer to preserve mcpServers from disk...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -q '_cdd_dc' "$index_js"; then
|
||||
echo ' mcpServers merge already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract variable names from the unique anchor:
|
||||
# await WRITE_FN(PATH_VAR, CONFIG_VAR), LOGGER.info("Config file written")
|
||||
local write_fn path_var config_var write_fn_re path_var_re
|
||||
|
||||
write_fn=$(grep -oP \
|
||||
'await \K[$\w]+(?=\([$\w]+,\s*[$\w]+\)\s*,\s*[$\w]+\.info\("Config file written"\))' \
|
||||
"$index_js")
|
||||
if [[ -z $write_fn ]]; then
|
||||
echo ' Could not extract write function name — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
write_fn_re="${write_fn//\$/\\$}"
|
||||
|
||||
path_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(\\K[\$\\w]+(?=,\\s*[\$\\w]+\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract path variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
path_var_re="${path_var//\$/\\$}"
|
||||
|
||||
config_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(${path_var_re},\\s*\\K[\$\\w]+(?=\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $config_var ]]; then
|
||||
echo ' Could not extract config variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
echo " Write fn: $write_fn, path: $path_var, config: $config_var"
|
||||
|
||||
if ! WRITE_FN="$write_fn" PATH_VAR="$path_var" CFG_VAR="$config_var" \
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const W = process.env.WRITE_FN;
|
||||
const P = process.env.PATH_VAR;
|
||||
const C = process.env.CFG_VAR;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const reEsc = (s) => s.replace(/[.*+?\${}()|[\\]\\\\]/g, '\\\\\$&');
|
||||
const anchor = new RegExp(
|
||||
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
|
||||
'\\\\)\\\\s*,\\\\s*\\\\w+\\\\.info\\\\(\"Config file written\"\\\\)'
|
||||
);
|
||||
if (!anchor.test(code)) {
|
||||
console.error(' [FAIL] Config-write anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const merge =
|
||||
'try{var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(' + P +
|
||||
',\"utf8\"));if(_cdd_dc.mcpServers){' + C +
|
||||
'.mcpServers=Object.assign({},_cdd_dc.mcpServers,' + C +
|
||||
'.mcpServers||{})}}catch(_cdd_ex){}';
|
||||
|
||||
code = code.replace(anchor, (m) => merge + ';' + m);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] mcpServers merge injected before config write');
|
||||
"; then
|
||||
echo 'Failed to inject config write merge' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_asar_trusted_folder_guard() {
|
||||
echo 'Patching addTrustedFolder to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -qF 'endsWith(".asar"))return' "$index_js"; then
|
||||
echo ' .asar guard already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
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 '##############################################################'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: filter .asar paths from --add-dir CLI dispatch and session restore
|
||||
#
|
||||
# PR #640 guards the directory-check helper and addTrustedFolder IPC
|
||||
# handler, but .asar paths in corrupted pre-#640 sessions survive
|
||||
# restore (existsSync passes via Electron's ASAR VFS shim) and reach
|
||||
# additionalDirectories -> --add-dir -> fatal Claude Code error.
|
||||
#
|
||||
# Fix: two sub-patches:
|
||||
# 1. Filter at the --add-dir CLI dispatch loop (the single convergence
|
||||
# point for ALL code paths that feed additionalDirectories).
|
||||
# 2. Filter at session restore to self-heal corrupted persisted state.
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_additional_dirs_guard() {
|
||||
echo 'Patching --add-dir dispatch to reject .asar paths (#649)...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency
|
||||
if grep -qF '.filter(_d=>!_d.endsWith(".asar"))' "$index_js"; then
|
||||
echo ' .asar --add-dir filter already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_ADDDIR_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
let patchCount = 0;
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 1: Filter .asar from --add-dir loop
|
||||
//
|
||||
// Target (unique, 1 occurrence):
|
||||
// for (let O of A) Y.push("--add-dir", O);
|
||||
// Fallback (if minifier uses .forEach):
|
||||
// A.forEach(O=>Y.push("--add-dir",O))
|
||||
// ================================================================
|
||||
{
|
||||
// Primary: for...of pattern
|
||||
const forOfRe = /for\s*\(\s*let\s+([\w$]+)\s+of\s+([\w$]+)\s*\)\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\1\s*\)/;
|
||||
// Fallback: .forEach pattern
|
||||
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/;
|
||||
|
||||
let match = code.match(forOfRe);
|
||||
let variant = 'for-of';
|
||||
if (!match) {
|
||||
match = code.match(forEachRe);
|
||||
variant = 'forEach';
|
||||
}
|
||||
if (!match) {
|
||||
console.error('FATAL: --add-dir dispatch loop not found.');
|
||||
console.error(' for(let X of Y) Z.push("--add-dir", X)');
|
||||
console.error(' Y.forEach(X=>Z.push("--add-dir", X))');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Count assertion: exactly 1 match expected
|
||||
const escaped = match[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allMatches = code.match(new RegExp(escaped, 'g'));
|
||||
if (allMatches && allMatches.length > 1) {
|
||||
console.error('FATAL: --add-dir pattern matches ' +
|
||||
allMatches.length + ' times (expected 1).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let filtered;
|
||||
if (variant === 'for-of') {
|
||||
const [, iterVar, arrVar, pushTarget] = match;
|
||||
filtered = 'for(let ' + iterVar + ' of ' + arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")))' +
|
||||
pushTarget + '.push("--add-dir",' + iterVar + ')';
|
||||
} else {
|
||||
const [, arrVar, iterVar, pushTarget] = match;
|
||||
filtered = arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
|
||||
iterVar + '=>' + pushTarget +
|
||||
'.push("--add-dir",' + iterVar + '))';
|
||||
}
|
||||
code = code.replace(match[0], filtered);
|
||||
console.log(' Filtered --add-dir dispatch (' +
|
||||
variant + ' variant)');
|
||||
patchCount++;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 2: Filter .asar from session restore
|
||||
//
|
||||
// Anchor: "Filtering out deleted folder from session" (unique)
|
||||
// Target: (VAR.userSelectedFolders||[]).filter(
|
||||
// Insert: .filter(l=>!l.endsWith(".asar")) before existing .filter(
|
||||
// ================================================================
|
||||
{
|
||||
const warn = (msg) => console.log(' WARNING: ' + msg +
|
||||
' (primary --add-dir filter still protects)');
|
||||
|
||||
const anchorIdx = code.indexOf(
|
||||
'Filtering out deleted folder from session');
|
||||
if (anchorIdx === -1) {
|
||||
warn('session restore anchor not found');
|
||||
} else {
|
||||
const searchStart = Math.max(0, anchorIdx - 500);
|
||||
const region = code.substring(searchStart, anchorIdx);
|
||||
const usIdx = region.lastIndexOf('userSelectedFolders');
|
||||
if (usIdx === -1) {
|
||||
warn('userSelectedFolders not found near anchor');
|
||||
} else {
|
||||
const absUsIdx = searchStart + usIdx;
|
||||
const afterUs = code.substring(absUsIdx, anchorIdx);
|
||||
const bracketMatch = afterUs.match(/\|\|\s*\[\s*\]\s*\)/);
|
||||
if (!bracketMatch) {
|
||||
warn('||[]) pattern not found');
|
||||
} else {
|
||||
const insertAt = absUsIdx + bracketMatch.index +
|
||||
bracketMatch[0].length;
|
||||
const peek = code.substring(insertAt, insertAt + 20);
|
||||
if (!peek.match(/^\s*\.filter\s*\(/)) {
|
||||
warn('.filter( not found after ||[])');
|
||||
} else if (code.substring(
|
||||
insertAt - 50, insertAt + 50
|
||||
).includes('!l.endsWith(".asar")')) {
|
||||
console.log(' Session restore filter ' +
|
||||
'already present');
|
||||
} else {
|
||||
code = code.substring(0, insertAt) +
|
||||
'.filter(l=>!l.endsWith(".asar"))' +
|
||||
code.substring(insertAt);
|
||||
console.log(' Injected .asar filter in ' +
|
||||
'session restore');
|
||||
patchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Applied ' + patchCount +
|
||||
' .asar additionalDirectories patch(es)');
|
||||
if (patchCount < 1) {
|
||||
console.error('FATAL: No patches applied — --add-dir filter ' +
|
||||
'must succeed (#649).');
|
||||
process.exit(1);
|
||||
}
|
||||
ASAR_ADDDIR_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar --add-dir filter patch failed' >&2
|
||||
echo 'Local agent mode will crash without this patch (#649).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
@@ -9,95 +9,6 @@
|
||||
# Modifies globals: node_pty_build_dir
|
||||
#===============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: reject .asar paths in the directory-check helper
|
||||
#
|
||||
# On Linux, app.asar is passed as an argv element to Electron. The
|
||||
# directory-check function (wFA in the current build) calls
|
||||
# fs.statSync(path).isDirectory(). Electron's ASAR virtual filesystem
|
||||
# shim makes .asar archives report isDirectory()===true, so app.asar
|
||||
# is dispatched to Cowork as a "folder drop". This causes:
|
||||
# - Permission dialog on every launch (#383)
|
||||
# - Forced Cowork mode (#622)
|
||||
# - Fatal --add-dir error in Claude Code >=2.1.111 (#632)
|
||||
#
|
||||
# Fix: inject !PARAM.endsWith(".asar")&& before the statSync call.
|
||||
# This runs independently of the Cowork-mode guard (the function
|
||||
# exists even if Cowork code is absent).
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_path_filter() {
|
||||
echo 'Patching directory check to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_FILTER_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
|
||||
// Find the directory-check helper function.
|
||||
// Beautified form:
|
||||
// function wFA(e) {
|
||||
// try { return ee.statSync(e).isDirectory(); }
|
||||
// catch { return !1; }
|
||||
// }
|
||||
// Minified form:
|
||||
// function wFA(e){try{return ee.statSync(e).isDirectory()}catch{return!1}}
|
||||
//
|
||||
// Stable anchors: .statSync( ).isDirectory() inside try/catch returning !1.
|
||||
// The function name, parameter, and fs variable are all minified.
|
||||
const dirCheckRe =
|
||||
/function\s+([\w$]+)\s*\(\s*([\w$]+)\s*\)\s*\{\s*try\s*\{\s*return\s+([\w$]+)\.statSync\(\s*\2\s*\)\.isDirectory\(\)/;
|
||||
const match = code.match(dirCheckRe);
|
||||
|
||||
if (!match) {
|
||||
console.error('FATAL: Could not find directory-check function' +
|
||||
' (statSync+isDirectory pattern).');
|
||||
console.error('This patch prevents .asar paths from triggering' +
|
||||
' false Cowork dispatch (#383, #622, #632).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [, funcName, paramName] = match;
|
||||
console.log(' Found directory-check function: ' + funcName +
|
||||
'(' + paramName + ')');
|
||||
|
||||
// Idempotency: check if already patched
|
||||
if (code.includes('.endsWith(".asar")')) {
|
||||
console.log(' .asar path filter already applied');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Insert the guard: !PARAM.endsWith(".asar")&&
|
||||
// Before: return FSVAR.statSync(PARAM).isDirectory()
|
||||
// After: return!PARAM.endsWith(".asar")&&FSVAR.statSync(PARAM).isDirectory()
|
||||
//
|
||||
// The replacement is scoped to the matched function via the full
|
||||
// regex match, so it cannot accidentally hit other statSync calls.
|
||||
code = code.replace(dirCheckRe, (whole, fn, param, fsVar) => {
|
||||
return 'function ' + fn + '(' + param + '){try{return!' +
|
||||
param + '.endsWith(".asar")&&' +
|
||||
fsVar + '.statSync(' + param + ').isDirectory()';
|
||||
});
|
||||
|
||||
// Verify the patch landed
|
||||
if (!code.includes('.endsWith(".asar")')) {
|
||||
console.error('FATAL: .asar path filter replacement failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Added .asar path rejection to ' + funcName + '()');
|
||||
ASAR_FILTER_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar path filter patch failed' >&2
|
||||
echo 'The app will show permission dialogs and may crash' \
|
||||
'without this patch (#383, #622, #632).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_cowork_linux() {
|
||||
echo 'Patching Cowork mode for Linux...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
@@ -140,7 +51,7 @@ function extractBlock(str, startIdx, open = '{') {
|
||||
// Pattern: VAR!=="darwin"&&VAR!=="win32" (unique in platform gate)
|
||||
// Anchor: appears near 'unsupported_platform' code value
|
||||
// ============================================================
|
||||
const platformGateRe = /([\w$]+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
|
||||
const platformGateRe = /(\w+)(\s*!==\s*"darwin"\s*&&\s*)\1(\s*!==\s*"win32")/g;
|
||||
const origCode = code;
|
||||
code = code.replace(platformGateRe, (match, varName, mid, end) => {
|
||||
// Only patch the instance near the "unsupported_platform" code value
|
||||
@@ -156,10 +67,10 @@ if (code !== origCode) {
|
||||
patchCount++;
|
||||
} else {
|
||||
// Try without backreference (in case minifier uses different var names)
|
||||
const simpleRe = /(!=="darwin"\s*&&\s*[\w$]+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
|
||||
const simpleRe = /(!=="darwin"\s*&&\s*\w+\s*!=="win32")([\s\S]{0,200}unsupported_platform)/;
|
||||
const simpleMatch = code.match(simpleRe);
|
||||
if (simpleMatch) {
|
||||
const varMatch = simpleMatch[0].match(/([\w$]+)\s*!==\s*"win32"/);
|
||||
const varMatch = simpleMatch[0].match(/(\w+)\s*!==\s*"win32"/);
|
||||
if (varMatch) {
|
||||
code = code.replace(simpleMatch[1],
|
||||
simpleMatch[1] + '&&' + varMatch[1] + '!=="linux"');
|
||||
@@ -180,7 +91,7 @@ if (code === origCode) {
|
||||
// Anchor: unique string "vmClient (TypeScript)"
|
||||
// Extracts the win32 platform variable, adds Linux OR condition
|
||||
// ============================================================
|
||||
const vmClientLogMatch = code.match(/([\w$]+)(\s*\?\s*"vmClient \(TypeScript\)")/);
|
||||
const vmClientLogMatch = code.match(/(\w+)(\s*\?\s*"vmClient \(TypeScript\)")/);
|
||||
if (vmClientLogMatch) {
|
||||
const win32Var = vmClientLogMatch[1];
|
||||
|
||||
@@ -198,13 +109,6 @@ if (vmClientLogMatch) {
|
||||
'(' + win32Var + '||process.platform==="linux")$1');
|
||||
console.log(' Patched VM client log check for Linux');
|
||||
patchCount++;
|
||||
} else if (code.includes(
|
||||
'||process.platform==="linux")?"vmClient (TypeScript)"'
|
||||
)) {
|
||||
console.log(' VM client log gate already applied (Patch 2a)');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find anchor for VM client log' +
|
||||
' gate (Patch 2a) — half-patched asar will fail Cowork startup');
|
||||
}
|
||||
|
||||
// 2b: Patch the actual module assignment
|
||||
@@ -221,12 +125,6 @@ if (vmClientLogMatch) {
|
||||
'(' + win32Var + '||process.platform==="linux")$1');
|
||||
console.log(' Patched VM module assignment for Linux');
|
||||
patchCount++;
|
||||
} else if (/\|\|process\.platform==="linux"\)\??\(?[\w$]+=\{vm:[\w$]+\}/.test(code)) {
|
||||
console.log(' VM module assignment already applied (Patch 2b)');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find anchor for VM module' +
|
||||
' assignment (Patch 2b) — half-patched asar will fail' +
|
||||
' Cowork startup (PR #555 failure mode)');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find vmClient variable for module loading patch');
|
||||
@@ -236,7 +134,7 @@ if (vmClientLogMatch) {
|
||||
// Patch 3: Socket path - use Unix domain socket on Linux
|
||||
// Anchor: unique string "cowork-vm-service" in pipe path
|
||||
// ============================================================
|
||||
const pipeMatch = code.match(/([\w$]+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
||||
const pipeMatch = code.match(/(\w+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
|
||||
if (pipeMatch) {
|
||||
const pipeVar = pipeMatch[1];
|
||||
const assign = pipeMatch[2];
|
||||
@@ -315,7 +213,7 @@ if (!code.includes('"linux":{') && !code.includes("'linux':{") &&
|
||||
// calls download() which returns success immediately).
|
||||
// ============================================================
|
||||
{
|
||||
const statusRe = /getDownloadStatus\(\)\{return\s+([\w$]+\(\)\?([\w$]+)\.Downloading:[\w$]+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
|
||||
const statusRe = /getDownloadStatus\(\)\{return\s+(\w+\(\)\?(\w+)\.Downloading:\w+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
|
||||
const statusMatch = code.match(statusRe);
|
||||
if (statusMatch) {
|
||||
const [whole, origExpr, enumVar] = statusMatch;
|
||||
@@ -368,104 +266,96 @@ if (serviceErrorIdx !== -1) {
|
||||
// Step 1: Find the ENOENT check and expand it to include ECONNREFUSED
|
||||
// Pattern: VAR.code==="ENOENT"
|
||||
// Search backwards from the error string to find it
|
||||
if (/process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED"/.test(code)) {
|
||||
console.log(' ENOENT/ECONNREFUSED expansion already applied');
|
||||
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
||||
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
||||
const enoentRe = /(\w+)\.code\s*===\s*"ENOENT"/g;
|
||||
let enoentMatch;
|
||||
let lastEnoent = null;
|
||||
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
||||
lastEnoent = enoentMatch;
|
||||
}
|
||||
if (lastEnoent) {
|
||||
const enoentStr = lastEnoent[0];
|
||||
const errVar = lastEnoent[1];
|
||||
const enoentAbsIdx = searchStart + lastEnoent.index;
|
||||
// Replace: VAR.code==="ENOENT"
|
||||
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
||||
const expanded =
|
||||
'(' + enoentStr +
|
||||
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
||||
code = code.substring(0, enoentAbsIdx) +
|
||||
expanded +
|
||||
code.substring(enoentAbsIdx + enoentStr.length);
|
||||
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
||||
} else {
|
||||
const searchStart = Math.max(0, serviceErrorIdx - 300);
|
||||
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
|
||||
const enoentRe = /([\w$]+)\.code\s*===\s*"ENOENT"/g;
|
||||
let enoentMatch;
|
||||
let lastEnoent = null;
|
||||
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
|
||||
lastEnoent = enoentMatch;
|
||||
}
|
||||
if (lastEnoent) {
|
||||
const enoentStr = lastEnoent[0];
|
||||
const errVar = lastEnoent[1];
|
||||
const enoentAbsIdx = searchStart + lastEnoent.index;
|
||||
// Replace: VAR.code==="ENOENT"
|
||||
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
|
||||
const expanded =
|
||||
'(' + enoentStr +
|
||||
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
|
||||
code = code.substring(0, enoentAbsIdx) +
|
||||
expanded +
|
||||
code.substring(enoentAbsIdx + enoentStr.length);
|
||||
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
||||
}
|
||||
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
|
||||
}
|
||||
|
||||
// Step 2: Inject auto-launch before the retry delay
|
||||
if (code.includes('cowork-autolaunch')) {
|
||||
console.log(' Service daemon auto-launch already applied');
|
||||
} else {
|
||||
// Re-find serviceErrorStr since indices shifted after step 1
|
||||
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
|
||||
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
|
||||
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
|
||||
const retryMatch = searchRegion.match(
|
||||
/await new Promise\(([\w$]+)=>\s*setTimeout\(\1,\s*([\w$]+)\)\)/
|
||||
);
|
||||
if (retryMatch) {
|
||||
const retryStr = retryMatch[0];
|
||||
const retryOffset = searchRegion.indexOf(retryStr);
|
||||
const retryAbsIdx = newServiceErrorIdx + retryOffset;
|
||||
// Inject auto-launch before the retry delay
|
||||
// Service script is in app.asar.unpacked/ (not inside asar, since
|
||||
// child_process cannot execute scripts from inside an asar).
|
||||
// Uses fork() instead of spawn() because process.execPath in Electron
|
||||
// is the Electron binary - spawn would trigger "file open" handling
|
||||
// instead of executing the script as Node.js.
|
||||
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
|
||||
// Extract the enclosing function name (Ma or whatever it's
|
||||
// minified to) so the dedup guard attaches to it
|
||||
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
|
||||
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
|
||||
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
|
||||
const funcNameRe = /async function (\w+)\s*\(\s*\w+\s*,\s*\w+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
|
||||
let funcMatch;
|
||||
let retryFuncName = null;
|
||||
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
|
||||
retryFuncName = funcMatch[1];
|
||||
}
|
||||
const spawnGuard = retryFuncName
|
||||
? retryFuncName + '._lastSpawn'
|
||||
: '_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');
|
||||
// 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');
|
||||
@@ -485,7 +375,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// toward recovery over re-download avoidance is correct.
|
||||
// ============================================================
|
||||
{
|
||||
const reinstallArrRe = /const ([\w$]+)=\[("rootfs\.img"[^\]]*)\];/;
|
||||
const reinstallArrRe = /const (\w+)=\[("rootfs\.img"[^\]]*)\];/;
|
||||
const arrMatch = code.match(reinstallArrRe);
|
||||
if (arrMatch) {
|
||||
const [whole, name, contents] = arrMatch;
|
||||
@@ -531,7 +421,7 @@ if (serviceErrorIdx !== -1) {
|
||||
{
|
||||
// Find: MKDTEMP(PATH.join(OS.tmpdir(), "wvm-"))
|
||||
// The bundle dir var is used in mkdir(VAR, ...) just before
|
||||
const mkdtempRe = /([\w$]+)\.mkdtemp\(\s*([\w$]+)\.join\(\s*([\w$]+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
|
||||
const mkdtempRe = /(\w+)\.mkdtemp\(\s*(\w+)\.join\(\s*(\w+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
|
||||
const mkdtempMatch = code.match(mkdtempRe);
|
||||
if (mkdtempMatch) {
|
||||
const [fullMatch, fsVar, pathVar, osVar] = mkdtempMatch;
|
||||
@@ -540,7 +430,7 @@ if (serviceErrorIdx !== -1) {
|
||||
const searchStart = Math.max(0, mkdtempIdx - 2000);
|
||||
const before = code.substring(searchStart, mkdtempIdx);
|
||||
// Look for: mkdir(VARNAME, { recursive
|
||||
const mkdirRe = /([\w$]+)\.mkdir\(\s*([\w$]+)\s*,\s*\{\s*recursive/g;
|
||||
const mkdirRe = /(\w+)\.mkdir\(\s*(\w+)\s*,\s*\{\s*recursive/g;
|
||||
let bundleVar = null;
|
||||
let lastMkdir;
|
||||
while ((lastMkdir = mkdirRe.exec(before)) !== null) {
|
||||
@@ -575,122 +465,118 @@ if (serviceErrorIdx !== -1) {
|
||||
// since minified names change between releases (#344).
|
||||
// ============================================================
|
||||
{
|
||||
if (code.includes('[VM:start] Copying smol-bin') && code.includes('process.platform==="linux"')) {
|
||||
console.log(' Linux smol-bin copy block already present');
|
||||
} else {
|
||||
const anchor = '"[VM:start] Windows VM service configured"';
|
||||
const anchorIdx = code.indexOf(anchor);
|
||||
if (anchorIdx !== -1) {
|
||||
// Find the "}" closing the win32 if-block after the anchor
|
||||
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
|
||||
if (closingBrace !== -1) {
|
||||
// Extract minified variable names from the win32 block
|
||||
// Search backwards from anchor to find the win32 block
|
||||
const regionStart = Math.max(0, anchorIdx - 1000);
|
||||
const region = code.substring(regionStart, anchorIdx);
|
||||
const anchor = '"[VM:start] Windows VM service configured"';
|
||||
const anchorIdx = code.indexOf(anchor);
|
||||
if (anchorIdx !== -1) {
|
||||
// Find the "}" closing the win32 if-block after the anchor
|
||||
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
|
||||
if (closingBrace !== -1) {
|
||||
// Extract minified variable names from the win32 block
|
||||
// Search backwards from anchor to find the win32 block
|
||||
const regionStart = Math.max(0, anchorIdx - 1000);
|
||||
const region = code.substring(regionStart, anchorIdx);
|
||||
|
||||
// JS identifier may start with $, _, or letter; \w doesn't
|
||||
// match $ so use [$\w]+ to capture vars like `$e` (Claude
|
||||
// >= 1.3109.0 uses $e for the fs module to avoid collision
|
||||
// with the parameter `e`). See issue #418.
|
||||
// path var: VAR.join(process.resourcesPath,
|
||||
const pathMatch = region.match(
|
||||
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
|
||||
);
|
||||
// fs var: VAR.existsSync(
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
// logger var: VAR.info("[VM:start]
|
||||
const logMatch = region.match(
|
||||
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
|
||||
);
|
||||
// stream/pipeline var: VAR.pipeline(
|
||||
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
|
||||
// arch function: const VAR=FUNC(), used in smol-bin
|
||||
const archMatch = region.match(
|
||||
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
|
||||
);
|
||||
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
|
||||
const bundleMatch = region.match(
|
||||
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
|
||||
);
|
||||
// JS identifier may start with $, _, or letter; \w doesn't
|
||||
// match $ so use [$\w]+ to capture vars like `$e` (Claude
|
||||
// >= 1.3109.0 uses $e for the fs module to avoid collision
|
||||
// with the parameter `e`). See issue #418.
|
||||
// path var: VAR.join(process.resourcesPath,
|
||||
const pathMatch = region.match(
|
||||
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
|
||||
);
|
||||
// fs var: VAR.existsSync(
|
||||
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
|
||||
// logger var: VAR.info("[VM:start]
|
||||
const logMatch = region.match(
|
||||
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
|
||||
);
|
||||
// stream/pipeline var: VAR.pipeline(
|
||||
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
|
||||
// arch function: const VAR=FUNC(), used in smol-bin
|
||||
const archMatch = region.match(
|
||||
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
|
||||
);
|
||||
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
|
||||
const bundleMatch = region.match(
|
||||
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
|
||||
);
|
||||
|
||||
if (pathMatch && fsMatch && logMatch &&
|
||||
streamMatch && archMatch && bundleMatch) {
|
||||
const pathVar = pathMatch[1];
|
||||
const fsVar = fsMatch[1];
|
||||
const logVar = logMatch[1];
|
||||
const streamVar = streamMatch[1];
|
||||
const archFunc = archMatch[2];
|
||||
const bundleVar = bundleMatch[1];
|
||||
if (pathMatch && fsMatch && logMatch &&
|
||||
streamMatch && archMatch && bundleMatch) {
|
||||
const pathVar = pathMatch[1];
|
||||
const fsVar = fsMatch[1];
|
||||
const logVar = logMatch[1];
|
||||
const streamVar = streamMatch[1];
|
||||
const archFunc = archMatch[2];
|
||||
const bundleVar = bundleMatch[1];
|
||||
|
||||
const linuxBlock =
|
||||
'if(process.platform==="linux"){' +
|
||||
'const _la=' + archFunc + '(),' +
|
||||
'_ls=' + pathVar + '.join(process.resourcesPath,' +
|
||||
'`smol-bin.${_la}.vhdx`),' +
|
||||
'_ld=' + pathVar + '.join(' + bundleVar +
|
||||
',"smol-bin.vhdx");' +
|
||||
fsVar + '.existsSync(_ls)?' +
|
||||
'(' + logVar + '.info(' +
|
||||
'`[VM:start] Copying smol-bin.${_la}' +
|
||||
'.vhdx to bundle (Linux)`),' +
|
||||
'await ' + streamVar + '.pipeline(' +
|
||||
fsVar + '.createReadStream(_ls),' +
|
||||
fsVar + '.createWriteStream(_ld)),' +
|
||||
logVar + '.info(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx copied successfully`))' +
|
||||
':' + logVar + '.warn(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx not found at ${_ls}`)' +
|
||||
'}';
|
||||
// Defensive: if a future upstream emits its own
|
||||
// if(process.platform==="linux"){...} block right
|
||||
// after the win32 close brace, strip it before
|
||||
// injecting our correctly-wired linuxBlock so we
|
||||
// don't end up with two competing blocks.
|
||||
const insertPos = closingBrace + 1;
|
||||
let stripUntil = insertPos;
|
||||
const afterWin32 = code.substring(insertPos);
|
||||
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
|
||||
const upstreamMatch = afterWin32.match(upstreamRe);
|
||||
if (upstreamMatch) {
|
||||
const matchEnd = insertPos + upstreamMatch[0].length;
|
||||
let depth = 1, pos = matchEnd;
|
||||
while (depth > 0 && pos < code.length) {
|
||||
if (code[pos] === '{') depth++;
|
||||
else if (code[pos] === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
if (depth === 0) {
|
||||
stripUntil = pos;
|
||||
console.log(' Stripped pre-existing upstream Linux block');
|
||||
} else {
|
||||
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
|
||||
}
|
||||
const linuxBlock =
|
||||
'if(process.platform==="linux"){' +
|
||||
'const _la=' + archFunc + '(),' +
|
||||
'_ls=' + pathVar + '.join(process.resourcesPath,' +
|
||||
'`smol-bin.${_la}.vhdx`),' +
|
||||
'_ld=' + pathVar + '.join(' + bundleVar +
|
||||
',"smol-bin.vhdx");' +
|
||||
fsVar + '.existsSync(_ls)?' +
|
||||
'(' + logVar + '.info(' +
|
||||
'`[VM:start] Copying smol-bin.${_la}' +
|
||||
'.vhdx to bundle (Linux)`),' +
|
||||
'await ' + streamVar + '.pipeline(' +
|
||||
fsVar + '.createReadStream(_ls),' +
|
||||
fsVar + '.createWriteStream(_ld)),' +
|
||||
logVar + '.info(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx copied successfully`))' +
|
||||
':' + logVar + '.warn(' +
|
||||
'`[VM:start] smol-bin.${_la}' +
|
||||
'.vhdx not found at ${_ls}`)' +
|
||||
'}';
|
||||
// Defensive: if a future upstream emits its own
|
||||
// if(process.platform==="linux"){...} block right
|
||||
// after the win32 close brace, strip it before
|
||||
// injecting our correctly-wired linuxBlock so we
|
||||
// don't end up with two competing blocks.
|
||||
const insertPos = closingBrace + 1;
|
||||
let stripUntil = insertPos;
|
||||
const afterWin32 = code.substring(insertPos);
|
||||
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
|
||||
const upstreamMatch = afterWin32.match(upstreamRe);
|
||||
if (upstreamMatch) {
|
||||
const matchEnd = insertPos + upstreamMatch[0].length;
|
||||
let depth = 1, pos = matchEnd;
|
||||
while (depth > 0 && pos < code.length) {
|
||||
if (code[pos] === '{') depth++;
|
||||
else if (code[pos] === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
if (depth === 0) {
|
||||
stripUntil = pos;
|
||||
console.log(' Stripped pre-existing upstream Linux block');
|
||||
} else {
|
||||
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
|
||||
}
|
||||
code = code.substring(0, insertPos) +
|
||||
linuxBlock +
|
||||
code.substring(stripUntil);
|
||||
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
|
||||
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
|
||||
patchCount++;
|
||||
} else {
|
||||
const missing = [];
|
||||
if (!pathMatch) missing.push('path');
|
||||
if (!fsMatch) missing.push('fs');
|
||||
if (!logMatch) missing.push('logger');
|
||||
if (!streamMatch) missing.push('stream');
|
||||
if (!archMatch) missing.push('arch');
|
||||
if (!bundleMatch) missing.push('bundlePath');
|
||||
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
|
||||
}
|
||||
code = code.substring(0, insertPos) +
|
||||
linuxBlock +
|
||||
code.substring(stripUntil);
|
||||
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
|
||||
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
|
||||
const missing = [];
|
||||
if (!pathMatch) missing.push('path');
|
||||
if (!fsMatch) missing.push('fs');
|
||||
if (!logMatch) missing.push('logger');
|
||||
if (!streamMatch) missing.push('stream');
|
||||
if (!archMatch) missing.push('arch');
|
||||
if (!bundleMatch) missing.push('bundlePath');
|
||||
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
|
||||
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,53 +586,49 @@ if (serviceErrorIdx !== -1) {
|
||||
// on Linux. Register our own to SIGTERM the daemon on app quit.
|
||||
// ============================================================
|
||||
{
|
||||
if (code.includes('cowork-linux-daemon-shutdown')) {
|
||||
console.log(' Linux cowork daemon quit handler already registered');
|
||||
} else {
|
||||
const quitFnRe = /registerQuitHandler:\s*([\w$]+)/;
|
||||
const quitFnMatch = code.match(quitFnRe);
|
||||
if (quitFnMatch) {
|
||||
const quitFn = quitFnMatch[1];
|
||||
console.log(' Found registerQuitHandler function: ' + quitFn);
|
||||
const quitFnRe = /registerQuitHandler:\s*(\w+)/;
|
||||
const quitFnMatch = code.match(quitFnRe);
|
||||
if (quitFnMatch) {
|
||||
const quitFn = quitFnMatch[1];
|
||||
console.log(' Found registerQuitHandler function: ' + quitFn);
|
||||
|
||||
const quitFnDef = 'function ' + quitFn + '(';
|
||||
const quitFnDefIdx = code.indexOf(quitFnDef);
|
||||
if (quitFnDefIdx !== -1) {
|
||||
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
|
||||
if (fnBlock) {
|
||||
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
|
||||
fnBlock.length;
|
||||
const shutdownHandler =
|
||||
'process.platform==="linux"&&' + quitFn + '({' +
|
||||
'name:"cowork-linux-daemon-shutdown",' +
|
||||
'fn:async()=>{' +
|
||||
'const _p=global.__coworkDaemonPid;' +
|
||||
'if(!_p)return;' +
|
||||
'try{const _cmd=require("fs").readFileSync(' +
|
||||
'"/proc/"+_p+"/cmdline","utf8");' +
|
||||
'if(!_cmd.includes("cowork-vm-service"))return' +
|
||||
'}catch(_e){return}' +
|
||||
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
|
||||
'for(let _i=0;_i<50;_i++){' +
|
||||
'await new Promise(_r=>setTimeout(_r,200));' +
|
||||
'try{process.kill(_p,0)}catch(_e){return}' +
|
||||
'}}});';
|
||||
code = code.substring(0, insertIdx) +
|
||||
shutdownHandler + code.substring(insertIdx);
|
||||
console.log(' Registered Linux cowork daemon quit handler');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function body for quit handler');
|
||||
}
|
||||
const quitFnDef = 'function ' + quitFn + '(';
|
||||
const quitFnDefIdx = code.indexOf(quitFnDef);
|
||||
if (quitFnDefIdx !== -1) {
|
||||
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
|
||||
if (fnBlock) {
|
||||
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
|
||||
fnBlock.length;
|
||||
const shutdownHandler =
|
||||
'process.platform==="linux"&&' + quitFn + '({' +
|
||||
'name:"cowork-linux-daemon-shutdown",' +
|
||||
'fn:async()=>{' +
|
||||
'const _p=global.__coworkDaemonPid;' +
|
||||
'if(!_p)return;' +
|
||||
'try{const _cmd=require("fs").readFileSync(' +
|
||||
'"/proc/"+_p+"/cmdline","utf8");' +
|
||||
'if(!_cmd.includes("cowork-vm-service"))return' +
|
||||
'}catch(_e){return}' +
|
||||
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
|
||||
'for(let _i=0;_i<50;_i++){' +
|
||||
'await new Promise(_r=>setTimeout(_r,200));' +
|
||||
'try{process.kill(_p,0)}catch(_e){return}' +
|
||||
'}}});';
|
||||
code = code.substring(0, insertIdx) +
|
||||
shutdownHandler + code.substring(insertIdx);
|
||||
console.log(' Registered Linux cowork daemon quit handler');
|
||||
patchCount++;
|
||||
} else {
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function definition');
|
||||
' function body for quit handler');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find registerQuitHandler' +
|
||||
' export for quit handler');
|
||||
console.log(' WARNING: Could not find ' + quitFn +
|
||||
' function definition');
|
||||
}
|
||||
} else {
|
||||
console.log(' WARNING: Could not find registerQuitHandler' +
|
||||
' export for quit handler');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,7 +664,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// 'sessionId:VAR' in the config itself — cheap, scoped, and
|
||||
// immune to unrelated *.userSelectedFolders references (e.g.
|
||||
// loop variables) that wander into the enclosing scope.
|
||||
const sidMatch = cfgBlock.match(/\{sessionId:([\w$]+)\b/);
|
||||
const sidMatch = cfgBlock.match(/\{sessionId:(\w+)\b/);
|
||||
if (!sidMatch) {
|
||||
console.log(' WARNING: #412 no sessionId field in config');
|
||||
} else {
|
||||
@@ -807,7 +689,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// --- 12c: accept a 13th param in spawn() method body ---
|
||||
let site3Done = false;
|
||||
const spawnIdempotent =
|
||||
/async spawn\([^)]+\)\{const [\w$]+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
|
||||
/async spawn\([^)]+\)\{const \w+=\{id:[^}]+\};[^{}]*\.sharedCwdPath=/;
|
||||
if (spawnIdempotent.test(code)) {
|
||||
console.log(' #412 spawn method already accepts sharedCwdPath');
|
||||
site3Done = true;
|
||||
@@ -815,7 +697,7 @@ if (serviceErrorIdx !== -1) {
|
||||
// Match the spawn body with the trailing mountConda setter and the
|
||||
// IPC call. Captures: arg list, payload var, setter chain, IPC tail.
|
||||
const spawnRe =
|
||||
/async spawn\(([^)]+)\)\{const ([\w$]+)=\{id:[^}]+\};([^{}]*?[\w$]+&&\(\2\.mountConda=[\w$]+\)),(await [\w$]+\("spawn",\2\)\})/;
|
||||
/async spawn\(([^)]+)\)\{const (\w+)=\{id:[^}]+\};([^{}]*?\w+&&\(\2\.mountConda=\w+\)),(await \w+\("spawn",\2\)\})/;
|
||||
const spawnMatch = code.match(spawnRe);
|
||||
if (!spawnMatch) {
|
||||
console.log(' WARNING: #412 spawn method body regex did not match');
|
||||
@@ -852,11 +734,11 @@ if (serviceErrorIdx !== -1) {
|
||||
// the uniqueness so a second upstream caller wouldn't silently take
|
||||
// only the first hit.
|
||||
let site2Done = false;
|
||||
if (/,[\w$]+\.mountConda,[\w$]+\.sharedCwdPath\)/.test(code)) {
|
||||
if (/,\w+\.mountConda,\w+\.sharedCwdPath\)/.test(code)) {
|
||||
console.log(' #412 caller already forwards sharedCwdPath');
|
||||
site2Done = true;
|
||||
} else {
|
||||
const callMatches = [...code.matchAll(/,([\w$]+)\.mountConda\)/g)];
|
||||
const callMatches = [...code.matchAll(/,(\w+)\.mountConda\)/g)];
|
||||
if (callMatches.length === 0) {
|
||||
console.log(' WARNING: #412 no ",VAR.mountConda)" pattern found');
|
||||
} else if (callMatches.length > 1) {
|
||||
@@ -912,40 +794,16 @@ install_node_pty() {
|
||||
echo '{"name":"node-pty-build","version":"1.0.0","private":true}' > package.json
|
||||
|
||||
echo 'Installing node-pty (this compiles native module)...'
|
||||
# Fail loudly on npm install failure rather than warn-and-continue.
|
||||
# The previous behavior silently dropped pty_src_dir, skipped the
|
||||
# entire copy block, and shipped the upstream Windows node-pty
|
||||
# binaries (the #401 failure mode). check_dependencies should now
|
||||
# install gcc/g++/make/python3 before we get here, so this branch
|
||||
# is the last line of defense for build-tool gaps that auto-install
|
||||
# couldn't fix (unknown distro, broken package mirror, etc.).
|
||||
if ! npm install node-pty 2>&1; then
|
||||
echo "Error: 'npm install node-pty' failed." >&2
|
||||
echo 'node-pty has a native module compiled via node-gyp;' >&2
|
||||
echo 'this usually means the build environment lacks a C/C++' >&2
|
||||
echo 'compiler, make, or python3.' >&2
|
||||
echo '' >&2
|
||||
echo 'Install build tools and re-run:' >&2
|
||||
echo ' Debian/Ubuntu: sudo apt install build-essential python3' >&2
|
||||
echo ' Fedora/RHEL: sudo dnf install gcc gcc-c++ make python3' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
if npm install node-pty 2>&1; then
|
||||
echo 'node-pty installed successfully'
|
||||
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
|
||||
else
|
||||
echo 'Failed to install node-pty - terminal features may not work'
|
||||
fi
|
||||
echo 'node-pty installed successfully'
|
||||
pty_src_dir="$node_pty_build_dir/node_modules/node-pty"
|
||||
fi
|
||||
|
||||
if [[ -n $pty_src_dir && -d $pty_src_dir ]]; then
|
||||
echo 'Copying node-pty JavaScript files into app.asar.contents...'
|
||||
# Wipe the upstream-extracted node-pty before staging the Linux
|
||||
# build. The Windows installer's app.asar ships node-pty with
|
||||
# Windows binaries (winpty.dll, winpty-agent.exe, Windows
|
||||
# build/Release/*.node files). `cp -r $pty_src_dir/build` only
|
||||
# overwrites same-named files; orphan Windows binaries persist
|
||||
# inside the asar, surface as PE32+ when users inspect with
|
||||
# `asar list`, and pollute /tmp via Electron's lazy-extract on
|
||||
# any spurious require() (#401).
|
||||
rm -rf "$app_staging_dir/app.asar.contents/node_modules/node-pty"
|
||||
mkdir -p "$app_staging_dir/app.asar.contents/node_modules/node-pty" || exit 1
|
||||
# --no-preserve=mode so read-only bits from the Nix store
|
||||
# (--node-pty-dir) don't propagate into the staging tree.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#===============================================================================
|
||||
# Linux org-plugins path: inject a case"linux" into the platform switch
|
||||
# that resolves the org-plugins source directory.
|
||||
#
|
||||
# Upstream only has cases for darwin and win32; the default returns null,
|
||||
# silently disabling the entire org-plugins marketplace feature on Linux.
|
||||
# This adds: case"linux":return"/etc/claude/org-plugins"
|
||||
#
|
||||
# /etc/claude/org-plugins is FHS-correct for MDM-managed configuration,
|
||||
# consistent with Claude Code's /etc/claude-code/ path.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: (none)
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_org_plugins_path() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency: skip if a Linux case already exists near the
|
||||
# org-plugins path resolver (upstream may add one in the future).
|
||||
if grep -q 'case"linux":return"/etc/claude/org-plugins"' \
|
||||
"$index_js"; then
|
||||
echo 'Linux org-plugins path already present'
|
||||
return
|
||||
fi
|
||||
|
||||
# Anchor: the darwin path string is unique in the entire bundle.
|
||||
# Verify it exists before attempting the patch.
|
||||
local anchor='Application Support/Claude/org-plugins'
|
||||
if ! grep -q "$anchor" "$index_js"; then
|
||||
echo 'Warning: org-plugins path resolver not found' \
|
||||
'in this version, skipping' >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# Pattern (minified):
|
||||
# ..."org-plugins");default:return null}
|
||||
#
|
||||
# The compound anchor — "org-plugins") immediately before
|
||||
# default:return null — is unique to this switch statement.
|
||||
# Insert case"linux":return"/etc/claude/org-plugins"; between
|
||||
# the end of the win32 case and the default case.
|
||||
#
|
||||
# \s* between tokens handles any future whitespace variation,
|
||||
# though the target file is always minified in practice.
|
||||
if grep -qP '"org-plugins"\)\s*;\s*default\s*:\s*return\s+null' \
|
||||
"$index_js"; then
|
||||
sed -i -E \
|
||||
's/("org-plugins"\)\s*;\s*)(default\s*:\s*return\s+null)/\1case"linux":return"\/etc\/claude\/org-plugins";\2/' \
|
||||
"$index_js"
|
||||
echo 'Added Linux org-plugins path (/etc/claude/org-plugins)'
|
||||
else
|
||||
echo 'Warning: org-plugins switch pattern not matched,' \
|
||||
'skipping' >&2
|
||||
fi
|
||||
}
|
||||
@@ -14,7 +14,7 @@ patch_quick_window() {
|
||||
# Extract the quick window variable name from the unique "pop-up-menu"
|
||||
# setAlwaysOnTop call, e.g.: Sa.setAlwaysOnTop(!0,"pop-up-menu")
|
||||
local quick_var
|
||||
quick_var=$(grep -oP '[$\w]+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
quick_var=$(grep -oP '\w+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $quick_var ]]; then
|
||||
echo 'WARNING: Could not extract quick window variable name'
|
||||
@@ -35,9 +35,9 @@ patch_quick_window() {
|
||||
de_check+='.toLowerCase().includes("kde")'
|
||||
if grep -qF "${quick_var}.blur(),${quick_var}.hide()" "$index_js"; then
|
||||
echo ' Quick window blur already patched'
|
||||
elif grep -qP "\|\|\s*${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/\|\|\s*${quick_var_re}\.hide\(\)/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
elif grep -qP "\|\|${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i \
|
||||
"s/||${quick_var_re}\.hide()/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
"$index_js"
|
||||
echo ' Added KDE-gated blur() before hide() on quick window'
|
||||
else
|
||||
@@ -57,11 +57,11 @@ let patchCount = 0;
|
||||
|
||||
// Find the minified isWindowFocused function via its named property
|
||||
// export: isWindowFocused: () => !!NAME()
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!([\w$]+)\(\)/;
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!(\w+)\(\)/;
|
||||
const focusedMatch = code.match(focusedPropRe);
|
||||
if (!focusedMatch) {
|
||||
console.log(' WARNING: Could not find isWindowFocused function');
|
||||
process.exit(1);
|
||||
process.exit(0);
|
||||
}
|
||||
const focusFn = focusedMatch[1];
|
||||
console.log(' Found focus check function: ' + focusFn);
|
||||
@@ -74,12 +74,12 @@ console.log(' Found focus check function: ' + focusFn);
|
||||
// group keeps the prefix optional in either case.
|
||||
const focusFnIdx = code.indexOf('function ' + focusFn + '(');
|
||||
const nearbyCode = code.substring(focusFnIdx, focusFnIdx + 500);
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var [\w$]+(?:,[\w$]+)*;)?return![\w$]+\|\|[\w$]+\.isDestroyed\(\)\?!1:[\w$]+\.isVisible\(\)/;
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var \w+(?:,\w+)*;)?return!\w+\|\|\w+\.isDestroyed\(\)\?!1:\w+\.isVisible\(\)/;
|
||||
const visMatch = nearbyCode.match(visFnRe);
|
||||
if (!visMatch) {
|
||||
console.log(' WARNING: Could not find visibility function near ' +
|
||||
focusFn);
|
||||
process.exit(1);
|
||||
process.exit(0);
|
||||
}
|
||||
const visFn = visMatch[1];
|
||||
console.log(' Found visibility check function: ' + visFn);
|
||||
@@ -106,7 +106,7 @@ for (const anchor of anchors) {
|
||||
}
|
||||
// matches: <focusFn>()||(someVar).show()
|
||||
const showRe = new RegExp(
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|([\w$]+)\.show\(\)`
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|(\w+)\.show\(\)`
|
||||
);
|
||||
const showMatch = region.match(showRe);
|
||||
if (showMatch) {
|
||||
|
||||
@@ -11,9 +11,9 @@ patch_tray_menu_handler() {
|
||||
echo 'Patching tray menu handler...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
local tray_func tray_func_re tray_var
|
||||
local tray_func tray_var first_const
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo 'Failed to extract tray menu function name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -21,12 +21,9 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray function: $tray_func"
|
||||
|
||||
# Escape `$` for PCRE / sed -E patterns where it would otherwise act
|
||||
# as an end-of-line anchor. Minifier emits identifiers like `i$A`.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
|
||||
tray_var=$(grep -oP \
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"$index_js")
|
||||
if [[ -z $tray_var ]]; then
|
||||
echo 'Failed to extract tray variable name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -34,26 +31,30 @@ patch_tray_menu_handler() {
|
||||
fi
|
||||
echo " Found tray variable: $tray_var"
|
||||
|
||||
# Idempotent: upstream may already ship the function as `async`
|
||||
# (1.8089.1 does). Re-applying the sed would produce
|
||||
# `async async function`, which then breaks downstream patches that
|
||||
# match `(?:async )?function NAME`.
|
||||
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
|
||||
sed -i -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
|
||||
first_const=$(grep -oP \
|
||||
"async function ${tray_func}\(\)\{.*?const \K\w+(?==)" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $first_const ]]; then
|
||||
echo 'Failed to extract first const in function' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo " Found first const variable: $first_const"
|
||||
|
||||
# Add mutex guard to prevent concurrent tray rebuilds
|
||||
if ! grep -q "${tray_func}._running" "$index_js"; then
|
||||
sed -i -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" \
|
||||
sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
|
||||
"$index_js"
|
||||
echo " Added mutex guard to ${tray_func}()"
|
||||
fi
|
||||
|
||||
# Add DBus cleanup delay after tray destroy
|
||||
tray_var_re="${tray_var//\$/\\$}"
|
||||
if ! grep -q "await new Promise.*setTimeout.*${tray_var_re}" "$index_js"; then
|
||||
sed -i -E "s/${tray_var_re}\s*\&\&\s*\(\s*${tray_var_re}\.destroy\(\)\s*,\s*${tray_var_re}\s*=\s*null\s*\)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
if ! grep -q "await new Promise.*setTimeout" "$index_js" \
|
||||
| grep -q "$tray_var"; then
|
||||
sed -i "s/${tray_var}\&\&(${tray_var}\.destroy(),${tray_var}=null)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
"$index_js"
|
||||
echo " Added DBus cleanup delay after $tray_var.destroy()"
|
||||
fi
|
||||
@@ -68,12 +69,9 @@ 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/\(([[:alnum:]_\$]+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"$index_js"
|
||||
echo ' Added startup delay check (3 second window)'
|
||||
if ! grep -q "Date.now()-_trayStartTime>3e3" "$index_js"; then
|
||||
echo 'WARNING: Startup delay conditional not injected' >&2
|
||||
fi
|
||||
fi
|
||||
echo '##############################################################'
|
||||
}
|
||||
@@ -83,9 +81,9 @@ patch_tray_icon_selection() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
local dark_check="${electron_var_re}.nativeTheme.shouldUseDarkColors"
|
||||
|
||||
if grep -qP ':[$\w]+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
if grep -qP ':\$?\w+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/:([[:alnum:]_\$]+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"s/:(\\\$?\w+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"$index_js"
|
||||
echo 'Patched tray icon selection for Linux theme support'
|
||||
else
|
||||
@@ -100,19 +98,18 @@ patch_tray_inplace_update() {
|
||||
|
||||
# Re-extract the tray variable name — `patch_tray_menu_handler`
|
||||
# declares it `local` so it's not visible here. Same grep pattern.
|
||||
local tray_func tray_func_re local_tray_var tray_var_re
|
||||
local tray_func local_tray_var tray_var_re
|
||||
local menu_func path_var enabled_var enabled_count
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
echo ' Could not find tray function — skipping'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
# Escape `$` for PCRE patterns; matches the `tray_var_re` trick below.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
local_tray_var=$(grep -oP \
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func})" \
|
||||
"$index_js")
|
||||
if [[ -z $local_tray_var ]]; then
|
||||
echo ' Could not extract tray variable name — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -122,7 +119,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'
|
||||
@@ -137,7 +134,7 @@ patch_tray_inplace_update() {
|
||||
# suffix)` earlier in the function; minifier renames it between
|
||||
# releases, so it needs to be extracted (not hardcoded).
|
||||
path_var=$(grep -oP \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K[\$\w]+(?=\))" \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K\w+(?=\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract icon-path var — skipping'
|
||||
@@ -151,8 +148,8 @@ patch_tray_inplace_update() {
|
||||
# tests, so binding to the wrong site is silently broken. Bail if
|
||||
# upstream ever ships >1 declaration site instead of taking the
|
||||
# first one.
|
||||
enabled_count=$(grep -cP \
|
||||
'const [$\w]+\s*=\s*[$\w]+\("menuBarEnabled"\)' "$index_js")
|
||||
enabled_count=$(grep -cE \
|
||||
'const \w+\s*=\s*\w+\("menuBarEnabled"\)' "$index_js")
|
||||
if [[ $enabled_count -ne 1 ]]; then
|
||||
echo " Expected 1 menuBarEnabled declaration, found" \
|
||||
"${enabled_count} — skipping"
|
||||
@@ -160,7 +157,7 @@ patch_tray_inplace_update() {
|
||||
return
|
||||
fi
|
||||
enabled_var=$(grep -oP \
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' "$index_js")
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' "$index_js")
|
||||
if [[ -z $enabled_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled var — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -239,7 +236,7 @@ patch_menu_bar_default() {
|
||||
|
||||
local menu_bar_var
|
||||
menu_bar_var=$(grep -oP \
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_bar_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled variable name'
|
||||
|
||||
@@ -22,51 +22,32 @@ check_dependencies() {
|
||||
rpm) all_deps="$all_deps rpmbuild" ;;
|
||||
esac
|
||||
|
||||
# node-pty has a native C++ module compiled via node-gyp during
|
||||
# `npm install`. Without gcc/g++/make/python3 the install silently
|
||||
# emits a warning, leaves pty_src_dir empty, and the build ends up
|
||||
# shipping the upstream Windows binaries (the #401 failure mode).
|
||||
# Skip when --node-pty-dir is set (Nix and explicit overrides bring
|
||||
# their own pre-built node-pty).
|
||||
if [[ -z ${node_pty_dir:-} ]]; then
|
||||
all_deps="$all_deps gcc g++ make python3"
|
||||
fi
|
||||
|
||||
# Command-to-package mappings per distro family
|
||||
declare -A debian_pkgs=(
|
||||
[p7zip]='p7zip-full' [wget]='wget' [wrestool]='icoutils'
|
||||
[icotool]='icoutils' [convert]='imagemagick'
|
||||
[dpkg-deb]='dpkg-dev' [rpmbuild]='rpm'
|
||||
[gcc]='build-essential' [g++]='build-essential'
|
||||
[make]='build-essential' [python3]='python3'
|
||||
)
|
||||
declare -A rpm_pkgs=(
|
||||
[p7zip]='p7zip p7zip-plugins' [wget]='wget' [wrestool]='icoutils'
|
||||
[icotool]='icoutils' [convert]='ImageMagick'
|
||||
[dpkg-deb]='dpkg' [rpmbuild]='rpm-build'
|
||||
[gcc]='gcc' [g++]='gcc-c++'
|
||||
[make]='make' [python3]='python3'
|
||||
)
|
||||
|
||||
local cmd pkg
|
||||
local cmd
|
||||
for cmd in $all_deps; do
|
||||
if ! check_command "$cmd"; then
|
||||
case "$distro_family" in
|
||||
debian) pkg="${debian_pkgs[$cmd]}" ;;
|
||||
rpm) pkg="${rpm_pkgs[$cmd]}" ;;
|
||||
debian)
|
||||
deps_to_install="$deps_to_install ${debian_pkgs[$cmd]}"
|
||||
;;
|
||||
rpm)
|
||||
deps_to_install="$deps_to_install ${rpm_pkgs[$cmd]}"
|
||||
;;
|
||||
*)
|
||||
echo "Warning: Cannot auto-install '$cmd' on unknown distro. Please install manually." >&2
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
# Several commands map to the same package (gcc/g++/make
|
||||
# -> build-essential, wrestool/icotool -> icoutils). Skip
|
||||
# if the package is already queued so the log line stays
|
||||
# readable.
|
||||
case " $deps_to_install " in
|
||||
*" $pkg "*) ;;
|
||||
*) deps_to_install="$deps_to_install $pkg" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -217,13 +198,6 @@ setup_nodejs() {
|
||||
setup_electron_asar() {
|
||||
section_header 'Electron & Asar Handling'
|
||||
|
||||
# Pin Electron to the exact version upstream Claude Desktop ships
|
||||
# (build-reference/app-extracted/package.json). The shipped app.asar
|
||||
# binds to specific V8/NAPI ABI, Chromium pairing, and node-pty
|
||||
# native surface — running a different Electron major against this
|
||||
# asar is unsupported. Bump when upstream bumps.
|
||||
local electron_version='41.5.0'
|
||||
|
||||
echo "Ensuring local Electron and Asar installation in $work_dir..."
|
||||
cd "$work_dir" || exit 1
|
||||
|
||||
@@ -240,91 +214,19 @@ setup_electron_asar() {
|
||||
[[ ! -f $asar_bin_path ]] && echo 'Asar binary not found.' && install_needed=true
|
||||
|
||||
if [[ $install_needed == true ]]; then
|
||||
echo "Installing electron@${electron_version} and Asar locally into $work_dir..."
|
||||
if ! npm install --no-save \
|
||||
"electron@${electron_version}" @electron/asar @electron/get extract-zip; then
|
||||
echo "Installing Electron and Asar locally into $work_dir..."
|
||||
if ! npm install --no-save electron @electron/asar; then
|
||||
echo 'Failed to install Electron and/or Asar locally.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'Electron and Asar installation command finished.'
|
||||
|
||||
# electron@42+ no longer ships a postinstall script that fetches
|
||||
# the prebuilt binary into dist/. If npm didn't populate it,
|
||||
# fetch the matching binary explicitly via @electron/get. See
|
||||
# #584. Retry once on transient CDN failures (503, network drops).
|
||||
#
|
||||
# Check for the binary itself (not just the dist/ directory),
|
||||
# because under Node 24 the extract-zip step in both the npm
|
||||
# postinstall (electron <42 path) and @electron/get can silently
|
||||
# no-op — leaving an empty dist/locales/ behind, which would pass
|
||||
# a bare `-d` check while no electron binary actually landed.
|
||||
if [[ ! -f $electron_dist_path/electron ]]; then
|
||||
echo 'Electron dist/electron missing; fetching binary explicitly...'
|
||||
local fetch_ok=false
|
||||
local fetch_attempts=0
|
||||
while ! node "$project_root/scripts/setup/fetch-electron-binary.js"; do
|
||||
fetch_attempts=$((fetch_attempts + 1))
|
||||
if (( fetch_attempts >= 2 )); then
|
||||
echo 'Failed to fetch Electron binary via @electron/get after 2 attempts.' >&2
|
||||
echo 'For air-gapped or mirrored builds set ELECTRON_MIRROR or ELECTRON_CUSTOM_DIR; see docs/building.md.' >&2
|
||||
break
|
||||
fi
|
||||
echo "Retrying Electron binary fetch (attempt $((fetch_attempts + 1))/2)..."
|
||||
sleep 2
|
||||
done
|
||||
if (( fetch_attempts < 2 )); then
|
||||
fetch_ok=true
|
||||
fi
|
||||
|
||||
# Final fallback: even when @electron/get reports success,
|
||||
# extract-zip can leave dist/ empty under Node 24 (the
|
||||
# unzip stream resolves without writing files). If we still
|
||||
# have no binary, the cache zip was downloaded successfully
|
||||
# — unpack it with system `unzip`.
|
||||
if [[ ! -f $electron_dist_path/electron ]]; then
|
||||
if [[ $fetch_ok == false ]]; then
|
||||
echo 'Electron download failed; no cached zip to fall back on.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo 'extract-zip path produced no binary; unpacking @electron/get cache with system unzip...'
|
||||
local electron_cache_dir="$HOME/.cache/electron"
|
||||
local electron_arch
|
||||
case $architecture in
|
||||
amd64) electron_arch='x64' ;;
|
||||
arm64) electron_arch='arm64' ;;
|
||||
*) electron_arch='x64' ;;
|
||||
esac
|
||||
local cached_zip
|
||||
cached_zip=$(find "$electron_cache_dir" -name "electron-v${electron_version}-linux-${electron_arch}.zip" 2>/dev/null | head -1)
|
||||
if [[ -z $cached_zip ]]; then
|
||||
echo "No cached zip matching electron-v${electron_version}-linux-*.zip under $electron_cache_dir" >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
echo "unzip not installed; cannot apply final fallback. Install unzip and retry, or upgrade extract-zip upstream." >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$electron_dist_path"
|
||||
if ! unzip -oq "$cached_zip" -d "$electron_dist_path"; then
|
||||
echo 'unzip fallback failed.' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
printf 'v%s\n' "$electron_version" > "$electron_dist_path/version"
|
||||
printf 'electron\n' > "$work_dir/node_modules/electron/path.txt"
|
||||
echo "unzip fallback populated $electron_dist_path ($(du -sh "$electron_dist_path" | awk '{print $1}'))"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo 'Local Electron distribution and Asar binary already present.'
|
||||
fi
|
||||
|
||||
if [[ -f $electron_dist_path/electron ]]; then
|
||||
echo "Found Electron binary at $electron_dist_path."
|
||||
if [[ -d $electron_dist_path ]]; then
|
||||
echo "Found Electron distribution directory at $electron_dist_path."
|
||||
chosen_electron_module_path="$(realpath "$work_dir/node_modules/electron")"
|
||||
echo "Setting Electron module path for copying to $chosen_electron_module_path."
|
||||
else
|
||||
|
||||
@@ -24,15 +24,15 @@ detect_architecture() {
|
||||
|
||||
case "$raw_arch" in
|
||||
x86_64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe'
|
||||
claude_exe_sha256='422461974b11d3c8709f930396132cb3d01d27e917f32ac6c4b9a077bcb7704b'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='e619c7bd3b6746a7307ebefe509bfe447a143aed97e6c7f666677b36a6b6ba54'
|
||||
architecture='amd64'
|
||||
claude_exe_filename='Claude-Setup-x64.exe'
|
||||
echo 'Configured for amd64 (x86_64) build.'
|
||||
;;
|
||||
aarch64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.9255.0/Claude-a22af1fabbbc85af5502e695ed8fbea9f74276fc.exe'
|
||||
claude_exe_sha256='1f2081752efddd31b0f5bedae37659b39c38e336d11f8041a1a3699ea861bdbc'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.5354.0/Claude-9a9e3d5a4a368f0f49a80dc303b0ed1a18bfedad.exe'
|
||||
claude_exe_sha256='bf7de5d6c012542feadf7d5caa77a72dfcea1b24512e9a6d28e004ba4ae2a11d'
|
||||
architecture='arm64'
|
||||
claude_exe_filename='Claude-Setup-arm64.exe'
|
||||
echo 'Configured for arm64 (aarch64) build.'
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Fetches the Electron prebuilt binary into node_modules/electron/dist/.
|
||||
//
|
||||
// electron@42.0.0 (2026-05-06) removed the postinstall script that
|
||||
// historically populated dist/ during `npm install`. This helper restores
|
||||
// that behavior using @electron/get + extract-zip, so the rest of the
|
||||
// build pipeline (which depends on the dist/ layout) keeps working.
|
||||
//
|
||||
// Run from the directory containing node_modules/electron. Reads the
|
||||
// installed electron version from its package.json and downloads the
|
||||
// matching binary for the host platform/arch.
|
||||
//
|
||||
// See: https://github.com/aaddrick/claude-desktop-debian/issues/584
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
async function main() {
|
||||
const cwd = process.cwd();
|
||||
const electronModuleDir = path.join(cwd, 'node_modules', 'electron');
|
||||
const distDir = path.join(electronModuleDir, 'dist');
|
||||
|
||||
if (!fs.existsSync(electronModuleDir)) {
|
||||
throw new Error(
|
||||
`Electron module not found at ${electronModuleDir}; ` +
|
||||
"run 'npm install electron' first.",
|
||||
);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(electronModuleDir, 'package.json');
|
||||
const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!version) {
|
||||
throw new Error(`Could not read version from ${pkgPath}`);
|
||||
}
|
||||
|
||||
const platform = 'linux';
|
||||
// node's process.arch values map cleanly to electron release archs,
|
||||
// except 'arm' which electron publishes as 'armv7l'.
|
||||
const arch = process.arch === 'arm' ? 'armv7l' : process.arch;
|
||||
|
||||
const supportedArchs = ['x64', 'arm64', 'armv7l', 'ia32'];
|
||||
if (!supportedArchs.includes(arch)) {
|
||||
throw new Error(
|
||||
`Unsupported architecture: ${arch}. ` +
|
||||
`Electron publishes Linux binaries for ${supportedArchs.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve @electron/get and extract-zip from the work-dir's
|
||||
// node_modules. The script lives at scripts/setup/ so a plain
|
||||
// require() walks up from there and never sees work_dir/.
|
||||
const workDirRequire = createRequire(path.join(cwd, 'package.json'));
|
||||
const { downloadArtifact } = workDirRequire('@electron/get');
|
||||
const extractZip = workDirRequire('extract-zip');
|
||||
|
||||
console.log(`Fetching electron@${version} for ${platform}-${arch}...`);
|
||||
const zipPath = await downloadArtifact({
|
||||
version,
|
||||
platform,
|
||||
arch,
|
||||
artifactName: 'electron',
|
||||
});
|
||||
|
||||
console.log(`Extracting ${zipPath} into ${distDir}`);
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
await extractZip(zipPath, { dir: distDir });
|
||||
|
||||
const electronBin = path.join(distDir, 'electron');
|
||||
if (fs.existsSync(electronBin)) {
|
||||
fs.chmodSync(electronBin, 0o755);
|
||||
}
|
||||
|
||||
console.log('Electron binary fetched and extracted successfully.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-patches.sh
|
||||
#
|
||||
# Static-greps a patched index.js for the patch markers defined in
|
||||
# a TSV (defaults to scripts/cowork-patch-markers.tsv). Exits non-zero
|
||||
# on any miss and names the missing markers in the output.
|
||||
#
|
||||
# Defends against silent half-patched asars (issue #559 D6, PR #555).
|
||||
# Reusable for non-cowork patch sets — pass any TSV of the same shape
|
||||
# via the second arg.
|
||||
#
|
||||
# Usage:
|
||||
# verify-patches.sh <path> [markers-tsv]
|
||||
#
|
||||
# <path> may be:
|
||||
# * a JavaScript file (the index.js itself)
|
||||
# * an .asar archive (extracted on the fly via npx @electron/asar)
|
||||
# * a directory containing app.asar.contents/.vite/build/index.js
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — every marker present.
|
||||
# 1 — usage error or input not found.
|
||||
# 2 — one or more markers missing (named on stderr).
|
||||
#
|
||||
|
||||
set -u
|
||||
IFS=$'\n\t'
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
default_markers_tsv="$script_dir/cowork-patch-markers.tsv"
|
||||
markers_tsv="$default_markers_tsv"
|
||||
|
||||
usage() {
|
||||
cat <<-EOF >&2
|
||||
Usage: $(basename "$0") <path> [markers-tsv]
|
||||
|
||||
<path> may be a .js file, an .asar archive, or a directory
|
||||
containing app.asar.contents/.vite/build/index.js. The script
|
||||
greps for patch markers (default: cowork, PR #555 / issue #559
|
||||
D6) and exits non-zero if any are missing.
|
||||
|
||||
[markers-tsv] overrides the default TSV so the same script can
|
||||
verify other patch sets.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse the marker TSV into three parallel arrays. Skips comments
|
||||
# and blank lines. Used by both the verify path here and by the
|
||||
# BATS test, which sources this script (see _is_sourced below) to
|
||||
# share parsing and avoid drift between the two consumers.
|
||||
load_markers() {
|
||||
marker_names=()
|
||||
marker_patterns=()
|
||||
marker_samples=()
|
||||
|
||||
if [[ ! -f $markers_tsv ]]; then
|
||||
echo "verify-patches: marker file not found:" \
|
||||
"$markers_tsv" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local name pattern sample
|
||||
while IFS=$'\t' read -r name pattern sample; do
|
||||
[[ -z $name || $name == '#'* ]] && continue
|
||||
if [[ -z ${pattern:-} || -z ${sample:-} ]]; then
|
||||
echo "verify-patches: malformed row '$name'" \
|
||||
'in markers file' >&2
|
||||
return 1
|
||||
fi
|
||||
marker_names+=("$name")
|
||||
marker_patterns+=("$pattern")
|
||||
marker_samples+=("$sample")
|
||||
done < "$markers_tsv"
|
||||
|
||||
if [[ ${#marker_names[@]} -eq 0 ]]; then
|
||||
echo 'verify-patches: no markers loaded' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve the input path to an actual index.js. For .asar inputs,
|
||||
# extracts to a temp dir and echoes the inner index.js path. The
|
||||
# caller cleans up via cleanup_tmp.
|
||||
tmp_extract_dir=''
|
||||
cleanup_tmp() {
|
||||
if [[ -n $tmp_extract_dir && -d $tmp_extract_dir ]]; then
|
||||
rm -rf "$tmp_extract_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup_tmp EXIT
|
||||
|
||||
resolve_index_js() {
|
||||
local input="$1"
|
||||
|
||||
if [[ ! -e $input ]]; then
|
||||
echo "verify-patches: not found: $input" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -d $input ]]; then
|
||||
local candidate="$input/app.asar.contents/.vite/build/index.js"
|
||||
if [[ -f $candidate ]]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
echo "verify-patches: directory does not contain" \
|
||||
"app.asar.contents/.vite/build/index.js: $input" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $input == *.asar ]]; then
|
||||
if ! command -v npx > /dev/null 2>&1; then
|
||||
echo 'verify-patches: npx not found; install Node.js' \
|
||||
'or pre-extract the asar' >&2
|
||||
return 1
|
||||
fi
|
||||
tmp_extract_dir="$(mktemp -d)"
|
||||
if ! npx --yes @electron/asar extract "$input" \
|
||||
"$tmp_extract_dir" > /dev/null 2>&1; then
|
||||
echo "verify-patches: asar extraction failed:" \
|
||||
"$input" >&2
|
||||
return 1
|
||||
fi
|
||||
local extracted="$tmp_extract_dir/.vite/build/index.js"
|
||||
if [[ ! -f $extracted ]]; then
|
||||
echo 'verify-patches: extracted asar lacks' \
|
||||
'.vite/build/index.js' >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$extracted"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Treat as a JS file (.js or any other extension) — let grep
|
||||
# decide whether the contents are sensible.
|
||||
printf '%s\n' "$input"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
-h | --help)
|
||||
usage
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $# -eq 2 ]]; then
|
||||
markers_tsv="$2"
|
||||
fi
|
||||
|
||||
local index_js
|
||||
if ! index_js="$(resolve_index_js "$1")"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! load_markers; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Verifying patch markers in: $index_js"
|
||||
echo "Marker source: $markers_tsv"
|
||||
|
||||
local i missing_names=()
|
||||
for i in "${!marker_names[@]}"; do
|
||||
if grep -qP -- "${marker_patterns[$i]}" "$index_js"; then
|
||||
printf ' OK %s\n' "${marker_names[$i]}"
|
||||
else
|
||||
printf ' MISS %s\n' "${marker_names[$i]}" >&2
|
||||
missing_names+=("${marker_names[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_names[@]} -gt 0 ]]; then
|
||||
local joined
|
||||
joined="$(IFS=','; printf '%s' "${missing_names[*]}")"
|
||||
printf '\nverify-patches: %d/%d markers missing: %s\n' \
|
||||
"${#missing_names[@]}" "${#marker_names[@]}" "$joined" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
printf '\nAll %d patch markers present.\n' \
|
||||
"${#marker_names[@]}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Library mode: when sourced (BATS test), expose load_markers and
|
||||
# the markers_tsv path without running main.
|
||||
_is_sourced() {
|
||||
[[ ${BASH_SOURCE[0]} != "${0}" ]]
|
||||
}
|
||||
|
||||
if ! _is_sourced; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -1,445 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# doctor.bats
|
||||
# Tests for diagnostic helpers in scripts/doctor.sh
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
export HOME="$TEST_TMP/home"
|
||||
export XDG_CACHE_HOME="$TEST_TMP/cache"
|
||||
export XDG_CONFIG_HOME="$TEST_TMP/config"
|
||||
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME"
|
||||
|
||||
# Clear all input/display vars to avoid host-state leakage
|
||||
unset DISPLAY
|
||||
unset WAYLAND_DISPLAY
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset GTK_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
|
||||
# shellcheck source=scripts/doctor.sh
|
||||
source "$SCRIPT_DIR/../scripts/doctor.sh"
|
||||
|
||||
_doctor_colors
|
||||
_doctor_failures=0
|
||||
|
||||
# Default _pkg_installed to "unknown" (rc=2) so tests don't have
|
||||
# to stub it unless they're exercising the package-check branch.
|
||||
# Override in-test for rc=0 (installed) or rc=1 (missing).
|
||||
_pkg_installed() { return 2; }
|
||||
|
||||
# Default stub for _detect_password_store (defined in
|
||||
# launcher-common.sh, not sourced here). Tests that exercise
|
||||
# _doctor_check_password_store override this in-test if needed.
|
||||
_detect_password_store() { echo 'basic'; }
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Make `command -v gtk-query-immodules-3.0` report "not found" so the
|
||||
# immodules cache check is skipped. Used by tests that aren't
|
||||
# exercising the cache branch but reach it because no earlier gate
|
||||
# fires. `command -v` finds bash functions too, so just unsetting a
|
||||
# stub function isn't enough — we shadow `command` itself.
|
||||
_skip_gtk_query() {
|
||||
command() {
|
||||
if [[ $1 == '-v' && $2 == 'gtk-query-immodules-3.0' ]]; then
|
||||
return 1
|
||||
fi
|
||||
builtin command "$@"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _cowork_pkg_hint: ibus-gtk3 mapping (#550)
|
||||
# =============================================================================
|
||||
|
||||
@test "_cowork_pkg_hint: debian maps ibus-gtk3 to ibus-gtk3 via apt" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint debian ibus-gtk3)
|
||||
[[ $result == "sudo apt install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: fedora maps ibus-gtk3 to ibus-gtk3 via dnf" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint fedora ibus-gtk3)
|
||||
[[ $result == "sudo dnf install ibus-gtk3" ]]
|
||||
}
|
||||
|
||||
@test "_cowork_pkg_hint: arch maps ibus-gtk3 to ibus (bundled)" {
|
||||
local result
|
||||
result=$(_cowork_pkg_hint arch ibus-gtk3)
|
||||
[[ $result == "sudo pacman -S ibus" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: CLAUDE_GTK_IM_MODULE override visibility
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits override line when CLAUDE_GTK_IM_MODULE set" {
|
||||
# CLAUDE_GTK_IM_MODULE makes active_im non-empty, so we'd reach
|
||||
# the cache check — skip it to keep this test focused.
|
||||
_skip_gtk_query
|
||||
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'CLAUDE_GTK_IM_MODULE=xim'* ]]
|
||||
[[ $output == *'overrides GTK_IM_MODULE for Electron'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no override line when CLAUDE_GTK_IM_MODULE unset" {
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'CLAUDE_GTK_IM_MODULE'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: XWayland-with-IBus routing note
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: emits XWayland note when wayland session and CLAUDE_USE_WAYLAND unset" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
# CLAUDE_USE_WAYLAND deliberately unset
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'XWayland'* ]]
|
||||
[[ $output == *'CLAUDE_USE_WAYLAND=1'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note when CLAUDE_USE_WAYLAND=1" {
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no XWayland note on X11 session" {
|
||||
XDG_SESSION_TYPE='x11'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'XWayland'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: ibus-gtk3 package check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when ibus selected but ibus-gtk3 missing" {
|
||||
# Package not installed (rc=1, definitive answer)
|
||||
_pkg_installed() { return 1; }
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'ibus-gtk3 is not installed'* ]]
|
||||
[[ $output == *'sudo apt install ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when ibus selected and ibus-gtk3 present" {
|
||||
# Package installed (rc=0); cache lists ibus.
|
||||
_pkg_installed() { return 0; }
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"ibus" "IBus" "ibus" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning when active module isn't ibus" {
|
||||
# Even with rc=1 for ibus-gtk3, the package check should be
|
||||
# skipped entirely when GTK_IM_MODULE isn't ibus.
|
||||
_pkg_installed() { return 1; }
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no package warning on unsupported distro (rc=2)" {
|
||||
# Default _pkg_installed (rc=2) — no warning even with ibus.
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
run _doctor_check_im_modules unknown
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_im_modules: immodules cache check
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_im_modules: warns when GTK_IM_MODULE not in immodules cache" {
|
||||
# gtk-query-immodules-3.0 lists xim but not fcitx
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *"'fcitx' not listed"* ]]
|
||||
[[ $output == *'gtk-query-immodules-3.0 --update-cache'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no warning when active module is in cache" {
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: skips cache check when gtk-query-immodules-3.0 missing" {
|
||||
_skip_gtk_query
|
||||
|
||||
GTK_IM_MODULE='fcitx'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'cache may be stale'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: CLAUDE_GTK_IM_MODULE takes precedence as active module" {
|
||||
# Cache lists xim but not ibus. CLAUDE_GTK_IM_MODULE=xim should
|
||||
# win over GTK_IM_MODULE=ibus, so no cache warning fires.
|
||||
gtk-query-immodules-3.0() {
|
||||
echo '"xim" "X Input Method" "gtk30" "/usr/share/locale" "*"'
|
||||
}
|
||||
export -f gtk-query-immodules-3.0
|
||||
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_im_modules: no checks fire when no IM module selected" {
|
||||
# Neither GTK_IM_MODULE nor CLAUDE_GTK_IM_MODULE set — function
|
||||
# should return early before the package or cache checks.
|
||||
run _doctor_check_im_modules debian
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output != *'ibus-gtk3'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_recent_crashes: GPU FATAL crash counter (#583)
|
||||
# =============================================================================
|
||||
|
||||
# Install a coredumpctl shim. $1 is the coredumpctl-list-style
|
||||
# multi-line output to emit (header + entry rows). The shim ignores
|
||||
# its arguments — tests don't exercise the filter syntax.
|
||||
_install_coredumpctl_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/coredumpctl" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
$1
|
||||
OUT
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/coredumpctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: no coredumpctl on PATH — silent" {
|
||||
# Force coredumpctl off PATH so the helper short-circuits.
|
||||
# Restore PATH before returning so teardown's rm works.
|
||||
local saved_path="$PATH"
|
||||
export PATH="/no-such-dir-for-test"
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
export PATH="$saved_path"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: zero crashes — silent" {
|
||||
# Listing has the header line only, no entry rows.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 1 crash — info line, no warn" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: 3+ crashes — warn + #583 pointer" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M
|
||||
Mon 2026-05-04 07:44:48 EDT 930532 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 22.8M
|
||||
Sun 2026-05-03 14:34:10 EDT 567221 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 12.4M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'Recent Electron crashes: 3'* ]]
|
||||
[[ $output == *'CLAUDE_DISABLE_GPU=1'* ]]
|
||||
[[ $output == *'/issues/583'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: path mismatch falls back with footnote" {
|
||||
# Three crashes from a DIFFERENT electron binary (e.g., Slack).
|
||||
# Caller passes claude-desktop's electron path, which doesn't
|
||||
# match — helper falls back to total count and adds the footnote
|
||||
# so the user knows the count may be cross-app.
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 09:00:00 EDT 200001 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-05 09:00:00 EDT 200002 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M
|
||||
Wed 2026-05-04 09:00:00 EDT 200003 1000 1000 SIGSEGV present /usr/lib/slack/electron 30M'
|
||||
run _doctor_check_recent_crashes \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_recent_crashes: empty electron_path falls back" {
|
||||
_install_coredumpctl_shim 'TIME PID UID GID SIG COREFILE EXE SIZE
|
||||
Wed 2026-05-06 08:00:21 EDT 130375 1000 1000 SIGTRAP present /usr/lib/claude-desktop/node_modules/electron/dist/electron 21.6M'
|
||||
# Caller didn't pass an electron_path — helper still counts and
|
||||
# emits the info line based on the unfiltered total.
|
||||
run _doctor_check_recent_crashes ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Recent Electron crashes: 1'* ]]
|
||||
[[ $output == *'may be from other Electron apps'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_filename_limit: NAME_MAX probe + eCryptfs hint (#590)
|
||||
# =============================================================================
|
||||
|
||||
# Install a getconf shim that emits $1 on stdout. Empty $1 → shim exits 1
|
||||
# so callers can test the "getconf failed" path.
|
||||
_install_getconf_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local value="$1"
|
||||
if [[ -z $value ]]; then
|
||||
cat > "$TEST_TMP/bin/getconf" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/getconf" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
echo ${value}
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/getconf"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
# Install a df shim that emits a single-column fstype listing matching
|
||||
# the `df --output=fstype` shape the helper relies on. Empty $1 → shim
|
||||
# exits 1 so callers can test the "df failed" path.
|
||||
_install_df_shim() {
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
local fstype="$1"
|
||||
if [[ -z $fstype ]]; then
|
||||
cat > "$TEST_TMP/bin/df" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SHIM
|
||||
else
|
||||
cat > "$TEST_TMP/bin/df" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
cat <<'OUT'
|
||||
Type
|
||||
${fstype}
|
||||
OUT
|
||||
SHIM
|
||||
fi
|
||||
chmod +x "$TEST_TMP/bin/df"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when NAME_MAX >= 200" {
|
||||
_install_getconf_shim '255'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: warns when NAME_MAX < 200" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ext4'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'#590'* ]]
|
||||
# Non-ecryptfs fs: no LUKS hint
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: eCryptfs adds LUKS workaround hint" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim 'ecryptfs'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output == *'eCryptfs'* ]]
|
||||
[[ $output == *'LUKS'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent on non-numeric getconf output" {
|
||||
_install_getconf_shim 'undefined'
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: silent when getconf fails" {
|
||||
_install_getconf_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_filename_limit: df failure suppresses eCryptfs hint, keeps warn" {
|
||||
_install_getconf_shim '143'
|
||||
_install_df_shim ''
|
||||
run _doctor_check_filename_limit
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'NAME_MAX=143'* ]]
|
||||
[[ $output != *'eCryptfs'* ]]
|
||||
[[ $output != *'LUKS'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_password_store
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_password_store: output contains 'Password store:' with a valid backend" {
|
||||
# setup() already stubs _detect_password_store to return 'basic'.
|
||||
run _doctor_check_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Password store:'* ]]
|
||||
[[ $output == *'basic'* ]]
|
||||
}
|
||||
@@ -18,35 +18,6 @@ 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
|
||||
@@ -64,25 +35,13 @@ setup() {
|
||||
unset CLAUDE_USE_WAYLAND
|
||||
unset NIRI_SOCKET
|
||||
unset XDG_CURRENT_DESKTOP
|
||||
unset XDG_SESSION_TYPE
|
||||
unset CLAUDE_MENU_BAR
|
||||
unset CLAUDE_TITLEBAR_STYLE
|
||||
unset COWORK_VM_BACKEND
|
||||
unset ELECTRON_USE_SYSTEM_TITLE_BAR
|
||||
unset GTK_IM_MODULE
|
||||
unset XMODIFIERS
|
||||
unset QT_IM_MODULE
|
||||
unset CLAUDE_GTK_IM_MODULE
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
|
||||
# Copy to temp dir so we can substitute the build-time placeholder
|
||||
# and co-locate doctor.sh (sourced via BASH_SOURCE dirname).
|
||||
cp "$SCRIPT_DIR/../scripts/launcher-common.sh" "$TEST_TMP/launcher-common.sh"
|
||||
cp "$SCRIPT_DIR/../scripts/doctor.sh" "$TEST_TMP/doctor.sh"
|
||||
sed -i 's/@@WM_CLASS@@/Claude/' "$TEST_TMP/launcher-common.sh"
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$TEST_TMP/launcher-common.sh"
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
@@ -127,70 +86,6 @@ teardown() {
|
||||
[[ "${lines[1]}" == "test message two" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# log_session_env
|
||||
# =============================================================================
|
||||
|
||||
@test "log_session_env: emits env={ ... } block with all required keys" {
|
||||
setup_logging
|
||||
XDG_SESSION_TYPE='wayland'
|
||||
WAYLAND_DISPLAY='wayland-0'
|
||||
DISPLAY=':0'
|
||||
XDG_CURRENT_DESKTOP='KDE'
|
||||
GTK_IM_MODULE='ibus'
|
||||
XMODIFIERS='@im=ibus'
|
||||
QT_IM_MODULE='ibus'
|
||||
CLAUDE_USE_WAYLAND='1'
|
||||
CLAUDE_TITLEBAR_STYLE='hybrid'
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
CLAUDE_DISABLE_GPU='1'
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match locks block structure (open/close braces on
|
||||
# their own lines) and per-key formatting in one pass.
|
||||
[[ "${lines[0]}" == 'env={' ]]
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=wayland' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=wayland-0' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=:0' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=KDE' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=@im=ibus' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=ibus' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=1' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=hybrid' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=basic' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=xim' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=1' ]]
|
||||
[[ "${lines[13]}" == '}' ]]
|
||||
}
|
||||
|
||||
@test "log_session_env: unset/empty values render as 'KEY=' (no value)" {
|
||||
setup_logging
|
||||
# All vars unset by setup() except this one, which exercises the
|
||||
# empty-string branch (must be indistinguishable from unset).
|
||||
GTK_IM_MODULE=''
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
log_session_env
|
||||
|
||||
run cat "$log_file"
|
||||
# Exact-line match proves the line ends right after '=' — a
|
||||
# substring like *'KEY='* would also match 'KEY=value'.
|
||||
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=' ]]
|
||||
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=' ]]
|
||||
[[ "${lines[3]}" == ' DISPLAY=' ]]
|
||||
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=' ]]
|
||||
[[ "${lines[5]}" == ' GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[6]}" == ' XMODIFIERS=' ]]
|
||||
[[ "${lines[7]}" == ' QT_IM_MODULE=' ]]
|
||||
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=' ]]
|
||||
[[ "${lines[9]}" == ' CLAUDE_TITLEBAR_STYLE=' ]]
|
||||
[[ "${lines[10]}" == ' CLAUDE_PASSWORD_STORE=' ]]
|
||||
[[ "${lines[11]}" == ' CLAUDE_GTK_IM_MODULE=' ]]
|
||||
[[ "${lines[12]}" == ' CLAUDE_DISABLE_GPU=' ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# check_display
|
||||
# =============================================================================
|
||||
@@ -310,13 +205,6 @@ teardown() {
|
||||
# build_electron_args
|
||||
# =============================================================================
|
||||
|
||||
@test "build_electron_args: includes --class matching upstream productName" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--class=Claude'
|
||||
}
|
||||
|
||||
@test "build_electron_args: X11 deb - only CustomTitlebar disabled" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
@@ -405,48 +293,6 @@ teardown() {
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set propagates to GTK_IM_MODULE" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
# Override is logged so users can verify it took effect
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set logs <unset> when GTK_IM_MODULE was unset" {
|
||||
setup_logging
|
||||
# GTK_IM_MODULE unset by setup()
|
||||
CLAUDE_GTK_IM_MODULE='xim'
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'xim' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output == *'GTK_IM_MODULE override: <unset> -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE unset leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
# CLAUDE_GTK_IM_MODULE unset by setup()
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
# No override line should appear in the log
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE empty leaves GTK_IM_MODULE alone" {
|
||||
setup_logging
|
||||
GTK_IM_MODULE='ibus'
|
||||
CLAUDE_GTK_IM_MODULE=''
|
||||
setup_electron_env
|
||||
[[ $GTK_IM_MODULE == 'ibus' ]]
|
||||
run cat "$log_file"
|
||||
[[ $output != *'GTK_IM_MODULE override'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _resolve_titlebar_style
|
||||
# =============================================================================
|
||||
@@ -741,40 +587,3 @@ 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' ]]
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# launcher-disable-gpu.bats
|
||||
# Tests for the CLAUDE_DISABLE_GPU env var handling in
|
||||
# build_electron_args (scripts/launcher-common.sh). The var is an
|
||||
# opt-in workaround for the Chromium GPU process FATAL exhaustion
|
||||
# tracked in #583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
|
||||
# --disable-software-rasterizer; co-occurrence with XRDP must not
|
||||
# stack duplicate flags.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
LAUNCHER_COMMON="${SCRIPT_DIR}/../scripts/launcher-common.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
|
||||
# loginctl shim — same pattern as launcher-xrdp-detection.bats.
|
||||
# Defaults to a non-XRDP session so CLAUDE_DISABLE_GPU is the
|
||||
# only signal in play unless a test overrides MOCK_LOGINCTL_TYPE.
|
||||
mkdir -p "$TEST_TMP/bin"
|
||||
cat > "$TEST_TMP/bin/loginctl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "${MOCK_LOGINCTL_TYPE:-x11}"
|
||||
SHIM
|
||||
chmod +x "$TEST_TMP/bin/loginctl"
|
||||
export PATH="$TEST_TMP/bin:$PATH"
|
||||
|
||||
log_file="$TEST_TMP/launcher.log"
|
||||
: > "$log_file"
|
||||
|
||||
unset CLAUDE_DISABLE_GPU
|
||||
unset XRDP_SESSION
|
||||
unset XDG_SESSION_ID
|
||||
unset MOCK_LOGINCTL_TYPE
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$LAUNCHER_COMMON"
|
||||
|
||||
is_wayland=false
|
||||
use_x11_on_wayland=true
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n ${TEST_TMP:-} && -d $TEST_TMP ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
args_contain() {
|
||||
local needle="$1"
|
||||
local arg
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
args_count() {
|
||||
local needle="$1"
|
||||
local arg count=0
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == "$needle" ]] && ((count++))
|
||||
done
|
||||
printf '%d' "$count"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CLAUDE_DISABLE_GPU=1 — flags must be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: CLAUDE_DISABLE_GPU=1 adds flags + logs message" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Co-occurrence with XRDP — no duplicate flags
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: with XRDP_SESSION, flags added exactly once (no dup)" {
|
||||
export CLAUDE_DISABLE_GPU=1
|
||||
export XRDP_SESSION=1
|
||||
export XDG_SESSION_ID=5
|
||||
export MOCK_LOGINCTL_TYPE=xrdp
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
[[ "$(args_count '--disable-gpu')" -eq 1 ]]
|
||||
[[ "$(args_count '--disable-software-rasterizer')" -eq 1 ]]
|
||||
# Both signals should still log (independent diagnostic value),
|
||||
# but only one set of flags should reach electron_args.
|
||||
grep -q 'XRDP session detected' "$log_file"
|
||||
grep -q 'CLAUDE_DISABLE_GPU=1' "$log_file"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Off-states — flags must NOT be added
|
||||
# =============================================================================
|
||||
|
||||
@test "disable-gpu: unset — flags NOT added" {
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
run args_contain '--disable-software-rasterizer'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: empty string — flags NOT added" {
|
||||
export CLAUDE_DISABLE_GPU=''
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =0 — flags NOT added (only literal '1' opts in)" {
|
||||
export CLAUDE_DISABLE_GPU=0
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: =true — flags NOT added (no boolean aliases)" {
|
||||
# Documents the strict equality check. If we ever add aliases,
|
||||
# update this test to match. Strict-only matches the existing
|
||||
# CLAUDE_USE_WAYLAND pattern.
|
||||
export CLAUDE_DISABLE_GPU=true
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
116
tests/test-artifact-appimage.sh
Executable file → Normal file
116
tests/test-artifact-appimage.sh
Executable file → Normal file
@@ -7,19 +7,6 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=tests/test-artifact-common.sh
|
||||
source "$script_dir/test-artifact-common.sh"
|
||||
|
||||
# Single point of cleanup, set at script scope so any interruption
|
||||
# between resource alloc and normal exit is covered.
|
||||
_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)
|
||||
@@ -107,108 +94,7 @@ assert_contains "$appdir/AppRun" 'build_electron_args' \
|
||||
|
||||
# --- App contents (asar) ---
|
||||
resources_dir="$appdir/usr/lib/node_modules/electron/dist/resources"
|
||||
validate_app_contents "$resources_dir" "${component_id}.desktop"
|
||||
|
||||
# --- Doctor smoke test ---
|
||||
# Some --doctor checks fail in CI (no display, etc.); we only care that
|
||||
# the script itself didn't crash via signal or exec failure (>=127).
|
||||
doctor_exit=0
|
||||
"$appimage_file" --doctor >/dev/null 2>&1 || doctor_exit=$?
|
||||
if [[ $doctor_exit -lt 127 ]]; then
|
||||
pass "--doctor runs without crashing (exit: $doctor_exit)"
|
||||
else
|
||||
fail "--doctor crashed (exit: $doctor_exit)"
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# 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
|
||||
validate_app_contents "$resources_dir"
|
||||
|
||||
# --- Cleanup ---
|
||||
rm -rf "$extract_dir"
|
||||
|
||||
@@ -38,14 +38,6 @@ 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
|
||||
@@ -67,10 +59,8 @@ 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"
|
||||
@@ -105,11 +95,6 @@ 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,14 +41,9 @@ electron_path='/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
assert_file_exists "$electron_path"
|
||||
assert_executable "$electron_path"
|
||||
|
||||
# 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"
|
||||
# chrome-sandbox
|
||||
assert_file_exists \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
|
||||
# --- Desktop entry validation ---
|
||||
desktop_file='/usr/share/applications/claude-desktop.desktop'
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
#!/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 10 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 10 ]] || {
|
||||
echo "expected >= 10 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:'* ]]
|
||||
}
|
||||
@@ -8,7 +8,10 @@ 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).
|
||||
5 H-prefix harness self-tests). See
|
||||
[`docs/testing/runner-implementation-plan.md`](../../docs/testing/runner-implementation-plan.md)
|
||||
for the tiered triage of remaining tests and the per-spec rationale
|
||||
behind tier classification.
|
||||
|
||||
| Test | What it checks | Layer |
|
||||
|------|----------------|-------|
|
||||
@@ -190,12 +193,15 @@ demonstrates the schema-rev path: when invocation rejects with
|
||||
the verbatim rejection string is the cheapest grep target back to
|
||||
the inline hand-rolled validator block (bundle bytes 5013601 /
|
||||
5018821 for the two CustomPlugins methods). See `lib/eipc.ts` for
|
||||
both surfaces.
|
||||
both surfaces, and
|
||||
[`runner-implementation-plan.md`](../../docs/testing/runner-implementation-plan.md)
|
||||
session 7 / 8 / 9 / 10 status sections for the findings.
|
||||
|
||||
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).
|
||||
Per-row pass/skip counts depend on which sweep runs against the row;
|
||||
see `runner-implementation-plan.md` for tier classification and
|
||||
matrix-regen for the most-recent per-row outcomes. The Quick Entry
|
||||
runners (S29-S35) all share the same primitive set (`installInterceptor()`
|
||||
+ `openAndWaitReady()` + scenario-specific state setup).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -435,7 +441,7 @@ is a snapshot of what's currently on screen.
|
||||
- **T04** uses `xprop` (no `xdotool` dependency — walks `_NET_CLIENT_LIST` + `_NET_WM_PID`). Works on X11 native and KDE Wayland (XWayland), **not** on native-Wayland sessions where the app is running through Ozone-Wayland directly. Per Decision 6, project default is X11; native-Wayland window-state queries are deferred until those tests get added.
|
||||
- **T17** is shallow — it intercepts `dialog.showOpenDialog` at the Electron main process level. The integration question "does Claude make the right *portal* call?" is a v2 concern; portal-level mocking via `dbus-next` is sketched in [`docs/testing/automation.md`](../../docs/testing/automation.md) but requires displacing the running portal service or running under `dbus-run-session`.
|
||||
- **`render-matrix.sh`** isn't here yet. `sweep.sh` prints a summary; the `matrix.md` regen step from JUnit is the next addition.
|
||||
- **No CI wrapper.** Decision 4: the harness is invocable from CI but sweeps run from the dev box for the first ~20 tests.
|
||||
- **No CI wrapper.** Decision 4: the harness is invokable from CI but sweeps run from the dev box for the first ~20 tests.
|
||||
|
||||
## Adding a test
|
||||
|
||||
|
||||
280
tools/test-harness/explore/derive-vocabulary.ts
Normal file
280
tools/test-harness/explore/derive-vocabulary.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// Derives the stable-UI vocabulary corpus from an existing inventory.
|
||||
// Output is committed at docs/testing/ui-vocabulary.json and consumed
|
||||
// by the v7 walker (Phase 2) when classifying captured accessible-
|
||||
// names. Re-run on each major upstream release.
|
||||
//
|
||||
// Rules (adapted from the v7 plan to the v6-collapsed inventory shape):
|
||||
// - Persistent entries collapse to one inventory entry with a
|
||||
// `surfaces[]` array recording every surface the element was
|
||||
// observed on. Any persistent label whose surfaces[] has length
|
||||
// >= 2 is stable by definition.
|
||||
// - Structural / menu entries: stable if the label is shared by 3+
|
||||
// entries OR appears on 2+ distinct surfaces. Either signal is
|
||||
// enough — the plan's strict 3-and-2 conjunction over-rejects
|
||||
// against a v6-collapsed inventory where most chrome already
|
||||
// deduped to one entry.
|
||||
// - Names matching any INSTANCE_SHAPES regex go to instanceShapes
|
||||
// and are excluded from stable / suspect even if they would have
|
||||
// qualified — the instance-shape pattern is the canonical
|
||||
// representation for those at resolve time.
|
||||
// - kind: instance entries are excluded from the stable corpus
|
||||
// entirely — those labels by definition vary per session. (A
|
||||
// label that appears in BOTH instance and structural entries
|
||||
// follows the structural / menu rule.)
|
||||
// - Everything else falls through to `suspect`, queued for human
|
||||
// reconciliation.
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { INSTANCE_SHAPES } from '../src/lib/name-classifier.js';
|
||||
import type { Inventory, InventoryEntry } from './walker.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const TESTING_DIR = resolve(HERE, '..', '..', '..', 'docs', 'testing');
|
||||
const DEFAULT_INVENTORY = resolve(TESTING_DIR, 'ui-inventory.json');
|
||||
const DEFAULT_OUTPUT = resolve(TESTING_DIR, 'ui-vocabulary.json');
|
||||
|
||||
interface CliOpts {
|
||||
inventory: string;
|
||||
output: string;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
interface InstanceShapeOutput {
|
||||
id: string;
|
||||
regex: string;
|
||||
flags: string;
|
||||
pattern: string | null;
|
||||
matchedNames: string[];
|
||||
}
|
||||
|
||||
interface VocabularyOutput {
|
||||
derivedAt: string;
|
||||
sourceInventory: {
|
||||
capturedAt: string;
|
||||
appVersion: string;
|
||||
walkerVersion: string;
|
||||
totalElements: number;
|
||||
};
|
||||
stable: string[];
|
||||
instanceShapes: InstanceShapeOutput[];
|
||||
suspect: string[];
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOpts {
|
||||
const opts: CliOpts = {
|
||||
inventory: DEFAULT_INVENTORY,
|
||||
output: DEFAULT_OUTPUT,
|
||||
help: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
switch (a) {
|
||||
case '-h':
|
||||
case '--help':
|
||||
opts.help = true;
|
||||
break;
|
||||
case '--inventory': {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('--inventory requires a path\n');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.inventory = resolve(v);
|
||||
break;
|
||||
}
|
||||
case '--output': {
|
||||
const v = argv[++i];
|
||||
if (!v) {
|
||||
process.stderr.write('--output requires a path\n');
|
||||
process.exit(1);
|
||||
}
|
||||
opts.output = resolve(v);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
process.stderr.write(
|
||||
`derive-vocabulary: unknown argument: ${a}\n`,
|
||||
);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
'Usage: tsx explore/derive-vocabulary.ts [options]\n' +
|
||||
'\n' +
|
||||
'Derives docs/testing/ui-vocabulary.json from an existing\n' +
|
||||
'inventory walk. Output records the stable-UI corpus, the\n' +
|
||||
'instance-shape registry hits, and any names flagged for\n' +
|
||||
'human triage.\n' +
|
||||
'\n' +
|
||||
'Options:\n' +
|
||||
' --inventory <path> Override default inventory path\n' +
|
||||
' (default: docs/testing/ui-inventory.json)\n' +
|
||||
' --output <path> Override default vocabulary output path\n' +
|
||||
' (default: docs/testing/ui-vocabulary.json)\n' +
|
||||
' -h, --help Print this help and exit\n',
|
||||
);
|
||||
}
|
||||
|
||||
function loadInventory(path: string): Inventory {
|
||||
if (!existsSync(path)) {
|
||||
process.stderr.write(
|
||||
`derive-vocabulary: inventory not found: ${path}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as Inventory;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`derive-vocabulary: failed to parse inventory: ${msg}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
interface LabelStats {
|
||||
kinds: Set<InventoryEntry['kind']>;
|
||||
surfaces: Set<string>;
|
||||
entryCount: number;
|
||||
maxPersistentSpan: number;
|
||||
}
|
||||
|
||||
function aggregate(inv: Inventory): Map<string, LabelStats> {
|
||||
const stats = new Map<string, LabelStats>();
|
||||
for (const e of inv.entries) {
|
||||
const lbl = e.label;
|
||||
if (!lbl) continue;
|
||||
let s = stats.get(lbl);
|
||||
if (!s) {
|
||||
s = {
|
||||
kinds: new Set(),
|
||||
surfaces: new Set(),
|
||||
entryCount: 0,
|
||||
maxPersistentSpan: 0,
|
||||
};
|
||||
stats.set(lbl, s);
|
||||
}
|
||||
s.kinds.add(e.kind);
|
||||
s.surfaces.add(e.surface);
|
||||
s.entryCount += 1;
|
||||
if (e.kind === 'persistent' && e.surfaces) {
|
||||
s.maxPersistentSpan = Math.max(
|
||||
s.maxPersistentSpan,
|
||||
e.surfaces.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function classify(inv: Inventory): VocabularyOutput {
|
||||
const stats = aggregate(inv);
|
||||
const stable = new Set<string>();
|
||||
const suspect = new Set<string>();
|
||||
const instanceHits = new Map<string, Set<string>>();
|
||||
for (const shape of INSTANCE_SHAPES) {
|
||||
instanceHits.set(shape.id, new Set());
|
||||
}
|
||||
|
||||
for (const [lbl, s] of stats) {
|
||||
// Pure-instance label — exclude entirely.
|
||||
if (s.kinds.size === 1 && s.kinds.has('instance')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Instance-shape regex match — record + skip stable/suspect.
|
||||
let shapeMatched = false;
|
||||
for (const shape of INSTANCE_SHAPES) {
|
||||
if (shape.regex.test(lbl)) {
|
||||
instanceHits.get(shape.id)!.add(lbl);
|
||||
shapeMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shapeMatched) continue;
|
||||
|
||||
// Persistent: surfaces[] >= 2 carries the proof that the chrome
|
||||
// element actually spans surfaces.
|
||||
if (s.maxPersistentSpan >= 2) {
|
||||
stable.add(lbl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Structural / menu: 3+ entries OR 2+ distinct surfaces.
|
||||
if (s.entryCount >= 3 || s.surfaces.size >= 2) {
|
||||
stable.add(lbl);
|
||||
continue;
|
||||
}
|
||||
|
||||
suspect.add(lbl);
|
||||
}
|
||||
|
||||
const instanceShapesOut: InstanceShapeOutput[] = INSTANCE_SHAPES.map(
|
||||
(shape) => ({
|
||||
id: shape.id,
|
||||
regex: shape.regex.source,
|
||||
flags: shape.regex.flags,
|
||||
pattern: shape.pattern,
|
||||
matchedNames: [...instanceHits.get(shape.id)!].sort(),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
derivedAt: new Date().toISOString(),
|
||||
sourceInventory: {
|
||||
capturedAt: inv.capturedAt,
|
||||
appVersion: inv.appVersion,
|
||||
walkerVersion: inv.walkerVersion,
|
||||
totalElements: inv.totalElements,
|
||||
},
|
||||
stable: [...stable].sort(),
|
||||
instanceShapes: instanceShapesOut,
|
||||
suspect: [...suspect].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, body: string): void {
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, body, 'utf8');
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
const inv = loadInventory(opts.inventory);
|
||||
const out = classify(inv);
|
||||
const body = `${JSON.stringify(out, null, 2)}\n`;
|
||||
atomicWrite(opts.output, body);
|
||||
|
||||
const shapeHitTotal = out.instanceShapes.reduce(
|
||||
(n, s) => n + s.matchedNames.length,
|
||||
0,
|
||||
);
|
||||
process.stdout.write(
|
||||
`derive-vocabulary: wrote ${opts.output}\n` +
|
||||
` source: ${opts.inventory} (${inv.totalElements} entries)\n` +
|
||||
` stable: ${out.stable.length}, ` +
|
||||
`instance-shaped: ${shapeHitTotal} (${out.instanceShapes.filter((s) => s.matchedNames.length > 0).length} shapes hit), ` +
|
||||
`suspect: ${out.suspect.length}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
313
tools/test-harness/explore/diff.ts
Normal file
313
tools/test-harness/explore/diff.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
// Snapshot comparator.
|
||||
//
|
||||
// Diff semantics, in priority order:
|
||||
// - removed: an element keyed in A is absent from B → drift signal.
|
||||
// - changed: same key, different visible text or aria-label → drift.
|
||||
// - added: new key in B → informational only (UI gained surface).
|
||||
//
|
||||
// Keys are stable identity tokens chosen per element class:
|
||||
// - df-pill: aria-label (Chat / Cowork / Code)
|
||||
// - compactPill: inner text (env value, "Select folder…", …)
|
||||
// - ariaButton: aria-label (sidebar "more" buttons share labels;
|
||||
// we de-dup by counting; see compareCounts below)
|
||||
// - modal: headingText ?? aria-label ?? aria-labelledby
|
||||
// - openMenu: items diffed by `${role}::${text}`
|
||||
//
|
||||
// Pure module — no I/O, no process.exit. The dispatcher reads files
|
||||
// and prints; this file just produces a Diff value.
|
||||
|
||||
import type {
|
||||
AriaButton,
|
||||
CompactPillSnap,
|
||||
DfPill,
|
||||
MenuItem,
|
||||
ModalSnap,
|
||||
OpenMenu,
|
||||
Snapshot,
|
||||
} from './snapshot.js';
|
||||
|
||||
export interface DiffEntry {
|
||||
kind: 'removed' | 'changed' | 'added';
|
||||
category: string;
|
||||
key: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
export interface DiffResult {
|
||||
a: { capturedAt: string; url: string; appVersion: string | null };
|
||||
b: { capturedAt: string; url: string; appVersion: string | null };
|
||||
entries: DiffEntry[];
|
||||
summary: { removed: number; changed: number; added: number };
|
||||
}
|
||||
|
||||
export function diff(a: Snapshot, b: Snapshot): DiffResult {
|
||||
const entries: DiffEntry[] = [];
|
||||
entries.push(...diffDfPills(a.dfPills, b.dfPills));
|
||||
entries.push(...diffCompactPills(a.compactPills, b.compactPills));
|
||||
entries.push(...diffAriaButtons(a.ariaLabeledButtons, b.ariaLabeledButtons));
|
||||
entries.push(...diffModals(a.modals, b.modals));
|
||||
entries.push(...diffOpenMenu(a.openMenu, b.openMenu));
|
||||
const summary = entries.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.kind] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ removed: 0, changed: 0, added: 0 },
|
||||
);
|
||||
return {
|
||||
a: {
|
||||
capturedAt: a.capturedAt,
|
||||
url: a.claudeAiUrl,
|
||||
appVersion: a.appVersion,
|
||||
},
|
||||
b: {
|
||||
capturedAt: b.capturedAt,
|
||||
url: b.claudeAiUrl,
|
||||
appVersion: b.appVersion,
|
||||
},
|
||||
entries,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
// Human-readable formatter. Removed/changed first (they're failures
|
||||
// in spirit), added last (informational). Empty diff prints a single
|
||||
// line so CI logs stay tidy.
|
||||
export function formatDiff(d: DiffResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`A: ${d.a.capturedAt} (${d.a.url}) app=${d.a.appVersion}`);
|
||||
lines.push(`B: ${d.b.capturedAt} (${d.b.url}) app=${d.b.appVersion}`);
|
||||
lines.push('');
|
||||
if (d.entries.length === 0) {
|
||||
lines.push('No differences.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
const order: DiffEntry['kind'][] = ['removed', 'changed', 'added'];
|
||||
for (const kind of order) {
|
||||
const group = d.entries.filter((e) => e.kind === kind);
|
||||
if (group.length === 0) continue;
|
||||
lines.push(`# ${kind.toUpperCase()} (${group.length})`);
|
||||
for (const e of group) {
|
||||
if (e.kind === 'changed') {
|
||||
lines.push(
|
||||
` [${e.category}] ${e.key}: ${e.before ?? ''} → ${e.after ?? ''}`,
|
||||
);
|
||||
} else if (e.kind === 'removed') {
|
||||
lines.push(` [${e.category}] ${e.key}: ${e.before ?? ''}`);
|
||||
} else {
|
||||
lines.push(` [${e.category}] ${e.key}: ${e.after ?? ''}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(
|
||||
`Summary: ${d.summary.removed} removed, ` +
|
||||
`${d.summary.changed} changed, ${d.summary.added} added`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function diffDfPills(a: DfPill[], b: DfPill[]): DiffEntry[] {
|
||||
const aMap = byKey(a, (p) => p.ariaLabel ?? p.text);
|
||||
const bMap = byKey(b, (p) => p.ariaLabel ?? p.text);
|
||||
return compareMaps(aMap, bMap, 'dfPill', (p) => p.text);
|
||||
}
|
||||
|
||||
function diffCompactPills(
|
||||
a: CompactPillSnap[],
|
||||
b: CompactPillSnap[],
|
||||
): DiffEntry[] {
|
||||
// Compact pills can repeat by text in pathological cases, so we
|
||||
// disambiguate by appending an ordinal when needed. The ordinal is
|
||||
// stable as long as DOM order is — same approach `findCompactPills`
|
||||
// callers rely on.
|
||||
const aMap = byKeyOrdinal(a, (p) => p.text);
|
||||
const bMap = byKeyOrdinal(b, (p) => p.text);
|
||||
return compareMaps(aMap, bMap, 'compactPill', (p) => `maxW=${p.maxW}`);
|
||||
}
|
||||
|
||||
// Aria-labeled buttons frequently repeat (sidebar's ~80 conversation-row
|
||||
// "more" buttons all share a label). We compare by *count per label*
|
||||
// instead of per-instance: a delta in count surfaces as a single
|
||||
// changed entry, which is far more readable than 80 added/removed
|
||||
// rows. Per-label text is omitted since duplicate labels mean text is
|
||||
// not a stable identity.
|
||||
function diffAriaButtons(a: AriaButton[], b: AriaButton[]): DiffEntry[] {
|
||||
return compareCounts(
|
||||
countBy(a, (x) => x.ariaLabel),
|
||||
countBy(b, (x) => x.ariaLabel),
|
||||
'ariaButton',
|
||||
);
|
||||
}
|
||||
|
||||
function diffModals(a: ModalSnap[], b: ModalSnap[]): DiffEntry[] {
|
||||
const key = (m: ModalSnap) =>
|
||||
m.headingText ?? m.ariaLabel ?? m.ariaLabelledBy ?? '<unlabeled-modal>';
|
||||
const aMap = byKeyOrdinal(a, key);
|
||||
const bMap = byKeyOrdinal(b, key);
|
||||
return compareMaps(aMap, bMap, 'modal', (m) =>
|
||||
`buttons=${m.buttonLabels.join('|')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Menu diff is special: the "key" is the menu identity, but a menu
|
||||
// diff is really an item-set diff. We compare item lists, scoped under
|
||||
// the menu's labelledBy/ariaLabel for context.
|
||||
function diffOpenMenu(
|
||||
a: OpenMenu | null,
|
||||
b: OpenMenu | null,
|
||||
): DiffEntry[] {
|
||||
if (!a && !b) return [];
|
||||
const scope =
|
||||
(a?.ariaLabel ?? b?.ariaLabel) ||
|
||||
(a?.ariaLabelledBy ?? b?.ariaLabelledBy) ||
|
||||
'<menu>';
|
||||
if (a && !b) {
|
||||
return [
|
||||
{
|
||||
kind: 'removed',
|
||||
category: 'openMenu',
|
||||
key: scope,
|
||||
before: a.items.map(itemKey).join(' | '),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!a && b) {
|
||||
return [
|
||||
{
|
||||
kind: 'added',
|
||||
category: 'openMenu',
|
||||
key: scope,
|
||||
after: b.items.map(itemKey).join(' | '),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!a || !b) return [];
|
||||
const aMap = byKeyOrdinal(a.items, itemKey);
|
||||
const bMap = byKeyOrdinal(b.items, itemKey);
|
||||
return compareMaps(
|
||||
aMap,
|
||||
bMap,
|
||||
`openMenu[${scope}]`,
|
||||
(it) =>
|
||||
`disabled=${it.disabled}` +
|
||||
(it.ariaChecked !== null ? ` checked=${it.ariaChecked}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
function itemKey(it: MenuItem): string {
|
||||
return `${it.role}::${it.text}`;
|
||||
}
|
||||
|
||||
function byKey<T>(arr: T[], k: (t: T) => string): Map<string, T> {
|
||||
const m = new Map<string, T>();
|
||||
for (const it of arr) m.set(k(it), it);
|
||||
return m;
|
||||
}
|
||||
|
||||
// When keys collide, append `#2`, `#3`, … so the comparator can still
|
||||
// detect "we used to have 3, now we have 2" (one #N drops out as
|
||||
// removed). Ordinals are local to this snapshot — they don't cross
|
||||
// snapshot boundaries.
|
||||
function byKeyOrdinal<T>(arr: T[], k: (t: T) => string): Map<string, T> {
|
||||
const m = new Map<string, T>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const it of arr) {
|
||||
const base = k(it);
|
||||
const n = (counts.get(base) ?? 0) + 1;
|
||||
counts.set(base, n);
|
||||
m.set(n === 1 ? base : `${base}#${n}`, it);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function countBy<T>(arr: T[], k: (t: T) => string): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const it of arr) {
|
||||
const key = k(it);
|
||||
m.set(key, (m.get(key) ?? 0) + 1);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function compareMaps<T>(
|
||||
a: Map<string, T>,
|
||||
b: Map<string, T>,
|
||||
category: string,
|
||||
describe: (t: T) => string,
|
||||
): DiffEntry[] {
|
||||
const out: DiffEntry[] = [];
|
||||
for (const [k, v] of a) {
|
||||
const bv = b.get(k);
|
||||
if (bv === undefined) {
|
||||
out.push({
|
||||
kind: 'removed',
|
||||
category,
|
||||
key: k,
|
||||
before: describe(v),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const before = describe(v);
|
||||
const after = describe(bv);
|
||||
if (before !== after) {
|
||||
out.push({
|
||||
kind: 'changed',
|
||||
category,
|
||||
key: k,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, v] of b) {
|
||||
if (!a.has(k)) {
|
||||
out.push({
|
||||
kind: 'added',
|
||||
category,
|
||||
key: k,
|
||||
after: describe(v),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function compareCounts(
|
||||
a: Map<string, number>,
|
||||
b: Map<string, number>,
|
||||
category: string,
|
||||
): DiffEntry[] {
|
||||
const out: DiffEntry[] = [];
|
||||
for (const [k, n] of a) {
|
||||
const m = b.get(k);
|
||||
if (m === undefined) {
|
||||
out.push({
|
||||
kind: 'removed',
|
||||
category,
|
||||
key: k,
|
||||
before: `count=${n}`,
|
||||
});
|
||||
} else if (m !== n) {
|
||||
out.push({
|
||||
kind: 'changed',
|
||||
category,
|
||||
key: k,
|
||||
before: `count=${n}`,
|
||||
after: `count=${m}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, m] of b) {
|
||||
if (!a.has(k)) {
|
||||
out.push({
|
||||
kind: 'added',
|
||||
category,
|
||||
key: k,
|
||||
after: `count=${m}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user