mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
Adds _doctor_check_recent_crashes, called from run_doctor before the log-file section. When systemd-coredump shows ≥3 Electron crashes in the last 7 days, surfaces a [WARN] with two workarounds (Settings toggle, CLAUDE_DISABLE_GPU=1) and a link to the tracking issue. Filters by the caller-supplied electron_path when entries match, falls back to all-electron entries with a footnote when they don't (covers AppImage's transient mount paths and other Electron apps installed side-by-side). Silent when coredumpctl isn't on PATH (non-systemd hosts), when there are zero matches, or when the count is below threshold. Co-Authored-By: Claude <claude@anthropic.com>
1020 lines
32 KiB
Bash
1020 lines
32 KiB
Bash
#===============================================================================
|
|
# Doctor Diagnostics
|
|
#
|
|
# Sourced by: scripts/launcher-common.sh (which is in turn sourced by the
|
|
# per-package launcher scripts — deb, rpm, AppImage, Nix).
|
|
#
|
|
# Provides: run_doctor (the `claude-desktop --doctor` entry point) plus its
|
|
# internal helpers. Self-contained — no dependencies on launcher-common.sh
|
|
# state or functions.
|
|
#
|
|
# To add a new check: define an internal function `_check_<name>`, call it
|
|
# from run_doctor in the appropriate section, use _pass / _fail / _warn /
|
|
# _info to print results. _fail increments _doctor_failures (local to
|
|
# run_doctor) which becomes the exit status.
|
|
#===============================================================================
|
|
|
|
# Color helpers (disabled when stdout is not a terminal)
|
|
_doctor_colors() {
|
|
if [[ -t 1 ]]; then
|
|
_green='\033[0;32m'
|
|
_red='\033[0;31m'
|
|
_yellow='\033[0;33m'
|
|
_bold='\033[1m'
|
|
_reset='\033[0m'
|
|
else
|
|
_green='' _red='' _yellow='' _bold='' _reset=''
|
|
fi
|
|
}
|
|
|
|
# Return the distro ID from /etc/os-release
|
|
_cowork_distro_id() {
|
|
local id='unknown'
|
|
if [[ -f /etc/os-release ]]; then
|
|
local line
|
|
while IFS= read -r line; do
|
|
if [[ $line == ID=* ]]; then
|
|
id="${line#ID=}"
|
|
id="${id//\"/}"
|
|
break
|
|
fi
|
|
done < /etc/os-release
|
|
fi
|
|
printf '%s' "$id"
|
|
}
|
|
|
|
# Return a distro-specific install command for a cowork tool
|
|
# Usage: _cowork_pkg_hint <distro_id> <tool_name>
|
|
_cowork_pkg_hint() {
|
|
local distro="$1"
|
|
local tool="$2"
|
|
local pkg_cmd
|
|
|
|
# Determine package manager command
|
|
case "$distro" in
|
|
debian|ubuntu) pkg_cmd='sudo apt install' ;;
|
|
fedora) pkg_cmd='sudo dnf install' ;;
|
|
arch) pkg_cmd='sudo pacman -S' ;;
|
|
*)
|
|
printf '%s' "Install $tool using your package manager"
|
|
return
|
|
;;
|
|
esac
|
|
|
|
# Map tool name to distro-specific package(s)
|
|
local pkg
|
|
case "$tool" in
|
|
qemu)
|
|
case "$distro" in
|
|
debian|ubuntu) pkg='qemu-system-x86 qemu-utils' ;;
|
|
fedora) pkg='qemu-kvm qemu-img' ;;
|
|
arch) pkg='qemu-full' ;;
|
|
esac
|
|
;;
|
|
ibus-gtk3)
|
|
# Arch ships the GTK3 immodule as part of the main ibus
|
|
# package; Debian/Ubuntu and Fedora split it out.
|
|
case "$distro" in
|
|
arch) pkg='ibus' ;;
|
|
*) pkg='ibus-gtk3' ;;
|
|
esac
|
|
;;
|
|
*) pkg="$tool" ;;
|
|
esac
|
|
|
|
printf '%s' "$pkg_cmd $pkg"
|
|
}
|
|
|
|
# Return 0 if the named package is installed, 1 otherwise. Returns 2
|
|
# (treated as "unknown") when no recognized package manager is
|
|
# available — callers should not warn in that case to avoid false
|
|
# positives on unsupported distros.
|
|
_pkg_installed() {
|
|
local distro="$1"
|
|
local pkg="$2"
|
|
case "$distro" in
|
|
debian|ubuntu)
|
|
command -v dpkg-query &>/dev/null || return 2
|
|
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
|
|
| grep -q 'install ok installed'
|
|
;;
|
|
fedora)
|
|
command -v rpm &>/dev/null || return 2
|
|
rpm -q "$pkg" &>/dev/null
|
|
;;
|
|
arch)
|
|
command -v pacman &>/dev/null || return 2
|
|
pacman -Q "$pkg" &>/dev/null
|
|
;;
|
|
*) return 2 ;;
|
|
esac
|
|
}
|
|
|
|
# Diagnose IBus / GTK input-method misconfigurations that break
|
|
# keyboard input in the chat (#550). Surfaces:
|
|
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
|
|
# - XWayland-with-IBus routing note: on a Wayland session Electron
|
|
# defaults to XWayland (preserves global hotkeys), which forces
|
|
# the IBus path through XIM — a known weak link for some IMEs.
|
|
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
|
|
# - GTK immodules cache stale: active module not listed by
|
|
# gtk-query-immodules-3.0 (--update-cache fixes it)
|
|
#
|
|
# Usage: _doctor_check_im_modules <distro_id>
|
|
_doctor_check_im_modules() {
|
|
local distro="$1"
|
|
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
|
|
|
|
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
|
|
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
|
|
"(overrides GTK_IM_MODULE for Electron)"
|
|
fi
|
|
|
|
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
|
|
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
|
|
_info \
|
|
'IME note: Wayland session, Electron via XWayland —' \
|
|
'IBus path goes through XIM (lossy for some IMEs).'
|
|
_info \
|
|
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
|
|
'(loses global hotkeys).'
|
|
fi
|
|
|
|
# Nothing further to check without an active IM module.
|
|
[[ -n $active_im ]] || return 0
|
|
|
|
# ibus-gtk3 package check — only when the active module is ibus.
|
|
# rc=1 means definitely missing (warn); rc=2 means unsupported
|
|
# distro / no package manager (skip silently to avoid false
|
|
# negatives). On warn, return early — `apt install` refreshes
|
|
# the immodules cache, so the cache check below would be noise.
|
|
if [[ $active_im == 'ibus' ]]; then
|
|
_pkg_installed "$distro" ibus-gtk3
|
|
case $? in
|
|
1)
|
|
_warn \
|
|
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
|
|
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
|
|
return 0
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
|
|
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
|
|
# means GTK 3 isn't in use — skip silently rather than warn.
|
|
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
|
|
|
|
if ! gtk-query-immodules-3.0 2>/dev/null \
|
|
| grep -q "\"$active_im\""; then
|
|
_warn \
|
|
"GTK immodules: '$active_im' not listed by" \
|
|
"gtk-query-immodules-3.0 (cache may be stale)"
|
|
_info \
|
|
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
|
|
fi
|
|
}
|
|
|
|
# Read the version string from the version file beside an Electron binary.
|
|
# Prints the raw version string, or nothing if unavailable.
|
|
_electron_version() {
|
|
local version_file
|
|
version_file="$(dirname "$1")/version"
|
|
[[ -r $version_file ]] && printf '%s' "$(< "$version_file")"
|
|
}
|
|
|
|
_pass() { echo -e "${_green}[PASS]${_reset} $*"; }
|
|
_fail() {
|
|
echo -e "${_red}[FAIL]${_reset} $*"
|
|
_doctor_failures=$((_doctor_failures + 1))
|
|
}
|
|
_warn() { echo -e "${_yellow}[WARN]${_reset} $*"; }
|
|
_info() { echo -e " $*"; }
|
|
|
|
# Warn about an unrecognized COWORK_VM_BACKEND value. The daemon
|
|
# (cowork-vm-service.js) ignores invalid values and falls through to
|
|
# auto-detect — see #442 for the daemon-side wart. Called from both
|
|
# COWORK_VM_BACKEND case statements below so the warning fires once
|
|
# at the severity-gating site and once at the user-facing summary.
|
|
_warn_unknown_backend() {
|
|
_warn "Unknown COWORK_VM_BACKEND: '${COWORK_VM_BACKEND}'"
|
|
_info 'Valid values: kvm, bwrap, host'
|
|
}
|
|
|
|
# Locate the virtiofsd binary. Distros install it at different
|
|
# off-PATH locations:
|
|
# - Debian/Ubuntu: /usr/libexec/virtiofsd (qemu-system-common)
|
|
# - Fedora/RHEL: /usr/libexec/virtiofsd
|
|
# - Older Debian: /usr/lib/qemu/virtiofsd
|
|
# - Arch/Manjaro: /usr/lib/virtiofsd
|
|
#
|
|
# `command -v virtiofsd` alone produces a false negative on any of
|
|
# the above. Search PATH first, then the well-known fallback paths.
|
|
#
|
|
# Prints the discovered path on stdout; returns 0 on hit, 1 on miss.
|
|
# Fallback paths are overridable via _COWORK_VFSD_PATHS
|
|
# (colon-separated) so tests can point at a stub directory. The
|
|
# namespaced prefix signals "internal test hook — not a user knob".
|
|
# Shared with the VM daemon (cowork-vm-service.js) so doctor's
|
|
# diagnosis and the daemon's actual probe stay in lock-step.
|
|
_find_virtiofsd() {
|
|
local bin
|
|
bin=$(command -v virtiofsd 2>/dev/null)
|
|
if [[ -n $bin ]]; then
|
|
printf '%s' "$bin"
|
|
return 0
|
|
fi
|
|
|
|
local fallback_paths="${_COWORK_VFSD_PATHS:-}"
|
|
if [[ -z $fallback_paths ]]; then
|
|
fallback_paths='/usr/libexec/virtiofsd'
|
|
fallback_paths+=':/usr/lib/qemu/virtiofsd'
|
|
fallback_paths+=':/usr/lib/virtiofsd'
|
|
fi
|
|
|
|
local fallback
|
|
local IFS=:
|
|
for fallback in $fallback_paths; do
|
|
if [[ -x $fallback ]]; then
|
|
printf '%s' "$fallback"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Check custom bwrap mount configuration and report findings
|
|
_doctor_check_bwrap_mounts() {
|
|
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
|
local config_file="$config_dir/claude_desktop_linux_config.json"
|
|
|
|
[[ -f $config_file ]] || return 0
|
|
|
|
local parser=''
|
|
if command -v python3 &>/dev/null; then
|
|
parser='python3'
|
|
elif command -v node &>/dev/null; then
|
|
parser='node'
|
|
else
|
|
return 0
|
|
fi
|
|
|
|
local mounts_json=''
|
|
if [[ $parser == 'python3' ]]; then
|
|
mounts_json=$(python3 - "$config_file" 2>/dev/null <<'PYEOF'
|
|
import json, sys
|
|
try:
|
|
with open(sys.argv[1]) as f:
|
|
cfg = json.load(f)
|
|
mounts = cfg.get('preferences', {}).get('coworkBwrapMounts', {})
|
|
if mounts:
|
|
print(json.dumps(mounts))
|
|
except Exception:
|
|
pass
|
|
PYEOF
|
|
)
|
|
else
|
|
mounts_json=$(node - "$config_file" 2>/dev/null <<'JSEOF'
|
|
try {
|
|
const fs = require('fs');
|
|
const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
|
const m = (cfg.preferences || {}).coworkBwrapMounts || {};
|
|
if (Object.keys(m).length > 0)
|
|
process.stdout.write(JSON.stringify(m));
|
|
} catch (_) {}
|
|
JSEOF
|
|
)
|
|
fi
|
|
|
|
if [[ -z $mounts_json ]]; then
|
|
_info 'Bwrap mounts: default (no custom configuration)'
|
|
return 0
|
|
fi
|
|
|
|
_info 'Bwrap custom mount configuration detected:'
|
|
|
|
local parsed_output=''
|
|
if [[ $parser == 'python3' ]]; then
|
|
parsed_output=$(python3 - "$mounts_json" 2>/dev/null <<'PYEOF'
|
|
import json, sys
|
|
def fmt(p):
|
|
if isinstance(p, str):
|
|
return p
|
|
if isinstance(p, dict) and isinstance(p.get('src'), str) \
|
|
and isinstance(p.get('dst'), str):
|
|
return p['src'] + ' -> ' + p['dst']
|
|
return None
|
|
m = json.loads(sys.argv[1])
|
|
for p in m.get('additionalROBinds', []):
|
|
s = fmt(p)
|
|
if s is not None:
|
|
print(s)
|
|
print('---')
|
|
for p in m.get('additionalBinds', []):
|
|
s = fmt(p)
|
|
if s is not None:
|
|
print(s)
|
|
print('---')
|
|
for p in m.get('disabledDefaultBinds', []):
|
|
if isinstance(p, str):
|
|
print(p)
|
|
PYEOF
|
|
)
|
|
else
|
|
parsed_output=$(node - "$mounts_json" 2>/dev/null <<'JSEOF'
|
|
function fmt(p) {
|
|
if (typeof p === 'string') return p;
|
|
if (p && typeof p === 'object'
|
|
&& typeof p.src === 'string' && typeof p.dst === 'string') {
|
|
return p.src + ' -> ' + p.dst;
|
|
}
|
|
return null;
|
|
}
|
|
const m = JSON.parse(process.argv[1]);
|
|
(m.additionalROBinds || []).forEach(p => {
|
|
const s = fmt(p);
|
|
if (s !== null) console.log(s);
|
|
});
|
|
console.log('---');
|
|
(m.additionalBinds || []).forEach(p => {
|
|
const s = fmt(p);
|
|
if (s !== null) console.log(s);
|
|
});
|
|
console.log('---');
|
|
(m.disabledDefaultBinds || []).forEach(p => {
|
|
if (typeof p === 'string') console.log(p);
|
|
});
|
|
JSEOF
|
|
)
|
|
fi
|
|
|
|
local ro_binds='' rw_binds='' disabled_binds=''
|
|
local section=0
|
|
while IFS= read -r line; do
|
|
if [[ $line == '---' ]]; then
|
|
((section++))
|
|
continue
|
|
fi
|
|
case $section in
|
|
0) ro_binds+="${line}"$'\n' ;;
|
|
1) rw_binds+="${line}"$'\n' ;;
|
|
2) disabled_binds+="${line}"$'\n' ;;
|
|
esac
|
|
done <<< "$parsed_output"
|
|
ro_binds=${ro_binds%$'\n'}
|
|
rw_binds=${rw_binds%$'\n'}
|
|
disabled_binds=${disabled_binds%$'\n'}
|
|
|
|
if [[ -n $ro_binds ]]; then
|
|
_info ' Read-only mounts:'
|
|
while IFS= read -r bind_path; do
|
|
_info " - $bind_path"
|
|
done <<< "$ro_binds"
|
|
fi
|
|
|
|
if [[ -n $rw_binds ]]; then
|
|
_info ' Read-write mounts:'
|
|
while IFS= read -r bind_path; do
|
|
_info " - $bind_path"
|
|
done <<< "$rw_binds"
|
|
fi
|
|
|
|
# Warn when an additional mount's dst lands on a default RO mount.
|
|
# bwrap honors the later mount, so this silently replaces a system
|
|
# path inside the sandbox. Only the {src, dst} form can trigger this
|
|
# (string form mounts src=dst, and additionalBinds requires src under
|
|
# $HOME, which never overlaps the default RO set).
|
|
local shadow_input=''
|
|
[[ -n $ro_binds ]] && shadow_input+="${ro_binds}"$'\n'
|
|
[[ -n $rw_binds ]] && shadow_input+="${rw_binds}"$'\n'
|
|
shadow_input=${shadow_input%$'\n'}
|
|
local shadow_line shadow_dst
|
|
if [[ -n $shadow_input ]]; then
|
|
while IFS= read -r shadow_line; do
|
|
[[ $shadow_line == *' -> '* ]] || continue
|
|
shadow_dst=${shadow_line##* -> }
|
|
# Long alternation pattern (STYLEGUIDE 80-col exception)
|
|
case $shadow_dst in
|
|
/usr|/usr/*|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib64|/lib64/*)
|
|
_warn \
|
|
"Mount dst '${shadow_dst}' shadows a default sandbox mount" \
|
|
'(may break system tools inside the sandbox)'
|
|
;;
|
|
esac
|
|
done <<< "$shadow_input"
|
|
fi
|
|
|
|
local critical_warned=false
|
|
if [[ -n $disabled_binds ]]; then
|
|
while IFS= read -r bind_path; do
|
|
case "$bind_path" in
|
|
/usr|/etc)
|
|
_warn \
|
|
"Disabled default mount: $bind_path" \
|
|
'(may break system tools!)'
|
|
critical_warned=true
|
|
;;
|
|
*)
|
|
_info " Disabled default mount: $bind_path"
|
|
;;
|
|
esac
|
|
done <<< "$disabled_binds"
|
|
if [[ $critical_warned == true ]]; then
|
|
_info \
|
|
' Disabling /usr or /etc may cause commands' \
|
|
'to fail inside the sandbox.'
|
|
_info \
|
|
' Restart the daemon after config changes:' \
|
|
'pkill -f cowork-vm-service'
|
|
fi
|
|
fi
|
|
|
|
if [[ $critical_warned != true ]]; then
|
|
_info \
|
|
' Note: Restart daemon for config changes:' \
|
|
'pkill -f cowork-vm-service'
|
|
fi
|
|
}
|
|
|
|
# Surface a warning when systemd-coredump shows N+ recent Electron
|
|
# crashes. The most common cause on Linux is the GPU process FATAL
|
|
# exhaustion tracked in #583 — workaround for affected users is the
|
|
# upstream Settings → disable hardware acceleration toggle, or
|
|
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
|
|
#
|
|
# Arguments: $1 = electron path (e.g.,
|
|
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
|
|
# Used to filter results to claude-desktop's electron when possible;
|
|
# falls back to all-electron crashes when the path doesn't match
|
|
# (e.g., AppImage mount paths are transient).
|
|
_doctor_check_recent_crashes() {
|
|
local electron_path="${1:-}"
|
|
command -v coredumpctl &>/dev/null || return 0
|
|
|
|
# `coredumpctl list electron` filters by COMM=electron. If the
|
|
# exact electron_path matches any entry's EXE column, prefer that
|
|
# tighter count; otherwise fall back to all-electron entries.
|
|
local listing total_count path_count
|
|
listing=$(coredumpctl list electron \
|
|
--since='7 days ago' --no-pager 2>/dev/null) || return 0
|
|
[[ -n $listing ]] || return 0
|
|
|
|
# Drop the header line; count remaining entries.
|
|
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
|
|
((total_count == 0)) && return 0
|
|
|
|
if [[ -n $electron_path ]]; then
|
|
path_count=$(awk -v p="$electron_path" \
|
|
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
|
|
else
|
|
path_count=0
|
|
fi
|
|
|
|
# Use the path-matched count when available; else the unfiltered
|
|
# count with a footnote so the user knows it may include other
|
|
# Electron apps (Slack, VSCode, etc.).
|
|
local count footnote=''
|
|
if ((path_count > 0)); then
|
|
count=$path_count
|
|
else
|
|
count=$total_count
|
|
footnote=' (some entries may be from other Electron apps)'
|
|
fi
|
|
|
|
if ((count >= 3)); then
|
|
_warn "Recent Electron crashes: $count in last 7 days$footnote"
|
|
_info \
|
|
'Most common cause: Chromium GPU process FATAL (#583).' \
|
|
'Try one of:'
|
|
_info ' Settings → toggle hardware acceleration off → restart'
|
|
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
|
|
_info \
|
|
'Tracking:' \
|
|
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
|
|
elif ((count > 0)); then
|
|
_info "Recent Electron crashes: $count in last 7 days$footnote"
|
|
fi
|
|
}
|
|
|
|
# Run all diagnostic checks and print results
|
|
# Arguments: $1 = electron path (optional, for package-specific checks)
|
|
run_doctor() {
|
|
local electron_path="${1:-}"
|
|
local _doctor_failures=0
|
|
_doctor_colors
|
|
|
|
# Distro ID is shared between the IM-module check (#550) and the
|
|
# Cowork Mode section further down. Resolve once.
|
|
local _distro_id
|
|
_distro_id=$(_cowork_distro_id)
|
|
|
|
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
|
|
echo '================================'
|
|
echo
|
|
|
|
# -- Installed package version --
|
|
if command -v dpkg-query &>/dev/null; then
|
|
local pkg_version
|
|
pkg_version=$(dpkg-query -W -f='${Version}' \
|
|
claude-desktop 2>/dev/null) || true
|
|
if [[ -n $pkg_version ]]; then
|
|
_pass "Installed version: $pkg_version"
|
|
else
|
|
_warn 'claude-desktop not found via dpkg (AppImage?)'
|
|
fi
|
|
fi
|
|
|
|
# -- Display server --
|
|
if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
|
|
_pass "Display server: Wayland (WAYLAND_DISPLAY=$WAYLAND_DISPLAY)"
|
|
local desktop="${XDG_CURRENT_DESKTOP:-unknown}"
|
|
_info "Desktop: $desktop"
|
|
if [[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]]; then
|
|
_info 'Mode: native Wayland (CLAUDE_USE_WAYLAND=1)'
|
|
else
|
|
_info 'Mode: X11 via XWayland (default, for global hotkey support)'
|
|
_info 'Tip: Set CLAUDE_USE_WAYLAND=1 for native Wayland'
|
|
_info ' (disables global hotkeys)'
|
|
fi
|
|
elif [[ -n "${DISPLAY:-}" ]]; then
|
|
_pass "Display server: X11 (DISPLAY=$DISPLAY)"
|
|
else
|
|
_fail "No display server detected" \
|
|
"(DISPLAY and WAYLAND_DISPLAY are unset)"
|
|
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
|
|
fi
|
|
|
|
# -- Input method (IBus / GTK) --
|
|
_doctor_check_im_modules "$_distro_id"
|
|
|
|
# -- Menu bar mode --
|
|
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
|
|
if [[ -n $menu_bar_mode ]]; then
|
|
local resolved_mode="${menu_bar_mode,,}"
|
|
# Resolve boolean-style aliases
|
|
case "$resolved_mode" in
|
|
1|true|yes|on) resolved_mode='visible' ;;
|
|
0|false|no|off) resolved_mode='hidden' ;;
|
|
esac
|
|
case "$resolved_mode" in
|
|
auto|visible|hidden)
|
|
_pass "Menu bar mode: $resolved_mode" \
|
|
"(CLAUDE_MENU_BAR=$menu_bar_mode)"
|
|
;;
|
|
*)
|
|
_warn "Unknown CLAUDE_MENU_BAR: '$menu_bar_mode'"
|
|
_info 'Will fall back to auto'
|
|
_info 'Valid values: auto, visible, hidden' \
|
|
'(or 0/1/true/false/yes/no/on/off)'
|
|
;;
|
|
esac
|
|
else
|
|
_info 'Menu bar mode: auto (default, Alt toggles visibility)'
|
|
fi
|
|
|
|
# -- Titlebar style --
|
|
local titlebar_style="${CLAUDE_TITLEBAR_STYLE:-}"
|
|
if [[ -n $titlebar_style ]]; then
|
|
local resolved_style="${titlebar_style,,}"
|
|
case "$resolved_style" in
|
|
hybrid|native)
|
|
_pass "Titlebar style: $resolved_style" \
|
|
"(CLAUDE_TITLEBAR_STYLE=$titlebar_style)"
|
|
;;
|
|
hidden)
|
|
_warn "Titlebar style: hidden — topbar clicks unresponsive on Linux (both X11 and Wayland)"
|
|
_info 'Use hybrid (default) or native for clickable buttons'
|
|
;;
|
|
*)
|
|
_warn "Unknown CLAUDE_TITLEBAR_STYLE: '$titlebar_style'"
|
|
_info 'Will fall back to hybrid'
|
|
_info 'Valid values: hybrid, native, hidden'
|
|
;;
|
|
esac
|
|
else
|
|
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
|
|
fi
|
|
|
|
# -- Electron binary --
|
|
# Version is read from the file next to the binary rather than
|
|
# launching Electron, which can hang (see #371).
|
|
if [[ -n $electron_path && -x $electron_path ]]; then
|
|
local ver
|
|
ver=$(_electron_version "$electron_path")
|
|
if [[ $ver =~ ^v?[0-9]+\.[0-9]+ ]]; then
|
|
_pass "Electron: v${ver#v} ($electron_path)"
|
|
else
|
|
_pass "Electron: found at $electron_path"
|
|
fi
|
|
elif [[ -n $electron_path ]]; then
|
|
_fail "Electron binary not found at $electron_path"
|
|
_info 'Fix: Reinstall claude-desktop package'
|
|
elif command -v electron &>/dev/null; then
|
|
local ver
|
|
ver=$(_electron_version "$(command -v electron)")
|
|
_pass "Electron: ${ver:+v${ver#v} }(system)"
|
|
else
|
|
_fail 'Electron binary not found'
|
|
_info 'Fix: Reinstall claude-desktop package'
|
|
fi
|
|
|
|
# -- Chrome sandbox permissions --
|
|
local sandbox_paths=(
|
|
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
|
|
)
|
|
# Also check relative to the provided electron path
|
|
if [[ -n $electron_path ]]; then
|
|
local electron_dir
|
|
electron_dir=$(dirname "$electron_path")
|
|
sandbox_paths+=("$electron_dir/chrome-sandbox")
|
|
fi
|
|
local sandbox_checked=false
|
|
for sandbox_path in "${sandbox_paths[@]}"; do
|
|
if [[ -f $sandbox_path ]]; then
|
|
sandbox_checked=true
|
|
local sandbox_perms sandbox_owner
|
|
sandbox_perms=$(stat -c '%a' "$sandbox_path" 2>/dev/null) || true
|
|
sandbox_owner=$(stat -c '%U' "$sandbox_path" 2>/dev/null) || true
|
|
if [[ $sandbox_perms == '4755' && $sandbox_owner == 'root' ]]; then
|
|
_pass "Chrome sandbox: permissions OK ($sandbox_path)"
|
|
else
|
|
_fail "Chrome sandbox: perms=${sandbox_perms:-?},\
|
|
owner=${sandbox_owner:-?}"
|
|
_info "Fix: sudo chown root:root $sandbox_path"
|
|
_info " sudo chmod 4755 $sandbox_path"
|
|
fi
|
|
break
|
|
fi
|
|
done
|
|
if [[ $sandbox_checked == false ]]; then
|
|
_warn 'Chrome sandbox not found (expected for AppImage)'
|
|
fi
|
|
|
|
# -- SingletonLock --
|
|
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
|
local lock_file="$config_dir/SingletonLock"
|
|
if [[ -L $lock_file ]]; then
|
|
local lock_target lock_pid
|
|
lock_target="$(readlink "$lock_file" 2>/dev/null)" || true
|
|
lock_pid="${lock_target##*-}"
|
|
if [[ $lock_pid =~ ^[0-9]+$ ]] && kill -0 "$lock_pid" 2>/dev/null; then
|
|
_pass "SingletonLock: held by running process (PID $lock_pid)"
|
|
else
|
|
_warn "SingletonLock: stale lock found" \
|
|
"(PID $lock_pid is not running)"
|
|
_info "Fix: rm '$lock_file'"
|
|
fi
|
|
else
|
|
_pass 'SingletonLock: no lock file (OK)'
|
|
fi
|
|
|
|
# -- MCP config --
|
|
local mcp_config="$config_dir/claude_desktop_config.json"
|
|
if [[ -f $mcp_config ]]; then
|
|
if command -v python3 &>/dev/null; then
|
|
if python3 -c \
|
|
"import json,sys; json.load(open(sys.argv[1]))" \
|
|
"$mcp_config" 2>/dev/null; then
|
|
_pass "MCP config: valid JSON ($mcp_config)"
|
|
# Check if any MCP servers are configured
|
|
local server_count
|
|
server_count=$(python3 -c "
|
|
import json,sys
|
|
with open(sys.argv[1]) as f:
|
|
cfg = json.load(f)
|
|
servers = cfg.get('mcpServers', {})
|
|
print(len(servers))
|
|
" "$mcp_config" 2>/dev/null) || server_count='0'
|
|
_info "MCP servers configured: $server_count"
|
|
else
|
|
_fail "MCP config: invalid JSON"
|
|
_info "Fix: Check $mcp_config for syntax errors"
|
|
_info "Tip: python3 -m json.tool '$mcp_config' to see the error"
|
|
fi
|
|
elif command -v node &>/dev/null; then
|
|
if node -e \
|
|
"JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" \
|
|
"$mcp_config" 2>/dev/null; then
|
|
_pass "MCP config: valid JSON ($mcp_config)"
|
|
else
|
|
_fail "MCP config: invalid JSON"
|
|
_info "Fix: Check $mcp_config for syntax errors"
|
|
fi
|
|
else
|
|
_warn "MCP config: exists but cannot validate" \
|
|
"(no python3 or node available)"
|
|
fi
|
|
else
|
|
_info "MCP config: not found at $mcp_config (OK if not using MCP)"
|
|
fi
|
|
|
|
# -- Node.js (needed by MCP servers) --
|
|
if command -v node &>/dev/null; then
|
|
local node_version
|
|
node_version=$(node --version 2>/dev/null) || true
|
|
local node_major="${node_version#v}"
|
|
node_major="${node_major%%.*}"
|
|
if ((node_major >= 20)); then
|
|
_pass "Node.js: $node_version"
|
|
elif ((node_major >= 1)); then
|
|
_warn "Node.js: $node_version (v20+ recommended for MCP servers)"
|
|
_info 'Fix: Update Node.js to v20 or later'
|
|
fi
|
|
_info "Path: $(command -v node)"
|
|
else
|
|
_warn 'Node.js: not found (required for MCP servers)'
|
|
_info 'Fix: Install Node.js v20+ from https://nodejs.org'
|
|
fi
|
|
|
|
# -- Desktop integration --
|
|
local desktop_file='/usr/share/applications/claude-desktop.desktop'
|
|
if [[ -f $desktop_file ]]; then
|
|
_pass "Desktop entry: $desktop_file"
|
|
else
|
|
_warn 'Desktop entry not found (expected for AppImage installs)'
|
|
fi
|
|
|
|
# -- Disk space --
|
|
local config_disk_avail
|
|
config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
|
|
| tail -1 | tr -d ' M') || true
|
|
if [[ -n $config_disk_avail ]]; then
|
|
if ((config_disk_avail < 100)); then
|
|
_fail "Disk space: ${config_disk_avail}MB free on config partition"
|
|
_info 'Fix: Free up disk space'
|
|
elif ((config_disk_avail < 500)); then
|
|
_warn "Disk space: ${config_disk_avail}MB free" \
|
|
"on config partition (low)"
|
|
else
|
|
_pass "Disk space: ${config_disk_avail}MB free"
|
|
fi
|
|
fi
|
|
|
|
# -- Cowork Mode --
|
|
echo
|
|
echo -e "${_bold}Cowork Mode${_reset}"
|
|
echo '----------------'
|
|
|
|
# Determine whether bwrap is the active backend (for severity
|
|
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
|
|
# bwrap is active unless the user has overridden to KVM or host.
|
|
local _bwrap_active=true
|
|
case "${COWORK_VM_BACKEND,,}" in
|
|
kvm|host) _bwrap_active=false ;;
|
|
''|bwrap) ;;
|
|
*)
|
|
# Unknown values: warn but leave _bwrap_active=true.
|
|
# The daemon falls through to auto-detect, which
|
|
# prefers bwrap — keep severity semantics aligned
|
|
# with that runtime behavior. See #442.
|
|
_warn_unknown_backend
|
|
;;
|
|
esac
|
|
|
|
# Bubblewrap (default backend)
|
|
if command -v bwrap &>/dev/null; then
|
|
_pass 'bubblewrap: found'
|
|
|
|
# Probe the sandbox. User namespaces must be available for
|
|
# bwrap to create its sandbox; Ubuntu 24.04+ blocks them via
|
|
# AppArmor by default (issue #351).
|
|
local _bwrap_probe_err='' _bwrap_probe_rc=0
|
|
_bwrap_probe_err=$(bwrap --ro-bind / / true 2>&1 >/dev/null) \
|
|
|| _bwrap_probe_rc=$?
|
|
if ((_bwrap_probe_rc == 0)); then
|
|
_pass 'bubblewrap: sandbox probe succeeded'
|
|
else
|
|
local _bwrap_issue=_warn
|
|
$_bwrap_active && _bwrap_issue=_fail
|
|
"$_bwrap_issue" \
|
|
"bubblewrap: sandbox probe failed" \
|
|
"(rc=$_bwrap_probe_rc)"
|
|
if [[ -n $_bwrap_probe_err ]]; then
|
|
_info " stderr: $_bwrap_probe_err"
|
|
fi
|
|
# Detect the Ubuntu 24.04 AppArmor userns block
|
|
# specifically, and hint the remediation.
|
|
local _userns_re='(user[[:space:]_-]?namespace|apparmor|[Oo]peration not permitted|CLONE_NEW|CAP_SYS_ADMIN)'
|
|
if [[ $_bwrap_probe_err =~ $_userns_re ]]; then
|
|
_info \
|
|
' Likely cause: unprivileged user namespaces' \
|
|
'are blocked.'
|
|
_info \
|
|
' Common on Ubuntu 24.04+ where AppArmor sets' \
|
|
'apparmor_restrict_unprivileged_userns=1'
|
|
_info \
|
|
' by default. See docs/TROUBLESHOOTING.md' \
|
|
'"Cowork on Ubuntu 24.04"'
|
|
_info ' for the AppArmor profile fix.'
|
|
fi
|
|
fi
|
|
else
|
|
_warn 'bubblewrap: not found'
|
|
_info \
|
|
"Fix: $(_cowork_pkg_hint "$_distro_id" bubblewrap)"
|
|
fi
|
|
|
|
# Warn on missing KVM deps only when explicitly requested;
|
|
# otherwise just inform since bwrap is the default.
|
|
local _kvm_active=false
|
|
[[ ${COWORK_VM_BACKEND-} == [Kk][Vv][Mm] ]] && _kvm_active=true
|
|
local _kvm_issue=_info
|
|
$_kvm_active && _kvm_issue=_warn
|
|
|
|
# KVM backend (opt-in via COWORK_VM_BACKEND=kvm)
|
|
if [[ -e /dev/kvm ]]; then
|
|
if [[ -r /dev/kvm && -w /dev/kvm ]]; then
|
|
_pass 'KVM: accessible'
|
|
else
|
|
"$_kvm_issue" 'KVM: /dev/kvm exists but not accessible'
|
|
if $_kvm_active; then
|
|
_info "Fix: sudo usermod -aG kvm $USER"
|
|
_info '(Log out and back in after running this)'
|
|
fi
|
|
fi
|
|
else
|
|
"$_kvm_issue" 'KVM: not available'
|
|
if $_kvm_active; then
|
|
_info \
|
|
'Fix: Install qemu-kvm and ensure KVM is enabled in BIOS'
|
|
fi
|
|
fi
|
|
|
|
# vsock module
|
|
if [[ -e /dev/vhost-vsock ]]; then
|
|
_pass 'vsock: module loaded'
|
|
else
|
|
"$_kvm_issue" 'vsock: /dev/vhost-vsock not found'
|
|
if $_kvm_active; then
|
|
_info 'Fix: sudo modprobe vhost_vsock'
|
|
fi
|
|
fi
|
|
|
|
# KVM tools: QEMU, socat. virtiofsd is handled separately below
|
|
# because Debian/Ubuntu install it off-PATH.
|
|
local _tool_label _tool_bin _tool_pkg
|
|
for _tool_label in \
|
|
'QEMU:qemu-system-x86_64:qemu' \
|
|
'socat:socat:socat'
|
|
do
|
|
_tool_bin="${_tool_label#*:}"
|
|
_tool_pkg="${_tool_bin#*:}"
|
|
_tool_bin="${_tool_bin%%:*}"
|
|
_tool_label="${_tool_label%%:*}"
|
|
|
|
if command -v "$_tool_bin" &>/dev/null; then
|
|
_pass "$_tool_label: found"
|
|
else
|
|
"$_kvm_issue" "$_tool_label: not found"
|
|
if $_kvm_active; then
|
|
_info \
|
|
"Fix: $(_cowork_pkg_hint "$_distro_id" "$_tool_pkg")"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# virtiofsd: ships off-PATH on several distros (see _find_virtiofsd
|
|
# above). Probe known locations so we don't report "not found" when
|
|
# the package is actually installed. KvmBackend spawns by PATH name
|
|
# and silently falls back to virtio-9p (lower perf) if the spawn
|
|
# fails — so when KVM is the active backend and virtiofsd is only
|
|
# reachable off-PATH, surface a [WARN] so the user knows they need
|
|
# a symlink to actually get virtiofs performance. On the bwrap
|
|
# default path virtiofsd is unused, so [PASS] is fine.
|
|
local _vfsd_path _vfsd_on_path
|
|
_vfsd_on_path=$(command -v virtiofsd 2>/dev/null)
|
|
_vfsd_path=$(_find_virtiofsd)
|
|
if [[ -n $_vfsd_path ]]; then
|
|
if [[ $_vfsd_path == "$_vfsd_on_path" ]]; then
|
|
_pass 'virtiofsd: found'
|
|
elif $_kvm_active; then
|
|
_warn "virtiofsd: found at $_vfsd_path but not on PATH"
|
|
_info 'KvmBackend spawns by PATH name and will fall back'
|
|
_info 'to virtio-9p (lower performance) without a symlink.'
|
|
_info "Fix: sudo ln -s $_vfsd_path /usr/local/bin/virtiofsd"
|
|
else
|
|
_pass "virtiofsd: found at $_vfsd_path (not on PATH)"
|
|
fi
|
|
else
|
|
"$_kvm_issue" 'virtiofsd: not found'
|
|
if $_kvm_active; then
|
|
_info "Fix: $(_cowork_pkg_hint "$_distro_id" virtiofsd)"
|
|
fi
|
|
fi
|
|
|
|
# VM image
|
|
local vm_image
|
|
vm_image="${HOME}/.local/share/claude-desktop/vm/rootfs.qcow2"
|
|
if [[ -f $vm_image ]]; then
|
|
local vm_size
|
|
vm_size=$(du -h "$vm_image" 2>/dev/null \
|
|
| cut -f1) || vm_size='unknown size'
|
|
_pass "VM image: $vm_size"
|
|
else
|
|
_info 'VM image: not downloaded yet'
|
|
fi
|
|
|
|
# Determine active backend (matches daemon's detectBackend())
|
|
local cowork_backend='none (host-direct, no isolation)'
|
|
if [[ -n ${COWORK_VM_BACKEND-} ]]; then
|
|
case ${COWORK_VM_BACKEND,,} in
|
|
kvm) cowork_backend='KVM (full VM isolation, via override)' ;;
|
|
bwrap) cowork_backend='bubblewrap (namespace sandbox, via override)' ;;
|
|
host) cowork_backend='host-direct (no isolation, via override)' ;;
|
|
*)
|
|
_warn_unknown_backend
|
|
cowork_backend="auto-detect (invalid override '${COWORK_VM_BACKEND}' — see warning above)"
|
|
;;
|
|
esac
|
|
elif command -v bwrap &>/dev/null; then
|
|
# bwrap is installed: if the probe succeeds, use it;
|
|
# otherwise fall to host (matching daemon behavior, so we
|
|
# don't silently imply KVM will be chosen when bwrap is
|
|
# blocked — see #351).
|
|
if bwrap --ro-bind / / true &>/dev/null; then
|
|
cowork_backend='bubblewrap (namespace sandbox)'
|
|
else
|
|
cowork_backend='host-direct (bwrap probe failed — see above)'
|
|
fi
|
|
elif [[ -e /dev/kvm ]] \
|
|
&& [[ -r /dev/kvm && -w /dev/kvm ]] \
|
|
&& command -v qemu-system-x86_64 &>/dev/null \
|
|
&& [[ -e /dev/vhost-vsock ]]; then
|
|
cowork_backend='KVM (full VM isolation)'
|
|
fi
|
|
_info "Cowork isolation: $cowork_backend"
|
|
|
|
# Custom bwrap mount configuration
|
|
_doctor_check_bwrap_mounts
|
|
|
|
# -- Orphaned cowork daemon --
|
|
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
|
|
# above: a live UI is an Electron main process on app.asar that is
|
|
# not a Chromium helper (--type=...), not the cowork daemon itself,
|
|
# and not stopped/zombie. Counting any `claude-desktop`-matching
|
|
# process (as the old check did) would include the launcher's own
|
|
# bash and stuck launcher bashes from previous crashes, producing
|
|
# false negatives where a real orphan is misreported as "parent
|
|
# alive".
|
|
local _cowork_pids
|
|
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|
|
|| true
|
|
if [[ -n $_cowork_pids ]]; then
|
|
local _daemon_orphaned=true _pid _cmdline _state
|
|
for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
|
|
[[ $_pid == "$$" || $_pid == "$PPID" ]] && continue
|
|
_cmdline=$(tr '\0' ' ' \
|
|
< "/proc/$_pid/cmdline" 2>/dev/null) || continue
|
|
[[ $_cmdline == *cowork-vm-service* ]] && continue
|
|
[[ $_cmdline == *--type=* ]] && continue
|
|
_state=$(awk '/^State:/ {print $2; exit}' \
|
|
"/proc/$_pid/status" 2>/dev/null) || continue
|
|
[[ $_state == T || $_state == t || $_state == Z ]] \
|
|
&& continue
|
|
_daemon_orphaned=false
|
|
break
|
|
done
|
|
if [[ $_daemon_orphaned == true ]]; then
|
|
_warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)"
|
|
_info 'Fix: Restart Claude Desktop' \
|
|
'(daemon will be cleaned up automatically)'
|
|
else
|
|
_pass 'Cowork daemon: running (parent alive)'
|
|
fi
|
|
fi
|
|
|
|
# -- Recent crashes --
|
|
# Surfaces the GPU process FATAL pattern (#583) before users
|
|
# notice the in-app "Claude crashed repeatedly" prompt.
|
|
_doctor_check_recent_crashes "$electron_path"
|
|
|
|
# -- Log file --
|
|
local log_path
|
|
log_path="${XDG_CACHE_HOME:-$HOME/.cache}"
|
|
log_path="$log_path/claude-desktop-debian/launcher.log"
|
|
if [[ -f $log_path ]]; then
|
|
local log_size
|
|
log_size=$(stat -c '%s' "$log_path" 2>/dev/null) || log_size=0
|
|
local log_size_kb=$((log_size / 1024))
|
|
if ((log_size_kb > 10240)); then
|
|
_warn "Log file: ${log_size_kb}KB" \
|
|
"(consider clearing: rm '$log_path')"
|
|
else
|
|
_pass "Log file: ${log_size_kb}KB ($log_path)"
|
|
fi
|
|
else
|
|
_info 'Log file: not yet created (OK)'
|
|
fi
|
|
|
|
# -- Summary --
|
|
echo
|
|
if ((_doctor_failures == 0)); then
|
|
echo -e "${_green}${_bold}All checks passed.${_reset}"
|
|
else
|
|
echo -e "${_red}${_bold}${_doctor_failures} check(s) failed.${_reset}"
|
|
echo 'See above for fixes.'
|
|
fi
|
|
|
|
return "$_doctor_failures"
|
|
}
|