Files
claude-desktop-debian/scripts/frame-fix-wrapper.js
JoshuaVlantis 8796aa2c82 fix(linux): mask thenable / coercion traps on autoUpdater no-op Proxy
The chainable-Proxy get-trap returned chainNoop for every property,
including `then`. V8's thenable check calls `then(resolve, reject)`
on anything with a function-typed `.then`, so `await someAutoUpdaterExpr`
or `Promise.resolve(autoUpdater).then(...)` invoked chainNoop with the
resolve/reject pair, got the Proxy back, and the await hung forever
with no error and no Sentry breadcrumb.

Verified against the actual Proxy in node:20-alpine: three await
scenarios (`await proxy`, `Promise.resolve(proxy).then(...)`,
`await proxy.checkForUpdates()`) all hung for the full 1500ms timeout.
After this change all three resolve to the Proxy itself, and existing
chain behavior (`.on().once().setFeedURL().checkForUpdates()`,
`emit.bind(proxy)`) keeps working.

Also masks `Symbol.toPrimitive` and `Symbol.iterator` so string
coercion / `for..of` / spread fail loudly rather than feeding the
Proxy back through V8's primitive- or iterator-protocol machinery.

Comment block updated to note the get-trap shadows reads but lets
writes land on the target.

Addresses review feedback on #567.
2026-05-14 12:18:34 +02:00

790 lines
37 KiB
JavaScript

