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>
This commit is contained in:
Aaddrick
2026-05-01 02:47:16 -04:00
committed by GitHub
parent c973f4922b
commit 5c8191e82f
15 changed files with 1053 additions and 60 deletions

View File

@@ -101,7 +101,7 @@ claude-desktop-debian/
│ ├── patches/ # sed/regex patches on minified JS (per-subsystem) │ ├── patches/ # sed/regex patches on minified JS (per-subsystem)
│ │ ├── _common.sh # extract_electron_variable, fix_native_theme_references │ │ ├── _common.sh # extract_electron_variable, fix_native_theme_references
│ │ ├── app-asar.sh # Asar repack, frame-fix wrapper injection │ │ ├── app-asar.sh # Asar repack, frame-fix wrapper injection
│ │ ├── titlebar.sh │ │ ├── wco-shim.sh # Inlines WCO/UA shim into mainView.js preload
│ │ ├── tray.sh # Tray menu handler + icon selection │ │ ├── tray.sh # Tray menu handler + icon selection
│ │ ├── quick-window.sh │ │ ├── quick-window.sh
│ │ ├── claude-code.sh │ │ ├── claude-code.sh
@@ -121,7 +121,7 @@ claude-desktop-debian/
| Function | File | Purpose | | Function | File | Purpose |
|----------|------|---------| |----------|------|---------|
| `patch_app_asar()` | `scripts/patches/app-asar.sh` | Extracts asar, injects frame-fix wrapper, repacks | | `patch_app_asar()` | `scripts/patches/app-asar.sh` | Extracts asar, injects frame-fix wrapper, repacks |
| `patch_titlebar_detection()` | `scripts/patches/titlebar.sh` | Removes `!` from `if(!isWindows && isMainWindow)` to enable titlebar | | `patch_wco_shim()` | `scripts/patches/wco-shim.sh` | Inlines `scripts/wco-shim.js` at the top of `mainView.js` (the BrowserView preload) so claude.ai's bundle sees Windows-like UA + matchMedia and renders the in-app topbar on Linux |
| `extract_electron_variable()` | `scripts/patches/_common.sh` | Finds the minified variable name for `require("electron")` | | `extract_electron_variable()` | `scripts/patches/_common.sh` | Finds the minified variable name for `require("electron")` |
| `fix_native_theme_references()` | `scripts/patches/_common.sh` | Fixes wrong `*.nativeTheme` references to use the correct electron var | | `fix_native_theme_references()` | `scripts/patches/_common.sh` | Fixes wrong `*.nativeTheme` references to use the correct electron var |
| `patch_tray_menu_handler()` | `scripts/patches/tray.sh` | Makes tray rebuild async, adds mutex guard, DBus cleanup delay, startup skip | | `patch_tray_menu_handler()` | `scripts/patches/tray.sh` | Makes tray rebuild async, adds mutex guard, DBus cleanup delay, startup skip |

View File

@@ -48,7 +48,7 @@ Use this when you're not confident enough to triage automatically. Examples: sec
## INVESTIGATION RULES ## INVESTIGATION RULES
### All bugs are ours to fix ### All bugs are ours to fix
This project's goal is to take a working Anthropic product and make it work on Linux. Every bug is something we can investigate and potentially patch. Check `scripts/patches/*.sh` first for bugs in patched areas (`cowork.sh`, `tray.sh`, `app-asar.sh`, `titlebar.sh`, `quick-window.sh`, `claude-code.sh`). Read the relevant `patch_` function and trace what it modifies. If a behavior difference exists between the Windows/macOS app and our Linux build, that's a gap in our patching, not someone else's problem. This project's goal is to take a working Anthropic product and make it work on Linux. Every bug is something we can investigate and potentially patch. Check `scripts/patches/*.sh` first for bugs in patched areas (`cowork.sh`, `tray.sh`, `app-asar.sh`, `wco-shim.sh`, `quick-window.sh`, `claude-code.sh`). Read the relevant `patch_` function and trace what it modifies. If a behavior difference exists between the Windows/macOS app and our Linux build, that's a gap in our patching, not someone else's problem.
### Verify before stating ### Verify before stating
Only state facts you verified by reading actual code or running commands. Never claim code exists, functions behave a certain way, or patterns match without finding them in the source. If you cannot find evidence, say so explicitly rather than speculating. Only state facts you verified by reading actual code or running commands. Never claim code exists, functions behave a certain way, or patterns match without finding them in the source. If you cannot find evidence, say so explicitly rather than speculating.
@@ -80,7 +80,7 @@ When investigating bugs, search these files based on the issue category:
| Category | Files to check | | Category | Files to check |
|----------|---------------| |----------|---------------|
| Build failures | `build.sh` (orchestrator), `scripts/setup/`, `.github/workflows/ci.yml`, `build-amd64.yml`, `build-arm64.yml` | | Build failures | `build.sh` (orchestrator), `scripts/setup/`, `.github/workflows/ci.yml`, `build-amd64.yml`, `build-arm64.yml` |
| Window/frame issues | `scripts/frame-fix-wrapper.js`, `scripts/patches/titlebar.sh`, `scripts/patches/app-asar.sh`, reference source for `BrowserWindow` | | Window/frame issues | `scripts/frame-fix-wrapper.js`, `scripts/wco-shim.js`, `scripts/patches/wco-shim.sh`, `scripts/patches/app-asar.sh`, reference source for `BrowserWindow` |
| Tray icon issues | `scripts/patches/tray.sh`, reference source for `Tray`, `StatusNotifier` | | Tray icon issues | `scripts/patches/tray.sh`, reference source for `Tray`, `StatusNotifier` |
| Packaging (deb) | `scripts/packaging/deb.sh`, `scripts/launcher-common.sh` | | Packaging (deb) | `scripts/packaging/deb.sh`, `scripts/launcher-common.sh` |
| Packaging (rpm) | `scripts/packaging/rpm.sh`, `scripts/launcher-common.sh` | | Packaging (rpm) | `scripts/packaging/rpm.sh`, `scripts/launcher-common.sh` |

