mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
* 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>
683 lines
32 KiB
JavaScript
683 lines
32 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);
|
|
}
|
|
}
|
|
`;
|
|
|
|
// 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');
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
}
|
|
});
|
|
}
|
|
|
|
return result;
|
|
};
|