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
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:
@@ -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}"
|
||||
}
|
||||
@@ -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 1–5 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`.
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
Executable
+379
@@ -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
|
||||
Executable
+437
@@ -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>
|
||||
Executable
+376
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 '##############################################################'
|
||||
}
|
||||
@@ -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 '##############################################################'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 '##############################################################'
|
||||
}
|
||||
@@ -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 '##############################################################'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user