3e076d4dd9
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
936 lines
34 KiB
Bash
936 lines
34 KiB
Bash
#!/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"
|