View File

@@ -14,6 +14,7 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook - [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race - [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround - [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
## Code Style ## Code Style

View File

@@ -52,8 +52,6 @@ source "$script_dir/scripts/setup/download.sh"
source "$script_dir/scripts/patches/_common.sh" source "$script_dir/scripts/patches/_common.sh"
# shellcheck source=scripts/patches/app-asar.sh # shellcheck source=scripts/patches/app-asar.sh
source "$script_dir/scripts/patches/app-asar.sh" source "$script_dir/scripts/patches/app-asar.sh"
# shellcheck source=scripts/patches/titlebar.sh
source "$script_dir/scripts/patches/titlebar.sh"
# shellcheck source=scripts/patches/tray.sh # shellcheck source=scripts/patches/tray.sh
source "$script_dir/scripts/patches/tray.sh" source "$script_dir/scripts/patches/tray.sh"
# shellcheck source=scripts/patches/quick-window.sh # shellcheck source=scripts/patches/quick-window.sh
@@ -62,6 +60,8 @@ source "$script_dir/scripts/patches/quick-window.sh"
source "$script_dir/scripts/patches/claude-code.sh" source "$script_dir/scripts/patches/claude-code.sh"
# shellcheck source=scripts/patches/cowork.sh # shellcheck source=scripts/patches/cowork.sh
source "$script_dir/scripts/patches/cowork.sh" source "$script_dir/scripts/patches/cowork.sh"
# shellcheck source=scripts/patches/wco-shim.sh
source "$script_dir/scripts/patches/wco-shim.sh"
# shellcheck source=scripts/staging/electron.sh # shellcheck source=scripts/staging/electron.sh
source "$script_dir/scripts/staging/electron.sh" source "$script_dir/scripts/staging/electron.sh"
# shellcheck source=scripts/staging/icons.sh # shellcheck source=scripts/staging/icons.sh

View File

@@ -15,6 +15,7 @@ Model Context Protocol settings are stored in:
|----------|---------|-------------| |----------|---------|-------------|
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. | | `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. | | `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
| `CLAUDE_TITLEBAR_STYLE` | unset (`hybrid`) | Controls window decoration style: `hybrid` (system frame + in-app topbar), `native` (system frame, no in-app topbar), `hidden` (frameless WCO — broken on X11, kept for diagnostics). See [Titlebar Style](#titlebar-style) below. |
| `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. | | `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. |
### Wayland Support ### Wayland Support
@@ -49,6 +50,28 @@ CLAUDE_MENU_BAR=visible claude-desktop
export CLAUDE_MENU_BAR=visible export CLAUDE_MENU_BAR=visible
``` ```
### Titlebar Style
Claude Desktop's web UI includes a custom topbar (hamburger menu, sidebar toggle, search, back/forward, Cowork ghost). On Windows / macOS the bundle gates rendering on `display-mode: window-controls-overlay`; on Linux a shim convinces the bundle to render anyway. Use `CLAUDE_TITLEBAR_STYLE` to choose the layout:
| Value | Frame | In-app topbar | Window controls drawn by | Notes |
|-------|-------|--------------|--------------------------|-------|
| unset / `hybrid` | system | Yes | Desktop environment | **Default.** Stacked layout — DE-drawn titlebar on top, in-app topbar below. Topbar buttons clickable. |
| `native` | system | No | Desktop environment | When the stacked layout looks wrong on your DE, or you don't need the in-app topbar. |
| `hidden` | frameless | Yes | Chromium (WCO region) | Matches Windows / macOS upstream config. **Broken on Linux X11** — topbar buttons unresponsive due to a Chromium-level implicit drag region for `frame:false` windows. Kept for diagnostic / Wayland investigation; see [docs/learnings/linux-topbar-shim.md](learnings/linux-topbar-shim.md). |
```bash
# Switch to the bare native experience (no in-app topbar)
CLAUDE_TITLEBAR_STYLE=native claude-desktop
# Or add to your environment permanently
export CLAUDE_TITLEBAR_STYLE=native
```
This setting applies to the main window only. The Quick Entry and About windows are always frameless.
Run `claude-desktop --doctor` to confirm the resolved titlebar style. The doctor output also flags `hidden` mode as broken on Linux and unrecognized values as fallbacks to `hybrid`.
## Cowork Backend ## Cowork Backend
Cowork mode auto-detects the best available isolation backend: Cowork mode auto-detects the best available isolation backend:

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -0,0 +1,367 @@
# 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.

View File

@@ -406,6 +406,29 @@ run_doctor() {
_info 'Menu bar mode: auto (default, Alt toggles visibility)' _info 'Menu bar mode: auto (default, Alt toggles visibility)'
fi fi
# -- Titlebar style --
local titlebar_style="${CLAUDE_TITLEBAR_STYLE:-}"
if [[ -n $titlebar_style ]]; then
local resolved_style="${titlebar_style,,}"
case "$resolved_style" in
hybrid|native)
_pass "Titlebar style: $resolved_style" \
"(CLAUDE_TITLEBAR_STYLE=$titlebar_style)"
;;
hidden)
_warn "Titlebar style: hidden — topbar clicks unresponsive on Linux (both X11 and Wayland)"
_info 'Use hybrid (default) or native for clickable buttons'
;;
*)
_warn "Unknown CLAUDE_TITLEBAR_STYLE: '$titlebar_style'"
_info 'Will fall back to hybrid'
_info 'Valid values: hybrid, native, hidden'
;;
esac
else
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
fi
# -- Electron binary -- # -- Electron binary --
# Version is read from the file next to the binary rather than # Version is read from the file next to the binary rather than
# launching Electron, which can hang (see #371). # launching Electron, which can hang (see #371).

View File

@@ -38,6 +38,37 @@ if (resolvedMode !== rawMenuBarMode) {
} }
console.log(`[Frame Fix] Menu bar mode: ${MENU_BAR_MODE}`); 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), // Keep the app alive when the main window is closed (hide to tray),
// so in-app schedulers / MCP servers / the tray icon survive a // so in-app schedulers / MCP servers / the tray icon survive a
// stray click on X. Explicit quit paths (Ctrl+Q via the focused // stray click on X. Explicit quit paths (Ctrl+Q via the focused
@@ -118,17 +149,67 @@ Module.prototype.require = function(id) {
delete options.titleBarStyle; delete options.titleBarStyle;
delete options.titleBarOverlay; delete options.titleBarOverlay;
console.log('[Frame Fix] Popup detected, keeping frameless'); console.log('[Frame Fix] Popup detected, keeping frameless');
} else { } else if (TITLEBAR_STYLE === 'native') {
// Main window: force native frame // Main window, native mode: force system frame.
options.frame = true; options.frame = true;
// Menu bar behavior depends on CLAUDE_MENU_BAR mode: // Menu bar behavior depends on CLAUDE_MENU_BAR mode:
// 'auto' (default): hidden, Alt toggles // 'auto' (default): hidden, Alt toggles
// 'visible'/'hidden': no Alt toggle // 'visible'/'hidden': no Alt toggle
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto'); options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
// Remove custom titlebar options
delete options.titleBarStyle; delete options.titleBarStyle;
delete options.titleBarOverlay; delete options.titleBarOverlay;
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`); 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); super(options);
@@ -144,6 +225,56 @@ Module.prototype.require = function(id) {
this.webContents.insertCSS(LINUX_CSS).catch(() => {}); 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. // Quit on Ctrl+Q, but only when Claude has keyboard focus.
// Replaces a prior globalShortcut registration that grabbed // Replaces a prior globalShortcut registration that grabbed
// the key system-wide and, on non-QWERTY layouts (e.g. // the key system-wide and, on non-QWERTY layouts (e.g.
@@ -373,6 +504,47 @@ Module.prototype.require = function(id) {
}); });
} }
// 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. // Route app.{get,set}LoginItemSettings through XDG Autostart on Linux.
// Electron's openAtLogin is a no-op on Linux (electron/electron#15198), // Electron's openAtLogin is a no-op on Linux (electron/electron#15198),
// which both prevents the app's "Run on startup" toggle from // which both prevents the app's "Run on startup" toggle from

View File

@@ -51,6 +51,22 @@ check_display() {
[[ -n $DISPLAY || -n $WAYLAND_DISPLAY ]] [[ -n $DISPLAY || -n $WAYLAND_DISPLAY ]]
} }
# Resolve CLAUDE_TITLEBAR_STYLE to one of {hybrid,native,hidden},
# defaulting to 'hybrid' when unset or invalid. Echoed (not exported)
# so callers can branch on it without polluting the environment.
# 'hybrid' is the recommended Linux experience: native OS frame +
# in-app topbar via the wco-shim. 'hidden' is upstream's frameless
# WCO config; broken on Linux X11 (clicks unresponsive) but kept for
# Wayland/diagnostic comparison.
_resolve_titlebar_style() {
local raw="${CLAUDE_TITLEBAR_STYLE:-hybrid}"
raw="${raw,,}"
case "$raw" in
hybrid|hidden|native) echo "$raw" ;;
*) echo 'hybrid' ;;
esac
}
# Build Electron arguments array based on display backend # Build Electron arguments array based on display backend
# Requires: is_wayland, use_x11_on_wayland to be set # Requires: is_wayland, use_x11_on_wayland to be set
# (call detect_display_backend first) # (call detect_display_backend first)
@@ -64,8 +80,21 @@ build_electron_args() {
# AppImage always needs --no-sandbox due to FUSE constraints # AppImage always needs --no-sandbox due to FUSE constraints
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox') [[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
# Disable CustomTitlebar for better Linux integration # CLAUDE_TITLEBAR_STYLE selects between:
electron_args+=('--disable-features=CustomTitlebar') # hybrid (default) / native: --disable-features=CustomTitlebar
# so Chromium's drawn CSD titlebar doesn't compete with
# the DE-drawn one. Both modes use frame:true.
# hidden: --enable-features=WindowControlsOverlay because WCO
# is off by default on Linux Chromium (Win/macOS have
# it on by default). Without this flag, titleBarOverlay
# is silently ignored at the page level.
local _tb
_tb=$(_resolve_titlebar_style)
if [[ $_tb == 'hidden' ]]; then
electron_args+=('--enable-features=WindowControlsOverlay')
else
electron_args+=('--disable-features=CustomTitlebar')
fi
# Remote XRDP sessions lack GPU acceleration and render a blank # Remote XRDP sessions lack GPU acceleration and render a blank
# window when GPU compositing is enabled. Detect via XRDP_SESSION # window when GPU compositing is enabled. Detect via XRDP_SESSION
@@ -247,7 +276,12 @@ setup_electron_env() {
# copied and app resources co-located in resources/, so resourcesPath # copied and app resources co-located in resources/, so resourcesPath
# naturally points to the right place on all package types. # naturally points to the right place on all package types.
export ELECTRON_FORCE_IS_PACKAGED=true export ELECTRON_FORCE_IS_PACKAGED=true
export ELECTRON_USE_SYSTEM_TITLE_BAR=1 # ELECTRON_USE_SYSTEM_TITLE_BAR=1 forces a system titlebar at the
# Electron level. Set in 'native' and 'hybrid' modes (both use
# frame:true); skipped in 'hidden' mode (frame:false + WCO config).
if [[ $(_resolve_titlebar_style) != 'hidden' ]]; then
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
fi
} }
#=============================================================================== #===============================================================================

View File

@@ -62,9 +62,6 @@ console.log('Updated package.json: main entry and node-pty dependency');
cp "$claude_extract_dir/lib/net45/resources/Tray"* app.asar.contents/resources/ 2>/dev/null || \ cp "$claude_extract_dir/lib/net45/resources/Tray"* app.asar.contents/resources/ 2>/dev/null || \
echo 'Warning: No tray icon files found for asar inclusion' echo 'Warning: No tray icon files found for asar inclusion'
# Patch title bar detection
patch_titlebar_detection
# Extract electron module variable name for tray patches # Extract electron module variable name for tray patches
extract_electron_variable extract_electron_variable
@@ -93,6 +90,13 @@ console.log('Updated package.json: main entry and node-pty dependency');
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket) # Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
patch_cowork_linux patch_cowork_linux
# Inject WCO shim into the BrowserView preload so claude.ai's
# desktop topbar renders on Linux. The shim spoofs the bundle's
# isWindows() UA check (load-bearing) plus matchMedia and
# windowControlsOverlay (defensive). See
# docs/learnings/linux-topbar-shim.md.
patch_wco_shim
# Copy cowork VM service daemon for Linux Cowork mode # Copy cowork VM service daemon for Linux Cowork mode
echo 'Installing cowork VM service daemon...' echo 'Installing cowork VM service daemon...'
cp "$source_dir/scripts/cowork-vm-service.js" \ cp "$source_dir/scripts/cowork-vm-service.js" \

View File

@@ -1,44 +0,0 @@
#===============================================================================
# Title bar detection patch: strip the negation so Linux renders the frame.
#
# Sourced by: build.sh
# Sourced globals: (none)
# Modifies globals: (none)
#===============================================================================
patch_titlebar_detection() {
echo '##############################################################'
echo "Removing '!' from 'if (\"!\"isWindows && isMainWindow) return null;'"
echo 'detection flag to enable title bar'
local search_base='app.asar.contents/.vite/renderer/main_window/assets'
local target_pattern='MainWindowPage-*.js'
echo "Searching for '$target_pattern' within '$search_base'..."
local target_files
mapfile -t target_files < <(find "$search_base" -type f -name "$target_pattern")
local num_files=${#target_files[@]}
case $num_files in
0)
echo "Error: No file matching '$target_pattern' found within '$search_base'." >&2
exit 1
;;
1)
local target_file="${target_files[0]}"
echo "Found target file: $target_file"
sed -i -E 's/if\(!([a-zA-Z]+)[[:space:]]*&&[[:space:]]*([a-zA-Z]+)\)/if(\1 \&\& \2)/g' "$target_file"
if grep -q -E 'if\(![a-zA-Z]+[[:space:]]*&&[[:space:]]*[a-zA-Z]+\)' "$target_file"; then
echo "Error: Failed to replace patterns in $target_file." >&2
exit 1
fi
echo "Successfully replaced patterns in $target_file"
;;
*)
echo "Error: Expected exactly one file matching '$target_pattern' within '$search_base', but found $num_files." >&2
exit 1
;;
esac
echo '##############################################################'
}

View File

@@ -0,0 +1,40 @@
#===============================================================================
# Inject Window Controls Overlay shim into the BrowserView preload.
#
# Sourced by: build.sh
# Sourced globals: source_dir
# Modifies globals: (none)
#===============================================================================
patch_wco_shim() {
echo '##############################################################'
echo 'Inlining WCO shim into mainView.js (Linux topbar workaround)'
local main_view='app.asar.contents/.vite/build/mainView.js'
if [[ ! -f $main_view ]]; then
echo "Error: mainView.js not found at $main_view." >&2
exit 1
fi
if grep -q '__claude_wco_shim' "$main_view"; then
echo 'mainView.js already has WCO shim, skipping inject'
echo '##############################################################'
return 0
fi
# Sandboxed preloads can only require a fixed allowlist of modules
# (electron, ipcRenderer, contextBridge, webFrame…). A relative
# require to a sibling file fails with "module not found" and
# aborts the entire preload — taking desktopBootFeatures and the
# rest of mainView's exposeInMainWorld surface down with it.
# So we inline the shim source directly at the top of mainView.js
# instead of pulling it in via require.
local shim_content
shim_content=$(cat "$source_dir/scripts/wco-shim.js")
local original
original=$(cat "$main_view")
printf '%s\n%s' "$shim_content" "$original" > "$main_view"
echo 'Inlined WCO shim at top of mainView.js'
echo '##############################################################'
}

267
scripts/wco-shim.js Normal file
View File

@@ -0,0 +1,267 @@
// __claude_wco_shim — marker for patch_wco_shim idempotency check
//
// Window Controls Overlay shim for Linux. Convinces claude.ai's
// React bundle that it's running in a Windows desktop window, so
// the in-app topbar (hamburger / sidebar / search / nav / Cowork
// ghost) renders. claude.ai's bundle gates topbar rendering on a
// `/(win32|win64|windows|wince)/i` test against navigator.userAgent;
// our overrides flip that to true page-side without touching the
// HTTP request UA, plus shim navigator.windowControlsOverlay and
// matchMedia('(display-mode: window-controls-overlay)') as
// defensive forward-compat in case the bundle ever tightens its
// check beyond the UA regex.
//
// Also installs a className intercept that strips 'draggable' from
// any DOM class assignment. This is belt-and-suspenders against
// claude.ai's CSS rule .draggable { app-region: drag } applying
// to in-content elements; in hybrid mode (frame:true) the OS
// handles window dragging via the native titlebar, so any
// remaining app-region:drag inside the BrowserView would only
// produce unexpected click-eaten regions.
//
// Investigation history: docs/learnings/linux-topbar-shim.md.
// CLAUDE_WCO_NATIVE=1 skips all overrides for diagnostic A/B
// testing against unmodified Chromium behavior.
//
// Active only when CLAUDE_TITLEBAR_STYLE != 'native'.
(function() {
if (process.platform !== 'linux') return;
var style = (process.env.CLAUDE_TITLEBAR_STYLE || 'hybrid').toLowerCase();
if (style === 'native') return;
// Diagnostic mode: skip all overrides so the BrowserView sees
// Chromium's native behavior. Native-state logging still runs
// so the user can inspect what Chromium actually reports.
var nativeMode = process.env.CLAUDE_WCO_NATIVE === '1';
try {
var webFrame = require('electron').webFrame;
if (!webFrame) return;
// Inline the shim as a string so it runs in the page's main
// world. CONTROLS_WIDTH leaves room on the right for window
// controls in the shimmed wco_rect; TITLEBAR_HEIGHT matches
// the upstream Windows topbar height. nativeMode flag is
// interpolated so the page script can honor the diagnostic
// switch.
var script = [
'(function(){',
'if(window.__claudeWcoShimInstalled)return;',
'window.__claudeWcoShimInstalled=true;',
'var CONTROLS_WIDTH=140;',
'var TITLEBAR_HEIGHT=40;',
'var __nativeMode=' + (nativeMode ? 'true' : 'false') + ';',
// Diagnostic: capture and log Chromium's NATIVE WCO
// state. Phase 1 captures non-DOM values synchronously
// (before any overrides apply). Phase 2 injects a
// stylesheet to read env(titlebar-area-*) once the DOM
// is ready, deferred via DOMContentLoaded if necessary
// — webFrame.executeJavaScript can fire before the html
// element exists, so an early getComputedStyle call
// throws "parameter 1 is not of type 'Element'".
// env() values are CSS-engine state that the shim's
// overrides don't touch, so reading them late still
// reflects native behavior. Surfaces in the BrowserView's
// DevTools console and in the launcher log via the
// console-message mirror in frame-fix-wrapper.js.
'var __nativeProbe={};',
'try{',
'var __wco=navigator.windowControlsOverlay;',
'__nativeProbe.visible=!!(__wco&&__wco.visible);',
'try{',
'var __r=__wco&&__wco.getTitlebarAreaRect&&__wco.getTitlebarAreaRect();',
'__nativeProbe.rect=__r?{x:__r.x,y:__r.y,width:__r.width,height:__r.height}:null;',
'}catch(e){__nativeProbe.rect=null;}',
'__nativeProbe.media_wco=matchMedia("(display-mode: window-controls-overlay)").matches;',
'__nativeProbe.media_standalone=matchMedia("(display-mode: standalone)").matches;',
'__nativeProbe.media_browser=matchMedia("(display-mode: browser)").matches;',
'__nativeProbe.userAgent=navigator.userAgent;',
'__nativeProbe.nativeMode=__nativeMode;',
'}catch(e){__nativeProbe.captureError=e.message;}',
// Phase 2: inject a stylesheet using CSS env() to extract
// titlebar-area values, then read them via custom
// properties. getPropertyValue('env(...)') is invalid;
// env() is only meaningful inside CSS values, so we
// indirect through --probe-* custom properties.
'var __finishProbe=function(){',
'try{',
'var __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);',
'var __cs=getComputedStyle(document.documentElement);',
'__nativeProbe.env_x=__cs.getPropertyValue("--probe-tbx").trim();',
'__nativeProbe.env_y=__cs.getPropertyValue("--probe-tby").trim();',
'__nativeProbe.env_w=__cs.getPropertyValue("--probe-tbw").trim();',
'__nativeProbe.env_h=__cs.getPropertyValue("--probe-tbh").trim();',
'__s.remove();',
'}catch(e){__nativeProbe.envProbeError=e.message;}',
'window.__claudeWcoNativeState=__nativeProbe;',
'console.log("[WCO Diagnostic] BrowserView native state:",JSON.stringify(__nativeProbe));',
'};',
'if(document.documentElement&&document.head){',
'__finishProbe();',
'}else{',
'document.addEventListener("DOMContentLoaded",__finishProbe,{once:true});',
'}',
// In native diagnostic mode, skip all overrides so
// the user can see how the page behaves with pure
// Chromium (and to test whether claude.ai's UA gate
// passes naturally — it won't, but it lets us
// confirm that as a baseline). Phase 2 was registered
// above the early return so it still fires.
'if(__nativeMode){',
'console.log("[WCO Shim] CLAUDE_WCO_NATIVE=1, skipping all overrides");',
'return;',
'}',
// 1. Shim navigator.windowControlsOverlay with proper
// event-target semantics so React listeners fire.
'var listeners={};',
'var overlay={',
'get visible(){return true},',
'getTitlebarAreaRect:function(){',
'return new DOMRect(0,0,Math.max(0,window.innerWidth-CONTROLS_WIDTH),TITLEBAR_HEIGHT);',
'},',
'addEventListener:function(t,fn){',
'(listeners[t]=listeners[t]||[]).push(fn);',
'},',
'removeEventListener:function(t,fn){',
'var arr=listeners[t]||[];',
'var i=arr.indexOf(fn);',
'if(i>=0)arr.splice(i,1);',
'},',
'dispatchEvent:function(e){',
'(listeners[e.type]||[]).slice().forEach(function(fn){',
'try{fn.call(overlay,e)}catch(err){console.warn("[WCO Shim]",err)}',
'});',
'if(typeof overlay["on"+e.type]==="function"){',
'try{overlay["on"+e.type](e)}catch(err){}',
'}',
'return true;',
'},',
'ongeometrychange:null',
'};',
'try{',
'Object.defineProperty(navigator,"windowControlsOverlay",{',
'value:overlay,configurable:true',
'});',
'}catch(e){console.warn("[WCO Shim] navigator override failed:",e.message)}',
// 2. Shim matchMedia for the WCO display-mode query.
// The CSS @media engine itself can't be fooled, but
// JS code that branches on matchMedia().matches can.
'var origMM=window.matchMedia.bind(window);',
'window.matchMedia=function(q){',
'if(typeof q==="string"&&q.indexOf("window-controls-overlay")!==-1){',
'return{',
'matches:true,',
'media:q,',
'onchange:null,',
'addEventListener:function(){},',
'removeEventListener:function(){},',
'addListener:function(){},',
'removeListener:function(){},',
'dispatchEvent:function(){return true}',
'};',
'}',
'return origMM(q);',
'};',
// 3. Shim navigator.userAgent so claude.ais isWindows()
// check passes. The bundle uses
// /(win32|win64|windows|wince)/i.test(navigator.userAgent)
// to decide whether to render the desktop topbar
// component (data-testid="topbar-windows-menu"). On
// Linux the UA contains "X11; Linux x86_64" and the
// regex fails, so the topbar is never rendered.
// Done page-side only: HTTP request UA is unchanged,
// so analytics and anti-bot fingerprints stay honest.
'try{',
'var origUA=navigator.userAgent;',
'if(!/(win32|win64|windows|wince)/i.test(origUA)){',
'Object.defineProperty(navigator,"userAgent",{',
'get:function(){return origUA+" Windows"},',
'configurable:true',
'});',
'}',
'}catch(e){console.warn("[WCO Shim] userAgent override failed:",e.message)}',
// 4. Strip 'draggable' class from any DOM class
// assignment. claude.ai's React renders the topbar
// parent with class="draggable absolute top-0
// inset-x-0 ..." which triggers a CSS rule
// .draggable { -webkit-app-region: drag }. In hybrid
// mode (frame:true) the OS handles window dragging,
// so any in-content app-region:drag region would
// just create surprise click-eaten zones inside the
// page. Stripping the class at the JS-DOM API level
// means the rule never matches, regardless of how
// Chromium decides to consume it.
// Three assignment vectors covered:
// el.className = '...'
// el.setAttribute('class', '...')
// el.classList.add('draggable', ...)
// Round-trip identity is broken for class strings
// containing 'draggable' — el.className=val then
// reading el.className will not return val. No
// code path in claude.ai's bundle appears to
// depend on this; if a regression appears, scope
// the strip to the specific class combination
// (e.g. /draggable\s+absolute\s+top-0/) instead
// of the bare word.
'try{',
'var __strip=function(v){',
'if(typeof v!=="string")return v;',
'return v.replace(/\\bdraggable\\b/g,"").replace(/\\s+/g," ").trim();',
'};',
'var __cnDesc=Object.getOwnPropertyDescriptor(Element.prototype,"className");',
'if(__cnDesc&&__cnDesc.set){',
'Object.defineProperty(Element.prototype,"className",{',
'configurable:true,',
'enumerable:__cnDesc.enumerable,',
'get:function(){return __cnDesc.get.call(this)},',
'set:function(v){__cnDesc.set.call(this,__strip(v))}',
'});',
'}',
'var __origSetAttr=Element.prototype.setAttribute;',
'Element.prototype.setAttribute=function(n,v){',
'if((n==="class"||n==="className")&&typeof v==="string"){',
'v=__strip(v);',
'}',
'return __origSetAttr.call(this,n,v);',
'};',
'var __origClAdd=DOMTokenList.prototype.add;',
'DOMTokenList.prototype.add=function(){',
'var args=[];',
'for(var i=0;i<arguments.length;i++){',
'if(arguments[i]!=="draggable")args.push(arguments[i]);',
'}',
'return __origClAdd.apply(this,args);',
'};',
'console.log("[Drag Shim] className intercept installed");',
'}catch(e){console.warn("[Drag Shim] className intercept failed:",e.message)}',
// 5. Fire events to nudge any framework that already
// rendered before the shim arrived. geometrychange
// is the official WCO signal; resize is a common
// fallback React layout effects listen to.
'setTimeout(function(){',
'try{overlay.dispatchEvent(new Event("geometrychange"))}catch(e){}',
'try{window.dispatchEvent(new Event("resize"))}catch(e){}',
'},0);',
'console.log("[WCO Shim] Installed in main world");',
'})();',
].join('');
webFrame.executeJavaScript(script).catch(function() {});
} catch (e) {
console.warn('[WCO Shim] Preload failed:', e.message);
}
})();

View File

@@ -36,7 +36,9 @@ setup() {
unset NIRI_SOCKET unset NIRI_SOCKET
unset XDG_CURRENT_DESKTOP unset XDG_CURRENT_DESKTOP
unset CLAUDE_MENU_BAR unset CLAUDE_MENU_BAR
unset CLAUDE_TITLEBAR_STYLE
unset COWORK_VM_BACKEND unset COWORK_VM_BACKEND
unset ELECTRON_USE_SYSTEM_TITLE_BAR
# shellcheck source=scripts/launcher-common.sh # shellcheck source=scripts/launcher-common.sh
source "$SCRIPT_DIR/../scripts/launcher-common.sh" source "$SCRIPT_DIR/../scripts/launcher-common.sh"
@@ -271,11 +273,115 @@ teardown() {
[[ $ELECTRON_FORCE_IS_PACKAGED == 'true' ]] [[ $ELECTRON_FORCE_IS_PACKAGED == 'true' ]]
} }
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR" { @test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR in hybrid mode (default)" {
setup_electron_env setup_electron_env
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]] [[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
} }
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR in native mode" {
CLAUDE_TITLEBAR_STYLE=native setup_electron_env
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
}
@test "setup_electron_env: skips ELECTRON_USE_SYSTEM_TITLE_BAR in hidden mode" {
CLAUDE_TITLEBAR_STYLE=hidden setup_electron_env
[[ -z ${ELECTRON_USE_SYSTEM_TITLE_BAR:-} ]]
}
@test "setup_electron_env: skips ELECTRON_USE_SYSTEM_TITLE_BAR for invalid value (falls back to hybrid)" {
CLAUDE_TITLEBAR_STYLE=garbage setup_electron_env
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
}
# =============================================================================
# _resolve_titlebar_style
# =============================================================================
@test "_resolve_titlebar_style: returns 'hybrid' when unset" {
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
}
@test "_resolve_titlebar_style: returns 'hybrid' for hybrid" {
CLAUDE_TITLEBAR_STYLE=hybrid
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
}
@test "_resolve_titlebar_style: returns 'native' for native" {
CLAUDE_TITLEBAR_STYLE=native
[[ $(_resolve_titlebar_style) == 'native' ]]
}
@test "_resolve_titlebar_style: returns 'hidden' for hidden" {
CLAUDE_TITLEBAR_STYLE=hidden
[[ $(_resolve_titlebar_style) == 'hidden' ]]
}
@test "_resolve_titlebar_style: case-insensitive (HYBRID)" {
CLAUDE_TITLEBAR_STYLE=HYBRID
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
}
@test "_resolve_titlebar_style: case-insensitive (Native)" {
CLAUDE_TITLEBAR_STYLE=Native
[[ $(_resolve_titlebar_style) == 'native' ]]
}
@test "_resolve_titlebar_style: case-insensitive (Hidden)" {
CLAUDE_TITLEBAR_STYLE=Hidden
[[ $(_resolve_titlebar_style) == 'hidden' ]]
}
@test "_resolve_titlebar_style: falls back to hybrid for invalid value" {
CLAUDE_TITLEBAR_STYLE=garbage
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
}
@test "_resolve_titlebar_style: falls back to hybrid for empty value" {
CLAUDE_TITLEBAR_STYLE=''
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
}
# =============================================================================
# build_electron_args: titlebar mode flag selection
# =============================================================================
@test "build_electron_args: hybrid mode (default) disables CustomTitlebar" {
is_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--disable-features=CustomTitlebar'
# shellcheck disable=SC2314
! has_electron_arg '--enable-features=WindowControlsOverlay'
}
@test "build_electron_args: native mode disables CustomTitlebar" {
CLAUDE_TITLEBAR_STYLE=native
is_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--disable-features=CustomTitlebar'
# shellcheck disable=SC2314
! has_electron_arg '--enable-features=WindowControlsOverlay'
}
@test "build_electron_args: hidden mode enables WindowControlsOverlay" {
CLAUDE_TITLEBAR_STYLE=hidden
is_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--enable-features=WindowControlsOverlay'
# shellcheck disable=SC2314
! has_electron_arg '--disable-features=CustomTitlebar'
}
@test "build_electron_args: invalid titlebar value falls back to hybrid flags" {
CLAUDE_TITLEBAR_STYLE=garbage
is_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--disable-features=CustomTitlebar'
}
# ============================================================================= # =============================================================================
# cleanup_stale_lock # cleanup_stale_lock
# ============================================================================= # =============================================================================