mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-06-26 04:07:09 +03:00
Compare commits
176 Commits
docs/gover
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdf934a061 | ||
|
|
bf78ec6c51 | ||
|
|
7327c95402 | ||
|
|
d15309746b | ||
|
|
96ffbb4640 | ||
|
|
ad2635a4f3 | ||
|
|
81e6e176d1 | ||
|
|
a1fc200254 | ||
|
|
8b70d0b05c | ||
|
|
295d71beb0 | ||
|
|
0d76a85104 | ||
|
|
83ea6372cd | ||
|
|
c5f8c0f4a7 | ||
|
|
d7e0611091 | ||
|
|
748b388548 | ||
|
|
da341d733c | ||
|
|
e8b9bfc27b | ||
|
|
5e4f26b28e | ||
|
|
2d1d0c59ff | ||
|
|
d2ce046631 | ||
|
|
e85450c90b | ||
|
|
e0c41b4e52 | ||
|
|
fa184216b3 | ||
|
|
dc2e0ecce2 | ||
|
|
4a6a540bf1 | ||
|
|
e1bdafd169 | ||
|
|
c2ceb5e74f | ||
|
|
63940b2684 | ||
|
|
55347230b2 | ||
|
|
73b504834a | ||
|
|
6f10b009ec | ||
|
|
50dd1f0366 | ||
|
|
9790c7d77c | ||
|
|
9648416aab | ||
|
|
26de2c2957 | ||
|
|
12cc726fc1 | ||
|
|
e50adf298a | ||
|
|
0aae168ab2 | ||
|
|
b0ca0c811f | ||
|
|
e18325dc25 | ||
|
|
98306b56b5 | ||
|
|
79973e3422 | ||
|
|
b35a1404ce | ||
|
|
974f4d397b | ||
|
|
bdd5b00f40 | ||
|
|
bcdc047dd5 | ||
|
|
1c241ab443 | ||
|
|
769036d55f | ||
|
|
a87024c3e2 | ||
|
|
8319407726 | ||
|
|
f3647b044d | ||
|
|
a4b85115f0 | ||
|
|
de957183f1 | ||
|
|
06736b4cd7 | ||
|
|
c6cc037a30 | ||
|
|
d1eb24a7d4 | ||
|
|
5c43ccd6e2 | ||
|
|
ab17b69a9c | ||
|
|
b78d3104e6 | ||
|
|
992dd34353 | ||
|
|
4002fd34f8 | ||
|
|
6ed49f5a3f | ||
|
|
9236476c10 | ||
|
|
29fa6daa36 | ||
|
|
eefaad3c9d | ||
|
|
133ffcee86 | ||
|
|
6b02fbbfa6 | ||
|
|
d854e67ed2 | ||
|
|
c4fd450cee | ||
|
|
b7b444d13e | ||
|
|
a691909ef2 | ||
|
|
62fca415e1 | ||
|
|
3cf58ca26d | ||
|
|
b2abc50a77 | ||
|
|
d8693baa6f | ||
|
|
b511a8ba6f | ||
|
|
d1fe02185b | ||
|
|
f2aa627a51 | ||
|
|
47e4011bd0 | ||
|
|
3a01379792 | ||
|
|
d99cdbd0e0 | ||
|
|
1b31a41835 | ||
|
|
b94aec10d1 | ||
|
|
2b332b99c9 | ||
|
|
9762043c84 | ||
|
|
4b6ff35236 | ||
|
|
6c09ca26b5 | ||
|
|
eb3fec9cd2 | ||
|
|
3cfe1812fc | ||
|
|
49acfd1ea0 | ||
|
|
3e18776965 | ||
|
|
fd72940516 | ||
|
|
407ba29add | ||
|
|
514bba919f | ||
|
|
77f7d8dafe | ||
|
|
af05e3d1dc | ||
|
|
e81f1a000d | ||
|
|
bd5c699f5a | ||
|
|
2f7c3e7148 | ||
|
|
c56130b906 | ||
|
|
230819fa23 | ||
|
|
21fe23d434 | ||
|
|
88eb10d1c2 | ||
|
|
95dc1840e1 | ||
|
|
d53474ba50 | ||
|
|
056fef077e | ||
|
|
6da192a6c2 | ||
|
|
09b883628d | ||
|
|
9ed1bf0f51 | ||
|
|
9505574cc3 | ||
|
|
5b55a98a7b | ||
|
|
aa08302d45 | ||
|
|
5772cc1eb9 | ||
|
|
2ad33d36c2 | ||
|
|
9dc431404f | ||
|
|
53dfe4a9dd | ||
|
|
32ce566080 | ||
|
|
0b281eb954 | ||
|
|
2ede75d049 | ||
|
|
8b4aa53449 | ||
|
|
a77cec7578 | ||
|
|
a5b54dd3c7 | ||
|
|
55bc328574 | ||
|
|
e13e331745 | ||
|
|
4fd8d8ce5d | ||
|
|
f803af9efc | ||
|
|
dd8d6e1dd6 | ||
|
|
1ae489dece | ||
|
|
623f1b0373 | ||
|
|
2ae2172a60 | ||
|
|
5dd948e96d | ||
|
|
5513f1b867 | ||
|
|
7dc26eae9b | ||
|
|
2ed019405f | ||
|
|
5b2fb4141b | ||
|
|
988a866310 | ||
|
|
e38066efef | ||
|
|
ec12c49092 | ||
|
|
73c9b8f6b2 | ||
|
|
7917ea4927 | ||
|
|
e7e647512a | ||
|
|
4409f3f0d4 | ||
|
|
4451694930 | ||
|
|
98232dbd81 | ||
|
|
76a5a21725 | ||
|
|
e31ac3b4da | ||
|
|
3e1e508f69 | ||
|
|
5f67aa1ae4 | ||
|
|
a894d41f76 | ||
|
|
a470b30079 | ||
|
|
b40441c66c | ||
|
|
364147ecc6 | ||
|
|
d6fc044490 | ||
|
|
9f99e578da | ||
|
|
ee3d656715 | ||
|
|
fed3b54bb5 | ||
|
|
7151d77b8d | ||
|
|
7b990c3aeb | ||
|
|
3df24958a3 | ||
|
|
58eef6d865 | ||
|
|
428777aca5 | ||
|
|
880d21d51f | ||
|
|
6bfb296d5c | ||
|
|
337e9a45b0 | ||
|
|
a32e1aa3c3 | ||
|
|
1e339aea93 | ||
|
|
e9b71cb567 | ||
|
|
016d8660c8 | ||
|
|
d54efca7de | ||
|
|
920c2be926 | ||
|
|
dc00767cd8 | ||
|
|
fa42d4d05f | ||
|
|
de604e9445 | ||
|
|
5b5c604723 | ||
|
|
97531b2cdf | ||
|
|
bc3580c23e |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -54,9 +54,11 @@ jobs:
|
||||
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
|
||||
|
||||
test-artifacts:
|
||||
name: Test Build Artifacts
|
||||
name: Test Build Artifacts (amd64)
|
||||
needs: [build-amd64]
|
||||
uses: ./.github/workflows/test-artifacts.yml
|
||||
with:
|
||||
arch: amd64
|
||||
|
||||
build-arm64:
|
||||
name: Build Packages (arm64 - ${{ matrix.artifact_suffix }})
|
||||
@@ -82,10 +84,17 @@ jobs:
|
||||
artifact_suffix: ${{ matrix.artifact_suffix }}
|
||||
release_tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
|
||||
|
||||
test-artifacts-arm64:
|
||||
name: Test Build Artifacts (arm64)
|
||||
needs: [build-arm64]
|
||||
uses: ./.github/workflows/test-artifacts.yml
|
||||
with:
|
||||
arch: arm64
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [test-flags, build-amd64, build-arm64, test-artifacts]
|
||||
needs: [test-flags, build-amd64, build-arm64, test-artifacts, test-artifacts-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
59
.github/workflows/issue-triage-v2.yml
vendored
59
.github/workflows/issue-triage-v2.yml
vendored
@@ -215,6 +215,7 @@ jobs:
|
||||
if: steps.classify.outputs.classification == 'bug' || steps.classify.outputs.classification == 'enhancement'
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
FIRST_PASS: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/classify-doublecheck-bug-vs-enhancement.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -249,7 +250,7 @@ jobs:
|
||||
printf '%s' "${structured}" \
|
||||
> /tmp/triage/classification-doublecheck.json
|
||||
|
||||
first_pass="${{ steps.classify.outputs.classification }}"
|
||||
first_pass="${FIRST_PASS}"
|
||||
verdict=$(jq -r '.verdict' \
|
||||
/tmp/triage/classification-doublecheck.json)
|
||||
|
||||
@@ -271,10 +272,14 @@ jobs:
|
||||
# classifier entirely.
|
||||
- name: Decide route
|
||||
id: route
|
||||
env:
|
||||
SUSPICIOUS: ${{ steps.suspicious.outputs.suspicious }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
DISAGREED: ${{ steps.doublecheck.outputs.disagreed }}
|
||||
run: |
|
||||
suspicious="${{ steps.suspicious.outputs.suspicious }}"
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
disagreed="${{ steps.doublecheck.outputs.disagreed }}"
|
||||
suspicious="${SUSPICIOUS}"
|
||||
classification="${CLASSIFICATION}"
|
||||
disagreed="${DISAGREED}"
|
||||
|
||||
if [[ "${suspicious}" == "true" ]]; then
|
||||
echo "route=deferral" >> "$GITHUB_OUTPUT"
|
||||
@@ -484,6 +489,7 @@ jobs:
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/investigate.json)
|
||||
title=$(jq -r '.title' /tmp/triage/issue.json)
|
||||
@@ -530,7 +536,7 @@ jobs:
|
||||
# the PR. The reporter named a culprit; the diff is a
|
||||
# primary input for Stage 4 because the defect site is
|
||||
# almost always inside the named PR's changed files.
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
echo "## Regression context (PR named by reporter)"
|
||||
echo ""
|
||||
reg_title=$(jq -r '.title' /tmp/triage/regression-of.json)
|
||||
@@ -763,6 +769,7 @@ jobs:
|
||||
|| steps.dup_fetch.outputs.dup_fetched == 'true')
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
HAS_REGRESSION: ${{ steps.regression.outputs.has_regression }}
|
||||
CLASSIFICATION_NAME: ${{ steps.classify.outputs.classification }}
|
||||
run: |
|
||||
schema=$(cat .claude/scripts/schemas/review.json)
|
||||
@@ -867,7 +874,7 @@ jobs:
|
||||
# regression_of diff block — only when Stage 3b validated.
|
||||
# Lets the reviewer check whether a finding's citation
|
||||
# actually lands inside the named PR's changed files.
|
||||
if [[ "${{ steps.regression.outputs.has_regression }}" == "true" ]]; then
|
||||
if [[ "${HAS_REGRESSION}" == "true" ]]; then
|
||||
echo "## regression_of PR diff (reporter-named culprit)"
|
||||
echo ""
|
||||
reg_num=$(jq -r '.pr_number' /tmp/triage/regression-of.json)
|
||||
@@ -1027,25 +1034,37 @@ jobs:
|
||||
# low-confidence cause).
|
||||
- name: Decide comment variant
|
||||
id: decide
|
||||
env:
|
||||
ROUTE: ${{ steps.route.outputs.route }}
|
||||
DEFERRAL_REASON_ID: ${{ steps.route.outputs.deferral_reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
FETCH_OK: ${{ steps.fetch.outputs.fetch_ok }}
|
||||
INVEST_OK: ${{ steps.investigate.outputs.investigate_ok }}
|
||||
DRIFT: ${{ steps.drift.outputs.drift_detected }}
|
||||
REVIEW_OK: ${{ steps.review.outputs.review_ok }}
|
||||
FINDINGS_PASSED: ${{ steps.validate.outputs.findings_passed }}
|
||||
KEPT: ${{ steps.filter.outputs.review_findings_kept }}
|
||||
AVG: ${{ steps.filter.outputs.review_avg_confidence }}
|
||||
DUP_RATING: ${{ steps.filter.outputs.duplicate_of_rating }}
|
||||
run: |
|
||||
route="${{ steps.route.outputs.route }}"
|
||||
route="${ROUTE}"
|
||||
|
||||
if [[ "${route}" == "deferral" ]]; then
|
||||
echo "variant=8b" >> "$GITHUB_OUTPUT"
|
||||
echo "reason_id=${{ steps.route.outputs.deferral_reason_id }}" \
|
||||
echo "reason_id=${DEFERRAL_REASON_ID}" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
fetch_ok="${{ steps.fetch.outputs.fetch_ok }}"
|
||||
invest_ok="${{ steps.investigate.outputs.investigate_ok }}"
|
||||
drift="${{ steps.drift.outputs.drift_detected }}"
|
||||
review_ok="${{ steps.review.outputs.review_ok }}"
|
||||
findings_passed="${{ steps.validate.outputs.findings_passed }}"
|
||||
kept="${{ steps.filter.outputs.review_findings_kept }}"
|
||||
avg="${{ steps.filter.outputs.review_avg_confidence }}"
|
||||
dup_rating="${{ steps.filter.outputs.duplicate_of_rating }}"
|
||||
classification="${CLASSIFICATION}"
|
||||
fetch_ok="${FETCH_OK}"
|
||||
invest_ok="${INVEST_OK}"
|
||||
drift="${DRIFT}"
|
||||
review_ok="${REVIEW_OK}"
|
||||
findings_passed="${FINDINGS_PASSED}"
|
||||
kept="${KEPT}"
|
||||
avg="${AVG}"
|
||||
dup_rating="${DUP_RATING}"
|
||||
|
||||
# Shared gates that apply to every investigate route.
|
||||
if [[ "${fetch_ok}" != "true" ]]; then
|
||||
@@ -1735,9 +1754,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REASON_ID: ${{ steps.decide.outputs.reason_id }}
|
||||
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
|
||||
VARIANT: ${{ steps.decide.outputs.variant }}
|
||||
run: |
|
||||
classification="${{ steps.classify.outputs.classification }}"
|
||||
variant="${{ steps.decide.outputs.variant }}"
|
||||
classification="${CLASSIFICATION}"
|
||||
variant="${VARIANT}"
|
||||
|
||||
if [[ "${variant}" == "8a" ]]; then
|
||||
triage_label="triage: investigated"
|
||||
|
||||
50
.github/workflows/test-artifacts.yml
vendored
50
.github/workflows/test-artifacts.yml
vendored
@@ -2,6 +2,11 @@ name: Test Build Artifacts (Reusable)
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
arch:
|
||||
description: Architecture of the artifacts under test (amd64/arm64)
|
||||
type: string
|
||||
default: amd64
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,17 +18,17 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- format: deb
|
||||
artifact: package-amd64-deb
|
||||
container: ""
|
||||
- format: rpm
|
||||
artifact: package-amd64-rpm
|
||||
container: "fedora:42"
|
||||
- format: appimage
|
||||
artifact: package-amd64-appimage
|
||||
container: ""
|
||||
|
||||
name: Validate ${{ matrix.format }} package
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate ${{ inputs.arch }} ${{ matrix.format }} package
|
||||
# arm64 artifacts run on a native arm64 runner (matching build-arm64)
|
||||
# so the launch smoke test actually executes the packaged binary
|
||||
# rather than failing on a foreign architecture.
|
||||
runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-latest' }}
|
||||
container: ${{ matrix.container || '' }}
|
||||
|
||||
steps:
|
||||
@@ -33,12 +38,24 @@ jobs:
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
name: package-${{ inputs.arch }}-${{ matrix.format }}
|
||||
path: artifacts/
|
||||
|
||||
- name: Install test dependencies (Fedora)
|
||||
if: matrix.format == 'rpm'
|
||||
run: dnf install -y findutils file nodejs npm
|
||||
# Electron's shared libraries (nss/nspr/gtk3/X11/etc.) must be
|
||||
# installed explicitly: the rpm is installed with `rpm -ivh --nodeps`
|
||||
# and its spec sets `AutoReqProv: no`, so the package declares no
|
||||
# runtime Requires and nothing pulls these in. Without them the
|
||||
# launch smoke test dies with `libnspr4.so: cannot open shared
|
||||
# object file` (exit 127). The Ubuntu runner already carries them.
|
||||
run: |
|
||||
dnf install -y findutils file nodejs npm \
|
||||
xorg-x11-server-Xvfb dbus-daemon util-linux procps-ng \
|
||||
nss nspr atk at-spi2-atk at-spi2-core cups-libs gtk3 \
|
||||
libdrm mesa-libgbm alsa-lib libX11 libXcomposite libXdamage \
|
||||
libXext libXfixes libXrandr libxcb libxkbcommon pango cairo \
|
||||
libXScrnSaver libXtst libxshmfence
|
||||
|
||||
- name: Install test dependencies (Ubuntu)
|
||||
if: matrix.format != 'rpm'
|
||||
@@ -47,7 +64,26 @@ jobs:
|
||||
sudo apt-get install -y file libfuse2 nodejs npm \
|
||||
xvfb dbus-x11 procps
|
||||
|
||||
# Fail loud if a smoke-test tool is missing. Without this guard a
|
||||
# missing/renamed tool turns run_launch_smoke_test into a silent
|
||||
# green skip (it does `pass "$skip"; return`), masking the test.
|
||||
- name: Verify smoke-test tools are present (Ubuntu)
|
||||
if: matrix.format != 'rpm'
|
||||
run: |
|
||||
for t in xvfb-run dbus-run-session setsid; do
|
||||
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Verify smoke-test tools are present (Fedora)
|
||||
if: matrix.format == 'rpm'
|
||||
run: |
|
||||
for t in xvfb-run dbus-run-session setsid runuser; do
|
||||
command -v "$t" >/dev/null || { echo "::error::missing $t"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Run artifact tests
|
||||
env:
|
||||
TARGET_ARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
chmod +x tests/test-artifact-${{ matrix.format }}.sh
|
||||
tests/test-artifact-${{ matrix.format }}.sh artifacts/
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -40,3 +40,19 @@ result-*
|
||||
|
||||
# Wrangler (Cloudflare Worker dev/deploy cache)
|
||||
worker/.wrangler/
|
||||
|
||||
# Graphify outputs and temporary files
|
||||
graphify-out/
|
||||
.graphify_*
|
||||
|
||||
# Local agent/editor state and helper bins
|
||||
.agents/
|
||||
.codex/
|
||||
.tmpbin/
|
||||
|
||||
# Local package artifacts
|
||||
*.rpm
|
||||
|
||||
# Root-level scratch extracts from app inspection
|
||||
/frame-fix-wrapper.js
|
||||
/index.js
|
||||
|
||||
132
CHANGELOG.md
132
CHANGELOG.md
@@ -6,21 +6,138 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) —
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
<!-- Updated automatically by check-claude-version; will be current at release time. -->
|
||||
|
||||
### Added
|
||||
## [v2.0.22] — 2026-06-25
|
||||
|
||||
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
|
||||
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
|
||||
Tracks upstream Claude Desktop 1.15200.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
|
||||
- The Cowork tab is no longer grayed out on Linux with a *"Cowork requires a newer installation — Reinstall the desktop app"* tooltip. Upstream 1.13576+ gates the tab's visibility on the yukonSilver support *evaluator* (`$oe`/`q4r`, the Windows capability probe), which returns `msix_required` on Linux — a separate consumer from the `startVM` execution gate that [#736](https://github.com/aaddrick/claude-desktop-debian/pull/736) re-derived. The evaluator now reports `supported` on Linux so the renderer un-grays the tab (the bwrap daemon was already healthy underneath), while the VM-image download drivers it also feeds are re-blocked so they don't pull the multi-GB `rootfs.vhdx` bundle that is intentionally disabled on Linux — cowork runs through the bwrap sandbox, not a downloaded VM. ([#743](https://github.com/aaddrick/claude-desktop-debian/pull/743), #736 follow-up)
|
||||
- Claude Desktop no longer hangs at startup on Linux with no window ever appearing (a regression introduced by upstream 1.13576+). The bundle calls the Windows-only `@ant/claude-native` methods `readRegistryValues()` and `getWindowsElevationType()` unconditionally during its enterprise-policy lookup, guarding only the native module being null and not the method being absent — so the Linux stub threw `"<method> is not a function"` at top-level execution, which the early empty `uncaughtException` handler swallowed, leaving the process alive but windowless. The Linux native stub now provides neutral no-ops for these Windows-only registry / MSIX / UAC methods. ([#729](https://github.com/aaddrick/claude-desktop-debian/issues/729))
|
||||
- Cowork Linux patches apply again on Claude Desktop 1.13576+ — the build's "Verify cowork patches in shipped asar" step had started failing with 9/11 markers missing. Upstream re-architected the cowork/VM subsystem ("yukonSilver") between 1.12603.1 and 1.13576.0: the platform gate moved from a `darwin`/`win32` check into `startVM`'s `yukonSilver.status` feature-flag check, the vmClient module load moved behind the isMsix detector, and `sharedCwdPath`/`mountConda` were removed. Patch 1 (which anchored on the gone check) `process.exit(1)`'d, which killed the whole node block and dropped every subsequent cowork patch. Patches 1, 2 and the daemon auto-launch anchor were re-derived against the new bundle, the smol-bin idempotency guard was fixed (it false-matched upstream's own log), the obsolete `sharedCwdPath` threading (Patch 12) was retired in favor of the daemon's mountMap fallback, and the Linux smol-bin copy patch gained a verification marker. ([#736](https://github.com/aaddrick/claude-desktop-debian/pull/736))
|
||||
- Builds (deb, RPM, AppImage, nix) no longer abort in the patch phase with `FATAL: --add-dir pattern matches 2 times (expected 1)`. Upstream Claude Desktop 1.12603.1 ships two identical `--add-dir` dispatch loops, but the `.asar` filter patch ([#650](https://github.com/aaddrick/claude-desktop-debian/pull/650)) asserted exactly one. The patch now filters every matching dispatch loop instead of bailing on a duplicate, and stays idempotent on re-runs. ([#718](https://github.com/aaddrick/claude-desktop-debian/issues/718))
|
||||
- `claude-desktop --doctor` reports the installed version from the package manager that actually owns the install (probed via `rpm -qf` on the bundled Electron binary) instead of trusting `dpkg-query` alone — rpm installs on hosts that also carry a stale dpkg record (e.g. Fedora boxes with dpkg installed as a build tool) no longer show a months-old version with a PASS. ([#712](https://github.com/aaddrick/claude-desktop-debian/pull/712), fixes [#711](https://github.com/aaddrick/claude-desktop-debian/issues/711))
|
||||
|
||||
## [v2.0.19] — 2026-06-10
|
||||
|
||||
Tracks upstream Claude Desktop 1.11847.5.
|
||||
|
||||
### Added
|
||||
|
||||
- AppStream metainfo (`io.github.aaddrick.claude-desktop-debian.metainfo.xml`) installed by the deb, RPM, and AppImage builds, so the package appears in GNOME Software, KDE Discover, and App Center with correct unofficial-repackaging branding and a `LicenseRef-proprietary` project license. Store search for not-yet-installed users needs repo-side DEP-11/appstream metadata, tracked in [#708](https://github.com/aaddrick/claude-desktop-debian/issues/708). ([#633](https://github.com/aaddrick/claude-desktop-debian/pull/633))
|
||||
- GPU crash auto-recovery in the launcher: when the previous launch died to a Chromium GPU-process FATAL (the [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583) SIGTRAP signature), the next launch automatically applies safe GPU flags — and stays recovered on subsequent launches instead of oscillating crash/work/crash. Detects NixOS launcher log headers too; set `CLAUDE_DISABLE_GPU=0` to override. ([#666](https://github.com/aaddrick/claude-desktop-debian/pull/666))
|
||||
|
||||
### Fixed
|
||||
|
||||
- `claude-desktop --doctor` no longer reports a false-green PASS when the password store reads back empty or when `df` returns a non-numeric disk reading — bad reads now fail or print a visible skip instead of falling through to the PASS branch, and leading-zero `df` output can no longer slip past as octal arithmetic. ([#692](https://github.com/aaddrick/claude-desktop-debian/pull/692))
|
||||
- Explicit quit now keeps the launcher alive until Electron exits, then runs
|
||||
stale-helper cleanup for Desktop-owned Cowork, Claude config, and extension
|
||||
helpers. Close-to-tray still leaves the app and helpers running.
|
||||
([#682](https://github.com/aaddrick/claude-desktop-debian/pull/682))
|
||||
- All launchers (deb, RPM, AppImage, nix) no longer pass `app.asar` as an Electron
|
||||
argument. Electron auto-loads `app.asar` from its default `resources/` dir next to the
|
||||
binary, so the extra argv entry was redundant — and the app treated it as a
|
||||
file-to-open, surfacing a spurious "Attach app.asar?" prompt on launch and on every
|
||||
taskbar reopen. This removes the path at the source, complementing the renderer-side
|
||||
`.asar` guards in [#669](https://github.com/aaddrick/claude-desktop-debian/pull/669)
|
||||
and surviving upstream re-minification. Live-UI detection in the launcher and doctor,
|
||||
which fingerprinted on the now-removed argv, was updated alongside.
|
||||
([#700](https://github.com/aaddrick/claude-desktop-debian/pull/700),
|
||||
fixes [#696](https://github.com/aaddrick/claude-desktop-debian/issues/696))
|
||||
- Cowork's VM daemon never auto-launched on packages built under a restrictive umask (CI builds with umask `022`, so released artifacts were unaffected; local builds with e.g. `umask 077` were) because the bundled `app.asar.unpacked/` directory shipped as mode `0700` owned by the build uid, so the desktop user running the app couldn't traverse it and the auto-launch `fs.existsSync()` fork guard silently returned `false` (symptom: endless `connect ENOENT …/cowork-vm-service.sock`, no `cowork_vm_daemon.log`, no `[cowork-autolaunch]` line). `deb.sh` now normalizes the installed tree to canonical permissions (directories and executables `755`, other files `644`) and builds with `dpkg-deb --root-owner-group` for `root:root` ownership; `appimage.sh` applies the same normalization to the AppDir before `mksquashfs` (it copies with `cp -a`, which preserved the bad modes); and `rpm.sh` normalizes file modes in `%install` — `%defattr(-, root, root, 0755)` forces directory modes in the payload, but its `-` first field preserves file modes from the `cp -r`-populated buildroot, so a restrictive-umask RPM build shipped an unreadable `app.asar` and a non-executable electron binary.
|
||||
- Claude Desktop no longer crashes on launch on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces Chromium's sandbox needs (`sandbox/linux/services/credentials.cc` FATAL, `Trace/breakpoint trap`, exit 133). The `.deb` `postinst` now installs a scoped AppArmor profile granting `userns` to the bundled Electron binary — mirroring the `google-chrome`/`code`/`slack` packages — and removes it again on uninstall. The Chromium sandbox stays enabled (no `--no-sandbox`). `claude-desktop --doctor` gained a **User namespaces** check that flags a missing profile. ([#687](https://github.com/aaddrick/claude-desktop-debian/pull/687))
|
||||
- Cowork mode no longer silently falls back to host-direct (no isolation) on Ubuntu 24.04+, where `apparmor_restrict_unprivileged_userns=1` blocks the user namespaces its bubblewrap sandbox needs. The `.deb` `postinst` now installs a second scoped AppArmor profile granting `userns` to `/usr/bin/bwrap` (distinct from the Electron profile above), automating the manual workaround from [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351) (contributed by [@hfyeh](https://github.com/hfyeh)). The profile is gated on the kernel's `apparmor_restrict_unprivileged_userns` knob and defers to any profile already attaching to `/usr/bin/bwrap` (a hand-made `/etc/apparmor.d/bwrap`, `apparmor-profiles`' `bwrap-userns-restrict`); put local overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` — they survive upgrades. `bubblewrap` is now a `Recommends`. ([#694](https://github.com/aaddrick/claude-desktop-debian/pull/694))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI now validates the arm64 deb, RPM, and AppImage artifacts on native `ubuntu-22.04-arm` runners (previously only amd64 was tested), and the AppImage launch smoke test's process sweep is keyed to `mount_claude` and gated behind `$CI` so a local test run can't kill a developer's live Claude Desktop session. The launcher's orphaned-daemon reaper also gained mutation-tested BATS coverage. ([#691](https://github.com/aaddrick/claude-desktop-debian/pull/691), [#693](https://github.com/aaddrick/claude-desktop-debian/pull/693))
|
||||
- The native-Wayland launch path now routes Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal: `GlobalShortcutsPortal` is added to the `--enable-features` set, and all Chromium feature requests are merged into a single `--enable-features=` switch (Chromium honours only the last one, so the previous code could silently clobber features). GNOME Wayland users can opt into the portal route with `CLAUDE_USE_WAYLAND=1`, which works on GNOME ≤ 49 after a one-time portal permission dialog and fixes the focus-bound hotkey from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404). The default GNOME session stays on XWayland (no rendering/IME regression risk); auto-selecting native Wayland on GNOME is deferred until it can be gated on a real render check. **On GNOME 50 / xdg-desktop-portal ≥ 1.20 the portal route is currently a no-op** — Electron/Chromium doesn't perform the portal's new host `Registry.Register` app-id handshake (filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875)). `CLAUDE_USE_WAYLAND` is now tri-state: `1` native Wayland, `0` force XWayland, unset auto-detects. ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404))
|
||||
|
||||
## [v2.0.18] — 2026-06-04
|
||||
|
||||
Tracks upstream Claude Desktop 1.10628.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tray icon no longer stuck black at startup on dark desktops. `nativeTheme.shouldUseDarkColors` reads `false` for the first ~50 ms then flips `true`, but the leading-edge rebuild mutex latched the transient `false` and dropped the corrective `"updated"` events; the mutex is now trailing-edge (re-applies the final value) and the obsolete 3 s startup-suppression window was removed. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680), fixes [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679))
|
||||
- Restored the in-place tray `setImage` fast-path ([#515](https://github.com/aaddrick/claude-desktop-debian/pull/515)), which silently stopped applying after upstream changed the context-menu wiring from `setContextMenu(BUILDER())` to a prebuilt `setContextMenu(MENU)` object — `patch_tray_inplace_update` now resolves the builder in both shapes, so the duplicate-icon SNI race no longer regresses. ([#680](https://github.com/aaddrick/claude-desktop-debian/pull/680))
|
||||
- File-drop collector no longer re-attaches the app's own `app.asar` on every taskbar reopen. Electron's ASAR VFS shim returns `true` from `existsSync()` for `.asar` paths, so the second-instance argv collector dispatched `app.asar` to the file-drop handler and surfaced an attach prompt on each relaunch; it now rejects `.asar` paths, mirroring the existing `statSync` guard. ([#669](https://github.com/aaddrick/claude-desktop-debian/pull/669), fixes [#668](https://github.com/aaddrick/claude-desktop-debian/issues/668))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI now runs a headless launch smoke test for the deb and rpm artifacts — previously only the AppImage actually booted, so a startup-only regression (e.g. the Fedora `SyntaxError`) could stay green on the formats it broke. A shared `run_launch_smoke_test` helper covers all three formats and gracefully skips when a container forbids Chromium's sandbox. ([#671](https://github.com/aaddrick/claude-desktop-debian/pull/671), closes [#670](https://github.com/aaddrick/claude-desktop-debian/issues/670))
|
||||
|
||||
## [v2.0.17] — 2026-06-04
|
||||
|
||||
Tracks upstream Claude Desktop 1.10628.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `addTrustedFolder` `.asar` guard re-anchored on the `async addTrustedFolder(…)` method declaration. Upstream Claude Desktop 1.10628.x folded the `LocalAgentModeSessions.addTrustedFolder: ${i}` log call into a comma-expression inside an `if`, removing the trailing `` `); `` the old anchor matched — `./build.sh` aborted with `[FAIL] addTrustedFolder anchor not found`. Both the parameter extraction and the injection point now key off the unminified method name, so they can't drift apart if upstream drops the log line. ([#685](https://github.com/aaddrick/claude-desktop-debian/pull/685))
|
||||
|
||||
## [v2.0.16] — 2026-05-27
|
||||
|
||||
Tracks upstream Claude Desktop 1.9255.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cowork spawn guard now captures `$`-prefixed minified function names (e.g. `$Be`) and uses `globalThis._lastSpawn` instead of a bare `_globalLastSpawn` identifier, fixing `ReferenceError: _globalLastSpawn is not defined` that broke Cowork on all platforms with upstream 1.9255.0. ([#660](https://github.com/aaddrick/claude-desktop-debian/pull/660), fixes [#658](https://github.com/aaddrick/claude-desktop-debian/issues/658), [#659](https://github.com/aaddrick/claude-desktop-debian/issues/659), [#661](https://github.com/aaddrick/claude-desktop-debian/issues/661))
|
||||
|
||||
## [v2.0.15] — 2026-05-27
|
||||
|
||||
Tracks upstream Claude Desktop 1.9255.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `StartupWMClass` aligned to `Claude` to match what Electron actually advertises via `productName`. The v2.0.14 value `claude-desktop` was silently ignored by Electron, causing orphan windows and duplicate gear icons on GNOME/KDE. Value centralized from 6 hardcoded locations to one source of truth in `build.sh`, with build-time substitution and a `productName` assertion guard. ([#655](https://github.com/aaddrick/claude-desktop-debian/pull/655), fixes [#652](https://github.com/aaddrick/claude-desktop-debian/issues/652))
|
||||
- Tray variable extraction re-anchored on `.Tray()` literal instead of minifier-dependent syntax that upstream 1.9255.0 reshuffled. ([#657](https://github.com/aaddrick/claude-desktop-debian/pull/657), fixes [#656](https://github.com/aaddrick/claude-desktop-debian/issues/656))
|
||||
|
||||
## [v2.0.14] — 2026-05-25
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `WM_CLASS` and `StartupWMClass` aligned to `claude-desktop` across all formats (deb, RPM, AppImage, autostart). Resolves ambiguity with the Claude Code CLI (`claude`) and ensures consistent taskbar grouping on KDE/GNOME. ([#648](https://github.com/aaddrick/claude-desktop-debian/pull/648), fixes [#647](https://github.com/aaddrick/claude-desktop-debian/issues/647))
|
||||
|
||||
### Changed
|
||||
|
||||
- AppImage smoke test: replaced flat 10s sleep with readiness-marker poll (30s ceiling, 0.5s tick), unified cleanup trap to prevent 190MB `squashfs-root` leaks on interrupt. ([#646](https://github.com/aaddrick/claude-desktop-debian/pull/646))
|
||||
|
||||
## [v2.0.13] — 2026-05-24
|
||||
|
||||
Tracks upstream Claude Desktop 1.8555.2.
|
||||
|
||||
### Added
|
||||
|
||||
- `CLAUDE_KEEP_AWAKE=0` env var to suppress `powerSaveBlocker` sleep inhibitor that upstream holds indefinitely on Linux (no lifecycle management). Adds diagnostic logging for all `powerSaveBlocker` calls and `--doctor` visibility. ([#605](https://github.com/aaddrick/claude-desktop-debian/issues/605))
|
||||
- `--doctor` flags filesystems with `NAME_MAX < 200` (eCryptfs, certain encrypted overlays) and surfaces the LUKS-symlink workaround for cowork. Thanks @RayCharlizard, @lizthegrey for the repro. ([#614](https://github.com/aaddrick/claude-desktop-debian/pull/614), fixes [#590](https://github.com/aaddrick/claude-desktop-debian/issues/590))
|
||||
- F11 fullscreen toggle via hidden menu accelerator — Linux parity with macOS green button / Windows F11. ([#638](https://github.com/aaddrick/claude-desktop-debian/pull/638), fixes [#580](https://github.com/aaddrick/claude-desktop-debian/issues/580))
|
||||
- Linux org-plugins path (`/etc/claude/org-plugins`) added to platform switch, enabling MDM-managed plugin configuration. ([#639](https://github.com/aaddrick/claude-desktop-debian/pull/639), fixes [#607](https://github.com/aaddrick/claude-desktop-debian/issues/607))
|
||||
- Top-level governance docs: this `CHANGELOG.md`, [`RELEASING.md`](RELEASING.md) (pre-release checklist + tag-driven CI flow), [`SECURITY.md`](SECURITY.md) (private GHSA reporting + in/out-of-scope), [`docs/index.md`](docs/index.md) (navigation hub), and [`docs/styleguides/docs_styleguide.md`](docs/styleguides/docs_styleguide.md) (page anatomy, naming, antipatterns). [`CLAUDE.md`](CLAUDE.md) gains explicit § Required reading, § Anti-patterns, and § Docs sections; [`AGENTS.md`](AGENTS.md) becomes a byte-identical mirror of the new body (was a 13-line stub) so non-Claude tools get the same instructions.
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) "Before you start" triage section: where to go for a bug, a fix-in-hand, a new-feature ask, or a security report.
|
||||
- `--password-store` keyring detection: probes D-Bus for kwallet6 / gnome-libsecret at startup and injects the flag before the app path, fixing session persistence on KDE Plasma and other desktops where `safeStorage.isEncryptionAvailable()` returned false. Adds `CLAUDE_PASSWORD_STORE` env override and `--doctor` diagnostic. Thanks @dubreal. ([#611](https://github.com/aaddrick/claude-desktop-debian/pull/611), fixes [#593](https://github.com/aaddrick/claude-desktop-debian/issues/593))
|
||||
- Unzip fallback for Node 24: detects missing electron binary after `extract-zip` silently no-ops and recovers from the `@electron/get` cache using system `unzip`. Thanks @JustinJLeopard. ([#631](https://github.com/aaddrick/claude-desktop-debian/pull/631), fixes [#584](https://github.com/aaddrick/claude-desktop-debian/issues/584))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Config writes no longer drop externally-added `mcpServers`. The stale in-memory cache was overwriting disk on every preference change; now re-reads `mcpServers` from disk before each write. ([#643](https://github.com/aaddrick/claude-desktop-debian/pull/643), fixes [#400](https://github.com/aaddrick/claude-desktop-debian/issues/400))
|
||||
- Menu bar toggle fires on Alt keyup only, not keydown — fixes Alt+Shift (language switch) and Alt+F4 accidentally triggering the menu bar. `CLAUDE_MENU_BAR=hidden` disables the Alt toggle entirely. ([#642](https://github.com/aaddrick/claude-desktop-debian/pull/642), fixes [#630](https://github.com/aaddrick/claude-desktop-debian/issues/630))
|
||||
- `.asar` paths rejected in directory check, preventing Electron's ASAR VFS shim from dispatching `app.asar` to Cowork as a "folder drop". Fixes permission dialog on every launch, forced Cowork mode on reopen from tray, and "No conversation found" loop in Claude Code >=2.1.111. ([#640](https://github.com/aaddrick/claude-desktop-debian/pull/640), fixes [#383](https://github.com/aaddrick/claude-desktop-debian/issues/383), [#622](https://github.com/aaddrick/claude-desktop-debian/issues/622), [#632](https://github.com/aaddrick/claude-desktop-debian/issues/632))
|
||||
- Identifier captures across all patch scripts hardened from `\w+` to `[$\w]+` (PCRE) / `[[:alnum:]_$]+` (ERE). Fixes broken idempotency guard in `tray.sh`, adds missing guards to `cowork.sh` patches 6/9/10, adds `\s*` whitespace tolerance to multiple patterns. ([#644](https://github.com/aaddrick/claude-desktop-debian/pull/644))
|
||||
- `exec` before Electron invocation in deb, RPM, and Nix launchers so Ctrl+C and signals forward correctly to the Electron process. ([#637](https://github.com/aaddrick/claude-desktop-debian/pull/637), fixes [#424](https://github.com/aaddrick/claude-desktop-debian/issues/424))
|
||||
- `--class=Claude` added to launcher args ensuring WM_CLASS matches `StartupWMClass` in the .desktop file, preventing GNOME extension crashes from unexpected class values. ([#636](https://github.com/aaddrick/claude-desktop-debian/pull/636), ref [#635](https://github.com/aaddrick/claude-desktop-debian/issues/635))
|
||||
- Sloppy/focus-follows-mouse: suppress redundant `webContents.focus()` calls that trigger X11 `_NET_ACTIVE_WINDOW` raise-on-hover. Grace window handles stale `isFocused()` on tray-restore and minimize-restore. Thanks @tkrag. ([#589](https://github.com/aaddrick/claude-desktop-debian/pull/589), fixes [#416](https://github.com/aaddrick/claude-desktop-debian/issues/416))
|
||||
- Tray: extracted JS identifier captures now accept `$` so the 1.8089.1 minified bundle ('`i$A`' menu handler) matches. Switches `\w+` to `[\w$]+`. ([#627](https://github.com/aaddrick/claude-desktop-debian/pull/627), fixes [#625](https://github.com/aaddrick/claude-desktop-debian/issues/625))
|
||||
- RPM: silence "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install` (replaces `%attr` in `%files`). Adds regression guard that fails the build if the warning reappears. Thanks @JoshuaVlantis. ([#610](https://github.com/aaddrick/claude-desktop-debian/pull/610), fixes [#609](https://github.com/aaddrick/claude-desktop-debian/issues/609))
|
||||
- Window close with `CLAUDE_QUIT_ON_CLOSE=1` now actively quits via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux. Rides upstream's own quit-in-progress guard. Thanks @phelps-matthew. ([#624](https://github.com/aaddrick/claude-desktop-debian/pull/624), fixes [#623](https://github.com/aaddrick/claude-desktop-debian/issues/623))
|
||||
- node-pty: wipe upstream Windows binaries (winpty.dll, winpty-agent.exe, Windows `.node` files) before staging the Linux build, preventing PE32+ orphans in the packaged asar. Thanks @JoshuaVlantis. ([#597](https://github.com/aaddrick/claude-desktop-debian/pull/597), addresses [#401](https://github.com/aaddrick/claude-desktop-debian/issues/401))
|
||||
|
||||
### Changed
|
||||
|
||||
- CI injection hardening: moved `${{ steps.*.outputs.* }}` expressions from `run:` blocks to `env:` blocks in `issue-triage-v2.yml`. Build pipeline: `process.exit(0)` → `process.exit(1)` in `quick-window.sh` when patch anchors aren't found so CI fails instead of shipping broken patches. Packaging scriptlets: replaced `&> /dev/null` with `> /dev/null 2>&1` for dash compatibility in deb/RPM postinst. ([#641](https://github.com/aaddrick/claude-desktop-debian/pull/641))
|
||||
- Credit @lizthegrey, @sabiut, @typedrat, @RayCharlizard in README Acknowledgments. ([#626](https://github.com/aaddrick/claude-desktop-debian/pull/626))
|
||||
- Troubleshooting: new "Repeated Electron Crashes / GPU Process FATAL" section documenting `CLAUDE_DISABLE_GPU=1`. Adds tuning-rationale comments around the `--doctor` 3-in-7-days threshold and the `coredumpctl` `COMM=electron` assumption. Thanks @sabiut. ([#615](https://github.com/aaddrick/claude-desktop-debian/pull/615), addresses [#608](https://github.com/aaddrick/claude-desktop-debian/issues/608))
|
||||
- Docs filenames are now lowercase kebab-case (`docs/building.md`, `docs/configuration.md`, `docs/decisions.md`, `docs/troubleshooting.md`); `STYLEGUIDE.md` moved to [`docs/styleguides/bash_styleguide.md`](docs/styleguides/bash_styleguide.md). Cross-references swept across README, CONTRIBUTING, CODEOWNERS, `.github/`, `.claude/`, `scripts/`, and `claude-desktop --doctor` user-facing output.
|
||||
@@ -225,7 +342,8 @@ First v2 wrapper release; tracks upstream Claude Desktop 1.3109.0, 1.3561.0.
|
||||
- **BREAKING**: Split `build.sh` into topical modules under `scripts/`; relocate packaging scripts into `scripts/packaging/`; extract `--doctor` into `scripts/doctor.sh`. Patch files now live in `scripts/patches/*.sh` (one per subsystem); `build.sh` is just an orchestrator. CI paths updated to `scripts/setup/detect-host.sh`.
|
||||
- Simplify cowork daemon recovery patch. ([#408](https://github.com/aaddrick/claude-desktop-debian/pull/408))
|
||||
|
||||
[Unreleased]: https://github.com/aaddrick/claude-desktop-debian/compare/v2.0.12+claude1.8555.2...HEAD
|
||||
[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
|
||||
|
||||
@@ -38,6 +38,7 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
|
||||
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||
- [`test-harness-electron-hooks.md`](docs/learnings/test-harness-electron-hooks.md) — why constructor-level `BrowserWindow` wraps are silently bypassed by `frame-fix-wrapper`'s Proxy, and the prototype-method hook pattern that works (used by the Quick Entry test runners)
|
||||
- [`test-harness-ax-tree-walker.md`](docs/learnings/test-harness-ax-tree-walker.md) — five non-obvious traps in the v7 fingerprint walker after the AX-tree migration: AX-enable async lag, navigateTo-to-same-URL no-op, claude.ai's flat `dialog>button[]` lists, the `more options for X` per-row shape, and sidebar virtualization vs the lookup-failure threshold
|
||||
- [`wayland-global-shortcuts-portal.md`](docs/learnings/wayland-global-shortcuts-portal.md) — why Quick Entry's hotkey is focus-bound on GNOME Wayland (mutter dropped XWayland global key grabs), the native-Wayland + `GlobalShortcutsPortal` launcher change (opt-in via `CLAUDE_USE_WAYLAND=1`; fixes GNOME ≤49, default GNOME stays on XWayland), the "only the last `--enable-features` switch wins → merge into one flag" trap, the tri-state `CLAUDE_USE_WAYLAND` escape hatch, and the proof that GNOME 50 / xdg-desktop-portal ≥1.20 is still blocked upstream because Electron/Chromium never calls the host `Registry.Register` app-id handshake ([electron#51875](https://github.com/electron/electron/issues/51875)); wlroots (Niri/Sway/Hyprland) lack a portal GlobalShortcuts backend entirely
|
||||
- [`patching-minified-js.md`](docs/learnings/patching-minified-js.md) — general lessons from maintaining a long-lived patch suite against an actively re-minified upstream: anchor selection (literals over identifiers), the `\w` vs `$` identifier-capture trap, beautified false-negatives, idempotency guards, multi-site coordination, non-unique anchor disambiguation, and the SHA-256-pinned hypothesis-verification recipe
|
||||
|
||||
## Code Style
|
||||
|
||||
23
README.md
23
README.md
@@ -273,9 +273,32 @@ Special thanks to:
|
||||
- RPM `chrome-sandbox` SUID via `%attr(4755, ...)` instead of a `%post` chmod scriptlet so the bit survives `--noscripts` and layered images (#539)
|
||||
- `autoUpdater` no-op Proxy on Linux that defends against future feed activation, with a thenable allowlist masking `then`/`catch`/`finally`/`Symbol.toPrimitive`/`Symbol.iterator` to `undefined` (#567)
|
||||
- Failing loudly on `npm install node-pty` failures instead of silently shipping the upstream Windows binaries, plus auto-installing `gcc`/`g++`/`make`/`python3` on minimal build environments (#401)
|
||||
- Silencing the RPM "File listed twice" warning on `chrome-sandbox` by moving `chmod 4755` into `%install`, with thorough investigation of four `%exclude`-based alternatives (#610)
|
||||
- Cleaning upstream Windows binaries from node-pty before staging the Linux build, preventing PE32+ orphans in the packaged asar (#597)
|
||||
- **[Hayao0819](https://github.com/Hayao0819)** for diagnosing the upstream `titleBarStyle:""` → `titleBarStyle:"hiddenInset"` migration that broke the About window render on GNOME/X11 and contributing the `isPopupWindow()` match extension (#481, #489)
|
||||
- **[michelsfun](https://github.com/michelsfun)** for reporting the cowork `ENAMETOOLONG` failure on eCryptfs-encrypted home directories with detailed `--doctor` output that pinpointed the short-NAME_MAX filesystem as the cause (#590)
|
||||
- **[proffalken](https://github.com/proffalken)** for the LUKS-volume + `pam_mount` workaround documented in `docs/troubleshooting.md`, restoring cowork support on legacy eCryptfs-encrypted home directories (#590)
|
||||
- **[phelps-matthew](https://github.com/phelps-matthew)** for fixing `CLAUDE_QUIT_ON_CLOSE=1` to actively quit via `app.quit()` instead of relying on the bundled handler that hardcodes hide-to-tray on Linux, with thorough root cause analysis and alternatives evaluation (#624, #623)
|
||||
- **[dubreal](https://github.com/dubreal)** for `--password-store` keyring detection that probes D-Bus for kwallet6 / gnome-libsecret at startup, fixing session persistence on KDE Plasma and other desktops where Electron's `safeStorage` was unavailable (#611, #593)
|
||||
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for detecting missing electron binaries after Node 24's `extract-zip` silently no-ops, with an `unzip` fallback that recovers from the `@electron/get` cache (#631, #584)
|
||||
- **[tkrag](https://github.com/tkrag)** for diagnosing and fixing the X11 window-raise-on-hover bug under sloppy/focus-follows-mouse WMs, tracing the upstream `webContents.focus()` → `_NET_ACTIVE_WINDOW` path through three iterations of review (#589, #416)
|
||||
- **[maplefater](https://github.com/maplefater)** for re-anchoring the `addTrustedFolder` `.asar` guard on the `async addTrustedFolder(…)` method declaration after upstream 1.10628.x folded the log call into a comma-expression, keying both the parameter extraction and the injection point off the unminified method name so they can't drift apart (#685)
|
||||
- **[MitchSchwartz](https://github.com/MitchSchwartz)** for finding the second `app.asar` file-drop path — the `existsSync()` branch in the second-instance argv collector that #640 never guarded — and rejecting `.asar` paths there so the app no longer prompts to attach its own bundle on every taskbar reopen (#669, #668)
|
||||
- **[LiukScot](https://github.com/LiukScot)** for making the tray rebuild mutex trailing-edge so the startup dark-theme icon no longer latches black, and restoring the in-place `setImage` fast-path after upstream changed the context-menu wiring to a prebuilt menu object (#680, #679)
|
||||
- **[sabiut](https://github.com/sabiut)**
|
||||
- BATS coverage for `cleanup_orphaned_cowork_daemon`, mutation-tested so the kill/escalation branches genuinely bite (#693)
|
||||
- Fixing two false-green `--doctor` PASSes: an empty password store read as healthy, and a non-numeric `df` reading falling through to the PASS branch (#692)
|
||||
- Extending the artifact launch smoke tests to arm64 on native `ubuntu-22.04-arm` runners, and re-keying the AppImage pkill sweep to `mount_claude` so escaped zygote/electron children stop leaking on the runner (#691)
|
||||
- **[jerem](https://github.com/jerem)** for routing Quick Entry's global shortcut through the XDG GlobalShortcuts portal on native Wayland, and merging all Chromium feature requests into a single `--enable-features=` switch — the old code silently clobbered `WindowControlsOverlay` (#690, #404)
|
||||
- **[caidejager](https://github.com/caidejager)** for diagnosing why Cowork's VM daemon never auto-launched on packages built under a restrictive umask — `app.asar.unpacked/` shipped mode `0700`, failing the auto-launch `existsSync()` guard — and normalizing install permissions across deb and AppImage, with `dpkg-deb --root-owner-group` closing a build-uid write exposure (#695)
|
||||
- **[JustinJLeopard](https://github.com/JustinJLeopard)** for the AppStream metainfo that surfaces the package in GNOME Software, KDE Discover, and App Center, wired into the deb, rpm, and AppImage builds (#633)
|
||||
- **[DhanushSantosh](https://github.com/DhanushSantosh)** for the GPU crash auto-recovery in the launcher: detecting a previous GPU-process FATAL in the launcher log and re-launching with safe GPU flags automatically, instead of leaving users to discover `CLAUDE_DISABLE_GPU=1` by hand (#666)
|
||||
- **[diarized](https://github.com/diarized)** for auto-installing scoped AppArmor userns profiles from the `.deb` postinst on Ubuntu 24.04+ — one for the bundled Electron binary (fixing the launch crash without `--no-sandbox`) and one for `/usr/bin/bwrap` (keeping Cowork's sandbox isolated instead of silently falling back to host-direct), automating the workaround from #351 (#687, #694)
|
||||
- **[emandel82](https://github.com/emandel82)** for root-causing the "Attach app.asar?" prompt: every launcher passed `app.asar` as a redundant Electron argument, which the second-instance argv collector treated as a file to open — removed at the source across all four package formats (#700, #696)
|
||||
- **[svankirk](https://github.com/svankirk)** for cleaning up Desktop helper processes after an explicit quit — a quit wrapper with signal forwarding and a bundle-keyed live-UI check, so closing the app no longer strands helper processes (#682)
|
||||
- **[pjordanandrsn](https://github.com/pjordanandrsn)** for re-deriving the cowork Linux patch suite against the upstream "yukonSilver" VM refactor (1.13576+) — re-anchoring the platform gate on `startVM`'s `yukonSilver.status` check after Patch 1's removed `darwin`/`win32` anchor started `process.exit(1)`'ing and dropping every subsequent cowork patch, fixing the build's "Verify cowork patches in shipped asar" gate (#736)
|
||||
- **[chrisw1005](https://github.com/chrisw1005)** for root-causing the Linux startup hang on Claude Desktop 1.13576+ — the unconditional `@ant/claude-native.readRegistryValues()` / `getWindowsElevationType()` enterprise-policy calls throwing a swallowed missing-method `TypeError` before window creation — via probe injection, and the complete Windows-only native stub fix (#729)
|
||||
- **[colonelpanic8](https://github.com/colonelpanic8)** for independently reproducing the same 1.13576+ startup hang and contributing BATS coverage for the Linux native stub (#730)
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
8
build.sh
8
build.sh
@@ -36,6 +36,8 @@ final_output_path=''
|
||||
|
||||
# Package metadata (constants)
|
||||
readonly PACKAGE_NAME='claude-desktop'
|
||||
readonly WM_CLASS='Claude'
|
||||
export WM_CLASS
|
||||
readonly MAINTAINER='Claude Desktop Linux Maintainers'
|
||||
readonly DESCRIPTION='Claude Desktop for Linux'
|
||||
|
||||
@@ -60,8 +62,12 @@ source "$script_dir/scripts/patches/quick-window.sh"
|
||||
source "$script_dir/scripts/patches/claude-code.sh"
|
||||
# shellcheck source=scripts/patches/cowork.sh
|
||||
source "$script_dir/scripts/patches/cowork.sh"
|
||||
# shellcheck source=scripts/patches/org-plugins.sh
|
||||
source "$script_dir/scripts/patches/org-plugins.sh"
|
||||
# shellcheck source=scripts/patches/wco-shim.sh
|
||||
source "$script_dir/scripts/patches/wco-shim.sh"
|
||||
# shellcheck source=scripts/patches/config.sh
|
||||
source "$script_dir/scripts/patches/config.sh"
|
||||
# shellcheck source=scripts/staging/electron.sh
|
||||
source "$script_dir/scripts/staging/electron.sh"
|
||||
# shellcheck source=scripts/staging/icons.sh
|
||||
@@ -155,7 +161,7 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;Network;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop (AppImage)
|
||||
EOF
|
||||
|
||||
@@ -13,24 +13,39 @@ Model Context Protocol settings are stored in:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
|
||||
| `CLAUDE_USE_WAYLAND` | unset (auto) | Force the display backend on Wayland: `1` = native Wayland, `0` = XWayland. Unset auto-detects per compositor (only Niri defaults to native Wayland). See [Wayland Support](#wayland-support) below. |
|
||||
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
|
||||
| `CLAUDE_TITLEBAR_STYLE` | unset (`hybrid`) | Controls window decoration style: `hybrid` (system frame + in-app topbar), `native` (system frame, no in-app topbar), `hidden` (frameless WCO — broken on X11, kept for diagnostics). See [Titlebar Style](#titlebar-style) below. |
|
||||
| `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. |
|
||||
|
||||
### Wayland Support
|
||||
|
||||
By default, Claude Desktop uses X11 mode (via XWayland) on Wayland sessions to ensure global hotkeys work. If you prefer native Wayland and don't need global hotkeys:
|
||||
On Wayland sessions the launcher picks a display backend per compositor:
|
||||
|
||||
| Compositor | Backend | Why |
|
||||
|------------|---------|-----|
|
||||
| Niri | native Wayland (auto) | no XWayland support at all |
|
||||
| Everything else (GNOME, KDE, Sway, Hyprland, COSMIC, …) | XWayland (auto) | XWayland global key grabs still work on most; mature path, broadest compatibility |
|
||||
|
||||
By default only Niri is auto-selected for native Wayland. GNOME Wayland stays on XWayland by default even though mutter no longer honours XWayland global key grabs ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)) — flipping the default GNOME session off XWayland is a rendering/IME/HiDPI risk, so it's left opt-in for now.
|
||||
|
||||
To route Quick Entry's global shortcut (`Ctrl+Alt+Space`) through the XDG GlobalShortcuts portal on GNOME, opt into native Wayland with `CLAUDE_USE_WAYLAND=1`. On **GNOME ≤ 49** this works after a one-time portal permission dialog (accept it to bind the shortcut). On **GNOME 50 / xdg-desktop-portal ≥ 1.20 it does not work yet**: the newer portal requires apps to declare identity via `org.freedesktop.host.portal.Registry.Register`, which Electron/Chromium doesn't do, so `globalShortcut.register()` fails and the shortcut stays focus-bound. Tracked upstream at [electron/electron#51875](https://github.com/electron/electron/issues/51875).
|
||||
|
||||
Override the auto-detection with `CLAUDE_USE_WAYLAND`:
|
||||
|
||||
```bash
|
||||
# One-time launch
|
||||
# Force native Wayland (GNOME portal route, or Sway/Hyprland)
|
||||
CLAUDE_USE_WAYLAND=1 claude-desktop
|
||||
|
||||
# Or add to your environment permanently
|
||||
# Force XWayland (e.g. to override Niri's auto-native, or if native
|
||||
# Wayland regresses rendering)
|
||||
CLAUDE_USE_WAYLAND=0 claude-desktop
|
||||
|
||||
# Or persist either choice
|
||||
export CLAUDE_USE_WAYLAND=1
|
||||
```
|
||||
|
||||
**Important:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal. If global hotkeys (Ctrl+Alt+Space) are important to your workflow, keep the default X11 mode.
|
||||
**Note:** portal-routed global shortcuts only work where the compositor's portal backend implements `org.freedesktop.portal.GlobalShortcuts`. Support is per-compositor and currently uneven — GNOME and KDE implement it (though the app-id requirement above — enforced for GlobalShortcuts since xdg-desktop-portal 1.21 — applies to all desktops, KDE included); wlroots compositors (Sway, Hyprland, Niri) and COSMIC currently ship no GlobalShortcuts backend, so the portal route is a no-op there until their portal gains one.
|
||||
|
||||
### Menu Bar
|
||||
|
||||
|
||||
@@ -119,6 +119,51 @@ Interpreting the log after a failure:
|
||||
| `lifecycle uncaughtException ...` | JS-level crash, stack is in the log entry |
|
||||
| `lifecycle SIGTERM received` + `lifecycle exit code=0` | Clean app-initiated shutdown |
|
||||
| No `startup` entry at all | `fork()` didn't complete; check launcher.log for `[cowork-autolaunch]` errors |
|
||||
| No `cowork_vm_daemon.log` file at all **and** no `[cowork-autolaunch]` line | The auto-launch `fs.existsSync()` guard returned false — `app.asar.unpacked/` isn't traversable by the running user. Packaging perms bug; see [below](#packaging--appasarunpacked-must-be-traversable-by-the-run-time-user). |
|
||||
|
||||
## Packaging — `app.asar.unpacked/` must be traversable by the run-time user
|
||||
|
||||
The auto-launch fork is guarded by an existence check:
|
||||
|
||||
```javascript
|
||||
const _d = _p.join(process.resourcesPath, "app.asar.unpacked",
|
||||
"cowork-vm-service.js");
|
||||
if (_fs.existsSync(_d)) { /* fork daemon */ }
|
||||
```
|
||||
|
||||
`fs.existsSync()` returns **false** when the directory can't be
|
||||
traversed, not only when the file is genuinely absent — and there is no
|
||||
`else`/`catch`, so the fork is skipped with zero log output. If the
|
||||
packaged `app.asar.unpacked/` ships as mode `0700` owned by the build
|
||||
uid (a restrictive build umask, plus `dpkg-deb` recording ownership
|
||||
verbatim when not run under fakeroot or `--root-owner-group`), the
|
||||
desktop user — a *different* uid — can't enter it. `existsSync` is
|
||||
false, the daemon never forks, and the client loops forever on `connect
|
||||
ENOENT`. The tell is that **both** the daemon log file and the
|
||||
`[cowork-autolaunch]` error line are absent: nothing was even attempted.
|
||||
|
||||
Confirm what the run-time user actually sees, not what root sees:
|
||||
|
||||
```bash
|
||||
svc=.../app.asar.unpacked/cowork-vm-service.js
|
||||
test -r "$svc" && echo OK || echo BLOCKED # run as the desktop user
|
||||
stat -c '%A %U:%G' "$(dirname "$svc")" # 0700 + foreign uid == broken
|
||||
```
|
||||
|
||||
Fixed at the packaging boundary (not in the app code): `deb.sh` and
|
||||
`appimage.sh` normalize the staged tree to canonical modes (directories
|
||||
and executables `755`, other files `644`) before building, and the deb
|
||||
is built with `dpkg-deb --root-owner-group` so ownership is `root:root`.
|
||||
RPM has the same exposure through *file* modes: `%defattr(-, root,
|
||||
root, 0755)` forces directory modes in the payload, but the `-` in its
|
||||
first field preserves file modes verbatim from the buildroot, which
|
||||
`%install` populates with plain `cp -r` — so a `umask 077` build ships
|
||||
an unreadable `app.asar` and a non-executable electron binary (louder
|
||||
symptom: EACCES, since the forced `0755` keeps directories
|
||||
traversable). `rpm.sh` therefore normalizes file modes in `%install`
|
||||
too. To unstick an already-installed package without rebuilding:
|
||||
`sudo chmod -R o+rX /usr/lib/claude-desktop` (preserves the setuid
|
||||
`chrome-sandbox`).
|
||||
|
||||
## Key Files
|
||||
|
||||
|
||||
@@ -287,6 +287,49 @@ Four layers: build log, syntactic validity, asar markers, runtime.
|
||||
listening`; socket should exist and be owned by the
|
||||
`cowork-vm-service.js` process listed by `ss`.
|
||||
|
||||
## One gate, multiple consumers: a marker can't catch a re-armed sibling
|
||||
|
||||
A single minified predicate is often read by several independent code
|
||||
paths. Patching it at the source flips *all* of them — some you want,
|
||||
some you don't — and a marker-based check won't catch the ones you
|
||||
didn't, because nothing is *missing*; the regression is behavioral.
|
||||
|
||||
The yukonSilver cowork gate (1.13576+) is the case study. The support
|
||||
evaluator `$oe()`/`q4r()` returns `{status:"supported"|"unsupported"}`,
|
||||
and at least four call sites read it: `startVM` (execution gate), the
|
||||
renderer (the Cowork tab's grayed-out / "reinstall" state), the
|
||||
download driver `u8A`, and the warm prefetch `mzn`. The tab was grayed
|
||||
out on Linux because the evaluator reported `unsupported` (the win32
|
||||
`q4r` probe hits `msix_required`). Flipping it to `supported` for Linux
|
||||
(`cowork.sh` Patch 1b) un-grayed the tab — and simultaneously re-armed
|
||||
the multi-GB `rootfs.vhdx` VM download that #337/`a3190c3` had disabled,
|
||||
because the two download consumers read the *same* evaluator.
|
||||
|
||||
`verify-patches.sh` was green throughout: Patch 1b's marker was present,
|
||||
and there is no "download must stay off" marker to go red. The only
|
||||
thing that surfaced it was launching the build and watching
|
||||
`cowork_vm_node.log` (`rootfs.vhdx not found, downloading...`). The fix
|
||||
was not to un-flip the evaluator but to re-block the now-reachable
|
||||
consumers individually — Patch 1c adds `process.platform==="linux"||`
|
||||
to `u8A` and `mzn` so they behave as they did under `unsupported`,
|
||||
while the evaluator stays `supported` for the renderer.
|
||||
|
||||
Two rules fall out of this:
|
||||
|
||||
- **Before flipping a shared gate, grep every read of the predicate**
|
||||
(here `\.status\)!=="supported"` / `status!=="supported"`). Enumerate
|
||||
the consumers and decide per-site which should follow the flip. A
|
||||
patch that "works" against the symptom you were chasing can arm a
|
||||
sibling you weren't looking at.
|
||||
- **Markers verify structure; only a runtime launch verifies
|
||||
behavior.** When a patch changes a value that other code branches on,
|
||||
the post-build click-through (and a log tail for unwanted side
|
||||
effects) is not optional — the static layers (build log, `node
|
||||
--check`, markers) are all blind to a re-armed consumer. Add a
|
||||
positive marker for the *counter*-patch (Patch 1c ships
|
||||
`vm-download-blocked-linux` + `warm-download-blocked-linux`) so the
|
||||
invariant you just restored has a fingerprint that can go red.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `tray-rebuild-race.md` "Resilience to minifier churn" — prior art
|
||||
|
||||
@@ -80,7 +80,7 @@ releases. All five are extracted dynamically in `tray.sh`:
|
||||
| `tray_func` | `on("menuBarEnabled",()=>{ … })` |
|
||||
| `tray_var` | `});let X=null;(async )?function ${tray_func}` |
|
||||
| `electron_var` | already extracted earlier in `_common.sh` |
|
||||
| `menu_func` | `${tray_var}.setContextMenu(X(` |
|
||||
| `menu_func` | `${tray_var}.setContextMenu(X(` — or, when upstream prebuilds the menu (`M=X(); setContextMenu(M)`), resolved one hop back via `M=X(` |
|
||||
| `path_var` | `${tray_var}=new ${electron_var}.Tray(${electron_var}.nativeImage.createFromPath(X))` |
|
||||
| `enabled_var` | `const X = fn("menuBarEnabled")` |
|
||||
|
||||
@@ -110,13 +110,54 @@ cd claude-desktop-debian
|
||||
After the patch: one SNI stays registered for the app's lifetime,
|
||||
icon updates in place on every theme change.
|
||||
|
||||
## Startup icon-colour race (leading-edge mutex drop)
|
||||
|
||||
A subtler bug lives in the same rebuild function. On a *dark* desktop
|
||||
(e.g. GNOME `color-scheme=prefer-dark`),
|
||||
`nativeTheme.shouldUseDarkColors` reads **`false` for the first
|
||||
~50 ms** of the process, then a burst of `nativeTheme "updated"`
|
||||
events flips it to `true`. Measured with a standalone Electron probe:
|
||||
|
||||
```
|
||||
[ready+0ms] shouldUseDarkColors=false <- tray created -> black icon
|
||||
[UPDATED-EVENT] shouldUseDarkColors=true <- ~50-100 ms later
|
||||
[ready+500ms] shouldUseDarkColors=true (stays true)
|
||||
```
|
||||
|
||||
The tray is created with the transient `false` (black). The
|
||||
correction never lands because the rebuild mutex was a *leading-edge*
|
||||
throttle (`if(f._running)return;f._running=true;setTimeout(...,1500)`):
|
||||
the first `"updated"` (false) takes the lock and renders black; the
|
||||
follow-up `"updated"` (true) events all arrive inside the 1500 ms
|
||||
window and are **dropped**. No further event fires on its own, so the
|
||||
icon stays black until a manual theme change forces a new `"updated"`.
|
||||
|
||||
The fix makes the mutex *trailing-edge* — a request that arrives while
|
||||
a rebuild is in flight is remembered and re-run once when the window
|
||||
clears, so the final value wins:
|
||||
|
||||
```js
|
||||
if (f._running) { f._pending = true; return; }
|
||||
f._running = true;
|
||||
setTimeout(() => {
|
||||
f._running = false;
|
||||
if (f._pending) { f._pending = false; f(); }
|
||||
}, 1500);
|
||||
```
|
||||
|
||||
The startup-suppression `_trayStartTime > 3e3` guard was removed at
|
||||
the same time: it gated the very `"updated"` → rebuild call the
|
||||
correction now depends on. Trade-off: a ~1.5 s black flash at startup
|
||||
before the trailing re-run lands (vs. permanently black before).
|
||||
See [#679](https://github.com/aaddrick/claude-desktop-debian/issues/679).
|
||||
|
||||
## Pitfalls to watch for
|
||||
|
||||
- **Fast-path runs inside the 3 s startup window too.** The
|
||||
existing `_trayStartTime > 3e3` guard only gates the
|
||||
`nativeTheme.on('updated')` → `tray_func()` call; once
|
||||
`tray_func()` is running for any reason, our fast-path executes.
|
||||
Fine — it's cheaper than the slow path even at startup.
|
||||
- **No startup window gates the rebuild any more.** An earlier
|
||||
`_trayStartTime > 3e3` guard suppressed `tray_func()` for the first
|
||||
3 s; it was removed because it also swallowed the startup colour
|
||||
correction (see the section above). The trailing-edge mutex bounds
|
||||
rebuild frequency instead.
|
||||
- **macOS path is left untouched.** The condition
|
||||
`process.platform !== 'darwin' && …setContextMenu` keeps the
|
||||
Electron macOS tray model (right-click pops up a menu via
|
||||
|
||||
73
docs/learnings/wayland-global-shortcuts-portal.md
Normal file
73
docs/learnings/wayland-global-shortcuts-portal.md
Normal file
@@ -0,0 +1,73 @@
|
||||
[< Back to learnings](./)
|
||||
|
||||
# Wayland global shortcuts via the XDG GlobalShortcuts portal
|
||||
|
||||
Quick Entry's global hotkey (`Ctrl+Alt+Space`) is focus-bound on modern GNOME Wayland; the native-Wayland path now routes it through the XDG GlobalShortcuts portal (a merged `--enable-features=…,GlobalShortcutsPortal`), opt-in on GNOME via `CLAUDE_USE_WAYLAND=1` — which fixes GNOME ≤ 49, but GNOME 50 / xdg-desktop-portal ≥ 1.20 is still blocked by an upstream Electron gap ([electron/electron#51875](https://github.com/electron/electron/issues/51875)).
|
||||
|
||||
## The problem (#404)
|
||||
|
||||
Upstream registers Quick Entry's hotkey with a raw `globalShortcut.register()` (build-reference `index.js:499416`) and has no portal fallback. On X11 that becomes an X11 key grab. The launcher historically defaulted *every* Wayland session to XWayland (`--ozone-platform=x11`) precisely so that grab would keep working.
|
||||
|
||||
That stopped working on GNOME. mutter (GNOME ≥ 49) no longer honours XWayland-side global key grabs, so the grab only fires when the Claude window already has focus — the opposite of "open Claude from everywhere." The symptom is intermittent (a brief compositor state can make it appear to work, then it stops), which sent more than one reporter chasing ghosts.
|
||||
|
||||
## The launcher change (necessary, not sufficient)
|
||||
|
||||
Electron ≥ 35 (we bundle 41) exposes Chromium's `GlobalShortcutsPortal` feature: under the **native Wayland ozone platform** it is *supposed* to route `globalShortcut.register()` through the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface instead of an X11 grab. So `build_electron_args` adds `GlobalShortcutsPortal` to the native-Wayland feature set.
|
||||
|
||||
GNOME Wayland is **not** auto-flipped to native Wayland. `detect_display_backend` still only auto-forces Niri (no XWayland at all). The reason: GNOME Wayland is the default session for a large slice of users, and moving it off mature XWayland is a rendering / IME / HiDPI / fractional-scaling risk — shipped on argv-only verification, and on GNOME 50 the portal route is a no-op anyway (so those users would take the risk for zero benefit). GNOME users opt in with `CLAUDE_USE_WAYLAND=1`, which fully works on **GNOME ≤ 49** after the one-time portal dialog. Auto-selecting native Wayland on GNOME is deferred to a follow-up gated on a real "still renders correctly" check, not just "the flag reached argv."
|
||||
|
||||
KDE/Sway/Hyprland likewise stay on XWayland by default (opt in with `=1`).
|
||||
|
||||
## Two traps that bite
|
||||
|
||||
- **`GlobalShortcutsPortal` is inert under XWayland.** The feature lives in Chromium's ozone/wayland layer. Passing the flag while `--ozone-platform=x11` does nothing. The flag and `--ozone-platform=wayland` are a package deal — that's why the launcher flips the backend, not just appends a flag.
|
||||
|
||||
- **Chromium honours only the *last* `--enable-features=` switch.** Two separate `--enable-features=A` `--enable-features=B` on one command line silently drops `A`. `build_electron_args` previously emitted up to two (`WindowControlsOverlay` for hidden titlebars; `UseOzonePlatform,WaylandWindowDecorations` for native Wayland), so adding a third would have clobbered the others. The function now accumulates into one `enable_features` array and emits a single comma-joined `--enable-features=` at the end. The test-harness `argvHasFlag` (`tools/test-harness/src/lib/argv.ts`) already matches a subkey inside a comma-joined value, so `S12` passes against the merged form.
|
||||
|
||||
## Why GNOME 50 is still broken — and how it was proven
|
||||
|
||||
On Fedora 44 / GNOME 50.2 / xdg-desktop-portal **1.21.2**, `globalShortcut.register()` returns `false` and the portal is **never contacted** (no `CreateSession`, no `BindShortcuts`). The feature flag has zero observable effect:
|
||||
|
||||
| ozone backend | `GlobalShortcutsPortal` flag | `register()` | portal `CreateSession` |
|
||||
|---|---|---|---|
|
||||
| wayland | enabled | `false` | 0 |
|
||||
| wayland | default (no flag) | `false` | 0 |
|
||||
| wayland | disabled | `false` | 0 |
|
||||
| x11 (XWayland) | enabled | `true` | 0 (X11 grab; mutter ignores it → focus-bound, the #404 symptom) |
|
||||
|
||||
Reproduced identically on Electron **40.6.1, 41.5.0, 41.7.1, and 42.3.3** (latest), with the relevant app-id fixes already present (electron#49988 → backported to `41-x-y` via #50051). So the Electron *version* is not the variable.
|
||||
|
||||
**Root cause (pinned to source on both sides):** xdg-desktop-portal grew a host-app identity step — non-sandboxed apps must call `org.freedesktop.host.portal.Registry.Register(app_id)` (added in **1.20**, commit `8fd5bdd5ec`), and GlobalShortcuts `CreateSession` now hard-rejects an empty app id (`src/global-shortcuts.c` `handle_create_session()` → `NOT_ALLOWED "An app id is required"`, added in **1.21.0**, commit `38dd2c03f2`). Chromium never makes that call in the normal case: `components/dbus/xdg/portal.cc` `PortalRegistrar::OnServiceChecked()` only calls `Register()` when starting its transient systemd scope *fails* — when the scope starts (`kUnitStarted`, the usual path; the browser creates `app-<id>-<pid>.scope`) it skips `Register()`, assuming the portal derives the app id from the scope. On portal 1.21 that derivation is gone, so the connection has an empty app id and `CreateSession` (issued from `ui/base/accelerators/global_accelerator_listener/global_accelerator_listener_linux.cc`) is rejected. Confirmed on plain Chromium 151 (HEAD) and Chrome 149, not just Electron.
|
||||
|
||||
**Proof the portal itself works** — a ~60-line Python client that performs the missing `Registry.Register` call (reverse-DNS app id backed by a `.desktop` file, launched in a matching `app-<id>.scope` via `systemd-run --user --scope`) drives the whole flow and receives `Activated` from an *unfocused* window:
|
||||
|
||||
```
|
||||
Registry.Register('com.example.GsPortalProof') OK
|
||||
CreateSession OK
|
||||
BindShortcuts OK -> id='open-quick-entry' trigger='Press <Control><Alt>space'
|
||||
*** ACTIVATED *** (press #1) *** ACTIVATED *** (press #2)
|
||||
```
|
||||
|
||||
Secondary gate: GNOME's backend also rejects app ids that are not reverse-DNS and backed by an installed `.desktop` (`gnome-control-center-global-shortcuts-provider: Discarded shortcut bind request … invalid app_id >gsportalproof<`). Electron's default app id is the executable name (`claude-desktop`), which has no dot and would likely also fail this even once `Registry.Register` is wired up.
|
||||
|
||||
Why it works on GNOME ≤ 49: older xdg-desktop-portal derived the app id from the systemd scope automatically and did not require `Registry.Register`. GNOME 50 / portal 1.21 introduced the requirement Chromium hasn't adopted.
|
||||
|
||||
Filed upstream: [electron/electron#51875](https://github.com/electron/electron/issues/51875) (accepted, milestone `42-x-y`) and the underlying Chromium bug at [crbug 520262204](https://issues.chromium.org/issues/520262204) — fundamentally the `components/dbus/xdg/portal.cc` skip-`Register()`-on-`kUnitStarted` gap, surfacing through Electron.
|
||||
|
||||
## First-run UX and escape hatch
|
||||
|
||||
When the portal path *does* engage (GNOME ≤ 49), GNOME shows a **one-time permission dialog** the first time the shortcut is registered; the user must accept it to bind the shortcut. Expected portal behaviour, not a bug. A dismissed or denied dialog persists in the portal permission store and later `globalShortcut.register()` calls then fail silently; clearing the stored decision with `flatpak permission-reset <app-id>` (the store is shared with non-Flatpak apps) should re-trigger the dialog on the next launch — untested here.
|
||||
|
||||
`CLAUDE_USE_WAYLAND` is tri-state: `1` forces native Wayland, `0` forces XWayland (skipping auto-detect), unset auto-detects. The `0` value is the escape hatch for a GNOME user who hits a native-Wayland rendering regression and wants the old XWayland behaviour back (losing global-shortcut-from-unfocused in the process — which on GNOME 50 is not yet working anyway).
|
||||
|
||||
## wlroots caveat (Niri / Sway / Hyprland)
|
||||
|
||||
The portal flag is harmless where the compositor's portal has no GlobalShortcuts backend, but does nothing useful there. wlroots' `xdg-desktop-portal-wlr` ships no GlobalShortcuts implementation, so on Niri `BindShortcuts` fails with `error code 5`. That's the `S14` known-failing detector: the assertion encodes the contract and will start passing if/when the wlroots portal gains the interface — no spec edit needed.
|
||||
|
||||
## Tests / anchors
|
||||
|
||||
- `tests/launcher-common.bats` — `detect_display_backend` GNOME/`CLAUDE_USE_WAYLAND=0` cases; `build_electron_args` single-merged-flag + portal-present/absent cases.
|
||||
- `tools/test-harness/src/runners/S12_global_shortcuts_portal_flag.spec.ts` — GNOME-W flag-in-argv detector (passes: the launcher delivers the flag).
|
||||
- `tools/test-harness/src/runners/S14_quick_entry_from_other_focus_niri.spec.ts` — Niri portal `BindShortcuts` detector (known-failing by design).
|
||||
- `docs/testing/cases/shortcuts-and-input.md` (S12/S14), `docs/testing/quick-entry-closeout.md` (QE-6).
|
||||
- Upstream blockers: [electron/electron#51875](https://github.com/electron/electron/issues/51875), Chromium [crbug 520262204](https://issues.chromium.org/issues/520262204).
|
||||
@@ -130,10 +130,10 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
|
||||
**Diagnostics on failure:** Launcher log (note `Using X11 backend via XWayland (for global hotkey support)`), `XDG_CURRENT_DESKTOP`, mutter version (`gnome-shell --version`), the active patch set.
|
||||
|
||||
**Currently:** Fedora 43 GNOME Wayland reproduces [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) — mutter doesn't honour the XWayland-side key grab, so the shortcut is focus-bound. On Ubuntu 24.04 GNOME, the [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406) KDE-only gate prevents the regressing patch from running, leaving the older (working) code path active — hence `🔧` on Ubu. The unsolved fix path is [S12](#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland).
|
||||
**Currently:** Fedora 43 GNOME Wayland reproduces [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) on the default (XWayland) path — mutter doesn't honour the XWayland-side key grab, so the shortcut is focus-bound. The fix is opt-in: launch with `CLAUDE_USE_WAYLAND=1` to use native Wayland + the XDG GlobalShortcuts portal (see [S12](#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland)), which mutter honours on **GNOME ≤ 49**. GNOME Wayland is not auto-flipped (rendering risk; GNOME 50 portal route is a no-op upstream). Re-verify on a GNOME Wayland host.
|
||||
|
||||
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), [PR #406](https://github.com/aaddrick/claude-desktop-debian/pull/406)
|
||||
**Code anchors:** project `scripts/launcher-common.sh:96-99` (XWayland-default `--ozone-platform=x11`); upstream `index.js:499416` (`globalShortcut.register`).
|
||||
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (native Wayland opt-in via `CLAUDE_USE_WAYLAND=1`; only Niri auto-forced) and `build_electron_args` (native-Wayland `GlobalShortcutsPortal` feature); upstream `index.js:499416` (`globalShortcut.register`).
|
||||
|
||||
## S12 — `--enable-features=GlobalShortcutsPortal` launcher flag wired up for GNOME Wayland
|
||||
|
||||
@@ -143,20 +143,18 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
**Issues:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
|
||||
|
||||
**Steps:**
|
||||
1. On GNOME Wayland, launch the app.
|
||||
1. On GNOME Wayland, launch the app with `CLAUDE_USE_WAYLAND=1`.
|
||||
2. Inspect the Electron command line via `pgrep -af claude-desktop` — look for `--enable-features=GlobalShortcutsPortal`.
|
||||
3. Test Quick Entry shortcut from unfocused state (see [T06](#t06--quick-entry-global-shortcut-unfocused)).
|
||||
|
||||
**Expected:** Launcher detects GNOME Wayland and appends `--enable-features=GlobalShortcutsPortal` to Electron's argv, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs. Once wired, [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404) is closeable.
|
||||
**Expected:** With `CLAUDE_USE_WAYLAND=1`, the launcher uses native Wayland and emits `GlobalShortcutsPortal` inside a single merged `--enable-features=…` switch, routing global shortcuts through XDG Desktop Portal instead of X11 key grabs ([#404](https://github.com/aaddrick/claude-desktop-debian/issues/404); GNOME is not auto-flipped — the portal route is opt-in). Note the flag is comma-joined with `UseOzonePlatform,WaylandWindowDecorations`, so match the `GlobalShortcutsPortal` subkey, not an exact `--enable-features=GlobalShortcutsPortal` token.
|
||||
|
||||
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f electron)/cmdline | tr '\0' ' '`), launcher log, `XDG_CURRENT_DESKTOP`.
|
||||
**Diagnostics on failure:** Full process argv (`cat /proc/$(pgrep -f 'app\.asar')/cmdline | tr '\0' ' '`), launcher log (expect `Using native Wayland backend (global shortcuts via XDG portal)`), `XDG_CURRENT_DESKTOP`.
|
||||
|
||||
**Currently:** Not yet implemented. Tracking under [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404).
|
||||
|
||||
> **⚠ Missing in build 1.5354.0** — `--enable-features=GlobalShortcutsPortal` is not appended by `scripts/launcher-common.sh` for any GNOME Wayland variant. Re-verify after next upstream bump and after #404 lands.
|
||||
**Currently:** Launcher side implemented — `build_electron_args` adds `GlobalShortcutsPortal` to the native-Wayland feature set (opt-in via `CLAUDE_USE_WAYLAND=1`; GNOME is not auto-flipped). The flag is verified present in argv on that opt-in path (this case launches with `CLAUDE_USE_WAYLAND=1` and passes). Functional global-from-unfocused works on **GNOME ≤ 49** (first registration shows a one-time portal permission dialog). On **GNOME 50 / xdg-desktop-portal ≥ 1.20** it does not yet fire: Electron/Chromium never performs the portal's host `Registry.Register` app-id handshake, so `globalShortcut.register()` returns `false` and the portal is never contacted. Proven via D-Bus capture + a Python portal client; filed upstream as [electron/electron#51875](https://github.com/electron/electron/issues/51875).
|
||||
|
||||
**References:** [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404)
|
||||
**Code anchors:** project `scripts/launcher-common.sh:59-112` (`build_electron_args` — no `GlobalShortcutsPortal` branch present).
|
||||
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (tri-state `CLAUDE_USE_WAYLAND` override) + `build_electron_args` (merged `enable_features` array). See [`wayland-global-shortcuts-portal.md`](../../learnings/wayland-global-shortcuts-portal.md).
|
||||
|
||||
## S14 — Global shortcuts via XDG portal work on Niri
|
||||
|
||||
@@ -177,7 +175,7 @@ Tests covering URL handling, the Quick Entry global shortcut, and DE-specific sh
|
||||
**Currently:** `Failed to call BindShortcuts (error code 5)` — portal global shortcuts fail on Niri. Different root cause from [#404](https://github.com/aaddrick/claude-desktop-debian/issues/404), same user-visible symptom (Quick Entry shortcut doesn't fire). Not yet filed.
|
||||
|
||||
**References:** —
|
||||
**Code anchors:** project `scripts/launcher-common.sh:41-44` (Niri force-native-Wayland branch); upstream `index.js:499416` (`globalShortcut.register`, which on native Wayland routes through Electron's `xdg-desktop-portal` `BindShortcuts` path inside Chromium).
|
||||
**Code anchors:** project `scripts/launcher-common.sh` `detect_display_backend` (Niri force-native-Wayland branch) + `build_electron_args` (native-Wayland `GlobalShortcutsPortal` feature, which Niri now also receives); upstream `index.js:499416` (`globalShortcut.register`, which on native Wayland routes through Electron's `xdg-desktop-portal` `BindShortcuts` path inside Chromium). wlroots' portal ships no GlobalShortcuts backend, so `BindShortcuts` still fails until that lands — this stays a known-failing detector.
|
||||
|
||||
## S29 — Quick Entry popup is created lazily on first shortcut press (closed-to-tray sanity)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Each item is a single check. Severity tier matches the existing scaffolding (Cri
|
||||
| QE-3 | Critical | App on a different workspace, press shortcut | Popup appears on current workspace | [T06](./cases/shortcuts-and-input.md#t06--quick-entry-global-shortcut-unfocused) |
|
||||
| QE-4 | Critical | App closed-to-tray (no window mapped), press shortcut | Popup appears | [S29](./cases/shortcuts-and-input.md#s29--quick-entry-popup-is-created-lazily-on-first-shortcut-press-closed-to-tray-sanity) |
|
||||
| QE-5 | Should | App quit entirely, press shortcut | No popup, no error, no zombie process | [S30](./cases/shortcuts-and-input.md#s30--quick-entry-shortcut-becomes-a-no-op-after-full-app-exit) |
|
||||
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` vs `Using native Wayland backend (global hotkeys may not work)` (verbatim from `scripts/launcher-common.sh:98, 102`). | **Pre-S12 fix:** flag absent; shortcut fails on GNOME Wayland (this is the #404 repro). **Post-S12 fix:** `--enable-features=GlobalShortcutsPortal` present in argv on GNOME Wayland; QE-2 / QE-3 begin to pass. | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
|
||||
| QE-6 | Should | Inspect Electron argv via `cat /proc/$(pgrep -f 'app\.asar')/cmdline \| tr '\0' ' '` (the launcher script also matches `claude-desktop`, so anchor on `app.asar` to hit the Electron process). Cross-check launcher log line `Using X11 backend via XWayland (for global hotkey support)` (the GNOME default) vs `Using native Wayland backend (global shortcuts via XDG portal)` (after `CLAUDE_USE_WAYLAND=1`). | **Launcher implemented (S12).** GNOME defaults to XWayland (no portal flag); launching with `CLAUDE_USE_WAYLAND=1` adds `--ozone-platform=wayland` and a single `--enable-features=…,GlobalShortcutsPortal` (comma-joined with the ozone features, not a standalone token). On that opt-in path QE-2 / QE-3 pass on **GNOME ≤ 49** after the one-time portal dialog; on **GNOME 50 / xdg-desktop-portal ≥ 1.20** they don't yet — Electron skips the portal's `Registry.Register` handshake ([electron#51875](https://github.com/electron/electron/issues/51875)). | [S12](./cases/shortcuts-and-input.md#s12----enable-featuresglobalshortcutsportal-launcher-flag-wired-up-for-gnome-wayland) |
|
||||
|
||||
### Submit → main window — covers #393
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ suggested fixes:
|
||||
| Input method | IBus/GTK immodule sanity (ibus-gtk3 installed, cache fresh, XWayland routing note) |
|
||||
| Electron binary | Existence and version |
|
||||
| Chrome sandbox | Correct permissions (4755/root) |
|
||||
| User namespaces | AppArmor userns restriction + Claude profile presence (Ubuntu 24.04+) |
|
||||
| SingletonLock | Stale lock file detection |
|
||||
| MCP config | JSON validity and server count |
|
||||
| Node.js | Version (v20+ recommended for MCP) |
|
||||
@@ -161,6 +162,13 @@ applied automatically inside XRDP sessions, where software
|
||||
rendering is required regardless. Either signal is sufficient —
|
||||
the launcher won't stack duplicate flags.
|
||||
|
||||
If the previous launch already died with the GPU-process FATAL
|
||||
signature and `CLAUDE_DISABLE_GPU` is unset, the next launch
|
||||
auto-applies the same flags and keeps them applied on subsequent
|
||||
launches. Set `CLAUDE_DISABLE_GPU=0` to suppress the auto-fallback
|
||||
when retesting hardware acceleration after a driver fix — any
|
||||
explicitly set value suppresses it; only `1` forces the flags on.
|
||||
|
||||
**When to prefer which:** the in-app toggle is friendlier if you
|
||||
can reach Settings without the app crashing. Reach for
|
||||
`CLAUDE_DISABLE_GPU=1` when the app crashes before you can open
|
||||
@@ -170,6 +178,49 @@ behavior to persist across reinstalls and config resets.
|
||||
|
||||
Tracking issue: [#583](https://github.com/aaddrick/claude-desktop-debian/issues/583).
|
||||
|
||||
### Black screen on Fedora KDE with Intel Iris Xe ([#706](https://github.com/aaddrick/claude-desktop-debian/issues/706))
|
||||
|
||||
If the window opens but renders entirely black on Fedora KDE with
|
||||
Intel Iris Xe graphics (TigerLake-LP GT2), force Mesa's reference
|
||||
software rasterizer:
|
||||
|
||||
```bash
|
||||
MESA_LOADER_DRIVER_OVERRIDE=softpipe claude-desktop
|
||||
```
|
||||
|
||||
The failing launch logs this signature in
|
||||
`~/.cache/claude-desktop-debian/launcher.log`:
|
||||
|
||||
```
|
||||
KMS: DRM_IOCTL_MODE_CREATE_DUMB failed: Permission denied
|
||||
```
|
||||
|
||||
**Try the faster fallbacks first.** softpipe renders everything on
|
||||
the CPU with no acceleration of any kind and is noticeably slow.
|
||||
Before reaching for it:
|
||||
|
||||
1. `CLAUDE_DISABLE_GPU=1 claude-desktop` — disables hardware
|
||||
acceleration entirely (see the previous section).
|
||||
2. `LIBGL_ALWAYS_SOFTWARE=1 claude-desktop` — selects llvmpipe,
|
||||
Mesa's supported software fallback, several times faster than
|
||||
softpipe.
|
||||
|
||||
Use `MESA_LOADER_DRIVER_OVERRIDE=softpipe` only if
|
||||
`LIBGL_ALWAYS_SOFTWARE=1` also produces a black screen. To make it
|
||||
persistent:
|
||||
|
||||
```bash
|
||||
echo 'export MESA_LOADER_DRIVER_OVERRIDE=softpipe' >> ~/.profile
|
||||
```
|
||||
|
||||
Tracking issue:
|
||||
[#706](https://github.com/aaddrick/claude-desktop-debian/issues/706).
|
||||
Credit: workaround discovered and confirmed by
|
||||
[@dubreal](https://github.com/dubreal) while diagnosing
|
||||
[#593](https://github.com/aaddrick/claude-desktop-debian/issues/593)
|
||||
and
|
||||
[#599](https://github.com/aaddrick/claude-desktop-debian/pull/599).
|
||||
|
||||
### AppImage Sandbox Warning
|
||||
|
||||
AppImages run with `--no-sandbox` due to electron's chrome-sandbox requiring root privileges for unprivileged namespace creation. This is a known limitation of AppImage format with Electron applications.
|
||||
@@ -181,18 +232,13 @@ For enhanced security, consider:
|
||||
|
||||
### 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:
|
||||
**Cause:** Ubuntu 24.04+ sets `apparmor_restrict_unprivileged_userns=1`. This blocks the user namespaces Cowork's bubblewrap sandbox needs.
|
||||
|
||||
- `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.
|
||||
**Symptom:** `claude-desktop --doctor` shows `Cowork isolation: host-direct (bwrap probe failed)`.
|
||||
|
||||
Permit user namespaces for `bwrap` via an AppArmor profile (one-time
|
||||
setup, requires sudo):
|
||||
**Fix (`.deb` installs):** None needed. The `postinst` installs `/etc/apparmor.d/claude-desktop-bwrap`, granting `userns` to `/usr/bin/bwrap`. Still failing? Reinstall the package — the `postinst` recreates the profile.
|
||||
|
||||
**Fix (AppImage, Nix, rpm, and manual installs):** The auto-install is deb-only; install the profile by hand:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/bwrap <<'EOF'
|
||||
@@ -209,20 +255,120 @@ 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.
|
||||
**Existing profiles win:** The `postinst` defers to any profile already attaching to `/usr/bin/bwrap` — the hand-made `/etc/apparmor.d/bwrap` above, or `bwrap-userns-restrict` from the `apparmor-profiles` package — rather than shadowing it with its unconfined-mode one. If such a profile blocks `userns`, resolve the conflict yourself before expecting Cowork isolation to work.
|
||||
|
||||
**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.
|
||||
**Customizing:** Put overrides in `/etc/apparmor.d/local/claude-desktop-bwrap` — they survive upgrades. Direct edits to the managed profile do not: the `postinst` rewrites any profile carrying its marker header on every upgrade, and removes it on purge.
|
||||
|
||||
Credit: this workaround was contributed by
|
||||
[@hfyeh](https://github.com/hfyeh) in
|
||||
[#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
**Security:** The profile grants `userns` to `/usr/bin/bwrap` host-wide. Bubblewrap's own sandbox does the confining. Review against your threat model.
|
||||
|
||||
**Credit:** [@hfyeh](https://github.com/hfyeh), [#351](https://github.com/aaddrick/claude-desktop-debian/issues/351).
|
||||
|
||||
### Claude Desktop crashes immediately on launch (Ubuntu 24.04+, AppArmor blocks user namespaces)
|
||||
|
||||
The `.deb` handles this automatically — this section is for the rare case
|
||||
where it doesn't. Ubuntu 24.04+ sets
|
||||
`apparmor_restrict_unprivileged_userns=1`, blocking the user namespaces
|
||||
Chromium's sandbox needs (same root cause as the Cowork case above, but it
|
||||
kills the **main app** on startup before any window appears). The deb's
|
||||
`postinst` installs a scoped AppArmor profile
|
||||
(`/etc/apparmor.d/claude-desktop`) that grants `userns` to the bundled
|
||||
Electron binary only — exactly as the `google-chrome`, `code`, and `slack`
|
||||
packages do — so a normal install needs no action.
|
||||
|
||||
You only need to act if the app still crashes on launch with:
|
||||
|
||||
- `FATAL:sandbox/linux/services/credentials.cc:131] Check failed: . :
|
||||
Permission denied (13)` in
|
||||
`~/.cache/claude-desktop-debian/launcher.log` (the line number varies by
|
||||
Electron version), and
|
||||
- a `Trace/breakpoint trap` / core dump (exit code 133).
|
||||
|
||||
Run `sudo claude-desktop --doctor` first — the **User namespaces** check
|
||||
reports whether the profile is actually loaded into the kernel (reading the
|
||||
loaded set needs root; without `sudo` it can only confirm the profile is
|
||||
present on disk). To (re)install it manually:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/claude-desktop <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile claude-desktop /usr/lib/claude-desktop/node_modules/electron/dist/electron flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/claude-desktop>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/claude-desktop
|
||||
```
|
||||
|
||||
To customize the profile on a `.deb` install, put overrides in
|
||||
`/etc/apparmor.d/local/claude-desktop` — they survive upgrades; direct
|
||||
edits to the managed profile are rewritten by the `postinst` on every
|
||||
upgrade.
|
||||
|
||||
Don't use `--no-sandbox` as a permanent fix on the `.deb` — it disables the
|
||||
Chromium sandbox entirely, which the package is built to keep. (AppImage
|
||||
builds already launch with `--no-sandbox` because they can't ship a SUID
|
||||
helper, so they never hit this crash.)
|
||||
|
||||
**Security note:** the profile grants the unconfined profile plus the
|
||||
`userns` capability to the bundled Electron binary only, not system-wide —
|
||||
narrower than relaxing `kernel.apparmor_restrict_unprivileged_userns`
|
||||
globally, which would lift the restriction for every program on the host.
|
||||
Review against your threat model before applying.
|
||||
|
||||
### Claude Desktop crashes immediately on launch (Ubuntu 24.04+, AppArmor blocks user namespaces)
|
||||
|
||||
The `.deb` handles this automatically — this section is for the rare case
|
||||
where it doesn't. Ubuntu 24.04+ sets
|
||||
`apparmor_restrict_unprivileged_userns=1`, blocking the user namespaces
|
||||
Chromium's sandbox needs (same root cause as the Cowork case above, but it
|
||||
kills the **main app** on startup before any window appears). The deb's
|
||||
`postinst` installs a scoped AppArmor profile
|
||||
(`/etc/apparmor.d/claude-desktop`) that grants `userns` to the bundled
|
||||
Electron binary only — exactly as the `google-chrome`, `code`, and `slack`
|
||||
packages do — so a normal install needs no action.
|
||||
|
||||
You only need to act if the app still crashes on launch with:
|
||||
|
||||
- `FATAL:sandbox/linux/services/credentials.cc:131] Check failed: . :
|
||||
Permission denied (13)` in
|
||||
`~/.cache/claude-desktop-debian/launcher.log` (the line number varies by
|
||||
Electron version), and
|
||||
- a `Trace/breakpoint trap` / core dump (exit code 133).
|
||||
|
||||
Run `sudo claude-desktop --doctor` first — the **User namespaces** check
|
||||
reports whether the profile is actually loaded into the kernel (reading the
|
||||
loaded set needs root; without `sudo` it can only confirm the profile is
|
||||
present on disk). To (re)install it manually:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/apparmor.d/claude-desktop <<'EOF'
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile claude-desktop /usr/lib/claude-desktop/node_modules/electron/dist/electron flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/claude-desktop>
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo apparmor_parser -r /etc/apparmor.d/claude-desktop
|
||||
```
|
||||
|
||||
Don't use `--no-sandbox` as a permanent fix on the `.deb` — it disables the
|
||||
Chromium sandbox entirely, which the package is built to keep. (AppImage
|
||||
builds already launch with `--no-sandbox` because they can't ship a SUID
|
||||
helper, so they never hit this crash.)
|
||||
|
||||
**Security note:** the profile grants the unconfined profile plus the
|
||||
`userns` capability to the bundled Electron binary only, not system-wide —
|
||||
narrower than relaxing `kernel.apparmor_restrict_unprivileged_userns`
|
||||
globally, which would lift the restriction for every program on the host.
|
||||
Review against your threat model before applying.
|
||||
|
||||
### Cowork: "VM connection timeout after 60 seconds"
|
||||
|
||||
|
||||
6
flake.lock
generated
6
flake.lock
generated
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"lastModified": 1781607440,
|
||||
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -16,16 +16,16 @@
|
||||
}:
|
||||
let
|
||||
pname = "claude-desktop";
|
||||
version = "1.8555.2";
|
||||
version = "1.15200.0";
|
||||
|
||||
srcs = {
|
||||
x86_64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-GrV+iMhkUc8ZnRVo11Hat/4p5L36Wj8DX9sVuHLHo1I=";
|
||||
url = "https://downloads.claude.ai/releases/win32/x64/1.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe";
|
||||
hash = "sha256-sxCC+1csPLYpUug3A94vy2SGjMNh9KXSaZbIEPMYzXU=";
|
||||
};
|
||||
aarch64-linux = fetchurl {
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe";
|
||||
hash = "sha256-PDGaWaWbML/rhvcbbfgIkcXJg0BPEuRk9L4XVM1NLJQ=";
|
||||
url = "https://downloads.claude.ai/releases/win32/arm64/1.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe";
|
||||
hash = "sha256-lc870OG/6CsnYrqm9tWdNZrlrxBZ7RNcW+lhcZBnIoA=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -239,6 +239,7 @@ fi
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -262,15 +263,20 @@ detect_display_backend
|
||||
# Build Electron arguments
|
||||
build_electron_args 'nix'
|
||||
|
||||
# Add app path
|
||||
electron_args+=("$app_path")
|
||||
# Intentionally NOT appended: app.asar sits in Electron's default
|
||||
# resources/ dir next to the binary, so Electron auto-loads it. Passing
|
||||
# the path again makes Electron treat it as a file-to-open, which the
|
||||
# app forwards to its file-drop handler, producing a spurious
|
||||
# "Attach app.asar?" prompt on launch and on every taskbar reopen
|
||||
# (the second-instance argv path). Omitting it is the root-cause fix.
|
||||
# See issue #696.
|
||||
log_message "App (auto-loaded by Electron): $app_path"
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep the launcher alive so explicit quit can
|
||||
# clean up Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: $electron_exec ''${electron_args[*]} $*"
|
||||
"$electron_exec" "''${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
exit_code=$?
|
||||
log_message "Electron exited with code: $exit_code"
|
||||
exit $exit_code
|
||||
run_electron_and_cleanup "$electron_exec" "''${electron_args[@]}" "$@"
|
||||
exit $?
|
||||
LAUNCHER
|
||||
# Substitute placeholders — electron_exec points to our custom
|
||||
# wrapper (which sets GTK/GIO env then execs our merged binary)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
buildFHSEnv,
|
||||
bubblewrap,
|
||||
claude-desktop,
|
||||
nodejs,
|
||||
docker,
|
||||
@@ -12,6 +13,7 @@ buildFHSEnv {
|
||||
name = "claude-desktop";
|
||||
|
||||
targetPkgs = pkgs: [
|
||||
bubblewrap
|
||||
claude-desktop
|
||||
docker
|
||||
docker-compose
|
||||
|
||||
@@ -42,6 +42,29 @@ class AuthRequest {
|
||||
|
||||
module.exports = {
|
||||
getWindowsVersion: () => "10.0.0",
|
||||
|
||||
// Windows-only native methods with no Linux equivalent. Newer upstream
|
||||
// (Claude Desktop >= 1.13576.0) calls readRegistryValues() and
|
||||
// getWindowsElevationType() UNCONDITIONALLY at startup — the
|
||||
// managed-config / enterprise-policy lookup — from the top level of
|
||||
// index.pre.js and index.js. The bundle only guards the native module
|
||||
// being null (e.g. `(o=g2())==null?void 0:o.readRegistryValues(r)`),
|
||||
// not the method being absent, so a missing method throws
|
||||
// "<method> is not a function" during top-level execution, before the
|
||||
// logger and main window exist. index.pre.js installs an empty
|
||||
// uncaughtException handler early, so the throw is swallowed: the
|
||||
// process stays alive in the event loop but no window ever appears.
|
||||
// Stub these as neutral no-ops (no registry, no MSIX package, no UAC
|
||||
// on Linux) so the `?? []` / `?? "default"` consumers proceed. Fixing
|
||||
// the stub covers every call site at the source and is robust against
|
||||
// re-minification. Fixes the "hangs indefinitely, app window never
|
||||
// shows up" regression (#729).
|
||||
readRegistryValues: () => [],
|
||||
writeRegistryValue: () => {},
|
||||
writeRegistryDword: () => {},
|
||||
getWindowsElevationType: () => "default",
|
||||
getCurrentPackageFamilyName: () => null,
|
||||
|
||||
setWindowEffect: () => {},
|
||||
removeWindowEffect: () => {},
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
# <name><TAB><pcre_pattern><TAB><sample>
|
||||
# Lines starting with '#' and blank lines are ignored.
|
||||
#
|
||||
# Each row names a post-patch fingerprint of patch_cowork_linux() in
|
||||
# scripts/patches/cowork.sh. Both verify-patches.sh and
|
||||
# tests/verify-patches.bats consume this file, so adding a marker
|
||||
# here adds it to the runtime check and the test matrix at the same
|
||||
# time.
|
||||
# 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.
|
||||
@@ -16,14 +15,23 @@
|
||||
# sample — concrete string the pattern matches; BATS uses it to
|
||||
# build positive and per-marker negative fixtures.
|
||||
#
|
||||
# The 9 markers below correspond 1:1 with the smoke-test set defined
|
||||
# in issue #559 (PR #555 retrofit, deliverable D6).
|
||||
vmclient-log-gate process\.platform==="linux"\)\s*\?\s*"vmClient \(TypeScript\)" (F||process.platform==="linux")?"vmClient (TypeScript)"
|
||||
vm-assignment-linux-gate process\.platform==="linux"\)\?\(?[\w$]+=\{vm:[\w$]+\} (F||process.platform==="linux")?N={vm:M}
|
||||
# Marker set tracks the cowork patch suite in scripts/patches/cowork.sh.
|
||||
# Re-derived for the yukonSilver VM architecture (Claude Desktop
|
||||
# 1.13576+): the platform gate moved to startVM's feature-flag check
|
||||
# and the vmClient module load moved behind the isMsix detector, so
|
||||
# Patch 1 + Patch 2 fingerprints changed and Patch 12 (sharedCwdPath
|
||||
# threading) was retired in favor of the daemon's mountMap fallback.
|
||||
vm-supported-linux-gate process\.platform!=="linux"&&\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported" process.platform!=="linux"&&(r==null?void 0:r.status)!=="supported"
|
||||
vm-supported-linux-evaluator if\(process\.platform==="linux"\)return\{status:"supported"\};const [\w$]+="win32" if(process.platform==="linux")return{status:"supported"};const A="win32"
|
||||
vm-download-blocked-linux process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1: (process.platform==="linux"||(t==null?void 0:t.status)!=="supported")?!1:
|
||||
warm-download-blocked-linux if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)\{await [\w$]+\(\[\]\);return\} if(process.platform==="linux"||!i||i.status!=="supported"){await YcA([]);return}
|
||||
vmclient-linux-gate \([\w$]+\(\)\|\|process\.platform==="linux"\)\? (Rl()||process.platform==="linux")?
|
||||
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]
|
||||
smol-bin-linux-copy Copying smol-bin\.\$\{_la\}\.vhdx to bundle \(Linux\) Copying smol-bin.${_la}.vhdx to bundle (Linux)
|
||||
asar-adddir-filter \.filter\(_d=>!_d\.endsWith\("\.asar"\)\).*"--add-dir" .filter(_d=>!_d.endsWith(".asar")))Y.push("--add-dir"
|
||||
asar-file-drop-guard \.startsWith\("-"\)\s*&&\s*![\w$]+\.endsWith\("\.asar"\) .startsWith("-")&&!i.endsWith(".asar")
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 21 and column 39.
|
@@ -2762,4 +2762,5 @@ module.exports = {
|
||||
loadBwrapMountsConfig,
|
||||
mergeBwrapArgs,
|
||||
classifyBwrapProbeError,
|
||||
detectBackend,
|
||||
};
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
# per-package launcher scripts — deb, rpm, AppImage, Nix).
|
||||
#
|
||||
# Provides: run_doctor (the `claude-desktop --doctor` entry point) plus its
|
||||
# internal helpers. Self-contained — no dependencies on launcher-common.sh
|
||||
# state or functions.
|
||||
# internal helpers. Self-contained except for the WM_CLASS constant defined
|
||||
# at the top of launcher-common.sh (substituted at build time), which the
|
||||
# live-UI fingerprint in the orphaned-daemon check reads at runtime.
|
||||
#
|
||||
# To add a new check: define an internal function `_check_<name>`, call it
|
||||
# from run_doctor in the appropriate section, use _pass / _fail / _warn /
|
||||
@@ -462,6 +463,8 @@ _doctor_check_filename_limit() {
|
||||
local name_max
|
||||
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
|
||||
[[ $name_max =~ ^[0-9]+$ ]] || return 0
|
||||
# Force base 10 so a leading zero can't trip octal arithmetic.
|
||||
name_max=$((10#$name_max))
|
||||
|
||||
((name_max >= 200)) && return 0
|
||||
|
||||
@@ -562,10 +565,16 @@ _doctor_check_recent_crashes() {
|
||||
# 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.
|
||||
# An empty result means detection itself failed (e.g. a sourcing-order
|
||||
# regression) and warns rather than emitting a green PASS with a blank
|
||||
# value.
|
||||
_doctor_check_password_store() {
|
||||
local store
|
||||
store=$(_detect_password_store)
|
||||
if [[ -z $store ]]; then
|
||||
_warn 'Password store: unable to detect backend'
|
||||
return
|
||||
fi
|
||||
_pass "Password store: $store"
|
||||
if [[ $store == 'basic' ]]; then
|
||||
_info \
|
||||
@@ -578,6 +587,88 @@ _doctor_check_password_store() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Report free space on the partition holding the Claude config dir.
|
||||
# Arguments: $1 = config directory to check.
|
||||
#
|
||||
# Skips when df is unavailable or yields a non-numeric value, leaving
|
||||
# an _info line so the summary never claims a pass over an unrun
|
||||
# check: better a visible skip than a green PASS reporting space we
|
||||
# could not read.
|
||||
_doctor_check_disk_space() {
|
||||
local config_dir="$1"
|
||||
local avail
|
||||
avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
|
||||
| tail -1 | tr -d ' M') || true
|
||||
if [[ ! $avail =~ ^[0-9]+$ ]]; then
|
||||
_info 'Disk space: unable to read (df)'
|
||||
return 0
|
||||
fi
|
||||
# Force base 10: a leading zero ("0099") would otherwise make
|
||||
# (( )) parse the value as octal and error out, falling through
|
||||
# to the PASS branch.
|
||||
avail=$((10#$avail))
|
||||
if ((avail < 100)); then
|
||||
_fail "Disk space: ${avail}MB free on config partition"
|
||||
_info 'Fix: Free up disk space'
|
||||
elif ((avail < 500)); then
|
||||
_warn "Disk space: ${avail}MB free" \
|
||||
"on config partition (low)"
|
||||
else
|
||||
_pass "Disk space: ${avail}MB free"
|
||||
fi
|
||||
}
|
||||
|
||||
# Report the installed claude-desktop version from the package manager
|
||||
# that actually owns the install (#711). On dual-DB hosts (e.g. a
|
||||
# Fedora box with dpkg installed for deb work) a stale dpkg record
|
||||
# must not shadow the live rpm install, so rpm ownership of the real
|
||||
# Electron binary is probed first: `rpm -qf <path>` succeeds only when
|
||||
# rpm installed the file, which a stale dpkg record can never claim.
|
||||
# dpkg is consulted only when rpm does not own the path.
|
||||
#
|
||||
# AppImage and Nix installs (no package owns the path) keep the
|
||||
# existing not-found warn; hosts with no package tools stay silent.
|
||||
#
|
||||
# Usage: _doctor_check_pkg_version <electron_path>
|
||||
_doctor_check_pkg_version() {
|
||||
local electron_path="${1:-}"
|
||||
local probe_path="$electron_path"
|
||||
local pkg_version=''
|
||||
|
||||
if [[ -z $probe_path ]]; then
|
||||
probe_path='/usr/lib/claude-desktop'
|
||||
probe_path+='/node_modules/electron/dist/electron'
|
||||
fi
|
||||
|
||||
# rpm branch: query the file, not the package name, so the answer
|
||||
# comes from the database that owns the actual install.
|
||||
if command -v rpm &>/dev/null; then
|
||||
pkg_version=$(rpm -qf --qf '%{VERSION}-%{RELEASE}' \
|
||||
"$probe_path" 2>/dev/null) || pkg_version=''
|
||||
if [[ -n $pkg_version ]]; then
|
||||
_pass "Installed version: $pkg_version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# dpkg branch: only consulted when rpm does not own the install.
|
||||
if command -v dpkg-query &>/dev/null; then
|
||||
pkg_version=$(dpkg-query -W -f='${Version}' \
|
||||
claude-desktop 2>/dev/null) || pkg_version=''
|
||||
if [[ -n $pkg_version ]]; then
|
||||
_pass "Installed version: $pkg_version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Neither manager knows the install — AppImage or Nix. Only warn
|
||||
# when a package tool exists; with none there is nothing to say.
|
||||
if command -v rpm &>/dev/null \
|
||||
|| command -v dpkg-query &>/dev/null; then
|
||||
_warn 'claude-desktop not found via dpkg/rpm (AppImage?)'
|
||||
fi
|
||||
}
|
||||
|
||||
# Run all diagnostic checks and print results
|
||||
# Arguments: $1 = electron path (optional, for package-specific checks)
|
||||
run_doctor() {
|
||||
@@ -595,16 +686,7 @@ run_doctor() {
|
||||
echo
|
||||
|
||||
# -- Installed package version --
|
||||
if command -v dpkg-query &>/dev/null; then
|
||||
local pkg_version
|
||||
pkg_version=$(dpkg-query -W -f='${Version}' \
|
||||
claude-desktop 2>/dev/null) || true
|
||||
if [[ -n $pkg_version ]]; then
|
||||
_pass "Installed version: $pkg_version"
|
||||
else
|
||||
_warn 'claude-desktop not found via dpkg (AppImage?)'
|
||||
fi
|
||||
fi
|
||||
_doctor_check_pkg_version "$electron_path"
|
||||
|
||||
# -- Display server --
|
||||
if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
|
||||
@@ -677,6 +759,14 @@ run_doctor() {
|
||||
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
|
||||
fi
|
||||
|
||||
# -- Keep awake override --
|
||||
local keep_awake="${CLAUDE_KEEP_AWAKE:-}"
|
||||
if [[ $keep_awake == '0' ]]; then
|
||||
_pass 'Keep awake: suppressed (CLAUDE_KEEP_AWAKE=0)'
|
||||
elif [[ -n $keep_awake ]]; then
|
||||
_info "Keep awake: CLAUDE_KEEP_AWAKE=$keep_awake (default behavior)"
|
||||
fi
|
||||
|
||||
# -- Electron binary --
|
||||
# Version is read from the file next to the binary rather than
|
||||
# launching Electron, which can hang (see #371).
|
||||
@@ -732,6 +822,74 @@ run_doctor() {
|
||||
_warn 'Chrome sandbox not found (expected for AppImage)'
|
||||
fi
|
||||
|
||||
# -- User-namespace sandbox (Ubuntu 24.04+ AppArmor) --
|
||||
# Ubuntu 24.04+ sets apparmor_restrict_unprivileged_userns=1, which
|
||||
# blocks the user namespaces Chromium's sandbox needs and crashes the
|
||||
# app on launch (credentials.cc FATAL, exit 133). A scoped AppArmor
|
||||
# profile permits them for Claude only. Only report when the
|
||||
# restriction is actually in force — on other distros the knob is
|
||||
# absent and this check stays silent.
|
||||
local _userns_path='/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
|
||||
local _userns_val=''
|
||||
[[ -r $_userns_path ]] && _userns_val=$(<"$_userns_path")
|
||||
# Gate on the deb's installed Electron, not $electron_path (the
|
||||
# invoking build's binary): the profile pins this exact path, so only
|
||||
# a deb install is confined by it. AppImage always runs --no-sandbox
|
||||
# and Nix binaries live in the store — neither can hit the crash.
|
||||
local _deb_electron='/usr/lib/claude-desktop'
|
||||
_deb_electron+='/node_modules/electron/dist/electron'
|
||||
if [[ $_userns_val == 1 && -e $_deb_electron ]]; then
|
||||
# Profile name must match deb.sh's /etc/apparmor.d/$package_name
|
||||
# (PACKAGE_NAME in build.sh).
|
||||
local _aa_profile='/etc/apparmor.d/claude-desktop'
|
||||
local _aa_loaded='/sys/kernel/security/apparmor/profiles'
|
||||
# securityfs marks this file world-readable (0444), but the kernel
|
||||
# still denies the actual read without CAP_MAC_ADMIN — so a -r test
|
||||
# passes for non-root yet the read returns nothing. Attempt the read
|
||||
# and judge by whether we actually got data, not by the mode bits.
|
||||
local _loaded_set=''
|
||||
_loaded_set=$(cat "$_aa_loaded" 2>/dev/null)
|
||||
if [[ -n $_loaded_set ]]; then
|
||||
# Authoritative: we actually read the kernel's loaded profile
|
||||
# set (needs root), so report the real load state — not
|
||||
# mere presence on disk.
|
||||
if printf '%s\n' "$_loaded_set" | grep -q '^claude-desktop '; then
|
||||
_pass 'User namespaces: restricted, AppArmor profile loaded'
|
||||
else
|
||||
_warn 'User namespaces: restricted by AppArmor,' \
|
||||
'Claude profile not loaded'
|
||||
if [[ -e $_aa_profile ]]; then
|
||||
_info ' Profile is on disk but not loaded. Load it:'
|
||||
_info " sudo apparmor_parser -r $_aa_profile"
|
||||
else
|
||||
_info ' No profile found. See docs/troubleshooting.md'
|
||||
_info ' "Claude Desktop crashes immediately on launch".'
|
||||
fi
|
||||
fi
|
||||
elif [[ -e $_aa_profile ]]; then
|
||||
# The loaded set was unreadable: non-root (the kernel needs
|
||||
# CAP_MAC_ADMIN despite the 0444 mode), or securityfs is
|
||||
# unmounted (common in containers). Report presence on disk
|
||||
# only — never a definitive PASS.
|
||||
if (( EUID == 0 )); then
|
||||
_info 'User namespaces: AppArmor profile present on disk' \
|
||||
'(securityfs unavailable; cannot confirm it is loaded)'
|
||||
else
|
||||
_info 'User namespaces: AppArmor profile present on disk' \
|
||||
'(re-run with sudo to confirm it is loaded)'
|
||||
fi
|
||||
else
|
||||
_warn 'User namespaces: restricted by AppArmor,' \
|
||||
'no Claude profile found'
|
||||
_info ' Unprivileged user namespaces are blocked, which'
|
||||
_info ' crashes the app on launch in X11 sessions'
|
||||
_info ' (credentials.cc FATAL). Wayland sessions run with'
|
||||
_info ' --no-sandbox and are unaffected.'
|
||||
_info ' See docs/troubleshooting.md "Claude Desktop crashes'
|
||||
_info ' immediately on launch" for the profile to install.'
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- SingletonLock --
|
||||
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
||||
local lock_file="$config_dir/SingletonLock"
|
||||
@@ -820,20 +978,7 @@ print(len(servers))
|
||||
fi
|
||||
|
||||
# -- Disk space --
|
||||
local config_disk_avail
|
||||
config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
|
||||
| tail -1 | tr -d ' M') || true
|
||||
if [[ -n $config_disk_avail ]]; then
|
||||
if ((config_disk_avail < 100)); then
|
||||
_fail "Disk space: ${config_disk_avail}MB free on config partition"
|
||||
_info 'Fix: Free up disk space'
|
||||
elif ((config_disk_avail < 500)); then
|
||||
_warn "Disk space: ${config_disk_avail}MB free" \
|
||||
"on config partition (low)"
|
||||
else
|
||||
_pass "Disk space: ${config_disk_avail}MB free"
|
||||
fi
|
||||
fi
|
||||
_doctor_check_disk_space "$config_dir"
|
||||
|
||||
# -- Cowork Mode --
|
||||
echo
|
||||
@@ -889,8 +1034,7 @@ print(len(servers))
|
||||
'apparmor_restrict_unprivileged_userns=1'
|
||||
_info \
|
||||
' by default. See docs/troubleshooting.md' \
|
||||
'"Cowork on Ubuntu 24.04"'
|
||||
_info ' for the AppArmor profile fix.'
|
||||
'"Cowork on Ubuntu 24.04" for the AppArmor profile fix.'
|
||||
fi
|
||||
fi
|
||||
else
|
||||
@@ -1037,33 +1181,21 @@ print(len(servers))
|
||||
_doctor_check_filename_limit
|
||||
|
||||
# -- Orphaned cowork daemon --
|
||||
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
|
||||
# above: a live UI is an Electron main process on app.asar that is
|
||||
# not a Chromium helper (--type=...), not the cowork daemon itself,
|
||||
# and not stopped/zombie. Counting any `claude-desktop`-matching
|
||||
# process (as the old check did) would include the launcher's own
|
||||
# bash and stuck launcher bashes from previous crashes, producing
|
||||
# false negatives where a real orphan is misreported as "parent
|
||||
# alive".
|
||||
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon:
|
||||
# _claude_desktop_ui_is_alive in launcher-common.sh fingerprints on
|
||||
# the --class=$WM_CLASS flag from build_electron_args (since #700
|
||||
# the launchers no longer pass app.asar in argv — Electron
|
||||
# auto-loads it), excluding Chromium helpers (--type=...), the
|
||||
# cowork daemon itself, our own launcher bash, and stopped/zombie
|
||||
# processes. Counting any `claude-desktop`-matching process (as
|
||||
# the old check did) would include the launcher's own bash and
|
||||
# stuck launcher bashes from previous crashes, producing false
|
||||
# negatives where a real orphan is misreported as "parent alive".
|
||||
local _cowork_pids
|
||||
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|
||||
|| true
|
||||
if [[ -n $_cowork_pids ]]; then
|
||||
local _daemon_orphaned=true _pid _cmdline _state
|
||||
for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
|
||||
[[ $_pid == "$$" || $_pid == "$PPID" ]] && continue
|
||||
_cmdline=$(tr '\0' ' ' \
|
||||
< "/proc/$_pid/cmdline" 2>/dev/null) || continue
|
||||
[[ $_cmdline == *cowork-vm-service* ]] && continue
|
||||
[[ $_cmdline == *--type=* ]] && continue
|
||||
_state=$(awk '/^State:/ {print $2; exit}' \
|
||||
"/proc/$_pid/status" 2>/dev/null) || continue
|
||||
[[ $_state == T || $_state == t || $_state == Z ]] \
|
||||
&& continue
|
||||
_daemon_orphaned=false
|
||||
break
|
||||
done
|
||||
if [[ $_daemon_orphaned == true ]]; then
|
||||
if ! _claude_desktop_ui_is_alive; then
|
||||
_warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)"
|
||||
_info 'Fix: Restart Claude Desktop' \
|
||||
'(daemon will be cleaned up automatically)'
|
||||
|
||||
@@ -81,6 +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)
|
||||
@@ -178,10 +187,7 @@ Module.prototype.require = function(id) {
|
||||
} else if (TITLEBAR_STYLE === 'native') {
|
||||
// Main window, native mode: force system frame.
|
||||
options.frame = true;
|
||||
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
|
||||
// 'auto' (default): hidden, Alt toggles
|
||||
// 'visible'/'hidden': no Alt toggle
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
options.autoHideMenuBar = false;
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
|
||||
@@ -211,7 +217,7 @@ Module.prototype.require = function(id) {
|
||||
// CSS rule still applying within the framed
|
||||
// window's content area.
|
||||
options.frame = true;
|
||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||
options.autoHideMenuBar = false;
|
||||
delete options.titleBarStyle;
|
||||
delete options.titleBarOverlay;
|
||||
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
|
||||
@@ -246,6 +252,22 @@ Module.prototype.require = function(id) {
|
||||
this.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
// Track the most recent 'show' event timestamp on the
|
||||
// window. Read by the webContents.focus() guard below to
|
||||
// distinguish a genuine post-show activation (which must
|
||||
// pass through to send _NET_ACTIVE_WINDOW and actually
|
||||
// give the window WM focus) from a sloppy-focus
|
||||
// reassertion (which is what we want to skip). Required
|
||||
// because Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland — a freshly-restored
|
||||
// window reports focused=true even though the WM never
|
||||
// activated it, and skipping the focus() call leaves the
|
||||
// window visible-but-inert until the user clicks it.
|
||||
// See #416 review notes.
|
||||
this._lastShownAt = 0;
|
||||
this.on('show', () => { this._lastShownAt = Date.now(); });
|
||||
this.on('restore', () => { this._lastShownAt = Date.now(); });
|
||||
|
||||
// Inject CSS for Linux scrollbar styling
|
||||
this.webContents.on('did-finish-load', () => {
|
||||
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
|
||||
@@ -316,8 +338,7 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
|
||||
// In 'hidden' mode, suppress Alt toggle by re-hiding
|
||||
// on every show event. In 'auto' mode, let
|
||||
// autoHideMenuBar handle the toggle natively.
|
||||
// on every show event.
|
||||
if (MENU_BAR_MODE === 'hidden') {
|
||||
this.on('show', () => {
|
||||
this.setMenuBarVisibility(false);
|
||||
@@ -367,6 +388,18 @@ Module.prototype.require = function(id) {
|
||||
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.
|
||||
// This bypasses Chromium's stale LayoutManagerBase cache
|
||||
// (only invalidated via _NET_WM_STATE atom changes, which
|
||||
@@ -530,11 +563,32 @@ Module.prototype.require = function(id) {
|
||||
|
||||
// Intercept Menu.setApplicationMenu to hide menu bar on Linux.
|
||||
// In 'hidden' mode, force-hide after every menu update.
|
||||
// In 'auto' mode, only hide initially (autoHideMenuBar handles
|
||||
// Alt toggle — re-hiding here would break that). Fixes: #321
|
||||
// In 'auto' mode, only hide initially (the before-input-event
|
||||
// Alt-keyup handler manages toggle). Fixes: #321
|
||||
const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu);
|
||||
patchedSetApplicationMenu = function(menu) {
|
||||
console.log('[Frame Fix] Intercepting setApplicationMenu');
|
||||
|
||||
// Append a hidden View submenu with F11 fullscreen toggle.
|
||||
// Upstream has fullscreenable:true and persists isFullScreen
|
||||
// across sessions; macOS provides the green traffic-light
|
||||
// button; Linux has no equivalent OS-level trigger, so we
|
||||
// register an accelerator here. visible:false keeps it out
|
||||
// of the menu bar — it only registers the keybinding.
|
||||
// Fixes: #580
|
||||
if (process.platform === 'linux' && menu) {
|
||||
const { MenuItem, Menu: MenuClass } = electronModule;
|
||||
menu.append(new MenuItem({
|
||||
label: 'View',
|
||||
visible: false,
|
||||
submenu: MenuClass.buildFromTemplate([{
|
||||
label: 'Toggle Full Screen',
|
||||
role: 'togglefullscreen',
|
||||
accelerator: 'F11',
|
||||
}]),
|
||||
}));
|
||||
}
|
||||
|
||||
originalSetAppMenu(menu);
|
||||
if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') {
|
||||
for (const win of PatchedBrowserWindow.getAllWindows()) {
|
||||
@@ -587,13 +641,105 @@ Module.prototype.require = function(id) {
|
||||
});
|
||||
}
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (!input.control) return;
|
||||
if (input.alt || input.shift || input.meta) return;
|
||||
if (input.key !== 'q' && input.key !== 'Q') return;
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
if (input.type === 'keyDown' && input.control
|
||||
&& !input.alt && !input.shift && !input.meta
|
||||
&& (input.key === 'q' || input.key === 'Q')) {
|
||||
event.preventDefault();
|
||||
result.app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt-keyup menu bar toggle (auto mode). Chromium's
|
||||
// autoHideMenuBar fires on keydown, grabbing focus
|
||||
// before Alt+Shift (language switch) or Alt+F4 can
|
||||
// complete. We suppress the keydown and toggle on
|
||||
// keyup only when Alt was released without any
|
||||
// intervening key. Fixes: #630
|
||||
if (MENU_BAR_MODE !== 'auto') return;
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return;
|
||||
const tracker = owner._altMenuTracker;
|
||||
if (!tracker) return;
|
||||
|
||||
if (input.key === 'Alt') {
|
||||
if (input.type === 'keyDown') {
|
||||
tracker.pressed = true;
|
||||
tracker.chorded = false;
|
||||
event.preventDefault();
|
||||
} else if (input.type === 'keyUp') {
|
||||
if (tracker.pressed && !tracker.chorded) {
|
||||
owner.setMenuBarVisibility(!owner.isMenuBarVisible());
|
||||
}
|
||||
tracker.pressed = false;
|
||||
}
|
||||
} else if (tracker.pressed && input.type === 'keyDown') {
|
||||
tracker.chorded = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress redundant webContents.focus() calls that would
|
||||
// re-trigger Chromium's X11Window::Activate() and send a
|
||||
// _NET_ACTIVE_WINDOW client message — EWMH defines that as
|
||||
// focus-AND-raise, so under sloppy / focus-follows-mouse
|
||||
// WMs (Cinnamon Muffin, Mutter, i3 with focus_follows_mouse)
|
||||
// every BrowserWindow 'focus' event causes a raise on
|
||||
// mouse-enter, undoing the user's "no auto-raise" config.
|
||||
// Tracks electron/electron#38184.
|
||||
//
|
||||
// Hooked at app.on('web-contents-created') so child views
|
||||
// are covered too — the BrowserWindow-class wrap only
|
||||
// touches the window's own webContents, but the upstream
|
||||
// call site lives on a child WebContentsView (the claude.ai
|
||||
// host view) whose webContents is a different object.
|
||||
//
|
||||
// Skip is gated on the *owning toplevel*'s isFocused(),
|
||||
// not the webContents'. wc.isFocused() returns false on a
|
||||
// freshly-attached child view even when the window is
|
||||
// focused — that's exactly the state on every sloppy hover,
|
||||
// so guarding on it would never skip and the raise loop
|
||||
// would continue.
|
||||
//
|
||||
// The post-'show' grace window is the second half of the
|
||||
// story. Electron's isFocused() returns stale-true after
|
||||
// hide() on Cinnamon/KDE/Wayland (the same trap that
|
||||
// drives the KDE-only patches in scripts/patches/
|
||||
// quick-window.sh); a tray-restore hide → show then sees
|
||||
// ownerFocused=true and a naive guard would skip, leaving
|
||||
// the window visible-but-inert (no _NET_ACTIVE_WINDOW, no
|
||||
// keyboard focus until the user clicks). Within
|
||||
// SHOW_GRACE_MS of a 'show' event we pass through
|
||||
// unconditionally, so the post-restore activation actually
|
||||
// lands. 1000 ms covers the synchronous show → focus
|
||||
// sequence with margin for slow restores.
|
||||
//
|
||||
// Trade-off: in sloppy mode, hover-induced focus events
|
||||
// are SKIPped, which suppresses both the X11 raise (the
|
||||
// bug we're fixing) and the renderer-focus direction that
|
||||
// webContents.focus() would also do. Net effect: hover
|
||||
// gives WM focus (frame highlight) but renderer focus
|
||||
// doesn't follow until the user clicks. The Electron API
|
||||
// doesn't expose a renderer-focus-only path on X11, so
|
||||
// this is the best available trade against the constant-
|
||||
// raise UX. Genuine activations (no recent show + not
|
||||
// already focused) still go through end-to-end.
|
||||
//
|
||||
// Known: deferred setTimeout focus sites (e.g. find-bar
|
||||
// dismiss) outside the grace window may lose renderer-focus
|
||||
// direction on keyboard dismissal. See #416 review.
|
||||
//
|
||||
// Fixes: #416
|
||||
const SHOW_GRACE_MS = 1000;
|
||||
const origFocus = wc.focus.bind(wc);
|
||||
wc.focus = (...args) => {
|
||||
const owner = result.BrowserWindow.fromWebContents(wc);
|
||||
if (!owner || owner.isDestroyed()) return origFocus(...args);
|
||||
if (!owner.isFocused()) return origFocus(...args);
|
||||
const shownAt = owner._lastShownAt || 0;
|
||||
if (Date.now() - shownAt < SHOW_GRACE_MS) {
|
||||
return origFocus(...args);
|
||||
}
|
||||
return;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -647,9 +793,8 @@ Module.prototype.require = function(id) {
|
||||
return { exec: 'claude-desktop', icon: 'claude-desktop' };
|
||||
};
|
||||
|
||||
// StartupWMClass matches the value set by scripts/packaging/{deb,rpm}.sh
|
||||
// so DEs group an autostarted window with user-launched instances
|
||||
// under the same taskbar / dock entry.
|
||||
// StartupWMClass derived from Electron's app.name (upstream
|
||||
// productName) so DEs group autostarted and launched instances.
|
||||
const buildAutostartContent = () => {
|
||||
const { exec, icon } = resolveAutostartTarget();
|
||||
return `[Desktop Entry]
|
||||
@@ -657,7 +802,7 @@ Type=Application
|
||||
Name=Claude
|
||||
Exec=${exec}
|
||||
Icon=${icon}
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=${result.app.name}
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`;
|
||||
@@ -793,6 +938,39 @@ 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/...
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# Common launcher functions for Claude Desktop (AppImage and deb)
|
||||
# This file is sourced by both launchers to avoid code duplication
|
||||
|
||||
# WM_CLASS / StartupWMClass — must match upstream productName.
|
||||
# @@WM_CLASS@@ is replaced at build time; see build.sh.
|
||||
readonly WM_CLASS='@@WM_CLASS@@'
|
||||
|
||||
# Setup logging directory and file
|
||||
# Sets: log_dir, log_file
|
||||
setup_logging() {
|
||||
@@ -58,18 +62,38 @@ detect_display_backend() {
|
||||
is_wayland=false
|
||||
[[ -n "${WAYLAND_DISPLAY:-}" ]] && is_wayland=true
|
||||
|
||||
# Default: Use X11/XWayland on Wayland for global hotkey support
|
||||
# Set CLAUDE_USE_WAYLAND=1 to use native Wayland (global hotkeys disabled)
|
||||
# Default: Use X11/XWayland on Wayland so upstream's globalShortcut
|
||||
# (Quick Entry's Ctrl+Alt+Space) keeps working via an X11 key grab.
|
||||
#
|
||||
# CLAUDE_USE_WAYLAND is tri-state:
|
||||
# 1 - force native Wayland (global shortcuts via XDG portal)
|
||||
# 0 - force XWayland, skipping the auto-detect below
|
||||
# unset - auto-detect per compositor
|
||||
use_x11_on_wayland=true
|
||||
[[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]] && use_x11_on_wayland=false
|
||||
local wayland_override="${CLAUDE_USE_WAYLAND:-}"
|
||||
[[ $wayland_override == '1' ]] && use_x11_on_wayland=false
|
||||
|
||||
# Fixes: #226 - Auto-detect compositors that require native Wayland
|
||||
# Only Niri is auto-forced: it has no XWayland support.
|
||||
# Sway and Hyprland have working XWayland, so users on those
|
||||
# compositors who want native Wayland can set CLAUDE_USE_WAYLAND=1.
|
||||
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME");
|
||||
# glob matching with *niri* handles this correctly.
|
||||
if [[ $is_wayland == true && $use_x11_on_wayland == true ]]; then
|
||||
# Fixes: #226 - Only Niri is auto-forced to native Wayland: it has
|
||||
# no XWayland at all, so the X11 backend can't even start.
|
||||
#
|
||||
# GNOME Wayland is NOT auto-forced. mutter no longer honours
|
||||
# XWayland global key grabs (#404), and native Wayland would route
|
||||
# Quick Entry's globalShortcut through the XDG GlobalShortcuts portal
|
||||
# instead -- but flipping the default session off mature XWayland is
|
||||
# a rendering / IME / HiDPI risk, and on GNOME 50 the portal path is
|
||||
# a no-op anyway (electron/electron#51875). GNOME users who want the
|
||||
# portal route opt in with CLAUDE_USE_WAYLAND=1 (works on GNOME <=49
|
||||
# after the one-time portal permission dialog).
|
||||
#
|
||||
# Sway and Hyprland keep working XWayland grabs and their wlroots
|
||||
# portal has no GlobalShortcuts backend, so they also stay on the
|
||||
# XWayland default; opt in with CLAUDE_USE_WAYLAND=1 if desired. An
|
||||
# explicit CLAUDE_USE_WAYLAND=0 opts out of this auto-detect entirely.
|
||||
#
|
||||
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME"); the
|
||||
# *glob* substring match handles this.
|
||||
if [[ $is_wayland == true && $use_x11_on_wayland == true \
|
||||
&& $wayland_override != '0' ]]; then
|
||||
local desktop="${XDG_CURRENT_DESKTOP:-}"
|
||||
desktop="${desktop,,}"
|
||||
|
||||
@@ -152,6 +176,55 @@ _detect_password_store() {
|
||||
echo 'basic'
|
||||
}
|
||||
|
||||
# Detect whether the previous launch ended in Chromium's
|
||||
# "GPU process isn't usable" crash signature (#583).
|
||||
#
|
||||
# setup_logging() must have run first so $log_file is available. The
|
||||
# launcher writes the current session header before build_electron_args()
|
||||
# runs, so the previous launch lives in the penultimate log section.
|
||||
#
|
||||
# A recovered launch (running with --disable-gpu) produces no GPU
|
||||
# output, so the crash signature alone would re-enable GPU on launch
|
||||
# N+2 and oscillate crash/work/crash on permanently broken hardware.
|
||||
# The launcher's own "disabling GPU" marker therefore also counts as
|
||||
# a trigger, making recovery sticky once tripped. CLAUDE_DISABLE_GPU=0
|
||||
# remains the escape hatch for retesting hardware acceleration.
|
||||
#
|
||||
# Section headers vary by package format: deb/rpm write "Launcher
|
||||
# Start", AppImage writes "AppImage Start", and Nix writes "Launcher
|
||||
# Start (NixOS)" (nix/claude-desktop.nix).
|
||||
_previous_launch_hit_gpu_fatal() {
|
||||
[[ -f ${log_file:-} ]] || return 1
|
||||
|
||||
awk '
|
||||
/^--- Claude Desktop (Launcher|AppImage) Start( \(NixOS\))? ---$/ {
|
||||
section++
|
||||
next
|
||||
}
|
||||
{
|
||||
sections[section] = sections[section] $0 "\n"
|
||||
}
|
||||
END {
|
||||
target = section > 1 ? section - 1 : section
|
||||
if (target < 1) {
|
||||
exit 1
|
||||
}
|
||||
text = sections[target]
|
||||
if (index(text,
|
||||
"GPU process launch failed: error_code=") &&
|
||||
index(text,
|
||||
"GPU process isn'\''t usable. Goodbye.")) {
|
||||
exit 0
|
||||
}
|
||||
if (index(text,
|
||||
"Previous launch hit GPU process FATAL")) {
|
||||
exit 0
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
' "$log_file"
|
||||
}
|
||||
|
||||
# Build Electron arguments array based on display backend
|
||||
# Requires: is_wayland, use_x11_on_wayland to be set
|
||||
# (call detect_display_backend first)
|
||||
@@ -162,6 +235,12 @@ build_electron_args() {
|
||||
|
||||
electron_args=()
|
||||
|
||||
# Chromium ignores all but the LAST --enable-features switch on a
|
||||
# command line, so every feature we want must end up in ONE
|
||||
# comma-joined flag. Accumulate them here and emit a single
|
||||
# --enable-features=... at the end of the function.
|
||||
local enable_features=()
|
||||
|
||||
# AppImage always needs --no-sandbox due to FUSE constraints
|
||||
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
|
||||
|
||||
@@ -169,26 +248,28 @@ build_electron_args() {
|
||||
# hybrid (default) / native: --disable-features=CustomTitlebar
|
||||
# so Chromium's drawn CSD titlebar doesn't compete with
|
||||
# the DE-drawn one. Both modes use frame:true.
|
||||
# hidden: --enable-features=WindowControlsOverlay because WCO
|
||||
# is off by default on Linux Chromium (Win/macOS have
|
||||
# it on by default). Without this flag, titleBarOverlay
|
||||
# is silently ignored at the page level.
|
||||
# hidden: WindowControlsOverlay because WCO is off by default on
|
||||
# Linux Chromium (Win/macOS have it on by default).
|
||||
# Without it, titleBarOverlay is silently ignored at the
|
||||
# page level.
|
||||
local _tb
|
||||
_tb=$(_resolve_titlebar_style)
|
||||
if [[ $_tb == 'hidden' ]]; then
|
||||
electron_args+=('--enable-features=WindowControlsOverlay')
|
||||
enable_features+=('WindowControlsOverlay')
|
||||
else
|
||||
electron_args+=('--disable-features=CustomTitlebar')
|
||||
fi
|
||||
|
||||
# WM_CLASS must match the .desktop StartupWMClass and upstream's
|
||||
# productName. Ref: #647, #652
|
||||
electron_args+=("--class=$WM_CLASS")
|
||||
|
||||
# Chromium's safeStorage API and cookie encryption both require a
|
||||
# system keyring selected by --password-store. Without an explicit
|
||||
# value, Electron may silently report encryption unavailable even
|
||||
# when a keyring daemon is running, discarding OAuth tokens on exit
|
||||
# and forcing re-authentication on every launch. We probe for the
|
||||
# best available store at startup 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
|
||||
# best available store at startup. Fixes: #593
|
||||
local pw_store
|
||||
pw_store=$(_detect_password_store)
|
||||
electron_args+=("--password-store=${pw_store}")
|
||||
@@ -217,39 +298,117 @@ build_electron_args() {
|
||||
# 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
|
||||
if [[ -v CLAUDE_DISABLE_GPU ]]; then
|
||||
if [[ ${CLAUDE_DISABLE_GPU} == '1' ]]; then
|
||||
_disable_gpu=true
|
||||
log_message \
|
||||
'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
|
||||
fi
|
||||
elif _previous_launch_hit_gpu_fatal; then
|
||||
_disable_gpu=true
|
||||
log_message 'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
|
||||
log_message \
|
||||
'Previous launch hit GPU process FATAL - disabling GPU'
|
||||
fi
|
||||
[[ $_disable_gpu == true ]] \
|
||||
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
|
||||
|
||||
# X11 session - no special flags needed
|
||||
# X11 session - no display-backend flags needed.
|
||||
if [[ $is_wayland != true ]]; then
|
||||
log_message 'X11 session detected'
|
||||
return
|
||||
fi
|
||||
|
||||
# Wayland: deb/nix packages need --no-sandbox in both modes
|
||||
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
|
||||
&& electron_args+=('--no-sandbox')
|
||||
|
||||
if [[ $use_x11_on_wayland == true ]]; then
|
||||
# Default: Use X11 via XWayland for global hotkey support
|
||||
log_message 'Using X11 backend via XWayland (for global hotkey support)'
|
||||
electron_args+=('--ozone-platform=x11')
|
||||
else
|
||||
# Native Wayland mode (user opted in via CLAUDE_USE_WAYLAND=1)
|
||||
log_message 'Using native Wayland backend (global hotkeys may not work)'
|
||||
electron_args+=('--enable-features=UseOzonePlatform,WaylandWindowDecorations')
|
||||
electron_args+=('--ozone-platform=wayland')
|
||||
electron_args+=('--enable-wayland-ime')
|
||||
electron_args+=('--wayland-text-input-version=3')
|
||||
# Override any system-wide GDK_BACKEND=x11 that would silently
|
||||
# prevent GTK from connecting to the Wayland compositor, causing
|
||||
# blurry rendering or launch failures on HiDPI displays.
|
||||
export GDK_BACKEND=wayland
|
||||
# Wayland: deb/nix packages need --no-sandbox in both modes
|
||||
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
|
||||
&& electron_args+=('--no-sandbox')
|
||||
|
||||
if [[ $use_x11_on_wayland == true ]]; then
|
||||
# Use X11 via XWayland; globalShortcut uses an X11 key grab.
|
||||
log_message 'Using X11 backend via XWayland (for global hotkey support)'
|
||||
electron_args+=('--ozone-platform=x11')
|
||||
else
|
||||
# Native Wayland: route globalShortcut through the XDG
|
||||
# GlobalShortcutsPortal instead of an X11 key grab. Needs
|
||||
# the wayland ozone platform (the feature is inert under
|
||||
# XWayland) and Electron >= 35. Fixes #404 on GNOME, where
|
||||
# mutter no longer honours XWayland grabs. On compositors
|
||||
# whose portal lacks a GlobalShortcuts backend (e.g.
|
||||
# wlroots) the feature is a harmless no-op.
|
||||
log_message 'Using native Wayland backend (global shortcuts via XDG portal)'
|
||||
enable_features+=(
|
||||
'UseOzonePlatform'
|
||||
'WaylandWindowDecorations'
|
||||
'GlobalShortcutsPortal'
|
||||
)
|
||||
electron_args+=('--ozone-platform=wayland')
|
||||
electron_args+=('--enable-wayland-ime')
|
||||
electron_args+=('--wayland-text-input-version=3')
|
||||
# Override any system-wide GDK_BACKEND=x11 that would silently
|
||||
# prevent GTK from connecting to the Wayland compositor, causing
|
||||
# blurry rendering or launch failures on HiDPI displays.
|
||||
export GDK_BACKEND=wayland
|
||||
fi
|
||||
fi
|
||||
|
||||
# Emit all accumulated Chromium features as a single switch (see the
|
||||
# enable_features declaration above for why a single switch matters).
|
||||
if [[ ${#enable_features[@]} -gt 0 ]]; then
|
||||
local IFS=','
|
||||
electron_args+=("--enable-features=${enable_features[*]}")
|
||||
fi
|
||||
}
|
||||
|
||||
# Does a /proc/PID/cmdline (joined with spaces) belong to the Claude
|
||||
# Desktop Electron UI main process?
|
||||
#
|
||||
# We can NOT fingerprint on `app.asar`: since #700 the launchers no
|
||||
# longer pass it as an argument (Electron auto-loads it from
|
||||
# resources/), so it never appears in any cmdline. The stable
|
||||
# signature across deb/rpm/AppImage/nix is the `--class=$WM_CLASS`
|
||||
# flag every launcher passes via build_electron_args; Chromium keeps
|
||||
# the exec'd argv in /proc/PID/cmdline and does not propagate --class
|
||||
# to its --type=... helper children (verified empirically).
|
||||
#
|
||||
# Callers join /proc/PID/cmdline with `tr '\0' ' '`, which leaves
|
||||
# every argument space-terminated, so anchoring on the trailing space
|
||||
# rejects look-alike classes (e.g. ClaudeDev).
|
||||
_claude_desktop_ui_cmdline_matches() {
|
||||
local cmdline="$1"
|
||||
|
||||
# Never the cowork daemon (defensive; it carries no --class) and
|
||||
# never a Chromium helper: zygote, renderer, gpu, utility, etc.
|
||||
[[ $cmdline == *cowork-vm-service* ]] && return 1
|
||||
[[ $cmdline == *--type=* ]] && return 1
|
||||
|
||||
[[ $cmdline == *"--class=$WM_CLASS "* ]]
|
||||
}
|
||||
|
||||
# Is a live Claude Desktop UI running for this user?
|
||||
#
|
||||
# We can NOT use `pgrep -f 'claude-desktop'` on its own for this: it
|
||||
# matches the launcher's own bash process (this script's cmdline
|
||||
# contains "/usr/bin/claude-desktop"), any stale launcher bash left
|
||||
# stopped/zombie after a previous crash, and the cowork daemon
|
||||
# itself. Counting any of those as "the UI is alive" causes false
|
||||
# negatives in the cleanup functions below. The reliable definition
|
||||
# is: a process whose cmdline carries our --class fingerprint (see
|
||||
# _claude_desktop_ui_cmdline_matches) and is actually runnable (not
|
||||
# stopped/zombie), excluding our own launcher bash and its parent.
|
||||
_claude_desktop_ui_is_alive() {
|
||||
local pid cmdline state
|
||||
for pid in \
|
||||
$(pgrep -u "$(id -u)" -f -- "--class=$WM_CLASS" 2>/dev/null); do
|
||||
# Skip our own launcher bash and its parent.
|
||||
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
|
||||
cmdline=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") \
|
||||
|| continue
|
||||
_claude_desktop_ui_cmdline_matches "$cmdline" || continue
|
||||
# Skip stopped (T/t) and zombie (Z) processes — not a live UI.
|
||||
state=$(awk '/^State:/ {print $2; exit}' \
|
||||
"/proc/$pid/status" 2>/dev/null) || continue
|
||||
[[ $state == T || $state == t || $state == Z ]] && continue
|
||||
# Found a genuine live Electron UI.
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Kill orphaned cowork-vm-service daemon processes.
|
||||
@@ -262,40 +421,16 @@ build_electron_args() {
|
||||
# Must run BEFORE cleanup_stale_lock / cleanup_stale_cowork_socket
|
||||
# so that stale files left behind by the daemon can be cleaned up.
|
||||
cleanup_orphaned_cowork_daemon() {
|
||||
local cowork_pids
|
||||
local cowork_pids pid
|
||||
cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|
||||
|| return 0
|
||||
|
||||
# Check if a live Claude Desktop UI process is also running.
|
||||
#
|
||||
# We can NOT use `pgrep -f 'claude-desktop'` on its own for this:
|
||||
# it matches the launcher's own bash process (this script's
|
||||
# cmdline contains "/usr/bin/claude-desktop"), any stale launcher
|
||||
# bash left stopped/zombie after a previous crash, and the cowork
|
||||
# daemon itself. Counting any of those as "the UI is alive"
|
||||
# causes a false negative and the orphan survives.
|
||||
#
|
||||
# The reliable definition of "UI is alive" is: an Electron main
|
||||
# process whose cmdline references app.asar and is NOT a Chromium
|
||||
# helper (--type=...) and NOT the cowork daemon, and is actually
|
||||
# runnable (not stopped/zombie).
|
||||
local pid cmdline state
|
||||
for pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
|
||||
# Skip our own launcher bash and its parent.
|
||||
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
|
||||
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) \
|
||||
|| continue
|
||||
# Skip the cowork daemon (matches app.asar.unpacked path).
|
||||
[[ $cmdline == *cowork-vm-service* ]] && continue
|
||||
# Skip Chromium helpers: zygote, renderer, gpu, utility, etc.
|
||||
[[ $cmdline == *--type=* ]] && continue
|
||||
# Skip stopped (T/t) and zombie (Z) processes — not a live UI.
|
||||
state=$(awk '/^State:/ {print $2; exit}' \
|
||||
"/proc/$pid/status" 2>/dev/null) || continue
|
||||
[[ $state == T || $state == t || $state == Z ]] && continue
|
||||
# Found a genuine live Electron UI — daemon is expected
|
||||
# A live Claude Desktop UI process means the daemon is expected;
|
||||
# leave it alone. See _claude_desktop_ui_is_alive for why neither
|
||||
# `pgrep -f 'claude-desktop'` nor an app.asar fingerprint works.
|
||||
if _claude_desktop_ui_is_alive; then
|
||||
return 0
|
||||
done
|
||||
fi
|
||||
|
||||
# No UI process found — daemon is orphaned, terminate it.
|
||||
# Escalate to SIGKILL if a daemon is stuck and does not exit
|
||||
@@ -320,6 +455,83 @@ cleanup_orphaned_cowork_daemon() {
|
||||
fi
|
||||
}
|
||||
|
||||
_desktop_helper_cmdline_matches() {
|
||||
local cmdline="$1"
|
||||
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
||||
|
||||
case "$cmdline" in
|
||||
*cowork-vm-service.js*)
|
||||
return 0
|
||||
;;
|
||||
*"--user-data-dir=$config_dir "*)
|
||||
return 0
|
||||
;;
|
||||
*"$config_dir/Claude Extensions/"*)
|
||||
return 0
|
||||
;;
|
||||
*/usr/lib/claude-desktop/*--type=*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
_desktop_helper_candidate_pids() {
|
||||
pgrep -u "$(id -u)" -f 'cowork-vm-service\.js|--user-data-dir=.*[/]Claude|Claude Extensions|/usr/lib/claude-desktop/' 2>/dev/null
|
||||
}
|
||||
|
||||
cleanup_stale_desktop_helpers() {
|
||||
# A live UI (any instance) suppresses all cleanup. We don't scope
|
||||
# helpers per-instance. Safe, not complete.
|
||||
if _claude_desktop_ui_is_alive; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pids pid cmdline
|
||||
pids=$(_desktop_helper_candidate_pids) || return 0
|
||||
|
||||
local matched=()
|
||||
for pid in $pids; do
|
||||
[[ $pid == "$$" || $pid == "$PPID" ]] && continue
|
||||
[[ ${_electron_child_pid:-} == "$pid" ]] && continue
|
||||
cmdline=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline") \
|
||||
|| continue
|
||||
_desktop_helper_cmdline_matches "$cmdline" || continue
|
||||
matched+=("$pid")
|
||||
done
|
||||
|
||||
[[ ${#matched[@]} -gt 0 ]] || return 0
|
||||
|
||||
for pid in "${matched[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
local wait_count=0 alive
|
||||
while ((wait_count < 20)); do
|
||||
alive=false
|
||||
for pid in "${matched[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
alive=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ $alive == false ]] && break
|
||||
sleep 0.1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
if [[ $alive == true ]]; then
|
||||
for pid in "${matched[@]}"; do
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
done
|
||||
log_message \
|
||||
"Killed stale Claude Desktop helpers (SIGKILL, PIDs: ${matched[*]})"
|
||||
else
|
||||
log_message "Killed stale Claude Desktop helpers (PIDs: ${matched[*]})"
|
||||
fi
|
||||
}
|
||||
|
||||
# Clean up stale SingletonLock if the owning process is no longer running.
|
||||
# Electron uses requestSingleInstanceLock() which silently quits if the lock
|
||||
# is held. A stale lock (from a crash or unclean update) blocks all launches
|
||||
@@ -380,6 +592,47 @@ cleanup_stale_cowork_socket() {
|
||||
log_message "Removed stale cowork-vm-service socket (no daemon running)"
|
||||
}
|
||||
|
||||
cleanup_after_electron_exit() {
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
}
|
||||
|
||||
_electron_launcher_forward_signal() {
|
||||
local signal="$1"
|
||||
|
||||
if [[ -n ${_electron_child_pid:-} ]]; then
|
||||
kill "-$signal" "$_electron_child_pid" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
run_electron_and_cleanup() {
|
||||
local status
|
||||
|
||||
"$@" >> "$log_file" 2>&1 &
|
||||
_electron_child_pid=$!
|
||||
|
||||
trap '_electron_launcher_forward_signal TERM' TERM
|
||||
trap '_electron_launcher_forward_signal INT' INT
|
||||
trap '_electron_launcher_forward_signal HUP' HUP
|
||||
|
||||
wait "$_electron_child_pid"
|
||||
status=$?
|
||||
while kill -0 "$_electron_child_pid" 2>/dev/null; do
|
||||
wait "$_electron_child_pid" # reap only; keep status
|
||||
done
|
||||
|
||||
trap - TERM INT HUP
|
||||
|
||||
log_message "Electron exited with code: $status"
|
||||
cleanup_after_electron_exit
|
||||
_electron_child_pid=''
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# Set common environment variables
|
||||
setup_electron_env() {
|
||||
# ELECTRON_FORCE_IS_PACKAGED makes app.isPackaged return true, which
|
||||
|
||||
@@ -48,6 +48,7 @@ echo 'Application files copied to Electron resources directory'
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "$appdir_path/usr/lib/claude-desktop" || exit 1
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$appdir_path/usr/lib/claude-desktop/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$appdir_path/usr/lib/claude-desktop/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -86,7 +87,13 @@ fi
|
||||
# Setup logging and environment
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
|
||||
# Path to the bundled Electron executable and app
|
||||
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
|
||||
app_path="$appdir/usr/lib/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -100,22 +107,26 @@ log_message "Arguments: $@"
|
||||
log_message "APPDIR: $appdir"
|
||||
log_session_env
|
||||
|
||||
# Path to the bundled Electron executable and app
|
||||
electron_exec="$appdir/usr/lib/node_modules/electron/dist/electron"
|
||||
app_path="$appdir/usr/lib/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
# Build electron args (appimage mode adds --no-sandbox)
|
||||
build_electron_args 'appimage'
|
||||
|
||||
# Add app path LAST - Chromium flags must come before this
|
||||
electron_args+=("$app_path")
|
||||
# Intentionally NOT appended: app.asar sits in Electron's default
|
||||
# resources/ dir next to the binary, so Electron auto-loads it. Passing
|
||||
# the path again makes Electron treat it as a file-to-open, which the
|
||||
# app forwards to its file-drop handler, producing a spurious
|
||||
# "Attach app.asar?" prompt on launch and on every taskbar reopen
|
||||
# (the second-instance argv path). Omitting it is the root-cause fix.
|
||||
# See issue #696.
|
||||
log_message "App (auto-loaded by Electron): $app_path"
|
||||
|
||||
# Change to HOME directory before exec'ing Electron to avoid CWD permission issues
|
||||
cd "$HOME" || exit 1
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep AppRun alive so explicit quit can clean up
|
||||
# Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: $electron_exec ${electron_args[*]} $*"
|
||||
exec "$electron_exec" "${electron_args[@]}" "$@" >> "$log_file" 2>&1
|
||||
run_electron_and_cleanup "$electron_exec" "${electron_args[@]}" "$@"
|
||||
exit $?
|
||||
EOF
|
||||
chmod +x "$appdir_path/AppRun" || exit 1
|
||||
echo 'AppRun script created'
|
||||
@@ -133,7 +144,7 @@ Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Comment=Claude Desktop for Linux
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
X-AppImage-Version=$version
|
||||
X-AppImage-Name=Claude Desktop
|
||||
EOF
|
||||
@@ -170,14 +181,15 @@ mkdir -p "$metadata_dir" || exit 1
|
||||
appdata_file="$metadata_dir/${component_id}.appdata.xml"
|
||||
|
||||
# Generate the AppStream XML file
|
||||
# Use MIT license based on LICENSE-MIT file in repo
|
||||
# project_license describes the app the user launches (the proprietary
|
||||
# Claude binary), not the MIT packaging scripts
|
||||
# ID follows reverse DNS convention
|
||||
cat > "$appdata_file" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>$component_id</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
<developer id="io.github.aaddrick">
|
||||
<name>aaddrick</name>
|
||||
</developer>
|
||||
@@ -268,6 +280,17 @@ if [[ -z $appimagetool_path ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Normalize AppDir permissions before squashing. The staging copy above
|
||||
# uses `cp -a`, which preserves source modes, and a restrictive build
|
||||
# umask can leave directories at 0700. mksquashfs records those verbatim,
|
||||
# so a user who later runs the AppImage can't traverse into
|
||||
# app.asar.unpacked/ — silently breaking Cowork's daemon auto-launch (the
|
||||
# fork is guarded by fs.existsSync(), false on a directory it can't read).
|
||||
# Canonical modes: dirs and already-executable files 755, the rest 644.
|
||||
echo 'Normalizing AppDir permissions...'
|
||||
find "$appdir_path" -type d -exec chmod 755 {} + || exit 1
|
||||
find "$appdir_path" -type f -exec chmod u=rwX,go=rX {} + || exit 1
|
||||
|
||||
# --- Build AppImage ---
|
||||
echo 'Building AppImage...'
|
||||
output_filename="${package_name}-${version}-${architecture}.AppImage"
|
||||
|
||||
@@ -70,6 +70,7 @@ echo 'Application files copied to Electron resources directory'
|
||||
# at runtime, so both must live in the same directory)
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cp "$(dirname "$script_dir")/launcher-common.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "$install_dir/lib/$package_name/launcher-common.sh"
|
||||
cp "$(dirname "$script_dir")/doctor.sh" "$install_dir/lib/$package_name/" || exit 1
|
||||
echo 'Shared launcher library + doctor copied'
|
||||
|
||||
@@ -84,10 +85,17 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
EOF
|
||||
echo 'Desktop entry created'
|
||||
|
||||
# --- Install AppStream metainfo (App Center / GNOME Software / KDE Discover) ---
|
||||
echo 'Installing AppStream metainfo...'
|
||||
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
|
||||
install -Dm 644 "$script_dir/$metainfo_name" \
|
||||
"$install_dir/share/metainfo/$metainfo_name" || exit 1
|
||||
echo 'AppStream metainfo installed'
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
echo 'Creating launcher script...'
|
||||
cat > "$install_dir/bin/claude-desktop" << EOF
|
||||
@@ -106,7 +114,12 @@ fi
|
||||
# Setup logging and environment
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -132,12 +145,14 @@ fi
|
||||
|
||||
# Determine Electron executable path
|
||||
electron_exec='electron'
|
||||
using_global_electron=false
|
||||
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
|
||||
if [[ -f \$local_electron_path ]]; then
|
||||
electron_exec="\$local_electron_path"
|
||||
log_message "Using local Electron: \$electron_exec"
|
||||
else
|
||||
if command -v electron &> /dev/null; then
|
||||
using_global_electron=true
|
||||
log_message "Using global Electron: \$electron_exec"
|
||||
else
|
||||
log_message 'Error: Electron executable not found'
|
||||
@@ -152,27 +167,35 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
# Build electron args
|
||||
build_electron_args 'deb'
|
||||
|
||||
# Add app path LAST
|
||||
electron_args+=("\$app_path")
|
||||
# Bundled Electron: app.asar sits in its default resources/ dir next
|
||||
# to the binary, so Electron auto-loads it. Passing the path again
|
||||
# makes Electron treat it as a file-to-open, which the app forwards
|
||||
# to its file-drop handler, producing a spurious "Attach app.asar?"
|
||||
# prompt on launch and on every taskbar reopen (the second-instance
|
||||
# argv path). Omitting it is the root-cause fix. See issue #696.
|
||||
# Global (PATH) Electron has no co-located app.asar and would boot
|
||||
# its default_app welcome screen instead — only there the explicit
|
||||
# app path is load-bearing and must stay.
|
||||
if [[ \$using_global_electron == true ]]; then
|
||||
electron_args+=("\$app_path")
|
||||
log_message "App (explicit arg, global Electron): \$app_path"
|
||||
else
|
||||
log_message "App (auto-loaded by Electron): \$app_path"
|
||||
fi
|
||||
|
||||
# Change to application directory
|
||||
app_dir="/usr/lib/$package_name"
|
||||
log_message "Changing directory to \$app_dir"
|
||||
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep the launcher alive so explicit quit can
|
||||
# clean up Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
|
||||
exit \$?
|
||||
EOF
|
||||
chmod +x "$install_dir/bin/claude-desktop" || exit 1
|
||||
echo 'Launcher script created'
|
||||
@@ -181,7 +204,9 @@ echo 'Launcher script created'
|
||||
echo 'Creating control file...'
|
||||
# Electron is bundled with its own Node.js runtime, so nodejs/npm are not
|
||||
# runtime dependencies. p7zip is only used at build time to extract the
|
||||
# installer. No external dependencies are required at runtime.
|
||||
# installer. bubblewrap is Recommended (not required): it provides the
|
||||
# default namespace-sandbox isolation for Cowork mode; the app runs without
|
||||
# it (Cowork falls back to host-direct). apt installs Recommends by default.
|
||||
|
||||
cat > "$package_root/DEBIAN/control" << EOF
|
||||
Package: $package_name
|
||||
@@ -189,6 +214,7 @@ Version: $version
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: $architecture
|
||||
Recommends: bubblewrap
|
||||
Maintainer: $maintainer
|
||||
Description: $description
|
||||
Claude is an AI assistant from Anthropic.
|
||||
@@ -206,7 +232,7 @@ set -e
|
||||
|
||||
# Update desktop database for MIME types
|
||||
echo "Updating desktop database..."
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
# Set correct permissions for chrome-sandbox if electron is installed globally
|
||||
# or locally packaged
|
||||
@@ -227,11 +253,177 @@ else
|
||||
echo "Warning: chrome-sandbox binary not found in local package at \$LOCAL_SANDBOX_PATH. Sandbox may not function correctly."
|
||||
fi
|
||||
|
||||
# --- AppArmor profile for Chromium's user-namespace sandbox ---
|
||||
# Ubuntu 24.04+ sets kernel.apparmor_restrict_unprivileged_userns=1, which
|
||||
# blocks the unprivileged user namespaces Chromium's sandbox relies on,
|
||||
# crashing the app on launch with a sandbox/.../credentials.cc FATAL.
|
||||
# Grant userns to our Electron binary via a scoped AppArmor profile, exactly
|
||||
# as the google-chrome, code, and slack packages do. Gate on the kernel knob
|
||||
# (not just apparmor_parser): only Ubuntu-family systems impose the
|
||||
# restriction, so on stock Debian/others the knob is absent and we skip the
|
||||
# profile entirely rather than installing one they never need. The knob may
|
||||
# read 0 now and flip to 1 later, so existence — not value — is the gate.
|
||||
APPARMOR_PROFILE="/etc/apparmor.d/$package_name"
|
||||
if command -v apparmor_parser >/dev/null 2>&1 \
|
||||
&& [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
echo "Configuring AppArmor profile for Chromium sandbox..."
|
||||
# Writing the profile is best-effort: a read-only or atypical /etc must
|
||||
# never abort the install (this postinst runs under set -e). Keeping the
|
||||
# grep / mkdir + heredoc in the if/elif conditions exempts them from
|
||||
# errexit. Debian Policy 10.7.3: a profile without our marker header was
|
||||
# hand-created or hand-edited by the admin — preserve it, never overwrite.
|
||||
if [ -e "\$APPARMOR_PROFILE" ] \
|
||||
&& ! grep -qF "managed by the $package_name package" \
|
||||
"\$APPARMOR_PROFILE" 2>/dev/null; then
|
||||
echo "Preserving locally modified \$APPARMOR_PROFILE (no marker header)"
|
||||
apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || true
|
||||
elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$APPARMOR_PROFILE" <<'APPARMOR_EOF'
|
||||
# This profile is managed by the $package_name package (postinst); direct
|
||||
# edits will be overwritten on upgrade. Put local changes in
|
||||
# /etc/apparmor.d/local/$package_name instead.
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile $package_name /usr/lib/$package_name/node_modules/electron/dist/electron flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/$package_name>
|
||||
}
|
||||
APPARMOR_EOF
|
||||
then
|
||||
if apparmor_parser -Q "\$APPARMOR_PROFILE" >/dev/null 2>&1; then
|
||||
apparmor_parser -r "\$APPARMOR_PROFILE" >/dev/null 2>&1 || echo "Note: AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot."
|
||||
echo "AppArmor profile installed at \$APPARMOR_PROFILE"
|
||||
else
|
||||
rm -f "\$APPARMOR_PROFILE"
|
||||
echo "AppArmor on this system does not support the userns rule; skipping profile (not required here)."
|
||||
fi
|
||||
else
|
||||
# A failed write may leave a truncated profile behind; clear it.
|
||||
# The || true is mandatory: this branch is errexit-live, and a bare
|
||||
# rm fails the upgrade on a read-only /etc.
|
||||
rm -f "\$APPARMOR_PROFILE" 2>/dev/null || true
|
||||
echo "Warning: could not write \$APPARMOR_PROFILE; skipping AppArmor profile."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- AppArmor profile for the Cowork bwrap sandbox helper ---
|
||||
# Cowork's "bwrap backend" runs the agent's Claude Code process inside a
|
||||
# bubblewrap sandbox, which itself needs unprivileged user namespaces — the
|
||||
# same thing Ubuntu 24.04+ blocks (apparmor_restrict_unprivileged_userns=1).
|
||||
# bwrap is a SEPARATE binary from the Electron app, so the claude-desktop
|
||||
# profile above (which scopes the Electron binary) does not cover it; it
|
||||
# needs its own profile on /usr/bin/bwrap. Without this, Cowork silently
|
||||
# falls back to host-direct (no isolation).
|
||||
#
|
||||
# Gate on the kernel knob, exactly like the Electron block above: only a
|
||||
# kernel that can enforce the restriction exposes the knob, and a userspace
|
||||
# parser that merely accepts the userns rule (AppArmor 4) is not
|
||||
# enforcement — without the knob the profile is dead weight on a binary
|
||||
# this package does not own. There is deliberately no [ -x /usr/bin/bwrap ]
|
||||
# gate: a profile attaching to a nonexistent binary is inert, and dpkg
|
||||
# gives Recommends no ordering edge, so gating on the binary races a
|
||||
# same-transaction bubblewrap install. Static checks only: postinst runs as
|
||||
# root, which is exempt from the unprivileged-userns restriction, so a
|
||||
# behavioral bwrap probe here would falsely pass — the behavioral probe
|
||||
# lives in 'claude-desktop --doctor' instead (runs as the user).
|
||||
BWRAP_PROFILE="/etc/apparmor.d/${package_name}-bwrap"
|
||||
if command -v apparmor_parser >/dev/null 2>&1 \
|
||||
&& [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||
echo "Configuring AppArmor profile for the Cowork bwrap sandbox..."
|
||||
# Writing the profile is best-effort: a read-only or atypical /etc must
|
||||
# never abort the install (this postinst runs under set -e). Keeping the
|
||||
# grep / mkdir + heredoc in the if/elif conditions exempts them from
|
||||
# errexit. Debian Policy 10.7.3: a profile without our marker header was
|
||||
# hand-created or hand-edited by the admin — preserve it, never overwrite.
|
||||
if [ -e "\$BWRAP_PROFILE" ] \
|
||||
&& ! grep -qF "managed by the $package_name package" \
|
||||
"\$BWRAP_PROFILE" 2>/dev/null; then
|
||||
echo "Preserving locally modified \$BWRAP_PROFILE (no marker header)"
|
||||
apparmor_parser -r "\$BWRAP_PROFILE" >/dev/null 2>&1 || true
|
||||
elif grep -rl '/usr/bin/bwrap' /etc/apparmor.d/ 2>/dev/null \
|
||||
| grep -vxF "\$BWRAP_PROFILE" | grep -q .; then
|
||||
# Another profile already attaches to /usr/bin/bwrap — a hand-made
|
||||
# /etc/apparmor.d/bwrap, apparmor-profiles' bwrap-userns-restrict,
|
||||
# or any other filename. Identical attachment strings have no
|
||||
# specificity tiebreak, and shadowing a restrictive profile with our
|
||||
# unconfined-mode one would silently undo distro hardening, so defer
|
||||
# to the existing profile. (A false grep hit in a comment fails
|
||||
# safe: we merely skip our profile.)
|
||||
echo "An existing AppArmor profile already covers /usr/bin/bwrap; leaving it in charge."
|
||||
elif mkdir -p /etc/apparmor.d 2>/dev/null && cat > "\$BWRAP_PROFILE" <<'BWRAP_APPARMOR_EOF'
|
||||
# This profile is managed by the $package_name package (postinst); direct
|
||||
# edits will be overwritten on upgrade. Put local changes in
|
||||
# /etc/apparmor.d/local/${package_name}-bwrap instead.
|
||||
abi <abi/4.0>,
|
||||
include <tunables/global>
|
||||
|
||||
profile ${package_name}-bwrap /usr/bin/bwrap flags=(unconfined) {
|
||||
userns,
|
||||
|
||||
include if exists <local/${package_name}-bwrap>
|
||||
}
|
||||
BWRAP_APPARMOR_EOF
|
||||
then
|
||||
if apparmor_parser -Q "\$BWRAP_PROFILE" >/dev/null 2>&1; then
|
||||
apparmor_parser -r "\$BWRAP_PROFILE" >/dev/null 2>&1 || echo "Note: bwrap AppArmor profile staged but not loaded now; it will apply on the next AppArmor reload or reboot."
|
||||
echo "Cowork bwrap AppArmor profile installed at \$BWRAP_PROFILE"
|
||||
else
|
||||
rm -f "\$BWRAP_PROFILE"
|
||||
echo "AppArmor on this system does not support the userns rule; skipping bwrap profile (not required here)."
|
||||
fi
|
||||
else
|
||||
# A failed write may leave a truncated profile behind; clear it.
|
||||
# The || true is mandatory: this branch is errexit-live, and a bare
|
||||
# rm fails the upgrade on a read-only /etc.
|
||||
rm -f "\$BWRAP_PROFILE" 2>/dev/null || true
|
||||
echo "Warning: could not write \$BWRAP_PROFILE; skipping bwrap AppArmor profile."
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$package_root/DEBIAN/postinst" || exit 1
|
||||
echo 'Postinst script created'
|
||||
|
||||
# --- Create Postrm Script ---
|
||||
echo 'Creating postrm script...'
|
||||
# The AppArmor profiles are generated by postinst, not tracked by dpkg, so we
|
||||
# unload and delete them ourselves. Cleanup lives in postrm (not prerm) so it
|
||||
# also fires on purge and abort-install. Skip on upgrade — the incoming
|
||||
# postinst rewrites and reloads them. 'disappear' is deliberately not handled:
|
||||
# matching it would also clean during the overwrite-by-another-package flow.
|
||||
# Two profiles: the Electron one (Chromium sandbox, #687) and the bwrap one
|
||||
# (Cowork sandbox helper, #694).
|
||||
# Per Debian Policy 10.7.3 the profiles are configuration: unload them
|
||||
# whenever the confined binaries go away, but delete the files only on
|
||||
# purge — a profile for an absent binary is a harmless no-op (google-chrome
|
||||
# leaves its profile behind the same way).
|
||||
cat > "$package_root/DEBIAN/postrm" << EOF
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "\$1" in
|
||||
remove|purge|abort-install)
|
||||
for _profile in "/etc/apparmor.d/$package_name" \
|
||||
"/etc/apparmor.d/${package_name}-bwrap"; do
|
||||
if [ -e "\$_profile" ] \
|
||||
&& command -v apparmor_parser >/dev/null 2>&1; then
|
||||
apparmor_parser -R "\$_profile" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Policy 10.7.3: config survives remove; delete on purge only.
|
||||
if [ "\$1" = purge ]; then
|
||||
rm -f "\$_profile" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$package_root/DEBIAN/postrm" || exit 1
|
||||
echo 'Postrm script created'
|
||||
|
||||
# --- Build .deb Package ---
|
||||
echo 'Building .deb package...'
|
||||
deb_file="$work_dir/${package_name}_${version}_${architecture}.deb"
|
||||
@@ -243,8 +435,27 @@ chmod 755 "$package_root/DEBIAN" || exit 1
|
||||
# Fix script permissions in DEBIAN directory
|
||||
echo 'Setting script permissions...'
|
||||
chmod 755 "$package_root/DEBIAN/postinst" || exit 1
|
||||
chmod 755 "$package_root/DEBIAN/postrm" || exit 1
|
||||
|
||||
if ! dpkg-deb --build "$package_root" "$deb_file"; then
|
||||
# Normalize the installed tree before building. A restrictive build umask
|
||||
# can leave directories at 0700, and dpkg-deb records file ownership
|
||||
# verbatim unless told otherwise. Both bite at runtime: the launcher runs
|
||||
# as the desktop user, who then can't traverse into app.asar.unpacked/ —
|
||||
# silently breaking Cowork's daemon auto-launch (the fork is guarded by
|
||||
# fs.existsSync(), which returns false on a directory it can't read, so
|
||||
# the symptom is an endless connect ENOENT on the VM-service socket with
|
||||
# no daemon log and no [cowork-autolaunch] line). Canonical modes: dirs
|
||||
# and already-executable files 755, every other file 644. The blanket
|
||||
# pass clears chrome-sandbox's setuid bit, but postinst re-asserts 4755
|
||||
# after install, so the net result is unchanged.
|
||||
echo 'Normalizing installed tree permissions...'
|
||||
find "$install_dir" -type d -exec chmod 755 {} + || exit 1
|
||||
find "$install_dir" -type f -exec chmod u=rwX,go=rX {} + || exit 1
|
||||
|
||||
# --root-owner-group forces root:root in the archive so a leaked build
|
||||
# uid can't deny access on the installed system (the build does not run
|
||||
# under fakeroot).
|
||||
if ! dpkg-deb --root-owner-group --build "$package_root" "$deb_file"; then
|
||||
echo 'Failed to build .deb package' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
AppStream metainfo for the claude-desktop package.
|
||||
Indexed by GNOME Software / Ubuntu App Center / KDE Discover under the
|
||||
Installed tab so users can see the package with name, summary, icon, and
|
||||
release history rather than as an unidentified entry.
|
||||
|
||||
See: https://www.freedesktop.org/software/appstream/docs/
|
||||
-->
|
||||
<component type="desktop-application">
|
||||
<id>io.github.aaddrick.claude-desktop-debian</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
|
||||
<name>Claude Desktop</name>
|
||||
<summary>Unofficial desktop client for Claude AI</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Claude Desktop is an unofficial community repackaging of Anthropic's
|
||||
Claude Desktop client for Debian and Ubuntu. The upstream Windows
|
||||
binary is repacked and patched for Linux compatibility (frame, tray,
|
||||
Cowork mode, MCP stdio, Quick Entry, etc.).
|
||||
</p>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>Conversations with the Claude model family (Sonnet, Opus, Haiku)</li>
|
||||
<li>Projects with persistent context and file uploads</li>
|
||||
<li>Cowork mode — local agent VM for sandboxed code tasks</li>
|
||||
<li>MCP (Model Context Protocol) stdio servers for tool integration</li>
|
||||
<li>System tray, Quick Entry hotkey, and tab system</li>
|
||||
</ul>
|
||||
<p>
|
||||
This packaging is community-maintained and is not affiliated with or
|
||||
endorsed by Anthropic. See the packaging source and issue tracker
|
||||
linked below.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">claude-desktop.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/aaddrick/claude-desktop-debian</url>
|
||||
<url type="bugtracker">https://github.com/aaddrick/claude-desktop-debian/issues</url>
|
||||
<url type="vcs-browser">https://github.com/aaddrick/claude-desktop-debian</url>
|
||||
|
||||
<developer id="io.github.aaddrick">
|
||||
<name>aaddrick</name>
|
||||
</developer>
|
||||
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<provides>
|
||||
<binary>claude-desktop</binary>
|
||||
</provides>
|
||||
</component>
|
||||
@@ -68,9 +68,13 @@ Type=Application
|
||||
Terminal=false
|
||||
Categories=Office;Utility;
|
||||
MimeType=x-scheme-handler/claude;
|
||||
StartupWMClass=Claude
|
||||
StartupWMClass=$WM_CLASS
|
||||
EOF
|
||||
|
||||
# --- Stage AppStream metainfo (installed via %files block below) ---
|
||||
metainfo_name='io.github.aaddrick.claude-desktop-debian.metainfo.xml'
|
||||
cp "$script_dir/$metainfo_name" "$staging_dir/$metainfo_name" || exit 1
|
||||
|
||||
# --- Create Launcher Script ---
|
||||
echo 'Creating launcher script...'
|
||||
cat > "$staging_dir/claude-desktop" << EOF
|
||||
@@ -89,7 +93,12 @@ fi
|
||||
# Setup logging and environment
|
||||
setup_logging || exit 1
|
||||
setup_electron_env
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
cleanup_orphaned_cowork_daemon
|
||||
cleanup_stale_desktop_helpers
|
||||
cleanup_stale_lock
|
||||
cleanup_stale_cowork_socket
|
||||
|
||||
@@ -115,12 +124,14 @@ fi
|
||||
|
||||
# Determine Electron executable path
|
||||
electron_exec='electron'
|
||||
using_global_electron=false
|
||||
local_electron_path="/usr/lib/$package_name/node_modules/electron/dist/electron"
|
||||
if [[ -f \$local_electron_path ]]; then
|
||||
electron_exec="\$local_electron_path"
|
||||
log_message "Using local Electron: \$electron_exec"
|
||||
else
|
||||
if command -v electron &> /dev/null; then
|
||||
using_global_electron=true
|
||||
log_message "Using global Electron: \$electron_exec"
|
||||
else
|
||||
log_message 'Error: Electron executable not found'
|
||||
@@ -135,27 +146,35 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# App path
|
||||
app_path="/usr/lib/$package_name/node_modules/electron/dist/resources/app.asar"
|
||||
|
||||
# Build electron args - use 'deb' type (same sandbox behavior)
|
||||
build_electron_args 'deb'
|
||||
|
||||
# Add app path LAST
|
||||
electron_args+=("\$app_path")
|
||||
# Bundled Electron: app.asar sits in its default resources/ dir next
|
||||
# to the binary, so Electron auto-loads it. Passing the path again
|
||||
# makes Electron treat it as a file-to-open, which the app forwards
|
||||
# to its file-drop handler, producing a spurious "Attach app.asar?"
|
||||
# prompt on launch and on every taskbar reopen (the second-instance
|
||||
# argv path). Omitting it is the root-cause fix. See issue #696.
|
||||
# Global (PATH) Electron has no co-located app.asar and would boot
|
||||
# its default_app welcome screen instead — only there the explicit
|
||||
# app path is load-bearing and must stay.
|
||||
if [[ \$using_global_electron == true ]]; then
|
||||
electron_args+=("\$app_path")
|
||||
log_message "App (explicit arg, global Electron): \$app_path"
|
||||
else
|
||||
log_message "App (auto-loaded by Electron): \$app_path"
|
||||
fi
|
||||
|
||||
# Change to application directory
|
||||
app_dir="/usr/lib/$package_name"
|
||||
log_message "Changing directory to \$app_dir"
|
||||
cd "\$app_dir" || { log_message "Failed to cd to \$app_dir"; exit 1; }
|
||||
|
||||
# Execute Electron
|
||||
# Execute Electron and keep the launcher alive so explicit quit can
|
||||
# clean up Desktop-owned helpers that outlive the Electron main process.
|
||||
log_message "Executing: \$electron_exec \${electron_args[*]} \$*"
|
||||
"\$electron_exec" "\${electron_args[@]}" "\$@" >> "\$log_file" 2>&1
|
||||
exit_code=\$?
|
||||
log_message "Electron exited with code: \$exit_code"
|
||||
log_message '--- Claude Desktop Launcher End ---'
|
||||
exit \$exit_code
|
||||
run_electron_and_cleanup "\$electron_exec" "\${electron_args[@]}" "\$@"
|
||||
exit \$?
|
||||
EOF
|
||||
chmod +x "$staging_dir/claude-desktop"
|
||||
|
||||
@@ -221,14 +240,25 @@ cp -r $app_staging_dir/app.asar.unpacked %{buildroot}/usr/lib/$package_name/node
|
||||
# Copy shared launcher library (launcher-common.sh sources doctor.sh
|
||||
# at runtime, so both must live in the same directory)
|
||||
cp $(dirname "$script_dir")/launcher-common.sh %{buildroot}/usr/lib/$package_name/
|
||||
sed -i "s/@@WM_CLASS@@/$WM_CLASS/" "%{buildroot}/usr/lib/$package_name/launcher-common.sh"
|
||||
cp $(dirname "$script_dir")/doctor.sh %{buildroot}/usr/lib/$package_name/
|
||||
|
||||
# Install desktop entry
|
||||
install -Dm 644 $staging_dir/claude-desktop.desktop %{buildroot}/usr/share/applications/claude-desktop.desktop
|
||||
|
||||
# Install AppStream metainfo (GNOME Software / KDE Discover)
|
||||
install -Dm 644 $staging_dir/$metainfo_name %{buildroot}/usr/share/metainfo/$metainfo_name
|
||||
|
||||
# Install launcher script
|
||||
install -Dm 755 $staging_dir/claude-desktop %{buildroot}/usr/bin/claude-desktop
|
||||
|
||||
# Normalize file modes — the cp -r above honors the build umask, and
|
||||
# the "-" first field of %defattr ships buildroot *file* modes verbatim
|
||||
# (only directory modes are forced to 0755), so a umask-077 build would
|
||||
# package an unreadable app.asar and a non-executable electron binary.
|
||||
# Must run before the chrome-sandbox chmod below so 4755 survives.
|
||||
find %{buildroot}/usr/lib/$package_name -type f -exec chmod u=rwX,go=rX {} +
|
||||
|
||||
# Set the chrome-sandbox suid bit in the buildroot so the /usr/lib
|
||||
# directory walk in %files records 4755 in the payload (preserves #539
|
||||
# without the "File listed twice" warning #609 — see %files block).
|
||||
@@ -236,17 +266,18 @@ chmod 4755 %{buildroot}/usr/lib/$package_name/node_modules/electron/dist/chrome-
|
||||
|
||||
%post
|
||||
# Update desktop database for MIME types
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
%postun
|
||||
# Update desktop database after removal
|
||||
update-desktop-database /usr/share/applications &> /dev/null || true
|
||||
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
|
||||
|
||||
%files
|
||||
%defattr(-, root, root, 0755)
|
||||
%attr(755, root, root) /usr/bin/claude-desktop
|
||||
/usr/lib/$package_name
|
||||
/usr/share/applications/claude-desktop.desktop
|
||||
/usr/share/metainfo/$metainfo_name
|
||||
/usr/share/icons/hicolor/*/apps/claude-desktop.png
|
||||
SPECEOF
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -53,6 +53,17 @@ 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
|
||||
|
||||
# Create stub native module
|
||||
echo 'Creating stub native module...'
|
||||
mkdir -p app.asar.contents/node_modules/@ant/claude-native || exit 1
|
||||
@@ -92,9 +103,22 @@ console.log('Updated package.json: main entry, desktopName, and node-pty depende
|
||||
# Add Linux Claude Code support
|
||||
patch_linux_claude_code
|
||||
|
||||
# Reject .asar paths in the directory-check helper so Electron's
|
||||
# ASAR VFS shim doesn't misidentify app.asar as a folder and
|
||||
# trigger false Cowork dispatch (#383, #622, #632).
|
||||
patch_asar_path_filter
|
||||
|
||||
# Reject .asar paths in the argv file-drop collector so the
|
||||
# existsSync branch doesn't dispatch app.asar as a file drop,
|
||||
# triggering a permission prompt on every window reopen (#383, #622).
|
||||
patch_asar_argv_file_drop_guard
|
||||
|
||||
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
|
||||
patch_cowork_linux
|
||||
|
||||
# Add Linux org-plugins path for MDM-managed plugin marketplace
|
||||
patch_org_plugins_path
|
||||
|
||||
# Inject WCO shim into the BrowserView preload so claude.ai's
|
||||
# desktop topbar renders on Linux. The shim spoofs the bundle's
|
||||
# isWindows() UA check (load-bearing) plus matchMedia and
|
||||
@@ -102,6 +126,17 @@ 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\(process\.platform==="win32"\)return \w+==="arm64"\?"win32-arm64":"win32-x64";throw' "$index_js"; then
|
||||
sed -i -E 's/if\(process\.platform==="win32"\)return (\w+)==="arm64"\?"win32-arm64":"win32-x64";throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
if grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+[$\w]+\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s+([[:alnum:]_$]+)\s*===\s*"arm64"\s*\?\s*"win32-arm64"\s*:\s*"win32-x64"\s*;\s*throw/if(process.platform==="win32")return \1==="arm64"?"win32-arm64":"win32-x64";if(process.platform==="linux")return \1==="arm64"?"linux-arm64":"linux-x64";throw/' "$index_js"
|
||||
echo 'Added linux claude code support (new arch-aware format)'
|
||||
# Old format (Claude <= 1.1.3363): no arch detection for win32
|
||||
elif grep -q 'if(process.platform==="win32")return"win32-x64";' "$index_js"; then
|
||||
sed -i 's/if(process.platform==="win32")return"win32-x64";/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
elif grep -qP 'if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;' "$index_js"; then
|
||||
sed -i -E 's/if\s*\(\s*process\.platform\s*===\s*"win32"\s*\)\s*return\s*"win32-x64"\s*;/if(process.platform==="win32")return"win32-x64";if(process.platform==="linux")return process.arch==="arm64"?"linux-arm64":"linux-x64";/' "$index_js"
|
||||
echo 'Added linux claude code support (legacy format)'
|
||||
else
|
||||
echo 'Warning: Could not find getHostPlatform pattern to patch for Linux claude code support'
|
||||
|
||||
296
scripts/patches/config.sh
Normal file
296
scripts/patches/config.sh
Normal file
@@ -0,0 +1,296 @@
|
||||
#===============================================================================
|
||||
# Config-related patches: preserve externally-added mcpServers across config
|
||||
# writes, guard addTrustedFolder against .asar paths, and filter .asar entries
|
||||
# from the --add-dir CLI dispatch and session restore.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: project_root
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_config_write_merge() {
|
||||
echo 'Patching config writer to preserve mcpServers from disk...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -q '_cdd_dc' "$index_js"; then
|
||||
echo ' mcpServers merge already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract variable names from the unique anchor:
|
||||
# await WRITE_FN(PATH_VAR, CONFIG_VAR), LOGGER.info("Config file written")
|
||||
local write_fn path_var config_var write_fn_re path_var_re
|
||||
|
||||
write_fn=$(grep -oP \
|
||||
'await \K[$\w]+(?=\([$\w]+,\s*[$\w]+\)\s*,\s*[$\w]+\.info\("Config file written"\))' \
|
||||
"$index_js")
|
||||
if [[ -z $write_fn ]]; then
|
||||
echo ' Could not extract write function name — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
write_fn_re="${write_fn//\$/\\$}"
|
||||
|
||||
path_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(\\K[\$\\w]+(?=,\\s*[\$\\w]+\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract path variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
path_var_re="${path_var//\$/\\$}"
|
||||
|
||||
config_var=$(grep -oP \
|
||||
"await ${write_fn_re}\\(${path_var_re},\\s*\\K[\$\\w]+(?=\\)\\s*,\\s*[\$\\w]+\\.info\\(\"Config file written\"\\))" \
|
||||
"$index_js")
|
||||
if [[ -z $config_var ]]; then
|
||||
echo ' Could not extract config variable — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
echo " Write fn: $write_fn, path: $path_var, config: $config_var"
|
||||
|
||||
if ! WRITE_FN="$write_fn" PATH_VAR="$path_var" CFG_VAR="$config_var" \
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const W = process.env.WRITE_FN;
|
||||
const P = process.env.PATH_VAR;
|
||||
const C = process.env.CFG_VAR;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const reEsc = (s) => s.replace(/[.*+?\${}()|[\\]\\\\]/g, '\\\\\$&');
|
||||
const anchor = new RegExp(
|
||||
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
|
||||
'\\\\)\\\\s*,\\\\s*\\\\w+\\\\.info\\\\(\"Config file written\"\\\\)'
|
||||
);
|
||||
if (!anchor.test(code)) {
|
||||
console.error(' [FAIL] Config-write anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const merge =
|
||||
'try{var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(' + P +
|
||||
',\"utf8\"));if(_cdd_dc.mcpServers){' + C +
|
||||
'.mcpServers=Object.assign({},_cdd_dc.mcpServers,' + C +
|
||||
'.mcpServers||{})}}catch(_cdd_ex){}';
|
||||
|
||||
code = code.replace(anchor, (m) => merge + ';' + m);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] mcpServers merge injected before config write');
|
||||
"; then
|
||||
echo 'Failed to inject config write merge' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_asar_trusted_folder_guard() {
|
||||
echo 'Patching addTrustedFolder to reject .asar paths...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency guard
|
||||
if grep -qF 'endsWith(".asar"))return' "$index_js"; then
|
||||
echo ' .asar guard already present (idempotent)'
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
|
||||
# Anchor on the method declaration itself — the method name
|
||||
# `addTrustedFolder` is not minified and is unique in the bundle.
|
||||
# Earlier releases let us anchor on the trailing `${param}`);` of the
|
||||
# log line, but upstream now folds that log call into the comma
|
||||
# expression `if(D.info(`…${i}`),await ZOe(i)===null){…}`, so the
|
||||
# `);` no longer exists. Injecting at the function body head is both
|
||||
# more robust and semantically earlier (reject .asar on entry).
|
||||
local folder_param
|
||||
folder_param=$(grep -oP \
|
||||
'async addTrustedFolder\(\K[$\w]+(?=\)\{)' \
|
||||
"$index_js")
|
||||
if [[ -z $folder_param ]]; then
|
||||
echo ' Could not extract folder parameter — skipping' >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
echo " Found folder parameter: $folder_param"
|
||||
|
||||
if ! FOLDER_PARAM="$folder_param" node -e "
|
||||
const fs = require('fs');
|
||||
const p = 'app.asar.contents/.vite/build/index.js';
|
||||
const F = process.env.FOLDER_PARAM;
|
||||
let code = fs.readFileSync(p, 'utf8');
|
||||
|
||||
const anchor = 'async addTrustedFolder(' + F + '){';
|
||||
const idx = code.indexOf(anchor);
|
||||
if (idx === -1) {
|
||||
console.error(' [FAIL] addTrustedFolder anchor not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const insertPoint = idx + anchor.length;
|
||||
const guard = 'if(' + F + '.endsWith(\".asar\"))return;';
|
||||
code = code.slice(0, insertPoint) + guard + code.slice(insertPoint);
|
||||
fs.writeFileSync(p, code);
|
||||
console.log(' [OK] .asar guard injected in addTrustedFolder');
|
||||
"; then
|
||||
echo 'Failed to inject .asar trusted folder guard' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch: filter .asar paths from --add-dir CLI dispatch and session restore
|
||||
#
|
||||
# PR #640 guards the directory-check helper and addTrustedFolder IPC
|
||||
# handler, but .asar paths in corrupted pre-#640 sessions survive
|
||||
# restore (existsSync passes via Electron's ASAR VFS shim) and reach
|
||||
# additionalDirectories -> --add-dir -> fatal Claude Code error.
|
||||
#
|
||||
# Fix: two sub-patches:
|
||||
# 1. Filter at the --add-dir CLI dispatch loop (the single convergence
|
||||
# point for ALL code paths that feed additionalDirectories).
|
||||
# 2. Filter at session restore to self-heal corrupted persisted state.
|
||||
# ---------------------------------------------------------------------------
|
||||
patch_asar_additional_dirs_guard() {
|
||||
echo 'Patching --add-dir dispatch to reject .asar paths (#649)...'
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
if ! INDEX_JS="$index_js" node << 'ASAR_ADDDIR_PATCH'
|
||||
const fs = require('fs');
|
||||
const indexJs = process.env.INDEX_JS;
|
||||
let code = fs.readFileSync(indexJs, 'utf8');
|
||||
let patchCount = 0;
|
||||
let dispatchPatchCount = 0;
|
||||
let dispatchAlreadyPresent = code.includes(
|
||||
'.filter(_d=>!_d.endsWith(".asar"))'
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 1: Filter .asar from --add-dir loop
|
||||
//
|
||||
// Targets (one or more occurrences):
|
||||
// for (let O of A) Y.push("--add-dir", O);
|
||||
// Fallback (if minifier uses .forEach):
|
||||
// A.forEach(O=>Y.push("--add-dir",O))
|
||||
// ================================================================
|
||||
{
|
||||
// Primary: for...of pattern
|
||||
const forOfRe = /for\s*\(\s*let\s+([\w$]+)\s+of\s+([\w$]+)\s*\)\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\1\s*\)/g;
|
||||
// Fallback: .forEach pattern
|
||||
const forEachRe = /([\w$]+)\.forEach\(\s*([\w$]+)\s*=>\s*([\w$]+)\.push\(\s*"--add-dir"\s*,\s*\2\s*\)\s*\)/g;
|
||||
|
||||
let forOfCount = 0;
|
||||
let forEachCount = 0;
|
||||
code = code.replace(forOfRe, (match, iterVar, arrVar, pushTarget) => {
|
||||
forOfCount++;
|
||||
dispatchPatchCount++;
|
||||
patchCount++;
|
||||
return 'for(let ' + iterVar + ' of ' + arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")))' +
|
||||
pushTarget + '.push("--add-dir",' + iterVar + ')';
|
||||
});
|
||||
code = code.replace(forEachRe, (match, arrVar, iterVar, pushTarget) => {
|
||||
forEachCount++;
|
||||
dispatchPatchCount++;
|
||||
patchCount++;
|
||||
return arrVar +
|
||||
'.filter(_d=>!_d.endsWith(".asar")).forEach(' +
|
||||
iterVar + '=>' + pushTarget +
|
||||
'.push("--add-dir",' + iterVar + '))';
|
||||
});
|
||||
|
||||
if (dispatchPatchCount === 0 && !dispatchAlreadyPresent) {
|
||||
console.error('FATAL: --add-dir dispatch loop not found.');
|
||||
console.error(' for(let X of Y) Z.push("--add-dir", X)');
|
||||
console.error(' Y.forEach(X=>Z.push("--add-dir", X))');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (dispatchPatchCount > 0) {
|
||||
console.log(' Filtered ' + dispatchPatchCount +
|
||||
' --add-dir dispatch loop(s) (for-of=' + forOfCount +
|
||||
', forEach=' + forEachCount + ')');
|
||||
} else {
|
||||
console.log(' .asar --add-dir filter already present ' +
|
||||
'(idempotent)');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Sub-patch 2: Filter .asar from session restore
|
||||
//
|
||||
// Anchor: "Filtering out deleted folder from session" (unique)
|
||||
// Target: (VAR.userSelectedFolders||[]).filter(
|
||||
// Insert: .filter(l=>!l.endsWith(".asar")) before existing .filter(
|
||||
// ================================================================
|
||||
{
|
||||
const warn = (msg) => console.log(' WARNING: ' + msg +
|
||||
' (primary --add-dir filter still protects)');
|
||||
|
||||
const anchorIdx = code.indexOf(
|
||||
'Filtering out deleted folder from session');
|
||||
if (anchorIdx === -1) {
|
||||
warn('session restore anchor not found');
|
||||
} else {
|
||||
const searchStart = Math.max(0, anchorIdx - 500);
|
||||
const region = code.substring(searchStart, anchorIdx);
|
||||
const usIdx = region.lastIndexOf('userSelectedFolders');
|
||||
if (usIdx === -1) {
|
||||
warn('userSelectedFolders not found near anchor');
|
||||
} else {
|
||||
const absUsIdx = searchStart + usIdx;
|
||||
const afterUs = code.substring(absUsIdx, anchorIdx);
|
||||
const bracketMatch = afterUs.match(/\|\|\s*\[\s*\]\s*\)/);
|
||||
if (!bracketMatch) {
|
||||
warn('||[]) pattern not found');
|
||||
} else {
|
||||
const insertAt = absUsIdx + bracketMatch.index +
|
||||
bracketMatch[0].length;
|
||||
const peek = code.substring(insertAt, insertAt + 20);
|
||||
if (!peek.match(/^\s*\.filter\s*\(/)) {
|
||||
warn('.filter( not found after ||[])');
|
||||
} else if (code.substring(
|
||||
insertAt - 50, insertAt + 50
|
||||
).includes('!l.endsWith(".asar")')) {
|
||||
console.log(' Session restore filter ' +
|
||||
'already present');
|
||||
} else {
|
||||
code = code.substring(0, insertAt) +
|
||||
'.filter(l=>!l.endsWith(".asar"))' +
|
||||
code.substring(insertAt);
|
||||
console.log(' Injected .asar filter in ' +
|
||||
'session restore');
|
||||
patchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(indexJs, code);
|
||||
console.log(' Applied ' + patchCount +
|
||||
' .asar additionalDirectories patch(es)');
|
||||
if (dispatchPatchCount < 1 && !dispatchAlreadyPresent) {
|
||||
console.error('FATAL: --add-dir filter must succeed (#649).');
|
||||
process.exit(1);
|
||||
}
|
||||
ASAR_ADDDIR_PATCH
|
||||
then
|
||||
echo 'FATAL: .asar --add-dir filter patch failed' >&2
|
||||
echo 'Local agent mode will crash without this patch (#649).' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '##############################################################'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
57
scripts/patches/org-plugins.sh
Normal file
57
scripts/patches/org-plugins.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#===============================================================================
|
||||
# Linux org-plugins path: inject a case"linux" into the platform switch
|
||||
# that resolves the org-plugins source directory.
|
||||
#
|
||||
# Upstream only has cases for darwin and win32; the default returns null,
|
||||
# silently disabling the entire org-plugins marketplace feature on Linux.
|
||||
# This adds: case"linux":return"/etc/claude/org-plugins"
|
||||
#
|
||||
# /etc/claude/org-plugins is FHS-correct for MDM-managed configuration,
|
||||
# consistent with Claude Code's /etc/claude-code/ path.
|
||||
#
|
||||
# Sourced by: build.sh
|
||||
# Sourced globals: (none)
|
||||
# Modifies globals: (none)
|
||||
#===============================================================================
|
||||
|
||||
patch_org_plugins_path() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
# Idempotency: skip if a Linux case already exists near the
|
||||
# org-plugins path resolver (upstream may add one in the future).
|
||||
if grep -q 'case"linux":return"/etc/claude/org-plugins"' \
|
||||
"$index_js"; then
|
||||
echo 'Linux org-plugins path already present'
|
||||
return
|
||||
fi
|
||||
|
||||
# Anchor: the darwin path string is unique in the entire bundle.
|
||||
# Verify it exists before attempting the patch.
|
||||
local anchor='Application Support/Claude/org-plugins'
|
||||
if ! grep -q "$anchor" "$index_js"; then
|
||||
echo 'Warning: org-plugins path resolver not found' \
|
||||
'in this version, skipping' >&2
|
||||
return
|
||||
fi
|
||||
|
||||
# Pattern (minified):
|
||||
# ..."org-plugins");default:return null}
|
||||
#
|
||||
# The compound anchor — "org-plugins") immediately before
|
||||
# default:return null — is unique to this switch statement.
|
||||
# Insert case"linux":return"/etc/claude/org-plugins"; between
|
||||
# the end of the win32 case and the default case.
|
||||
#
|
||||
# \s* between tokens handles any future whitespace variation,
|
||||
# though the target file is always minified in practice.
|
||||
if grep -qP '"org-plugins"\)\s*;\s*default\s*:\s*return\s+null' \
|
||||
"$index_js"; then
|
||||
sed -i -E \
|
||||
's/("org-plugins"\)\s*;\s*)(default\s*:\s*return\s+null)/\1case"linux":return"\/etc\/claude\/org-plugins";\2/' \
|
||||
"$index_js"
|
||||
echo 'Added Linux org-plugins path (/etc/claude/org-plugins)'
|
||||
else
|
||||
echo 'Warning: org-plugins switch pattern not matched,' \
|
||||
'skipping' >&2
|
||||
fi
|
||||
}
|
||||
@@ -14,7 +14,7 @@ patch_quick_window() {
|
||||
# Extract the quick window variable name from the unique "pop-up-menu"
|
||||
# setAlwaysOnTop call, e.g.: Sa.setAlwaysOnTop(!0,"pop-up-menu")
|
||||
local quick_var
|
||||
quick_var=$(grep -oP '\w+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
quick_var=$(grep -oP '[$\w]+(?=\.setAlwaysOnTop\(\s*!0\s*,\s*"pop-up-menu"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $quick_var ]]; then
|
||||
echo 'WARNING: Could not extract quick window variable name'
|
||||
@@ -35,9 +35,9 @@ patch_quick_window() {
|
||||
de_check+='.toLowerCase().includes("kde")'
|
||||
if grep -qF "${quick_var}.blur(),${quick_var}.hide()" "$index_js"; then
|
||||
echo ' Quick window blur already patched'
|
||||
elif grep -qP "\|\|${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i \
|
||||
"s/||${quick_var_re}\.hide()/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
elif grep -qP "\|\|\s*${quick_var_re}\.hide\(\)" "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/\|\|\s*${quick_var_re}\.hide\(\)/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
|
||||
"$index_js"
|
||||
echo ' Added KDE-gated blur() before hide() on quick window'
|
||||
else
|
||||
@@ -57,11 +57,11 @@ let patchCount = 0;
|
||||
|
||||
// Find the minified isWindowFocused function via its named property
|
||||
// export: isWindowFocused: () => !!NAME()
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!(\w+)\(\)/;
|
||||
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!([\w$]+)\(\)/;
|
||||
const focusedMatch = code.match(focusedPropRe);
|
||||
if (!focusedMatch) {
|
||||
console.log(' WARNING: Could not find isWindowFocused function');
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
const focusFn = focusedMatch[1];
|
||||
console.log(' Found focus check function: ' + focusFn);
|
||||
@@ -74,12 +74,12 @@ console.log(' Found focus check function: ' + focusFn);
|
||||
// group keeps the prefix optional in either case.
|
||||
const focusFnIdx = code.indexOf('function ' + focusFn + '(');
|
||||
const nearbyCode = code.substring(focusFnIdx, focusFnIdx + 500);
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var \w+(?:,\w+)*;)?return!\w+\|\|\w+\.isDestroyed\(\)\?!1:\w+\.isVisible\(\)/;
|
||||
const visFnRe = /function (\w+)\(\)\{(?:var [\w$]+(?:,[\w$]+)*;)?return![\w$]+\|\|[\w$]+\.isDestroyed\(\)\?!1:[\w$]+\.isVisible\(\)/;
|
||||
const visMatch = nearbyCode.match(visFnRe);
|
||||
if (!visMatch) {
|
||||
console.log(' WARNING: Could not find visibility function near ' +
|
||||
focusFn);
|
||||
process.exit(0);
|
||||
process.exit(1);
|
||||
}
|
||||
const visFn = visMatch[1];
|
||||
console.log(' Found visibility check function: ' + visFn);
|
||||
@@ -106,7 +106,7 @@ for (const anchor of anchors) {
|
||||
}
|
||||
// matches: <focusFn>()||(someVar).show()
|
||||
const showRe = new RegExp(
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|(\w+)\.show\(\)`
|
||||
escapeRegExp(focusFn) + String.raw`\(\)\|\|([\w$]+)\.show\(\)`
|
||||
);
|
||||
const showMatch = region.match(showRe);
|
||||
if (showMatch) {
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 first_const
|
||||
local tray_func tray_func_re tray_var
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
@@ -26,8 +26,7 @@ patch_tray_menu_handler() {
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
|
||||
tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"$index_js")
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
if [[ -z $tray_var ]]; then
|
||||
echo 'Failed to extract tray variable name' >&2
|
||||
cd "$project_root" || exit 1
|
||||
@@ -40,50 +39,35 @@ patch_tray_menu_handler() {
|
||||
# `async async function`, which then breaks downstream patches that
|
||||
# match `(?:async )?function NAME`.
|
||||
if ! grep -q "async function ${tray_func}(){" "$index_js"; then
|
||||
sed -i "s/function ${tray_func}(){/async function ${tray_func}(){/g" \
|
||||
sed -i -E "s/function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){/g" \
|
||||
"$index_js"
|
||||
fi
|
||||
|
||||
first_const=$(grep -oP \
|
||||
"async function ${tray_func_re}\(\)\{.*?const \K\w+(?==)" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $first_const ]]; then
|
||||
echo 'Failed to extract first const in function' >&2
|
||||
cd "$project_root" || exit 1
|
||||
exit 1
|
||||
fi
|
||||
echo " Found first const variable: $first_const"
|
||||
|
||||
# Add mutex guard to prevent concurrent tray rebuilds
|
||||
# Trailing-edge mutex guard. Still prevents concurrent/reentrant
|
||||
# rebuilds (the slow path's 250ms DBus await can interleave), but —
|
||||
# unlike a plain leading-edge drop — it remembers a request that
|
||||
# arrives while a rebuild is in flight and re-runs once when the
|
||||
# window clears, so the FINAL nativeTheme value wins. At startup
|
||||
# shouldUseDarkColors reads false for ~50ms, then a burst of
|
||||
# "updated" events flips it true; a dropping mutex latches the
|
||||
# initial (wrong) value and leaves the tray icon stuck black on a
|
||||
# dark panel. See docs/learnings/tray-rebuild-race.md.
|
||||
if ! grep -q "${tray_func}._running" "$index_js"; then
|
||||
sed -i "s/async function ${tray_func}(){/async function ${tray_func}(){if(${tray_func}._running)return;${tray_func}._running=true;setTimeout(()=>${tray_func}._running=false,1500);/g" \
|
||||
sed -i -E "s/async\s+function\s+${tray_func_re}\s*\(\s*\)\s*\{/async function ${tray_func}(){if(${tray_func}._running){${tray_func}._pending=true;return}${tray_func}._running=true;setTimeout(()=>{${tray_func}._running=false;if(${tray_func}._pending){${tray_func}._pending=false;${tray_func}()}},1500);/g" \
|
||||
"$index_js"
|
||||
echo " Added mutex guard to ${tray_func}()"
|
||||
echo " Added trailing-edge mutex guard to ${tray_func}()"
|
||||
fi
|
||||
|
||||
# Add DBus cleanup delay after tray destroy
|
||||
if ! grep -q "await new Promise.*setTimeout" "$index_js" \
|
||||
| grep -q "$tray_var"; then
|
||||
sed -i "s/${tray_var}\&\&(${tray_var}\.destroy(),${tray_var}=null)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
tray_var_re="${tray_var//\$/\\$}"
|
||||
if ! grep -q "await new Promise.*setTimeout.*${tray_var_re}" "$index_js"; then
|
||||
sed -i -E "s/${tray_var_re}\s*\&\&\s*\(\s*${tray_var_re}\.destroy\(\)\s*,\s*${tray_var_re}\s*=\s*null\s*\)/${tray_var}\&\&(${tray_var}.destroy(),${tray_var}=null,await new Promise(r=>setTimeout(r,250)))/g" \
|
||||
"$index_js"
|
||||
echo " Added DBus cleanup delay after $tray_var.destroy()"
|
||||
fi
|
||||
|
||||
echo 'Tray menu handler patched'
|
||||
echo '##############################################################'
|
||||
|
||||
# Skip tray updates during startup (3 second window)
|
||||
echo 'Patching nativeTheme handler for startup delay...'
|
||||
if ! grep -q '_trayStartTime' "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/(${electron_var_re}\.nativeTheme\.on\(\s*\"updated\"\s*,\s*\(\)\s*=>\s*\{)/let _trayStartTime=Date.now();\1/g" \
|
||||
"$index_js"
|
||||
sed -i -E \
|
||||
"s/\((\w+\([^)]*\))\s*,\s*${tray_func_re}\(\)\s*,/(\1,Date.now()-_trayStartTime>3e3\&\&${tray_func}(),/g" \
|
||||
"$index_js"
|
||||
echo ' Added startup delay check (3 second window)'
|
||||
fi
|
||||
echo '##############################################################'
|
||||
}
|
||||
|
||||
patch_tray_icon_selection() {
|
||||
@@ -91,9 +75,9 @@ patch_tray_icon_selection() {
|
||||
local index_js='app.asar.contents/.vite/build/index.js'
|
||||
local dark_check="${electron_var_re}.nativeTheme.shouldUseDarkColors"
|
||||
|
||||
if grep -qP ':\$?\w+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
if grep -qP ':[$\w]+="TrayIconTemplate\.png"' "$index_js"; then
|
||||
sed -i -E \
|
||||
"s/:(\\\$?\w+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"s/:([[:alnum:]_\$]+)=\"TrayIconTemplate\.png\"/:\1=${dark_check}?\"TrayIconTemplate-Dark.png\":\"TrayIconTemplate.png\"/g" \
|
||||
"$index_js"
|
||||
echo 'Patched tray icon selection for Linux theme support'
|
||||
else
|
||||
@@ -109,7 +93,7 @@ patch_tray_inplace_update() {
|
||||
# Re-extract the tray variable name — `patch_tray_menu_handler`
|
||||
# declares it `local` so it's not visible here. Same grep pattern.
|
||||
local tray_func tray_func_re local_tray_var tray_var_re
|
||||
local menu_func path_var enabled_var enabled_count
|
||||
local menu_func menu_var menu_var_re path_var enabled_var enabled_count
|
||||
tray_func=$(grep -oP \
|
||||
'on\("menuBarEnabled",\(\)=>\{\K[\w$]+(?=\(\)\})' "$index_js")
|
||||
if [[ -z $tray_func ]]; then
|
||||
@@ -120,8 +104,7 @@ patch_tray_inplace_update() {
|
||||
# Escape `$` for PCRE patterns; matches the `tray_var_re` trick below.
|
||||
tray_func_re="${tray_func//\$/\\$}"
|
||||
local_tray_var=$(grep -oP \
|
||||
"\}\);let \K\w+(?==null;(?:async )?function ${tray_func_re})" \
|
||||
"$index_js")
|
||||
'[$\w]+(?=\s*=\s*new\s+[$\w]+\.Tray\()' "$index_js" | head -1)
|
||||
if [[ -z $local_tray_var ]]; then
|
||||
echo ' Could not extract tray variable name — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -131,10 +114,38 @@ patch_tray_inplace_update() {
|
||||
|
||||
tray_var_re="${local_tray_var//\$/\\$}"
|
||||
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K\w+(?=\(\))" \
|
||||
# Two upstream shapes wire the context menu differently:
|
||||
# old: ${tray_var}.setContextMenu(BUILDER()) — builder called inline
|
||||
# new: M=BUILDER(); ${tray_var}.setContextMenu(M) — prebuilt menu object
|
||||
# Resolve the BUILDER name in both. The injected fast-path emits
|
||||
# setContextMenu(BUILDER()), so landing on the menu *object* (M) instead
|
||||
# of its builder would emit setContextMenu(M()) and throw at runtime —
|
||||
# M is a Menu instance, not a function.
|
||||
menu_func=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\(\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_func ]]; then
|
||||
echo ' Could not extract menu function name — skipping'
|
||||
menu_var=$(grep -oP "${tray_var_re}\.setContextMenu\(\K[\$\w]+(?=\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -n $menu_var ]]; then
|
||||
menu_var_re="${menu_var//\$/\\$}"
|
||||
# Word-boundary lookbehind, not a fixed [,;({] class, so the
|
||||
# assignment resolves whether it follows a separator or a
|
||||
# declarator (`let `/`const ` leaves a space before the var).
|
||||
# First assignment site wins, matching the inline-form grep.
|
||||
menu_func=$(grep -oP "(?<![\$\w])${menu_var_re}=\K[\$\w]+(?=\(\))" \
|
||||
"$index_js" | head -1)
|
||||
fi
|
||||
fi
|
||||
if [[ -z $menu_func ]]; then
|
||||
# Both the inline grep and the menu_var fallback came up empty.
|
||||
# A silent skip here is how the #515 duplicate-icon race
|
||||
# regressed before — make it loud on stderr so the next silent
|
||||
# regression surfaces in CI logs. Still skip gracefully so the
|
||||
# build completes.
|
||||
echo "WARNING: could not resolve tray menu function" \
|
||||
"(inline + fallback both failed) — in-place" \
|
||||
"fast-path NOT applied; duplicate-icon race" \
|
||||
"(#515) may regress" >&2
|
||||
echo '##############################################################'
|
||||
return
|
||||
fi
|
||||
@@ -146,7 +157,7 @@ patch_tray_inplace_update() {
|
||||
# suffix)` earlier in the function; minifier renames it between
|
||||
# releases, so it needs to be extracted (not hardcoded).
|
||||
path_var=$(grep -oP \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K\w+(?=\))" \
|
||||
"${tray_var_re}=new ${electron_var_re}\.Tray\(${electron_var_re}\.nativeImage\.createFromPath\(\K[\$\w]+(?=\))" \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $path_var ]]; then
|
||||
echo ' Could not extract icon-path var — skipping'
|
||||
@@ -160,8 +171,8 @@ patch_tray_inplace_update() {
|
||||
# tests, so binding to the wrong site is silently broken. Bail if
|
||||
# upstream ever ships >1 declaration site instead of taking the
|
||||
# first one.
|
||||
enabled_count=$(grep -cE \
|
||||
'const \w+\s*=\s*\w+\("menuBarEnabled"\)' "$index_js")
|
||||
enabled_count=$(grep -cP \
|
||||
'const [$\w]+\s*=\s*[$\w]+\("menuBarEnabled"\)' "$index_js")
|
||||
if [[ $enabled_count -ne 1 ]]; then
|
||||
echo " Expected 1 menuBarEnabled declaration, found" \
|
||||
"${enabled_count} — skipping"
|
||||
@@ -169,7 +180,7 @@ patch_tray_inplace_update() {
|
||||
return
|
||||
fi
|
||||
enabled_var=$(grep -oP \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' "$index_js")
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' "$index_js")
|
||||
if [[ -z $enabled_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled var — skipping'
|
||||
echo '##############################################################'
|
||||
@@ -248,7 +259,7 @@ patch_menu_bar_default() {
|
||||
|
||||
local menu_bar_var
|
||||
menu_bar_var=$(grep -oP \
|
||||
'const \K\w+(?=\s*=\s*\w+\("menuBarEnabled"\))' \
|
||||
'const \K[$\w]+(?=\s*=\s*[$\w]+\("menuBarEnabled"\))' \
|
||||
"$index_js" | head -1)
|
||||
if [[ -z $menu_bar_var ]]; then
|
||||
echo ' Could not extract menuBarEnabled variable name'
|
||||
|
||||
@@ -24,15 +24,15 @@ detect_architecture() {
|
||||
|
||||
case "$raw_arch" in
|
||||
x86_64)
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='1ab57e88c86451cf199d1568d751dab7fe29e4bdfa5a3f035fdb15b872c7a352'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/x64/1.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe'
|
||||
claude_exe_sha256='b31082fb572c3cb62952e83703de2fcb64868cc361f4a5d26996c810f318cd75'
|
||||
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.8555.2/Claude-a476c316c741715263e34f9c9d2bc45b6d0f21c7.exe'
|
||||
claude_exe_sha256='3c319a59a59b30bfeb86f71b6df80891c5c983404f12e464f4be1754cd4d2c94'
|
||||
claude_download_url='https://downloads.claude.ai/releases/win32/arm64/1.15200.0/Claude-250bae744478f92cc2796a6dcc060a867d66cb85.exe'
|
||||
claude_exe_sha256='95cf3bd0e1bfe82b2762baa6f6d59d359ae5af1059ed135c5be9617190672280'
|
||||
architecture='arm64'
|
||||
claude_exe_filename='Claude-Setup-arm64.exe'
|
||||
echo 'Configured for arm64 (aarch64) build.'
|
||||
|
||||
91
tests/claude-native-stub.bats
Normal file
91
tests/claude-native-stub.bats
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# claude-native-stub.bats
|
||||
# Tests for the Linux @ant/claude-native stub (scripts/claude-native-stub.js)
|
||||
# copied into app.asar and app.asar.unpacked during packaging.
|
||||
#
|
||||
# The Windows-only registry / MSIX / UAC methods are the load-bearing
|
||||
# part here: upstream (>= 1.13576.0) calls readRegistryValues() and
|
||||
# getWindowsElevationType() unconditionally at startup, so a missing
|
||||
# method throws before any window is created and the app hangs (#729).
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
STUB_JS="${SCRIPT_DIR}/../scripts/claude-native-stub.js"
|
||||
|
||||
# Evaluate a snippet of JS with the stub loaded as `stub`. The snippet
|
||||
# must `process.exit(1)` (via thrown error) on failure; a clean exit is
|
||||
# a pass. Keeps each @test to a single Node spawn.
|
||||
run_stub_js() {
|
||||
run node -e "
|
||||
const stub = require('${STUB_JS}');
|
||||
$1
|
||||
"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "claude-native stub: readRegistryValues returns an empty array" {
|
||||
run_stub_js '
|
||||
const v = stub.readRegistryValues(["HKCU\\\\Software\\\\Anthropic"]);
|
||||
if (!Array.isArray(v) || v.length !== 0) {
|
||||
throw new Error("expected [], got " + JSON.stringify(v));
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
@test "claude-native stub: getWindowsElevationType returns \"default\"" {
|
||||
run_stub_js '
|
||||
if (stub.getWindowsElevationType() !== "default") {
|
||||
throw new Error("expected default");
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
@test "claude-native stub: getCurrentPackageFamilyName returns null" {
|
||||
run_stub_js '
|
||||
if (stub.getCurrentPackageFamilyName() !== null) {
|
||||
throw new Error("expected null");
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
@test "claude-native stub: registry writers are callable no-ops" {
|
||||
run_stub_js '
|
||||
stub.writeRegistryValue("k", "v");
|
||||
stub.writeRegistryDword("k", 1);
|
||||
'
|
||||
}
|
||||
|
||||
@test "claude-native stub: all Windows-only policy methods are functions" {
|
||||
run_stub_js '
|
||||
const required = [
|
||||
"readRegistryValues",
|
||||
"writeRegistryValue",
|
||||
"writeRegistryDword",
|
||||
"getWindowsElevationType",
|
||||
"getCurrentPackageFamilyName",
|
||||
];
|
||||
for (const name of required) {
|
||||
if (typeof stub[name] !== "function") {
|
||||
throw new Error(name + " is not a function");
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
@test "claude-native stub: existing exports are preserved" {
|
||||
run_stub_js '
|
||||
if (stub.getWindowsVersion() !== "10.0.0") {
|
||||
throw new Error("getWindowsVersion regressed");
|
||||
}
|
||||
if (typeof stub.flashFrame !== "function") {
|
||||
throw new Error("flashFrame missing");
|
||||
}
|
||||
if (!stub.KeyboardKey || stub.KeyboardKey.Enter !== 261) {
|
||||
throw new Error("KeyboardKey regressed");
|
||||
}
|
||||
'
|
||||
}
|
||||
67
tests/config-patches.bats
Normal file
67
tests/config-patches.bats
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# config-patches.bats
|
||||
# Tests for scripts/patches/config.sh patch helpers.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
PATCH_SH="$SCRIPT_DIR/../scripts/patches/config.sh"
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
project_root="$TEST_TMP"
|
||||
export project_root
|
||||
mkdir -p "$TEST_TMP/app.asar.contents/.vite/build"
|
||||
cd "$TEST_TMP" || return 1
|
||||
|
||||
# shellcheck source=scripts/patches/config.sh
|
||||
source "$PATCH_SH"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
write_index_js() {
|
||||
local fixture='app.asar.contents/.vite/build/index.js'
|
||||
{
|
||||
printf '%s' \
|
||||
'function a(A,Y){for(let O of A)Y.push("--add-dir",O)}'
|
||||
printf '%s' \
|
||||
'function b(A,Y){for(let O of A)Y.push("--add-dir",O)}'
|
||||
printf '%s' \
|
||||
'function c(S){(S.userSelectedFolders||[]).filter(p=>true);'
|
||||
printf '%s' \
|
||||
'console.log("Filtering out deleted folder from session")}'
|
||||
} > "$fixture"
|
||||
}
|
||||
|
||||
@test "additional dirs guard filters every --add-dir dispatch loop" {
|
||||
write_index_js
|
||||
|
||||
run patch_asar_additional_dirs_guard
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
local patched='app.asar.contents/.vite/build/index.js'
|
||||
run grep -oF '.filter(_d=>!_d.endsWith(".asar"))' "$patched"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo 'expected .asar filters to be injected'
|
||||
return 1
|
||||
}
|
||||
[[ "${#lines[@]}" -eq 2 ]] || {
|
||||
echo "expected 2 dispatch filters, got ${#lines[@]}"
|
||||
return 1
|
||||
}
|
||||
|
||||
run grep -qF 'for(let O of A)Y.push("--add-dir",O)' "$patched"
|
||||
[[ "$status" -eq 1 ]] || {
|
||||
echo 'unfiltered --add-dir dispatch remained'
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -124,3 +124,60 @@ assertEqual(r.kind, 'unknown', 'null error does not crash');
|
||||
"
|
||||
[[ "$status" -eq 0 ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# detectBackend — COWORK_VM_BACKEND override contract
|
||||
#
|
||||
# KVM uses a downloaded VM image; on Linux cowork normally runs through
|
||||
# the bwrap daemon, and the renderer-gate fix (cowork.sh Patch 1b) is
|
||||
# paired with a download block (Patch 1c) so the multi-GB VM bundle is
|
||||
# never pulled. The daemon half of that policy is here: KVM is reachable
|
||||
# only via an explicit COWORK_VM_BACKEND=kvm opt-in — auto-detect never
|
||||
# selects it while bwrap works (#351). These pin the override contract;
|
||||
# COWORK_VM_BACKEND is read at module load, so each case is a fresh
|
||||
# process with the env preset.
|
||||
# =============================================================================
|
||||
|
||||
# Resolve the backend class name for a given COWORK_VM_BACKEND value.
|
||||
# detectBackend's log()/logError() chatter can land on stdout/stderr, so
|
||||
# emit a sentinel and parse only that — robust against any log noise.
|
||||
backend_name() {
|
||||
COWORK_VM_BACKEND="$1" node -e '
|
||||
const { detectBackend } = require("'"${SCRIPT_DIR}"'/../scripts/cowork-vm-service.js");
|
||||
const b = detectBackend(() => {});
|
||||
process.stdout.write("\n__BACKEND__:" +
|
||||
(b && b.constructor ? b.constructor.name : "null") + "\n");
|
||||
' 2>/dev/null | grep -oE '__BACKEND__:[A-Za-z]+' | cut -d: -f2
|
||||
}
|
||||
|
||||
@test "detectBackend: COWORK_VM_BACKEND=kvm opts into KvmBackend" {
|
||||
[[ "$(backend_name kvm)" == "KvmBackend" ]] || {
|
||||
echo "expected KvmBackend, got: $(backend_name kvm)"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "detectBackend: COWORK_VM_BACKEND=bwrap selects BwrapBackend" {
|
||||
[[ "$(backend_name bwrap)" == "BwrapBackend" ]] || {
|
||||
echo "expected BwrapBackend, got: $(backend_name bwrap)"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "detectBackend: COWORK_VM_BACKEND=host selects HostBackend" {
|
||||
[[ "$(backend_name host)" == "HostBackend" ]] || {
|
||||
echo "expected HostBackend, got: $(backend_name host)"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "detectBackend: an unknown override never silently lands on KVM" {
|
||||
# Garbage override falls through to auto-detect, which prefers bwrap
|
||||
# and stops at host on probe failure — it must not become KVM (#351).
|
||||
local got
|
||||
got="$(backend_name not-a-backend)"
|
||||
[[ "$got" == "BwrapBackend" || "$got" == "HostBackend" ]] || {
|
||||
echo "unknown override resolved to unexpected backend: $got"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
126
tests/cowork-patches.bats
Normal file
126
tests/cowork-patches.bats
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bats
|
||||
#
|
||||
# cowork-patches.bats
|
||||
# Application tests for the Cowork index.js patches in
|
||||
# scripts/patches/cowork.sh — specifically the yukonSilver
|
||||
# renderer-gate fix (Patch 1b) and the paired VM-download block
|
||||
# (Patch 1c). verify-patches.bats proves each marker regex matches its
|
||||
# sample; this proves patch_cowork_linux() actually PRODUCES those
|
||||
# markers from an unpatched bundle and is idempotent on re-run.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
|
||||
PATCH_SH="$SCRIPT_DIR/../scripts/patches/cowork.sh"
|
||||
INDEX='app.asar.contents/.vite/build/index.js'
|
||||
|
||||
setup() {
|
||||
TEST_TMP=$(mktemp -d)
|
||||
export TEST_TMP
|
||||
mkdir -p "$TEST_TMP/app.asar.contents/.vite/build"
|
||||
cd "$TEST_TMP" || return 1
|
||||
# cowork-vm-service.js path is read by SVC_PATH-aware patches; a
|
||||
# bare placeholder is enough for the index.js transforms.
|
||||
: > "$TEST_TMP/cowork-vm-service.js"
|
||||
# shellcheck source=scripts/patches/cowork.sh
|
||||
source "$PATCH_SH"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
|
||||
rm -rf "$TEST_TMP"
|
||||
fi
|
||||
}
|
||||
|
||||
# Minimal minified fixture carrying the anchors patch_cowork_linux()
|
||||
# needs: the "vmClient (TypeScript)" guard, the FATAL startVM gate
|
||||
# (Patch 1), the q4r support evaluator (Patch 1b), and the two
|
||||
# download gates u8A / mzn (Patch 1c-A / 1c-B). Other patches warn
|
||||
# harmlessly on this fixture; only Patch 1 is fatal-on-miss.
|
||||
write_cowork_fixture() {
|
||||
{
|
||||
printf '%s' \
|
||||
'function VF(A,e,t){const{yukonSilver:r}=D_();if((r==null?void 0:r.status)!=="supported"){Ve.warn("[startVM] VM not supported ("+(r==null?void 0:r.status)+")");return}return ov()}'
|
||||
printf '%s' \
|
||||
'function q4r(){var i;const A="win32",e=process.arch;if(e!=="x64"&&e!=="arm64")return{status:"unsupported",unsupportedCode:"unsupported_architecture"};if(!bl())return{status:"unsupported",unsupportedCode:"msix_required"};return{status:"supported"}}'
|
||||
printf '%s' \
|
||||
'function u8A(A,e){const{yukonSilver:t}=z_();return(t==null?void 0:t.status)!=="supported"?!1:(ul(x,y).catch(()=>{}),TP?(Ve.info("[downloadVM] Download already in progress, waiting..."),TP):f6()?!1:P8r(A,e))}'
|
||||
printf '%s' \
|
||||
'async function mzn(A,e,t){const{yukonSilver:i}=z_();if(!i||i.status!=="supported"){await YcA([]);return}if(!nOt()){await YcA([ao.sha]);return}}'
|
||||
printf '%s' \
|
||||
'async function YBt(){return bl()?(QL||(Ve.info("vmClient (TypeScript)"),QL={vm:hji}),QL):null}'
|
||||
} > "$INDEX"
|
||||
}
|
||||
|
||||
@test "patch_cowork_linux injects the evaluator + download-block markers" {
|
||||
write_cowork_fixture
|
||||
|
||||
run patch_cowork_linux
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "patch_cowork_linux exited $status"
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Patch 1b: evaluator reports supported on Linux at q4r's top.
|
||||
run grep -cP 'if\(process\.platform==="linux"\)return\{status:"supported"\};const [\w$]+="win32"' "$INDEX"
|
||||
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
|
||||
echo "evaluator marker count: $output"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Patch 1c-A: VM-download driver short-circuits on Linux.
|
||||
run grep -cP 'process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1:' "$INDEX"
|
||||
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
|
||||
echo "vm-download-block marker count: $output"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Patch 1c-B: warm prefetch early-returns on Linux.
|
||||
run grep -cP 'if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)\{await [\w$]+\(\[\]\);return\}' "$INDEX"
|
||||
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
|
||||
echo "warm-download-block marker count: $output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "patch_cowork_linux still parses as valid JS after patching" {
|
||||
write_cowork_fixture
|
||||
run patch_cowork_linux
|
||||
[[ "$status" -eq 0 ]] || { echo "$output"; return 1; }
|
||||
|
||||
run node --check "$INDEX"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "patched fixture failed node --check"
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@test "patch_cowork_linux is idempotent for the new markers" {
|
||||
write_cowork_fixture
|
||||
run patch_cowork_linux
|
||||
[[ "$status" -eq 0 ]] || { echo "$output"; return 1; }
|
||||
cp "$INDEX" first.js
|
||||
|
||||
# Second run must not double-inject and must be byte-identical.
|
||||
run patch_cowork_linux
|
||||
[[ "$status" -eq 0 ]] || { echo "$output"; return 1; }
|
||||
|
||||
run diff first.js "$INDEX"
|
||||
[[ "$status" -eq 0 ]] || {
|
||||
echo "re-run changed the bundle (not idempotent):"
|
||||
echo "$output"
|
||||
return 1
|
||||
}
|
||||
|
||||
for marker in \
|
||||
'if\(process\.platform==="linux"\)return\{status:"supported"\}' \
|
||||
'process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1:' \
|
||||
'if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)'; do
|
||||
run grep -cP "$marker" "$INDEX"
|
||||
[[ "$status" -eq 0 && "$output" -eq 1 ]] || {
|
||||
echo "marker not unique after re-run: $marker (count $output)"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
@@ -443,3 +443,172 @@ SHIM
|
||||
[[ $output == *'Password store:'* ]]
|
||||
[[ $output == *'basic'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_password_store: warns, not PASS, when detection returns empty" {
|
||||
# An empty backend means detection failed (e.g. sourcing-order
|
||||
# regression) — it must not surface as a green PASS with a blank value.
|
||||
_detect_password_store() { echo ''; }
|
||||
run _doctor_check_password_store
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_disk_space
|
||||
# =============================================================================
|
||||
|
||||
@test "_doctor_check_disk_space: fails when under 100MB free" {
|
||||
df() { printf 'Avail\n50M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[FAIL]'* ]]
|
||||
[[ $output == *'50MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: warns when under 500MB free" {
|
||||
df() { printf 'Avail\n300M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'300MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: warns at exactly 100MB (tier boundary)" {
|
||||
# 100 is not < 100, so the FAIL tier must not fire; < 500 → WARN.
|
||||
df() { printf 'Avail\n100M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output != *'[FAIL]'* ]]
|
||||
[[ $output == *'100MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: passes at exactly 500MB (tier boundary)" {
|
||||
# 500 is not < 500, so the WARN tier must not fire → PASS.
|
||||
df() { printf 'Avail\n500M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output == *'500MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: no false PASS on leading-zero df output" {
|
||||
# '0099' clears the numeric regex but would make (( )) parse the
|
||||
# value as octal and error out, falling through to the PASS
|
||||
# branch. The 10# normalization must read it as 99 → FAIL tier.
|
||||
df() { printf 'Avail\n0099M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[FAIL]'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
[[ $output == *'99MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: passes with ample free space" {
|
||||
df() { printf 'Avail\n2048M\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'2048MB free'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: no false PASS on non-numeric df output" {
|
||||
# A malformed/empty avail field must not slip through as a PASS,
|
||||
# and the skip must be visible rather than hiding behind a clean
|
||||
# summary.
|
||||
df() { printf 'Avail\nN/A\n'; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
[[ $output != *'[FAIL]'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
[[ $output == *'Disk space: unable to read (df)'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_disk_space: visible skip when df is unavailable" {
|
||||
df() { return 127; }
|
||||
run _doctor_check_disk_space "$XDG_CONFIG_HOME"
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'Disk space: unable to read (df)'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
[[ $output != *'[FAIL]'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _doctor_check_pkg_version: package-manager ownership (#711)
|
||||
# =============================================================================
|
||||
|
||||
# Make `command -v` report the named package tools (rpm, dpkg-query)
|
||||
# as missing so tests can simulate single-manager or tool-less hosts
|
||||
# regardless of what the CI/dev box really has installed. Same shadow
|
||||
# trick as _skip_gtk_query: `command -v` finds functions too, so
|
||||
# shadowing `command` itself is the only reliable way.
|
||||
_hide_pkg_tools() {
|
||||
_hidden_pkg_tools=" $* "
|
||||
command() {
|
||||
if [[ $1 == '-v' \
|
||||
&& $_hidden_pkg_tools == *" $2 "* ]]; then
|
||||
return 1
|
||||
fi
|
||||
builtin command "$@"
|
||||
}
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: rpm owns the path — rpm version wins over stale dpkg record (#711)" {
|
||||
# The #711 repro: Fedora host, rpm owns the install, but a stale
|
||||
# dpkg record from an old deb experiment still answers. The rpm
|
||||
# answer must win; the stale dpkg version must not appear at all.
|
||||
rpm() { printf '1.11847.5-2.0.19'; }
|
||||
dpkg-query() { printf '1.5354.0'; }
|
||||
|
||||
run _doctor_check_pkg_version \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/electron'
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Installed version: 1.11847.5-2.0.19'* ]]
|
||||
[[ $output != *'1.5354.0'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: dpkg-only host reports dpkg version" {
|
||||
_hide_pkg_tools rpm
|
||||
dpkg-query() { printf '1.11847.5'; }
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Installed version: 1.11847.5'* ]]
|
||||
[[ $output != *'[WARN]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: dual-DB host where rpm does not own the path falls back to dpkg" {
|
||||
# rpm exists but the install is a real deb: `rpm -qf` says "not
|
||||
# owned" (rc=1, message on stdout) and dpkg must be consulted.
|
||||
rpm() {
|
||||
# $4 = probe path ($1=-qf $2=--qf $3=<format>)
|
||||
printf 'file %s is not owned by any package\n' "$4"
|
||||
return 1
|
||||
}
|
||||
dpkg-query() { printf '1.11847.5'; }
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[PASS]'* ]]
|
||||
[[ $output == *'Installed version: 1.11847.5'* ]]
|
||||
[[ $output != *'not owned'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: neither manager owns the install — warn (AppImage/Nix)" {
|
||||
rpm() { return 1; }
|
||||
dpkg-query() { return 1; }
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ $output == *'[WARN]'* ]]
|
||||
[[ $output == *'AppImage'* ]]
|
||||
[[ $output != *'[PASS]'* ]]
|
||||
}
|
||||
|
||||
@test "_doctor_check_pkg_version: silent when no package tools exist" {
|
||||
_hide_pkg_tools rpm dpkg-query
|
||||
|
||||
run _doctor_check_pkg_version ''
|
||||
[[ $status -eq 0 ]]
|
||||
[[ -z $output ]]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,17 @@ has_electron_arg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Count how many electron_args entries start with --enable-features=.
|
||||
# Chromium honours only the last such switch, so the launcher must emit
|
||||
# exactly one; this lets tests assert that invariant.
|
||||
count_enable_features() {
|
||||
local n=0 arg
|
||||
for arg in "${electron_args[@]}"; do
|
||||
[[ $arg == --enable-features=* ]] && ((n++))
|
||||
done
|
||||
echo "$n"
|
||||
}
|
||||
|
||||
# Install a dbus-send stub at the front of PATH.
|
||||
# kwallet6 — echoes 'boolean true', exits 0 (kwallet6 detectable)
|
||||
# secrets-ok — fails for kwalletd6 dest, succeeds for all other dests
|
||||
@@ -76,8 +87,13 @@ setup() {
|
||||
unset CLAUDE_PASSWORD_STORE
|
||||
CLAUDE_PASSWORD_STORE='basic'
|
||||
|
||||
# Copy to temp dir so we can substitute the build-time placeholder
|
||||
# and co-locate doctor.sh (sourced via BASH_SOURCE dirname).
|
||||
cp "$SCRIPT_DIR/../scripts/launcher-common.sh" "$TEST_TMP/launcher-common.sh"
|
||||
cp "$SCRIPT_DIR/../scripts/doctor.sh" "$TEST_TMP/doctor.sh"
|
||||
sed -i 's/@@WM_CLASS@@/Claude/' "$TEST_TMP/launcher-common.sh"
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
source "$TEST_TMP/launcher-common.sh"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
@@ -283,7 +299,7 @@ teardown() {
|
||||
[[ $use_x11_on_wayland == false ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: non-Niri Wayland keeps XWayland default" {
|
||||
@test "detect_display_backend: non-Niri non-GNOME Wayland keeps XWayland default" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="sway"
|
||||
setup_logging
|
||||
@@ -301,10 +317,68 @@ teardown() {
|
||||
[[ $use_x11_on_wayland == false ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: GNOME Wayland keeps XWayland default (not auto-flipped)" {
|
||||
# GNOME native+portal is opt-in only; the default session stays on
|
||||
# mature XWayland to avoid rendering/IME regressions (#404 portal
|
||||
# route is opt-in via CLAUDE_USE_WAYLAND=1).
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="GNOME"
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $is_wayland == true ]]
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: GNOME Wayland + CLAUDE_USE_WAYLAND=1 opts into native" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="ubuntu:GNOME"
|
||||
CLAUDE_USE_WAYLAND=1
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $use_x11_on_wayland == false ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: GNOME on X11 (not Wayland) stays X11" {
|
||||
DISPLAY=":0"
|
||||
XDG_CURRENT_DESKTOP="GNOME"
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $is_wayland == false ]]
|
||||
# use_x11_on_wayland is the default true; the auto-detect block is
|
||||
# guarded by is_wayland so it never flips it on an X11 session.
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on GNOME" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
XDG_CURRENT_DESKTOP="GNOME"
|
||||
CLAUDE_USE_WAYLAND=0
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $is_wayland == true ]]
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on Niri" {
|
||||
WAYLAND_DISPLAY="wayland-0"
|
||||
NIRI_SOCKET="/tmp/niri.sock"
|
||||
CLAUDE_USE_WAYLAND=0
|
||||
setup_logging
|
||||
detect_display_backend
|
||||
[[ $use_x11_on_wayland == true ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# build_electron_args
|
||||
# =============================================================================
|
||||
|
||||
@test "build_electron_args: includes --class matching upstream productName" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--class=Claude'
|
||||
}
|
||||
|
||||
@test "build_electron_args: X11 deb - only CustomTitlebar disabled" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
@@ -330,6 +404,17 @@ teardown() {
|
||||
has_electron_arg '--no-sandbox'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland XWayland deb - no GlobalShortcutsPortal feature" {
|
||||
# The portal feature is inert under XWayland, so it must not be
|
||||
# emitted on the X11-via-XWayland path.
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=true
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
# shellcheck disable=SC2314 # last command in test, ! works correctly
|
||||
! has_electron_arg '*GlobalShortcutsPortal*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland native deb - includes wayland platform flags" {
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
@@ -340,6 +425,45 @@ teardown() {
|
||||
has_electron_arg '*WaylandWindowDecorations*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland native deb - enables GlobalShortcutsPortal (#404)" {
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '*GlobalShortcutsPortal*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland native deb - portal + ozone share one --enable-features" {
|
||||
# Chromium honours only the last --enable-features switch, so the
|
||||
# portal feature, UseOzonePlatform and WaylandWindowDecorations must
|
||||
# all live in a single comma-joined flag — not separate switches.
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
# Exactly one --enable-features switch (Chromium honours only the
|
||||
# last), carrying both features. Order inside the value is irrelevant
|
||||
# to Chromium, so assert each subkey independently rather than with an
|
||||
# ordered glob.
|
||||
[[ $(count_enable_features) -eq 1 ]]
|
||||
has_electron_arg '--enable-features=*UseOzonePlatform*'
|
||||
has_electron_arg '--enable-features=*GlobalShortcutsPortal*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: hidden titlebar + native Wayland - one merged --enable-features" {
|
||||
# WindowControlsOverlay (hidden titlebar) and the wayland/portal
|
||||
# features must coexist in a single flag rather than clobber.
|
||||
CLAUDE_TITLEBAR_STYLE=hidden
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
[[ $(count_enable_features) -eq 1 ]]
|
||||
has_electron_arg '*WindowControlsOverlay*'
|
||||
has_electron_arg '*GlobalShortcutsPortal*'
|
||||
has_electron_arg '*WaylandWindowDecorations*'
|
||||
}
|
||||
|
||||
@test "build_electron_args: Wayland appimage - always includes --no-sandbox" {
|
||||
is_wayland=true
|
||||
use_x11_on_wayland=true
|
||||
@@ -607,6 +731,219 @@ s.close()
|
||||
[[ ! -S "$sock" ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# cleanup_orphaned_cowork_daemon
|
||||
#
|
||||
# Reaps a cowork-vm-service daemon left behind by a crashed UI, but only
|
||||
# when no live Claude UI is running. pgrep/kill/sleep are stubbed; the
|
||||
# "live UI" case uses a real background process so the /proc cmdline and
|
||||
# status reads resolve naturally without faking /proc.
|
||||
# =============================================================================
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: no daemon running — no action, no log" {
|
||||
# Daemon pgrep finds nothing, so the function returns before any
|
||||
# UI scan or kill.
|
||||
pgrep() { return 1; }
|
||||
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
|
||||
|
||||
setup_logging
|
||||
run cleanup_orphaned_cowork_daemon
|
||||
[[ $status -eq 0 ]]
|
||||
[[ ! -f "$TEST_TMP/kills" ]]
|
||||
[[ ! -f $log_file ]]
|
||||
}
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: live UI present — daemon left running" {
|
||||
# A real background process stands in for the live Electron UI so
|
||||
# the /proc cmdline and status reads resolve naturally. The UI
|
||||
# scan fingerprints on the launcher-passed --class flag (since
|
||||
# #700 app.asar no longer appears in any cmdline), so the
|
||||
# stand-in's argv[0] is renamed to carry it via exec -a. Its state
|
||||
# is sleeping (not T/t/Z), so the function treats it as a live UI
|
||||
# and must NOT kill the daemon.
|
||||
bash -c 'exec -a "--class=Claude" sleep 300' &
|
||||
ui_pid=$!
|
||||
|
||||
# Match on "$*", not "$2": the UI scan passes -u <uid> and a `--`
|
||||
# end-of-options separator before the pattern, so the pattern is
|
||||
# not at a fixed argument position.
|
||||
pgrep() {
|
||||
if [[ $* == *cowork-vm-service* ]]; then
|
||||
echo 4242
|
||||
elif [[ $* == *--class=Claude* ]]; then
|
||||
echo "$ui_pid"
|
||||
fi
|
||||
}
|
||||
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
|
||||
|
||||
setup_logging
|
||||
cleanup_orphaned_cowork_daemon
|
||||
local rc=$?
|
||||
builtin kill "$ui_pid" 2>/dev/null
|
||||
|
||||
[[ $rc -eq 0 ]]
|
||||
# Daemon kill must never have been attempted.
|
||||
[[ ! -f "$TEST_TMP/kills" ]]
|
||||
}
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: orphan exits on SIGTERM — no SIGKILL" {
|
||||
# Daemon present, no live UI. The daemon disappears once SIGTERM is
|
||||
# sent, so the escalation to SIGKILL must not fire.
|
||||
local term_sent="$TEST_TMP/term_sent"
|
||||
pgrep() {
|
||||
if [[ $* == *cowork-vm-service* ]]; then
|
||||
[[ -f $term_sent ]] && return 1
|
||||
echo 4242
|
||||
else
|
||||
# UI scan (--class fingerprint): no live UI.
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
kill() {
|
||||
echo "kill $*" >> "$TEST_TMP/kills"
|
||||
# A plain SIGTERM ($1 is the PID, not -KILL) reaps the daemon.
|
||||
[[ $1 == -KILL ]] || : > "$term_sent"
|
||||
}
|
||||
sleep() { :; }
|
||||
|
||||
setup_logging
|
||||
# Via `run` so the function's internal `((_wait++))` (which returns 1
|
||||
# when _wait starts at 0) doesn't trip bats' errexit. Production has
|
||||
# no set -e, so this is a harness concern, not a code defect.
|
||||
run cleanup_orphaned_cowork_daemon
|
||||
|
||||
grep -q 'Killed orphaned cowork-vm-service daemon (PIDs: 4242)' \
|
||||
"$log_file"
|
||||
# Negative assertions via `run` + status: a bare `! grep` that isn't
|
||||
# the last command does not fail a bats test (SC2314), so it would be
|
||||
# a hollow check.
|
||||
run grep -q 'SIGKILL' "$log_file"
|
||||
[[ $status -ne 0 ]]
|
||||
grep -q '^kill 4242$' "$TEST_TMP/kills"
|
||||
run grep -qF -- '-KILL' "$TEST_TMP/kills"
|
||||
[[ $status -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "cleanup_orphaned_cowork_daemon: orphan survives SIGTERM — escalates to SIGKILL" {
|
||||
# Daemon never dies, so after the SIGTERM grace window the function
|
||||
# escalates to SIGKILL and logs the SIGKILL variant.
|
||||
pgrep() {
|
||||
if [[ $* == *cowork-vm-service* ]]; then
|
||||
echo 4242
|
||||
else
|
||||
# UI scan (--class fingerprint): no live UI.
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
|
||||
sleep() { :; }
|
||||
|
||||
setup_logging
|
||||
# `run` for the same errexit reason as the SIGTERM test above.
|
||||
run cleanup_orphaned_cowork_daemon
|
||||
|
||||
grep -q 'Killed orphaned cowork-vm-service daemon (SIGKILL, PIDs: 4242)' \
|
||||
"$log_file"
|
||||
grep -q '^kill 4242$' "$TEST_TMP/kills"
|
||||
grep -q '^kill -KILL 4242$' "$TEST_TMP/kills"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# cleanup_stale_desktop_helpers
|
||||
# =============================================================================
|
||||
|
||||
@test "_desktop_helper_cmdline_matches: matches known Desktop helpers only" {
|
||||
local config_dir="$XDG_CONFIG_HOME/Claude"
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --type=utility --user-data-dir=$config_dir"
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
# tr '\0' ' ' joins cmdline args with a trailing space, so the
|
||||
# --user-data-dir arm anchors on "$config_dir " — exact dir only.
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=$config_dir "
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=${config_dir}Dev "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar.unpacked/cowork-vm-service.js"
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"node $config_dir/Claude Extensions/ant.dir.example/server.js"
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron /usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar"
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"claude --dangerously-skip-permissions"
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
run _desktop_helper_cmdline_matches \
|
||||
"/home/scott/dev/dude/core/agent-dude/dist/index.js mcp"
|
||||
[[ $status -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "_claude_desktop_ui_cmdline_matches: keys on the --class fingerprint" {
|
||||
# Live UI: launcher argv carries --class=$WM_CLASS (tr '\0' ' '
|
||||
# leaves every argument space-terminated). Since #700 app.asar no
|
||||
# longer appears in any cmdline, so the --class flag from
|
||||
# build_electron_args is the only stable UI signature.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --class=Claude --enable-features=WaylandWindowDecorations "
|
||||
[[ $status -eq 0 ]]
|
||||
|
||||
# Another Electron app's asar path must not match.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/opt/other-electron-app/resources/app.asar "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
# Look-alike WM class is rejected by the trailing-space anchor.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/opt/claude-dev/electron --class=ClaudeDev "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
# Chromium helpers (--type=) never count as the UI, even if a
|
||||
# --class flag leaked into their argv.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/electron --type=utility --user-data-dir=$XDG_CONFIG_HOME/Claude --class=Claude "
|
||||
[[ $status -ne 0 ]]
|
||||
|
||||
# The cowork daemon never counts as the UI.
|
||||
run _claude_desktop_ui_cmdline_matches \
|
||||
"/usr/lib/claude-desktop/node_modules/electron/dist/resources/app.asar.unpacked/cowork-vm-service.js --class=Claude "
|
||||
[[ $status -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "run_electron_and_cleanup: runs cleanup after Electron exits and preserves status" {
|
||||
local marker="$TEST_TMP/cleanup-ran"
|
||||
local electron="$TEST_TMP/electron"
|
||||
|
||||
cat > "$electron" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
echo "electron argv: $*"
|
||||
exit 7
|
||||
STUB
|
||||
chmod +x "$electron"
|
||||
|
||||
cleanup_after_electron_exit() {
|
||||
touch "$marker"
|
||||
}
|
||||
|
||||
setup_logging
|
||||
run run_electron_and_cleanup "$electron" '--flag' 'value'
|
||||
[[ $status -eq 7 ]]
|
||||
[[ -f $marker ]]
|
||||
run cat "$log_file"
|
||||
[[ $output == *'electron argv: --flag value'* ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Doctor helper functions
|
||||
# =============================================================================
|
||||
|
||||
@@ -142,3 +142,72 @@ args_count() {
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@test "disable-gpu: prior GPU fatal auto-disables on next launch" {
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start ---
|
||||
LOG
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
|
||||
}
|
||||
|
||||
@test "disable-gpu: recovery stays sticky on launch N+2 (no oscillation)" {
|
||||
# A recovered launch runs with --disable-gpu and writes no GPU
|
||||
# output, so the crash signature alone would re-enable GPU on
|
||||
# launch N+2 (crash/work/crash forever). The launcher's own
|
||||
# "disabling GPU" marker in the penultimate section must keep
|
||||
# recovery tripped.
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start ---
|
||||
Previous launch hit GPU process FATAL - disabling GPU
|
||||
--- Claude Desktop Launcher Start ---
|
||||
LOG
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
}
|
||||
|
||||
@test "disable-gpu: NixOS launcher header sections are detected" {
|
||||
# nix/claude-desktop.nix writes "Launcher Start (NixOS)" headers;
|
||||
# the section regex must match them or recovery silently no-ops
|
||||
# on Nix.
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start (NixOS) ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start (NixOS) ---
|
||||
LOG
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
args_contain '--disable-gpu'
|
||||
args_contain '--disable-software-rasterizer'
|
||||
grep -q 'Previous launch hit GPU process FATAL' "$log_file"
|
||||
}
|
||||
|
||||
@test "disable-gpu: CLAUDE_DISABLE_GPU=0 suppresses auto fallback" {
|
||||
cat > "$log_file" <<'LOG'
|
||||
--- Claude Desktop Launcher Start ---
|
||||
GPU process launch failed: error_code=1002
|
||||
GPU process isn't usable. Goodbye.
|
||||
--- Claude Desktop Launcher Start ---
|
||||
LOG
|
||||
export CLAUDE_DISABLE_GPU=0
|
||||
|
||||
build_electron_args deb
|
||||
|
||||
run args_contain '--disable-gpu'
|
||||
[[ "$status" -ne 0 ]]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,16 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=tests/test-artifact-common.sh
|
||||
source "$script_dir/test-artifact-common.sh"
|
||||
|
||||
# Single point of cleanup, set at script scope so any interruption
|
||||
# between resource alloc and normal exit is covered. _launch_smoke_cleanup
|
||||
# (test-artifact-common.sh) reaps an interrupted launch and its temp dirs;
|
||||
# extract_dir is AppImage-specific so it's torn down here.
|
||||
_cleanup() {
|
||||
_launch_smoke_cleanup
|
||||
[[ -n ${extract_dir:-} ]] && rm -rf "$extract_dir"
|
||||
}
|
||||
trap _cleanup EXIT INT TERM
|
||||
|
||||
component_id='io.github.aaddrick.claude-desktop-debian'
|
||||
|
||||
# Find the AppImage file (exclude .zsync)
|
||||
@@ -108,78 +118,16 @@ else
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# Catches startup-only regressions (asar/frame-fix-wrapper syntax errors)
|
||||
# that pure structure checks miss.
|
||||
#
|
||||
# Scope: main-process startup failures only. GPU/renderer-process
|
||||
# crashes (e.g. #583-class) leave the main process alive and pass
|
||||
# this check — Xvfb has no GPU, so Electron falls back to SwiftShader
|
||||
# and the GPU-crash path isn't exercised here.
|
||||
if command -v xvfb-run &>/dev/null \
|
||||
&& command -v dbus-run-session &>/dev/null \
|
||||
&& command -v setsid &>/dev/null; then
|
||||
|
||||
# XDG_CACHE_HOME redirect so the test owns the launcher log.
|
||||
cache_root=$(mktemp -d)
|
||||
export XDG_CACHE_HOME="$cache_root"
|
||||
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
|
||||
|
||||
# setsid puts xvfb-run + Xvfb + dbus + AppRun + electron in a fresh
|
||||
# process group; xvfb-run's EXIT trap alone leaves Xvfb behind on
|
||||
# TERM, so we need kill -- -PGID below.
|
||||
# AppRun redirects electron's stdout/stderr into launcher_log;
|
||||
# xvfb_log captures xvfb-run's own stderr.
|
||||
xvfb_log=$(mktemp)
|
||||
setsid xvfb-run -a -s '-screen 0 1280x720x24' \
|
||||
dbus-run-session -- "$appimage_file" \
|
||||
>"$xvfb_log" 2>&1 &
|
||||
launch_pid=$!
|
||||
|
||||
# Safety net: covers Ctrl-C, CI timeout, or any earlier `exit` so we
|
||||
# never leak Xvfb/electron between launch and the explicit kill below.
|
||||
trap '
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
' EXIT INT TERM
|
||||
|
||||
# CI is slow; 10s is the floor for Electron startup.
|
||||
sleep 10
|
||||
|
||||
if kill -0 "$launch_pid" 2>/dev/null; then
|
||||
pass "AppImage stays alive under Xvfb for 10s"
|
||||
else
|
||||
wait "$launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
fail "AppImage exited within 10s (exit: $exit_code)"
|
||||
if [[ -f $launcher_log ]]; then
|
||||
echo '--- launcher.log (last 40 lines) ---' >&2
|
||||
tail -40 "$launcher_log" >&2
|
||||
echo '------------------------------------' >&2
|
||||
fi
|
||||
if [[ -s $xvfb_log ]]; then
|
||||
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
|
||||
tail -20 "$xvfb_log" >&2
|
||||
echo '---------------------------------------' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Negative PID targets the process group.
|
||||
kill -TERM -- "-$launch_pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -KILL -- "-$launch_pid" 2>/dev/null || true
|
||||
wait "$launch_pid" 2>/dev/null || true
|
||||
# Sweep any electron child that escaped the group (e.g. zygote).
|
||||
pkill -KILL -f "$appimage_file" 2>/dev/null || true
|
||||
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
unset XDG_CACHE_HOME
|
||||
else
|
||||
# Match the codebase convention (test-artifact-common.sh
|
||||
# validate_app_contents): tool absence is a skip, not a failure.
|
||||
# Loud failure on missing tools belongs at the workflow layer.
|
||||
pass "Skipping launch smoke test (xvfb-run/dbus-run-session/setsid missing)"
|
||||
fi
|
||||
# The AppImage runs as the (non-root) CI user, so no privilege drop.
|
||||
# The pkill sweep matches 'mount_claude', not the .AppImage path: a running
|
||||
# AppImage execs Electron from its FUSE mount (/tmp/.mount_claudeXXXX), so
|
||||
# the escaped zygote/electron children live there. Matching the artifact
|
||||
# path would sweep nothing. See CLAUDE.md (`pkill -9 -f "mount_claude"`).
|
||||
# Sweep escaped children only in CI: locally, 'mount_claude' also
|
||||
# matches a developer's live Claude Desktop AppImage session.
|
||||
smoke_sweep=''
|
||||
[[ -n ${CI:-} ]] && smoke_sweep='mount_claude'
|
||||
run_launch_smoke_test 'AppImage' "$smoke_sweep" '' "$appimage_file"
|
||||
|
||||
# --- Cleanup ---
|
||||
rm -rf "$extract_dir"
|
||||
|
||||
@@ -141,6 +141,187 @@ validate_app_contents() {
|
||||
rm -rf "$extract_dir"
|
||||
}
|
||||
|
||||
# Headless launch smoke test. Boots the packaged app under Xvfb + dbus
|
||||
# and waits for the frame-fix readiness marker
|
||||
# ('[Frame Fix] Patches built successfully'), which scripts/frame-fix-
|
||||
# wrapper.js emits on the FIRST require('electron') — i.e. before
|
||||
# app.whenReady(), not after full startup. Reaching it proves the asar
|
||||
# loaded and the wrapper's electron interception ran without a
|
||||
# SyntaxError (the #666 class) — note a hang after this point would
|
||||
# still pass. Catches startup-only regressions (asar/wrapper syntax
|
||||
# errors, bad patch anchors that yield a SyntaxError) that pure
|
||||
# structure checks miss. Ref: #670 (deb/rpm),
|
||||
# #646 (AppImage readiness-poll pattern this generalizes).
|
||||
#
|
||||
# Scope: main-process startup only. GPU/renderer crashes (#583-class)
|
||||
# leave the main process alive and pass — Xvfb has no GPU, so Electron
|
||||
# falls back to SwiftShader and that path isn't exercised here.
|
||||
#
|
||||
# Usage:
|
||||
# run_launch_smoke_test <label> <pkill_match> <run_as> <cmd> [args...]
|
||||
# label human name for pass/fail messages
|
||||
# pkill_match pattern for the pkill -f child sweep (may be empty)
|
||||
# run_as unprivileged user to drop to, or '' to run as-is.
|
||||
# Electron aborts as root without --no-sandbox, and the
|
||||
# launcher only adds that on Wayland/deb, so a root
|
||||
# container (rpm) must drop privileges to exercise the
|
||||
# real setuid-sandbox path.
|
||||
# cmd [args] the launch command
|
||||
#
|
||||
# Tool absence (xvfb-run/dbus-run-session/setsid, or runuser when a
|
||||
# run_as user is requested) is a skip, not a failure — matching
|
||||
# validate_app_contents. Loud failure on missing tools belongs at the
|
||||
# workflow layer.
|
||||
|
||||
# Module-scope state so the caller's trap can reap an interrupted launch.
|
||||
_smoke_launch_pid=''
|
||||
_smoke_cache_root=''
|
||||
_smoke_xvfb_log=''
|
||||
_smoke_pkill_match=''
|
||||
|
||||
_launch_smoke_cleanup() {
|
||||
if [[ -n $_smoke_launch_pid ]]; then
|
||||
# Negative PID targets the whole process group.
|
||||
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null
|
||||
[[ -n $_smoke_pkill_match ]] \
|
||||
&& pkill -KILL -f "$_smoke_pkill_match" 2>/dev/null
|
||||
fi
|
||||
[[ -n $_smoke_cache_root ]] && rm -rf "$_smoke_cache_root"
|
||||
[[ -n $_smoke_xvfb_log ]] && rm -rf "$_smoke_xvfb_log"
|
||||
}
|
||||
|
||||
# True when any passed log file carries the sandbox-namespace-denied
|
||||
# signature: the CI container forbidding Chromium's user/PID namespace
|
||||
# sandbox. Matches `Failed to move to new namespace`,
|
||||
# `zygote_host_impl_linux`, or `Operation not permitted` co-occurring
|
||||
# with `namespace`. Missing files are skipped silently.
|
||||
_smoke_sandbox_denied() {
|
||||
local log
|
||||
for log in "$@"; do
|
||||
[[ -f $log ]] || continue
|
||||
grep -qE 'Failed to move to new namespace|zygote_host_impl_linux' \
|
||||
"$log" && return 0
|
||||
grep -q 'Operation not permitted' "$log" \
|
||||
&& grep -q 'namespace' "$log" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_launch_smoke_test() {
|
||||
local label="$1" pkill_match="$2" run_as="$3"
|
||||
shift 3
|
||||
|
||||
local skip="Skipping launch smoke test for $label"
|
||||
if ! { command -v xvfb-run && command -v dbus-run-session \
|
||||
&& command -v setsid; } &>/dev/null; then
|
||||
pass "$skip (xvfb-run/dbus-run-session/setsid missing)"
|
||||
return
|
||||
fi
|
||||
if [[ -n $run_as ]] && ! command -v runuser &>/dev/null; then
|
||||
pass "$skip (runuser missing)"
|
||||
return
|
||||
fi
|
||||
|
||||
local cache_root xvfb_log launcher_log
|
||||
cache_root=$(mktemp -d)
|
||||
xvfb_log=$(mktemp)
|
||||
launcher_log="$cache_root/claude-desktop-debian/launcher.log"
|
||||
_smoke_cache_root="$cache_root"
|
||||
_smoke_xvfb_log="$xvfb_log"
|
||||
_smoke_pkill_match="$pkill_match"
|
||||
|
||||
# setsid puts xvfb-run + Xvfb + dbus + launcher + electron in a fresh
|
||||
# process group; xvfb-run's own EXIT trap leaves Xvfb behind on TERM,
|
||||
# so we reap via kill -- -PGID below. XDG_CACHE_HOME is redirected so
|
||||
# the test owns the launcher log the readiness marker is written to
|
||||
# (the launcher execs electron with stdout/stderr >> "$log_file").
|
||||
local -a runner=(setsid)
|
||||
if [[ -n $run_as ]]; then
|
||||
# The unprivileged user must be able to write the redirected
|
||||
# cache (and read the world-readable install + setuid sandbox).
|
||||
chmod 0777 "$cache_root"
|
||||
runner+=(runuser -u "$run_as" --)
|
||||
fi
|
||||
runner+=(env "XDG_CACHE_HOME=$cache_root"
|
||||
xvfb-run -a -s '-screen 0 1280x720x24'
|
||||
dbus-run-session -- "$@")
|
||||
|
||||
"${runner[@]}" >"$xvfb_log" 2>&1 &
|
||||
_smoke_launch_pid=$!
|
||||
|
||||
# Poll for the readiness marker or early process death, up to 30s.
|
||||
# Replaces a flat sleep: faster on healthy startups, less flaky on
|
||||
# noisy runners.
|
||||
local readiness_marker='[Frame Fix] Patches built successfully'
|
||||
local readiness_timeout=30 deadline saw_marker=0
|
||||
deadline=$((SECONDS + readiness_timeout))
|
||||
while ((SECONDS < deadline)); do
|
||||
if [[ -f $launcher_log ]] \
|
||||
&& grep -qF "$readiness_marker" "$launcher_log"; then
|
||||
saw_marker=1
|
||||
break
|
||||
fi
|
||||
kill -0 "$_smoke_launch_pid" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ((saw_marker == 1)); then
|
||||
pass "$label reached ready state under Xvfb"
|
||||
else
|
||||
# Build the failure detail message, but defer the fail/skip
|
||||
# verdict until after we've dumped and scanned the logs below.
|
||||
local detail exit_code
|
||||
if kill -0 "$_smoke_launch_pid" 2>/dev/null; then
|
||||
detail="$label did not reach ready state within"
|
||||
detail+=" ${readiness_timeout}s"
|
||||
else
|
||||
wait "$_smoke_launch_pid" 2>/dev/null
|
||||
exit_code=$?
|
||||
detail="$label exited before reaching ready state"
|
||||
detail+=" (exit: $exit_code)"
|
||||
fi
|
||||
if [[ -f $launcher_log ]]; then
|
||||
echo '--- launcher.log (last 40 lines) ---' >&2
|
||||
tail -40 "$launcher_log" >&2
|
||||
echo '------------------------------------' >&2
|
||||
fi
|
||||
if [[ -s $xvfb_log ]]; then
|
||||
echo '--- xvfb-run stderr (last 20 lines) ---' >&2
|
||||
tail -20 "$xvfb_log" >&2
|
||||
echo '---------------------------------------' >&2
|
||||
fi
|
||||
# Narrow skip: the GHA container's default seccomp/userns policy
|
||||
# blocks Chromium's namespace sandbox, so the zygote aborts before
|
||||
# the readiness marker. That's an environment limit, not an app
|
||||
# defect (deb/appimage jobs prove the same code boots where the
|
||||
# sandbox is allowed). Treat ONLY this signature as a skip; every
|
||||
# other pre-marker exit stays a hard failure.
|
||||
if _smoke_sandbox_denied "$launcher_log" "$xvfb_log"; then
|
||||
pass "$label: SKIP — Chromium sandbox cannot initialize in this container (namespace creation denied by seccomp/userns policy); launch not exercised here. App boots where the sandbox is permitted (see deb/appimage jobs)."
|
||||
else
|
||||
fail "$detail"
|
||||
fi
|
||||
fi
|
||||
|
||||
kill -TERM -- "-$_smoke_launch_pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -KILL -- "-$_smoke_launch_pid" 2>/dev/null || true
|
||||
wait "$_smoke_launch_pid" 2>/dev/null || true
|
||||
# Sweep any electron child that escaped the group (e.g. zygote).
|
||||
# Under the rpm runuser path PAM re-setsid()s the child into its own
|
||||
# session/process group, so the negative-PID group kills above miss
|
||||
# it entirely — this pkill -f sweep is the ACTUAL reaper there, not a
|
||||
# belt-and-suspenders extra. Don't drop it.
|
||||
if [[ -n $pkill_match ]]; then
|
||||
pkill -KILL -f "$pkill_match" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -rf "$cache_root" "$xvfb_log"
|
||||
_smoke_launch_pid=''
|
||||
_smoke_cache_root=''
|
||||
_smoke_xvfb_log=''
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo
|
||||
echo '================================'
|
||||
|
||||
@@ -6,6 +6,9 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=tests/test-artifact-common.sh
|
||||
source "$script_dir/test-artifact-common.sh"
|
||||
|
||||
# Reap an interrupted launch smoke test (see test-artifact-common.sh).
|
||||
trap _launch_smoke_cleanup EXIT INT TERM
|
||||
|
||||
# Find the .deb file
|
||||
deb_file=$(find "$artifact_dir" -name '*.deb' -type f | head -1)
|
||||
if [[ -z $deb_file ]]; then
|
||||
@@ -23,10 +26,16 @@ else
|
||||
fail "Package name is not claude-desktop"
|
||||
fi
|
||||
|
||||
if [[ $pkg_info == *'Architecture: amd64'* ]]; then
|
||||
pass "Architecture is amd64"
|
||||
# Architecture must match the target we built for. TARGET_ARCH is set by
|
||||
# the CI workflow's per-arch matrix; fall back to the host's dpkg
|
||||
# architecture for standalone/local runs (each CI arch runs on a native
|
||||
# runner, so the host arch matches the package arch there too).
|
||||
expected_arch="${TARGET_ARCH:-$(dpkg --print-architecture 2>/dev/null)}"
|
||||
if [[ -n $expected_arch ]] \
|
||||
&& [[ $pkg_info == *"Architecture: $expected_arch"* ]]; then
|
||||
pass "Architecture is $expected_arch"
|
||||
else
|
||||
fail "Architecture is not amd64"
|
||||
fail "Architecture is not ${expected_arch:-<undetermined>}"
|
||||
fi
|
||||
|
||||
if [[ $pkg_info == *'Version:'* ]]; then
|
||||
@@ -46,6 +55,8 @@ fi
|
||||
# --- File existence checks ---
|
||||
assert_executable '/usr/bin/claude-desktop'
|
||||
assert_file_exists '/usr/share/applications/claude-desktop.desktop'
|
||||
assert_file_exists \
|
||||
'/usr/share/metainfo/io.github.aaddrick.claude-desktop-debian.metainfo.xml'
|
||||
assert_dir_exists '/usr/lib/claude-desktop'
|
||||
assert_file_exists '/usr/lib/claude-desktop/launcher-common.sh'
|
||||
|
||||
@@ -58,6 +69,11 @@ assert_executable "$electron_path"
|
||||
assert_file_exists \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
|
||||
# The build's permission normalization clears the setuid bit; postinst
|
||||
# must re-assert 4755 or the Electron sandbox breaks silently (#695).
|
||||
assert_setuid \
|
||||
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
||||
|
||||
# --- Desktop entry validation ---
|
||||
desktop_file='/usr/share/applications/claude-desktop.desktop'
|
||||
assert_contains "$desktop_file" 'Exec=/usr/bin/claude-desktop' \
|
||||
@@ -99,6 +115,15 @@ assert_contains '/usr/bin/claude-desktop' 'build_electron_args' \
|
||||
resources_dir='/usr/lib/claude-desktop/node_modules/electron/dist/resources'
|
||||
validate_app_contents "$resources_dir"
|
||||
|
||||
# app.asar.unpacked must be world-traversable and root-owned, or
|
||||
# Cowork's auto-launch fs.existsSync() guard silently fails (#695).
|
||||
unpacked_stat=$(stat -c '%a %U:%G' "$resources_dir/app.asar.unpacked")
|
||||
if [[ $unpacked_stat == '755 root:root' ]]; then
|
||||
pass 'app.asar.unpacked is 755 root:root'
|
||||
else
|
||||
fail "app.asar.unpacked is $unpacked_stat (want 755 root:root)"
|
||||
fi
|
||||
|
||||
# --- Doctor smoke test ---
|
||||
# --doctor checks system state; some checks will fail in CI (no display,
|
||||
# etc.) but the script itself should not crash with signal or 127.
|
||||
@@ -110,4 +135,9 @@ else
|
||||
fail "--doctor crashed (exit: $doctor_exit)"
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# ubuntu-latest runs as a non-root user, so no privilege drop needed.
|
||||
run_launch_smoke_test 'deb package' '/usr/lib/claude-desktop' '' \
|
||||
/usr/bin/claude-desktop
|
||||
|
||||
print_summary
|
||||
|
||||
@@ -6,6 +6,16 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=tests/test-artifact-common.sh
|
||||
source "$script_dir/test-artifact-common.sh"
|
||||
|
||||
# Reap an interrupted launch smoke test, then remove the throwaway
|
||||
# unprivileged user the launch drops to (see below / test-artifact-
|
||||
# common.sh).
|
||||
_rpm_cleanup() {
|
||||
_launch_smoke_cleanup
|
||||
[[ -n ${smoke_user:-} ]] \
|
||||
&& userdel -r "$smoke_user" 2>/dev/null
|
||||
}
|
||||
trap _rpm_cleanup EXIT INT TERM
|
||||
|
||||
# Find the .rpm file
|
||||
rpm_file=$(find "$artifact_dir" -name '*.rpm' -type f | head -1)
|
||||
if [[ -z $rpm_file ]]; then
|
||||
@@ -33,6 +43,8 @@ fi
|
||||
# --- File existence checks ---
|
||||
assert_executable '/usr/bin/claude-desktop'
|
||||
assert_file_exists '/usr/share/applications/claude-desktop.desktop'
|
||||
assert_file_exists \
|
||||
'/usr/share/metainfo/io.github.aaddrick.claude-desktop-debian.metainfo.xml'
|
||||
assert_dir_exists '/usr/lib/claude-desktop'
|
||||
assert_file_exists '/usr/lib/claude-desktop/launcher-common.sh'
|
||||
|
||||
@@ -85,6 +97,15 @@ assert_contains '/usr/bin/claude-desktop' 'build_electron_args' \
|
||||
resources_dir='/usr/lib/claude-desktop/node_modules/electron/dist/resources'
|
||||
validate_app_contents "$resources_dir"
|
||||
|
||||
# app.asar.unpacked must be world-traversable and root-owned, or
|
||||
# Cowork's auto-launch fs.existsSync() guard silently fails (#695).
|
||||
unpacked_stat=$(stat -c '%a %U:%G' "$resources_dir/app.asar.unpacked")
|
||||
if [[ $unpacked_stat == '755 root:root' ]]; then
|
||||
pass 'app.asar.unpacked is 755 root:root'
|
||||
else
|
||||
fail "app.asar.unpacked is $unpacked_stat (want 755 root:root)"
|
||||
fi
|
||||
|
||||
# --- Doctor smoke test ---
|
||||
doctor_exit=0
|
||||
/usr/bin/claude-desktop --doctor >/dev/null 2>&1 || doctor_exit=$?
|
||||
@@ -94,4 +115,22 @@ else
|
||||
fail "--doctor crashed (exit: $doctor_exit)"
|
||||
fi
|
||||
|
||||
# --- Headless launch smoke test ---
|
||||
# The container runs as root; Electron aborts as root without
|
||||
# --no-sandbox (which the launcher only adds on Wayland/deb), so drop to
|
||||
# a throwaway unprivileged user. The install is world-readable and
|
||||
# chrome-sandbox is setuid root, so this exercises the real sandbox path
|
||||
# a Fedora user hits. The user is removed by the EXIT trap.
|
||||
# In a non-root env or without useradd, smoke_user stays empty and the
|
||||
# helper runs the launch as-is rather than dropping privileges.
|
||||
smoke_user=''
|
||||
if [[ $(id -u) -eq 0 ]] && command -v useradd &>/dev/null; then
|
||||
smoke_user='claude-smoke'
|
||||
useradd -m "$smoke_user" 2>/dev/null \
|
||||
|| smoke_user=''
|
||||
fi
|
||||
|
||||
run_launch_smoke_test 'rpm package' '/usr/lib/claude-desktop' \
|
||||
"$smoke_user" /usr/bin/claude-desktop
|
||||
|
||||
print_summary
|
||||
|
||||
@@ -64,9 +64,9 @@ write_fixture() {
|
||||
done
|
||||
}
|
||||
|
||||
@test "markers file: at least 9 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 9 ]] || {
|
||||
echo "expected >= 9 markers, got ${#marker_names[@]}"
|
||||
@test "markers file: at least 10 markers loaded" {
|
||||
[[ "${#marker_names[@]}" -ge 10 ]] || {
|
||||
echo "expected >= 10 markers, got ${#marker_names[@]}"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,23 @@ import { readPidArgv, argvHasFlag } from '../lib/argv.js';
|
||||
import { readLauncherLog, captureSessionEnv } from '../lib/diagnostics.js';
|
||||
|
||||
// S12 — `--enable-features=GlobalShortcutsPortal` launcher flag
|
||||
// wired up for GNOME Wayland. Backs QE-6 in
|
||||
// wired up for the native-Wayland path. Backs QE-6 in
|
||||
// docs/testing/quick-entry-closeout.md.
|
||||
//
|
||||
// On GNOME Wayland, mutter no longer honors XWayland-side key grabs,
|
||||
// so the Quick Entry global shortcut fails from unfocused state
|
||||
// (#404). The fix is to route global shortcuts through XDG Desktop
|
||||
// Portal: pass `--enable-features=GlobalShortcutsPortal` to Electron
|
||||
// from the launcher when XDG_CURRENT_DESKTOP includes GNOME and
|
||||
// XDG_SESSION_TYPE is wayland.
|
||||
// (#404). The launcher routes global shortcuts through XDG Desktop
|
||||
// Portal by adding `GlobalShortcutsPortal` to the native-Wayland
|
||||
// `--enable-features` set.
|
||||
//
|
||||
// As of writing, this fix is NOT implemented. The test asserts the
|
||||
// fix's signature (the flag is in the spawned Electron's argv) and
|
||||
// will therefore FAIL on GNOME-W until the launcher patch lands.
|
||||
// That's intentional — it's the regression detector, not a smoke
|
||||
// test. Once the patch is in, this becomes a Critical green cell.
|
||||
// GNOME native Wayland is opt-in (CLAUDE_USE_WAYLAND=1), NOT the
|
||||
// default — flipping the default GNOME session off XWayland is a
|
||||
// rendering/IME risk, and on GNOME 50 the portal route is a no-op
|
||||
// upstream (electron/electron#51875). So this test launches with
|
||||
// CLAUDE_USE_WAYLAND=1 and asserts the flag is present on that
|
||||
// opt-in path. The portal feature is comma-joined with the ozone
|
||||
// features (Chromium honors only the last `--enable-features`), so we
|
||||
// match the subkey, not an exact token.
|
||||
//
|
||||
// Row gate: GNOME Wayland only. KDE rows skip with `-`.
|
||||
|
||||
@@ -41,6 +43,8 @@ test('S12 — --enable-features=GlobalShortcutsPortal launcher flag wired up for
|
||||
const useHostConfig = process.env.CLAUDE_TEST_USE_HOST_CONFIG === '1';
|
||||
const app = await launchClaude({
|
||||
isolation: useHostConfig ? null : undefined,
|
||||
// GNOME native+portal is opt-in; exercise that path explicitly.
|
||||
extraEnv: { CLAUDE_USE_WAYLAND: '1' },
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user