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)
│ │ ├── _common.sh # extract_electron_variable, fix_native_theme_references
│ │ ├── 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
│ │ ├── quick-window.sh
│ │ ├── claude-code.sh
@@ -121,7 +121,7 @@ claude-desktop-debian/
| Function | File | Purpose |
|----------|------|---------|
| `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")` |
| `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 |

View File

@@ -48,7 +48,7 @@ Use this when you're not confident enough to triage automatically. Examples: sec
## INVESTIGATION RULES
### 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
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 |
|----------|---------------|
| 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` |
| Packaging (deb) | `scripts/packaging/deb.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
- [`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
- [`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

View File

@@ -52,8 +52,6 @@ source "$script_dir/scripts/setup/download.sh"
source "$script_dir/scripts/patches/_common.sh"
# shellcheck source=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
source "$script_dir/scripts/patches/tray.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"
# shellcheck source=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
source "$script_dir/scripts/staging/electron.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_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. |
### Wayland Support
@@ -49,6 +50,28 @@ CLAUDE_MENU_BAR=visible claude-desktop
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 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)'
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 --
# Version is read from the file next to the binary rather than
# 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}`);
// Titlebar mode, controlled by CLAUDE_TITLEBAR_STYLE env var:
// 'hybrid' (default) - native OS frame (frame:true) + wco-shim active.
// Stacked layout: OS titlebar on top draws
// min/max/close, claude.ai's in-app topbar
// renders below it via the shim's UA +
// matchMedia overrides. Topbar buttons clickable.
// Recommended Linux experience.
// 'native' - system-decorated window (frame:true), no shim.
// DE draws min/max/close; claude.ai's in-app
// topbar is hidden by its UA gate. Use if the
// in-app topbar conflicts with your DE.
// 'hidden' - frameless window with Window Controls Overlay
// configured (matches Windows / macOS upstream).
// BROKEN ON LINUX X11: topbar buttons not
// clickable because Chromium creates an implicit
// WM-level drag region for frameless windows
// that intercepts mouse events. Kept for
// Wayland comparison and future investigation;
// see docs/learnings/linux-topbar-shim.md.
// Applies to the main window only. Popups (Quick Entry, About) are
// always frameless regardless of this setting.
const VALID_TITLEBAR_STYLES = ['hybrid', 'native', 'hidden'];
const rawTitlebarStyle = (process.env.CLAUDE_TITLEBAR_STYLE || 'hybrid').toLowerCase();
const TITLEBAR_STYLE = VALID_TITLEBAR_STYLES.includes(rawTitlebarStyle)
? rawTitlebarStyle
: 'hybrid';
if (rawTitlebarStyle !== TITLEBAR_STYLE) {
console.warn(`[Frame Fix] Unknown CLAUDE_TITLEBAR_STYLE value '${process.env.CLAUDE_TITLEBAR_STYLE}', falling back to 'hybrid'. Valid: ${VALID_TITLEBAR_STYLES.join(', ')}`);
}
console.log(`[Frame Fix] Titlebar style: ${TITLEBAR_STYLE}`);
// Keep the app alive when the main window is closed (hide to tray),
// so in-app schedulers / MCP servers / the tray icon survive a
// stray click on X. Explicit quit paths (Ctrl+Q via the focused
@@ -118,17 +149,67 @@ Module.prototype.require = function(id) {
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log('[Frame Fix] Popup detected, keeping frameless');
} else {
// Main window: force native frame
} else if (TITLEBAR_STYLE === 'native') {
// Main window, native mode: force system frame.
options.frame = true;
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
// 'auto' (default): hidden, Alt toggles
// 'visible'/'hidden': no Alt toggle
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
// Remove custom titlebar options
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
} else if (TITLEBAR_STYLE === 'hybrid') {
// Main window, hybrid mode: native OS frame +
// claude.ai's in-app topbar via wco-shim.
//
// Why this shape: Linux X11 + frameless windows
// hits a Chromium-level implicit drag region at
// the top of the window that intercepts mouse
// events at the WM level. We've ruled out
// titleBarOverlay and titleBarStyle as the source
// (disabling either still produced unclickable
// topbar buttons). The drag region appears to be
// a Linux-X11 default for frame:false windows. With
// frame:true the OS handles dragging via the native
// titlebar and Chromium pushes no drag-region map,
// so the in-app topbar's buttons are clickable.
//
// Visual trade-off vs Windows: stacked layout — OS
// titlebar on top, in-app topbar below it. The
// buttons we care about (hamburger / sidebar /
// search / nav / Cowork ghost) all live in the
// in-app topbar via the shim's UA + matchMedia
// overrides. The shim's className intercept stays
// as belt-and-suspenders against the .draggable
// CSS rule still applying within the framed
// window's content area.
options.frame = true;
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
delete options.titleBarStyle;
delete options.titleBarOverlay;
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
} else {
// Main window, hidden mode: frameless + Window Controls
// Overlay configured (matches Windows / macOS upstream).
// BROKEN ON LINUX X11 — topbar buttons not clickable
// because Chromium creates an implicit drag region for
// frame:false windows that intercepts mouse events at
// the WM level. Investigation chain in
// docs/learnings/linux-topbar-shim.md ruled out
// titleBarOverlay height and titleBarStyle:'hidden' as
// the source. The default is now 'hybrid'; this branch
// is kept for Wayland comparison and future probes.
options.frame = false;
options.titleBarStyle = 'hidden';
options.titleBarOverlay = {
color: '#1a1a1a',
symbolColor: '#ffffff',
height: 40,
};
console.log('[Frame Fix] Hidden mode (frame=false, '
+ 'titleBarStyle=hidden, titleBarOverlay=object) — '
+ 'topbar clicks broken on X11');
}
}
super(options);
@@ -144,6 +225,56 @@ Module.prototype.require = function(id) {
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
});
// WCO diagnostic: probe Chromium's native Window Controls
// Overlay state on the main window webContents. Upstream
// electron/electron#41769 (June 2024) implements WCO on
// Linux X11; runtime probes (2026-04-29) show the API
// surface returns visible:true here while display-mode
// and env() vars don't match — partial implementation.
// env() extraction goes through a custom-property
// indirection because getPropertyValue('env(...)') is
// invalid; env() is only meaningful inside CSS values.
// Logs to stdout so the result lands in launcher.log.
// Only meaningful for non-popup main windows in hidden
// mode (the only path that requests WCO).
if (!popup && TITLEBAR_STYLE !== 'native') {
this.webContents.on('did-finish-load', () => {
this.webContents.executeJavaScript(`
(() => {
const wco = navigator.windowControlsOverlay;
let rect = null;
try {
const r = wco && wco.getTitlebarAreaRect && wco.getTitlebarAreaRect();
if (r) rect = { x: r.x, y: r.y, width: r.width, height: r.height };
} catch (e) { /* ignore */ }
const s = document.createElement('style');
s.textContent = ':root{--probe-tbx:env(titlebar-area-x);--probe-tby:env(titlebar-area-y);--probe-tbw:env(titlebar-area-width);--probe-tbh:env(titlebar-area-height);}';
document.head.appendChild(s);
const cs = getComputedStyle(document.documentElement);
const result = {
visible: !!(wco && wco.visible),
rect,
media_wco: matchMedia('(display-mode: window-controls-overlay)').matches,
media_standalone: matchMedia('(display-mode: standalone)').matches,
media_browser: matchMedia('(display-mode: browser)').matches,
env_x: cs.getPropertyValue('--probe-tbx').trim(),
env_y: cs.getPropertyValue('--probe-tby').trim(),
env_w: cs.getPropertyValue('--probe-tbw').trim(),
env_h: cs.getPropertyValue('--probe-tbh').trim(),
userAgent: navigator.userAgent,
location: location.href,
};
s.remove();
return JSON.stringify(result);
})()
`).then((json) => {
console.log('[WCO Diagnostic] main window webContents:', json);
}).catch((err) => {
console.warn('[WCO Diagnostic] main window probe failed:', err.message);
});
});
}
// Quit on Ctrl+Q, but only when Claude has keyboard focus.
// Replaces a prior globalShortcut registration that grabbed
// the key system-wide and, on non-QWERTY layouts (e.g.
@@ -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.
// Electron's openAtLogin is a no-op on Linux (electron/electron#15198),
// which both prevents the app's "Run on startup" toggle from

View File

@@ -51,6 +51,22 @@ check_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
# Requires: is_wayland, use_x11_on_wayland to be set
# (call detect_display_backend first)
@@ -64,8 +80,21 @@ build_electron_args() {
# AppImage always needs --no-sandbox due to FUSE constraints
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
# Disable CustomTitlebar for better Linux integration
# CLAUDE_TITLEBAR_STYLE selects between:
# 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
# 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
# naturally points to the right place on all package types.
export ELECTRON_FORCE_IS_PACKAGED=true
# 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 || \
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_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_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
echo 'Installing cowork VM service daemon...'
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 XDG_CURRENT_DESKTOP
unset CLAUDE_MENU_BAR
unset CLAUDE_TITLEBAR_STYLE
unset COWORK_VM_BACKEND
unset ELECTRON_USE_SYSTEM_TITLE_BAR
# shellcheck source=scripts/launcher-common.sh
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
@@ -271,11 +273,115 @@ teardown() {
[[ $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
[[ $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
# =============================================================================