* 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>
* fix(autostart): route openAtLogin through XDG Autostart on Linux (#128)
Electron's app.getLoginItemSettings()/setLoginItemSettings() are
no-ops on Linux (electron/electron#15198), so the "Run on startup"
toggle never persists and isStartupOnLoginEnabled() returns
undefined, failing the IPC handler's typeof === 'boolean' check.
Intercept both calls in frame-fix-wrapper.js and back them with
~/.config/autostart/claude-desktop.desktop, which is honoured by
GNOME/KDE/XFCE/Cinnamon/MATE/LXQt (XDG Autostart spec). Also
coerce executableWillLaunchAtLogin (Windows-only in Electron,
undefined on Linux) to a boolean so the IPC handler stops
throwing.
Fixes#128
Co-Authored-By: Claude <claude@anthropic.com>
* fix(autostart): address review — APPIMAGE runtime target, XDG_CONFIG_HOME, StartupWMClass (#128)
Addresses review comments on #450:
- Resolve Exec= and Icon= at toggle time via process.env.APPIMAGE
so AppImage users (who don't have claude-desktop on $PATH unless
integrated via AppImageLauncher) get an autostart entry that
launches the actual .AppImage bundle instead of a broken binary
reference. escapeExecArg() handles Desktop Entry Exec escaping
(quote + backslash-escape reserved chars).
- Honour $XDG_CONFIG_HOME when set and non-empty, falling back to
~/.config only otherwise. Home-manager and dotfile users who
relocate the config root were getting the entry dropped in the
wrong place silently.
- Add StartupWMClass=Claude to the generated entry, matching the
value set by scripts/packaging/{deb,rpm}.sh, so DEs group the
autostarted window with user-launched instances under a single
taskbar/dock item. Drop Categories= per review guidance
(autostart parsers ignore it).
- Comment why opts.path is intentionally ignored: process.execPath
points at the electron binary, not the launcher shim that sets
ELECTRON_FORCE_IS_PACKAGED / ozone flags / orphan cleanup —
honouring opts.path would write a broken autostart entry.
The "removed" log placement (review item 4) is already inside the
inner try, so unlinkSync throwing ENOENT short-circuits before the
log runs. Left as-is.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(readme): credit lizthegrey for XDG Autostart contribution
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: aaddrick <aaddrick@gmail.com>
* fix(lifecycle): hide main window to tray on close, Linux (#448)
Electron's default window-all-closed handler quits the app on
Linux. The existing tray icon and Ctrl+Q patches keep the app
reachable while a window is alive, but as soon as the last
window is closed (stray click on X, or a sign-out flow that
closes mainWindow) the app exits and the tray goes with it —
taking any in-app schedulers / MCP servers / cron tasks
(/schedule skill) down silently until the user re-launches.
Intercept BrowserWindow.close on main windows (not popups;
Quick Entry and About already dismiss via hide(), never emit
close) and preventDefault + hide unless app is in a real quit
path. The quit path is detected via before-quit: Ctrl+Q, tray
Quit, cmd+Q, SIGTERM and app.quit() from anywhere all emit
before-quit, which arms app._quittingIntentionally so the
close handler lets the window actually close.
Gated by CLOSE_TO_TRAY, default on. Set CLAUDE_QUIT_ON_CLOSE=1
to restore the Electron-default behaviour.
Fixes#448
Co-Authored-By: Claude <claude@anthropic.com>
* fix(frame-fix-wrapper): drop superseded globalShortcut Ctrl+Q
Removes the globalShortcut.register('CommandOrControl+Q') block
that #484 superseded with the per-window
webContents.on('before-input-event') listener. Auto-merging main
into this branch left both registrations in place, which would
re-introduce the AZERTY physical-keycode grab and system-wide
shortcut steal that #484 fixed. The focus-scoped listener
already covers the original #321 hidden-menu-bar use case.
Also updates the close-to-tray comment to reference the new
listener path instead of the removed global shortcut.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(readme): credit lizthegrey for close-to-tray contribution
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: aaddrick <aaddrick@gmail.com>
Replaces the globalShortcut registration in frame-fix-wrapper.js with a
per-window webContents 'before-input-event' handler. The previous global
grab stole Ctrl+Q from every app on the system and — on non-QWERTY
layouts — also swallowed whatever keysym sits at the physical "Q"
position (Ctrl+A on AZERTY be,us).
The new handler only fires when Claude has keyboard focus, so other apps
keep their Ctrl+Q. Menu-accelerator coverage for the hidden menu bar
case (original #321 motivation) is preserved because webContents
intercepts the key directly, independent of menu visibility.
Fixes: #399Fixes: #474
Co-authored-by: Claude <claude@anthropic.com>
Two changes for quit accessibility on Linux:
1. Fix Alt menu bar toggle in 'auto' mode (the default). The show
event handler and setApplicationMenu interceptor were force-hiding
the menu bar on every event, overriding autoHideMenuBar's native
Alt toggle. Now only 'hidden' mode force-hides; 'auto' lets
Electron handle the toggle natively.
2. Register Ctrl+Q as a global shortcut to quit. The upstream menu
has a CmdOrCtrl+Q accelerator but Electron doesn't fire menu
accelerators when the menu bar is hidden on Linux. The global
shortcut ensures Ctrl+Q always works, using the same API as
Ctrl+Alt+Space (works under XWayland).
Together these give GNOME and other DE users two ways to quit
without needing a tray icon: Alt → File → Quit, or Ctrl+Q.
Fixes#321
Co-Authored-By: Claude <claude@anthropic.com>
Extract duplicate blur/focus and hide/show armed-pair patterns into
a single armPair(armEvt, fireEvt) helper. Separate flashFrame(false)
into its own focus listener for clarity.
Co-Authored-By: Claude <claude@anthropic.com>
PR #331 added a resize event handler but didn't cover Hyprland workspace
switches where tile size is unchanged (no resize event fires, only
blur/focus). Add armed-pair detection for blur→focus and hide→show
transitions with a debounced 1px setSize jiggle that only fires when
fixChildBounds() finds no mismatch (stale compositor cache).
Safety measures:
- jiggling flag suppresses resize/moved cascade from setSize calls
- will-resize guard prevents jiggle during interactive drag resize
- 100ms debounce coalesces event storms (invariant: exceeds 50ms jiggle)
- On stacking WMs (KDE/GNOME), jiggle is imperceptible (content correct)
Co-Authored-By: Claude <claude@anthropic.com>
On tiling WMs (Hyprland, i3, sway), workspace switches emit 'resize'
events that change the window frame size. The upstream layout handler
uses getContentBounds() which returns stale cached values, setting
child views to wrong dimensions.
Add 'resize' to the events that trigger fixAfterStateChange(). Unlike
the old jiggle-based resize handler (removed in 8bf10dc for causing
drag-resize jitter), fixChildBounds() only calls setBounds on child
views when there's a genuine mismatch. During drag resize the cache
stays in sync, so the guard prevents unnecessary setBounds calls.
Fixes#323
Co-Authored-By: Claude <claude@anthropic.com>
Follow-up to PR #299:
- Log original env var value instead of lowercased in alias resolution
- Use single quotes for literal string per style guide
- Document yes/no aliases in CONFIGURATION.md table
- Update noctuum's contributor entry for boolean alias work
Co-Authored-By: Claude <claude@anthropic.com>
CLAUDE_MENU_BAR=0 was silently ignored after 07c1388 added strict
validation. Since CLAUDE_USE_WAYLAND=1 establishes a boolean env var
convention, users naturally try 0/1 for other vars too.
Add alias resolution: 0/false/no/off -> hidden, 1/true/yes/on -> visible.
Named values (auto/visible/hidden) continue to work as before.
Invalid values still fall back to auto with a warning.
Fixes#298
Co-Authored-By: Claude <claude@anthropic.com>
The app loads tray icons via app.getAppPath()/resources/, which resolves
inside the asar archive. Previously, tray icons were only copied alongside
the asar (in the Electron resources directory), so unpackaged builds
(like Nix, where isPackaged=false) couldn't find them.
Copy tray icon PNGs into app.asar.contents/resources/ before repacking
so both packaged and unpackaged code paths resolve correctly.
Also derive process.resourcesPath from the asar's actual location at
startup, fixing path resolution for app.asar.unpacked and other
resources in Nix builds where Electron is a separate store path.
Co-Authored-By: Claude <claude@anthropic.com>
Follow-up to #251: validate unrecognized CLAUDE_MENU_BAR values with a
warning and fallback to 'auto', document the env var in CONFIGURATION.md,
and report the setting in --doctor diagnostics.
Co-Authored-By: Claude <claude@anthropic.com>
Allow users to control menu bar behavior via CLAUDE_MENU_BAR:
- 'auto' (default): current behavior, hidden with Alt toggle
- 'visible': always visible, no Alt toggle, no layout shift
- 'hidden': always hidden, Alt does not activate menu bar
This addresses layout shift issues on KDE Plasma and other DEs
where accidental Alt presses cause the menu bar to appear and
displace window content.
Default behavior is unchanged — existing users are not affected.
Fixes#250
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The getContentBounds() monkey-patch caused drag resize jitter (~60Hz)
and missed KWin corner-snap (which emits 'moved', not 'maximize').
Replace with fixChildBounds() that directly sets children[0].setBounds()
only on discrete state changes (maximize/unmaximize/fullscreen/moved),
avoiding interference with normal drag-resize.
Fixes#239
Co-Authored-By: Claude <claude@anthropic.com>
Chromium's LayoutManagerBase cache is only invalidated via
OnWindowStateChanged() -> ScheduleRelayout() -> InvalidateLayout(),
which requires _NET_WM_STATE atom changes. KWin's corner-snap/quick-tile
never sets those atoms, so getContentBounds() returns stale dimensions
after tiling operations.
Patch getContentBounds() as an instance method to read from getSize()
directly. getSize() reads from Widget/platform window bounds updated by
X11 ConfigureNotify before JS events fire, so it always reflects the
real geometry. Under XWayland, frame overhead calibrates to 0x0 since
the WM draws the frame outside the app's window bounds.
Frame overhead is calibrated once at startup after the initial jiggle
settles, validated for sanity (content bounds > 0, overhead < 200px),
then cached. A dimension guard falls back to origGetContentBounds() if
getSize() returns stale data during a transition.
For maximize/unmaximize/fullscreen, re-emit resize twice (immediately
and after 16ms) so the layout handler runs with fresh getSize() data.
Fixes: #239
Co-Authored-By: Claude <claude@anthropic.com>
getContentBounds() returns stale data after frame:true is forced on
Linux, causing the WebContentsView to have wrong bounds after resize
and login/logout transitions.
Add a debounced post-resize jiggle (200ms) using real setSize() calls
to force Electron to recalculate content bounds. A jiggling guard flag
suppresses the resize events fired by the jiggle itself, and a
jigglePending flag ensures any resize that occurs during an active
jiggle is not dropped - instead it triggers one follow-up jiggle once
the current one completes.
Also consolidates all main-window-only logic (ready-to-show, KDE flash
fix) into the existing !popup block alongside the new resize handler.
Fixes: #84, #239
Co-Authored-By: Claude <claude@anthropic.com>
Major rework of frame-fix-wrapper.js to properly intercept BrowserWindow:
- Use Proxy to intercept electron module's read-only BrowserWindow getter
(property is non-configurable with a getter, direct assignment silently fails)
- Detect popups by titleBarStyle:'' without minWidth (Quick Entry, About)
vs main window which has minWidth:400
- Fix popup variable scoping (was const inside first if block, referenced
in second if block causing ReferenceError)
- Remove sed patches from build.sh that modified minified JS source
(frame:false→true, titleBarStyle normalization) — the wrapper now
handles all frame/titleBarStyle modifications at runtime
- Build patches once on first require('electron'), reuse via Proxy
Note: titleBarOverlay cannot be used as differentiator because it's set
to process.platform==="win32" which is false on Linux for all windows.
Co-Authored-By: Claude <claude@anthropic.com>
The app uses titleBarStyle:"" (empty string, not "hidden") for popup
windows. The main window also uses titleBarStyle:"" but pairs it with
titleBarOverlay. Detect popups as titleBarStyle:"" WITHOUT
titleBarOverlay, which matches Quick Entry and About windows while
excluding the main window.
Co-Authored-By: Claude <claude@anthropic.com>
The Quick Entry popup window uses titleBarStyle:'hidden' (macOS style)
rather than frame:false, so the popup detection was missing it. This
caused the window to get frame:true forced on it, showing an unwanted
titlebar and menu bar.
Expand isPopupWindow() to also check for titleBarStyle:'hidden' as a
frameless intent signal.
Co-Authored-By: Claude <claude@anthropic.com>
Add isDestroyed() check in the setApplicationMenu interceptor to skip
destroyed windows when hiding menu bars, matching the defensive pattern
used elsewhere in the codebase.
Add TODO comment on getWindow() popup fallback limitation to flag that
callers like getIsMaximized() may behave unexpectedly when a popup is
returned instead of the main window.
Co-Authored-By: Claude <claude@anthropic.com>
- Drop isVisible() filter from getWindow() fallback so flashFrame()
works on minimized windows, which is its primary use case
- Add isDestroyed() guard to setTimeout resize callback to prevent
errors if the window is closed within the 50ms delay
Co-Authored-By: Claude <claude@anthropic.com>
- Trim verbose comments to be concise while preserving references
- Shorten log message to fit 80-char line width
- Remove intermediate `compositor` variable in launcher-common.sh
since Niri is the only auto-detected compositor
- Combine local declaration with assignment
Co-Authored-By: Claude <claude@anthropic.com>
The comment said "improved rendering" but the CSS only styles scrollbars
after removing the no-op properties in a prior commit.
Co-Authored-By: Claude <claude@anthropic.com>
Add comments linking frame-fix-wrapper.js and claude-native-stub.js
for the flashFrame feature: the stub provides the API that the app
calls, while the wrapper auto-clears the attention state on focus.
This helps future maintainers understand the two-file relationship.
Addresses #231 item 9.
Co-Authored-By: Claude <claude@anthropic.com>
Remove -webkit-font-smoothing and text-rendering properties that have
no effect in Linux Chromium/Electron. -webkit-font-smoothing is macOS
WebKit only, and text-rendering: optimizeLegibility can slow rendering
on long conversations. Scrollbar styling is preserved.
Addresses item 8 from #228 review findings.
Co-Authored-By: Claude <claude@anthropic.com>
Merge duplicate ready-to-show handlers (on + once) into a single once()
since the event only fires once per window lifecycle (#231 item 6).
Guard resize hack (#84) and flashFrame clearing (#149) with !popup check
so they only apply to main windows, avoiding flicker and unnecessary
event listeners on transient popup windows (#231 item 5).
Co-Authored-By: Claude <claude@anthropic.com>
Wayland compositors process window operations asynchronously and may
coalesce two rapid successive resizes into one. Replace setImmediate
with setTimeout(fn, 50) to give the compositor time to process the
first setSize before restoring the original dimensions.
Refs: #231 item 2, PR #228 review
Co-Authored-By: Claude <claude@anthropic.com>
Replace fragile pixel-dimension heuristics (width < 600 || height < 400)
with the app's own frame:false intent to identify popup windows. This is
more robust across display scaling, HiDPI, and future dialog sizes.
Remove skipTaskbar=true which was never set by the original app and
breaks Alt+Tab visibility on Xfce and tiling window managers.
Addresses #231 items 1 and 4.
Co-Authored-By: Claude <claude@anthropic.com>
- frame-fix-wrapper.js: Add popup/Quick Entry window detection (#223),
CSS injection for scrollbar styling and font rendering, persistent
menu bar hiding (#172), KDE attention flash fix (#149), and content
resize fix (#84)
- claude-native-stub.js: Make getIsMaximized, flashFrame, setProgressBar
functional using Electron's native Linux support instead of no-ops
- launcher-common.sh: Auto-detect Niri/Sway/Hyprland compositors and
force native Wayland mode (#226), safe variable expansion with ${VAR:-}
Fixes#84, #149, #172, #223, #226
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract frame-fix-wrapper.js to scripts/frame-fix-wrapper.js
- Extract claude-native stub to scripts/claude-native-stub.js
- Remove duplicate claude-native stub (was defined twice)
- Reduces build.sh by ~145 lines
Part of #179
Co-Authored-By: Claude <claude@anthropic.com>