* feat(linux): hybrid titlebar mode for clickable in-app topbar Default `CLAUDE_TITLEBAR_STYLE` is now `hybrid`: native OS frame plus a BrowserView preload shim that convinces claude.ai's bundle to render its in-app topbar (hamburger / sidebar / search / nav / Cowork ghost). Stacked layout instead of Windows's combined bar, but every button is clickable. Why not the upstream `frame:false` + WCO config: investigation (see docs/learnings/linux-topbar-shim.md) ruled out `titleBarOverlay`, `titleBarStyle:'hidden'`, and the `.draggable` CSS class as the source of the topbar click-eating drag region. The remaining cause is a Chromium-level implicit drag region for `frame:false` windows that exists on both X11 and Wayland and has no Electron-API knob. With `frame:true` the OS handles dragging and Chromium pushes no drag-region map, so the buttons receive mouse events normally. Modes: - `hybrid` (default) — system frame + shim, topbar visible and clickable - `native` — system frame, no shim, no in-app topbar - `hidden` — frameless + WCO config, matches Windows/macOS upstream; topbar visible but not clickable on Linux. Kept for Wayland comparison and future investigation Tests: tests/launcher-common.bats grew 16 cases covering `_resolve_titlebar_style`, `build_electron_args` flag selection per mode, and `setup_electron_env` env-var wiring per mode. `claude-desktop --doctor` now reports the resolved mode and warns when `hidden` is set. Co-Authored-By: Claude <claude@anthropic.com> * docs(learnings): add hybrid-mode screenshot Visual reference of the stacked layout: DE-drawn titlebar on top with native window controls, claude.ai's in-app topbar (hamburger / search / back-forward) immediately below it. Co-Authored-By: Claude <claude@anthropic.com> * docs(learnings): fix codespell hit (Pre-emptive → Preemptive) Codespell flags hyphenated "Pre-emptive" as a misspelling of "Preemptive". Drops the hyphen to clear the spellcheck CI gate on PR #538. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
19 KiB
Claude Desktop Debian - Development Notes
Project Overview
This project repackages Claude Desktop (Electron app) for Debian/Ubuntu Linux, applying necessary patches for Linux compatibility.
Learnings
The docs/learnings/ directory contains hard-won technical knowledge from debugging and fixing issues — things that aren't obvious from reading the code or docs alone. Consult these before working on related areas. Add new entries when you discover something non-obvious that would save future contributors (human or AI) significant time.
nix.md— NixOS packaging, Electron resource path resolution, testing without NixOScowork-vm-daemon.md— Cowork VM daemon lifecycle, respawn logic, crash diagnosisplugin-install.md— Anthropic & Partners plugin install flow, gate logic, backend endpoints, and DevTools recipesapt-worker-architecture.md— APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbooktray-rebuild-race.md— why destroy + recreate onnativeThemeupdates briefly duplicates the tray icon on KDE Plasma, and the in-placesetImage+setContextMenufast-path that avoids the SNI re-registration racemcp-double-spawn.md— Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaroundlinux-topbar-shim.md— why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstreamframe:false+ WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
Code Style
All shell scripts in this project must follow the Bash Style Guide. Key points:
- Tabs for indentation, lines under 80 characters (exception: URLs and regex patterns)
- Use
[[ ]]for conditionals,$(...)for command substitution - Single quotes for literals, double quotes for expansions
- Lowercase variables; UPPERCASE only for constants/exports
- Use
localin functions, avoidset -eandeval
Linting
Shell scripts are checked with shellcheck and GitHub Actions workflows with actionlint before pushing. When lint issues are found:
- Fix the code - Correct the underlying issue rather than suppressing the warning
- Disable directives are a last resort - Only use
# shellcheck disable=SCXXXXwhen:- The warning is a false positive
- The pattern is intentional and unavoidable
- Always add a comment explaining why the disable is needed
- Run
/lintto check manually - Use this skill to check for issues before pushing
GitHub Workflow
General Approach
- Use
ghCLI for all GitHub interactions - Create branches based on issue numbers:
fix/123-descriptionorfeature/123-description - Reference issues in commits and PRs with
#123orFixes #123 - After creating a PR, add a comment to the related issue with a summary and link to the PR
Investigating Issues
For older issues, review the state of the code when the issue was raised - it may have already been addressed:
# Get issue creation date
gh issue view 123 --json createdAt
# Find the commit just before the issue was created
git log --oneline --until="2025-08-23T08:48:35Z" -1
# View a file at that point in time
git show <commit>:path/to/file.sh
# Search for relevant changes since the issue was created
git log --oneline --after="2025-08-23" -- path/to/file.sh
# View a specific commit that may have fixed the issue
git show <commit>
This helps identify if the issue was already fixed, and allows referencing the specific commit in the response.
Attribution
For PR descriptions, include full attribution:
---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <model-name> <noreply@anthropic.com>
<XX>% AI / <YY>% Human
Claude: <what AI did>
Human: <what human did>
- Use the actual model name (e.g.,
Claude Opus 4.5,Claude Sonnet 4) - The percentage split should honestly reflect the contribution balance for that specific work
- This provides a trackable record of AI-assisted development over time
For issues and comments, use simplified attribution:
---
Written by Claude <model-name> via [Claude Code](https://claude.ai/code)
For commits, include a Co-Authored-By trailer:
Co-Authored-By: Claude <claude@anthropic.com>
Contributor Credits
The README Acknowledgments section credits external contributors in chronological order (by merge date or fix date). Update it when:
- Merging an external PR — Add the author to the Acknowledgments list with a link to their GitHub profile and a brief description of their contribution.
- Implementing a fix suggested in an issue — If an issue author (or commenter) provided a concrete fix, workaround, code snippet, or detailed technical analysis that was directly used, credit them too.
Contributors are listed in chronological order: inspirational projects first (k3d3, emsi, leobuskin), then contributors ordered by when their contribution was merged or implemented.
Working with Minified JavaScript
Important Guidelines
-
Always use regex patterns when modifying the source JavaScript. Patches live in
scripts/patches/*.sh(one file per subsystem:tray.sh,cowork.sh,claude-code.sh, etc.);build.shis only an orchestrator that sources them. Variable and function names are minified and change between releases. -
The beautified code in
build-reference/has different spacing than the actual minified code in the app. Patterns must handle both:- Minified:
oe.nativeTheme.on("updated",()=>{ - Beautified:
oe.nativeTheme.on("updated", () => {
- Minified:
-
Use
-Eflag with sed for extended regex support when patterns need grouping or alternation. -
Extract variable names dynamically rather than hardcoding them. Shared extraction helpers live in
scripts/patches/_common.sh. Example:# Extract function name from a known pattern TRAY_FUNC=$(grep -oP 'on\("menuBarEnabled",\(\)=>\{\K\w+(?=\(\)\})' app.asar.contents/.vite/build/index.js) -
Handle optional whitespace in regex patterns:
# Bad: assumes no spaces sed -i 's/oe.nativeTheme.on("updated",()=>{/...' # Good: handles optional whitespace sed -i -E 's/(oe\.nativeTheme\.on\(\s*"updated"\s*,\s*\(\)\s*=>\s*\{)/...'
Reference Files
build-reference/app-extracted/- Extracted and beautified source for analysisbuild-reference/tray-icons/- Tray icon assets for reference
Frame Fix Wrapper
The app uses a wrapper system to intercept and fix Electron behavior for Linux:
frame-fix-wrapper.js- Interceptsrequire('electron')to patch BrowserWindow defaults (e.g.,frame: truefor proper window decorations on Linux)frame-fix-entry.js- Entry point that loads the wrapper before the main app
These are injected by scripts/patches/app-asar.sh (inside patch_app_asar) and referenced in package.json's main field. The wrapper pattern allows fixing Electron behavior without modifying the minified app code directly.
Setting Up build-reference
If build-reference/ is missing or you need to inspect source for a new version, follow these steps to download, extract, and beautify the source code.
Prerequisites
# Install required tools
sudo apt install p7zip-full wget nodejs npm
# Install asar and prettier globally (or use npx)
npm install -g @electron/asar prettier
Step 1: Download the Windows Installer
The Windows installer contains the app.asar which has the full Electron app source.
# Create working directory
mkdir -p build-reference && cd build-reference
# Download URL pattern (update version as needed):
# x64: https://downloads.claude.ai/releases/win32/x64/VERSION/Claude-COMMIT.exe
# arm64: https://downloads.claude.ai/releases/win32/arm64/VERSION/Claude-COMMIT.exe
# Example for version 1.1.381:
wget -O Claude-Setup-x64.exe "https://downloads.claude.ai/releases/win32/x64/1.1.381/Claude-c2a39e9c82f5a4d51f511f53f532afd276312731.exe"
Step 2: Extract the Installer
# Extract the exe (it's a 7z archive)
7z x -y Claude-Setup-x64.exe -o"exe-contents"
# Find and extract the nupkg
cd exe-contents
NUPKG=$(find . -name "AnthropicClaude-*.nupkg" | head -1)
7z x -y "$NUPKG" -o"nupkg-contents"
cd ..
# Copy out the important files
cp exe-contents/nupkg-contents/lib/net45/resources/app.asar .
cp -a exe-contents/nupkg-contents/lib/net45/resources/app.asar.unpacked .
# Optional: copy tray icons for reference
mkdir -p tray-icons
cp exe-contents/nupkg-contents/lib/net45/resources/*.png tray-icons/ 2>/dev/null || true
cp exe-contents/nupkg-contents/lib/net45/resources/*.ico tray-icons/ 2>/dev/null || true
Step 3: Extract app.asar
# Extract the asar archive
asar extract app.asar app-extracted
Step 4: Beautify the JavaScript Files
The extracted JS files are minified. Use prettier to make them readable:
# Beautify all JS files in the build directory
npx prettier --write "app-extracted/.vite/build/*.js"
# Or beautify specific files
npx prettier --write app-extracted/.vite/build/index.js
npx prettier --write app-extracted/.vite/build/mainWindow.js
Step 5: Clean Up (Optional)
# Remove intermediate files, keep only what's needed for reference
rm -rf exe-contents
rm Claude-Setup-x64.exe
rm -rf app.asar app.asar.unpacked # Keep only app-extracted
Final Structure
build-reference/
├── app-extracted/
│ ├── .vite/
│ │ ├── build/
│ │ │ ├── index.js # Main process (beautified)
│ │ │ ├── mainWindow.js # Main window preload
│ │ │ ├── mainView.js # Main view preload
│ │ │ └── ...
│ │ └── renderer/
│ │ └── ...
│ ├── node_modules/
│ │ └── @ant/claude-native/ # Native bindings (stubs)
│ └── package.json
├── tray-icons/
│ ├── TrayIconTemplate.png # Black icon (for light panels)
│ ├── TrayIconTemplate-Dark.png # White icon (for dark panels)
│ └── ...
└── nupkg-contents/ # Optional: full extracted nupkg
Adding New Package Formats or Repositories
When adding support for new distribution formats (e.g., RPM, Flatpak, Snap) or package repositories, follow these guidelines to avoid iterative debugging in CI.
Research Before Implementing
-
Understand the target system's constraints - Each package format has specific rules:
- Version string formats (e.g., RPM cannot have hyphens in Version field)
- Required metadata fields
- Signing requirements and tools
-
Search for existing CI implementations - Look for "GitHub Actions [format] signing" or similar. Existing workflows reveal required flags, environment setup, and common pitfalls.
-
Check tool behavior in non-interactive environments - CI has no TTY. Tools like GPG need flags like
--batchand--yesto work without prompts.
Consider Concurrency
-
Multiple jobs writing to the same branch will race - If APT and DNF repos both push to
gh-pages, add:- Job dependencies (
needs: [other-job]), or - Retry loops with
git pull --rebasebefore push
- Job dependencies (
-
External processes may also modify branches - GitHub Pages deployment runs automatically and can cause push conflicts.
Test the Full Pipeline
-
Test CI steps locally first - Run the signing/packaging commands manually to catch errors before committing.
-
Use a test tag for new infrastructure - Create a non-release tag to validate the full CI pipeline before merging to main.
-
Verify the end-user experience - After CI succeeds, actually test the install commands from the README on a clean system.
Common CI Pitfalls
| Issue | Solution |
|---|---|
| GPG "cannot open /dev/tty" | Add --batch flag |
| GPG "File exists" error | Add --yes flag to overwrite |
| Push rejected (ref changed) | Add git pull --rebase before push, with retry loop |
| Version format invalid | Research target format's version constraints upfront |
| Signing key not found | Ensure key is imported before signing step, check key ID output |
CI/CD
Triggering Builds
# Trigger CI on a branch
gh workflow run CI --ref branch-name
# Watch the run
gh run watch RUN_ID
# Download artifacts
gh run download RUN_ID -n artifact-name
Build Artifacts
claude-desktop-VERSION-amd64.deb- Debian package for x86_64claude-desktop-VERSION-amd64.AppImage- AppImage for x86_64claude-desktop-VERSION-arm64.deb- Debian package for ARM64claude-desktop-VERSION-arm64.AppImage- AppImage for ARM64result/- Nix build output (symlink, gitignored)
Distribution
APT and DNF binaries are fronted by a Cloudflare Worker at pkg.claude-desktop-debian.dev. Metadata (InRelease, Packages, KEY.gpg, repodata/*) passes through to the gh-pages branch; binary requests (/pool/.../*.deb, /rpm/*/*.rpm) get 302'd to the corresponding GitHub Release asset. This keeps .deb / .rpm files out of gh-pages entirely, so they never hit GitHub's 100 MB per-file push cap.
Key files:
worker/src/worker.js— Worker sourceworker/wrangler.toml— Worker config (route,custom_domain = true).github/workflows/deploy-worker.yml— deploys on push tomainwhenworker/**changes.github/workflows/apt-repo-heartbeat.yml— daily chain validation, auto-opens tracking issue on failureupdate-apt-repoandupdate-dnf-repojobs in.github/workflows/ci.yml— gate a strip step on Worker liveness, so binaries are removed from the local pool tree before push
Repo secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID. Token scoped to the "Edit Cloudflare Workers" template.
Full details including the redirect chain, the http-scheme-downgrade gotcha, credential ownership, and heartbeat failure runbook: docs/learnings/apt-worker-architecture.md.
Testing
Local Build
./build.sh --build appimage --clean no
Nix Build
nix build .#claude-desktop
nix build .#claude-desktop-fhs
Testing AppImage
# Run with logging
./test-build/claude-desktop-*.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log
Debugging Workflow
Inspecting the Running App's Code
# Find the mounted AppImage path
mount | grep claude
# Example: /tmp/.mount_claudeXXXXXX
# Extract the running app's asar for inspection
npx asar extract /tmp/.mount_claudeXXXXXX/usr/lib/node_modules/electron/dist/resources/app.asar /tmp/claude-inspect
# Search for patterns in the extracted code
grep -n "pattern" /tmp/claude-inspect/.vite/build/index.js
Checking DBus/Tray Status
# List registered tray icons
gdbus call --session --dest=org.kde.StatusNotifierWatcher \
--object-path=/StatusNotifierWatcher \
--method=org.freedesktop.DBus.Properties.Get \
org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems
# Find which process owns a DBus connection
gdbus call --session --dest=org.freedesktop.DBus \
--object-path=/org/freedesktop/DBus \
--method=org.freedesktop.DBus.GetConnectionUnixProcessID ":1.XXXX"
Log Locations
- Launcher log:
~/.cache/claude-desktop-debian/launcher.log - App logs:
~/.config/Claude/logs/ - Run with logging:
./app.AppImage 2>&1 | tee ~/.cache/claude-desktop-debian/launcher.log
Useful Locations
- App data:
~/.config/Claude/ - Logs:
~/.config/Claude/logs/ - SingletonLock:
~/.config/Claude/SingletonLock - Launcher log:
~/.cache/claude-desktop-debian/launcher.log
Versioning
Release versions are managed via two GitHub Actions repository variables (not files):
REPO_VERSION- The project's own version (e.g.,1.3.23). Bump this manually viagh variable set REPO_VERSION --body "X.Y.Z"when shipping project changes.CLAUDE_DESKTOP_VERSION- The upstream Claude Desktop version (e.g.,1.1.8629). Updated automatically by thecheck-claude-versionworkflow when a new upstream release is detected.
Tag format
Tags follow the pattern v{REPO_VERSION}+claude{CLAUDE_DESKTOP_VERSION}, e.g., v1.3.23+claude1.1.7714. Pushing a tag triggers the CI release build.
# Check current values
gh variable get REPO_VERSION
gh variable get CLAUDE_DESKTOP_VERSION
# Bump repo version and tag a release
gh variable set REPO_VERSION --body "1.3.24"
git tag "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
git push origin "v1.3.24+claude$(gh variable get CLAUDE_DESKTOP_VERSION)"
When upstream Claude Desktop updates, the check-claude-version workflow automatically updates CLAUDE_DESKTOP_VERSION, patches the URLs in scripts/setup/detect-host.sh, and creates a new tag — no manual intervention needed.
Common Gotchas
.zsyncfiles - Used for delta updates, can be ignored/deleted- AppImage mount points - Running AppImages mount to
/tmp/.mount_claude*; check withmount | grep claude - Killing the app - Must kill all electron child processes, not just the main one:
pkill -9 -f "mount_claude" - SingletonLock - If app won't start, check for stale lock:
~/.config/Claude/SingletonLock - Node version - Build requires Node.js; the script downloads its own if needed
- Nix hashes - When Claude Desktop version changes, both the URLs in
scripts/setup/detect-host.shandnix/claude-desktop.nix(version, URLs, SRI hashes) must be updated. The CI handles this automatically. - Claude Desktop version - A GitHub Action automatically updates the
CLAUDE_DESKTOP_VERSIONrepo variable and the URLs inscripts/setup/detect-host.shon main when a new version is detected. Before committingscripts/setup/detect-host.sh, ensure your branch has the latest URLs:Update both amd64 and arm64 URLs in# Check repo variable (source of truth) gh variable get CLAUDE_DESKTOP_VERSION # Check current version in the detect_architecture case statement grep -oP 'x64/\K[0-9]+\.[0-9]+\.[0-9]+' scripts/setup/detect-host.sh | head -1 # If outdated, pull URLs from main branch gh api repos/aaddrick/claude-desktop-debian/contents/scripts/setup/detect-host.sh?ref=main \ --jq '.content' | base64 -d | grep -E "claude_download_url="detect_architecture()to match main