diff --git a/.claude/agents/electron-linux-specialist.md b/.claude/agents/electron-linux-specialist.md index 3cfb758..3333d31 100644 --- a/.claude/agents/electron-linux-specialist.md +++ b/.claude/agents/electron-linux-specialist.md @@ -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 | diff --git a/.claude/agents/issue-triage.md b/.claude/agents/issue-triage.md index 77810e6..f0a1b11 100644 --- a/.claude/agents/issue-triage.md +++ b/.claude/agents/issue-triage.md @@ -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` | diff --git a/CLAUDE.md b/CLAUDE.md index d1c557c..0aceec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/build.sh b/build.sh index 0569b7c..30db739 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7761dd8..1cd9b87 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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: diff --git a/docs/learnings/images/linux-topbar-hybrid.png b/docs/learnings/images/linux-topbar-hybrid.png new file mode 100644 index 0000000..5b95f40 Binary files /dev/null and b/docs/learnings/images/linux-topbar-hybrid.png differ diff --git a/docs/learnings/linux-topbar-shim.md b/docs/learnings/linux-topbar-shim.md new file mode 100644 index 0000000..e513c99 --- /dev/null +++ b/docs/learnings/linux-topbar-shim.md @@ -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: ` (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=']`. +`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 `
` +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 `
` 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. diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 61e45f3..b1aa777 100644 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -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). diff --git a/scripts/frame-fix-wrapper.js b/scripts/frame-fix-wrapper.js index 4cd7107..d8d9f50 100644 --- a/scripts/frame-fix-wrapper.js +++ b/scripts/frame-fix-wrapper.js @@ -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 diff --git a/scripts/launcher-common.sh b/scripts/launcher-common.sh index bfb4efe..05bb183 100644 --- a/scripts/launcher-common.sh +++ b/scripts/launcher-common.sh @@ -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 - electron_args+=('--disable-features=CustomTitlebar') + # 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 - 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 } #=============================================================================== diff --git a/scripts/patches/app-asar.sh b/scripts/patches/app-asar.sh index 8f5b15f..2982738 100644 --- a/scripts/patches/app-asar.sh +++ b/scripts/patches/app-asar.sh @@ -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" \ diff --git a/scripts/patches/titlebar.sh b/scripts/patches/titlebar.sh deleted file mode 100644 index 7fe9f8d..0000000 --- a/scripts/patches/titlebar.sh +++ /dev/null @@ -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 '##############################################################' -} diff --git a/scripts/patches/wco-shim.sh b/scripts/patches/wco-shim.sh new file mode 100644 index 0000000..af41e49 --- /dev/null +++ b/scripts/patches/wco-shim.sh @@ -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 '##############################################################' +} diff --git a/scripts/wco-shim.js b/scripts/wco-shim.js new file mode 100644 index 0000000..a4984a2 --- /dev/null +++ b/scripts/wco-shim.js @@ -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