Files
claude-desktop-debian/docs/learnings/linux-topbar-shim.md
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

368 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Linux desktop topbar — design and history
How claude.ai's in-app topbar (hamburger / sidebar / search / nav /
Cowork ghost) is wired up on Linux, why the upstream frameless-WCO
config doesn't work on X11, and how the **hybrid mode** (system
frame + in-app topbar shim) lands functional buttons at the cost
of a stacked-bar layout.
## Status
**Resolved 2026-04-29 via hybrid mode.** Default
`CLAUDE_TITLEBAR_STYLE` is `hybrid`: native OS frame plus the
wco-shim that convinces claude.ai's bundle to render its in-app
topbar. Topbar buttons are clickable. The trade-off vs Windows is
a stacked layout (DE-drawn titlebar on top, in-app topbar below)
instead of Windows's combined single bar.
![Hybrid mode on KDE Plasma — DE-drawn "Claude" titlebar on top, claude.ai's in-app topbar (hamburger / search / back-forward) directly below it](images/linux-topbar-hybrid.png)
Modes:
| mode | frame | shim | layout | notes |
|---|---|---|---|---|
| `hybrid` (default) | system | active | stacked: OS bar + in-app bar | clickable ✓ |
| `native` | system | inactive | OS bar only | no in-app topbar |
| `hidden` | frameless | active | Windows-style single bar | **clicks broken on X11** — kept for Wayland / future investigation |
## How the topbar gets to render
The topbar is **not bundled in `app.asar`**. claude.ai's web app
inside the BrowserView renders it. Rendering is gated by an
independent stack — each gate must pass.
### Gate 1: server-delivered markup
Every request to claude.ai/claude.com from the desktop shell
carries unconditional headers set in `index.js:504876-504907`:
- `anthropic-desktop-topbar: 1`
- `anthropic-client-platform: desktop_app`
- `anthropic-client-os-platform: <process.platform>` (literal `linux`)
The topbar markup *is* delivered to Linux clients — this gate
isn't load-bearing for our scenario.
### Gate 2: Electron-shell boot features
`index.js` builds a feature-flag object via `J0()` (line 301965)
and passes it to the BrowserView via
`webPreferences.additionalArguments=['--desktop-features=<JSON>']`.
`mainView.js` parses the arg and exposes the parsed object via
`contextBridge` as `window.desktopBootFeatures`. The relevant key
`desktopTopBar.status` is `"supported"` on Linux, so this gate
also isn't load-bearing.
### Gate 3: the `isWindows()` user-agent check
**Load-bearing.** The React bundle
(`https://assets-proxy.anthropic.com/.../index-*.js`) contains:
```js
const HV = /(win32|win64|windows|wince)/i;
function WV() {
if (typeof window === "undefined") return false;
// ... HV.test(window.navigator.userAgent)
}
```
This function and a sibling gate the topbar JSX. Linux's UA
contains `X11; Linux x86_64`, fails the regex, and React skips
rendering the entire `<div class="draggable absolute top-0 ...">`
topbar tree (note the `topbar-windows-menu` test ID — upstream
treats this as Windows-specific).
The shim's `navigator.userAgent` override appends `" Windows"`
page-side so the regex passes. HTTP request UA is unchanged so
analytics, anti-bot fingerprints, and the
`anthropic-client-os-platform` header stay honest.
### Gate 4: `-webkit-app-region: drag` on the topbar parent
On Linux X11 with frameless windows, this is what kills clicks in
hidden mode. The topbar's `<div class="draggable absolute top-0
inset-x-0">` would normally trigger the CSS rule
`.draggable { -webkit-app-region: drag }`. On Windows, Chromium
hit-tests per pixel and child `app-region: no-drag` regions are
clickable; on Linux X11, Chromium pushes a drag-region map to the
WM as a region for `_NET_WM_MOVERESIZE` and the WM intercepts
mouse events before the page sees them. Critically: that map is
**sticky** — not refreshable from CSS, DOM mutations, setSize
jiggles, or hide/show cycles after first paint.
In hybrid mode (frame:true) this isn't an issue. The OS handles
window dragging via the native titlebar; Chromium doesn't push a
drag-region map for framed windows. The shim's className intercept
strips `'draggable'` from any DOM class assignment as
belt-and-suspenders against the `.draggable` rule producing
surprise click-eaten regions inside the page.
## The shim: what each part does
Inlined into mainView.js by `patch_wco_shim`. Skipped in `native`
mode; active in `hybrid` (default) and `hidden`.
| component | role | load-bearing? |
|---|---|---|
| Native-state probes | Capture Chromium's WCO state for launcher.log diagnostics. Phase 1 syncs non-DOM values; Phase 2 reads `env(titlebar-area-*)` via custom-property indirection on DOMContentLoaded. Bypassed by `CLAUDE_WCO_NATIVE=1`. | No (diagnostic) |
| `navigator.windowControlsOverlay` shim | Returns `visible: true` and synthesized rect. | No (defensive — bundle grep shows no current use) |
| `matchMedia` shim | Returns `matches: true` for `(display-mode: window-controls-overlay)` queries. | No (defensive — same) |
| **`navigator.userAgent` shim** | Appends `" Windows"` so Gate 3 passes. | **Yes** |
| className intercept | Strips `'draggable'` from any class assignment via `Element.prototype.className`, `setAttribute`, `DOMTokenList.prototype.add` overrides. Three vectors covered. | Defensive (belt-and-suspenders) |
| Event nudge | Dispatches `geometrychange` + `resize` to wake any framework that rendered before the shim arrived. | No (defensive) |
## Investigation chain — why hybrid
Two phases. Phase 1: render the topbar at all. Phase 2: figure
out why the buttons don't fire mouse events. Phase 2 went through
several false hypotheses before landing on hybrid.
### Phase 1: render-the-topbar
Original assumption was WCO `@media` gating. Several wasted
attempts at activating WCO at the page level
(`titleBarStyle:hidden` + `titleBarOverlay`; explicit object form;
`--enable-features=WindowControlsOverlay`; native Wayland) all
failed at the time, leading to the empirical conclusion that
"Linux Electron doesn't activate WCO." Bundle probing eventually
surfaced **Gate 3** (the UA regex). UA spoof made the topbar
render. The other shims stayed in as defensive forward-compat.
### Phase 2: clicks-don't-fire
Six escape attempts at defeating the X11 drag-region map all
failed:
1. CSS override of `.draggable` to `no-drag !important` — computed
style flipped, clicks still broken
2. `MutationObserver` stripping the class on attach — DOM correct,
clicks broken
3. IPC-triggered `setSize` jiggle — no effect
4. `setSize` + hide/show cycle — no effect
5. JS-side `programmaticClickFired: true` confirmed — handlers
wire correctly, problem is purely OS/WM-level
6. Preemptive global `.draggable { no-drag !important }` from
preload — no effect
All six targeted the `.draggable` class as the source. The 7th
attempt — a JS-DOM API intercept stripping `'draggable'` from any
class assignment via `Element.prototype` overrides — also failed,
even though probes confirmed *zero* elements ended up with the
class. The drag region wasn't coming from `.draggable` at all.
### Narrowing the source
With no element having computed `app-region: drag` yet clicks
still broken, the source had to be at the Electron/Chromium
config layer. Three diagnostic experiments narrowed it:
| experiment | result |
|---|---|
| `CLAUDE_TBO_HEIGHT=off` (omit `titleBarOverlay`) | clicks still broken |
| `CLAUDE_TBS_DISABLE=1` (also omit `titleBarStyle:'hidden'`) | clicks still broken |
| `frame: true` (hybrid mode) | **clicks work** |
So the source is **`frame: false` itself**, not anything we can
configure at the Electron API level. Chromium-Linux-X11 has a
hardcoded behavior that creates an implicit drag region for the
top of `frame: false` windows. The fix is to not be frameless.
Hybrid trades a stacked layout for clickability.
## Outstanding upstream bugs
Two unrelated Linux-X11 / Electron 41 / Chromium 146 issues
surfaced during the investigation. Worth filing if someone has
time. Bug A is the most actionable.
### Bug A: WCO `@media` query doesn't match where WCO is otherwise active
In the **main window** webContents of a `frame:false +
titleBarStyle:'hidden' + titleBarOverlay:{...}` BrowserWindow,
runtime probe 2026-04-29:
| signal | value |
|---|---|
| `navigator.windowControlsOverlay.visible` | true |
| `windowControlsOverlay.getTitlebarAreaRect()` | 1131×40 (matches config) |
| `env(titlebar-area-width)` (via custom-property indirection) | 1131px (matches) |
| `matchMedia('(display-mode: window-controls-overlay)').matches` | **false** ✗ |
Three of four WCO entry points agree; only the documented `@media`
detection point is broken.
Minimal repro after `did-finish-load`:
```js
const wco = navigator.windowControlsOverlay;
const r = wco.getTitlebarAreaRect();
const s = document.createElement('style');
s.textContent = ':root { --w: env(titlebar-area-width) }';
document.head.appendChild(s);
({
visible: wco.visible, // true
rect: { width: r.width, height: r.height }, // populated
cssEnvWidth: getComputedStyle(document.documentElement)
.getPropertyValue('--w'), // populated
mediaQueryMatches:
matchMedia('(display-mode: window-controls-overlay)').matches, // false
});
```
### Bug B: WCO state doesn't propagate to BrowserView webContents
Same parent BrowserWindow, probing the BrowserView instead:
| signal | value |
|---|---|
| `navigator.windowControlsOverlay.visible` | false |
| `getTitlebarAreaRect()` | 0×0 |
| `env(titlebar-area-width)` | empty |
| `matchMedia('(display-mode: window-controls-overlay)').matches` | false |
The BrowserView sees nothing. May be intentional isolation (each
webContents independent) — could be working-as-designed and not
worth filing. Means any WCO-aware page hosted in a BrowserView
never sees WCO regardless of parent config.
### Bug C: implicit drag region for `frame:false` Linux windows
The root cause of the hidden-mode click problem. Investigation
ruled out `.draggable`, `titleBarOverlay`, and `titleBarStyle` as
the source — what remains is some hardcoded behavior in
Chromium's ozone backend that creates a non-overridable drag
region for the top of frameless windows. **Confirmed present on
both X11 and Wayland (2026-04-29):** running
`CLAUDE_USE_WAYLAND=1 CLAUDE_TITLEBAR_STYLE=hidden` produces the
same unclickable topbar as X11, ruling out a Wayland-only
shipping path. Characterizing this as a filable bug would
require source-level inspection of `ui/ozone/platform/{x11,wayland}/`.
The combined impact of A + B + C is that WCO is effectively
unusable on Linux today.
## Future directions
- **Wayland-only shipping (ruled out 2026-04-29).** Wayland WCO
landed in Electron 38.2 / 41 with apparently fuller support
([Electron Wayland tech talk](https://www.electronjs.org/blog/tech-talk-wayland)),
raising the possibility that hidden mode might work on native
Wayland even though X11 is broken. Tested with
`CLAUDE_USE_WAYLAND=1 CLAUDE_TITLEBAR_STYLE=hidden`: topbar
clicks are still unresponsive. The implicit drag region (Bug C)
exists on both backends. Hybrid is the answer everywhere.
- **Bundle rewriting via `session.protocol.handle()`** — was the
proposed last-resort path before hybrid worked. Would intercept
claude.ai's React bundle and regex-replace `class="draggable
absolute top-0` to remove the `draggable` token before Chromium
parses it. Now obsolete given hybrid; documented for posterity.
## Files
- `scripts/wco-shim.js` — shim source
- `scripts/patches/wco-shim.sh` — inlines shim into mainView.js
- `scripts/frame-fix-wrapper.js` — main-process BrowserWindow
patching, mode resolution, diagnostic probes
- `scripts/launcher-common.sh` — Chromium feature flags per mode
- `scripts/doctor.sh``--doctor` reports the resolved titlebar
style (`PASS` for `hybrid`/`native`, `WARN` for `hidden` with a
pointer to the working modes, `WARN` + valid-value hint for
unrecognized values)
- `tests/launcher-common.bats` — covers `_resolve_titlebar_style`
(default + each mode + case-insensitivity + invalid fallback),
`build_electron_args` flag selection per mode, and
`setup_electron_env` `ELECTRON_USE_SYSTEM_TITLE_BAR` wiring per
mode. Shim runtime behavior (className intercept, UA spoof) is
not unit-tested — verified empirically via the click test in
this doc
- `docs/CONFIGURATION.md` — user-facing env-var docs
## Diagnostic recipes
### Bundle probe — re-discover gates if claude.ai changes the bundle
```js
(async () => {
const reactBundle = [...document.scripts]
.map(s => s.src).filter(Boolean)
.find(s => /index-[A-Za-z0-9]+\.js/.test(s));
const text = await (await fetch(reactBundle)).text();
const ctx = (term, len = 200) => {
const i = text.indexOf(term);
return i < 0 ? null : text.slice(Math.max(0, i - len), i + term.length + len);
};
return {
bundleSize: text.length,
ctx_topbar_windows: ctx('topbar-windows'),
ctx_isWindows_regex: ctx('win32|win64'),
ctx_desktopTopBar: ctx('desktopTopBar'),
ctx_windowControlsOverlay: ctx('windowControlsOverlay'),
};
})();
```
Inspect the regex pattern, gate variable names, and any new
condition strings. The shim probably needs an update if any of
those move.
### Drag-region search
Should return `[]` in hybrid mode (className intercept strips the
class). If it returns elements, the intercept missed a vector
(e.g. `dangerouslySetInnerHTML`, parser-set classes) — investigate
where the class came from.
```js
[...document.querySelectorAll('*')].filter(el =>
getComputedStyle(el).webkitAppRegion === 'drag'
).map(el => ({
tag: el.tagName,
cls: (el.className || '').toString().slice(0, 100),
rect: el.getBoundingClientRect().toJSON(),
}));
```
### Click-state diagnostic
Confirms a click problem is OS-level rather than CSS or JS:
```js
const hamburger = document.querySelector('[data-testid="topbar-windows-menu"]');
const topbar = document.querySelector('div.absolute.top-0.inset-x-0');
const ts = getComputedStyle(topbar);
const hs = getComputedStyle(hamburger);
let clickFired = false;
hamburger.addEventListener('click', () => { clickFired = true; }, { once: true });
hamburger.click();
const r = hamburger.getBoundingClientRect();
const elemAtCenter = document.elementFromPoint(r.x + r.width/2, r.y + r.height/2);
({
topbarAppRegion: ts.webkitAppRegion,
hamburgerAppRegion: hs.webkitAppRegion,
topbarPointerEvents: ts.pointerEvents,
hamburgerPointerEvents: hs.pointerEvents,
programmaticClickFired: clickFired,
hitIsHamburgerOrDescendant: hamburger.contains(elemAtCenter),
});
```
When this looks correct (`no-drag`, `auto`, `true`, `true`) but
real mouse clicks don't fire, the click is being intercepted at
the WM level — same failure mode as the hidden-mode investigation.
### Pitfalls (don't repeat)
- DOM probes that search `[class*="topbar" i]` or
`header[role="banner"]` won't find the topbar. It identifies
via `data-testid="topbar-windows-menu"` and uses
`class="draggable absolute top-0 ..."`. Search by
`data-testid` first.
- A relative `require('./wco-shim.js')` from the sandboxed
preload **aborts the entire preload** because sandboxed
preloads can only require an allowlist (`electron`,
`ipcRenderer`, `contextBridge`, `webFrame`, ...). The shim
must be inlined into mainView.js, not pulled in via require.
- `webFrame.executeJavaScript` may fire before
`document.documentElement` exists. Probe code that calls
`getComputedStyle(document.documentElement)` immediately
throws "parameter 1 is not of type 'Element'". Defer to
`DOMContentLoaded` if needed.