linux
Linux compatibility reference for the claude-desktop-extra project (repackaging Anthropic's official Claude Desktop Linux .deb and patching its app.asar for the distros Anthropic does not ship). Use w
SKILL.md.claude/skills/linux/SKILL.md
GitHubLinux compatibility - claude-desktop-extra
Repackages Anthropic's official Claude Desktop Linux .deb (bundles Electron 44.4.3 as of v2.7032.0 - read the deb tree's usr/lib/claude-desktop/version for the current one - plus a native Cowork VM backend) for the distros Anthropic does not ship, patching its app.asar to add Linux value-adds (Computer Use, themes, multi-profile, Quick Entry) and Linux fixes. Linux only - never add macOS/Windows code. Minified JS identifiers change every upstream release, so patterns must use [\w$]+ wildcards anchored on stable strings (feature names, log messages, process.platform==="darwin"). For a clean unpatched bundle to test patterns against, see /fresh-upstream.
Support matrix (must validate every change against ALL rows)
| Session | Compositors/DEs | Input backend | Screenshot |
|---|---|---|---|
| X11 | any (GNOME, KDE, i3, …) | x11-bridge (bundled, static) |
x11-bridge (bundled, static) |
| Wayland wlroots | Sway, Hyprland, Niri | wlroots-bridge (bundled, static) |
wlroots-bridge (bundled, static) |
| Wayland GNOME | GNOME Shell | gnome-portal-bridge (bundled; portal consent, persisted GNOME 46+) |
gnome-portal-bridge (bundled) |
| Wayland KDE 6.6+ | KDE Plasma | kwin-portal-bridge (bundled) |
kwin-portal-bridge (bundled) |
| Wayland KDE <6.6 | KDE Plasma | ydotool, else x11-bridge/XWayland |
spectacle+convert |
| XWayland | any Wayland | x11-bridge (bundled) |
x11-bridge (bundled) |
| Wayland exotic | COSMIC, Enlightenment, … | ydotool (+ydotoold), else x11-bridge/XWayland |
desktopCapturer / COWORK_SCREENSHOT_CMD |
| Distro | Pkg | Min glibc | Arch |
|---|---|---|---|
| Arch | own pacman repo | 2.41 | x86_64, aarch64 |
| Ubuntu 22.04+ | deb | 2.35 | amd64, arm64 |
| Debian 12+ | deb | 2.36 | amd64, arm64 |
| Fedora 40+ | rpm | 2.39 | x86_64, aarch64 |
| RHEL 9+ | rpm | 2.34 | x86_64, aarch64 |
| NixOS | flake | 2.40 | x86_64 (the Nix package is x86_64-only) |
| Jetson (JetPack 6) | deb | 2.35 | aarch64 |
| Fedora Atomic (Bazzite, Kinoite, Silverblue) | AppImage (immutable /usr; the rpm would need rpm-ostree layering, untested) |
2.39+ | x86_64, aarch64 |
| Any (glibc) | AppImage | varies | x86_64, aarch64 |
Immutable distros (Bazzite, Kinoite, Silverblue): everything the launcher writes is under $HOME or $XDG_RUNTIME_DIR (desktop entry + claude:// handler, Quick Entry socket, systemd scope, userData, KDE shortcut), and the host tools it reads (/usr/bin/busctl, kwin_wayland, systemd-run, FUSE 2) ship in the image; verified on the Bazzite 44 live ISO (Plasma 6.7.5) 2026-10-17 (#281).
glibc floor is 2.34 (RHEL 9 / Ubuntu 22.04). Debian 11 (bullseye, glibc 2.31) is no longer supported - the official Linux .deb we repackage targets newer glibc.
Session detection (scripts/claude-desktop-launcher.sh)
- Wayland iff
$WAYLAND_DISPLAYset; else X11. WhenWAYLAND_DISPLAYis set butXDG_SESSION_TYPEis empty ortty(compositor started from a TTY), the launcher exportsXDG_SESSION_TYPE=waylandso the app and the CU executors take the Wayland path; an explicitx11is left alone. platform_mode ∈ {x11, wayland, xwayland}drives Electron flags.CLAUDE_USE_XWAYLAND=1forces xwayland only when$DISPLAYis set (an X server exists; niri + xwayland-satellite qualifies); withoutDISPLAYit stays wayland and logs why.- Electron <40 on Wayland warns loudly: broken GlobalShortcutsPortal (electron/electron#49806) → global hotkeys only work when focused. Fix: update Electron, or
CLAUDE_USE_XWAYLAND=1. claude-desktop --diagnosedumps raw vs effectiveXDG_SESSION_TYPE,WAYLAND_DISPLAY,platform_mode, electron major, the GlobalShortcuts portal probed exactly as the app does it (busctl, same args/timeout, gdbus cross-check), a Host capabilities section (each host tool the app execs -> upstream path / PATH only / MISSING -> consequence), a run check of all four CU bridges, tray host, keep-awake backend, bluetoothd, and a closing Problems found list.--diagnoseand--helphave no side effects (no profile refresh, no AppImage integration).
Input + screenshot executors - single source of truth
The dispatch logic lives in checked-in JS under js/, embedded into patches/linux/fix_computer_use_linux.nim via staticRead (35 sub-patches, still std/nre for backreferences - the one patch that keeps it; new patches use the regex package). Edit the .js, not the patch.
js/cu_linux_executor.js- inline executor: session detection, per-session bridge dispatch, residual fallbacks,[claude-cu] diagnostics:lines.js/executor_linux.js- KWin/KDE hybrid executor (kwin mode).js/cu_handler_injection.js,js/cu_mode_preamble.js- handler wiring + mode/bridge-binary resolution: env var override (X11_BRIDGE_BIN,WLROOTS_BRIDGE_BIN,GNOME_PORTAL_BRIDGE_BIN,KWIN_PORTAL_BRIDGE_BIN) → bundledprocess.resourcesPath/<bridge>. There is NO$PATHlookup. A resolved bridge must then pass a runnable check (<bridge> --version, async, 3 s, cached per path) before it is selected; a failure is logged with its real cause (PipeWire/glibc floor, foreign ELF loader on NixOS, wrong arch, noexec) and the next tier is used (KDE: spectacle).CLAUDE_CU_MODE=kwin-wayland|regularoverrides the auto mode.
Upstream's own Linux CU (since v2.19675.1): the official build ships a native executor in claude-native-binding.node (XTest on X11, RemoteDesktop + ScreenCast portal over PipeWire on Wayland), gated by GrowthBook 3676943315 plus a Wayland portal probe (RemoteDesktop >= 2 and Clipboard; no wlroots). Our patch makes hasComputerUseExecutor true on Linux and puts the __linuxExecutor branch in front of upstream's linux arm, so the bridges below always win; upstream's executor is unreachable while the patch is applied.
Dispatch (bridge-only on covered sessions): wlroots session → wlroots-bridge; GNOME Wayland → gnome-portal-bridge (portal session daemon scoped to the CU lock via __setLockHeld); X11/XWayland → x11-bridge; KDE 6.6+ → kwin mode. All bridges share the x11-bridge CLI/JSON contract (subcommand + --kebab flags, one JSON value on stdout, exit 1 + stderr on error). On covered sessions there is NO third-party fallback - a missing bridge is a hard, diagnosable error. COWORK_SCREENSHOT_CMD overrides screenshots everywhere; Electron desktopCapturer is the last-resort screenshot tier. Clipboard + cursor-position are Electron-native in the regular executor. _checkYdotool() (ydotool + running ydotoold) gates only the exotic-Wayland residual path.
Executor methods run OUTSIDE the CU lock - treat them as ambient API (issue #184). Upstream builds the CU tool schemas at Code/Cowork session open and calls executor.listInstalledApps()/listRunningApps() there (raced against a 1s timeout that CANNOT preempt a synchronous block - execFileSync freezes the whole main process, timer never fires). Therefore: enumeration subcommands (windows, frontmost-app, app-under-point, screens) must NEVER open a portal session, pop a consent dialog, or wait on user interaction; portal sessions belong to the lock lifecycle (__setLockHeld, async execFile) plus the per-portal-command sync backstop. In cu_linux_executor.js this is enforced by the _gnomePortalCmds allowlist - only input/capture subcommands (pointer-, key-sequence, type, hold-key, zoom, screenshot, left-mouse-) may ensure the portal session. Any new executor method or bridge subcommand must declare which side it is on. All four bridges audited 2026-07-08: kwin already enforces this structurally (enumeration = portal-free KWin scripting; portal only via explicit session-start, fail-closed otherwise; JS fully async); wlroots/x11 have zero portal/consent surface (session-start is a no-op there) and bounded enumeration - GNOME was the only offender.
[claude-cu] diagnostics: lines (input-backend, per-bridge present (path)|absent, screenshot-cascade=[...], VM/Wayland detection) are written via globalThis.__cdbDiag to ~/.config/Claude/logs/claude-patches.log + raw fd 2. Debugging locally: read that file yourself (grep -a 'claude-cu' ~/.config/Claude/logs/claude-patches.log); ask remote issue reporters to attach it. The official .deb build DISCARDS main-process console.log/process.stdout (fds healthy - proven via /proc fd probe); never add plain console.log diagnostics to main-process patch code - use __cdbDiag (or the (globalThis.__cdbDiag||console.log) fallback outside the CU patch).
Native binaries & glibc floors (CI enforces via objdump -T | grep GLIBC_)
| Binary | Floor | Why | CI base |
|---|---|---|---|
| x11-bridge | none (static musl) | pure Rust, runs everywhere incl. NixOS | ubuntu:noble (rustup musl) |
| wlroots-bridge | none (static musl) | pure Rust, runs everywhere incl. NixOS | ubuntu:noble (rustup musl) |
| gnome-portal-bridge | 2.39 (Ubuntu Noble) | lamco-pipewire needs PipeWire >= 1.0.5 runtime (Noble/Fedora 40+/Debian 13+); Jammy (0.3.48) and Bookworm (0.3.65) cannot even load the binary | ubuntu:noble + native cross |
| kwin-portal-bridge | 2.39 (Ubuntu Noble) | KWin 6.6+ only on Noble+/Fedora 40+ | ubuntu:noble + native cross |
- node-pty is no longer rebuilt by us. The official Linux
.debships a pre-builtpty.node+spawn-helperfor x86_64 and arm64 insideapp.asar.unpacked; our repackage preserves them (asar packkeeps the unpacked dir). There is norebuild-pty-for-arch.shand no Electron pin (.electron-version/.electron-shasumsare gone) - Electron (44.4.3 as of v2.7032.0) is bundled in the official.deb. - Three CU bridges are ours (patrickjaja/x11-bridge, patrickjaja/wlroots-bridge, patrickjaja/gnome-bridge); kwin-portal-bridge is mosi0815's project - patrickjaja/kwin-portal-bridge is a fork, fixes go through a PR to mosi0815/kwin-portal-bridge. Built for x86_64 + arm64 by CI, cached by upstream HEAD SHA. Static bridges assert
statically linked|static-pie; dynamic bridges assert the glibc floor. - On NixOS the static bridges run as bundled; the dynamic two do not (foreign glibc loader) - the runnable check rejects them, so KDE falls back to spectacle and GNOME needs
.override { gnome-portal-bridge = …; }. - New native binary → pick floor = its minimum viable distro; CI verifies it via
objdump -T | grep GLIBC_. - RPM caveat: per-binary floors above the distro floor only work because
packaging/rpm/claude-desktop-extra.specexcludes the bundled tree from rpm's automatic ELF dependency generator (__requires_exclude_from); without it the gnome/kwin bridges' glibc-2.39 symbols become package requirements and the rpm cannot install on RHEL 9 (glibc 2.34). CI's rockylinux:9 install test guards this.
Multi-profile / window identity (scripts/claude-desktop-launcher.sh)
- Per-profile Electron binary at
~/.local/lib/claude-desktop/<APP_ID>-<name>via hardlink → reflink → copy (never symlink - Electron derives identity fromrealpath(/proc/self/exe), kernel resolves symlinks first), refreshed fromCLAUDE_ELECTRONwhen set (Nix). Gives per-profile process/exe identity, NOT a per-profile window app_id.--create-profileis refused on the AppImage (its tree moves every run; use--profile=NAME). On wrapped installs (Nix) the profile shortcut is a two-line exec script and autostart/profile entries runCLAUDE_LAUNCHER(the wrapper), so they keep the wrapper env and survive GC. - WM_CLASS = Wayland app_id =
com.anthropic.Claude- Chromium derives it from the bundle'sdesktopName(upstream's own reverse-DNS identity; the oldclaude-desktoppin was removed 2026-07-15). Shared across ALL profiles; distinct per-profile app_id needs a per-profiledesktopNameoverride (unsolved follow-up). systemd scopeapp-com.anthropic.Claude(-<name>)-$$.scope; installed.desktop=com.anthropic.Claude.desktop(StartupWMClass=com.anthropic.Claude); Quick Entry keeps its ownclaude-quick-entryid. XDG autostart entries derive from the BINARY basename instead:~/.config/autostart/claude(-<name>).desktop. - Per-profile isolation:
--user-data-dir(Electron userData),CLAUDE_CONFIG_DIR(Claude Code CLI),CLAUDE_PROFILEenv (sockets/markers). See AGENTS.md "Profile System" table. - Portal identity: xdg-desktop-portal resolves unsandboxed Electron via systemd scope →
.desktopid; a reverse-DNS id is required for activation routing and KDE persistent grants. Shipped since 2026-07-15:desktopName/.desktop/StartupWMClass/ scope all agree oncom.anthropic.Claude. A build tripwire inbuild-patched-tarball.shfails loud if upstream renamesdesktopNameagain.
Known Linux gotchas (one line each → which patch)
- Opening a Code session enumerates apps via the executor (no CU lock); a portal-session ensure on that path popped GNOME's consent dialog + froze the main process (#184) →
js/cu_linux_executor.js_gnomePortalCmdsallowlist + async__setLockHeldlifecycle. - A gnome-portal-bridge that never answers froze the whole app: every bridge call on the capture/input paths was a blocking
execFileSync, and a failedsession-startleft no memo, so each action re-paidscreens+session-start+ the command itself (#232) → screenshot path is async end to end (_wlBridgeCallAsync,_x11BridgeAsync), a failed portal session latches for 60 s (_gnomePortalDown, cleared by a fresh CU lock), the sync backstop is capped at 8 s (consent belongs to the async lock path, which keeps 30 s), and the x11-bridge screenshot tier is gated on!_wlb- a rootless XWayland root answersBadMatch, so a covered session goes straight todesktopCapturer. Pinned byscripts/tests/linux/test-cu-nonblocking.mjs;--diagnoseself-testsgnome-portal-bridge screenson GNOME Wayland. - Upstream execs host tools by absolute FHS path (
/usr/bin/busctlfor the GlobalShortcuts portal probe - a failure there disables every Wayland shortcut incl. Quick Entry;/usr/bin/secret-tool/kwallet-queryfor Chrome cookie import;/usr/bin/sqlite3for detected projects) →fix_host_tool_paths_linux.nim/fix_detected_projects_linux.nimkeep the literal when the file exists, else the bare name via PATH (NixOS, non-FHS). Theindex.pre.jsKWallet pre-flightbusctlis left alone (the launcher probes kwallet itself: busctl → dbus-send → gdbus). - Sandbox credential-path blocklist (.ssh/.gnupg/.aws/keyrings/.pki/autostart) →
fix_sensitive_dirs_linux.nim. - Tray icon theme →
fix_tray_icon_theme.nim(tray update-in-place is upstream-native;fix_tray_dbuswas retired). Wayland with noorg.kde.StatusNotifierWatcheron the bus (GNOME without AppIndicator, sway/niri without a tray bar): close quits natively (upstream's ownIsStatusNotifierHostRegisteredcheck), but an autostart (--startup) launch would stay invisible →fix_tray_less_desktops.nim(hidden launch shows the window; probed once at startup; X11 unchanged because XEmbed trays are invisible to the bus). - "Keep computer awake" relies on
powerSaveBlocker, which is a no-op withoutorg.gnome.SessionManager/org.freedesktop.PowerManagement(Sway, Hyprland, niri, i3) →fix_keep_awake_linux.nimholds a logind idle inhibitor (systemd-inhibit --what=idle); logind IdleAction and hypridle honor it, plain swayidle timeouts do not. - GNOME Mutter focus-stealing: Quick Entry opens then self-dismisses →
fix_quick_entry_wayland_blur_guard.nim(ignore blur with no preceding focus). KDE/Hyprland transfer focus so click-outside works; GNOME users close with Esc. - Native titlebar overlay was win32-only →
fix_native_frame.nim(switches persisted byadd_feature_window_controls.nim); three runtime modes: default integrated,CLAUDE_NATIVE_TITLEBAR=1(system frame, wins),CLAUDE_NO_WINDOW_CONTROLS=1(frameless, no overlay,hasShadow:false). The last one is the only in-app way to drop the 4 px border Chromium paints inside frameless windows on xfwm4 and on WMs that do not advertise_GTK_FRAME_EXTENTS(i3, Awesome): its gate iswants_frame_ = !IsTranslucent() && (HasShadow() || IsWindowControlsOverlayEnabled()), so BOTH disjuncts must be false - droppinghasShadow:falsesilently restores the border. - Resource paths are upstream-native: every package ships the official install tree verbatim (exe-adjacent
resources/app.asar, autoloaded via the OnlyLoadAppFromAsar fuse; launcher passes NO asar argv).process.resourcesPath/app.isPackagedbehave exactly as on the stock .deb; bridges +ion-dist.asar(the 3P-config SPA; the build repacks it in place after patching) + virtiofsd live directly inresources/. The former repackaging patches (fix_locale_paths,fix_0_node_host,fix_asar_folder_drop,fix_asar_workspace_cwd) were removed 2026-07-13. - RHEL 9 ships QEMU only as
/usr/libexec/qemu-kvm, while upstream's Cowork probe walks PATH forqemu-system-<arch>→ the.rpminstalls/usr/lib/claude-desktop/qemu-shim/qemu-system-<arch>->/usr/libexec/qemu-kvm, and the launcher appends that dir to PATH only when noqemu-system-<arch>is on PATH and/usr/libexec/qemu-kvmexists (VM boot verified on RHEL 9 x86_64; aarch64 untested). Debian 12 has no Rust virtiofsd (its C one rejects--shared-dir), so Cowork is unavailable there. process.argvundefined in renderer broke Claude Code web bundle →fix_process_argv_renderer.nim.app.dock.bounce(macOS) →fix_dock_bounce.nim(stub).
Wayland hotkey notes
- GNOME hotkey: GlobalShortcuts portal exists only on GNOME 48+ and its approval is easy to miss (Electron returns true even if it failed).
claude-desktop --install-gnome-hotkeywrites a gsettings custom keybinding (bypasses portal), toggling via Unix socket in$XDG_RUNTIME_DIR. - xdg-desktop-portal-wlr has no GlobalShortcuts (Sway, river, niri): users bind
claude-desktop --togglein the compositor. Hyprland's portal has it but only fires through aglobalbind. The app's portal probe runs once per process (busctl --timeout=2under a 3 s exec timeout), so a portal that is slow at login leaves hotkeys off until restart. Per-compositor snippets:docs/quick-entry.md. - KDE stale kglobalaccel entries after crash block re-registration →
gdbusunregister before re-register. - KDE Plasma 6.7.4+ (xdg-desktop-portal-kde) no longer arms a stored portal shortcut at CreateSession, and Chromium 152 (Electron 44) skips BindShortcuts when its id is already listed → hotkey dead after the first restart (#281) →
fix_quick_entry_kde_portal_rebind.nim/js/kde_global_shortcuts_rebind.js: a second portal session from the main process (pure-JS D-Bus client, same app id) re-binds the stored ids on every launch, silently. Gated to KDE Wayland, KWin >= 6.4, not XWayland, Chromium < 159 (fixed upstream on main 2026-10-15). Needs the app id to resolve: systemd scope AND an installedcom.anthropic.Claude.desktop(xdp 1.22 answers "An app id is required" otherwise - no dialog, no hotkey, on any Plasma).
Rules
- Every change must work across all session types and all distro/arch rows - think through each, especially Wayland fragmentation (GNOME ≠ KDE ≠ wlroots ≠ exotic).
- New input/screenshot work → change the BRIDGE for that session type (x11-bridge / wlroots-bridge / gnome-portal-bridge / kwin-portal-bridge), or the dispatch in
js/cu_linux_executor.js(+executor_linux.jsfor KDE); keep diagnostics lines and the shared CLI/JSON contract (x11-bridge DESIGN.md is authoritative). - Fixed user-level paths → prefer
app.getPath("userData")(auto-isolates per profile) over~/.config/Claude. - New native binary we ship → builds for x86_64 AND aarch64, pick a glibc floor (or static musl), CI verifies. (We do NOT rebuild node-pty - it comes pre-built in the official
.deb.) - Verify JS syntax after any patch:
node --check ./tmp/app.asar.contents/.vite/build/index.js.