| name | porting-claude-dmg-to-linux |
| description | Use when adapting a new macOS Claude.dmg (or similar Electron app DMG) into the Linux pipeline in this repo, when a fresh upstream release breaks the asar patches, or when diagnosing a Claude Desktop launch failure on Linux via ~/.cache/claude-desktop/launcher.log. |
Porting the Claude Desktop macOS DMG to Linux
Overview
This repo repackages the upstream Claude.dmg as a runnable Linux Electron app, then as a native .deb / .rpm / .pkg.tar.zst. The pipeline is deterministic but has three Claude-specific gotchas that will bite anyone adapting a new DMG release without reading this first.
Core principle: Don't invent — the upstream @ant/desktop bundle assumes macOS process-resource semantics. On Linux the fixes are surgical: replace one native module with a JS shim, stage locale JSONs where the unpackaged fallback looks for them, and let Electron auto-discover resources/app.asar instead of passing it as a CLI arg.
When to use
- A new
Claude.dmg dropped and ./install.sh fails, or the app launches and crashes.
- You see
ENOENT, resources/i18n/<locale>.json not found in ... app.asar in ~/.cache/claude-desktop/launcher.log.
- You see
claude-native-binding.node or swift_addon.node in a crash trace.
- You need to extend
install_native_shims because @ant/claude-native surfaced a new call site on Linux.
- You need to bump
ELECTRON_VERSION to match a new upstream build.
Pipeline at a glance
7z x Claude.dmg — modern 7-zip only (Ubuntu's p7zip-full 16.02 is too old; scripts/install-deps.sh bootstraps a newer 7zz into ~/.local/bin).
asar extract resources/app.asar app-extracted/
- Merge
app.asar.unpacked/ on top so .node files are visible.
- Stage locale JSONs into
app-extracted/resources/i18n/ (see Gotcha 2).
- Rebuild
node-pty for Linux via @electron/rebuild.
- Delete macOS
.node binaries under @ant/claude-swift and @ant/claude-native (37 MB saved).
- Install the
@ant/claude-native Linux shim (see Gotcha 1).
node scripts/patch-linux-window-ui.js app-extracted — today only rewrites package.json.desktopName.
asar pack app-extracted app.asar --unpack "{*.node,*.so,*.dylib}"
- Download matching Linux Electron binary →
claude-app/electron.
- Write
claude-app/start.sh (see Gotcha 3).
scripts/build-{deb,rpm,pacman}.sh stages claude-app/ under /opt/claude-desktop/ plus a full rebuild bundle under /opt/claude-desktop/update-builder/.
Gotcha 1 — @ant/claude-native is the only hard blocker
Two native modules in app.asar.unpacked/node_modules/@ant/:
| Module | Linux behavior |
|---|
@ant/claude-swift | Its js/index.js checks process.platform !== "darwin" and returns module.exports = {}. No action needed. The .node binaries (swift_addon.node, computer_use.node, ~37 MB) are unreachable on Linux — delete them to save space. |
@ant/claude-native | Its index.js does an unconditional require("./claude-native-binding.node") and will crash on Linux. Replace index.js with a JS shim and delete the macOS .node. |
The shim lives in install.sh::install_native_shims and exports a Proxy so unknown methods return no-ops. When the launcher log surfaces a new call site (e.g. claudeNative.newlyAddedMethod is not a function), extend the shim with a real implementation or a more specific no-op. Do not guess the full API up-front — react to observed errors.
Gotcha 2 — app.isPackaged is always false on Linux
Electron decides app.isPackaged based on process.execPath basename: if it's literally electron (our case), isPackaged is false regardless of how you launch it. The upstream i18n loader has this fallback:
wA.app.isPackaged
? process.resourcesPath
: path.resolve(__dirname, "..", "..", "resources", "i18n")
On Linux that resolves to app.asar/resources/i18n/<locale>.json, which didn't exist in the stock DMG. Solution: during patch_asar, copy every *.json from Claude.app/Contents/Resources/ into app-extracted/resources/i18n/ before repacking. install.sh does this automatically; the only failure mode is the upstream moving locale files elsewhere, in which case grep the bundle for "i18n":
node -e "const s=require('fs').readFileSync('/path/.vite/build/index.js','utf8'); console.log(s.slice(Math.max(0,s.indexOf('i18n')-400), s.indexOf('i18n')+400))"
Gotcha 3 — claude.ai shows "Upgrade to the latest desktop app to use cowork"
claude.ai's frontend asks the desktop for its cowork capability via getSupportedFeatures IPC. The bundle's evaluator Hvr() hardcodes the platform check:
if (platform !== "darwin" && platform !== "win32")
return { status: "unsupported", unsupportedCode: "unsupported_platform", reason: ... };
claude.ai's UI treats any non-mac/win platform as "app too old" — because Anthropic ships no Linux-specific cowork UX — and shows a persistent "Upgrade to the latest desktop app to use cowork" banner. The reason/unsupportedCode fields we send are ignored.
Cowork needs Apple VZ via @ant/claude-swift (macOS) or the @ant/cowork-win32-service HCS client (Windows); neither exists on Linux, so the feature fundamentally cannot work here. scripts/patch-linux-window-ui.js rewrites the Linux branch of Hvr() to return {status: "supported"} — this silences the banner. If a user then clicks cowork, p0t() returns null (@ant/claude-swift short-circuits on non-darwin) and the resulting VM-startup failure surfaces as a concrete error rather than the misleading upgrade nag.
Needle: function Hvr(){const e=process.platform;if(e!=="darwin"&&e!=="win32")return{status:"unsupported",
If the upstream restructures the check, re-derive by grepping for unsupported_platform in .vite/build/index.js and locating the surrounding function.
Gotcha 4 — titleBarStyle: "hidden" collapses the Linux viewport
The bundle's main BrowserWindow is created with titleBarStyle:"hidden", titleBarOverlay:ws, trafficLightPosition:d1t. It branches only on en=darwin / ws=win32 — there is no linux identifier anywhere in .vite/build/index.js (49 two-way en?mac:other branches, all treating Linux as "Windows"). On Linux, titleBarStyle:"hidden" produces a frameless window that expects renderer-side CSS chrome which Claude's renderer only ships for macOS/Windows. Symptoms: WM maximizes the frame to screen size but content lays out at ~iPhone defaults in the top-left; user reports "detects as the Windows version."
scripts/patch-linux-window-ui.js rewrites the needle to titleBarStyle:process.platform==="linux"?"default":"hidden" so Linux gets a native WM titlebar. titleBarOverlay and trafficLightPosition are also nulled out on Linux. If the needle minHeight:400,titleBarStyle:"hidden",titleBarOverlay:ws,trafficLightPosition:d1t, ever stops matching (upstream reshuffle), re-derive it from .vite/build/index.js around the single OBt({...}) call that builds the main window options.
Gotcha 5 — claude:// registration races setAsDefaultProtocolClient
The bundle calls app.setAsDefaultProtocolClient("claude") unconditionally and registers a second-instance handler. On Linux this writes ~/.local/share/applications/<app>-handler.desktop pointing at the raw process.execPath (the electron binary) with Exec=... %U. When the user clicks a claude://magic-link?... URL, xdg-open invokes that .desktop file, which spawns Electron without --no-sandbox; the spawned instance dies before it can IPC the URL to the running first instance via second-instance. Net effect: email magic links and Google OAuth callbacks never reach the running app.
Two fixes:
- Dev checkouts:
bash scripts/register-url-scheme-dev.sh writes ~/.local/share/applications/claude-desktop-dev.desktop with Exec=<repo>/claude-app/start.sh %U and runs xdg-mime default. This overrides Electron's registration on subsequent xdg-open invocations.
- Packaged installs: the
.deb ships /usr/share/applications/claude-desktop.desktop with Exec=/usr/bin/claude-desktop %U (which execs /opt/claude-desktop/start.sh), so dpkg -i claude-desktop_*.deb fixes this for free.
Verify: xdg-mime query default x-scheme-handler/claude should resolve to our .desktop, and xdg-open 'claude://test' should bring the running app forward with the URL in argv.
Gotcha 6 — Do not pass app.asar as a CLI arg to Electron
exec "$SCRIPT_DIR/electron" "$SCRIPT_DIR/resources/app.asar"
exec "$SCRIPT_DIR/electron"
If start.sh ever passes the asar explicitly, the locale paths reshuffle and you get the resources/i18n/en-US.json not found error even after Gotcha 2 is fixed.
Debugging loop
rm -f ~/.cache/claude-desktop/launcher.log
./claude-app/start.sh &
sleep 6
tail -60 ~/.cache/claude-desktop/launcher.log
What to look for:
| Log symptom | Cause | Fix |
|---|
ENOENT, resources/i18n/*.json not found in app.asar | Gotcha 2 — locale JSONs not staged | Re-run install.sh; confirm asar list ... | grep /resources/i18n/ shows locale files |
claude-native-binding.node in stack | Gotcha 1 — shim missing or method unshimmed | Extend install_native_shims with the method the log names |
Found exact match "en-US" in available locales | i18n working | Ignore; keep reading |
App is not installed, not enabling auto-updates | Benign on Linux — Sparkle path skipped | Ignore |
DBus ... already exported | Prior instance didn't clean up; harmless | pkill electron then relaunch |
GPU process exited ... exit_code=15 | SIGTERM from your timeout / kill | Expected on forced shutdown |
If all onQuitCleanup handlers log Successfully run ... and the app is connecting to https://claude.ai/api/desktop/features, the process is healthy — you just can't see the window without a display.
Bumping ELECTRON_VERSION
- Extract the new DMG:
7z x Claude.dmg -o/tmp/claude-inspect
asar extract-file .../app.asar package.json /tmp/pkg.json && jq .devDependencies.electron /tmp/pkg.json
- Set
ELECTRON_VERSION="<value>" at the top of install.sh.
- Re-run
./install.sh --fresh Claude.dmg. @electron/rebuild will recompile node-pty against the new ABI.
Verification checklist
bash -n install.sh scripts/build-*.sh scripts/install-deps.sh
node -c scripts/patch-linux-window-ui.js
cargo check -p claude-update-manager && cargo test -p claude-update-manager
./install.sh Claude.dmg
asar list claude-app/resources/app.asar | grep -E '^/resources/i18n/' | head -3
./claude-app/start.sh &
sleep 6 && grep -E 'Found exact match|Switching to locale' ~/.cache/claude-desktop/launcher.log
make deb PACKAGE_VERSION=$(date -u +%Y.%m.%d.%H%M%S)+local
dpkg-deb -c dist/claude-desktop_*.deb | grep -E 'usr/bin|applications|systemd'
All of the above should succeed before committing changes to install.sh or the shim.
Files you will touch most often
| File | Why |
|---|
install.sh | Main installer — Electron version bump, shim surface, staging steps. |
install.sh::install_native_shims | Extend when new @ant/claude-native call sites appear. |
scripts/patch-linux-window-ui.js | Add new string-level UI patches here if you port any from Codex's bundle. |
updater/src/config.rs | dmg_url default (currently a placeholder — no stable Anthropic URL exists). |
packaging/linux/claude-desktop.desktop | Add new MIME types / URL schemes here. |
assets/claude.png | 256×256 icon extracted from electron.icns via @fiahfy/icns + sharp. |
Things NOT to do
- Do not try to build the Swift modules on Linux. The
swift_addon.node path is macOS/Xcode-only and the JS wrapper already short-circuits — deleting the .node is the correct answer.
- Do not reintroduce the webview HTTP server from Codex's
start.sh. Claude loads the renderer from .vite/renderer/*.html via file:// like a standard Electron app; it does not need python3 -m http.server.
- Do not add
better-sqlite3 to build_native_modules unless the new DMG adds it. Claude does not currently depend on it (its sqlite worker is a .js wrapper, not a .node).
- Do not commit a
Claude.dmg to git — it is gitignored and carries Anthropic's signed binaries.