Commit Graph

32 Commits

Author SHA1 Message Date
Aaddrick
5c8191e82f feat(linux): hybrid titlebar mode for clickable in-app topbar (#538)
* 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>
2026-05-01 02:47:16 -04:00
Liz Fong-Jones
412b267710 fix(autostart): route openAtLogin through XDG Autostart on Linux (#450)
* 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>
2026-04-28 19:02:47 -04:00
Liz Fong-Jones
8530342b2e fix(lifecycle): hide main window to tray on close, Linux (#451)
* 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>
2026-04-28 18:16:56 -04:00
Aaddrick
4e2b9d7256 fix(shortcut): scope Ctrl+Q to focused window, not system-wide (#484)
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: #399
Fixes: #474

Co-authored-by: Claude <claude@anthropic.com>
2026-04-21 17:51:40 -04:00
aaddrick
c429cfb3d0 fix: enable Alt menu toggle and Ctrl+Q quit on Linux
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>
2026-04-01 07:04:45 -04:00
aaddrick
f62b5531a6 refactor: consolidate armed-pair handlers into reusable helper
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>
2026-03-22 21:49:02 -04:00
aaddrick
062f460441 fix: debounced jiggle for same-size tiling WM workspace switches (#323)
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>
2026-03-22 21:47:13 -04:00
aaddrick
209ccee440 style: condense resize event comment to essential details
Co-Authored-By: Claude <claude@anthropic.com>
2026-03-22 09:51:36 -04:00
aaddrick
2cfc6a8ef9 fix: handle resize events for tiling WM workspace switches (#323)
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>
2026-03-22 09:44:29 -04:00
aaddrick
9b4ac63323 fix: clean up CLAUDE_MENU_BAR boolean alias implementation
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>
2026-03-19 07:43:20 -04:00
noctuum
e1dbbc2c07 fix: accept boolean aliases for CLAUDE_MENU_BAR env var
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>
2026-03-19 15:13:10 +07:00
Alexis Williams
573f052391 fix: bundle tray icons inside asar and correct resourcesPath for Nix
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>
2026-02-28 15:14:34 -08:00
aaddrick
07c1388a6b fix: add CLAUDE_MENU_BAR input validation, docs, and --doctor integration
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>
2026-02-28 16:10:18 -05:00
noctuum
0b56a2f11c feat: add CLAUDE_MENU_BAR env var for menu bar visibility control
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>
2026-02-23 05:41:24 +07:00
aaddrick
7ce3d88add style: simplify guard and event listener registration
Co-Authored-By: Claude <claude@anthropic.com>
2026-02-19 13:30:55 -05:00
aaddrick
8bf10dc889 fix: replace getContentBounds() patch with direct child setBounds()
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>
2026-02-19 13:29:48 -05:00
aaddrick
7adbdae063 fix: patch getContentBounds() to bypass stale Chromium layout cache
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>
2026-02-18 00:06:03 -05:00
aaddrick
82efbfaa22 fix: debounced resize jiggle for WebContentsView layout sync
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>
2026-02-17 20:43:37 -05:00
aaddrick
0f776a1cdb fix: use Proxy-based BrowserWindow interception for popup detection
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>
2026-02-16 13:52:35 -05:00
aaddrick
d97f643ca1 fix: detect Quick Entry/About windows by titleBarStyle:'' without overlay
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>
2026-02-16 12:11:46 -05:00
aaddrick
99a7117bb9 fix: detect Quick Entry window by titleBarStyle:'hidden'
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>
2026-02-16 12:01:54 -05:00
aaddrick
0fc8286943 fix: add isDestroyed guard in setApplicationMenu and TODO for getWindow fallback
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>
2026-02-16 11:38:04 -05:00
aaddrick
75841f0813 fix: address code review feedback for PR #232
- 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>
2026-02-16 11:30:26 -05:00
aaddrick
b544a7bb6c style: simplify comments and eliminate dead variable
- 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>
2026-02-16 11:25:24 -05:00
aaddrick
d6ac99c25d fix: clarify CSS injection comment to reflect actual purpose
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>
2026-02-16 11:23:00 -05:00
aaddrick
9410db246e docs: add cross-reference comments for flashFrame interaction
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>
2026-02-16 10:12:53 -05:00
aaddrick
7c68b99f65 fix: remove no-op CSS properties from injected styles
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>
2026-02-16 10:04:34 -05:00
aaddrick
57850768a9 fix: consolidate ready-to-show handlers and guard popup-unsafe patches
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>
2026-02-16 09:59:58 -05:00
aaddrick
f723de41ae fix: use setTimeout(50ms) instead of setImmediate for resize hack
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>
2026-02-16 09:56:25 -05:00
aaddrick
befc7572fd fix: use frame:false intent for popup detection, remove skipTaskbar
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>
2026-02-16 09:52:48 -05:00
vboi
83cbb9a022 fix: improve Linux UX with popup detection, functional stubs, and Wayland compositor support
- 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>
2026-02-14 20:30:22 +01:00
aaddrick
4bf59866a3 refactor: extract embedded JS files from build.sh
- 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>
2026-01-22 16:28:56 -05:00