// Inject frame fix before main app loads
const Module = require('module');
const path = require('path');
const originalRequire = Module.prototype.require;
console.log('[Frame Fix] Wrapper loaded');
// Fix process.resourcesPath to match the actual location of app.asar.
// In Nix builds, electron is a separate store path so process.resourcesPath
// points to the Electron package's resources dir, not where our tray icons
// and app.asar.unpacked live. Deriving from __dirname (the asar root) gives
// the correct path; for deb/AppImage builds the values already match.
const derivedResourcesPath = path.dirname(__dirname);
if (derivedResourcesPath !== process.resourcesPath) {
console.log('[Frame Fix] Correcting process.resourcesPath');
console.log('[Frame Fix] Was:', process.resourcesPath);
console.log('[Frame Fix] Now:', derivedResourcesPath);
process.resourcesPath = derivedResourcesPath;
}
// Menu bar visibility mode, controlled by CLAUDE_MENU_BAR env var:
// 'auto' - hidden by default, Alt toggles visibility (current default)
// 'visible' - always visible, Alt does not toggle (stable layout)
// 'hidden' - always hidden, Alt does not toggle
// Also accepts boolean-style aliases: 1/true/yes/on -> visible, 0/false/no/off -> hidden
const VALID_MENU_BAR_MODES = ['auto', 'visible', 'hidden'];
const MENU_BAR_ALIASES = {
'1': 'visible', 'true': 'visible', 'yes': 'visible', 'on': 'visible',
'0': 'hidden', 'false': 'hidden', 'no': 'hidden', 'off': 'hidden',
};
const rawMenuBarMode = (process.env.CLAUDE_MENU_BAR || 'auto').toLowerCase();
const resolvedMode = MENU_BAR_ALIASES[rawMenuBarMode] || rawMenuBarMode;
const MENU_BAR_MODE = VALID_MENU_BAR_MODES.includes(resolvedMode) ? resolvedMode : 'auto';
if (resolvedMode !== rawMenuBarMode) {
console.log(`[Frame Fix] CLAUDE_MENU_BAR '${process.env.CLAUDE_MENU_BAR}' resolved to '${resolvedMode}'`);
} else if (resolvedMode !== MENU_BAR_MODE) {
console.warn(`[Frame Fix] Unknown CLAUDE_MENU_BAR value '${process.env.CLAUDE_MENU_BAR}', falling back to 'auto'. Valid: ${VALID_MENU_BAR_MODES.join(', ')}, or 0/1/true/false/yes/no/on/off`);
}
console.log(`[Frame Fix] Menu bar mode: ${MENU_BAR_MODE}`);
// Titlebar mode, controlled by CLAUDE_TITLEBAR_STYLE env var:
// 'hybrid' (default) - native OS frame (frame:true) + wco-shim active.
// Stacked layout: OS titlebar on top draws
// min/max/close, claude.ai's in-app topbar
// renders below it via the shim's UA +
// matchMedia overrides. Topbar buttons clickable.
// Recommended Linux experience.
// 'native' - system-decorated window (frame:true), no shim.
// DE draws min/max/close; claude.ai's in-app
// topbar is hidden by its UA gate. Use if the
// in-app topbar conflicts with your DE.
// 'hidden' - frameless window with Window Controls Overlay
// configured (matches Windows / macOS upstream).
// BROKEN ON LINUX X11: topbar buttons not
// clickable because Chromium creates an implicit
// WM-level drag region for frameless windows
// that intercepts mouse events. Kept for
// Wayland comparison and future investigation;
// see docs/learnings/linux-topbar-shim.md.
// Applies to the main window only. Popups (Quick Entry, About) are
// always frameless regardless of this setting.
const VALID_TITLEBAR_STYLES = ['hybrid', 'native', 'hidden'];
const rawTitlebarStyle = (process.env.CLAUDE_TITLEBAR_STYLE || 'hybrid').toLowerCase();
const TITLEBAR_STYLE = VALID_TITLEBAR_STYLES.includes(rawTitlebarStyle)
? rawTitlebarStyle
: 'hybrid';
if (rawTitlebarStyle !== TITLEBAR_STYLE) {
console.warn(`[Frame Fix] Unknown CLAUDE_TITLEBAR_STYLE value '${process.env.CLAUDE_TITLEBAR_STYLE}', falling back to 'hybrid'. Valid: ${VALID_TITLEBAR_STYLES.join(', ')}`);
}
console.log(`[Frame Fix] Titlebar style: ${TITLEBAR_STYLE}`);
// Keep the app alive when the main window is closed (hide to tray),
// so in-app schedulers / MCP servers / the tray icon survive a
// stray click on X. Explicit quit paths (Ctrl+Q via the focused
// webContents listener above, tray menu Quit, File > Quit, cmd+Q,
// SIGTERM) still go through app.quit() → before-quit, which arms
// the flag so the close handler lets the windows actually close.
// Set CLAUDE_QUIT_ON_CLOSE=1 to restore the Electron-default
// "closing the last window quits the app" behaviour.
const CLOSE_TO_TRAY = process.platform === 'linux'
&& process.env.CLAUDE_QUIT_ON_CLOSE !== '1';
console.log(`[Frame Fix] Close-to-tray: ${CLOSE_TO_TRAY ? 'on' : 'off'}`);
// Detect if a window intends to be frameless (popup/Quick Entry/About)
// Quick Entry: titleBarStyle:"", skipTaskbar:true, transparent:true, resizable:false
// About: titleBarStyle:"", skipTaskbar:true, resizable:false
// Main: titleBarStyle:"", titleBarOverlay:false(linux), resizable (has minWidth)
// The main window has minWidth set; popups do not.
function isPopupWindow(options) {
if (!options) return false;
if (options.frame === false) return true;
if (options.titleBarStyle === '' && !options.minWidth) return true;
return false;
}
// CSS injection for Linux scrollbar styling
// Respects both light and dark themes via prefers-color-scheme
const LINUX_CSS = `
/* Scrollbar styling - thin, unobtrusive, adapts to theme */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 4px;
transition: background 0.15s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.55);
}
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-thumb {
background: rgba(200, 200, 200, 0.2);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(200, 200, 200, 0.4);
}
}
`;
// autoUpdater no-op: every property access returns a chainable function
// so `.on(...).once(...).setFeedURL(...).checkForUpdates()` is harmless.
// `getFeedURL` returns '' so any code that inspects the URL gets a
// well-typed empty string rather than undefined. `then`/`catch`/`finally`
// and `Symbol.toPrimitive`/`Symbol.iterator` resolve to `undefined` so the
// Proxy is not mistaken for a thenable (which would call chainNoop as
// `then(resolve, reject)` and never resolve — silent await hang) or
// asked to coerce to a primitive. Writes land on the target but are
// shadowed by the get-trap. Defined once and reused across all
// require('electron') calls. Linux-only; macOS/Windows still see the
// real autoUpdater. See #567.
const autoUpdaterNoop = new Proxy({}, {
get(_target, prop) {
if (prop === 'getFeedURL') return () => '';
if (prop === 'then' || prop === 'catch' || prop === 'finally'
|| prop === Symbol.toPrimitive || prop === Symbol.iterator) {
return undefined;
}
return function chainNoop() { return autoUpdaterNoop; };
},
});
// Build the patched BrowserWindow class and Menu interceptor once,
// on first require('electron'), then reuse via Proxy on every access.
let PatchedBrowserWindow = null;
let patchedSetApplicationMenu = null;
let electronModule = null;
Module.prototype.require = function(id) {
const result = originalRequire.apply(this, arguments);
if (id === 'electron') {
// Build patches once from the real electron module
if (!PatchedBrowserWindow) {
electronModule = result;
const OriginalBrowserWindow = result.BrowserWindow;
const OriginalMenu = result.Menu;
PatchedBrowserWindow = class BrowserWindowWithFrame extends OriginalBrowserWindow {
constructor(options) {
console.log('[Frame Fix] BrowserWindow constructor called');
let popup = false;
if (process.platform === 'linux') {
options = options || {};
const originalFrame = options.frame;
popup = isPopupWindow(options);
if (popup) {
// Popup/Quick Entry windows: keep frameless for proper UX
options.frame = false;
// Remove macOS-specific titlebar options that don't apply on Linux
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log('[Frame Fix] Popup detected, keeping frameless');
} else if (TITLEBAR_STYLE === 'native') {
// Main window, native mode: force system frame.
options.frame = true;
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
// 'auto' (default): hidden, Alt toggles
// 'visible'/'hidden': no Alt toggle
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
} else if (TITLEBAR_STYLE === 'hybrid') {
// Main window, hybrid mode: native OS frame +
// claude.ai's in-app topbar via wco-shim.
//
// Why this shape: Linux X11 + frameless windows
// hits a Chromium-level implicit drag region at
// the top of the window that intercepts mouse
// events at the WM level. We've ruled out
// titleBarOverlay and titleBarStyle as the source
// (disabling either still produced unclickable
// topbar buttons). The drag region appears to be
// a Linux-X11 default for frame:false windows. With
// frame:true the OS handles dragging via the native
// titlebar and Chromium pushes no drag-region map,
// so the in-app topbar's buttons are clickable.
//
// Visual trade-off vs Windows: stacked layout — OS
// titlebar on top, in-app topbar below it. The
// buttons we care about (hamburger / sidebar /
// search / nav / Cowork ghost) all live in the
// in-app topbar via the shim's UA + matchMedia
// overrides. The shim's className intercept stays
// as belt-and-suspenders against the .draggable
// CSS rule still applying within the framed
// window's content area.
options.frame = true;
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
} else {
// Main window, hidden mode: frameless + Window Controls
// Overlay configured (matches Windows / macOS upstream).
// BROKEN ON LINUX X11 — topbar buttons not clickable
// because Chromium creates an implicit drag region for
// frame:false windows that intercepts mouse events at
// the WM level. Investigation chain in
// docs/learnings/linux-topbar-shim.md ruled out
// titleBarOverlay height and titleBarStyle:'hidden' as
// the source. The default is now 'hybrid'; this branch
// is kept for Wayland comparison and future probes.
options.frame = false;
options.titleBarStyle = 'hidden';
options.titleBarOverlay = {
color: '#1a1a1a',
symbolColor: '#ffffff',
height: 40,
};
console.log('[Frame Fix] Hidden mode (frame=false, '
+ 'titleBarStyle=hidden, titleBarOverlay=object) — '
+ 'topbar clicks broken on X11');
}
}
super(options);
if (process.platform === 'linux') {
// Hide menu bar after window creation (unless user wants it visible)
if (MENU_BAR_MODE !== 'visible') {
this.setMenuBarVisibility(false);
}
// Inject CSS for Linux scrollbar styling
this.webContents.on('did-finish-load', () => {
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
});
// WCO diagnostic: probe Chromium's native Window Controls
// Overlay state on the main window webContents. Upstream
// electron/electron#41769 (June 2024) implements WCO on
// Linux X11; runtime probes (2026-04-29) show the API
// surface returns visible:true here while display-mode
// and env() vars don't match — partial implementation.
// env() extraction goes through a custom-property
// indirection because getPropertyValue('env(...)') is
// invalid; env() is only meaningful inside CSS values.
// Logs to stdout so the result lands in launcher.log.
// Only meaningful for non-popup main windows in hidden
// mode (the only path that requests WCO).
if (!popup && TITLEBAR_STYLE !== 'native') {
this.webContents.on('did-finish-load', () => {
this.webContents.executeJavaScript(`
(() => {
const wco = navigator.windowControlsOverlay;
let rect = null;
try {
const r = wco && wco.getTitlebarAreaRect && wco.getTitlebarAreaRect();
if (r) rect = { x: r.x, y: r.y, width: r.width, height: r.height };
} catch (e) { /* ignore */ }
const s = document.createElement('style');
s.textContent = ':root{--probe-tbx:env(titlebar-area-x);--probe-tby:env(titlebar-area-y);--probe-tbw:env(titlebar-area-width);--probe-tbh:env(titlebar-area-height);}';
document.head.appendChild(s);
const cs = getComputedStyle(document.documentElement);
const result = {
visible: !!(wco && wco.visible),
rect,
media_wco: matchMedia('(display-mode: window-controls-overlay)').matches,
media_standalone: matchMedia('(display-mode: standalone)').matches,
media_browser: matchMedia('(display-mode: browser)').matches,
env_x: cs.getPropertyValue('--probe-tbx').trim(),
env_y: cs.getPropertyValue('--probe-tby').trim(),
env_w: cs.getPropertyValue('--probe-tbw').trim(),
env_h: cs.getPropertyValue('--probe-tbh').trim(),
userAgent: navigator.userAgent,
location: location.href,
};
s.remove();
return JSON.stringify(result);
})()
`).then((json) => {
console.log('[WCO Diagnostic] main window webContents:', json);
}).catch((err) => {
console.warn('[WCO Diagnostic] main window probe failed:', err.message);
});
});
}
// Quit on Ctrl+Q, but only when Claude has keyboard focus.
// Replaces a prior globalShortcut registration that grabbed
// the key system-wide and, on non-QWERTY layouts (e.g.
// AZERTY), swallowed other shortcuts like Ctrl+A because
// Electron matches globals by physical keycode. Fixes: #399
this.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (!input.control) return;
if (input.alt || input.shift || input.meta) return;
if (input.key !== 'q' && input.key !== 'Q') return;
event.preventDefault();
electronModule.app.quit();
});
// In 'hidden' mode, suppress Alt toggle by re-hiding
// on every show event. In 'auto' mode, let
// autoHideMenuBar handle the toggle natively.
if (MENU_BAR_MODE === 'hidden') {
this.on('show', () => {
this.setMenuBarVisibility(false);
});
}
if (!popup) {
// Close-to-tray: intercept close on main windows and hide
// instead. app.on('before-quit') below sets the flag when
// the user picks an explicit quit path, so real quits still
// let the window actually close. Popups (Quick Entry,
// About) already dismiss via hide() in the upstream code;
// they never see close events, so they're unaffected.
// Fixes: #448
if (CLOSE_TO_TRAY) {
this.on('close', (e) => {
if (!result.app._quittingIntentionally && !this.isDestroyed()) {
e.preventDefault();
this.hide();
}
});
}
// Directly set child view bounds to match content size.
// This bypasses Chromium's stale LayoutManagerBase cache
// (only invalidated via _NET_WM_STATE atom changes, which
// KWin corner-snap/quick-tile never sets). Instead of
// monkey-patching getContentBounds() (which causes drag
// resize jitter at ~60Hz), we only act on discrete state
// changes. Fixes: #239
const fixChildBounds = () => {
if (this.isDestroyed()) return false;
const children = this.contentView?.children;
if (!children?.length) return false;
const [cw, ch] = this.getContentSize();
if (cw <= 0 || ch <= 0) return false;
const cur = children[0].getBounds();
if (cur.width !== cw || cur.height !== ch) {
children[0].setBounds({ x: 0, y: 0, width: cw, height: ch });
return true;
}
return false;
};
// Geometry settles in stages after state changes.
// Three passes at 0/16/150ms cover immediate, next-frame,
// and compositor-animation-complete timing.
const fixAfterStateChange = () => {
fixChildBounds();
setTimeout(fixChildBounds, 16);
setTimeout(fixChildBounds, 150);
};
// Suppresses resize/moved→fixAfterStateChange cascade
// during jiggle. Without this, each setSize triggers the
// resize handler, creating 6+ unnecessary timer callbacks.
let jiggling = false;
// Track interactive (user-drag) resizing. will-resize
// only fires for user-initiated drags, not programmatic
// setSize() or WM-initiated resizes. On Wayland compositors
// where will-resize may not fire, the guard stays false —
// safe because jiggle only triggers from armed pairs.
let userResizing = false;
let userResizeTimer = null;
this.on('will-resize', () => {
userResizing = true;
if (userResizeTimer) clearTimeout(userResizeTimer);
userResizeTimer = setTimeout(() => { userResizing = false; }, 300);
});
// Debounced 1px jiggle for workspace switches where tile
// size is unchanged (bounds match but compositor cache is
// stale). Only called from armed-pair handlers, never
// from resize/maximize. Same pattern as ready-to-show
// but debounced and guarded.
// INVARIANT: debounce (100ms) must exceed jiggle duration
// (50ms) to prevent overlapping jiggles on rapid workspace
// switching. Do not reduce debounce below jiggle timeout.
let jiggleTimer = null;
const jiggleIfStale = () => {
if (jiggleTimer) clearTimeout(jiggleTimer);
jiggleTimer = setTimeout(() => {
jiggleTimer = null;
if (this.isDestroyed() || userResizing) return;
if (!fixChildBounds()) {
jiggling = true;
const [w, h] = this.getSize();
this.setSize(w + 1, h);
setTimeout(() => {
if (!this.isDestroyed()) {
this.setSize(w, h);
fixChildBounds();
}
jiggling = false;
}, 50);
}
}, 100);
};
for (const evt of ['maximize', 'unmaximize',
'enter-full-screen', 'leave-full-screen']) {
this.on(evt, fixAfterStateChange);
}
// KWin corner-snap/quick-tile emits 'moved' but not
// 'maximize'/'unmaximize'. Guard with a size-change check
// so normal window drags (position-only) are ignored.
let lastSize = [0, 0];
this.on('moved', () => {
if (this.isDestroyed() || jiggling) return;
const [w, h] = this.getSize();
if (w !== lastSize[0] || h !== lastSize[1]) {
lastSize = [w, h];
fixAfterStateChange();
}
});
// Tiling WMs (Hyprland, i3, sway) emit 'resize' on
// workspace switches with stale getContentBounds()
// cache. The size-change guard in fixChildBounds()
// prevents unnecessary work during drag resize.
// Fixes: #323
this.on('resize', () => {
if (!jiggling) fixAfterStateChange();
});
// ready-to-show fires once per window lifecycle
this.once('ready-to-show', () => {
if (MENU_BAR_MODE !== 'visible') {
this.setMenuBarVisibility(false);
}
// One-time jiggle for initial layout. Fixes: #84
const [w, h] = this.getSize();
this.setSize(w + 1, h + 1);
setTimeout(() => {
if (this.isDestroyed()) return;
this.setSize(w, h);
fixAfterStateChange();
}, 50);
});
// Tiling WMs signal workspace switches via blur/focus
// (Hyprland) or hide/show pairs. Jiggle only fires
// when fixChildBounds() finds no mismatch (stale
// compositor cache on same-size workspace switch).
// Fixes: #323
const armPair = (armEvt, fireEvt) => {
let armed = false;
this.on(armEvt, () => { armed = true; });
this.on(fireEvt, () => {
if (armed) {
armed = false;
jiggleIfStale();
}
});
};
this.on('focus', () => {
this.flashFrame(false); // Fixes: #149
});
armPair('blur', 'focus');
armPair('hide', 'show');
}
console.log('[Frame Fix] Linux patches applied');
}
}
};
// Copy static methods and properties from original
for (const key of Object.getOwnPropertyNames(OriginalBrowserWindow)) {
if (key !== 'prototype' && key !== 'length' && key !== 'name') {
try {
const descriptor = Object.getOwnPropertyDescriptor(OriginalBrowserWindow, key);
if (descriptor) {
Object.defineProperty(PatchedBrowserWindow, key, descriptor);
}
} catch (e) {
// Ignore errors for non-configurable properties
}
}
}
// Intercept Menu.setApplicationMenu to hide menu bar on Linux.
// In 'hidden' mode, force-hide after every menu update.
// In 'auto' mode, only hide initially (autoHideMenuBar handles
// Alt toggle — re-hiding here would break that). Fixes: #321
const originalSetAppMenu = OriginalMenu.setApplicationMenu.bind(OriginalMenu);
patchedSetApplicationMenu = function(menu) {
console.log('[Frame Fix] Intercepting setApplicationMenu');
originalSetAppMenu(menu);
if (process.platform === 'linux' && MENU_BAR_MODE === 'hidden') {
for (const win of PatchedBrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue;
win.setMenuBarVisibility(false);
}
console.log('[Frame Fix] Menu bar hidden on all windows');
}
};
// Arm the close-to-tray flag on every real quit path
// (app.quit() from Ctrl+Q, tray Quit, cmd+Q, SIGTERM). The
// BrowserWindow close handler above checks this flag to
// decide whether to hide or actually close. Harmless when
// CLOSE_TO_TRAY is off (the close handler is never attached).
if (CLOSE_TO_TRAY) {
result.app.on('before-quit', () => {
result.app._quittingIntentionally = true;
});
}
// WCO diagnostic console mirror + global Ctrl+Q.
//
// The console mirror forwards [WCO Diagnostic] / [WCO Shim] /
// [Drag Shim] messages from any webContents (including the
// BrowserView that hosts claude.ai) back to stdout so probes
// run from preload land in launcher.log alongside the main
// window probe. Filtered prefixes avoid mirroring claude.ai's
// noisy console.
//
// The Ctrl+Q handler is replicated here from the per-window
// setup above because before-input-event only fires on the
// webContents that has keyboard focus. The BrowserView has
// its own webContents that takes focus over the main window,
// so a handler on the main window alone never sees keypresses
// when the BrowserView is focused (the typical case). Adding
// it to every webContents covers main + BrowserView + popups.
// Linux-only because the per-window handler above is
// Linux-only (and macOS has Cmd+Q natively).
if (process.platform === 'linux') {
result.app.on('web-contents-created', (_evt, wc) => {
if (TITLEBAR_STYLE !== 'native') {
wc.on('console-message', (event) => {
const msg = (event && event.message) || '';
if (msg.startsWith('[WCO Diagnostic]')
|| msg.startsWith('[WCO Shim]')
|| msg.startsWith('[Drag Shim]')) {
console.log('[BrowserView]', msg);
}
});
}
wc.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
if (!input.control) return;
if (input.alt || input.shift || input.meta) return;
if (input.key !== 'q' && input.key !== 'Q') return;
event.preventDefault();
result.app.quit();
});
});
}
// Route app.{get,set}LoginItemSettings through XDG Autostart on Linux.
// Electron's openAtLogin is a no-op on Linux (electron/electron#15198),
// which both prevents the app's "Run on startup" toggle from
// persisting and makes isStartupOnLoginEnabled() return undefined
// (the app's IPC handler then fails boolean validation). Writing
// $XDG_CONFIG_HOME/autostart/claude-desktop.desktop is honoured by
// every mainstream DE (GNOME/KDE/XFCE/Cinnamon/MATE/LXQt). Fixes: #128
if (process.platform === 'linux') {
const fs = require('fs');
const os = require('os');
// XDG Base Directory Spec §3: autostart lives under $XDG_CONFIG_HOME/autostart,
// falling back to ~/.config/autostart only when the env var is unset or empty.
// Home-manager / dotfile setups relocate this; writing unconditionally to
// ~/.config would drop the entry in the wrong place for those users.
const xdgConfigHome = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim()
? process.env.XDG_CONFIG_HOME
: path.join(os.homedir(), '.config');
const autostartDir = path.join(xdgConfigHome, 'autostart');
const autostartPath = path.join(autostartDir, 'claude-desktop.desktop');
// Desktop Entry Exec= escaping (freedesktop.org Desktop Entry Spec):
// quote args containing whitespace or reserved chars; double-backslash
// and escape inner quotes inside the quoted form.
const escapeExecArg = (s) => {
const reserved = /[\s"`$\\]/;
if (!reserved.test(s)) return s;
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
};
// Resolve the Exec/Icon targets at toggle time (not module load),
// so an AppImage run picks up process.env.APPIMAGE — the absolute
// path to the current .AppImage, set by the AppImage runtime.
// Without this, AppImage users who haven't integrated via
// AppImageLauncher get a file that launches a `claude-desktop`
// binary not on $PATH, silently failing at next login. Icon=
// accepts an absolute file path; DEs fall back gracefully when
// they can't extract the embedded icon. For deb/RPM/Nix,
// 'claude-desktop' resolves via the launcher shim and the
// hicolor icon name matches scripts/packaging/{deb,rpm}.sh.
const resolveAutostartTarget = () => {
if (process.env.APPIMAGE) {
return {
exec: escapeExecArg(process.env.APPIMAGE),
icon: escapeExecArg(process.env.APPIMAGE),
};
}
return { exec: 'claude-desktop', icon: 'claude-desktop' };
};
// StartupWMClass matches the value set by scripts/packaging/{deb,rpm}.sh
// so DEs group an autostarted window with user-launched instances
// under the same taskbar / dock entry.
const buildAutostartContent = () => {
const { exec, icon } = resolveAutostartTarget();
return `[Desktop Entry]
Type=Application
Name=Claude
Exec=${exec}
Icon=${icon}
StartupWMClass=Claude
Terminal=false
X-GNOME-Autostart-enabled=true
`;
};
const origGetLoginItemSettings = result.app.getLoginItemSettings.bind(result.app);
result.app.getLoginItemSettings = function(...args) {
const settings = origGetLoginItemSettings(...args);
const enabled = fs.existsSync(autostartPath);
settings.openAtLogin = enabled;
// executableWillLaunchAtLogin is Windows-only in Electron and
// comes back undefined on Linux; coerce to boolean so the app's
// IPC handler's typeof === 'boolean' validation passes.
settings.executableWillLaunchAtLogin = enabled;
return settings;
};
const origSetLoginItemSettings = result.app.setLoginItemSettings.bind(result.app);
result.app.setLoginItemSettings = function(opts = {}) {
// Intentionally ignore opts.path / opts.name: process.execPath on
// Electron is the electron binary itself, not the launcher script
// that sets up ELECTRON_FORCE_IS_PACKAGED / ozone flags / orphan
// cleanup. Honouring opts.path would write a broken autostart
// entry that skips all of that. resolveAutostartTarget() derives
// the right Exec line from the current runtime instead.
if (typeof opts.openAtLogin === 'boolean') {
try {
fs.mkdirSync(autostartDir, { recursive: true });
if (opts.openAtLogin) {
fs.writeFileSync(autostartPath, buildAutostartContent());
console.log('[Autostart] wrote', autostartPath);
} else {
try {
fs.unlinkSync(autostartPath);
console.log('[Autostart] removed', autostartPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
} catch (err) {
console.error('[Autostart] failed to toggle', autostartPath, err);
}
}
return origSetLoginItemSettings(opts);
};
console.log('[Autostart] XDG Autostart shim installed');
}
// Detect in-place package upgrade (dpkg/rpm rename-replace of
// app.asar) and offer a restart, since post-swap window loads
// mix v(N+1) HTML/assets with the v(N) IPC/preload still in
// memory. AppImage and Nix are immune (immutable running file);
// the watcher just no-ops there. Fixes: see PR #564.
const armUpgradeWatcher = () => {
if (process.platform !== 'linux') return;
const fs = require('fs');
const asarPath = path.join(process.resourcesPath, 'app.asar');
let baseline;
try { baseline = fs.statSync(asarPath); } catch { return; }
let notified = false;
let debounceTimer = null;
const promptRestart = () => {
if (notified) return;
let cur;
try { cur = fs.statSync(asarPath); } catch { return; }
// ino catches rename-replace; mtime catches in-place
// rewrite. Either is sufficient on its own for dpkg/rpm,
// but checking both keeps us honest against odd packagers.
if (cur.ino === baseline.ino
&& cur.mtimeMs === baseline.mtimeMs) return;
notified = true;
console.log('[Frame Fix] app.asar replaced — prompting restart');
// whenReady() resolves immediately if already ready, so no
// isReady() branch needed. Linux libnotify ignores
// Notification.actions (macOS-only), so whole-notification
// click is the only restart affordance.
result.app.whenReady().then(() => {
try {
const n = new result.Notification({
title: 'Claude Desktop has been updated',
body: 'Click to restart and apply the update.',
});
n.on('click', () => {
result.app.relaunch();
result.app.quit();
});
n.show();
} catch (err) {
console.warn('[Frame Fix] Restart notification failed:',
err.message);
}
});
};
// Watch the parent dir, not the file: file-level fs.watch
// loses the inode across rename-replace. Filename filter
// ignores unrelated activity in the resources dir; 5s
// debounce covers dpkg's .dpkg-new → rename dance and
// similar multi-stage swaps in rpm/Nix.
const watcher = fs.watch(path.dirname(asarPath),
(_evt, filename) => {
if (filename !== 'app.asar') return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(promptRestart, 5000);
});
// App's other handles drive process lifetime; the watcher
// shouldn't keep the loop alive on its own.
watcher.unref();
console.log('[Frame Fix] Upgrade watcher armed:', asarPath);
};
try { armUpgradeWatcher(); } catch (err) {
console.warn('[Frame Fix] Upgrade watcher failed to arm:',
err.message);
}
console.log('[Frame Fix] Patches built successfully');
}
// Return a Proxy that intercepts property access on the electron module.
// This is needed because electron's exports use non-configurable getters,
// so we cannot directly reassign module.BrowserWindow.
return new Proxy(result, {
get(target, prop, receiver) {
if (prop === 'BrowserWindow') return PatchedBrowserWindow;
if (prop === 'Menu') {
// Return a proxy for Menu that intercepts setApplicationMenu
const originalMenu = target.Menu;
return new Proxy(originalMenu, {
get(menuTarget, menuProp) {
if (menuProp === 'setApplicationMenu') return patchedSetApplicationMenu;
return Reflect.get(menuTarget, menuProp);
}
});
}
if (prop === 'autoUpdater' && process.platform === 'linux') {
// Force autoUpdater into a no-op on Linux. Upstream's bundled
// app code sets a feed URL of api.anthropic.com/api/desktop/linux/...
// when app.isPackaged is true (we set ELECTRON_FORCE_IS_PACKAGED=true
// unconditionally). Today this is a happy accident: Electron's Linux
// autoUpdater is unimplemented and logs "AutoUpdater is not supported
// on Linux", so the calls no-op. If a future Electron implements it,
// every install would start hitting that feed and would either 404
// or — worse — receive content the install wasn't prepared for.
// .deb/.rpm/AppImage updates flow through the OS package manager
// (or AppImageUpdate); the Anthropic feed has no Linux artifacts.
// We replace the entire autoUpdater object with a Proxy that
// no-ops every method and returns chainable stubs for EventEmitter
// calls so listener registration in the bundled code is harmless.
// See #567.
return autoUpdaterNoop;
}
return Reflect.get(target, prop, receiver);
}
});
}
return result;
};