chore: import upstream snapshot with attribution
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:21 +08:00
commit 3e076d4dd9
341 changed files with 80308 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
#===============================================================================
# Common shell utilities: logging, command checks, checksum verification.
#
# Sourced by: build.sh
# Sourced globals: (none)
# Modifies globals: (none)
#===============================================================================
check_command() {
if ! command -v "$1" &> /dev/null; then
echo "$1 not found"
return 1
else
echo "$1 found"
return 0
fi
}
section_header() {
echo -e "\033[1;36m--- $1 ---\033[0m"
}
section_footer() {
echo -e "\033[1;36m--- End $1 ---\033[0m"
}
verify_sha256() {
local file_path="$1"
local expected_hash="$2"
local label="${3:-file}"
if [[ -z $expected_hash ]]; then
echo "Warning: No SHA-256 hash for ${label}," \
'skipping verification' >&2
return 0
fi
echo "Verifying SHA-256 checksum for ${label}..."
local actual_hash _
read -r actual_hash _ < <(sha256sum "$file_path")
if [[ $actual_hash != "$expected_hash" ]]; then
echo "SHA-256 mismatch for ${label}!" >&2
echo " Expected: $expected_hash" >&2
echo " Actual: $actual_hash" >&2
return 1
fi
echo "SHA-256 verified: ${label}"
}
+129
View File
@@ -0,0 +1,129 @@
# Official cowork-linux-helper socket protocol (1.18286.0)
The wire contract the official client speaks to its helper over
`$XDG_RUNTIME_DIR/claude-cowork-vm.sock`, extracted from the beautified
bundle (`build-reference/app-extracted/.vite/build/index.js`, line refs
below drift between releases). `cowork-vm-service.js` implements the
server side of this contract when `COWORK_VM_BACKEND=bwrap` swaps it in
for the native helper.
## Spawn contract
The client spawns the helper itself (`~157177`):
```
<helper> -socket $XDG_RUNTIME_DIR/claude-cowork-vm.sock
```
- stdio is piped; stdout/stderr lines land in `cowork_vm_node.log`.
- Restart backoff is client-owned (exponential 1 s → 60 s, reset after
30 s uptime). The helper just needs to bind the socket and serve.
- On `ENOENT`/`ECONNREFUSED` during a request, the client respawns the
helper and retries (5 attempts, 1 s apart).
- The client connects via `@ant/claude-native`'s
`connectUnixSocketSameUid` — the server process must run as the same
uid (a locally spawned child always does). The socket's parent dir
must be `0700`, same-uid, and the socket path must not be a symlink
(client-side checks at `~157010`/`~157026`).
## Framing
Every connection, both directions: 4-byte big-endian length prefix +
UTF-8 JSON body (`~157249`). Max body 10 MiB (`~157259`).
## Connection modes
The client opens three kinds of connections to the same socket:
1. **One-shot request** (`~157268`): `{method, params?}` (no `id`),
one `{success, result?|error?}` reply, then the client half-closes.
Used always when remote-config flag `770567414` is on, and as the
fallback when the persistent pipe can't connect.
2. **Persistent RPC pipe** (`~157384`): `{method, id, params?}` with an
incrementing numeric `id`; replies `{success, result?|error?, id}`
matched by `id`, out-of-order allowed. Client-side timeout 30 s
(remote-configurable). Immediately after connecting, the client
sends `configure {userDataName, userDataRoot, sessionOnly: true}`
on the pipe.
3. **Event subscription** (`~157539`): client sends
`{method: "subscribeEvents", params: {userDataName, userDataRoot}}`
and expects an ack frame `{success: true}` FIRST, then a stream of
event frames on the same connection (server → client only):
| frame | shape |
|---|---|
| stdout/stderr | `{type, id, data}` |
| exit | `{type: "exit", id, exitCode?, signal?, oomKillCount?}` |
| error | `{type: "error", id, message, fatal?}` |
| networkStatus | `{type: "networkStatus", status}` |
| apiReachability | `{type: "apiReachability", status}` |
| startupStep | `{type: "startupStep", step, status}` |
| guest request | `{type: "request", id, method, params}` |
Guest requests are answered out-of-band by the client via the
`sendGuestResponse` RPC. If the subscription drops, the client
resubscribes after 15 s.
`userDataName`/`userDataRoot` identify the Electron profile (multi-
profile isolation); the server may scope per-profile state by them.
## Methods
`params` / expected `result` (all optional fields may be absent):
| method | params | result consumed by client |
|---|---|---|
| `configure` | `{userDataName, userDataRoot, networkDrives, memoryMB?, cpuCount?, smolBinPath, logDir, virtiofsdPath, firmwareCodePath, firmwareVarsTemplatePath}` (also `{…, sessionOnly: true}` pipe-connect variant) | — |
| `subscribeEvents` | `{userDataName, userDataRoot}` | ack `{success: true}`, then events |
| `createVM` | `{bundlePath, diskSizeGB}` | — |
| `startVM` | `{bundlePath, memoryGB?, cpuCount?, apiProbeURL?}` | — |
| `stopVM` | — | — |
| `isRunning` | — | `{running}` |
| `isGuestConnected` | — | `{connected}` |
| `spawn` | `{id, name, command, args, isResume, cwd?, env?, additionalMounts?, allowedDomains?, oneShot?, mountSkeletonHome?, oauthToken?}` | `{failedMounts?}` |
| `kill` | `{id, signal}` | — |
| `writeStdin` | `{id, data}` | — |
| `isProcessRunning` | `{id}` | `{running, exitCode?}` |
| `sendGuestResponse` | `{id, resultJson?, error?}` | — |
| `setDebugLogging` | `{enabled}` | — |
| `mountPath` | `{processId, subpath, mountName, mode}` | — |
| `readFile` | `{processName, filePath}` | `{content}` |
| `getSessionsDiskInfo` | `{lowWaterBytes}` | `{totalBytes, freeBytes, sessions[]}` |
| `deleteSessionDirs` | `{names[]}` | `{deleted[], errors{}}` |
| `pruneSessionCaches` | `{onlyIfFreeBytesBelow, includeSessionTmp, sessionTmpOlderThanSeconds}` | `{prunedSessions[], skippedSessions[], freedBytes, errors{}}` |
| `installSdk` | `{sdkSubpath, version}` | — |
| `addApprovedOauthToken` | `{token}` | — |
| `getNetworkDrives` | — | `{drives[]}` (Linux client builds `networkDrives` as `[]`) |
`isRunning`, `isGuestConnected`, `isProcessRunning`, `readFile` are
called through an idempotent-retry wrapper — they must be safe to
repeat.
## Boot sequence the server must satisfy
From the client's Linux boot orchestrator (`~283525`):
1. `configure` (RPC) — "Linux VM helper configured" logged on success.
2. `startVM {bundlePath, memoryGB?, cpuCount?, apiProbeURL?}` — must
return success; the client treats a thrown error as `vm_boot_error`.
3. The client then polls `isGuestConnected` (a few times per second,
multi-minute timeout) until `{connected: true}`.
4. `installSdk {sdkSubpath, version}` — the `sdk_install` step.
5. Cowork sessions arrive as `spawn` calls; their stdout/stderr/exit
flow back as events on the subscription connection.
Balloon-related methods (`getMemoryTier`, `enableBalloonMonitoring`,
`getHostMemoryInfo`) are optional-chained client-side and absent from
the Linux client — never called over the wire.
The client's heartbeat and "already connected" fast path both reduce to
`isGuestConnected`, so the server must keep answering it truthfully for
the daemon's lifetime.
## Support gate context (not part of the wire protocol)
The client only dials the helper when the `yukonSilver` evaluator
(`~281877`) reports `supported`, which requires qemu/OVMF/virtiofsd,
`/dev/kvm`, and `/dev/vhost-vsock`. The `COWORK_VM_BACKEND=bwrap` asar
patch short-circuits that evaluator and swaps the spawn target; see
`scripts/patches/cowork-bwrap.sh`.
+44
View File
@@ -0,0 +1,44 @@
# Cowork bwrap fallback (opt-in)
A non-KVM Cowork backend for hosts that can't run the official KVM
microVM. Wired into the build as the `patch_cowork_bwrap` asar patch,
but dormant unless the user launches with `COWORK_VM_BACKEND=bwrap`.
The official v3.x client runs Cowork as coworkd (Go) + QEMU/KVM over a
`SO_PEERCRED` Unix socket, gated on `/dev/kvm` + `/dev/vhost-vsock`.
Hosts like ChromeOS Crostini block `vhost_vsock` at the kernel level and
can never pass that gate ([#772](https://github.com/aaddrick/claude-desktop-debian/issues/772)).
This backend impersonates the helper's socket protocol and backs it with
bubblewrap instead of QEMU.
## How it's wired
- **`cowork-vm-service.js`** — the daemon. Speaks the official helper's
length-prefixed-JSON socket protocol (see [`PROTOCOL.md`](PROTOCOL.md))
and dispatches to a bwrap/host/kvm backend. Shipped to `resources/`
(next to `app.asar`, i.e. `process.resourcesPath`) by `patch_app_asar`
when the patch is active — outside the asar because `child_process`
can't exec from inside one, and outside `app.asar.unpacked` to keep
the repack invariant pinned to upstream's set.
- **`../patches/cowork-bwrap.sh`** (`patch_cowork_bwrap`) — the asar
patch. Three flag-gated injections: report the KVM support evaluator
as `supported`, swap the native-helper spawn for
`node cowork-vm-service.js -socket <path>`, and suppress the unused
VM-image download. Every branch requires
`process.env.COWORK_VM_BACKEND==="bwrap"`, so on unflagged launches
every branch evaluates false and the official path runs unchanged.
- **launcher** (`setup_cowork_bwrap_env` in `launcher-common.sh`) —
resolves a system `node`/`nodejs` and exports `COWORK_NODE_PATH` for
the patched spawn, since the official Electron binary has the
RunAsNode fuse off and can't run the daemon itself.
- **`tests/`** — bats suites for the daemon (backend detection, bwrap
config, guest-path translation). Run from the repo root:
`bats scripts/cowork-fallback/tests/`.
## Protocol note
`PROTOCOL.md` is the wire contract extracted from the official bundle
(`build-reference/app-extracted/.vite/build/index.js`). When upstream
reshapes the helper protocol, re-derive it there and re-verify the
daemon against it — the daemon's job is to be a drop-in for whatever the
current client dials.
File diff suppressed because it is too large Load Diff
+798
View File
@@ -0,0 +1,798 @@
#===============================================================================
# PARKED — not sourced by build.sh. Reference code for the 3.1
# cowork-bwrapd investigation (owner @RayCharlizard); see README.md in
# this directory.
#
# Cowork-mode Linux asar patch (TypeScript VM client -> Unix socket,
# daemon auto-launch, smol-bin copy, sharedCwdPath forwarding, etc.).
# Reroutes the app's VM client to the bwrap-backed daemon in
# cowork-vm-service.js. Written against the Windows-repackage bundle;
# anchors and staging paths need re-verification against official bytes
# before any revival.
#===============================================================================
patch_cowork_linux() {
echo 'Patching Cowork mode for Linux...'
local index_js='app.asar.contents/.vite/build/index.js'
if ! grep -q 'vmClient (TypeScript)' "$index_js"; then
echo ' Cowork mode code not found in this version, skipping'
echo '##############################################################'
return
fi
# All complex patches are done via node to avoid shell escaping issues
# with minified JavaScript. Uses unique string anchors and dynamic
# variable extraction to be version-agnostic per CLAUDE.md guidelines.
if ! INDEX_JS="$index_js" SVC_PATH="cowork-vm-service.js" \
node << 'COWORK_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
let patchCount = 0;
// Helper: extract a balanced block starting at a delimiter.
// Returns the substring from open to close (inclusive), or null.
// Works for {} [] () by specifying the open char.
function extractBlock(str, startIdx, open = '{') {
const close = { '{': '}', '[': ']', '(': ')' }[open];
const blockStart = str.indexOf(open, startIdx);
if (blockStart === -1) return null;
let depth = 1;
let pos = blockStart + 1;
while (depth > 0 && pos < str.length) {
if (str[pos] === open) depth++;
else if (str[pos] === close) depth--;
pos++;
}
return depth === 0 ? str.substring(blockStart, pos) : null;
}
// ============================================================
// Patch 1: VM-supported gate - allow Linux through startVM
// Upstream 1.13576+ replaced the old darwin/win32 platform gate
// with a feature-flag gate ("yukonSilver") inside startVM (VF):
// const{yukonSilver:r}=D_();
// if((r==null?void 0:r.status)!=="supported"){...return}
// On Linux the flag is never "supported", so startVM bails before
// it ever talks to our daemon. Anchor on the unique log string
// "[startVM] VM not supported" to locate the guard, then make Linux
// bypass the support check (mirrors the old "allow Linux through"
// intent). This is load-bearing — FATAL on miss.
// ============================================================
{
const gateAnchor = '[startVM] VM not supported';
const gateIdx = code.indexOf(gateAnchor);
if (/process\.platform!=="linux"&&\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"/.test(code)) {
console.log(' VM-supported Linux gate already applied (Patch 1)');
} else if (gateIdx === -1) {
console.error('FATAL: Could not find startVM support-gate anchor.');
console.error('The app will crash at startup without this patch.');
console.error('The "[startVM] VM not supported" anchor may have changed.');
process.exit(1);
} else {
// Find the nearest yukonSilver support check before the anchor.
const winStart = Math.max(0, gateIdx - 200);
const region = code.substring(winStart, gateIdx);
const supRe = /if\(\(([\w$]+)==null\?void 0:\1\.status\)!=="supported"\)/g;
let m, last = null;
while ((m = supRe.exec(region)) !== null) last = m;
if (!last) {
console.error('FATAL: Could not find yukonSilver support check.');
console.error('The app will crash at startup without this patch.');
console.error('The platform gate structure may have changed.');
process.exit(1);
}
const orig = last[0];
const guardVar = last[1];
const patched = 'if(process.platform!=="linux"&&(' + guardVar +
'==null?void 0:' + guardVar + '.status)!=="supported")';
const absStart = winStart + last.index;
code = code.substring(0, absStart) + patched +
code.substring(absStart + orig.length);
console.log(' Patched VM-supported gate to allow Linux');
patchCount++;
}
}
// ============================================================
// Patch 1b: VM-supported *evaluator* - report supported on Linux
// Patch 1 opens the startVM *execution* gate, but the refactored
// renderer (claude.ai web) gates the Cowork tab's *visibility* on the
// yukonSilver support *evaluator* ($oe -> q4r), a separate consumer.
// q4r() is the Windows capability probe (win32 VM bundle, MSIX via the
// install-type detector, Win10 build, Hyper-V HCS). On Linux it returns
// unsupportedCode:"msix_required" ("...installed with our modern
// installer"), which the web app maps to the grayed-out
// "Cowork requires a newer installation / Reinstall" tab (the daemon is
// up and healthy, but the UI never lets you reach it).
//
// Inject an early Linux return of {status:"supported"} at the top of
// q4r() so the evaluator reports supported. The downstream enterprise/
// user gates in $oe() (secureVmEnabled, coworkSurface.enabled,
// secureVmFeaturesEnabled — default-allow) still apply. Anchor on q4r's
// distinctive win32 + process.arch opening. Do NOT touch the install-
// type detector (see Patch 2's warning). Non-fatal: on a miss the tab
// stays grayed out but the app still runs, so warn rather than exit.
// ============================================================
{
const evalRe =
/(const [\w$]+="win32",([\w$]+)=process\.arch;if\(\2!=="x64"&&\2!=="arm64"\))/;
if (/if\(process\.platform==="linux"\)return\{status:"supported"\};const [\w$]+="win32"/.test(code)) {
console.log(' VM-supported evaluator Linux gate already' +
' applied (Patch 1b)');
} else {
const evalMatch = code.match(evalRe);
if (!evalMatch) {
console.log(' WARNING: could not find q4r support-evaluator' +
' anchor (win32/arch probe) — Cowork tab may stay grayed' +
' out on Linux (renderer reads the support evaluator)');
} else {
code = code.replace(evalRe,
'if(process.platform==="linux")return{status:"supported"};$1');
console.log(' Patched VM-supported evaluator to report' +
' supported on Linux');
patchCount++;
}
}
}
// ============================================================
// Patch 1c: keep the VM-image download DISABLED on Linux
// Patch 1b flips the yukonSilver evaluator to "supported" so the
// renderer un-grays the Cowork tab. But the evaluator is ALSO read by
// the VM-image download drivers, which gate on
// yukonSilver.status==="supported". With 1b alone they re-arm and pull
// the multi-GB rootfs.vhdx/vmlinuz/initrd VM bundle that #337/a3190c3
// deliberately disabled — Linux runs cowork through the bwrap daemon,
// not a downloaded VM. Re-block the two download triggers on Linux so
// they behave as they did pre-1b (the old status="unsupported" path):
// - download driver (startVM's download_and_sdk_prepare): returns !1
// - warm prefetch (autoDownloadInBackground): early-returns
// startVM itself stays open (Patch 1), so the bwrap session is
// unaffected. Two sites: flag each; a non-fatal WARNING fires if either
// misses, so a half-applied build surfaces in CI's WARNING grep.
// ============================================================
{
let dlDriverDone = false, warmDone = false;
// Site A: download driver — (X==null?void 0:X.status)!=="supported"?!1:
// The unique "[downloadVM] Download already in progress" log lives in
// the same function, confirming this is the VM-image driver gate.
const dlGateRe =
/(\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported")\?!1:/;
if (/process\.platform==="linux"\|\|\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\)\?!1:/.test(code)) {
console.log(' VM-download Linux block already applied (Patch 1c-A)');
dlDriverDone = true;
} else if (dlGateRe.test(code) &&
code.includes('[downloadVM] Download already in progress')) {
code = code.replace(dlGateRe,
'(process.platform==="linux"||$1)?!1:');
console.log(' Patched VM-download driver to skip on Linux');
dlDriverDone = true;
patchCount++;
}
// Site B: warm prefetch — if(!X||X.status!=="supported"){await Y([]);return}
const warmGateRe =
/(if\()(![\w$]+\|\|[\w$]+\.status!=="supported")(\)\{await [\w$]+\(\[\]\);return\})/;
if (/if\(process\.platform==="linux"\|\|![\w$]+\|\|[\w$]+\.status!=="supported"\)\{await [\w$]+\(\[\]\);return\}/.test(code)) {
console.log(' Warm-download Linux block already applied (Patch 1c-B)');
warmDone = true;
} else if (warmGateRe.test(code)) {
code = code.replace(warmGateRe,
'$1process.platform==="linux"||$2$3');
console.log(' Patched warm prefetch to skip on Linux');
warmDone = true;
patchCount++;
}
if (!dlDriverDone || !warmDone) {
console.log(' WARNING: VM-download block partial — driver=' +
dlDriverDone + ' warm=' + warmDone + '; Linux may re-arm the' +
' rootfs.vhdx download (#337) now that the evaluator reports' +
' supported (Patch 1b)');
}
}
// ============================================================
// Patch 2: Module loading - use TypeScript VM client on Linux
// Anchor: unique string "vmClient (TypeScript)"
// Upstream 1.13576+ gates the vmClient module load behind Rl()
// (the MSIX/install-type detector) inside YBt():
// async function YBt(){return Rl()?QL||QrA||(...,"vmClient
// (TypeScript)"...QL={vm:hji}...):null}
// Both the log line and the {vm:...} assignment now live in this
// one Rl()?...:null expression, so widening the gate covers the
// old Patch 2a + 2b at once. Do NOT patch the gate fn itself — it
// also drives the isMsix install-type detection and would mis-flag
// Linux as an MSIX install.
// ============================================================
{
const vmAnchor = '"vmClient (TypeScript)"';
const vmIdx = code.indexOf(vmAnchor);
const winStart = Math.max(0, vmIdx - 400);
if (vmIdx === -1) {
console.log(' WARNING: vmClient (TypeScript) anchor not found' +
' — Cowork module load gate not patched');
} else if (/\([\w$]+\(\)\|\|process\.platform==="linux"\)\?/.test(
code.substring(winStart, vmIdx))) {
console.log(' vmClient Linux load gate already applied (Patch 2)');
} else {
// Find the `return FN()?` gate immediately before the log
// string (scoped so the install-type `Rl()?"msix":...` ternary
// isn't touched). FN is the minified isMsix detector and
// changes between releases, so capture it dynamically.
const region = code.substring(winStart, vmIdx);
const gateRe = /return ([\w$]+)\(\)\?/g;
let m, last = null;
while ((m = gateRe.exec(region)) !== null) last = m;
if (!last) {
console.log(' WARNING: could not find `return FN()?` gate' +
' before vmClient log — module load not patched');
} else {
const fnName = last[1];
const absStart = winStart + last.index;
const orig = 'return ' + fnName + '()?';
const patched =
'return (' + fnName + '()||process.platform==="linux")?';
code = code.substring(0, absStart) + patched +
code.substring(absStart + orig.length);
console.log(' Patched vmClient module load gate for Linux' +
' (gate fn: ' + fnName + ')');
patchCount++;
}
}
}
// ============================================================
// Patch 3: Socket path - use Unix domain socket on Linux
// Anchor: unique string "cowork-vm-service" in pipe path
// ============================================================
const pipeMatch = code.match(/([\w$]+)(\s*=\s*)"([^"]*\\\\[^"]*cowork-vm-service[^"]*)"/);
if (pipeMatch) {
const pipeVar = pipeMatch[1];
const assign = pipeMatch[2];
const pipeStr = pipeMatch[3];
const oldExpr = pipeVar + assign + '"' + pipeStr + '"';
const newExpr = pipeVar + assign +
'process.platform==="linux"?' +
'(process.env.XDG_RUNTIME_DIR||"/tmp")+"/cowork-vm-service.sock"' +
':"' + pipeStr + '"';
code = code.replace(oldExpr, newExpr);
console.log(' Patched socket path for Linux Unix domain socket');
patchCount++;
} else {
console.log(' WARNING: Could not find pipe path for socket patch');
}
// ============================================================
// Patch 4: Bundle manifest - add empty Linux entries to files
// The linux key MUST exist to prevent TypeError when the app
// accesses files["linux"]["x64"] during cowork status checks.
// Empty arrays mean no VM files are downloaded — this is correct
// because the VM backend is non-functional on Linux (bwrap is
// the only working backend and doesn't use VM files).
// Note: [].every() returns true (vacuous truth), so iBA() reports
// that VM files are present. That makes the download() IPC
// short-circuit without fetching anything, which is the intent
// here. Patch 4b handles the downstream side-effect on
// getDownloadStatus() so the Cowork tab doesn't auto-select on
// every launch (#341).
// ============================================================
if (!code.includes('"linux":{') && !code.includes("'linux':{") &&
!code.includes('linux:{')) {
const shaRe = /sha\s*:\s*"([a-f0-9]{40})"/;
const shaMatch = code.match(shaRe);
if (shaMatch) {
const shaIdx = code.indexOf(shaMatch[0]);
const afterSha = code.indexOf('files', shaIdx);
if (afterSha !== -1 && afterSha - shaIdx < 200) {
const filesBlock = extractBlock(code, afterSha, '{');
if (filesBlock) {
const filesEnd = code.indexOf(filesBlock, afterSha)
+ filesBlock.length;
const insertPos = filesEnd - 1;
const linuxEntry = ',linux:{x64:[],arm64:[]}';
code = code.substring(0, insertPos) +
linuxEntry + code.substring(insertPos);
console.log(' Added empty Linux entries to' +
' bundle manifest (VM download disabled)');
patchCount++;
}
}
}
if (!code.includes('linux:{x64:')) {
console.log(' WARNING: Could not add Linux bundle' +
' manifest entries');
}
}
// ============================================================
// Patch 4b: Suppress Cowork tab auto-selection on launch (#341)
// Anchor: getDownloadStatus() method with readable enum property
// names (.Downloading, .Ready, .NotDownloaded) — stable
// across minifier releases.
//
// Patch 4's vacuous-truth workaround makes iBA() report that VM
// files are "ready", which is what short-circuits the download
// path. The side-effect is that getDownloadStatus() also returns
// Ready on every startup, and the remote web app treats a
// startup observation of Ready as the "download just finished"
// transition that auto-navigates to Cowork on macOS/Windows.
// Linux users hit that transition on every launch.
//
// Fix: return NotDownloaded on Linux from getDownloadStatus().
// iBA() is left alone so download() still short-circuits, and
// clicking the Cowork tab still works (the web app's setup flow
// calls download() which returns success immediately).
// ============================================================
{
const statusRe = /getDownloadStatus\(\)\{return\s+([\w$]+\(\)\?([\w$]+)\.Downloading:[\w$]+\(\)\?\2\.Ready:\2\.NotDownloaded)\}/;
const statusMatch = code.match(statusRe);
if (statusMatch) {
const [whole, origExpr, enumVar] = statusMatch;
const replacement =
'getDownloadStatus(){return process.platform==="linux"?' +
enumVar + '.NotDownloaded:' + origExpr + '}';
code = code.replace(whole, replacement);
console.log(' Patched getDownloadStatus to return ' +
'NotDownloaded on Linux (suppresses auto-nav, #341)');
patchCount++;
} else if (code.includes(
'getDownloadStatus(){return process.platform==="linux"?'
)) {
console.log(' Cowork auto-nav suppression already applied');
} else {
console.log(' WARNING: Could not find getDownloadStatus' +
' pattern for auto-nav suppression (#341)');
}
}
// ============================================================
// Patch 5: MSIX check bypass for Linux
// The fz() function checks: if(t==="win32"&&!ga()) for MSIX
// This is already gated to win32, so no change needed.
// ============================================================
// ============================================================
// Patch 6: Auto-launch service daemon on first connection attempt
// Anchor: unique string "VM service not running. The service failed to start."
//
// The retry loop only retries on ENOENT (socket missing). On Linux,
// stale sockets from a previous session give ECONNREFUSED instead,
// which causes an immediate throw with no retry or auto-launch.
//
// Fix: patch the ENOENT check to also match ECONNREFUSED on Linux,
// then inject auto-launch before the retry delay.
//
// The auto-launch uses a timestamp-based cooldown (_lastSpawn) instead
// of a one-shot boolean so the daemon can be re-spawned after it dies
// mid-session (issue #408). 10s cooldown prevents fork storms on hard
// failures while allowing recovery on the next retry iteration.
//
// stdout/stderr of the forked daemon is piped to
// ~/.config/Claude/logs/cowork_vm_daemon.log so crashes are no longer
// silent. Falls back to "ignore" if the log dir can't be opened.
// ============================================================
const serviceErrorStr = 'VM service not running. The service failed to start.';
const serviceErrorIdx = code.lastIndexOf(serviceErrorStr);
if (serviceErrorIdx !== -1) {
// Step 1: Find the ENOENT check and expand it to include ECONNREFUSED
// Pattern: VAR.code==="ENOENT"
// Search backwards from the error string to find it
if (/process\.platform==="linux"&&[\w$]+\.code==="ECONNREFUSED"/.test(code)) {
console.log(' ENOENT/ECONNREFUSED expansion already applied');
} else {
const searchStart = Math.max(0, serviceErrorIdx - 300);
const beforeRegion = code.substring(searchStart, serviceErrorIdx);
const enoentRe = /([\w$]+)\.code\s*===\s*"ENOENT"/g;
let enoentMatch;
let lastEnoent = null;
while ((enoentMatch = enoentRe.exec(beforeRegion)) !== null) {
lastEnoent = enoentMatch;
}
if (lastEnoent) {
const enoentStr = lastEnoent[0];
const errVar = lastEnoent[1];
const enoentAbsIdx = searchStart + lastEnoent.index;
// Replace: VAR.code==="ENOENT"
// With: (VAR.code==="ENOENT"||process.platform==="linux"&&VAR.code==="ECONNREFUSED")
const expanded =
'(' + enoentStr +
'||process.platform==="linux"&&' + errVar + '.code==="ECONNREFUSED")';
code = code.substring(0, enoentAbsIdx) +
expanded +
code.substring(enoentAbsIdx + enoentStr.length);
console.log(' Expanded ENOENT check to include ECONNREFUSED on Linux');
} else {
console.log(' WARNING: Could not find ENOENT check for ECONNREFUSED expansion');
}
}
// Step 2: Inject auto-launch before the retry delay
if (code.includes('cowork-autolaunch')) {
console.log(' Service daemon auto-launch already applied');
} else {
// Re-find serviceErrorStr since indices shifted after step 1
const newServiceErrorIdx = code.lastIndexOf(serviceErrorStr);
const searchEnd = Math.min(code.length, newServiceErrorIdx + 300);
const searchRegion = code.substring(newServiceErrorIdx, searchEnd);
// Upstream 1.13576+ replaced the inline retry delay
// `await new Promise(r=>setTimeout(r,N))` with a helper call
// `await dn(Eji)`. Match the single-arg awaited delay call
// (two-arg awaits like `await Cji(A,e)` won't match).
const retryMatch = searchRegion.match(
/await [\w$]+\([\w$]+\)/
);
if (retryMatch) {
const retryStr = retryMatch[0];
const retryOffset = searchRegion.indexOf(retryStr);
const retryAbsIdx = newServiceErrorIdx + retryOffset;
// Inject auto-launch before the retry delay
// Service script is in app.asar.unpacked/ (not inside asar, since
// child_process cannot execute scripts from inside an asar).
// Uses fork() instead of spawn() because process.execPath in Electron
// is the Electron binary - spawn would trigger "file open" handling
// instead of executing the script as Node.js.
const svcPath = process.env.SVC_PATH || 'cowork-vm-service.js';
// Extract the enclosing function name (Ma or whatever it's
// minified to) so the dedup guard attaches to it
const funcSearchStart = Math.max(0, newServiceErrorIdx - 2000);
const funcRegion = code.substring(funcSearchStart, newServiceErrorIdx);
// The function is defined as: async function NAME(t,e){...for(let r=0;r<=LIMIT;r++)
const funcNameRe = /async function ([$\w]+)\s*\(\s*[$\w]+\s*,\s*[$\w]+\s*\)\s*\{[\s\S]*?for\s*\(\s*let/g;
let funcMatch;
let retryFuncName = null;
while ((funcMatch = funcNameRe.exec(funcRegion)) !== null) {
retryFuncName = funcMatch[1];
}
const spawnGuard = retryFuncName
? retryFuncName + '._lastSpawn'
: 'globalThis._lastSpawn';
// Cooldown in ms — long enough to avoid fork storms, short enough
// that the retry loop can re-spawn after a mid-session daemon death.
const autoLaunch =
'process.platform==="linux"&&' +
'(!' + spawnGuard + '||Date.now()-' + spawnGuard + '>1e4)' +
'&&(' + spawnGuard + '=Date.now(),' +
'(()=>{try{' +
'const _p=require("path"),_fs=require("fs");' +
'const _d=_p.join(process.resourcesPath,' +
'"app.asar.unpacked","' + svcPath + '");' +
'if(_fs.existsSync(_d)){' +
// Open daemon log for append; fall back to ignoring stdio.
'let _stdio="ignore";' +
'try{' +
'const _ld=_p.join(process.env.HOME||"/tmp",' +
'".config/Claude/logs");' +
'_fs.mkdirSync(_ld,{recursive:true});' +
'const _fd=_fs.openSync(' +
'_p.join(_ld,"cowork_vm_daemon.log"),"a");' +
'_stdio=["ignore",_fd,_fd,"ipc"]' +
'}catch(_){}' +
'const _c=require("child_process").fork(_d,[],' +
'{detached:true,stdio:_stdio,env:{...process.env,' +
'ELECTRON_RUN_AS_NODE:"1"}});' +
'global.__coworkDaemonPid=_c.pid;_c.unref()}' +
'}catch(_e){console.error("[cowork-autolaunch]",_e)}})()),';
code = code.substring(0, retryAbsIdx) +
autoLaunch + code.substring(retryAbsIdx);
console.log(' Added service daemon auto-launch on Linux');
patchCount++;
} else {
console.log(' WARNING: Could not find retry delay for auto-launch patch');
}
}
} else {
console.log(' WARNING: Could not find VM service error string for auto-launch');
}
// ============================================================
// Patch 6b: Extend auto-reinstall delete list (issue #408)
// Anchor: const NAME=["rootfs.img",...] — the module-level array
// driving the reinstall-files cleanup in _ue()/deleteVMBundle().
//
// NOTE (1.13576+/yukonSilver): rootfs.img now appears only in
// object form ([{name:"rootfs.img",...}]); the string-array anchor
// is gone, so this WARNs and is a safe no-op on the current bundle
// (the Linux VM-download path is disabled by Patch 4 anyway). It
// auto-reactivates if upstream restores the string array.
//
// Upstream preserves sessiondata.img and rootfs.img.zst across
// auto-reinstall to avoid re-download. On 1.2773.0, preserving
// them puts the daemon into an unstartable state that persists
// across app restarts and OS reboots. Trade-off: next startup
// re-downloads/re-extracts these files. This only runs on the
// auto-reinstall path (already in a failed state), so biasing
// toward recovery over re-download avoidance is correct.
// ============================================================
{
const reinstallArrRe = /const ([\w$]+)=\[("rootfs\.img"[^\]]*)\];/;
const arrMatch = code.match(reinstallArrRe);
if (arrMatch) {
const [whole, name, contents] = arrMatch;
const additions = [];
if (!contents.includes('"sessiondata.img"')) {
additions.push('"sessiondata.img"');
}
if (!contents.includes('"rootfs.img.zst"')) {
additions.push('"rootfs.img.zst"');
}
if (additions.length) {
const newContents = contents + ',' + additions.join(',');
code = code.replace(
whole,
'const ' + name + '=[' + newContents + '];'
);
console.log(' Added VM images to reinstall delete list');
patchCount++;
} else {
console.log(' Reinstall delete list already includes VM images');
}
} else {
console.log(' WARNING: Could not find reinstall file list array');
}
}
// ============================================================
// Patch 7: Skip Windows-specific smol-bin.vhdx copy on Linux
// The code already checks: if(process.platform==="win32")
// No change needed - win32-gated code is skipped on Linux.
// ============================================================
// ============================================================
// Patch 8: VM download tmpdir fix for Linux
// On Linux, os.tmpdir() returns /tmp which is often a small
// tmpfs (3-4GB). The VM rootfs download decompresses to ~9GB,
// causing ENOSPC. Patch to use the bundle directory (on real
// disk) instead of tmpfs for the download temp files.
// Anchor: unique string "wvm-" in mkdtemp call
// Strategy: find the bundle dir variable from nearby mkdir(),
// then replace tmpdir() with that variable in the mkdtemp call.
//
// NOTE (1.13576+/yukonSilver): the mkdtemp(os.tmpdir(),"wvm-")
// shape is gone (the temp constant is now ".wvm-tmp-"), so this
// WARNs and is a safe no-op — the Linux VM-download path that
// would hit /tmp ENOSPC is disabled by Patch 4. Re-derive if the
// rootfs-download path is ever re-enabled on Linux.
// ============================================================
{
// Find: MKDTEMP(PATH.join(OS.tmpdir(), "wvm-"))
// The bundle dir var is used in mkdir(VAR, ...) just before
const mkdtempRe = /([\w$]+)\.mkdtemp\(\s*([\w$]+)\.join\(\s*([\w$]+)\.tmpdir\(\)\s*,\s*"wvm-"\s*\)\s*\)/;
const mkdtempMatch = code.match(mkdtempRe);
if (mkdtempMatch) {
const [fullMatch, fsVar, pathVar, osVar] = mkdtempMatch;
// Find the bundle dir variable: mkdir(VAR, { recursive before wvm-
const mkdtempIdx = code.indexOf(fullMatch);
const searchStart = Math.max(0, mkdtempIdx - 2000);
const before = code.substring(searchStart, mkdtempIdx);
// Look for: mkdir(VARNAME, { recursive
const mkdirRe = /([\w$]+)\.mkdir\(\s*([\w$]+)\s*,\s*\{\s*recursive/g;
let bundleVar = null;
let lastMkdir;
while ((lastMkdir = mkdirRe.exec(before)) !== null) {
bundleVar = lastMkdir[2];
}
if (bundleVar) {
// Replace os.tmpdir() with the bundle dir variable
// On Linux, use the bundle dir; on other platforms keep tmpdir
const replacement =
`${fsVar}.mkdtemp(${pathVar}.join(` +
`process.platform==="linux"?${bundleVar}:${osVar}.tmpdir(),` +
`"wvm-"))`;
code = code.substring(0, mkdtempIdx) + replacement +
code.substring(mkdtempIdx + fullMatch.length);
console.log(' Patched VM download temp dir to use bundle path on Linux');
patchCount++;
} else {
console.log(' WARNING: Could not find bundle dir variable for tmpdir patch');
}
} else {
console.log(' WARNING: Could not find mkdtemp("wvm-") for tmpdir patch');
}
}
// ============================================================
// Patch 9: Copy smol-bin VHDX on Linux
// The win32 block copies smol-bin then calls _.configure()
// (Windows HCS setup) which causes "Request timed out" on
// Linux (#315). Inject a separate Linux block after the win32
// block that only does the smol-bin copy.
// Variable names are extracted dynamically from the win32 block
// since minified names change between releases (#344).
// ============================================================
{
// Idempotency: key on the fork's OWN injected log ("…to bundle
// (Linux)"), NOT the generic "[VM:start] Copying smol-bin" string
// — upstream now ships its own (win32-gated) smol-bin copy that
// emits the latter, which would falsely report "already present".
if (code.includes('smol-bin.${_la}.vhdx to bundle (Linux)')) {
console.log(' Linux smol-bin copy block already present');
} else {
const anchor = '"[VM:start] Windows VM service configured"';
const anchorIdx = code.indexOf(anchor);
if (anchorIdx !== -1) {
// Find the "}" closing the win32 if-block after the anchor
const closingBrace = code.indexOf('}', anchorIdx + anchor.length);
if (closingBrace !== -1) {
// Extract minified variable names from the win32 block
// Search backwards from anchor to find the win32 block
const regionStart = Math.max(0, anchorIdx - 1000);
const region = code.substring(regionStart, anchorIdx);
// JS identifier may start with $, _, or letter; \w doesn't
// match $ so use [$\w]+ to capture vars like `$e` (Claude
// >= 1.3109.0 uses $e for the fs module to avoid collision
// with the parameter `e`). See issue #418.
// path var: VAR.join(process.resourcesPath,
const pathMatch = region.match(
/([$\w]+)\.join\(\s*process\.resourcesPath\s*,/
);
// fs var: VAR.existsSync(
const fsMatch = region.match(/([$\w]+)\.existsSync\(/);
// logger var: VAR.info("[VM:start]
const logMatch = region.match(
/([$\w]+)\.info\(\s*[`"]\[VM:start\]/
);
// stream/pipeline var: VAR.pipeline(
const streamMatch = region.match(/([$\w]+)\.pipeline\(/);
// arch function: const VAR=FUNC(), used in smol-bin
const archMatch = region.match(
/const\s+([$\w]+)\s*=\s*([$\w]+)\(\)\s*,\s*[$\w]+\s*=\s*[$\w]+\.join/
);
// bundlePath var: PATH.join(VAR,"smol-bin.vhdx")
const bundleMatch = region.match(
/\.join\(\s*([$\w]+)\s*,\s*"smol-bin\.vhdx"\s*\)/
);
if (pathMatch && fsMatch && logMatch &&
streamMatch && archMatch && bundleMatch) {
const pathVar = pathMatch[1];
const fsVar = fsMatch[1];
const logVar = logMatch[1];
const streamVar = streamMatch[1];
const archFunc = archMatch[2];
const bundleVar = bundleMatch[1];
const linuxBlock =
'if(process.platform==="linux"){' +
'const _la=' + archFunc + '(),' +
'_ls=' + pathVar + '.join(process.resourcesPath,' +
'`smol-bin.${_la}.vhdx`),' +
'_ld=' + pathVar + '.join(' + bundleVar +
',"smol-bin.vhdx");' +
fsVar + '.existsSync(_ls)?' +
'(' + logVar + '.info(' +
'`[VM:start] Copying smol-bin.${_la}' +
'.vhdx to bundle (Linux)`),' +
'await ' + streamVar + '.pipeline(' +
fsVar + '.createReadStream(_ls),' +
fsVar + '.createWriteStream(_ld)),' +
logVar + '.info(' +
'`[VM:start] smol-bin.${_la}' +
'.vhdx copied successfully`))' +
':' + logVar + '.warn(' +
'`[VM:start] smol-bin.${_la}' +
'.vhdx not found at ${_ls}`)' +
'}';
// Defensive: if a future upstream emits its own
// if(process.platform==="linux"){...} block right
// after the win32 close brace, strip it before
// injecting our correctly-wired linuxBlock so we
// don't end up with two competing blocks.
const insertPos = closingBrace + 1;
let stripUntil = insertPos;
const afterWin32 = code.substring(insertPos);
const upstreamRe = /^\s*if\s*\(\s*process\.platform\s*===\s*"linux"\s*\)\s*\{/;
const upstreamMatch = afterWin32.match(upstreamRe);
if (upstreamMatch) {
const matchEnd = insertPos + upstreamMatch[0].length;
let depth = 1, pos = matchEnd;
while (depth > 0 && pos < code.length) {
if (code[pos] === '{') depth++;
else if (code[pos] === '}') depth--;
pos++;
}
if (depth === 0) {
stripUntil = pos;
console.log(' Stripped pre-existing upstream Linux block');
} else {
console.log(' WARNING: Upstream Linux block found but braces unbalanced; not stripping');
}
}
code = code.substring(0, insertPos) +
linuxBlock +
code.substring(stripUntil);
console.log(' Injected Linux smol-bin copy block (skips _.configure)');
console.log(` vars: path=${pathVar} fs=${fsVar} log=${logVar} stream=${streamVar} arch=${archFunc} bundle=${bundleVar}`);
patchCount++;
} else {
const missing = [];
if (!pathMatch) missing.push('path');
if (!fsMatch) missing.push('fs');
if (!logMatch) missing.push('logger');
if (!streamMatch) missing.push('stream');
if (!archMatch) missing.push('arch');
if (!bundleMatch) missing.push('bundlePath');
console.log(` WARNING: Could not extract minified variable(s): ${missing.join(', ')}`);
}
} else {
console.log(' WARNING: Could not find closing brace after Windows VM service anchor');
}
} else {
console.log(' WARNING: Could not find Windows VM service anchor for smol-bin patch');
}
}
}
// ============================================================
// Patch 10: Register quit handler for cowork daemon cleanup
// The upstream vm-shutdown handler uses a Swift addon unavailable
// on Linux. Register our own to SIGTERM the daemon on app quit.
// ============================================================
{
if (code.includes('cowork-linux-daemon-shutdown')) {
console.log(' Linux cowork daemon quit handler already registered');
} else {
const quitFnRe = /registerQuitHandler:\s*([\w$]+)/;
const quitFnMatch = code.match(quitFnRe);
if (quitFnMatch) {
const quitFn = quitFnMatch[1];
console.log(' Found registerQuitHandler function: ' + quitFn);
const quitFnDef = 'function ' + quitFn + '(';
const quitFnDefIdx = code.indexOf(quitFnDef);
if (quitFnDefIdx !== -1) {
const fnBlock = extractBlock(code, quitFnDefIdx, '{');
if (fnBlock) {
const insertIdx = code.indexOf(fnBlock, quitFnDefIdx) +
fnBlock.length;
const shutdownHandler =
'process.platform==="linux"&&' + quitFn + '({' +
'name:"cowork-linux-daemon-shutdown",' +
'fn:async()=>{' +
'const _p=global.__coworkDaemonPid;' +
'if(!_p)return;' +
'try{const _cmd=require("fs").readFileSync(' +
'"/proc/"+_p+"/cmdline","utf8");' +
'if(!_cmd.includes("cowork-vm-service"))return' +
'}catch(_e){return}' +
'try{process.kill(_p,"SIGTERM")}catch(_e){return}' +
'for(let _i=0;_i<50;_i++){' +
'await new Promise(_r=>setTimeout(_r,200));' +
'try{process.kill(_p,0)}catch(_e){return}' +
'}}});';
code = code.substring(0, insertIdx) +
shutdownHandler + code.substring(insertIdx);
console.log(' Registered Linux cowork daemon quit handler');
patchCount++;
} else {
console.log(' WARNING: Could not find ' + quitFn +
' function body for quit handler');
}
} else {
console.log(' WARNING: Could not find ' + quitFn +
' function definition');
}
} else {
console.log(' WARNING: Could not find registerQuitHandler' +
' export for quit handler');
}
}
}
fs.writeFileSync(indexJs, code);
console.log(` Applied ${patchCount} cowork patches`);
if (patchCount < 5) {
console.log(' WARNING: Some patches failed - Cowork mode may not work');
}
COWORK_PATCH
then
echo 'WARNING: Cowork Linux patches failed' >&2
echo 'Cowork mode may not be available on Linux' >&2
fi
echo '##############################################################'
}
@@ -0,0 +1,183 @@
#!/usr/bin/env bats
#
# cowork-backend-detection.bats
# Tests for classifyBwrapProbeError — diagnoses why the bwrap sandbox
# probe failed so the daemon can emit actionable errors instead of
# silently falling through to a broken KVM backend (issue #351).
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
NODE_PREAMBLE='
const {
classifyBwrapProbeError,
} = require("'"${SCRIPT_DIR}"'/../cowork-vm-service.js");
function assert(condition, msg) {
if (!condition) {
process.stderr.write("ASSERTION FAILED: " + msg + "\n");
process.exit(1);
}
}
function assertEqual(actual, expected, msg) {
assert(actual === expected,
msg + " expected=" + JSON.stringify(expected) +
" actual=" + JSON.stringify(actual));
}
function mkErr(stderr, message) {
return {
message: message || "Command failed",
stderr: Buffer.from(stderr || ""),
stdout: Buffer.from(""),
};
}
'
# =============================================================================
# classifyBwrapProbeError — AppArmor / userns denials (the #351 case)
# =============================================================================
@test "classifyBwrapProbeError: bwrap EPERM on user namespace" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: Creating new user namespace: Operation not permitted');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'EPERM on userns should classify as userns');
assert(r.stderr.includes('user namespace'), 'stderr is preserved');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: AppArmor denial message" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: setting up uid map: Permission denied');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'uid map denial should classify as userns');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: explicit apparmor keyword" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('denied by AppArmor policy');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'apparmor keyword should classify as userns');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: CLONE_NEWUSER keyword in kernel log" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: unshare: CLONE_NEWUSER failed: EPERM');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'CLONE_NEW* should classify as userns');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: CAP_SYS_ADMIN hint" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('need CAP_SYS_ADMIN to create user namespace');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'CAP_SYS_ADMIN hint should classify as userns');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# classifyBwrapProbeError — non-userns failures
# =============================================================================
@test "classifyBwrapProbeError: unrelated bwrap failure" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: No such file or directory: /does-not-exist');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'unknown', 'unrelated errors should classify as unknown');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: spawn ENOENT has no stderr" {
run node -e "${NODE_PREAMBLE}
const e = { message: 'spawn bwrap ENOENT', code: 'ENOENT' };
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'unknown', 'ENOENT without userns text is unknown');
assertEqual(r.stderr, '', 'missing stderr normalized to empty string');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: empty error object" {
run node -e "${NODE_PREAMBLE}
const r = classifyBwrapProbeError({});
assertEqual(r.kind, 'unknown', 'empty error is unknown, not a crash');
assertEqual(r.stderr, '', 'missing stderr normalized to empty string');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: null-safe" {
run node -e "${NODE_PREAMBLE}
const r = classifyBwrapProbeError(null);
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}"'/../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
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
#!/usr/bin/env bats
#
# cowork-bwrap-patch.bats
# Guards patch_cowork_bwrap against a fixture carrying the four anchor
# shapes (copied verbatim from the official 1.18286.0 bundle): all
# injections land, the swapped spawn selects node+daemon only when
# flagged, re-runs are no-ops, and a missing or duplicated load-bearing
# anchor fails the build with the file left untouched.
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
PATCH_SH="${SCRIPT_DIR}/../../patches/cowork-bwrap.sh"
# Minimal fixture reproducing the exact anchor shapes the patch targets.
# The spawn site is wrapped in a callable so the injected expression can
# be evaluated with stub IE/A/Vie bindings.
fixture() {
cat <<'JS'
function Ben(){return process.platform,Cen()}
function Cen(){return{status:"unsupported"}}
function ygi(A){return IE.spawn(A,["-socket",Vie()],{stdio:["pipe","pipe","pipe"]})}
async function OzA(A,e){const{yukonSilver:t}=sM();return(t==null?void 0:t.status)!=="supported"?!1:doDownload()}
async function Vdo(A,e,t){if(!e){log("[warm] Warm download disabled");return}return warmPrefetch()}
JS
}
setup() {
WORK="$(mktemp -d)"
mkdir -p "$WORK/app.asar.contents/.vite/build"
fixture > "$WORK/app.asar.contents/.vite/build/index.js"
# shellcheck source=scripts/patches/cowork-bwrap.sh
source "$PATCH_SH"
}
teardown() {
rm -rf "$WORK"
}
target() { printf '%s' "$WORK/app.asar.contents/.vite/build/index.js"; }
@test "patch: applies all four injections and reports success" {
cd "$WORK"
run patch_cowork_bwrap
echo "$output"
[ "$status" -eq 0 ]
[[ "$output" == *"A: gated yukonSilver"* ]]
[[ "$output" == *"B: swapped helper spawn"* ]]
[[ "$output" == *"C1: blocked foreground"* ]]
[[ "$output" == *"C2: blocked warm"* ]]
}
@test "patch: injected markers present and bundle still parses" {
cd "$WORK"
patch_cowork_bwrap
run node --check "$(target)"
[ "$status" -eq 0 ]
grep -q '/\*cowork-bwrap-spawn\*/' "$(target)"
grep -q '/\*cowork-bwrap-dl\*/' "$(target)"
grep -q '/\*cowork-bwrap-warm\*/' "$(target)"
grep -q 'COWORK_VM_BACKEND==="bwrap")return{status:"supported"}' "$(target)"
}
@test "patch: evaluator returns supported only when flagged" {
cd "$WORK"
patch_cowork_bwrap
# flagged -> supported
run env COWORK_VM_BACKEND=bwrap node -e "
$(cat "$(target)")
process.exit(Ben().status==='supported'?0:1)"
[ "$status" -eq 0 ]
# unflagged -> falls through to the real (unsupported) evaluator
run env -u COWORK_VM_BACKEND node -e "
$(cat "$(target)")
process.exit(Ben().status==='unsupported'?0:1)"
[ "$status" -eq 0 ]
}
@test "patch: spawn picks node+daemon when flagged, helper when not" {
cd "$WORK"
patch_cowork_bwrap
# Evaluate the patched ygi() with stub IE/Vie and a resourcesPath,
# capturing the (command, args) the swap chose without spawning.
run node -e "
global.IE={spawn:(c,a)=>({c,a})};
global.Vie=()=>'/run/sock';
Object.defineProperty(process,'resourcesPath',{value:'/RES'});
$(cat "$(target)")
process.env.COWORK_VM_BACKEND='bwrap';
process.env.COWORK_NODE_PATH='/usr/bin/node';
const f=ygi('/HELPER');
const okFlagged=f.c==='/usr/bin/node'
&& f.a[0]==='/RES/cowork-vm-service.js'
&& f.a[1]==='-socket' && f.a[2]==='/run/sock';
delete process.env.COWORK_VM_BACKEND;
const u=ygi('/HELPER');
const okUnflagged=u.c==='/HELPER'
&& u.a[0]==='-socket' && u.a[1]==='/run/sock'
&& !u.a.some(x=>String(x).endsWith('cowork-vm-service.js'));
process.exit(okFlagged&&okUnflagged?0:1)"
echo "$output"
[ "$status" -eq 0 ]
}
@test "patch: re-run is a clean no-op (idempotent)" {
cd "$WORK"
patch_cowork_bwrap
local first
first="$(cat "$(target)")"
run patch_cowork_bwrap
[ "$status" -eq 0 ]
[[ "$output" == *"already"* ]]
[ "$(cat "$(target)")" == "$first" ]
}
@test "patch: missing load-bearing anchor (B) fails, file untouched" {
cd "$WORK"
# Remove the spawn anchor; A stays so only B is missing.
local t; t="$(target)"
grep -v 'IE.spawn' "$t" > "$t.tmp" && mv "$t.tmp" "$t"
local before; before="$(cat "$t")"
run patch_cowork_bwrap
[ "$status" -ne 0 ]
[[ "$output" == *"B: FATAL"* ]]
# A load-bearing miss must not write a half-patched file.
[ "$(cat "$t")" == "$before" ]
}
@test "patch: duplicated spawn anchor fails loud (exactly-1 guard)" {
cd "$WORK"
local t; t="$(target)"
# Append a second identical spawn shape.
printf '\nfunction ygi2(A){return IE.spawn(A,["-socket",Vie()],{stdio:["pipe","pipe","pipe"]})}\n' >> "$t"
run patch_cowork_bwrap
[ "$status" -ne 0 ]
[[ "$output" == *"B: FATAL"* ]]
[[ "$output" == *"expected exactly 1"* ]]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
#!/usr/bin/env bats
#
# cowork-protocol.bats
# End-to-end guard for the bwrap fallback daemon's wire protocol: spawns
# the daemon and drives it over the length-prefixed-JSON socket the
# official client uses (see PROTOCOL.md). Delegates to protocol-smoke.js,
# which spawns and reaps its own daemon.
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
@test "protocol: daemon speaks the official helper socket contract" {
run node "${SCRIPT_DIR}/protocol-smoke.js"
echo "$output"
[ "$status" -eq 0 ]
[[ "$output" == *"ALL PASS"* ]]
}
@@ -0,0 +1,198 @@
// Protocol regression guard for the bwrap fallback daemon.
//
// Spawns cowork-vm-service.js on a private socket and drives it the way
// the official client does — 4-byte big-endian length-prefixed JSON —
// exercising the wire contract in PROTOCOL.md: one-shot requests,
// id-multiplexed pipe, subscribeEvents ack-then-events, the VM
// lifecycle, spawn stdout/exit events, and the methods added for the
// official helper protocol. Self-contained (spawns + reaps its own
// daemon) so it runs the same locally and in CI.
//
// Usage: node protocol-smoke.js (exit 0 = all checks passed)
'use strict';
const net = require('net');
const os = require('os');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const DAEMON = path.join(__dirname, '..', 'cowork-vm-service.js');
const SOCK = path.join(os.tmpdir(),
`cowork-proto-smoke-${process.pid}.sock`);
function frame(obj) {
const body = Buffer.from(JSON.stringify(obj), 'utf8');
const len = Buffer.alloc(4);
len.writeUInt32BE(body.length, 0);
return Buffer.concat([len, body]);
}
function deframe(buf) {
if (buf.length < 4) return null;
const n = buf.readUInt32BE(0);
if (buf.length < 4 + n) return null;
return {
msg: JSON.parse(buf.subarray(4, 4 + n).toString('utf8')),
rest: Buffer.from(buf.subarray(4 + n)),
};
}
let failures = 0;
function check(name, cond, extra) {
process.stdout.write(`${cond ? 'ok' : 'FAIL'} - ${name}` +
`${cond ? '' : ' ' + JSON.stringify(extra)}\n`);
if (!cond) failures++;
}
function oneShot(payload) {
return new Promise((resolve, reject) => {
const s = net.createConnection(SOCK);
let buf = Buffer.alloc(0);
s.on('data', (d) => {
buf = Buffer.concat([buf, d]);
const r = deframe(buf);
if (r) { s.end(); resolve(r.msg); }
});
s.on('error', reject);
s.setTimeout(5000, () => { s.destroy(); reject(new Error('timeout')); });
s.on('connect', () => s.write(frame(payload)));
});
}
function waitForSocket(ms) {
const deadline = Date.now() + ms;
return new Promise((resolve, reject) => {
(function poll() {
if (fs.existsSync(SOCK)) return resolve();
if (Date.now() > deadline) return reject(new Error('no socket'));
setTimeout(poll, 100);
})();
});
}
async function main() {
try { fs.unlinkSync(SOCK); } catch (_) {}
const daemon = spawn(process.execPath, [DAEMON, '-socket', SOCK], {
env: { ...process.env, COWORK_VM_BACKEND: 'host' },
stdio: ['ignore', 'ignore', 'inherit'],
});
const stop = () => { try { daemon.kill('SIGKILL'); } catch (_) {} };
try {
await waitForSocket(6000);
// one-shot, no id
let r = await oneShot({ method: 'isRunning' });
check('one-shot isRunning envelope', r.success === true && r.result
&& r.result.running === false && r.id === undefined, r);
// persistent pipe: three id-tagged requests, matched by id
const pipe = net.createConnection(SOCK);
let pbuf = Buffer.alloc(0);
const responses = new Map();
let pipeDone;
const pipeWait = new Promise((res) => { pipeDone = res; });
pipe.on('data', (d) => {
pbuf = Buffer.concat([pbuf, d]);
let x;
while ((x = deframe(pbuf)) !== null) {
pbuf = x.rest;
responses.set(x.msg.id, x.msg);
if (responses.size === 3) pipeDone();
}
});
await new Promise((res) => pipe.on('connect', res));
pipe.write(frame({ method: 'configure', id: 1,
params: { userDataName: 'smoke', sessionOnly: true } }));
pipe.write(frame({ method: 'getSessionsDiskInfo', id: 2,
params: { lowWaterBytes: 0 } }));
pipe.write(frame({ method: 'getNetworkDrives', id: 3 }));
await Promise.race([pipeWait, new Promise((_, rej) =>
setTimeout(() => rej(new Error('pipe timeout')), 5000))]);
check('pipe: 3 id-matched responses', responses.size === 3,
[...responses.keys()]);
check('pipe: configure ok', responses.get(1).success === true,
responses.get(1));
const disk = responses.get(2);
check('getSessionsDiskInfo shape', disk.success === true
&& typeof disk.result.totalBytes === 'number'
&& typeof disk.result.freeBytes === 'number'
&& Array.isArray(disk.result.sessions), disk);
check('getNetworkDrives shape', responses.get(3).success === true
&& Array.isArray(responses.get(3).result.drives),
responses.get(3));
pipe.end();
// event subscription: ack first, then events
const sub = net.createConnection(SOCK);
let sbuf = Buffer.alloc(0);
const frames = [];
sub.on('data', (d) => {
sbuf = Buffer.concat([sbuf, d]);
let x;
while ((x = deframe(sbuf)) !== null) { sbuf = x.rest; frames.push(x.msg); }
});
await new Promise((res) => sub.on('connect', res));
sub.write(frame({ method: 'subscribeEvents',
params: { userDataName: 'smoke' } }));
await new Promise((res) => setTimeout(res, 300));
check('subscribeEvents ack first', frames.length >= 1
&& frames[0].success === true, frames[0]);
// startVM -> guest connects (polled, as the client does)
r = await oneShot({ method: 'startVM',
params: { bundlePath: '/nonexistent' } });
check('startVM success', r.success === true, r);
let connected = false;
for (let i = 0; i < 20 && !connected; i++) {
r = await oneShot({ method: 'isGuestConnected' });
connected = r.success === true && r.result.connected === true;
if (!connected) await new Promise((res) => setTimeout(res, 200));
}
check('isGuestConnected after startVM (polled)', connected, r);
// spawn a real process; expect stdout + exit events
r = await oneShot({ method: 'spawn', params: {
id: 'smoke-1', name: 'smoke', command: '/bin/echo',
args: ['hello-proto'], isResume: false, oauthToken: 'tok',
} });
check('spawn success', r.success === true, r);
await new Promise((res) => setTimeout(res, 800));
const stdout = frames.find((f) => f.type === 'stdout'
&& f.id === 'smoke-1');
const exit = frames.find((f) => f.type === 'exit' && f.id === 'smoke-1');
check('stdout event received', !!stdout
&& stdout.data.includes('hello-proto'), frames.slice(1));
check('exit event received', !!exit && exit.exitCode === 0, exit);
// added methods respond with their documented shapes
r = await oneShot({ method: 'pruneSessionCaches',
params: { onlyIfFreeBytesBelow: 1, includeSessionTmp: false } });
check('pruneSessionCaches shape', r.success === true
&& Array.isArray(r.result.prunedSessions), r);
r = await oneShot({ method: 'sendGuestResponse',
params: { id: 99, resultJson: '{}' } });
check('sendGuestResponse accepted', r.success === true, r);
r = await oneShot({ method: 'deleteSessionDirs',
params: { names: ['x'] } });
check('deleteSessionDirs shape', r.success === true
&& Array.isArray(r.result.deleted), r);
// unknown method -> success:false
r = await oneShot({ method: 'noSuchMethod' });
check('unknown method rejected', r.success === false
&& /Unknown method/.test(r.error), r);
sub.end();
} catch (e) {
process.stderr.write('fatal: ' + (e && e.stack || e) + '\n');
failures++;
} finally {
stop();
try { fs.unlinkSync(SOCK); } catch (_) {}
}
process.stdout.write(failures === 0 ? 'ALL PASS\n'
: `${failures} FAILURES\n`);
process.exit(failures === 0 ? 0 : 1);
}
main();
+1729
View File
File diff suppressed because it is too large Load Diff
+935
View File
@@ -0,0 +1,935 @@
#!/usr/bin/env bash
# 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@@'
# Rotate launcher.log when it exceeds a size cap, keeping a couple of
# old copies. Runs at the start of each launch, before the session
# header is written, so _previous_launch_hit_gpu_fatal always scans a
# bounded file and the log can't grow without bound across sessions
# (#747). Every branch returns 0 -- rotation must never block launch.
# $log_file must already be set (setup_logging runs before this).
rotate_log_file() {
[[ -n ${log_file:-} && -f $log_file ]] || return 0
# 5 MiB aligns with doctor.sh's existing launcher.log size warning.
local max_bytes=$((5 * 1024 * 1024))
local keep=2 size i
size=$(stat -c '%s' "$log_file" 2>/dev/null) || return 0
[[ $size =~ ^[0-9]+$ ]] || return 0
((size > max_bytes)) || return 0
for (( i = keep - 1; i >= 1; i-- )); do
[[ -f "$log_file.$i" ]] && \
mv -f "$log_file.$i" "$log_file.$((i + 1))" 2>/dev/null
done
mv -f "$log_file" "$log_file.1" 2>/dev/null || return 0
}
# Setup logging directory and file
# Sets: log_dir, log_file
setup_logging() {
log_dir="${XDG_CACHE_HOME:-$HOME/.cache}/claude-desktop-debian"
mkdir -p "$log_dir" || return 1
log_file="$log_dir/launcher.log"
rotate_log_file
return 0
}
# Log a message to the log file
# Usage: log_message "message"
#
# Joins all arguments with spaces ("$*"), so a wrapped call site that
# passes continuation fragments as separate words logs the full
# message, not just the first fragment. No-ops before setup_logging —
# the --doctor path loads the launcher config without a log file.
#
# LOG-1: never persist OAuth authorization codes. A relaunch through the
# login redirect carries claude://login/...?code=<secret> in argv, which
# reaches both the "Arguments:" and "Executing:" lines. Strip the query
# string of any claude://login token before writing, keeping the path for
# context. Guarded so the common case pays no subprocess.
log_message() {
[[ -n ${log_file:-} ]] || return 0
local msg="$*"
if [[ "$msg" == *claude://login* ]]; then
msg=$(printf '%s' "$msg" \
| sed -E 's#(claude://login[^ ?]*)\?[^ ]*#\1?<redacted>#g')
fi
echo "$msg" >> "$log_file"
}
# Log the session/IME environment vars that drive display and input
# decisions, so bug reports include enough context to reason about
# them without round-trip env-dump requests (#548).
#
# Emits one block:
# env={
# KEY=value
# ...
# }
#
# Empty or unset values are emitted as `KEY=` so absence is
# unambiguous (vs. silently omitted). Caller must run setup_logging
# first.
log_session_env() {
local key
log_message 'env={'
for key in \
XDG_SESSION_TYPE \
WAYLAND_DISPLAY \
DISPLAY \
XDG_CURRENT_DESKTOP \
GTK_IM_MODULE \
XMODIFIERS \
QT_IM_MODULE \
CLAUDE_USE_WAYLAND \
CLAUDE_PASSWORD_STORE \
CLAUDE_GTK_IM_MODULE \
CLAUDE_DISABLE_GPU
do
log_message " $key=${!key:-}"
done
log_message '}'
}
# Detect display backend (Wayland vs X11)
# Sets: is_wayland, use_x11_on_wayland
detect_display_backend() {
# Detect if Wayland is running
is_wayland=false
[[ -n "${WAYLAND_DISPLAY:-}" ]] && is_wayland=true
# Default: Use X11/XWayland on Wayland so upstream's globalShortcut
# (Quick Entry's Ctrl+Alt+Space) keeps working via an X11 key grab.
#
# CLAUDE_USE_WAYLAND is tri-state:
# 1 - force native Wayland (global shortcuts via XDG portal)
# 0 - force XWayland, skipping the auto-detect below
# unset - auto-detect per compositor
use_x11_on_wayland=true
local wayland_override="${CLAUDE_USE_WAYLAND:-}"
[[ $wayland_override == '1' ]] && use_x11_on_wayland=false
# Fixes: #226 - Only Niri is auto-forced to native Wayland: it has
# no XWayland at all, so the X11 backend can't even start.
#
# GNOME Wayland is NOT auto-forced. mutter no longer honours
# XWayland global key grabs (#404), and native Wayland would route
# Quick Entry's globalShortcut through the XDG GlobalShortcuts portal
# instead -- but flipping the default session off mature XWayland is
# a rendering / IME / HiDPI risk, and on GNOME 50 the portal path is
# a no-op anyway (electron/electron#51875). GNOME users who want the
# portal route opt in with CLAUDE_USE_WAYLAND=1 (works on GNOME <=49
# after the one-time portal permission dialog).
#
# Sway and Hyprland keep working XWayland grabs and their wlroots
# portal has no GlobalShortcuts backend, so they also stay on the
# XWayland default; opt in with CLAUDE_USE_WAYLAND=1 if desired. An
# explicit CLAUDE_USE_WAYLAND=0 opts out of this auto-detect entirely.
#
# XDG_CURRENT_DESKTOP can be colon-separated (e.g. "niri:GNOME"); the
# *glob* substring match handles this.
if [[ $is_wayland == true && $use_x11_on_wayland == true \
&& $wayland_override != '0' ]]; then
local desktop="${XDG_CURRENT_DESKTOP:-}"
desktop="${desktop,,}"
if [[ -n "${NIRI_SOCKET:-}" || "$desktop" == *niri* ]]; then
log_message "Niri detected - forcing native Wayland"
use_x11_on_wayland=false
fi
fi
}
# Check if we have a valid display (not running from TTY)
# Returns: 0 if display available, 1 if not
check_display() {
[[ -n $DISPLAY || -n $WAYLAND_DISPLAY ]]
}
# 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).
#
# Single-pass, constant-memory scan (#747): no section text is ever
# accumulated. Three crash-signature booleans are tracked for the
# section currently being read (cur_*); on each header line the
# just-finished section's flags shift into prev_*, so at EOF prev_*
# always holds the *penultimate* section's flags -- the same target
# the old string-accumulating version selected via section-1. This
# fixes the O(n^2) cost of growing an awk string per line: the cost
# was dominated by the largest single section, not the number of
# sections -- a GPU-crash-looping session can spew megabytes into one
# section, and that giant section was re-scanned on every subsequent
# launch, hanging the launcher before Electron ever started.
_previous_launch_hit_gpu_fatal() {
[[ -f ${log_file:-} ]] || return 1
awk '
/^--- Claude Desktop (Launcher|AppImage) Start( \(NixOS\))? ---$/ {
section++
prev_failed = cur_failed
prev_notusable = cur_notusable
prev_prevfatal = cur_prevfatal
cur_failed = 0
cur_notusable = 0
cur_prevfatal = 0
next
}
{
if (index($0,
"GPU process launch failed: error_code=")) {
cur_failed = 1
}
if (index($0,
"GPU process isn'\''t usable. Goodbye.")) {
cur_notusable = 1
}
if (index($0,
"Previous launch hit GPU process FATAL")) {
cur_prevfatal = 1
}
}
END {
if (section >= 2) {
t_failed = prev_failed
t_notusable = prev_notusable
t_prevfatal = prev_prevfatal
} else if (section == 1) {
t_failed = cur_failed
t_notusable = cur_notusable
t_prevfatal = cur_prevfatal
} else {
exit 1
}
if ((t_failed && t_notusable) || t_prevfatal) {
exit 0
}
exit 1
}
' "$log_file"
}
# Build Electron arguments array based on display backend.
#
# LAUNCHER POLICY — opt-in only. Since the v3.0.0 rebase the packaged
# app is Anthropic's official Linux build, so the launcher must NOT
# pass any default flag that shadows an official upstream code path
# (window frame, titlebar, password store, feature flags). Every
# default flag that remains has to justify itself against a concrete
# Linux-environment gap; the tools/chromium-switch-smoke.sh guard
# fails loudly if the effective switch list drifts without a
# deliberate baseline update. Kept defaults, each with its reason:
# --class=$WM_CLASS WM_CLASS/.desktop contract (#647, #652)
# XRDP auto GPU-off blank window on remote GPU (#319)
# GPU-crash sticky recovery GPU process FATAL exhaustion (#583)
# Wayland backend selection CLAUDE_USE_WAYLAND tri-state (#226, #404)
# --no-sandbox only where structurally required
# (AppImage FUSE; deb/nix on Wayland)
# --password-store is passed ONLY when CLAUDE_PASSWORD_STORE is set;
# otherwise the official os_crypt autodetection owns the decision.
#
# Requires: is_wayland, use_x11_on_wayland to be set
# (call detect_display_backend first)
# Sets: electron_args array
# Arguments: $1 = "appimage" or "deb" (affects --no-sandbox behavior)
build_electron_args() {
local package_type="${1:-deb}"
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')
# WM_CLASS must match the .desktop StartupWMClass and upstream's
# productName. Ref: #647, #652
electron_args+=("--class=$WM_CLASS")
# Password store: the official build's os_crypt autodetection owns
# this decision by default (it deliberately declines weak persistence
# on some sessions rather than storing tokens unsafely). We only pass
# --password-store when the user sets CLAUDE_PASSWORD_STORE, the
# documented escape hatch — never a launcher-chosen default that would
# shadow the upstream autodetect. History: #593.
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
electron_args+=("--password-store=$CLAUDE_PASSWORD_STORE")
log_message "Password store: $CLAUDE_PASSWORD_STORE (env override)"
fi
# Remote XRDP sessions lack GPU acceleration and render a blank
# window when GPU compositing is enabled. Detect via XRDP_SESSION
# (set by xrdp's session init) and loginctl session Type. We do
# not probe xrdp-sesman via pgrep because that daemon also runs
# on hosts where the user is on a local (non-XRDP) session.
# Fixes: #319
local rdp_session_type=''
[[ -n ${XDG_SESSION_ID:-} ]] && rdp_session_type=$(
loginctl show-session "$XDG_SESSION_ID" \
-p Type --value 2>/dev/null
)
# Track GPU-disable decision so XRDP and CLAUDE_DISABLE_GPU don't
# stack duplicate flags. Either signal is sufficient.
local _disable_gpu=false
if [[ -n ${XRDP_SESSION:-} || $rdp_session_type == xrdp ]]; then
_disable_gpu=true
log_message 'XRDP session detected - GPU compositing disabled'
fi
# CLAUDE_DISABLE_GPU=1: opt-in workaround for users hitting the
# Chromium GPU process FATAL exhaustion (#583). The same upstream
# behaviour is reachable via Settings → disable hardware
# acceleration; this lets users persist it via the env without
# having to reach the Settings UI through repeated crashes.
if [[ -v CLAUDE_DISABLE_GPU ]]; then
if [[ ${CLAUDE_DISABLE_GPU} == '1' ]]; then
_disable_gpu=true
log_message \
'CLAUDE_DISABLE_GPU=1 - hardware acceleration disabled'
fi
elif _previous_launch_hit_gpu_fatal; then
_disable_gpu=true
log_message \
'Previous launch hit GPU process FATAL - disabling GPU'
fi
[[ $_disable_gpu == true ]] \
&& electron_args+=('--disable-gpu' '--disable-software-rasterizer')
# X11 session - no display-backend flags needed.
if [[ $is_wayland != true ]]; then
log_message 'X11 session detected'
else
# Wayland: deb/nix packages need --no-sandbox in both modes
[[ $package_type == 'deb' || $package_type == 'nix' ]] \
&& electron_args+=('--no-sandbox')
if [[ $use_x11_on_wayland == true ]]; then
# Use X11 via XWayland; globalShortcut uses an X11 key grab.
log_message 'Using X11 backend via XWayland (for global hotkey support)'
electron_args+=('--ozone-platform=x11')
else
# Native Wayland: route globalShortcut through the XDG
# GlobalShortcutsPortal instead of an X11 key grab. Needs
# the wayland ozone platform (the feature is inert under
# XWayland) and Electron >= 35. Fixes #404 on GNOME, where
# mutter no longer honours XWayland grabs. On compositors
# whose portal lacks a GlobalShortcuts backend (e.g.
# wlroots) the feature is a harmless no-op.
log_message 'Using native Wayland backend (global shortcuts via XDG portal)'
enable_features+=(
'UseOzonePlatform'
'WaylandWindowDecorations'
'GlobalShortcutsPortal'
)
electron_args+=('--ozone-platform=wayland')
electron_args+=('--enable-wayland-ime')
electron_args+=('--wayland-text-input-version=3')
# Override any system-wide GDK_BACKEND=x11 that would silently
# prevent GTK from connecting to the Wayland compositor, causing
# blurry rendering or launch failures on HiDPI displays.
export GDK_BACKEND=wayland
fi
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 a cowork helper (defensive; neither carries --class) — the
# 2.x cowork-vm-service daemon nor the official Rust
# cowork-linux-helper — and never a Chromium helper: zygote,
# renderer, gpu, utility, etc.
[[ $cmdline == *cowork-vm-service* ]] && return 1
[[ $cmdline == *cowork-linux-helper* ]] && 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.
# After a crash or unclean shutdown the cowork daemon may outlive the
# main Electron UI process. The orphaned daemon holds LevelDB locks
# in ~/.config/Claude/Local Storage/ AND keeps the Unix socket at
# $XDG_RUNTIME_DIR/cowork-vm-service.sock bound, which causes a new
# launch to either silently quit (LevelDB) or connect to the stale
# daemon (socket) and hang with a blank window.
# Must run BEFORE cleanup_stale_lock / cleanup_stale_cowork_socket
# so that stale files left behind by the daemon can be cleaned up.
cleanup_orphaned_cowork_daemon() {
local cowork_pids pid
cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|| return 0
# A live Claude Desktop UI process means the daemon is expected;
# leave it alone. See _claude_desktop_ui_is_alive for why neither
# `pgrep -f 'claude-desktop'` nor an app.asar fingerprint works.
if _claude_desktop_ui_is_alive; then
return 0
fi
# No UI process found — daemon is orphaned, terminate it.
# Escalate to SIGKILL if a daemon is stuck and does not exit
# after SIGTERM within ~2s, so cleanup_stale_cowork_socket
# (which runs next) reliably sees no daemon.
for pid in $cowork_pids; do
kill "$pid" 2>/dev/null || true
done
local _wait=0
while ((_wait < 20)); do
pgrep -f 'cowork-vm-service\.js' &>/dev/null || break
sleep 0.1
((_wait++))
done
if pgrep -f 'cowork-vm-service\.js' &>/dev/null; then
for pid in $cowork_pids; do
kill -KILL "$pid" 2>/dev/null || true
done
log_message "Killed orphaned cowork-vm-service daemon (SIGKILL, PIDs: $cowork_pids)"
else
log_message "Killed orphaned cowork-vm-service daemon (PIDs: $cowork_pids)"
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
;;
*cowork-linux-helper*)
# Official Rust Cowork helper, spawned via
# process.resourcesPath (relocation-safe, so no fixed path).
return 0
;;
*"--user-data-dir=$config_dir "*)
return 0
;;
*"$config_dir/Claude Extensions/"*)
return 0
;;
*/usr/lib/claude-desktop/*--type=*)
return 0
;;
*/usr/lib/claude-desktop-unofficial/*--type=*)
# Phase 3 package rename, landing in v3.0.0: our package
# installs to /usr/lib/claude-desktop-unofficial while the
# official arm above keeps matching Anthropic's install
# (and the AppImage internal tree).
return 0
;;
esac
return 1
}
_desktop_helper_candidate_pids() {
pgrep -u "$(id -u)" -f 'cowork-vm-service\.js|cowork-linux-helper|--user-data-dir=.*[/]Claude|Claude Extensions|/usr/lib/claude-desktop(-unofficial)?/' 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
# with no user-facing error message.
# The lock is a symlink whose target is "hostname-PID".
cleanup_stale_lock() {
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
local lock_file="$config_dir/SingletonLock"
[[ -L $lock_file ]] || return 0
local lock_target
lock_target="$(readlink "$lock_file" 2>/dev/null)" || return 0
local lock_pid="${lock_target##*-}"
# Validate that we extracted a numeric PID
[[ $lock_pid =~ ^[0-9]+$ ]] || return 0
if kill -0 "$lock_pid" 2>/dev/null; then
# Process is still running — lock is valid
return 0
fi
rm -f "$lock_file"
log_message "Removed stale SingletonLock (PID $lock_pid no longer running)"
}
# Clean up stale cowork-vm-service socket if no daemon is listening.
# The service daemon creates a Unix socket at
# $XDG_RUNTIME_DIR/cowork-vm-service.sock. After a crash or unclean
# shutdown, the socket file persists but nothing is listening, causing
# ECONNREFUSED instead of ENOENT when the app tries to connect.
#
# NOTE: this function MUST run after cleanup_orphaned_cowork_daemon,
# which is responsible for killing any orphaned daemon. Given that
# ordering, the presence of a live daemon proves the socket is in
# use; the absence of a daemon proves the socket is stale.
# We use that invariant directly instead of depending on socat (not
# shipped by default on Debian/Ubuntu) or an age heuristic (the old
# 24h fallback effectively disabled the cleanup for any recent
# crash).
cleanup_stale_cowork_socket() {
local sock="${XDG_RUNTIME_DIR:-/tmp}/cowork-vm-service.sock"
[[ -S $sock ]] || return 0
# If a cowork daemon is alive, it owns this socket; leave it.
# cleanup_orphaned_cowork_daemon has already run and removed any
# orphan (with SIGKILL escalation), so anything still alive here
# is a non-orphaned, live daemon.
if pgrep -f 'cowork-vm-service\.js' &>/dev/null; then
return 0
fi
# No daemon — the socket file is left over from a crash.
rm -f "$sock"
log_message "Removed stale cowork-vm-service socket (no daemon running)"
}
# P1 (#768): rotate out-of-band backups of the user config and the
# per-account Cowork store index files before launch, so the
# poisoned-cache / corrupt-load wipe class is recoverable. Upstream's
# config loader silently falls back to {} on a failed cold-start read
# and then serializes the whole cached object over the file on the next
# settings write; the Cowork stores (spaces / remote-session-spaces /
# scheduled-tasks) share the same rewrite-from-memory shape. See
# anthropics/claude-code #32345 / #59640 / #63651 and
# docs/learnings/config-wipe-guard.md.
#
# We cannot fix upstream's write path from the launcher, but this keeps
# the last few good copies out of band. It runs BEFORE Electron starts,
# so it captures the previous session's (good) state; after an
# in-session wipe the good copy is still down the rotation. This is the
# patch-zero-clean primary fix — it covers every wipe mode (corrupt
# JSON, ENOENT, single-bad-entry Zod) that an in-band asar guard would
# miss. Rotation keeps $keep copies per file and only rotates on a
# real change, so it neither churns nor evicts the pre-wipe copy on
# every launch. Fail-safe: never blocks launch.
backup_user_config() {
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
local backup_dir
backup_dir="${XDG_CACHE_HOME:-$HOME/.cache}/claude-desktop-debian"
backup_dir="$backup_dir/config-backups"
local keep=5
mkdir -p "$backup_dir" 2>/dev/null || return 0
local -a sources=("$config_dir/claude_desktop_config.json")
# The Cowork stores live under nested account/org UUID dirs. An
# unmatched glob stays literal and is filtered by the -f test.
local lam="$config_dir/local-agent-mode-sessions"
if [[ -d $lam ]]; then
local f
for f in "$lam"/*/*/spaces.json \
"$lam"/*/*/remote-session-spaces.json \
"$lam"/*/*/scheduled-tasks.json; do
[[ -f $f ]] && sources+=("$f")
done
fi
local src flat newest i
for src in "${sources[@]}"; do
[[ -f $src ]] || continue
# Flatten the path under config_dir into one backup filename.
flat="${src#"$config_dir"/}"
flat="${flat//\//__}"
newest="$backup_dir/$flat.1"
# Unchanged since the newest backup: nothing to rotate.
[[ -f $newest ]] && cmp -s "$src" "$newest" && continue
# Shift .1..(keep-1) down one slot, dropping the oldest.
for (( i = keep - 1; i >= 1; i-- )); do
[[ -f "$backup_dir/$flat.$i" ]] && \
mv -f "$backup_dir/$flat.$i" \
"$backup_dir/$flat.$((i + 1))" 2>/dev/null
done
cp -f "$src" "$newest" 2>/dev/null && \
log_message "Backed up $flat (keep $keep)"
done
}
# AUTO-1: when "Run on startup" is enabled, the official app writes
# its own XDG autostart entry with Exec=<process.execPath> --startup —
# the raw Electron ELF (or, under AppImage, the ephemeral
# /tmp/.mount_claude* path). Login launches would bypass every launcher
# policy (Wayland opt-in, GPU recovery, --class, CLAUDE_PASSWORD_STORE),
# and the AppImage path rots on unmount. Rewrite the Exec command to
# the launcher on every start.
#
# Safe against the Settings toggle: upstream's is-enabled check reads
# only file existence plus Hidden/X-GNOME-Autostart-enabled — never the
# Exec content (verified on 1.18286.0 bytes). The app rewrites the
# entry on each toggle-on, so the heal has to repeat per launch.
#
# $1 = absolute launcher path to point the entry at. Callers pass
# /usr/bin/claude-desktop-unofficial (deb/rpm) or "$APPIMAGE" (AppRun;
# empty when the AppImage runtime did not set it — no-op then).
heal_autostart_entry() {
local launcher="$1"
local entry_dir="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
local entry="$entry_dir/claude-desktop.desktop"
local exec_line current args rest escaped new_line tmp line
local replaced=false
[[ -n $launcher && -f $entry ]] || return 0
exec_line=$(LC_ALL=C grep -m1 '^Exec=' "$entry") || return 0
# The command token: upstream writes it double-quoted; fall back to
# the first unquoted word for hand-edited entries.
if [[ $exec_line =~ ^Exec=\"([^\"]*)\"(.*)$ ]]; then
current="${BASH_REMATCH[1]}"
args="${BASH_REMATCH[2]}"
else
rest="${exec_line#Exec=}"
current="${rest%%[[:space:]]*}"
args="${rest#"$current"}"
fi
# Already healed, or pointing at something that is not the app
# itself (a hand-rolled wrapper): leave it alone.
[[ $current == "$launcher" ]] && return 0
case "$current" in
*/claude-desktop) ;;
*) return 0 ;;
esac
# Desktop-entry escaping, mirroring what upstream applies to its
# own execPath: backslash-escape \ " ` $, then % -> %%.
escaped="$launcher"
escaped=${escaped//\\/\\\\}
escaped=${escaped//\"/\\\"}
escaped=${escaped//\`/\\\`}
escaped=${escaped//\$/\\\$}
escaped=${escaped//%/%%}
new_line="Exec=\"$escaped\"$args"
# Rewrite only the first Exec line; keep everything else verbatim.
tmp="$entry.tmp.$$"
while IFS= read -r line || [[ -n $line ]]; do
if [[ $replaced == false && $line == Exec=* ]]; then
printf '%s\n' "$new_line"
replaced=true
else
printf '%s\n' "$line"
fi
done < "$entry" > "$tmp" || { rm -f "$tmp"; return 0; }
mv "$tmp" "$entry" || { rm -f "$tmp"; return 0; }
if [[ -n ${log_file:-} ]]; then
log_message \
"Healed autostart Exec: $current -> $launcher (AUTO-1)"
fi
return 0
}
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
# Load persistent launcher env from a per-user config file. The
# generated .desktop Exec line can't carry per-user environment, so a
# GUI launch has no way to set e.g. COWORK_VM_BACKEND=bwrap (#772). This
# file fills that gap: KEY=value lines, honored only for a fixed
# allowlist of launcher variables, and only when the variable is not
# already set — an explicit terminal env or `Exec=env VAR=... ` still
# wins. Values are read literally; the file is never executed as shell.
#
# ${XDG_CONFIG_HOME:-~/.config}/claude-desktop-debian/environment
#
# Callable before setup_logging: log_message no-ops without $log_file,
# which is what lets run_doctor load the config without a log.
load_launcher_config() {
local cfg
cfg="${XDG_CONFIG_HOME:-$HOME/.config}/claude-desktop-debian/environment"
[[ -r $cfg ]] || return 0
# Built with += — a \-continuation inside single quotes embeds a
# literal backslash+newline, which silently breaks the
# space-delimited match for the key that follows it.
local allowlist=' CLAUDE_USE_WAYLAND CLAUDE_PASSWORD_STORE'
allowlist+=' CLAUDE_GTK_IM_MODULE CLAUDE_DISABLE_GPU'
allowlist+=' COWORK_VM_BACKEND COWORK_NODE_PATH '
local line key val
while IFS= read -r line || [[ -n $line ]]; do
# Skip blanks and comments.
[[ -z ${line//[[:space:]]/} || ${line#"${line%%[![:space:]]*}"} == '#'* ]] \
&& continue
[[ $line == *=* ]] || continue
key="${line%%=*}"
key="${key//[[:space:]]/}"
val="${line#*=}"
# Trim surrounding whitespace ("KEY = value" is a config file,
# not shell — an untrimmed value would export " 1" and fail the
# consuming comparison while the log still reports it as set).
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
# Allowlist only — anything else in the file is ignored.
[[ $allowlist == *" $key "* ]] || {
log_message "Config: ignoring unrecognized key '$key' in $cfg"
continue
}
# Environment wins: never override an already-set variable.
[[ -n ${!key:-} ]] && continue
# Strip one layer of surrounding quotes, if present.
val="${val#[\"\']}"
val="${val%[\"\']}"
export "$key=$val"
log_message "Config: $key=$val (from $cfg)"
done < "$cfg"
}
setup_electron_env() {
# Persistent per-user launcher env (GUI launches can't set env via
# the .desktop Exec line) — load before anything reads these vars.
load_launcher_config
# The official Linux build ships packaged, so ELECTRON_FORCE_IS_PACKAGED
# is dropped (forcing it would shadow upstream's own isPackaged logic),
# and the official build owns its window frame, so the
# ELECTRON_USE_SYSTEM_TITLE_BAR export is gone too. See the launcher
# policy note above build_electron_args.
#
# CLAUDE_GTK_IM_MODULE: opt-in override for users hit by broken
# IBus integration on Linux (#549). Propagated to GTK_IM_MODULE
# so e.g. `xim` can be persisted without wrapping every launch.
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
local prev="${GTK_IM_MODULE:-<unset>}"
export GTK_IM_MODULE="$CLAUDE_GTK_IM_MODULE"
log_message \
"GTK_IM_MODULE override: $prev -> $GTK_IM_MODULE (via CLAUDE_GTK_IM_MODULE)"
fi
setup_cowork_bwrap_env
}
# Opt-in Cowork bubblewrap backend (COWORK_VM_BACKEND=bwrap) for hosts
# without KVM/vhost-vsock (ChromeOS Crostini, #772). The asar patch
# (patch_cowork_bwrap) swaps the native Cowork helper for the Node
# daemon shipped at resources/cowork-vm-service.js — but the official
# Electron binary ships with the RunAsNode fuse OFF, so it can't run the
# daemon itself. Resolve a system node here and hand its path to the
# patched spawn via COWORK_NODE_PATH. Only touches the environment when
# the user actually opts in; unflagged launches are untouched.
setup_cowork_bwrap_env() {
[[ ${COWORK_VM_BACKEND:-} == 'bwrap' ]] || return 0
# Respect an explicit override; otherwise probe PATH for node then
# nodejs (Debian/Ubuntu ship the interpreter as `nodejs`).
if [[ -z ${COWORK_NODE_PATH:-} ]]; then
local node_bin
node_bin=$(command -v node 2>/dev/null) \
|| node_bin=$(command -v nodejs 2>/dev/null) || node_bin=''
if [[ -n $node_bin ]]; then
export COWORK_NODE_PATH="$node_bin"
fi
fi
if [[ -z ${COWORK_NODE_PATH:-} ]]; then
log_message \
'Cowork backend: bwrap requested but no system node/nodejs' \
'found on PATH — the fallback daemon cannot start. Install' \
'Node.js (>= 18.15, e.g. sudo apt install nodejs) or set' \
'COWORK_NODE_PATH.'
return 0
fi
# Warn when the node lacks the daemon's required capability. The
# daemon needs fs.statfsSync (added in Node 18.15 / 16.19), so probe
# the call directly rather than compare a major — 18.0-18.14 has
# major 18 but not the call. The daemon self-guards on the same
# capability; this surfaces it in the launcher log where the user
# looks first. Kept in lock-step with cowork_node_has_features
# (doctor) and nodeHasRequiredFeatures (daemon).
# cowork_node_has_features is defined in doctor.sh, which this file
# sources; both it and the daemon check the same capability.
if cowork_node_has_features "$COWORK_NODE_PATH"; then
log_message \
"Cowork backend: bwrap (daemon node: $COWORK_NODE_PATH)"
else
log_message \
"Cowork backend: bwrap node $COWORK_NODE_PATH lacks" \
'fs.statfsSync (needs Node >= 18.15) — the daemon will' \
'refuse to start. Install a newer Node.js or set' \
'COWORK_NODE_PATH.'
fi
}
#===============================================================================
# Doctor Diagnostics
#
# run_doctor and its helpers live in doctor.sh alongside this file. Sourced
# here so any consumer of launcher-common.sh gets the full run_doctor entry
# point without needing to know about the split. Each packaging target
# (deb/rpm/AppImage/Nix) installs doctor.sh next to launcher-common.sh.
#===============================================================================
# shellcheck source=scripts/doctor.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/doctor.sh"
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env bash
# Arguments passed from the main script
version="$1"
architecture="$2"
work_dir="$3" # The top-level build directory (e.g., ./build)
app_staging_dir="$4" # Directory containing the prepared app files
package_name="$5"
# MAINTAINER and DESCRIPTION might not be directly used by AppImage tools
# but passed for consistency
echo '--- Starting AppImage Build ---'
echo "Version: $version"
echo "Architecture: $architecture"
echo "Work Directory: $work_dir"
echo "App Staging Directory: $app_staging_dir"
echo "Package Name: $package_name"
component_id='io.github.aaddrick.claude-desktop-debian'
# Define AppDir structure path
appdir_path="$work_dir/${component_id}.AppDir"
rm -rf "$appdir_path"
mkdir -p "$appdir_path/usr/bin" || exit 1
mkdir -p "$appdir_path/usr/lib" || exit 1
mkdir -p "$appdir_path/usr/share/icons/hicolor/256x256/apps" || exit 1
mkdir -p "$appdir_path/usr/share/applications" || exit 1
echo 'Staging application files into AppDir...'
# The staging dir is the extracted official usr/lib/claude-desktop tree
# (Electron ELF, chrome-sandbox, resources/, locales/, ...); ship it as-is.
mkdir -p "$appdir_path/usr/lib/claude-desktop" || exit 1
cp -a "$app_staging_dir/." "$appdir_path/usr/lib/claude-desktop/" || exit 1
echo 'Official application tree copied'
# Copy shared launcher library (launcher-common.sh sources doctor.sh
# 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" "$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'
# Ensure the official binary made it into the AppDir
bundled_app_path="$appdir_path/usr/lib/claude-desktop/claude-desktop"
echo "Checking for executable at: $bundled_app_path"
if [[ ! -f $bundled_app_path ]]; then
echo 'Claude Desktop binary not found in staging area.' >&2
echo "Path checked: $bundled_app_path" >&2
exit 1
fi
chmod +x "$bundled_app_path" || exit 1
# --- Create AppRun Script ---
echo 'Creating AppRun script...'
cat > "$appdir_path/AppRun" << 'EOF'
#!/usr/bin/env bash
# Find the location of the AppRun script
appdir=$(dirname "$(readlink -f "$0")")
# Source shared launcher library
source "$appdir/usr/lib/claude-desktop/launcher-common.sh"
# The official Electron binary; it auto-loads the co-located
# resources/app.asar, so no app path is ever passed (issue #696).
app_exec="$appdir/usr/lib/claude-desktop/claude-desktop"
# Handle --doctor flag before anything else
if [[ "${1:-}" == '--doctor' ]]; then
run_doctor "$app_exec"
exit $?
fi
# --version never reaches the terminal via Electron: the launcher
# redirects all app output to the log (#772). Answer it here instead.
if [[ "${1:-}" == '--version' ]]; then
echo '@@PACKAGE_NAME@@ @@VERSION@@'
exit 0
fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
# APPIMAGE is set by the AppImage runtime to the persistent image path;
# an extracted/direct run leaves it unset and the heal no-ops.
heal_autostart_entry "${APPIMAGE:-}"
backup_user_config
# Detect display backend
detect_display_backend
# Log startup info
log_message '--- Claude Desktop AppImage Start ---'
log_message "Timestamp: $(date)"
log_message "Arguments: $@"
log_message "APPDIR: $appdir"
log_session_env
# Build Chromium switches (appimage mode adds --no-sandbox for FUSE)
build_electron_args 'appimage'
# Change to HOME directory before exec'ing the app to avoid CWD permission issues
cd "$HOME" || exit 1
# Execute the official binary and keep AppRun alive so explicit quit can
# clean up Desktop-owned helpers that outlive the main process.
log_message "Executing: $app_exec ${electron_args[*]} $*"
run_electron_and_cleanup "$app_exec" "${electron_args[@]}" "$@"
exit $?
EOF
chmod +x "$appdir_path/AppRun" || exit 1
# The AppRun heredoc is quoted (runtime expansion), so the build-time
# values for the --version fast-path are stamped in afterwards.
sed -i "s/@@PACKAGE_NAME@@/$package_name/; s/@@VERSION@@/$version/" \
"$appdir_path/AppRun" || exit 1
echo 'AppRun script created'
# --- Create Desktop Entry (Bundled inside AppDir) ---
echo 'Creating bundled desktop entry...'
# This is the desktop file *inside* the AppImage, used by tools like appimaged
cat > "$appdir_path/$component_id.desktop" << EOF
[Desktop Entry]
Name=Claude
Exec=AppRun %u
Icon=$component_id
Type=Application
Terminal=false
Categories=Network;Utility;
Comment=Claude Desktop for Linux
MimeType=x-scheme-handler/claude;
StartupWMClass=$WM_CLASS
X-AppImage-Version=$version
X-AppImage-Name=Claude Desktop
EOF
# Also place it in the standard location for tools like appimaged and validation
mkdir -p "$appdir_path/usr/share/applications" || exit 1
cp "$appdir_path/$component_id.desktop" "$appdir_path/usr/share/applications/" || exit 1
echo 'Bundled desktop entry created and copied to usr/share/applications/'
# --- Copy Icons ---
echo 'Copying icons...'
# Use the official 256x256 hicolor icon as the main AppImage icon
icon_source_path="${CLAUDE_EXTRACT_DIR:?}/usr/share/icons/hicolor/256x256/apps/claude-desktop.png"
if [[ -f $icon_source_path ]]; then
# Standard location within AppDir
cp "$icon_source_path" "$appdir_path/usr/share/icons/hicolor/256x256/apps/${component_id}.png" || exit 1
# Top-level icon (used by appimagetool) - Should match the Icon field in .desktop
cp "$icon_source_path" "$appdir_path/${component_id}.png" || exit 1
# Top-level icon without extension (fallback for some tools)
cp "$icon_source_path" "$appdir_path/${component_id}" || exit 1
# Hidden .DirIcon (fallback for some systems/tools)
cp "$icon_source_path" "$appdir_path/.DirIcon" || exit 1
echo 'Icon copied to standard path, top-level (.png and no ext), and .DirIcon'
else
echo "Warning: Missing 256x256 icon at $icon_source_path. AppImage icon might be missing."
fi
# --- Create AppStream Metadata ---
echo 'Creating AppStream metadata...'
metadata_dir="$appdir_path/usr/share/metainfo"
mkdir -p "$metadata_dir" || exit 1
# Use the package name for the appdata file name (seems required by appimagetool warning)
# Use reverse-DNS for component ID and filename, following common practice
appdata_file="$metadata_dir/${component_id}.appdata.xml"
# Generate the AppStream XML file
# project_license describes the app the user launches (the proprietary
# Claude binary), not the MIT packaging scripts
# ID follows reverse DNS convention
cat > "$appdata_file" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>$component_id</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LicenseRef-proprietary</project_license>
<developer id="io.github.aaddrick">
<name>aaddrick</name>
</developer>
<name>Claude Desktop</name>
<summary>Unofficial desktop client for Claude AI</summary>
<description>
<p>
Provides a desktop experience for interacting with Claude AI, wrapping the web interface.
</p>
</description>
<launchable type="desktop-id">${component_id}.desktop</launchable>
<icon type="stock">${component_id}</icon>
<url type="homepage">https://github.com/aaddrick/claude-desktop-debian</url>
<screenshots>
<screenshot type="default">
<image>https://github.com/user-attachments/assets/93080028-6f71-48bd-8e59-5149d148cd45</image>
</screenshot>
</screenshots>
<provides>
<binary>AppRun</binary>
</provides>
<categories>
<category>Network</category>
<category>Utility</category>
</categories>
<content_rating type="oars-1.1" />
<releases>
<release version="$version" date="$(date +%Y-%m-%d)">
<description>
<p>Version $version.</p>
</description>
</release>
</releases>
</component>
EOF
echo "AppStream metadata created at $appdata_file"
# --- Get appimagetool ---
# appimagetool is a native binary that must run on the HOST machine, not
# the package's target architecture: CI cross-builds (e.g. an arm64
# package on an ubuntu-latest/x86_64 runner) need the x86_64 tool even
# though $architecture says arm64. Select strictly by uname -m here;
# the target architecture is only used later for the embedded ARCH.
host_arch=$(uname -m)
case "$host_arch" in
x86_64|aarch64) ;;
*)
echo "Unsupported host architecture for appimagetool: $host_arch" >&2
exit 1
;;
esac
appimagetool_path=''
# Check system PATH first
if command -v appimagetool &> /dev/null; then
appimagetool_path=$(command -v appimagetool)
echo "Found appimagetool in PATH: $appimagetool_path"
fi
# Check for a previously downloaded HOST-arch tool
if [[ -z $appimagetool_path ]]; then
local_path="$work_dir/appimagetool-${host_arch}.AppImage"
if [[ -f $local_path ]]; then
appimagetool_path="$local_path"
echo "Found downloaded ${host_arch} appimagetool: $appimagetool_path"
fi
fi
# Download if not found
if [[ -z $appimagetool_path ]]; then
echo 'Downloading appimagetool...'
appimagetool_url="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${host_arch}.AppImage"
appimagetool_path="$work_dir/appimagetool-${host_arch}.AppImage"
if wget -q -O "$appimagetool_path" "$appimagetool_url"; then
chmod +x "$appimagetool_path" || exit 1
echo "Downloaded appimagetool to $appimagetool_path"
else
echo "Failed to download appimagetool from $appimagetool_url" >&2
rm -f "$appimagetool_path"
exit 1
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"
output_path="$work_dir/$output_filename"
# ARCH names the TARGET architecture (canonical uname-style), which can
# differ from the host running the tool during a cross-build. It only
# covers naming/validation: appimagetool ALWAYS embeds the runtime stub
# bundled with the tool itself, which is host-arch. On a cross-build
# that bakes an x86_64 stub into an arm64 AppImage, which then can't
# start on target hardware (caught by test-artifacts on the first
# native-arm64 run). Fetch the TARGET-arch runtime from the same
# release as the tool and force it in with --runtime-file.
case "$architecture" in
amd64) export ARCH='x86_64' ;;
arm64) export ARCH='aarch64' ;;
*)
echo "Unsupported target architecture for ARCH: $architecture" >&2
exit 1
;;
esac
echo "Using ARCH=$ARCH"
runtime_path="$work_dir/appimage-runtime-${ARCH}"
if [[ ! -f $runtime_path ]]; then
runtime_url="https://github.com/AppImage/AppImageKit/releases/download/continuous/runtime-${ARCH}"
echo "Downloading AppImage runtime for ${ARCH}..."
if ! wget -q -O "$runtime_path" "$runtime_url"; then
echo "Failed to download AppImage runtime from $runtime_url" >&2
rm -f "$runtime_path"
exit 1
fi
fi
# Local build - no update information
if [[ $GITHUB_ACTIONS != 'true' ]]; then
echo 'Running locally - building AppImage without update information'
echo '(Update info and zsync files are only generated in GitHub Actions for releases)'
if ! "$appimagetool_path" --runtime-file "$runtime_path" \
"$appdir_path" "$output_path"; then
echo "Failed to build AppImage using $appimagetool_path" >&2
exit 1
fi
echo "AppImage built successfully: $output_path"
echo '--- AppImage Build Finished ---'
exit 0
fi
# GitHub Actions build - embed update information
echo 'Running in GitHub Actions - embedding update information for automatic updates...'
# Install zsync if needed for .zsync file generation
if ! command -v zsyncmake &> /dev/null; then
echo 'zsyncmake not found. Installing zsync package for .zsync file generation...'
if command -v apt-get &> /dev/null; then
sudo apt-get update && sudo apt-get install -y zsync
elif command -v dnf &> /dev/null; then
sudo dnf install -y zsync
elif command -v zypper &> /dev/null; then
sudo zypper install -y zsync
else
echo 'Cannot install zsync automatically. .zsync files may not be generated.'
fi
fi
# Format: gh-releases-zsync|<username>|<repository>|<tag>|<filename-pattern>
# The 'claude-desktop-*' wildcard is deliberately NOT renamed along with
# $package_name: it matches both the old claude-desktop-* and the new
# claude-desktop-unofficial-* artifact names, so AppImages installed
# before the rename keep self-updating. Do not narrow it.
update_info="gh-releases-zsync|aaddrick|claude-desktop-debian|latest|claude-desktop-*-${architecture}.AppImage.zsync"
echo "Update info: $update_info"
if ! "$appimagetool_path" --runtime-file "$runtime_path" \
--updateinformation "$update_info" "$appdir_path" "$output_path"; then
echo "Failed to build AppImage using $appimagetool_path" >&2
exit 1
fi
echo "AppImage built successfully with embedded update info: $output_path"
zsync_file="${output_path}.zsync"
if [[ -f $zsync_file ]]; then
echo "zsync file generated: $zsync_file"
echo 'zsync file will be included in release artifacts'
else
echo 'zsync file not generated (zsyncmake may not be installed)'
fi
echo '--- AppImage Build Finished ---'
exit 0
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env bash
# Arguments passed from the main script
version="$1"
architecture="$2"
work_dir="$3" # The top-level build directory (e.g., ./build)
app_staging_dir="$4" # Directory containing the prepared app files
package_name="$5"
maintainer="$6"
description="$7"
echo '--- Starting Debian Package Build ---'
echo "Version: $version"
echo "Architecture: $architecture"
echo "Work Directory: $work_dir"
echo "App Staging Directory: $app_staging_dir"
echo "Package Name: $package_name"
package_root="$work_dir/package"
install_dir="$package_root/usr"
# Clean previous package structure if it exists
rm -rf "$package_root"
# Create Debian package structure
echo "Creating package structure in $package_root..."
mkdir -p "$package_root/DEBIAN" || exit 1
mkdir -p "$install_dir/lib/$package_name" || exit 1
mkdir -p "$install_dir/share/applications" || exit 1
mkdir -p "$install_dir/share/icons" || exit 1
mkdir -p "$install_dir/bin" || exit 1
# --- Icon Installation ---
echo 'Installing icons...'
# The official tree ships hicolor PNGs (16-256px). The source basename
# stays upstream's claude-desktop.png, but the destination is renamed to
# $package_name.png so our files never collide with the icons installed
# by Anthropic's official claude-desktop package.
official_hicolor="${CLAUDE_EXTRACT_DIR:?}/usr/share/icons/hicolor"
found_icons=false
for icon_source_path in "$official_hicolor"/*/apps/claude-desktop.png; do
[[ -f $icon_source_path ]] || continue
size_dir=$(basename "$(dirname "$(dirname "$icon_source_path")")")
echo "Installing $size_dir icon..."
install -Dm 644 "$icon_source_path" \
"$install_dir/share/icons/hicolor/$size_dir/apps/$package_name.png" || exit 1
found_icons=true
done
if [[ $found_icons == false ]]; then
echo "Warning: no hicolor icons found under $official_hicolor"
fi
echo 'Icons installed'
# --- Copy Application Files ---
echo "Copying application files from $app_staging_dir..."
# The staging dir is the extracted official usr/lib/claude-desktop tree
# (Electron ELF, chrome-sandbox, resources/, locales/, ...); ship it as-is.
cp -a "$app_staging_dir/." "$install_dir/lib/$package_name/" || exit 1
echo 'Official application tree copied'
# Copy shared launcher library (launcher-common.sh sources doctor.sh
# 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'
# --- Create Desktop Entry ---
echo 'Creating desktop entry...'
cat > "$install_dir/share/applications/$package_name.desktop" << EOF
[Desktop Entry]
Name=Claude
Exec=/usr/bin/$package_name %u
Icon=$package_name
Type=Application
Terminal=false
Categories=Office;Utility;
MimeType=x-scheme-handler/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-unofficial.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/$package_name" << EOF
#!/usr/bin/env bash
# Source shared launcher library
source "/usr/lib/$package_name/launcher-common.sh"
# The official Electron binary; it auto-loads the co-located
# resources/app.asar, so no app path is ever passed (issue #696).
app_exec="/usr/lib/$package_name/claude-desktop"
# Handle --doctor flag before anything else
if [[ "\${1:-}" == '--doctor' ]]; then
run_doctor "\$app_exec"
exit \$?
fi
# --version never reaches the terminal via Electron: the launcher
# redirects all app output to the log (#772). Answer it here instead.
if [[ "\${1:-}" == '--version' ]]; then
echo "$package_name $version"
exit 0
fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
heal_autostart_entry "/usr/bin/$package_name"
backup_user_config
# Log startup info
log_message '--- Claude Desktop Launcher Start ---'
log_message "Timestamp: \$(date)"
log_message "Arguments: \$@"
log_session_env
# Check for display
if ! check_display; then
log_message 'No display detected (TTY session)'
echo 'Error: Claude Desktop requires a graphical desktop environment.' >&2
echo 'Please run from within an X11 or Wayland session, not from a TTY.' >&2
exit 1
fi
# Detect display backend
detect_display_backend
if [[ \$is_wayland == true ]]; then
log_message 'Wayland detected'
fi
if [[ ! -x \$app_exec ]]; then
log_message "Error: Claude Desktop binary not found at \$app_exec"
echo "Error: Claude Desktop binary not found at \$app_exec" >&2
exit 1
fi
# Build Chromium switches for the official binary
build_electron_args 'deb'
# 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 the official binary and keep the launcher alive so explicit
# quit can clean up Desktop-owned helpers that outlive the main process.
log_message "Executing: \$app_exec \${electron_args[*]} \$*"
run_electron_and_cleanup "\$app_exec" "\${electron_args[@]}" "\$@"
exit \$?
EOF
chmod +x "$install_dir/bin/$package_name" || exit 1
echo 'Launcher script created'
# --- Create Control File ---
echo 'Creating control file...'
# Depends/Recommends are re-emitted verbatim from the extracted official
# control file (exported by build.sh): the contract differs per arch
# (arm64 recommends qemu-system-arm/qemu-efi-aarch64 instead of
# qemu-system-x86/ovmf) and tracks upstream automatically this way.
{
echo "Package: $package_name"
echo "Version: $version"
echo 'Section: utils'
echo 'Priority: optional'
echo "Architecture: $architecture"
# The 'claude-desktop' name is shared by two other packages: our own
# legacy repack (<= 1.15200.x) and Anthropic's official package
# (>= 1.17377.1). The relation must stay version-scoped to hit only
# the legacy repack — unscoped, apt would remove the official package
# on install. The bound also sits below the 1.16000.0-1 transitional
# dummy built at the end of this script, so the dummy and this
# package can coexist during the rename upgrade.
echo 'Conflicts: claude-desktop (<< 1.16000)'
echo 'Replaces: claude-desktop (<< 1.16000)'
if [[ -n ${OFFICIAL_DEB_DEPENDS:-} ]]; then
echo "Depends: $OFFICIAL_DEB_DEPENDS"
fi
if [[ -n ${OFFICIAL_DEB_RECOMMENDS:-} ]]; then
echo "Recommends: $OFFICIAL_DEB_RECOMMENDS"
fi
echo "Maintainer: $maintainer"
echo "Description: $description"
echo ' Claude is an AI assistant from Anthropic.'
echo ' This package provides the desktop interface for Claude.'
echo ' .'
echo ' Supported on Debian-based Linux distributions (Debian, Ubuntu, Linux Mint, MX Linux, etc.)'
} > "$package_root/DEBIAN/control"
echo 'Control file created'
# --- Create Postinst Script ---
echo 'Creating postinst script...'
cat > "$package_root/DEBIAN/postinst" << EOF
#!/bin/sh
set -e
# Update desktop database for MIME types
echo "Updating desktop database..."
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
# Set correct permissions for chrome-sandbox. The official data.tar
# records it SUID, but our non-root ar|tar extraction strips the bit, so
# it must be re-asserted here.
echo "Setting chrome-sandbox permissions..."
SANDBOX_PATH=""
LOCAL_SANDBOX_PATH="/usr/lib/$package_name/chrome-sandbox"
if [ -f "\$LOCAL_SANDBOX_PATH" ]; then
SANDBOX_PATH="\$LOCAL_SANDBOX_PATH"
fi
if [ -n "\$SANDBOX_PATH" ] && [ -f "\$SANDBOX_PATH" ]; then
echo "Found chrome-sandbox at: \$SANDBOX_PATH"
chown root:root "\$SANDBOX_PATH" || echo "Warning: Failed to chown chrome-sandbox"
chmod 4755 "\$SANDBOX_PATH" || echo "Warning: Failed to chmod chrome-sandbox"
echo "Permissions set for \$SANDBOX_PATH"
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/claude-desktop 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
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.
# Three profile names: the Electron one (Chromium sandbox, #687), the bwrap
# one (Cowork sandbox helper, #694), and the legacy pre-rename names. The
# bwrap profile is no longer installed since the official-deb rebase parked
# the bwrap Cowork backend, but 2.x installs left it behind (as
# 'claude-desktop-bwrap', before the rename) — keep removing it here.
# The legacy 'claude-desktop' Electron profile needs care: Anthropic's
# official package postinst also writes /etc/apparmor.d/claude-desktop
# (untracked by dpkg, so ownership can't be queried) — only our marker
# header proves the file came from our legacy 2.x postinst. Markerless
# files (official's, admin-created, or pre-v2.0.19 ours) are preserved.
# 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" \
"/etc/apparmor.d/claude-desktop-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
# Legacy 2.x profile from before the rename to $package_name.
# The official claude-desktop package writes the same path from
# its postinst; touch it only when our marker header proves our
# legacy postinst wrote it.
_legacy='/etc/apparmor.d/claude-desktop'
if [ -e "\$_legacy" ] \
&& grep -qF 'managed by the claude-desktop package' \
"\$_legacy" 2>/dev/null; then
if command -v apparmor_parser >/dev/null 2>&1; then
apparmor_parser -R "\$_legacy" >/dev/null 2>&1 || true
fi
if [ "\$1" = purge ]; then
rm -f "\$_legacy" 2>/dev/null || true
fi
fi
;;
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"
# Fix DEBIAN directory permissions (must be 755 for dpkg-deb)
echo 'Setting DEBIAN directory permissions...'
chmod 755 "$package_root/DEBIAN" || exit 1
# Fix script permissions in DEBIAN directory
echo 'Setting script permissions...'
chmod 755 "$package_root/DEBIAN/postinst" || exit 1
chmod 755 "$package_root/DEBIAN/postrm" || exit 1
# Normalize the installed tree before building. A restrictive build umask
# can leave directories at 0700, and dpkg-deb records file ownership
# verbatim unless told otherwise. Both bite at runtime: the launcher runs
# as the desktop user, who then can't traverse into app.asar.unpacked/ —
# silently breaking Cowork's daemon auto-launch (the fork is guarded by
# fs.existsSync(), which returns false on a directory it can't read, so
# the symptom is an endless connect ENOENT on the VM-service socket with
# no daemon log and no [cowork-autolaunch] line). Canonical modes: dirs
# and already-executable files 755, every other file 644. The blanket
# pass clears chrome-sandbox's setuid bit, but postinst re-asserts 4755
# after install, so the net result is unchanged.
echo 'Normalizing installed tree permissions...'
find "$install_dir" -type d -exec chmod 755 {} + || exit 1
find "$install_dir" -type f -exec chmod u=rwX,go=rX {} + || exit 1
# --root-owner-group forces root:root in the archive so a leaked build
# uid can't deny access on the installed system (the build does not run
# under fakeroot).
if ! dpkg-deb --root-owner-group --build "$package_root" "$deb_file"; then
echo 'Failed to build .deb package' >&2
exit 1
fi
echo "Deb package built successfully: $deb_file"
# --- Build Transitional Dummy Package ---
# The package renamed from 'claude-desktop' to claude-desktop-unofficial
# when Anthropic's official package claimed the old name. This
# control-only dummy walks legacy repack installs (<= 1.15200.x) over
# the rename: apt upgrades the old 'claude-desktop' to it, and its
# Depends pulls in the real package. Version 1.16000.0-1 sits above
# every legacy repack and below the first official build (1.17377.1),
# so the official package still upgrades cleanly over the dummy.
# Architecture: all — built on the amd64 leg only so the arm64 CI leg
# doesn't emit a duplicate artifact. build.sh moves it next to the
# main .deb (the filename is referenced there; keep them in sync).
if [[ $architecture == 'amd64' ]]; then
echo 'Building transitional dummy package...'
transitional_root="$work_dir/transitional-package"
transitional_deb="$work_dir/claude-desktop_1.16000.0-1_all.deb"
rm -rf "$transitional_root"
mkdir -p "$transitional_root/DEBIAN" || exit 1
{
echo 'Package: claude-desktop'
echo 'Version: 1.16000.0-1'
echo 'Architecture: all'
echo "Depends: $package_name"
echo 'Section: oldlibs'
echo 'Priority: optional'
echo "Maintainer: $maintainer"
echo 'Description: Transitional package for the rename to claude-desktop-unofficial'
echo " This dummy package eases upgrades from the legacy 'claude-desktop'"
echo " community repack to its new name, $package_name."
echo ' It can be safely removed after the upgrade.'
} > "$transitional_root/DEBIAN/control"
chmod 755 "$transitional_root/DEBIAN" || exit 1
if ! dpkg-deb --root-owner-group --build \
"$transitional_root" "$transitional_deb"; then
echo 'Failed to build transitional .deb package' >&2
exit 1
fi
echo "Transitional package built successfully: $transitional_deb"
fi
echo '--- Debian Package Build Finished ---'
exit 0
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AppStream metainfo for the claude-desktop-unofficial 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-unofficial.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-unofficial</binary>
</provides>
</component>
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env bash
# Arguments passed from the main script
version="$1"
architecture="$2"
work_dir="$3" # The top-level build directory (e.g., ./build)
app_staging_dir="$4" # Directory containing the prepared app files
package_name="$5"
# $6 is maintainer (unused in RPM spec, kept for parameter compatibility with deb)
description="$7"
echo '--- Starting RPM Package Build ---'
echo "Version: $version"
# RPM Version field cannot contain hyphens. If version contains a hyphen,
# split into version (before hyphen) and release (after hyphen).
# e.g., "1.1.799-1.3.3" -> rpm_version="1.1.799", rpm_release="1.3.3"
if [[ $version == *-* ]]; then
rpm_version="${version%%-*}"
rpm_release="${version#*-}"
# RPM Release field cannot contain hyphens either. The wrapper
# suffix appended in official-deb.sh can itself carry an RC suffix
# (e.g. "3.0.0-rc1"), which lands entirely in rpm_release here since
# the split above only cuts on the first hyphen.
rpm_release="${rpm_release//-/.}"
echo "RPM Version: $rpm_version"
echo "RPM Release: $rpm_release"
else
rpm_version="$version"
rpm_release="1"
fi
echo "Architecture: $architecture"
echo "Work Directory: $work_dir"
echo "App Staging Directory: $app_staging_dir"
echo "Package Name: $package_name"
# Map architecture to RPM naming
case "$architecture" in
amd64) rpm_arch='x86_64' ;;
arm64) rpm_arch='aarch64' ;;
*)
echo "Unsupported architecture for RPM: $architecture" >&2
exit 1
;;
esac
# RPM build directories
rpmbuild_dir="$work_dir/rpmbuild"
# Clean previous RPM build structure if it exists
rm -rf "$rpmbuild_dir"
# Create RPM build directory structure
echo "Creating RPM build structure in $rpmbuild_dir..."
mkdir -p "$rpmbuild_dir"/{BUILD,RPMS,SOURCES,SPECS,SRPMS} || exit 1
# Get script directory for accessing launcher-common.sh
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Create staging area for files to include
staging_dir="$work_dir/rpm-staging"
rm -rf "$staging_dir"
mkdir -p "$staging_dir" || exit 1
# --- Create Desktop Entry ---
echo 'Creating desktop entry...'
cat > "$staging_dir/$package_name.desktop" << EOF
[Desktop Entry]
Name=Claude
Exec=/usr/bin/$package_name %u
Icon=$package_name
Type=Application
Terminal=false
Categories=Office;Utility;
MimeType=x-scheme-handler/claude;
StartupWMClass=$WM_CLASS
EOF
# --- Stage AppStream metainfo (installed via %files block below) ---
metainfo_name='io.github.aaddrick.claude-desktop-unofficial.metainfo.xml'
cp "$script_dir/$metainfo_name" "$staging_dir/$metainfo_name" || exit 1
# --- Create Launcher Script ---
echo 'Creating launcher script...'
cat > "$staging_dir/$package_name" << EOF
#!/usr/bin/env bash
# Source shared launcher library
source "/usr/lib/$package_name/launcher-common.sh"
# The official Electron binary; it auto-loads the co-located
# resources/app.asar, so no app path is ever passed (issue #696).
app_exec="/usr/lib/$package_name/claude-desktop"
# Handle --doctor flag before anything else
if [[ "\${1:-}" == '--doctor' ]]; then
run_doctor "\$app_exec"
exit \$?
fi
# --version never reaches the terminal via Electron: the launcher
# redirects all app output to the log (#772). Answer it here instead.
if [[ "\${1:-}" == '--version' ]]; then
echo "$package_name $version"
exit 0
fi
# Setup logging and environment
setup_logging || exit 1
setup_electron_env
cleanup_orphaned_cowork_daemon
cleanup_stale_desktop_helpers
cleanup_stale_lock
cleanup_stale_cowork_socket
heal_autostart_entry "/usr/bin/$package_name"
backup_user_config
# Log startup info
log_message '--- Claude Desktop Launcher Start ---'
log_message "Timestamp: \$(date)"
log_message "Arguments: \$@"
log_session_env
# Check for display
if ! check_display; then
log_message 'No display detected (TTY session)'
echo 'Error: Claude Desktop requires a graphical desktop environment.' >&2
echo 'Please run from within an X11 or Wayland session, not from a TTY.' >&2
exit 1
fi
# Detect display backend
detect_display_backend
if [[ \$is_wayland == true ]]; then
log_message 'Wayland detected'
fi
if [[ ! -x \$app_exec ]]; then
log_message "Error: Claude Desktop binary not found at \$app_exec"
echo "Error: Claude Desktop binary not found at \$app_exec" >&2
exit 1
fi
# Build Chromium switches - use 'deb' type (same sandbox behavior)
build_electron_args 'deb'
# 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 the official binary and keep the launcher alive so explicit
# quit can clean up Desktop-owned helpers that outlive the main process.
log_message "Executing: \$app_exec \${electron_args[*]} \$*"
run_electron_and_cleanup "\$app_exec" "\${electron_args[@]}" "\$@"
exit \$?
EOF
chmod +x "$staging_dir/$package_name"
# --- Create RPM Spec File ---
echo 'Creating RPM spec file...'
# Build icon installation commands from the official hicolor tree. The
# source basename stays upstream's claude-desktop.png; the destination
# is renamed to $package_name.png so our files never collide with the
# icons installed by Anthropic's official claude-desktop package.
icon_install_cmds=""
official_hicolor="${CLAUDE_EXTRACT_DIR:?}/usr/share/icons/hicolor"
for icon_source_path in "$official_hicolor"/*/apps/claude-desktop.png; do
[[ -f $icon_source_path ]] || continue
size_dir=$(basename "$(dirname "$(dirname "$icon_source_path")")")
icon_install_cmds+="install -Dm 644 $icon_source_path %{buildroot}/usr/share/icons/hicolor/${size_dir}/apps/${package_name}.png
"
done
# CW-1: the official Cowork client probes a hardcoded, Debian-layout
# firmware list (x64: /usr/share/OVMF/OVMF_CODE{_4M,}.fd, arm64:
# /usr/share/AAVMF/AAVMF_CODE.fd) with no env override. Fedora ships
# its own /usr/share/OVMF compat layer, but other RPM hosts (openSUSE,
# Arch-derived layouts) put edk2 firmware elsewhere, so Cowork breaks
# out of the box there. The %post scriptlet below creates a compat
# symlink at the probed path when no probed path exists but a known
# edk2/qemu layout does; %postun removes it on erase (never a real
# file, never another package's symlink). Filed upstream as the
# OVMF-probe-rigidity report.
if [[ $rpm_arch == 'aarch64' ]]; then
fw_probe_dir='/usr/share/AAVMF'
fw_probe_list='/usr/share/AAVMF/AAVMF_CODE.fd'
fw_link_name='AAVMF_CODE.fd'
fw_candidates='/usr/share/edk2/aarch64/QEMU_EFI-pflash.raw'
fw_candidates+=' /usr/share/qemu/aavmf-aarch64-code.bin'
else
fw_probe_dir='/usr/share/OVMF'
fw_probe_list='/usr/share/OVMF/OVMF_CODE_4M.fd'
fw_probe_list+=' /usr/share/OVMF/OVMF_CODE.fd'
fw_link_name='OVMF_CODE_4M.fd'
fw_candidates='/usr/share/edk2/ovmf/OVMF_CODE.fd'
fw_candidates+=' /usr/share/edk2/x64/OVMF_CODE.4m.fd'
fw_candidates+=' /usr/share/qemu/ovmf-x86_64-code.bin'
fi
cat > "$rpmbuild_dir/SPECS/$package_name.spec" << SPECEOF
Name: $package_name
Version: $rpm_version
Release: $rpm_release%{?dist}
Summary: $description
License: Proprietary
URL: https://claude.ai
# The 'claude-desktop' name is our legacy repack (<= 1.15200.x); no
# official rpm exists. Obsoletes gives dnf the automatic rename
# upgrade path; the bound mirrors the deb Conflicts/Replaces scope.
Obsoletes: claude-desktop < 1.16000
Provides: claude-desktop = %{version}-%{release}
# Disable automatic dependency scanning (we bundle everything)
AutoReqProv: no
# Disable debug package generation
%define debug_package %{nil}
# Disable binary stripping (Electron binaries don't like it)
%define __strip /bin/true
# Disable build ID generation (avoids issues with Electron binaries)
%define _build_id_links none
%description
Claude is an AI assistant from Anthropic.
This package provides the desktop interface for Claude.
Supported on RPM-based Linux distributions (Fedora, RHEL, CentOS, etc.)
%install
rm -rf %{buildroot}
mkdir -p %{buildroot}/usr/lib/$package_name
mkdir -p %{buildroot}/usr/share/applications
mkdir -p %{buildroot}/usr/bin
# Install icons
$icon_install_cmds
# Copy application files (the extracted official usr/lib/claude-desktop
# tree: Electron ELF, chrome-sandbox, resources/, locales/, ...)
cp -a $app_staging_dir/. %{buildroot}/usr/lib/$package_name/
# 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/$package_name.desktop %{buildroot}/usr/share/applications/$package_name.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/$package_name %{buildroot}/usr/bin/$package_name
# 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).
# The official data.tar records the bit, but our non-root ar|tar
# extraction strips it.
chmod 4755 %{buildroot}/usr/lib/$package_name/chrome-sandbox
%post
# Update desktop database for MIME types
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
# Cowork firmware compat symlink (CW-1): the official client probes a
# hardcoded Debian-layout firmware list with no env override. If no
# probed path exists but a known edk2/qemu layout does, bridge it.
fw_have=''
for fw_probe in $fw_probe_list; do
if [ -e "\$fw_probe" ]; then
fw_have=1
break
fi
done
if [ -z "\$fw_have" ]; then
for fw_src in $fw_candidates; do
if [ -e "\$fw_src" ]; then
if mkdir -p $fw_probe_dir 2>/dev/null \\
&& ln -sf "\$fw_src" $fw_probe_dir/$fw_link_name 2>/dev/null
then
echo "Cowork firmware compat symlink: $fw_probe_dir/$fw_link_name -> \$fw_src"
fi
break
fi
done
fi
%postun
# Update desktop database after removal
update-desktop-database /usr/share/applications > /dev/null 2>&1 || true
# CW-1 cleanup on erase only (\$1 = 0; upgrades keep the symlink).
# Remove the compat symlink only when it is ours: a symlink no rpm
# package owns (distro compat links — e.g. Fedora's directory-level
# /usr/share/OVMF — are package-owned and must survive), pointing at
# one of the layouts %post bridges.
if [ "\$1" -eq 0 ] && [ -L $fw_probe_dir/$fw_link_name ] \\
&& ! rpm -qf $fw_probe_dir/$fw_link_name >/dev/null 2>&1; then
case "\$(readlink $fw_probe_dir/$fw_link_name)" in
/usr/share/edk2/*|/usr/share/qemu/*)
rm -f $fw_probe_dir/$fw_link_name
rmdir $fw_probe_dir 2>/dev/null || true
;;
esac
fi
%files
%defattr(-, root, root, 0755)
%attr(755, root, root) /usr/bin/$package_name
/usr/lib/$package_name
/usr/share/applications/$package_name.desktop
/usr/share/metainfo/$metainfo_name
/usr/share/icons/hicolor/*/apps/${package_name}.png
SPECEOF
echo 'RPM spec file created'
# --- Build RPM Package ---
echo 'Building RPM package...'
rpmbuild_log="$work_dir/rpmbuild.log"
rpmbuild --define "_topdir $rpmbuild_dir" \
--define "_rpmdir $work_dir" \
--target "$rpm_arch" \
-bb "$rpmbuild_dir/SPECS/$package_name.spec" 2>&1 |
tee "$rpmbuild_log"
if (( PIPESTATUS[0] != 0 )); then
echo 'Failed to build RPM package' >&2
exit 1
fi
# Guard against re-introducing #609. The "File listed twice" warning
# means %files has overlapping listings, and on modern rpmbuild any
# %exclude workaround silently strips the file from the payload.
if grep -qF 'File listed twice' "$rpmbuild_log"; then
echo 'rpmbuild emitted "File listed twice" — %files has overlapping listings (see #609)' >&2
grep -F 'File listed twice' "$rpmbuild_log" >&2
exit 1
fi
# Find and move the built RPM (it will be in a subdirectory)
rpm_file=$(find "$work_dir" -name "${package_name}-${rpm_version}*.rpm" -type f | head -n 1)
if [[ -z $rpm_file ]]; then
echo 'Could not find built RPM file' >&2
exit 1
fi
# Rename to consistent format at work_dir root
# Use original $version to maintain filename compatibility with DEB and AppImage
final_rpm="$work_dir/${package_name}-${version}-1.${rpm_arch}.rpm"
if [[ $rpm_file != "$final_rpm" ]]; then
mv "$rpm_file" "$final_rpm" || exit 1
fi
echo "RPM package built successfully: $final_rpm"
echo '--- RPM Package Build Finished ---'
exit 0
+256
View File
@@ -0,0 +1,256 @@
#===============================================================================
# app.asar patch orchestration for the official Linux tree.
#
# active_patches lists the asar patch functions that still justify
# themselves against the official bytes — the patch-zero contract: the
# default verdict for any patch is delete, and when the array is empty
# the official app.asar ships byte-identical (no extract, no repack).
#
# Each entry is a function sourced from scripts/patches/*.sh that edits
# the main-process JS relative to CWD; patch_app_asar runs them with
# CWD = $app_staging_dir/resources and sets $main_js (resolved by
# _resolve_main_js — see below) as the file every patch operates on.
#
# Sourced by: build.sh
# Sourced globals:
# app_staging_dir, asar_exec, work_dir, project_root, WM_CLASS
# Modifies globals: main_js
#===============================================================================
# Survivor candidates per docs/learnings/official-deb-rebase-verification.md:
# patch_quick_window — Electron-on-KDE stale-focus bug: official
# bundle still hides without blur() (pending
# Plasma repro; drop if it doesn't reproduce)
# patch_org_plugins_path — upstream platform switch has no linux case,
# so MDM org plugins are dead on Linux without
# this (filed upstream)
# patch_virtiofsd_probe — upstream resolves virtiofsd from two paths
# plus an Ubuntu-22-only bundled fallback, so
# Cowork reports "requires QEMU" on
# Arch/Debian/Pop with a complete KVM stack
# (#771/#772; filed upstream)
# patch_cowork_bwrap — opt-in bubblewrap Cowork backend for hosts
# without KVM/vhost-vsock (ChromeOS Crostini,
# #772). Every branch is gated on
# COWORK_VM_BACKEND=bwrap, so unflagged
# launches ship the official path unchanged.
active_patches=(
patch_quick_window
patch_org_plugins_path
patch_virtiofsd_probe
patch_cowork_bwrap
)
# The #768 config-wipe guard (config.sh) is NOT wired: a contrarian
# review (see docs/learnings/config-wipe-guard.md) established that the
# primary fix is launcher-side backup rotation (backup_user_config in
# launcher-common.sh) — patch-zero-clean, out of app.asar, and covers
# the corrupt-JSON / ENOENT / single-bad-entry Zod modes an in-band
# guard misses. config.sh stays sourced-but-parked as the ready-to-arm
# fallback. Its sibling local-stores.sh was deleted outright: its
# "does-not-JSON-parse" rule missed the throwing Zod loader (WBn.parse)
# that produces spaces.json's real wipe.
# AU-1/MB-1: build-time tripwires on upstream behavior we deleted
# patches for. Each deleted patch used to WARN at patch time when its
# anchor moved; with the patches gone, an upstream flip would land
# silently. Grep the asar directly (asar stores file contents
# uncompressed) so the check also runs in patch-zero mode, where the
# archive is never extracted.
#
# managed_by_package_manager — the telemetry reason inside the
# Linux build's constant-folded updater early-return ("[updater]
# Linux: in-app updater off (updates via apt)"). Renamed by
# upstream from apt_channel_pending in the 1.18286.2 → 1.19367.0
# window when the APT channel went live: Linux updates are now
# permanently the package manager's job (decision D-001). If it
# disappears, upstream rewrote that gate and may have turned on
# self-updating, which fights the package manager — the 2.x
# autoUpdater-noop question is live again.
# menuBarEnabled:!0 — the settings default that keeps the menu bar
# on. If it disappears, upstream flipped the default the deleted
# menuBar patch used to enforce.
#
# Patterns tolerate optional whitespace so a beautified or re-minified
# bundle still matches (see CLAUDE.md, Working with Minified JavaScript).
_check_upstream_tripwires() {
local asar_path="$1"
if ! LC_ALL=C grep -aq 'managed_by_package_manager' "$asar_path"
then
echo 'Tripwire (AU-1): "managed_by_package_manager" is gone' \
'from the official bundle — upstream may have enabled the' \
'autoupdater. Re-evaluate before shipping (see' \
'docs/decisions.md D-001).' >&2
exit 1
fi
if ! LC_ALL=C grep -aqE 'menuBarEnabled:[[:space:]]*!0' "$asar_path"
then
echo 'Tripwire (MB-1): "menuBarEnabled:!0" is gone from the' \
'official bundle — upstream may have flipped the menu-bar' \
'default. Re-evaluate before shipping.' >&2
exit 1
fi
echo 'Upstream tripwires clear (updater off on Linux, menu bar on)'
}
# Read one field out of the asar's package.json without a full extract.
_asar_package_json_field() {
local field="$1"
local asar_path="$2"
local meta_dir="$work_dir/asar-meta"
rm -rf "$meta_dir"
mkdir -p "$meta_dir" || return 1
(cd "$meta_dir" && "$asar_exec" extract-file "$asar_path" package.json) \
|| return 1
node -e 'console.log(require(process.argv[1])[process.argv[2]] ?? "")' \
"$meta_dir/package.json" "$field"
}
# Resolve the main-process JS file inside the extracted asar and echo
# its path relative to the resources CWD. Pre-3.x bundles kept the whole
# main process in .vite/build/index.js. Since upstream 1.19367.0 the
# bundle is code-split: index.js is a ~700-byte stub that require()s the
# real main chunk (index.chunk-<hash>.js — content-hashed, so the name
# changes every release). Follow the stub's require to the chunk; fall
# back to index.js for the pre-split layout. All active patches anchor on
# literals that live in this one chunk; if a future release spreads them
# across chunks, the patches need per-anchor resolution (this returns
# non-zero on a multi-chunk split rather than mispatching silently).
_resolve_main_js() {
local build_dir='app.asar.contents/.vite/build'
local stub="$build_dir/index.js"
if [[ ! -f $stub ]]; then
echo "No index.js under $build_dir — upstream layout changed?" >&2
return 1
fi
local -a chunks
mapfile -t chunks < <(
grep -oP 'require\("\./\Kindex\.chunk-[^"]+\.js(?="\))' "$stub"
)
if (( ${#chunks[@]} == 0 )); then
# Pre-split layout: index.js is the main process itself.
printf '%s\n' "$stub"
return 0
fi
if (( ${#chunks[@]} > 1 )); then
echo "index.js requires ${#chunks[@]} main chunks" \
"(${chunks[*]}) — upstream split the main bundle across" \
'files; patches need per-anchor resolution. Re-point' \
'scripts/patches/*.sh before shipping.' >&2
return 1
fi
local chunk="$build_dir/${chunks[0]}"
if [[ ! -f $chunk ]]; then
echo "index.js requires ${chunks[0]} but $chunk is missing" >&2
return 1
fi
printf '%s\n' "$chunk"
}
patch_app_asar() {
section_header 'Patch app.asar'
local resources_dir="$app_staging_dir/resources"
if [[ ! -f "$resources_dir/app.asar" ]]; then
echo "No app.asar at $resources_dir — upstream layout changed?" >&2
exit 1
fi
# Fail fast if upstream changed productName — a mismatch silently
# breaks StartupWMClass in every .desktop file we ship.
local product_name
product_name=$(_asar_package_json_field productName \
"$resources_dir/app.asar")
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
echo "productName '$product_name' matches WM_CLASS"
# Runs against the pristine bytes, before any patch touches them.
_check_upstream_tripwires "$resources_dir/app.asar"
if (( ${#active_patches[@]} == 0 )); then
echo 'active_patches is empty — shipping the official app.asar' \
'byte-identical (patch-zero)'
section_footer 'Patch app.asar'
return 0
fi
echo "Active asar patches: ${active_patches[*]}"
cd "$resources_dir" || exit 1
"$asar_exec" extract app.asar app.asar.contents || exit 1
# Resolve the code-split main chunk once; every patch reads $main_js.
main_js=$(_resolve_main_js) || exit 1
echo "Main-process JS: $main_js"
local patch_fn
for patch_fn in "${active_patches[@]}"; do
"$patch_fn" || exit 1
done
# Repack, preserving upstream's unpacked set exactly. The unpack
# expression is derived from the shipped app.asar.unpacked tree
# rather than hardcoded, so upstream can add native helpers without
# breaking the build. asar pack honors only ONE --unpack expression,
# so every unpacked path is folded into a single brace glob.
local unpack_files unpack_glob
mapfile -t unpack_files < <(
cd app.asar.unpacked && find . -type f | sed 's|^\./||' | sort
)
if (( ${#unpack_files[@]} == 0 )); then
echo 'Warning: official app.asar.unpacked is empty —' \
'packing without --unpack'
"$asar_exec" pack app.asar.contents app.asar || exit 1
else
unpack_glob=$(IFS=,; echo "${unpack_files[*]}")
(( ${#unpack_files[@]} > 1 )) && unpack_glob="{$unpack_glob}"
"$asar_exec" pack app.asar.contents app.asar \
--unpack "$unpack_glob" || exit 1
# The repack rewrote app.asar.unpacked; diverging from the
# upstream set means a native helper got inlined (or dropped)
# and would fail at runtime.
local repacked_files
mapfile -t repacked_files < <(
cd app.asar.unpacked && find . -type f | sed 's|^\./||' | sort
)
if [[ "${unpack_files[*]}" != "${repacked_files[*]}" ]]; then
echo 'Error: repacked app.asar.unpacked diverges from the' \
'upstream unpacked set' >&2
exit 1
fi
fi
# The extracted contents must not leak into the packaged tree.
rm -rf app.asar.contents
# Ship the bwrap fallback daemon beside app.asar (in resources/,
# i.e. process.resourcesPath) when its patch is active. It sits
# OUTSIDE the asar on purpose: child_process cannot exec a script
# from inside an asar, and keeping it out of app.asar.unpacked keeps
# the repack invariant above pinned to upstream's set. The file is
# inert unless the user launches with COWORK_VM_BACKEND=bwrap.
local p
for p in "${active_patches[@]}"; do
if [[ $p == 'patch_cowork_bwrap' ]]; then
cp "$project_root/scripts/cowork-fallback/cowork-vm-service.js" \
"$resources_dir/cowork-vm-service.js" || exit 1
echo 'Cowork bwrap daemon staged at resources/cowork-vm-service.js'
break
fi
done
cd "$project_root" || exit 1
section_footer 'Patch app.asar'
}
+185
View File
@@ -0,0 +1,185 @@
#===============================================================================
# Config-write guard (PARKED — not in active_patches). In-band fallback
# for the claude_desktop_config.json wipe class (#768; upstream
# anthropics/claude-code #32345 / #59640 / #63651). The PRIMARY fix is
# launcher-side backup rotation (backup_user_config in
# launcher-common.sh) — patch-zero-clean and broader-coverage; this
# stays hardened and ready to arm if the backup proves insufficient.
# Full rationale + the contrarian review that demoted it:
# docs/learnings/config-wipe-guard.md.
#
# What #768 actually hit (lead with the on-target rule, R3): the
# claude.ai renderer mirrors its grouping/starring stores into
# preferences.epitaxyPrefs on every launch via the AppPreferences
# bridge. A transient IndexedDB hydration failure hydrates those stores
# empty, and the mirror writes the empty state into epitaxyPrefs. R3
# catches exactly that.
#
# Same-class, seen upstream but NOT confirmed in #768: the config
# loader caches the parsed file once at cold start and silently falls
# back to {} on a failed read (inaccessible file, JSON-parse error,
# Zod rejection). A later whole-file write then serializes {} over a
# populated config, stubbing mcpServers / groupings / trusted folders.
# R1/R2 catch that.
#
# R1 top-level keys present on disk but absent from the outgoing
# object are restored (no code path legitimately deletes a
# top-level key, so absence always means "never loaded")
# R2 same rule per preferences.* key
# R3 preferences.epitaxyPrefs: only when EVERY outgoing value is
# deep-empty (the hydration-failure signature — a real session
# carries non-empty numeric view state) restore the non-empty
# values from disk
#
# Restores land on a lazy CLONE of the outgoing object (see the guard
# body), so a wrong R3 restore touches only the bytes on disk, never
# the live config cache — the sticky-trap the review flagged is gone.
#
# Does NOT revive the #400 Object.assign mcpServers merge: CF-1
# (2026-07-03) showed 1.18286.0 deletes entries programmatically, so a
# blind merge resurrects deleted servers. Deletions keep the key
# present, so none of R1-R3 fire on them.
#
# Known blind spots (fail-open, no worse than upstream):
# - Corrupt-JSON cold-start (loader mode 2): the guard's own re-parse
# of the still-corrupt disk file throws, so it writes the stub.
# Acceptable — corrupt bytes hold no recoverable structured data.
# - Persistent read failure (mode 1 not recovered by write time).
# - Deliberate clear-all of every epitaxy key including paneStore
# numerics: indistinguishable from a hydration failure, so R3 would
# restore it (disk-only now, not sticky). Rare; launcher backup is
# the real safety net.
#
# Sourced by: build.sh
# Sourced globals: project_root
# Modifies globals: (none)
#===============================================================================
patch_config_write_guard() {
echo 'Patching config writer to guard against poisoned-cache wipes...'
local index_js='app.asar.contents/.vite/build/index.js'
# Idempotency guard
if grep -q '_cdd_dc' "$index_js"; then
echo ' config-write guard 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 'Tripwire (CFG-1): config-write anchor missing — could not' \
'extract the write function around "Config file written".' \
'Upstream reshaped the config writer; re-derive the guard' \
'before shipping (scripts/patches/config.sh).' >&2
return 1
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 'Tripwire (CFG-1): could not extract the config path' \
'variable — re-derive the guard before shipping.' >&2
return 1
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 'Tripwire (CFG-1): could not extract the config object' \
'variable — re-derive the guard before shipping.' >&2
return 1
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, '\\\\\$&');
// [\$\\w] not \\w: minified identifiers may contain \$
// (docs/learnings/patching-minified-js.md)
const anchorSrc =
'await\\\\s+' + reEsc(W) + '\\\\(' + reEsc(P) + ',\\\\s*' + reEsc(C) +
'\\\\)\\\\s*,\\\\s*[\$\\\\w]+\\\\.info\\\\(\"Config file written\"\\\\)';
const hits = code.match(new RegExp(anchorSrc, 'g')) ?? [];
if (hits.length !== 1) {
console.error(' [FAIL] Config-write anchor matched ' + hits.length +
' times (expected 1)');
process.exit(1);
}
const anchor = new RegExp(anchorSrc);
// Restores are applied to a LAZY CLONE of the outgoing object, never
// to the object itself. arA's config param is a local binding
// captured by the write closure, so reassigning it (CFG=...) feeds the
// clone to the writer while leaving the in-memory config cache (PaA)
// exactly as the renderer set it. That removes the sticky-trap: a
// wrong R3 restore (see the doc's false-positive analysis) affects
// only the bytes on disk, not live session state, and never
// re-materializes into subsequent writes.
//
// _cdd_em: deep-empty — null/[]/{} or an object whose every value is
// deep-empty. Numbers/strings/booleans are never empty, so real view
// state (rowSplit:0.5, version:0) keeps R3 from firing on live data.
const guard =
C + '=(function(_p,_c){try{' +
'var _cdd_dc=JSON.parse(require(\"fs\").readFileSync(_p,\"utf8\"));' +
'if(!_cdd_dc||typeof _cdd_dc!=\"object\"||Array.isArray(_cdd_dc))return _c;' +
'var _cdd_em=function(v){if(v==null)return!0;' +
'if(Array.isArray(v))return v.length===0;' +
'if(typeof v==\"object\"){for(var _cdd_i in v){' +
'if(!_cdd_em(v[_cdd_i]))return!1}return!0}return!1};' +
'var _cdd_o=null,_cdd_cl=function(){' +
'return _cdd_o||(_cdd_o=Object.assign({},_c))};' +
'for(var _cdd_k in _cdd_dc){if(_c[_cdd_k]===void 0)' +
'_cdd_cl()[_cdd_k]=_cdd_dc[_cdd_k]}' +
'var _cdd_dp=_cdd_dc.preferences,_cdd_cp=_c.preferences;' +
'if(_cdd_dp&&typeof _cdd_dp==\"object\"&&_cdd_cp&&' +
'typeof _cdd_cp==\"object\"){' +
'var _cdd_po=null,_cdd_pcl=function(){if(!_cdd_po){' +
'_cdd_po=Object.assign({},_cdd_cp);_cdd_cl().preferences=_cdd_po}' +
'return _cdd_po};' +
'for(var _cdd_p in _cdd_dp){if(_cdd_cp[_cdd_p]===void 0)' +
'_cdd_pcl()[_cdd_p]=_cdd_dp[_cdd_p]}' +
'var _cdd_de=_cdd_dp.epitaxyPrefs,' +
'_cdd_ce=(_cdd_po||_cdd_cp).epitaxyPrefs;' +
'if(_cdd_de&&typeof _cdd_de==\"object\"&&_cdd_ce&&' +
'typeof _cdd_ce==\"object\"&&_cdd_em(_cdd_ce)&&!_cdd_em(_cdd_de)){' +
'var _cdd_eo=Object.assign({},_cdd_ce);' +
'for(var _cdd_g in _cdd_de){if(!_cdd_em(_cdd_de[_cdd_g]))' +
'_cdd_eo[_cdd_g]=_cdd_de[_cdd_g]}' +
'_cdd_pcl().epitaxyPrefs=_cdd_eo}' +
'}' +
'return _cdd_o||_c;' +
'}catch(_cdd_ex){return _c}})(' + P + ',' + C + ')';
code = code.replace(anchor, (m) => guard + ';' + m);
fs.writeFileSync(p, code);
console.log(' [OK] config-write wipe guard injected before config write');
"; then
echo 'Failed to inject config write guard' >&2
cd "$project_root" || exit 1
exit 1
fi
echo '##############################################################'
}
+230
View File
@@ -0,0 +1,230 @@
# shellcheck shell=bash
#===============================================================================
# Cowork bwrap fallback — opt-in, runtime-gated on COWORK_VM_BACKEND=bwrap.
#
# The official client runs Cowork in a KVM microVM: its yukonSilver
# evaluator demands /dev/kvm + /dev/vhost-vsock, and on success it
# spawns a bundled native helper (`cowork-linux-helper -socket <path>`)
# that owns QEMU. Hosts without KVM/vsock — notably ChromeOS Crostini,
# whose Termina kernel blocks vhost_vsock outright (#772) — can never
# satisfy that gate, and upstream's own "install QEMU" hint can't fix a
# kernel-level block.
#
# This patch reinstates the pre-3.0.0 bubblewrap backend as an opt-in
# path. Every injected branch is gated on BOTH process.platform==="linux"
# AND process.env.COWORK_VM_BACKEND==="bwrap", so on an unflagged launch
# every injected branch evaluates false and the official code path runs
# unchanged — nothing changes for the KVM majority. When flagged:
#
# A (evaluator) — report yukonSilver "supported" so the Cowork tab
# un-grays and startVM's gate opens.
# B (spawn swap) — spawn `node cowork-vm-service.js -socket <path>`
# (system node; the official binary's RunAsNode fuse
# is off so it can't run the daemon itself) in place
# of the native helper. The daemon speaks the helper
# socket protocol (scripts/cowork-fallback/PROTOCOL.md)
# backed by bubblewrap instead of QEMU.
# C (download) — suppress the multi-GB VM-image download the bwrap
# backend has no use for (foreground + warm).
#
# A and B are load-bearing: an anchor miss fails the build (shipping
# without them silently reverts the flag to a broken state). C is
# best-effort — a miss only wastes bandwidth, so it warns.
#
# The daemon ships in resources/ (next to app.asar), NOT inside the asar
# or app.asar.unpacked: the repack invariant requires the unpacked-file
# set to match upstream, and child_process can't exec from inside an
# asar. The launcher exports COWORK_NODE_PATH (detected system node) and
# only wires all this up when the user sets COWORK_VM_BACKEND=bwrap.
#
# Sourced by: build.sh
# Sourced globals: main_js (optional — the resolved main chunk; set by
# patch_app_asar. A/B/C1 patch it; C2 patches the warm chunk, resolved
# here by its [warm] log literal. Both fall back to index.js on older
# single-file bundles.)
# Modifies globals: (none)
#===============================================================================
patch_cowork_bwrap() {
echo 'Patching Cowork bwrap fallback (opt-in COWORK_VM_BACKEND=bwrap)...'
local index_js="${main_js:-app.asar.contents/.vite/build/index.js}"
# A/B/C1 live in the main chunk; C2's warm-prefetch code can sit in a
# separate code-split chunk (1.19367.0+). Resolve the file carrying
# the warm anchor by its stable log literal; fall back to the main
# chunk for older single-file bundles.
local build_dir warm_js
build_dir=$(dirname "$index_js")
warm_js=$(grep -lF '[warm] Warm download disabled' \
"$build_dir"/*.js 2>/dev/null | head -1)
[[ -z $warm_js ]] && warm_js="$index_js"
if INDEX_JS="$index_js" WARM_JS="$warm_js" node << 'COWORK_BWRAP_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
const warmJs = process.env.WARM_JS || indexJs;
const warmSameFile = warmJs === indexJs;
let code = fs.readFileSync(indexJs, 'utf8');
// The runtime gate shared by every injected branch. Unflagged launches
// never enter any of them, so the official path ships unchanged.
const GATE =
'process.platform==="linux"&&process.env.COWORK_VM_BACKEND==="bwrap"';
let loadBearingFailed = false;
// ---------------------------------------------------------------------
// Patch A: yukonSilver evaluator — report "supported" when flagged.
//
// The Linux support computer is reached through a platform-dispatch
// wrapper whose whole body is `return process.platform,Cen()` (the
// `process.platform,` is a discarded comma-expression left by upstream
// minification — a stable, unique anchor). Injecting a flagged early
// return here covers BOTH consumers of the evaluator: the renderer's
// Cowork-tab visibility and startVM's execution gate.
// ---------------------------------------------------------------------
const evalRe =
/function\s+([\w$]+)\(\)\{return process\.platform,([\w$]+)\(\)\}/;
if (new RegExp('return\\{status:"supported"\\};return process\\.platform,')
.test(code)) {
console.log(' A: evaluator already gated (supported when flagged)');
} else {
// Fail loud if upstream ever grows a second platform-dispatch of
// this exact shape — a blind first-match swap would be wrong.
const evalAll = [...code.matchAll(new RegExp(evalRe, 'g'))];
const m = evalAll.length === 1 ? evalAll[0] : null;
if (evalAll.length > 1) {
console.log(' A: FATAL — yukonSilver platform-dispatch anchor ' +
'matched ' + evalAll.length + ' sites, expected exactly 1');
loadBearingFailed = true;
} else if (m) {
const replacement = 'function ' + m[1] + '(){if(' + GATE +
')return{status:"supported"};return process.platform,' +
m[2] + '()}';
// () => replacement: "$" is legal in minified identifiers, and
// a string replacement would reinterpret $1/$&-style sequences
// inside a captured name as substitution patterns — silently
// corrupting the bundle (the $ trap in
// docs/learnings/patching-minified-js.md, replacement side).
code = code.replace(evalRe, () => replacement);
console.log(' A: gated yukonSilver evaluator -> supported ' +
'when flagged (' + m[1] + '/' + m[2] + ')');
} else {
console.log(' A: FATAL — yukonSilver platform-dispatch anchor ' +
'(function X(){return process.platform,Y()}) not found');
loadBearingFailed = true;
}
}
// ---------------------------------------------------------------------
// Patch B: helper spawn swap.
//
// Official: IE.spawn(A,["-socket",Vie()],{stdio:["pipe","pipe","pipe"]})
// A = native helper path (kMt(), resources/cowork-linux-helper)
// Vie = $XDG_RUNTIME_DIR/claude-cowork-vm.sock
// When flagged, spawn the Node daemon instead: system node (from
// COWORK_NODE_PATH, exported by the launcher) running the daemon shipped
// at resources/cowork-vm-service.js, with the same -socket argv appended.
// The client's restart-backoff wraps this call, so respawns route
// through the swap too. Identifiers (IE, A, Vie) are captured, not
// hardcoded.
// ---------------------------------------------------------------------
if (code.includes('/*cowork-bwrap-spawn*/')) {
console.log(' B: helper spawn swap already applied');
} else {
const spawnRe =
/([\w$]+)\.spawn\(([\w$]+),\[\s*"-socket"\s*,\s*([\w$]+)\(\)\s*\]\s*,\s*\{\s*stdio:\s*\[\s*"pipe"\s*,\s*"pipe"\s*,\s*"pipe"\s*\]\s*\}\)/;
// Assert the helper-spawn shape is unique before swapping — a blind
// first-match replace would half-patch if upstream duplicates it.
const spawnAll = [...code.matchAll(new RegExp(spawnRe, 'g'))];
const m = spawnAll.length === 1 ? spawnAll[0] : null;
if (spawnAll.length > 1) {
console.log(' B: FATAL — helper spawn anchor matched ' +
spawnAll.length + ' sites, expected exactly 1');
loadBearingFailed = true;
} else if (m) {
const spawnObj = m[1], helperPath = m[2], sockFn = m[3];
const flagged = '(' + GATE + ')';
const daemon =
'require("path").join(process.resourcesPath,' +
'"cowork-vm-service.js")';
const cmd = flagged +
'?(process.env.COWORK_NODE_PATH||"node"):' + helperPath;
const args = flagged +
'?[' + daemon + ',"-socket",' + sockFn + '()]' +
':["-socket",' + sockFn + '()]';
const replacement = '/*cowork-bwrap-spawn*/' + spawnObj +
'.spawn(' + cmd + ',' + args +
',{stdio:["pipe","pipe","pipe"]})';
// () => replacement: same $-in-identifier trap as Patch A.
code = code.replace(spawnRe, () => replacement);
console.log(' B: swapped helper spawn -> node daemon when ' +
'flagged (' + spawnObj + '/' + helperPath + '/' + sockFn + ')');
} else {
console.log(' B: FATAL — helper spawn anchor ' +
'(X.spawn(P,["-socket",S()],{stdio:[...]})) not found');
loadBearingFailed = true;
}
}
// ---------------------------------------------------------------------
// Patch C: suppress the VM-image download on the bwrap path (best
// effort). Both the foreground downloader and the warm prefetch gate on
// yukonSilver being supported — which Patch A now makes true — so guard
// each at its function head. A miss here only wastes bandwidth/disk, so
// warn rather than fail.
// ---------------------------------------------------------------------
// Foreground: async function OzA(A,e){const{yukonSilver:t}=sM();return...
const dlRe =
/(async function\s+[\w$]+\([\w$]+,[\w$]+\)\{)(const\{yukonSilver:[\w$]+\}=[\w$]+\(\);return\([\w$]+==null\?void 0:[\w$]+\.status\)!=="supported"\?!1:)/;
if (code.includes('/*cowork-bwrap-dl*/')) {
console.log(' C1: foreground download block already applied');
} else if (dlRe.test(code)) {
code = code.replace(dlRe,
'$1/*cowork-bwrap-dl*/if(' + GATE + ')return!1;$2');
console.log(' C1: blocked foreground VM download when flagged');
} else {
console.log(' C1: WARNING — foreground download anchor not found; ' +
'flagged runs may download an unused VM image');
}
// Warm prefetch: async function Vdo(A,e,t){if(!e){..."[warm] Warm download
// This function moved to its own code-split chunk in 1.19367.0, so C2
// operates on warmCode (the warm chunk), which is the same string as
// `code` only in the older single-file layout.
let warmCode = warmSameFile ? code : fs.readFileSync(warmJs, 'utf8');
const warmRe =
/(async function\s+[\w$]+\([\w$]+,[\w$]+,[\w$]+\)\{)(if\(![\w$]+\)\{[\s\S]{0,120}?\[warm\] Warm download disabled)/;
if (warmCode.includes('/*cowork-bwrap-warm*/')) {
console.log(' C2: warm download block already applied');
} else if (warmRe.test(warmCode)) {
warmCode = warmCode.replace(warmRe,
'$1/*cowork-bwrap-warm*/if(' + GATE + ')return;$2');
console.log(' C2: blocked warm VM prefetch when flagged');
} else {
console.log(' C2: WARNING — warm download anchor not found; flagged ' +
'runs may prefetch an unused VM image');
}
if (warmSameFile) code = warmCode;
if (loadBearingFailed) {
console.log(' One or more load-bearing anchors (A/B) missed — ' +
'refusing to ship a half-patched bwrap fallback.');
process.exit(1);
}
fs.writeFileSync(indexJs, code);
if (!warmSameFile) fs.writeFileSync(warmJs, warmCode);
COWORK_BWRAP_PATCH
then
echo 'Cowork bwrap fallback patch applied'
else
echo 'ERROR: Cowork bwrap patch failed. The opt-in' \
'COWORK_VM_BACKEND=bwrap path (#772, ChromeOS/Crostini and' \
'other KVM-less hosts) would be broken. Update the anchors' \
'in scripts/patches/cowork-bwrap.sh against the new bundle' \
'before shipping.' >&2
return 1
fi
echo '##############################################################'
}
+58
View File
@@ -0,0 +1,58 @@
#===============================================================================
# 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: main_js (optional — the resolved main chunk; set by
# patch_app_asar. Falls back to .vite/build/index.js for older bundles.)
# Modifies globals: (none)
#===============================================================================
patch_org_plugins_path() {
local index_js="${main_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
}
+164
View File
@@ -0,0 +1,164 @@
#===============================================================================
# Quick-window patches: KDE-gated blur/focus workarounds for the pop-up menu
# so the main window reappears after quick-entry submit.
#
# Sourced by: build.sh
# Sourced globals: main_js (optional — the resolved main chunk; set by
# patch_app_asar. Falls back to .vite/build/index.js for older bundles.)
# Modifies globals: (none)
#===============================================================================
patch_quick_window() {
echo 'Patching quick window for Linux...'
local index_js="${main_js:-app.asar.contents/.vite/build/index.js}"
# 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"\))' \
"$index_js" | head -1)
if [[ -z $quick_var ]]; then
echo 'WARNING: Could not extract quick window variable name'
echo '##############################################################'
return
fi
echo " Found quick window variable: $quick_var"
local quick_var_re="${quick_var//\$/\\$}"
# Part 1: Add blur() before hide() on the quick window so that
# isFocused() returns false after hiding (Electron Linux bug on KDE).
# The hide call sits after || (e.g. GUARD()||VAR.hide()), so both
# calls must be wrapped in parens to preserve short-circuit semantics.
# Gated to KDE only: on GNOME/Ubuntu the blur() regresses quick entry
# (see #393), and the focus-stale bug doesn't manifest there.
local de_check='(process.env.XDG_CURRENT_DESKTOP||"")'
de_check+='.toLowerCase().includes("kde")'
if grep -qF "${quick_var}.blur(),${quick_var}.hide()" "$index_js"; then
echo ' Quick window blur already patched'
elif grep -qP "\|\|\s*${quick_var_re}\.hide\(\)" "$index_js"; then
sed -i -E \
"s/\|\|\s*${quick_var_re}\.hide\(\)/||(${de_check}?(${quick_var}.blur(),${quick_var}.hide()):${quick_var}.hide())/g" \
"$index_js"
echo ' Added KDE-gated blur() before hide() on quick window'
else
echo ' WARNING: Could not find quick window hide() call'
fi
# Part 2: Fix main window not appearing after quick entry submit.
# On KDE, isFocused() can return stale true after hiding, causing
# FOCUS_CHECK()||Lt.show() to skip the show. Gate the visibility-check
# replacement to KDE only: on GNOME, the original focus check works
# and replacing it regresses quick entry (see #393).
if INDEX_JS="$index_js" node << 'QUICK_WINDOW_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
let patchCount = 0;
// Find the minified isWindowFocused function via its named property
// export: isWindowFocused: () => !!NAME()
const focusedPropRe = /isWindowFocused:\s*\(\)\s*=>\s*!!([\w$]+)\(\)/;
const focusedMatch = code.match(focusedPropRe);
if (!focusedMatch) {
console.log(' WARNING: Could not find isWindowFocused function');
process.exit(1);
}
const focusFn = focusedMatch[1];
console.log(' Found focus check function: ' + focusFn);
// Find the sibling isVisible function defined near the focus function.
// Tolerate the optional `var <name>(,<name>)*;` declaration the
// minifier hoists when the function body uses optional chaining
// (1.3883.0+ shape: `function aZA(){var e;return!Qt...}`). Older
// builds don't declare anything before `return!`. The non-capturing
// group keeps the prefix optional in either case.
//
// The window handle churns: a bare minified local (`Qt`, `it`) through
// 1.18286.0, a property access (`exports.mainWindow`) from 1.19367.0.
// [\w$]+(?:\.[\w$]+)* covers both; the fn-name capture is [\w$]+ (not
// \w+) so a `$`-prefixed name isn't silently truncated
// (docs/learnings/patching-minified-js.md).
const focusFnIdx = code.indexOf('function ' + focusFn + '(');
const nearbyCode = code.substring(focusFnIdx, focusFnIdx + 500);
const win = String.raw`[\w$]+(?:\.[\w$]+)*`;
const visFnRe = new RegExp(
String.raw`function ([\w$]+)\(\)\{(?:var [\w$]+(?:,[\w$]+)*;)?` +
`return!${win}\\|\\|${win}\\.isDestroyed\\(\\)\\?!1:` +
`${win}\\.isVisible\\(\\)`
);
const visMatch = nearbyCode.match(visFnRe);
if (!visMatch) {
console.log(' WARNING: Could not find visibility function near ' +
focusFn);
process.exit(1);
}
const visFn = visMatch[1];
console.log(' Found visibility check function: ' + visFn);
// Anchor on unique QuickEntry log strings to patch only the right sites
const anchors = [
'Navigating to existing chat',
'Creating new chat with submit_quick_entry',
];
const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (const anchor of anchors) {
const anchorIdx = code.indexOf(anchor);
if (anchorIdx === -1) {
console.log(' WARNING: anchor not found: ' + anchor);
continue;
}
// Search region after anchor (1500 chars covers promise chains)
const region = code.substring(anchorIdx, anchorIdx + 1500);
// Idempotency: if region already contains the DE gate, skip
if (region.indexOf('XDG_CURRENT_DESKTOP') !== -1) {
console.log(' Quick entry show() already patched near "' +
anchor.substring(0, 30) + '..."');
continue;
}
// matches: <focusFn>()||<win>.show(), where <win> is a bare local
// (older builds) or a property access like exports.mainWindow
// (1.19367.0+). Capturing the whole handle keeps the .show() call
// pointed at the real window after the rewrite.
const showRe = new RegExp(
escapeRegExp(focusFn) + String.raw`\(\)\|\|(${win})\.show\(\)`
);
const showMatch = region.match(showRe);
if (showMatch) {
const oldStr = showMatch[0];
const mainWin = showMatch[1];
// Gate the visibility check to KDE only; fall back to original
// focus check on GNOME/other so #390 doesn't regress them (#393).
const deCheck = '(process.env.XDG_CURRENT_DESKTOP||"")' +
'.toLowerCase().includes("kde")';
const newStr = '(' + deCheck + '?' + visFn + '():' +
focusFn + '())||' + mainWin + '.show()';
if (oldStr !== newStr) {
const absIdx = anchorIdx + region.indexOf(oldStr);
code = code.substring(0, absIdx) + newStr +
code.substring(absIdx + oldStr.length);
console.log(' KDE-gated ' + focusFn + '()/' + visFn +
'() for show() near "' + anchor.substring(0, 30) + '..."');
patchCount++;
}
} else {
console.log(' WARNING: show() pattern not found near "' +
anchor + '"');
}
}
if (patchCount > 0) {
fs.writeFileSync(indexJs, code);
console.log(' Patched ' + patchCount +
' quick entry show() calls to use visibility check');
} else {
console.log(' WARNING: No quick entry show() calls patched');
}
QUICK_WINDOW_PATCH
then
echo 'Quick window patches applied'
else
echo 'WARNING: Quick window show patch failed' >&2
fi
echo '##############################################################'
}
+100
View File
@@ -0,0 +1,100 @@
# shellcheck shell=bash
#===============================================================================
# virtiofsd resolution: un-gate the bundled fallback so Cowork's KVM
# stack resolves virtiofsd on every distro, not just Ubuntu 22.x.
#
# The official client resolves virtiofsd from exactly two absolute
# paths (/usr/libexec/virtiofsd, /usr/bin/virtiofsd) and only falls
# back to its own bundled copy (resources/virtiofsd) when
# /etc/os-release says ID=ubuntu with VERSION_ID 22.x. Arch installs
# virtiofsd at /usr/lib/virtiofsd, Debian at /usr/lib/qemu/virtiofsd,
# and Ubuntu derivatives (ID=pop, ID=linuxmint) fail the os-release
# check — on all of them virtiofsdPath resolves null and the support
# evaluator reports "Cowork requires QEMU" with everything installed
# (#771, #772; filed upstream, docs/upstream-reports/).
#
# The minimal fix is to drop the os-release condition on the bundled
# fallback: system paths stay preferred, and the version-matched
# binary Anthropic already ships covers everyone else. The probe
# array is deliberately NOT extended with the Arch/Debian paths —
# on qemu <8 hosts /usr/lib/qemu/virtiofsd can be the legacy C
# implementation, whose CLI is incompatible with how the client
# spawns the Rust virtiofsd (the likely reason upstream gates the
# list this tightly).
#
# Sourced by: build.sh
# Sourced globals: main_js (optional — the resolved main chunk; set by
# patch_app_asar. Falls back to .vite/build/index.js for older bundles.)
# Modifies globals: (none)
#===============================================================================
patch_virtiofsd_probe() {
echo 'Patching virtiofsd resolution (bundled fallback un-gate)...'
local index_js="${main_js:-app.asar.contents/.vite/build/index.js}"
# Anchored on the probe-path array literal (path strings survive
# minification); the gate rewrite happens in a bounded window after
# it so the shape-matched expression can't hit an unrelated site.
# Unlike the survivor cosmetic patches, an anchor miss here fails
# the build: shipping without this patch silently re-opens #771.
if INDEX_JS="$index_js" node << 'VIRTIOFSD_PATCH'
const fs = require('fs');
const indexJs = process.env.INDEX_JS;
let code = fs.readFileSync(indexJs, 'utf8');
// The client's virtiofsd probe-path array, whitespace-tolerant for
// beautified bundles:
// ["/usr/libexec/virtiofsd","/usr/bin/virtiofsd"]
const arrRe =
/\[\s*"\/usr\/libexec\/virtiofsd"\s*,\s*"\/usr\/bin\/virtiofsd"\s*\]/g;
const arrMatches = [...code.matchAll(arrRe)];
if (arrMatches.length !== 1) {
console.log(' WARNING: expected exactly 1 virtiofsd probe-path ' +
'array, found ' + arrMatches.length);
process.exit(1);
}
// Window after the array: the Ubuntu-detect helper sits between the
// array and the resolver (~230 chars minified, ~400 beautified).
const start = arrMatches[0].index + arrMatches[0][0].length;
const region = code.substring(start, start + 1200);
// Gated resolver tail (minified):
// return e||(A?d3A(igi,bA.constants.X_OK):null)
// e = system-path hit, A = "is Ubuntu 22.x", d3A(igi,...) = bundled
// copy at process.resourcesPath. Identifiers are captured, not
// hardcoded — they change every release.
const gatedRe =
/return\s+([\w$]+)\s*\|\|\s*\(\s*[\w$]+\s*\?\s*([\w$]+\(\s*[\w$]+\s*,\s*[\w$]+\.constants\.X_OK\s*\))\s*:\s*null\s*\)/;
// Already-ungated form, for idempotency (re-run, or upstream fix):
const ungatedRe =
/return\s+([\w$]+)\s*\|\|\s*[\w$]+\(\s*[\w$]+\s*,\s*[\w$]+\.constants\.X_OK\s*\)/;
const gated = region.match(gatedRe);
if (gated) {
const abs = start + gated.index;
const replacement = 'return ' + gated[1] + '||' + gated[2];
code = code.substring(0, abs) + replacement +
code.substring(abs + gated[0].length);
fs.writeFileSync(indexJs, code);
console.log(' Un-gated bundled virtiofsd fallback: ' + gated[2]);
} else if (ungatedRe.test(region)) {
console.log(' Bundled virtiofsd fallback already un-gated');
} else {
console.log(' WARNING: bundled-fallback gate not found near the ' +
'probe array — upstream reshaped the resolver');
process.exit(1);
}
VIRTIOFSD_PATCH
then
echo 'virtiofsd probe patch applied'
else
echo 'ERROR: virtiofsd probe patch failed. Without it, Cowork' \
'reports "requires QEMU" on Arch/Debian/Ubuntu-derivative' \
'hosts with a complete KVM stack (#771, #772). Update the' \
'anchors in scripts/patches/virtiofsd-probe.sh against the' \
'new bundle before shipping.' >&2
return 1
fi
echo '##############################################################'
}
+244
View File
@@ -0,0 +1,244 @@
#===============================================================================
# Dependency installation and work-directory/Node/asar bootstrap.
#
# Sourced by: build.sh
# Sourced globals:
# build_format, distro_family, work_dir, project_root
# Modifies globals:
# asar_exec (via setup_asar); PATH is exported (via setup_nodejs)
#===============================================================================
check_dependencies() {
echo 'Checking dependencies...'
local deps_to_install=''
# ar (binutils) plus tar with xz/zstd support unpack the official
# .deb without dpkg, so rpm-family and Arch hosts can build too.
local all_deps='wget ar tar xz zstd'
# Add format-specific dependencies
case "$build_format" in
deb) all_deps="$all_deps dpkg-deb" ;;
rpm) all_deps="$all_deps rpmbuild" ;;
esac
# Command-to-package mappings per distro family
declare -A debian_pkgs=(
[wget]='wget' [ar]='binutils' [tar]='tar'
[xz]='xz-utils' [zstd]='zstd'
[dpkg-deb]='dpkg-dev' [rpmbuild]='rpm'
)
declare -A rpm_pkgs=(
[wget]='wget' [ar]='binutils' [tar]='tar'
[xz]='xz' [zstd]='zstd'
[dpkg-deb]='dpkg' [rpmbuild]='rpm-build'
)
local cmd pkg
for cmd in $all_deps; do
if ! check_command "$cmd"; then
case "$distro_family" in
debian) pkg="${debian_pkgs[$cmd]}" ;;
rpm) pkg="${rpm_pkgs[$cmd]}" ;;
*)
echo "Warning: Cannot auto-install '$cmd' on unknown distro. Please install manually." >&2
continue
;;
esac
# Several commands can map to the same package. Skip if the
# package is already queued so the log line stays readable.
case " $deps_to_install " in
*" $pkg "*) ;;
*) deps_to_install="$deps_to_install $pkg" ;;
esac
fi
done
if [[ -n $deps_to_install ]]; then
echo "System dependencies needed:$deps_to_install"
# Determine if we need sudo (skip if already root)
local sudo_cmd='sudo'
if (( EUID == 0 )); then
sudo_cmd=''
echo 'Installing as root (no sudo needed)...'
else
echo 'Attempting to install using sudo...'
# Check if we can sudo without a password first
if sudo -n true 2>/dev/null; then
echo 'Passwordless sudo detected.'
elif ! sudo -v; then
echo 'Failed to validate sudo credentials. Please ensure you can run sudo.' >&2
exit 1
fi
fi
case "$distro_family" in
debian)
if ! $sudo_cmd apt update; then
echo "Failed to run 'apt update'." >&2
exit 1
fi
# shellcheck disable=SC2086
if ! $sudo_cmd apt install -y $deps_to_install; then
echo "Failed to install dependencies using 'apt install'." >&2
exit 1
fi
;;
rpm)
# shellcheck disable=SC2086
if ! $sudo_cmd dnf install -y $deps_to_install; then
echo "Failed to install dependencies using 'dnf install'." >&2
exit 1
fi
;;
*)
echo "Cannot auto-install dependencies on unknown distro." >&2
echo "Please install these packages manually: $deps_to_install" >&2
exit 1
;;
esac
echo 'System dependencies installed successfully.'
fi
}
setup_work_directory() {
rm -rf "$work_dir"
mkdir -p "$work_dir" || exit 1
}
setup_nodejs() {
section_header 'Node.js Setup'
echo 'Checking Node.js version...'
local node_version_ok=false
if command -v node &> /dev/null; then
local node_version node_major
node_version=$(node --version | cut -d'v' -f2)
node_major="${node_version%%.*}"
echo "System Node.js version: v$node_version"
if (( node_major >= 20 )); then
echo "System Node.js version is adequate (v$node_version)"
node_version_ok=true
else
echo "System Node.js version is too old (v$node_version). Need v20+"
fi
else
echo 'Node.js not found in system'
fi
if [[ $node_version_ok == true ]]; then
section_footer 'Node.js Setup'
return 0
fi
# Node.js version inadequate - install locally
echo 'Installing Node.js v20 locally in build directory...'
# Node is build-host tooling: it runs asar here and never ships in
# the package, so it is keyed to uname -m, NOT to $architecture —
# which --arch can override to the cross-build target (an arm64
# node on an amd64 runner dies with an exec-format error).
local node_arch host_arch
host_arch=$(uname -m)
case "$host_arch" in
x86_64) node_arch='x64' ;;
aarch64) node_arch='arm64' ;;
*)
echo "Unsupported host architecture for Node.js: $host_arch" >&2
exit 1
;;
esac
local node_version_to_install='20.18.1'
local node_tarball="node-v${node_version_to_install}-linux-${node_arch}.tar.xz"
local node_url="https://nodejs.org/dist/v${node_version_to_install}/${node_tarball}"
local node_install_dir="$work_dir/node"
echo "Downloading Node.js v${node_version_to_install} for ${node_arch}..."
cd "$work_dir" || exit 1
if ! wget -O "$node_tarball" "$node_url"; then
echo "Failed to download Node.js from $node_url" >&2
cd "$project_root" || exit 1
exit 1
fi
# Verify against official Node.js checksums
local shasums_url node_expected_sha256
shasums_url="https://nodejs.org/dist/v${node_version_to_install}/SHASUMS256.txt"
node_expected_sha256=$(
wget -qO- "$shasums_url" \
| grep -F "$node_tarball" \
| awk '{print $1}'
) || true
if ! verify_sha256 "$work_dir/$node_tarball" \
"$node_expected_sha256" 'Node.js tarball'; then
cd "$project_root" || exit 1
exit 1
fi
echo 'Extracting Node.js...'
if ! tar -xf "$node_tarball"; then
echo 'Failed to extract Node.js tarball' >&2
cd "$project_root" || exit 1
exit 1
fi
mv "node-v${node_version_to_install}-linux-${node_arch}" "$node_install_dir" || exit 1
export PATH="$node_install_dir/bin:$PATH"
if command -v node &> /dev/null; then
echo "Local Node.js installed successfully: $(node --version)"
else
echo 'Failed to install local Node.js' >&2
cd "$project_root" || exit 1
exit 1
fi
rm -f "$node_tarball"
cd "$project_root" || exit 1
section_footer 'Node.js Setup'
}
setup_asar() {
section_header 'Asar Tooling'
# @electron/asar is only needed while at least one asar patch is
# active (see active_patches in scripts/patches/app-asar.sh);
# patch_app_asar also uses it to read package.json fields without a
# full extract. No Electron install: the official tree ships its own
# runtime, so the old pinned-Electron staging is gone.
echo "Ensuring local asar installation in $work_dir..."
cd "$work_dir" || exit 1
if [[ ! -f package.json ]]; then
echo "Creating temporary package.json in $work_dir for local install..."
echo '{"name":"claude-desktop-build","version":"0.0.1","private":true}' > package.json
fi
local asar_bin_path="$work_dir/node_modules/.bin/asar"
if [[ ! -f $asar_bin_path ]]; then
echo "Installing @electron/asar locally into $work_dir..."
if ! npm install --no-save @electron/asar; then
echo 'Failed to install @electron/asar locally.' >&2
cd "$project_root" || exit 1
exit 1
fi
else
echo 'Local asar binary already present.'
fi
if [[ -f $asar_bin_path ]]; then
asar_exec="$(realpath "$asar_bin_path")"
echo "Using asar executable: $asar_exec"
else
echo "Failed to find asar binary at '$asar_bin_path' after installation attempt." >&2
cd "$project_root" || exit 1
exit 1
fi
cd "$project_root" || exit 1
section_footer 'Asar Tooling'
}
+234
View File
@@ -0,0 +1,234 @@
#===============================================================================
# Host detection and argument parsing: architecture, distro, requirements,
# CLI flag processing.
#
# Sourced by: build.sh
# Sourced globals: (none read on entry)
# Modifies globals:
# architecture, distro_family, original_user, original_home, project_root,
# work_dir, build_format, cleanup_action, perform_cleanup, test_flags_mode,
# local_deb_path, release_tag, source_dir
#===============================================================================
detect_architecture() {
section_header 'Architecture Detection'
echo 'Detecting system architecture...'
local raw_arch
raw_arch=$(uname -m) || {
echo 'Failed to detect architecture' >&2
exit 1
}
echo "Detected machine architecture: $raw_arch"
# Download URLs and SHA256 pins for the official .deb live in
# scripts/setup/official-deb.sh; only the arch mapping happens here.
case "$raw_arch" in
x86_64)
architecture='amd64'
echo 'Configured for amd64 (x86_64) build.'
;;
aarch64)
architecture='arm64'
echo 'Configured for arm64 (aarch64) build.'
;;
*)
echo "Unsupported architecture: $raw_arch. This script supports x86_64 (amd64) and aarch64 (arm64)." >&2
exit 1
;;
esac
echo "Target Architecture: $architecture"
section_footer 'Architecture Detection'
}
detect_distro() {
section_header 'Distribution Detection'
echo 'Detecting Linux distribution family...'
if [[ -f /etc/debian_version ]]; then
distro_family='debian'
echo "Detected Debian-based distribution"
echo " Debian version: $(cat /etc/debian_version)"
elif [[ -f /etc/fedora-release ]]; then
distro_family='rpm'
echo "Detected Fedora"
echo " $(cat /etc/fedora-release)"
elif [[ -f /etc/redhat-release ]]; then
distro_family='rpm'
echo "Detected Red Hat-based distribution"
echo " $(cat /etc/redhat-release)"
elif [[ -f /etc/NIXOS ]]; then
distro_family='nix'
echo "Detected NixOS"
else
distro_family='unknown'
echo "Warning: Could not detect distribution family"
echo " AppImage build will still work, but native packages (deb/rpm) may not"
fi
echo "Distribution: $(grep 'PRETTY_NAME' /etc/os-release 2>/dev/null | cut -d'"' -f2 || echo 'Unknown')"
echo "Distribution family: $distro_family"
section_footer 'Distribution Detection'
}
check_system_requirements() {
# Allow running as root in CI/container environments
if (( EUID == 0 )); then
if [[ -n ${CI:-} || -n ${GITHUB_ACTIONS:-} || -f /.dockerenv ]]; then
echo 'Running as root in CI/container environment (allowed)'
else
echo 'This script should not be run using sudo or as the root user.' >&2
echo 'It will use sudo when needed for specific actions (may prompt for password).' >&2
echo 'Please run as a normal user.' >&2
exit 1
fi
fi
original_user=$(whoami)
original_home=$(getent passwd "$original_user" | cut -d: -f6)
if [[ -z $original_home ]]; then
echo "Could not determine home directory for user $original_user." >&2
exit 1
fi
echo "Running as user: $original_user (Home: $original_home)"
# Check for NVM and source it if found
if [[ -d $original_home/.nvm ]]; then
echo "Found NVM installation for user $original_user, checking for Node.js 20+..."
export NVM_DIR="$original_home/.nvm"
if [[ -s $NVM_DIR/nvm.sh ]]; then
# shellcheck disable=SC1091
\. "$NVM_DIR/nvm.sh"
local node_bin_path=''
node_bin_path=$(nvm which current | xargs dirname 2>/dev/null || \
find "$NVM_DIR/versions/node" -maxdepth 2 -type d -name 'bin' | sort -V | tail -n 1)
if [[ -n $node_bin_path && -d $node_bin_path ]]; then
echo "Adding NVM Node bin path to PATH: $node_bin_path"
export PATH="$node_bin_path:$PATH"
else
echo 'Warning: Could not determine NVM Node bin path.'
fi
else
echo 'Warning: nvm.sh script not found or not sourceable.'
fi
fi
echo 'System Information:'
echo "Distribution: $(grep 'PRETTY_NAME' /etc/os-release 2>/dev/null | cut -d'"' -f2 || echo 'Unknown')"
echo "Distribution family: $distro_family"
# No architecture line here: this runs before parse_arguments(), so
# $architecture is still the uname -m auto-detection, and printing
# it here would read as authoritative right next to the distro
# info. The auto-detected value is already logged in
# detect_architecture() above; the value after a possible --arch
# override is logged at the end of Argument Parsing below, which is
# the only trustworthy target-arch line in this output.
}
parse_arguments() {
section_header 'Argument Parsing'
project_root="$(pwd)"
work_dir="$project_root/build"
# app_staging_dir is derived in build.sh after the official .deb is
# extracted (it points into the extracted tree).
# Set default build format based on detected distro
case "$distro_family" in
debian) build_format='deb' ;;
rpm) build_format='rpm' ;;
nix) build_format='nix' ;;
*) build_format='appimage' ;;
esac
while (( $# > 0 )); do
case "$1" in
-a|--arch|-b|--build|-c|--clean|-d|--deb|-r|--release-tag|-s|--source-dir)
if [[ -z ${2:-} || $2 == -* ]]; then
echo "Error: Argument for $1 is missing" >&2
exit 1
fi
case "$1" in
-a|--arch) architecture="$2" ;;
-b|--build) build_format="$2" ;;
-c|--clean) cleanup_action="$2" ;;
-d|--deb) local_deb_path="$2" ;;
-r|--release-tag) release_tag="$2" ;;
-s|--source-dir) source_dir="$2" ;;
esac
shift 2
;;
--test-flags)
test_flags_mode=true
shift
;;
-h|--help)
echo "Usage: $0 [--build deb|rpm|appimage|nix] [--clean yes|no] [--deb /path/to/claude-desktop.deb] [--source-dir /path] [--release-tag TAG] [--arch amd64|arm64] [--test-flags]"
echo ' --build: Specify the build format (deb, rpm, appimage, or nix).'
echo " Default: auto-detected based on distro (current: $build_format)"
echo ' --clean: Specify whether to clean intermediate build files (yes or no). Default: yes'
echo ' --deb: Use a local official Claude Desktop .deb instead of downloading'
echo ' --source-dir: Path to repo root for scripts/ and assets (default: project root)'
echo ' --release-tag: Release tag (e.g., v3.0.0+claude1.17377.2) to append wrapper version to package'
echo ' --arch: Override detected target architecture (amd64 or arm64),'
echo ' for cross-building (default: auto-detected via uname -m,'
echo " current: $architecture)"
echo ' --test-flags: Parse flags, print results, and exit without building.'
exit 0
;;
*)
echo "Unknown option: $1" >&2
echo 'Use -h or --help for usage information.' >&2
exit 1
;;
esac
done
# source_dir is where scripts/assets live (default: project_root)
source_dir="${source_dir:-$project_root}"
# Validate arguments
build_format="${build_format,,}"
cleanup_action="${cleanup_action,,}"
architecture="${architecture,,}"
if [[ ! -d $source_dir ]]; then
echo "Error: --source-dir path does not exist: $source_dir" >&2
exit 1
fi
if [[ $build_format != 'deb' && $build_format != 'rpm' && $build_format != 'appimage' && $build_format != 'nix' ]]; then
echo "Invalid build format specified: '$build_format'. Must be 'deb', 'rpm', 'appimage', or 'nix'." >&2
exit 1
fi
# --arch overrides the uname -m detection in detect_architecture(),
# enabling cross-building (e.g. an amd64 CI runner producing an
# arm64 package, since repackaging the official .deb is
# arch-independent).
if [[ $architecture != 'amd64' && $architecture != 'arm64' ]]; then
echo "Invalid architecture specified: '$architecture'. Must be 'amd64' or 'arm64'." >&2
exit 1
fi
# Warn if building native package for wrong distro
if [[ $build_format == 'deb' && $distro_family != 'debian' ]]; then
echo "Warning: Building .deb package on non-Debian system ($distro_family). This may fail." >&2
elif [[ $build_format == 'rpm' && $distro_family != 'rpm' ]]; then
echo "Warning: Building .rpm package on non-RPM system ($distro_family). This may fail." >&2
fi
if [[ $cleanup_action != 'yes' && $cleanup_action != 'no' ]]; then
echo "Invalid cleanup option specified: '$cleanup_action'. Must be 'yes' or 'no'." >&2
exit 1
fi
echo "Selected build format: $build_format"
echo "Cleanup intermediate files: $cleanup_action"
echo "Target Architecture: $architecture"
[[ $cleanup_action == 'yes' ]] && perform_cleanup=true
section_footer 'Argument Parsing'
}
+227
View File
@@ -0,0 +1,227 @@
#===============================================================================
# Official Claude Desktop .deb acquisition: resolve, download, verify,
# extract. Replaced the Windows-installer download path in the v3.0.0
# official-Linux rebase.
#
# The official APT repository is plain HTTPS (no bot challenge), so both
# resolution and download are curl/wget-able. Extraction deliberately
# avoids dpkg-deb so rpm-family and Arch hosts can build: ar + tar handle
# every Debian archive member.
#
# Sourced by: build.sh, tools/patch-necessity-audit.sh
# Sourced globals:
# work_dir, architecture, local_deb_path (optional), release_tag (optional)
# Modifies globals:
# claude_extract_dir, version, official_deb_url, official_deb_sha256,
# official_deb_filename, official_deb_depends, official_deb_recommends,
# resolved_official_version, resolved_official_filename,
# resolved_official_sha256, resolved_official_size
#===============================================================================
OFFICIAL_APT_BASE='https://downloads.claude.ai/claude-desktop/apt/stable'
# Pinned artifact per architecture, seeded from the Packages indexes on
# 2026-07-04. Bumped by check-claude-version after the rebase lands.
OFFICIAL_DEB_VERSION='1.19367.0'
OFFICIAL_DEB_POOL_AMD64='pool/main/c/claude-desktop/claude-desktop_1.19367.0_amd64.deb'
OFFICIAL_DEB_SHA256_AMD64='76f570730c1185924e2423c5f88a27bec17c1e7adb055ecd09401a0e93a9299b'
OFFICIAL_DEB_POOL_ARM64='pool/main/c/claude-desktop/claude-desktop_1.19367.0_arm64.deb'
OFFICIAL_DEB_SHA256_ARM64='98ab0e9e2cf3ecae073a38d81ecabd2b85c7e7fe319cf72df575642915045a83'
# Set official_deb_url/sha256/filename from the pinned block for the
# current (or given) architecture.
official_deb_pin() {
local arch="${1:-$architecture}"
local pool_path
case "$arch" in
amd64)
pool_path="$OFFICIAL_DEB_POOL_AMD64"
official_deb_sha256="$OFFICIAL_DEB_SHA256_AMD64"
;;
arm64)
pool_path="$OFFICIAL_DEB_POOL_ARM64"
official_deb_sha256="$OFFICIAL_DEB_SHA256_ARM64"
;;
*)
echo "Unsupported architecture for official .deb: $arch" >&2
return 1
;;
esac
official_deb_url="$OFFICIAL_APT_BASE/$pool_path"
official_deb_filename="${pool_path##*/}"
}
# Query the official Packages index for the newest claude-desktop entry.
# Used by CI (check-claude-version) and the doctor drift check, never by
# the pinned build itself. Sets resolved_official_{version,filename,
# sha256,size}.
#
# sort -V is sufficient for the upstream scheme (dotted numerics, no
# epochs or tildes observed); revisit if upstream ever ships either.
resolve_official_deb() {
local arch="${1:-$architecture}"
local index_url="$OFFICIAL_APT_BASE/dists/stable/main/binary-${arch}/Packages"
local newest
newest=$(curl -fsS --max-time 30 "$index_url" | awk -v RS='' '
/^Package: claude-desktop\n/ || $1 == "Package:" {
v = f = s = z = ""
n = split($0, lines, "\n")
for (i = 1; i <= n; i++) {
if (lines[i] ~ /^Version: /) v = substr(lines[i], 10)
else if (lines[i] ~ /^Filename: /) f = substr(lines[i], 11)
else if (lines[i] ~ /^SHA256: /) s = substr(lines[i], 9)
else if (lines[i] ~ /^Size: /) z = substr(lines[i], 7)
}
if (v != "" && f != "" && s != "")
printf "%s\t%s\t%s\t%s\n", v, f, s, z
}' | sort -V -k1,1 | tail -1)
if [[ -z $newest ]]; then
echo "Could not resolve claude-desktop from $index_url" >&2
return 1
fi
IFS=$'\t' read -r resolved_official_version resolved_official_filename \
resolved_official_sha256 resolved_official_size <<< "$newest"
echo "Newest official $arch package: $resolved_official_version" \
"($resolved_official_filename)"
}
# Extract one member family (data.tar.* or control.tar.*) of a Debian
# archive into a directory, without dpkg. Handles zst/xz/gz/uncompressed.
_extract_deb_member() {
local deb_path="$1"
local member_prefix="$2"
local dest_dir="$3"
local member tar_flag
member=$(ar t "$deb_path" | grep "^${member_prefix}\.tar" | head -1)
if [[ -z $member ]]; then
echo "No ${member_prefix}.tar member in $deb_path" >&2
return 1
fi
case "$member" in
*.tar.zst) tar_flag='--zstd' ;;
*.tar.xz) tar_flag='-J' ;;
*.tar.gz) tar_flag='-z' ;;
*.tar) tar_flag='' ;;
*)
echo "Unsupported compression on $member" >&2
return 1
;;
esac
mkdir -p "$dest_dir" || return 1
# tar_flag is intentionally unquoted: empty for plain .tar, one
# decompression flag otherwise.
# shellcheck disable=SC2086
ar p "$deb_path" "$member" | tar $tar_flag -x -C "$dest_dir"
}
# Read a single field from an extracted Debian control file. Multi-line
# fields (continuation lines) are not needed for the fields we read.
_control_field() {
local control_path="$1"
local field="$2"
LC_ALL=C grep -oP "^${field}: \K.*" "$control_path"
}
# Download (or copy) the pinned official .deb, verify it, and extract it
# into work_dir/claude-extract. The app tree lands at
# claude-extract/usr/lib/claude-desktop; package metadata at
# claude-extract/DEBIAN-meta/control.
fetch_official_deb() {
section_header 'Download the official Claude Desktop .deb'
official_deb_pin || exit 1
local claude_deb_path="$work_dir/$official_deb_filename"
if [[ -n ${local_deb_path:-} ]]; then
echo "Using local official .deb: $local_deb_path"
if [[ ! -f $local_deb_path ]]; then
echo "Local .deb file not found: $local_deb_path" >&2
exit 1
fi
cp "$local_deb_path" "$claude_deb_path" || exit 1
echo 'Local .deb copied to build directory'
else
echo "Downloading official Claude Desktop $OFFICIAL_DEB_VERSION" \
"for $architecture..."
if ! wget -q --show-progress -O "$claude_deb_path" \
"$official_deb_url"; then
echo "Failed to download $official_deb_url" >&2
exit 1
fi
echo "Download complete: $official_deb_filename"
if ! verify_sha256 "$claude_deb_path" "$official_deb_sha256" \
'official Claude Desktop .deb'; then
exit 1
fi
fi
echo 'Extracting the official .deb...'
claude_extract_dir="$work_dir/claude-extract"
mkdir -p "$claude_extract_dir" || exit 1
_extract_deb_member "$claude_deb_path" data "$claude_extract_dir" || {
echo 'Failed to extract data archive from .deb' >&2
exit 1
}
_extract_deb_member "$claude_deb_path" control \
"$claude_extract_dir/DEBIAN-meta" || {
echo 'Failed to extract control archive from .deb' >&2
exit 1
}
local control_path="$claude_extract_dir/DEBIAN-meta/control"
version=$(_control_field "$control_path" Version)
if [[ -z $version ]]; then
echo "Could not read Version from $control_path" >&2
exit 1
fi
echo "Detected Claude version: $version"
if [[ -z ${local_deb_path:-} && $version != "$OFFICIAL_DEB_VERSION" ]]; then
echo "Warning: control Version ($version) differs from pinned" \
"OFFICIAL_DEB_VERSION ($OFFICIAL_DEB_VERSION)" >&2
fi
# The dependency contract differs per arch (e.g. qemu-system-x86 vs
# qemu-system-arm); packaging re-emits it verbatim rather than
# hardcoding a copy.
official_deb_depends=$(_control_field "$control_path" Depends)
official_deb_recommends=$(_control_field "$control_path" Recommends)
if [[ ! -d "$claude_extract_dir/usr/lib/claude-desktop" ]]; then
echo 'Extracted tree missing usr/lib/claude-desktop —' \
'upstream layout changed?' >&2
exit 1
fi
# Extract wrapper version from release tag if provided
# (e.g., v3.0.0+claude1.17377.2 -> 3.0.0; v3.0.0-rc1+claude1.17377.2
# -> 3.0.0-rc1). Deb versions may contain hyphens; rpm.sh sanitizes
# the RC suffix (hyphens -> dots) for the RPM Release field.
if [[ -n ${release_tag:-} ]]; then
local wrapper_version
wrapper_version=$(echo "$release_tag" | \
LC_ALL=C grep -oP \
'^v\K[0-9]+\.[0-9]+\.[0-9]+(?:-rc[0-9]+)?(?=\+claude)')
if [[ -n $wrapper_version ]]; then
version="${version}-${wrapper_version}"
echo "Package version with wrapper suffix: $version"
else
echo 'Warning: Could not extract wrapper version from' \
"release tag: $release_tag" >&2
fi
fi
section_footer 'Download the official Claude Desktop .deb'
}