mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
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:
@@ -101,7 +101,7 @@ claude-desktop-debian/
|
|||||||
│ ├── patches/ # sed/regex patches on minified JS (per-subsystem)
|
│ ├── patches/ # sed/regex patches on minified JS (per-subsystem)
|
||||||
│ │ ├── _common.sh # extract_electron_variable, fix_native_theme_references
|
│ │ ├── _common.sh # extract_electron_variable, fix_native_theme_references
|
||||||
│ │ ├── app-asar.sh # Asar repack, frame-fix wrapper injection
|
│ │ ├── app-asar.sh # Asar repack, frame-fix wrapper injection
|
||||||
│ │ ├── titlebar.sh
|
│ │ ├── wco-shim.sh # Inlines WCO/UA shim into mainView.js preload
|
||||||
│ │ ├── tray.sh # Tray menu handler + icon selection
|
│ │ ├── tray.sh # Tray menu handler + icon selection
|
||||||
│ │ ├── quick-window.sh
|
│ │ ├── quick-window.sh
|
||||||
│ │ ├── claude-code.sh
|
│ │ ├── claude-code.sh
|
||||||
@@ -121,7 +121,7 @@ claude-desktop-debian/
|
|||||||
| Function | File | Purpose |
|
| Function | File | Purpose |
|
||||||
|----------|------|---------|
|
|----------|------|---------|
|
||||||
| `patch_app_asar()` | `scripts/patches/app-asar.sh` | Extracts asar, injects frame-fix wrapper, repacks |
|
| `patch_app_asar()` | `scripts/patches/app-asar.sh` | Extracts asar, injects frame-fix wrapper, repacks |
|
||||||
| `patch_titlebar_detection()` | `scripts/patches/titlebar.sh` | Removes `!` from `if(!isWindows && isMainWindow)` to enable titlebar |
|
| `patch_wco_shim()` | `scripts/patches/wco-shim.sh` | Inlines `scripts/wco-shim.js` at the top of `mainView.js` (the BrowserView preload) so claude.ai's bundle sees Windows-like UA + matchMedia and renders the in-app topbar on Linux |
|
||||||
| `extract_electron_variable()` | `scripts/patches/_common.sh` | Finds the minified variable name for `require("electron")` |
|
| `extract_electron_variable()` | `scripts/patches/_common.sh` | Finds the minified variable name for `require("electron")` |
|
||||||
| `fix_native_theme_references()` | `scripts/patches/_common.sh` | Fixes wrong `*.nativeTheme` references to use the correct electron var |
|
| `fix_native_theme_references()` | `scripts/patches/_common.sh` | Fixes wrong `*.nativeTheme` references to use the correct electron var |
|
||||||
| `patch_tray_menu_handler()` | `scripts/patches/tray.sh` | Makes tray rebuild async, adds mutex guard, DBus cleanup delay, startup skip |
|
| `patch_tray_menu_handler()` | `scripts/patches/tray.sh` | Makes tray rebuild async, adds mutex guard, DBus cleanup delay, startup skip |
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Use this when you're not confident enough to triage automatically. Examples: sec
|
|||||||
## INVESTIGATION RULES
|
## INVESTIGATION RULES
|
||||||
|
|
||||||
### All bugs are ours to fix
|
### All bugs are ours to fix
|
||||||
This project's goal is to take a working Anthropic product and make it work on Linux. Every bug is something we can investigate and potentially patch. Check `scripts/patches/*.sh` first for bugs in patched areas (`cowork.sh`, `tray.sh`, `app-asar.sh`, `titlebar.sh`, `quick-window.sh`, `claude-code.sh`). Read the relevant `patch_` function and trace what it modifies. If a behavior difference exists between the Windows/macOS app and our Linux build, that's a gap in our patching, not someone else's problem.
|
This project's goal is to take a working Anthropic product and make it work on Linux. Every bug is something we can investigate and potentially patch. Check `scripts/patches/*.sh` first for bugs in patched areas (`cowork.sh`, `tray.sh`, `app-asar.sh`, `wco-shim.sh`, `quick-window.sh`, `claude-code.sh`). Read the relevant `patch_` function and trace what it modifies. If a behavior difference exists between the Windows/macOS app and our Linux build, that's a gap in our patching, not someone else's problem.
|
||||||
|
|
||||||
### Verify before stating
|
### Verify before stating
|
||||||
Only state facts you verified by reading actual code or running commands. Never claim code exists, functions behave a certain way, or patterns match without finding them in the source. If you cannot find evidence, say so explicitly rather than speculating.
|
Only state facts you verified by reading actual code or running commands. Never claim code exists, functions behave a certain way, or patterns match without finding them in the source. If you cannot find evidence, say so explicitly rather than speculating.
|
||||||
@@ -80,7 +80,7 @@ When investigating bugs, search these files based on the issue category:
|
|||||||
| Category | Files to check |
|
| Category | Files to check |
|
||||||
|----------|---------------|
|
|----------|---------------|
|
||||||
| Build failures | `build.sh` (orchestrator), `scripts/setup/`, `.github/workflows/ci.yml`, `build-amd64.yml`, `build-arm64.yml` |
|
| Build failures | `build.sh` (orchestrator), `scripts/setup/`, `.github/workflows/ci.yml`, `build-amd64.yml`, `build-arm64.yml` |
|
||||||
| Window/frame issues | `scripts/frame-fix-wrapper.js`, `scripts/patches/titlebar.sh`, `scripts/patches/app-asar.sh`, reference source for `BrowserWindow` |
|
| Window/frame issues | `scripts/frame-fix-wrapper.js`, `scripts/wco-shim.js`, `scripts/patches/wco-shim.sh`, `scripts/patches/app-asar.sh`, reference source for `BrowserWindow` |
|
||||||
| Tray icon issues | `scripts/patches/tray.sh`, reference source for `Tray`, `StatusNotifier` |
|
| Tray icon issues | `scripts/patches/tray.sh`, reference source for `Tray`, `StatusNotifier` |
|
||||||
| Packaging (deb) | `scripts/packaging/deb.sh`, `scripts/launcher-common.sh` |
|
| Packaging (deb) | `scripts/packaging/deb.sh`, `scripts/launcher-common.sh` |
|
||||||
| Packaging (rpm) | `scripts/packaging/rpm.sh`, `scripts/launcher-common.sh` |
|
| Packaging (rpm) | `scripts/packaging/rpm.sh`, `scripts/launcher-common.sh` |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ The [`docs/learnings/`](docs/learnings/) directory contains hard-won technical k
|
|||||||
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
|
- [`apt-worker-architecture.md`](docs/learnings/apt-worker-architecture.md) — APT/DNF binary distribution via Cloudflare Worker + GitHub Releases, redirect chain, credential ownership, heartbeat runbook
|
||||||
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
|
- [`tray-rebuild-race.md`](docs/learnings/tray-rebuild-race.md) — why destroy + recreate on `nativeTheme` updates briefly duplicates the tray icon on KDE Plasma, and the in-place `setImage` + `setContextMenu` fast-path that avoids the SNI re-registration race
|
||||||
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
|
- [`mcp-double-spawn.md`](docs/learnings/mcp-double-spawn.md) — Stdio MCPs spawn 2× when chat and Code/Agent panels are both active, root cause in upstream session managers, MCP-author workaround
|
||||||
|
- [`linux-topbar-shim.md`](docs/learnings/linux-topbar-shim.md) — why claude.ai's in-app topbar is missing on Linux, the four gates that hide it, why the upstream `frame:false` + WCO config has unclickable buttons on X11 (Chromium-level implicit drag region), and the resolution: hybrid mode (system frame + UA-spoof shim → stacked layout, full button functionality)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
4
build.sh
4
build.sh
@@ -52,8 +52,6 @@ source "$script_dir/scripts/setup/download.sh"
|
|||||||
source "$script_dir/scripts/patches/_common.sh"
|
source "$script_dir/scripts/patches/_common.sh"
|
||||||
# shellcheck source=scripts/patches/app-asar.sh
|
# shellcheck source=scripts/patches/app-asar.sh
|
||||||
source "$script_dir/scripts/patches/app-asar.sh"
|
source "$script_dir/scripts/patches/app-asar.sh"
|
||||||
# shellcheck source=scripts/patches/titlebar.sh
|
|
||||||
source "$script_dir/scripts/patches/titlebar.sh"
|
|
||||||
# shellcheck source=scripts/patches/tray.sh
|
# shellcheck source=scripts/patches/tray.sh
|
||||||
source "$script_dir/scripts/patches/tray.sh"
|
source "$script_dir/scripts/patches/tray.sh"
|
||||||
# shellcheck source=scripts/patches/quick-window.sh
|
# shellcheck source=scripts/patches/quick-window.sh
|
||||||
@@ -62,6 +60,8 @@ source "$script_dir/scripts/patches/quick-window.sh"
|
|||||||
source "$script_dir/scripts/patches/claude-code.sh"
|
source "$script_dir/scripts/patches/claude-code.sh"
|
||||||
# shellcheck source=scripts/patches/cowork.sh
|
# shellcheck source=scripts/patches/cowork.sh
|
||||||
source "$script_dir/scripts/patches/cowork.sh"
|
source "$script_dir/scripts/patches/cowork.sh"
|
||||||
|
# shellcheck source=scripts/patches/wco-shim.sh
|
||||||
|
source "$script_dir/scripts/patches/wco-shim.sh"
|
||||||
# shellcheck source=scripts/staging/electron.sh
|
# shellcheck source=scripts/staging/electron.sh
|
||||||
source "$script_dir/scripts/staging/electron.sh"
|
source "$script_dir/scripts/staging/electron.sh"
|
||||||
# shellcheck source=scripts/staging/icons.sh
|
# shellcheck source=scripts/staging/icons.sh
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Model Context Protocol settings are stored in:
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
|
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
|
||||||
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
|
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
|
||||||
|
| `CLAUDE_TITLEBAR_STYLE` | unset (`hybrid`) | Controls window decoration style: `hybrid` (system frame + in-app topbar), `native` (system frame, no in-app topbar), `hidden` (frameless WCO — broken on X11, kept for diagnostics). See [Titlebar Style](#titlebar-style) below. |
|
||||||
| `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. |
|
| `COWORK_VM_BACKEND` | unset (auto-detect) | Force a specific Cowork isolation backend: `kvm` (full VM), `bwrap` (bubblewrap namespace sandbox), or `host` (no isolation). See [Cowork Backend](#cowork-backend) below. |
|
||||||
|
|
||||||
### Wayland Support
|
### Wayland Support
|
||||||
@@ -49,6 +50,28 @@ CLAUDE_MENU_BAR=visible claude-desktop
|
|||||||
export CLAUDE_MENU_BAR=visible
|
export CLAUDE_MENU_BAR=visible
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Titlebar Style
|
||||||
|
|
||||||
|
Claude Desktop's web UI includes a custom topbar (hamburger menu, sidebar toggle, search, back/forward, Cowork ghost). On Windows / macOS the bundle gates rendering on `display-mode: window-controls-overlay`; on Linux a shim convinces the bundle to render anyway. Use `CLAUDE_TITLEBAR_STYLE` to choose the layout:
|
||||||
|
|
||||||
|
| Value | Frame | In-app topbar | Window controls drawn by | Notes |
|
||||||
|
|-------|-------|--------------|--------------------------|-------|
|
||||||
|
| unset / `hybrid` | system | Yes | Desktop environment | **Default.** Stacked layout — DE-drawn titlebar on top, in-app topbar below. Topbar buttons clickable. |
|
||||||
|
| `native` | system | No | Desktop environment | When the stacked layout looks wrong on your DE, or you don't need the in-app topbar. |
|
||||||
|
| `hidden` | frameless | Yes | Chromium (WCO region) | Matches Windows / macOS upstream config. **Broken on Linux X11** — topbar buttons unresponsive due to a Chromium-level implicit drag region for `frame:false` windows. Kept for diagnostic / Wayland investigation; see [docs/learnings/linux-topbar-shim.md](learnings/linux-topbar-shim.md). |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Switch to the bare native experience (no in-app topbar)
|
||||||
|
CLAUDE_TITLEBAR_STYLE=native claude-desktop
|
||||||
|
|
||||||
|
# Or add to your environment permanently
|
||||||
|
export CLAUDE_TITLEBAR_STYLE=native
|
||||||
|
```
|
||||||
|
|
||||||
|
This setting applies to the main window only. The Quick Entry and About windows are always frameless.
|
||||||
|
|
||||||
|
Run `claude-desktop --doctor` to confirm the resolved titlebar style. The doctor output also flags `hidden` mode as broken on Linux and unrecognized values as fallbacks to `hybrid`.
|
||||||
|
|
||||||
## Cowork Backend
|
## Cowork Backend
|
||||||
|
|
||||||
Cowork mode auto-detects the best available isolation backend:
|
Cowork mode auto-detects the best available isolation backend:
|
||||||
|
|||||||
BIN
docs/learnings/images/linux-topbar-hybrid.png
Normal file
BIN
docs/learnings/images/linux-topbar-hybrid.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
367
docs/learnings/linux-topbar-shim.md
Normal file
367
docs/learnings/linux-topbar-shim.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -406,6 +406,29 @@ run_doctor() {
|
|||||||
_info 'Menu bar mode: auto (default, Alt toggles visibility)'
|
_info 'Menu bar mode: auto (default, Alt toggles visibility)'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# -- Titlebar style --
|
||||||
|
local titlebar_style="${CLAUDE_TITLEBAR_STYLE:-}"
|
||||||
|
if [[ -n $titlebar_style ]]; then
|
||||||
|
local resolved_style="${titlebar_style,,}"
|
||||||
|
case "$resolved_style" in
|
||||||
|
hybrid|native)
|
||||||
|
_pass "Titlebar style: $resolved_style" \
|
||||||
|
"(CLAUDE_TITLEBAR_STYLE=$titlebar_style)"
|
||||||
|
;;
|
||||||
|
hidden)
|
||||||
|
_warn "Titlebar style: hidden — topbar clicks unresponsive on Linux (both X11 and Wayland)"
|
||||||
|
_info 'Use hybrid (default) or native for clickable buttons'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
_warn "Unknown CLAUDE_TITLEBAR_STYLE: '$titlebar_style'"
|
||||||
|
_info 'Will fall back to hybrid'
|
||||||
|
_info 'Valid values: hybrid, native, hidden'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
|
||||||
|
fi
|
||||||
|
|
||||||
# -- Electron binary --
|
# -- Electron binary --
|
||||||
# Version is read from the file next to the binary rather than
|
# Version is read from the file next to the binary rather than
|
||||||
# launching Electron, which can hang (see #371).
|
# launching Electron, which can hang (see #371).
|
||||||
|
|||||||
@@ -38,6 +38,37 @@ if (resolvedMode !== rawMenuBarMode) {
|
|||||||
}
|
}
|
||||||
console.log(`[Frame Fix] Menu bar mode: ${MENU_BAR_MODE}`);
|
console.log(`[Frame Fix] Menu bar mode: ${MENU_BAR_MODE}`);
|
||||||
|
|
||||||
|
// Titlebar mode, controlled by CLAUDE_TITLEBAR_STYLE env var:
|
||||||
|
// 'hybrid' (default) - native OS frame (frame:true) + wco-shim active.
|
||||||
|
// Stacked layout: OS titlebar on top draws
|
||||||
|
// min/max/close, claude.ai's in-app topbar
|
||||||
|
// renders below it via the shim's UA +
|
||||||
|
// matchMedia overrides. Topbar buttons clickable.
|
||||||
|
// Recommended Linux experience.
|
||||||
|
// 'native' - system-decorated window (frame:true), no shim.
|
||||||
|
// DE draws min/max/close; claude.ai's in-app
|
||||||
|
// topbar is hidden by its UA gate. Use if the
|
||||||
|
// in-app topbar conflicts with your DE.
|
||||||
|
// 'hidden' - frameless window with Window Controls Overlay
|
||||||
|
// configured (matches Windows / macOS upstream).
|
||||||
|
// BROKEN ON LINUX X11: topbar buttons not
|
||||||
|
// clickable because Chromium creates an implicit
|
||||||
|
// WM-level drag region for frameless windows
|
||||||
|
// that intercepts mouse events. Kept for
|
||||||
|
// Wayland comparison and future investigation;
|
||||||
|
// see docs/learnings/linux-topbar-shim.md.
|
||||||
|
// Applies to the main window only. Popups (Quick Entry, About) are
|
||||||
|
// always frameless regardless of this setting.
|
||||||
|
const VALID_TITLEBAR_STYLES = ['hybrid', 'native', 'hidden'];
|
||||||
|
const rawTitlebarStyle = (process.env.CLAUDE_TITLEBAR_STYLE || 'hybrid').toLowerCase();
|
||||||
|
const TITLEBAR_STYLE = VALID_TITLEBAR_STYLES.includes(rawTitlebarStyle)
|
||||||
|
? rawTitlebarStyle
|
||||||
|
: 'hybrid';
|
||||||
|
if (rawTitlebarStyle !== TITLEBAR_STYLE) {
|
||||||
|
console.warn(`[Frame Fix] Unknown CLAUDE_TITLEBAR_STYLE value '${process.env.CLAUDE_TITLEBAR_STYLE}', falling back to 'hybrid'. Valid: ${VALID_TITLEBAR_STYLES.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.log(`[Frame Fix] Titlebar style: ${TITLEBAR_STYLE}`);
|
||||||
|
|
||||||
// Keep the app alive when the main window is closed (hide to tray),
|
// Keep the app alive when the main window is closed (hide to tray),
|
||||||
// so in-app schedulers / MCP servers / the tray icon survive a
|
// so in-app schedulers / MCP servers / the tray icon survive a
|
||||||
// stray click on X. Explicit quit paths (Ctrl+Q via the focused
|
// stray click on X. Explicit quit paths (Ctrl+Q via the focused
|
||||||
@@ -118,17 +149,67 @@ Module.prototype.require = function(id) {
|
|||||||
delete options.titleBarStyle;
|
delete options.titleBarStyle;
|
||||||
delete options.titleBarOverlay;
|
delete options.titleBarOverlay;
|
||||||
console.log('[Frame Fix] Popup detected, keeping frameless');
|
console.log('[Frame Fix] Popup detected, keeping frameless');
|
||||||
} else {
|
} else if (TITLEBAR_STYLE === 'native') {
|
||||||
// Main window: force native frame
|
// Main window, native mode: force system frame.
|
||||||
options.frame = true;
|
options.frame = true;
|
||||||
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
|
// Menu bar behavior depends on CLAUDE_MENU_BAR mode:
|
||||||
// 'auto' (default): hidden, Alt toggles
|
// 'auto' (default): hidden, Alt toggles
|
||||||
// 'visible'/'hidden': no Alt toggle
|
// 'visible'/'hidden': no Alt toggle
|
||||||
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||||
// Remove custom titlebar options
|
|
||||||
delete options.titleBarStyle;
|
delete options.titleBarStyle;
|
||||||
delete options.titleBarOverlay;
|
delete options.titleBarOverlay;
|
||||||
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
|
console.log(`[Frame Fix] Modified frame from ${originalFrame} to true`);
|
||||||
|
} else if (TITLEBAR_STYLE === 'hybrid') {
|
||||||
|
// Main window, hybrid mode: native OS frame +
|
||||||
|
// claude.ai's in-app topbar via wco-shim.
|
||||||
|
//
|
||||||
|
// Why this shape: Linux X11 + frameless windows
|
||||||
|
// hits a Chromium-level implicit drag region at
|
||||||
|
// the top of the window that intercepts mouse
|
||||||
|
// events at the WM level. We've ruled out
|
||||||
|
// titleBarOverlay and titleBarStyle as the source
|
||||||
|
// (disabling either still produced unclickable
|
||||||
|
// topbar buttons). The drag region appears to be
|
||||||
|
// a Linux-X11 default for frame:false windows. With
|
||||||
|
// frame:true the OS handles dragging via the native
|
||||||
|
// titlebar and Chromium pushes no drag-region map,
|
||||||
|
// so the in-app topbar's buttons are clickable.
|
||||||
|
//
|
||||||
|
// Visual trade-off vs Windows: stacked layout — OS
|
||||||
|
// titlebar on top, in-app topbar below it. The
|
||||||
|
// buttons we care about (hamburger / sidebar /
|
||||||
|
// search / nav / Cowork ghost) all live in the
|
||||||
|
// in-app topbar via the shim's UA + matchMedia
|
||||||
|
// overrides. The shim's className intercept stays
|
||||||
|
// as belt-and-suspenders against the .draggable
|
||||||
|
// CSS rule still applying within the framed
|
||||||
|
// window's content area.
|
||||||
|
options.frame = true;
|
||||||
|
options.autoHideMenuBar = (MENU_BAR_MODE === 'auto');
|
||||||
|
delete options.titleBarStyle;
|
||||||
|
delete options.titleBarOverlay;
|
||||||
|
console.log('[Frame Fix] Hybrid mode: native frame + in-app topbar shim');
|
||||||
|
} else {
|
||||||
|
// Main window, hidden mode: frameless + Window Controls
|
||||||
|
// Overlay configured (matches Windows / macOS upstream).
|
||||||
|
// BROKEN ON LINUX X11 — topbar buttons not clickable
|
||||||
|
// because Chromium creates an implicit drag region for
|
||||||
|
// frame:false windows that intercepts mouse events at
|
||||||
|
// the WM level. Investigation chain in
|
||||||
|
// docs/learnings/linux-topbar-shim.md ruled out
|
||||||
|
// titleBarOverlay height and titleBarStyle:'hidden' as
|
||||||
|
// the source. The default is now 'hybrid'; this branch
|
||||||
|
// is kept for Wayland comparison and future probes.
|
||||||
|
options.frame = false;
|
||||||
|
options.titleBarStyle = 'hidden';
|
||||||
|
options.titleBarOverlay = {
|
||||||
|
color: '#1a1a1a',
|
||||||
|
symbolColor: '#ffffff',
|
||||||
|
height: 40,
|
||||||
|
};
|
||||||
|
console.log('[Frame Fix] Hidden mode (frame=false, '
|
||||||
|
+ 'titleBarStyle=hidden, titleBarOverlay=object) — '
|
||||||
|
+ 'topbar clicks broken on X11');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
super(options);
|
super(options);
|
||||||
@@ -144,6 +225,56 @@ Module.prototype.require = function(id) {
|
|||||||
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
|
this.webContents.insertCSS(LINUX_CSS).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WCO diagnostic: probe Chromium's native Window Controls
|
||||||
|
// Overlay state on the main window webContents. Upstream
|
||||||
|
// electron/electron#41769 (June 2024) implements WCO on
|
||||||
|
// Linux X11; runtime probes (2026-04-29) show the API
|
||||||
|
// surface returns visible:true here while display-mode
|
||||||
|
// and env() vars don't match — partial implementation.
|
||||||
|
// env() extraction goes through a custom-property
|
||||||
|
// indirection because getPropertyValue('env(...)') is
|
||||||
|
// invalid; env() is only meaningful inside CSS values.
|
||||||
|
// Logs to stdout so the result lands in launcher.log.
|
||||||
|
// Only meaningful for non-popup main windows in hidden
|
||||||
|
// mode (the only path that requests WCO).
|
||||||
|
if (!popup && TITLEBAR_STYLE !== 'native') {
|
||||||
|
this.webContents.on('did-finish-load', () => {
|
||||||
|
this.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
const wco = navigator.windowControlsOverlay;
|
||||||
|
let rect = null;
|
||||||
|
try {
|
||||||
|
const r = wco && wco.getTitlebarAreaRect && wco.getTitlebarAreaRect();
|
||||||
|
if (r) rect = { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.textContent = ':root{--probe-tbx:env(titlebar-area-x);--probe-tby:env(titlebar-area-y);--probe-tbw:env(titlebar-area-width);--probe-tbh:env(titlebar-area-height);}';
|
||||||
|
document.head.appendChild(s);
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const result = {
|
||||||
|
visible: !!(wco && wco.visible),
|
||||||
|
rect,
|
||||||
|
media_wco: matchMedia('(display-mode: window-controls-overlay)').matches,
|
||||||
|
media_standalone: matchMedia('(display-mode: standalone)').matches,
|
||||||
|
media_browser: matchMedia('(display-mode: browser)').matches,
|
||||||
|
env_x: cs.getPropertyValue('--probe-tbx').trim(),
|
||||||
|
env_y: cs.getPropertyValue('--probe-tby').trim(),
|
||||||
|
env_w: cs.getPropertyValue('--probe-tbw').trim(),
|
||||||
|
env_h: cs.getPropertyValue('--probe-tbh').trim(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
location: location.href,
|
||||||
|
};
|
||||||
|
s.remove();
|
||||||
|
return JSON.stringify(result);
|
||||||
|
})()
|
||||||
|
`).then((json) => {
|
||||||
|
console.log('[WCO Diagnostic] main window webContents:', json);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.warn('[WCO Diagnostic] main window probe failed:', err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Quit on Ctrl+Q, but only when Claude has keyboard focus.
|
// Quit on Ctrl+Q, but only when Claude has keyboard focus.
|
||||||
// Replaces a prior globalShortcut registration that grabbed
|
// Replaces a prior globalShortcut registration that grabbed
|
||||||
// the key system-wide and, on non-QWERTY layouts (e.g.
|
// the key system-wide and, on non-QWERTY layouts (e.g.
|
||||||
@@ -373,6 +504,47 @@ Module.prototype.require = function(id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WCO diagnostic console mirror + global Ctrl+Q.
|
||||||
|
//
|
||||||
|
// The console mirror forwards [WCO Diagnostic] / [WCO Shim] /
|
||||||
|
// [Drag Shim] messages from any webContents (including the
|
||||||
|
// BrowserView that hosts claude.ai) back to stdout so probes
|
||||||
|
// run from preload land in launcher.log alongside the main
|
||||||
|
// window probe. Filtered prefixes avoid mirroring claude.ai's
|
||||||
|
// noisy console.
|
||||||
|
//
|
||||||
|
// The Ctrl+Q handler is replicated here from the per-window
|
||||||
|
// setup above because before-input-event only fires on the
|
||||||
|
// webContents that has keyboard focus. The BrowserView has
|
||||||
|
// its own webContents that takes focus over the main window,
|
||||||
|
// so a handler on the main window alone never sees keypresses
|
||||||
|
// when the BrowserView is focused (the typical case). Adding
|
||||||
|
// it to every webContents covers main + BrowserView + popups.
|
||||||
|
// Linux-only because the per-window handler above is
|
||||||
|
// Linux-only (and macOS has Cmd+Q natively).
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
result.app.on('web-contents-created', (_evt, wc) => {
|
||||||
|
if (TITLEBAR_STYLE !== 'native') {
|
||||||
|
wc.on('console-message', (event) => {
|
||||||
|
const msg = (event && event.message) || '';
|
||||||
|
if (msg.startsWith('[WCO Diagnostic]')
|
||||||
|
|| msg.startsWith('[WCO Shim]')
|
||||||
|
|| msg.startsWith('[Drag Shim]')) {
|
||||||
|
console.log('[BrowserView]', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wc.on('before-input-event', (event, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
if (!input.control) return;
|
||||||
|
if (input.alt || input.shift || input.meta) return;
|
||||||
|
if (input.key !== 'q' && input.key !== 'Q') return;
|
||||||
|
event.preventDefault();
|
||||||
|
result.app.quit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Route app.{get,set}LoginItemSettings through XDG Autostart on Linux.
|
// Route app.{get,set}LoginItemSettings through XDG Autostart on Linux.
|
||||||
// Electron's openAtLogin is a no-op on Linux (electron/electron#15198),
|
// Electron's openAtLogin is a no-op on Linux (electron/electron#15198),
|
||||||
// which both prevents the app's "Run on startup" toggle from
|
// which both prevents the app's "Run on startup" toggle from
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ check_display() {
|
|||||||
[[ -n $DISPLAY || -n $WAYLAND_DISPLAY ]]
|
[[ -n $DISPLAY || -n $WAYLAND_DISPLAY ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve CLAUDE_TITLEBAR_STYLE to one of {hybrid,native,hidden},
|
||||||
|
# defaulting to 'hybrid' when unset or invalid. Echoed (not exported)
|
||||||
|
# so callers can branch on it without polluting the environment.
|
||||||
|
# 'hybrid' is the recommended Linux experience: native OS frame +
|
||||||
|
# in-app topbar via the wco-shim. 'hidden' is upstream's frameless
|
||||||
|
# WCO config; broken on Linux X11 (clicks unresponsive) but kept for
|
||||||
|
# Wayland/diagnostic comparison.
|
||||||
|
_resolve_titlebar_style() {
|
||||||
|
local raw="${CLAUDE_TITLEBAR_STYLE:-hybrid}"
|
||||||
|
raw="${raw,,}"
|
||||||
|
case "$raw" in
|
||||||
|
hybrid|hidden|native) echo "$raw" ;;
|
||||||
|
*) echo 'hybrid' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# Build Electron arguments array based on display backend
|
# Build Electron arguments array based on display backend
|
||||||
# Requires: is_wayland, use_x11_on_wayland to be set
|
# Requires: is_wayland, use_x11_on_wayland to be set
|
||||||
# (call detect_display_backend first)
|
# (call detect_display_backend first)
|
||||||
@@ -64,8 +80,21 @@ build_electron_args() {
|
|||||||
# AppImage always needs --no-sandbox due to FUSE constraints
|
# AppImage always needs --no-sandbox due to FUSE constraints
|
||||||
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
|
[[ $package_type == 'appimage' ]] && electron_args+=('--no-sandbox')
|
||||||
|
|
||||||
# Disable CustomTitlebar for better Linux integration
|
# CLAUDE_TITLEBAR_STYLE selects between:
|
||||||
electron_args+=('--disable-features=CustomTitlebar')
|
# hybrid (default) / native: --disable-features=CustomTitlebar
|
||||||
|
# so Chromium's drawn CSD titlebar doesn't compete with
|
||||||
|
# the DE-drawn one. Both modes use frame:true.
|
||||||
|
# hidden: --enable-features=WindowControlsOverlay because WCO
|
||||||
|
# is off by default on Linux Chromium (Win/macOS have
|
||||||
|
# it on by default). Without this flag, titleBarOverlay
|
||||||
|
# is silently ignored at the page level.
|
||||||
|
local _tb
|
||||||
|
_tb=$(_resolve_titlebar_style)
|
||||||
|
if [[ $_tb == 'hidden' ]]; then
|
||||||
|
electron_args+=('--enable-features=WindowControlsOverlay')
|
||||||
|
else
|
||||||
|
electron_args+=('--disable-features=CustomTitlebar')
|
||||||
|
fi
|
||||||
|
|
||||||
# Remote XRDP sessions lack GPU acceleration and render a blank
|
# Remote XRDP sessions lack GPU acceleration and render a blank
|
||||||
# window when GPU compositing is enabled. Detect via XRDP_SESSION
|
# window when GPU compositing is enabled. Detect via XRDP_SESSION
|
||||||
@@ -247,7 +276,12 @@ setup_electron_env() {
|
|||||||
# copied and app resources co-located in resources/, so resourcesPath
|
# copied and app resources co-located in resources/, so resourcesPath
|
||||||
# naturally points to the right place on all package types.
|
# naturally points to the right place on all package types.
|
||||||
export ELECTRON_FORCE_IS_PACKAGED=true
|
export ELECTRON_FORCE_IS_PACKAGED=true
|
||||||
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
|
# ELECTRON_USE_SYSTEM_TITLE_BAR=1 forces a system titlebar at the
|
||||||
|
# Electron level. Set in 'native' and 'hybrid' modes (both use
|
||||||
|
# frame:true); skipped in 'hidden' mode (frame:false + WCO config).
|
||||||
|
if [[ $(_resolve_titlebar_style) != 'hidden' ]]; then
|
||||||
|
export ELECTRON_USE_SYSTEM_TITLE_BAR=1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
#===============================================================================
|
#===============================================================================
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ console.log('Updated package.json: main entry and node-pty dependency');
|
|||||||
cp "$claude_extract_dir/lib/net45/resources/Tray"* app.asar.contents/resources/ 2>/dev/null || \
|
cp "$claude_extract_dir/lib/net45/resources/Tray"* app.asar.contents/resources/ 2>/dev/null || \
|
||||||
echo 'Warning: No tray icon files found for asar inclusion'
|
echo 'Warning: No tray icon files found for asar inclusion'
|
||||||
|
|
||||||
# Patch title bar detection
|
|
||||||
patch_titlebar_detection
|
|
||||||
|
|
||||||
# Extract electron module variable name for tray patches
|
# Extract electron module variable name for tray patches
|
||||||
extract_electron_variable
|
extract_electron_variable
|
||||||
|
|
||||||
@@ -93,6 +90,13 @@ console.log('Updated package.json: main entry and node-pty dependency');
|
|||||||
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
|
# Patch Cowork mode for Linux (TypeScript VM client + Unix socket)
|
||||||
patch_cowork_linux
|
patch_cowork_linux
|
||||||
|
|
||||||
|
# Inject WCO shim into the BrowserView preload so claude.ai's
|
||||||
|
# desktop topbar renders on Linux. The shim spoofs the bundle's
|
||||||
|
# isWindows() UA check (load-bearing) plus matchMedia and
|
||||||
|
# windowControlsOverlay (defensive). See
|
||||||
|
# docs/learnings/linux-topbar-shim.md.
|
||||||
|
patch_wco_shim
|
||||||
|
|
||||||
# Copy cowork VM service daemon for Linux Cowork mode
|
# Copy cowork VM service daemon for Linux Cowork mode
|
||||||
echo 'Installing cowork VM service daemon...'
|
echo 'Installing cowork VM service daemon...'
|
||||||
cp "$source_dir/scripts/cowork-vm-service.js" \
|
cp "$source_dir/scripts/cowork-vm-service.js" \
|
||||||
|
|||||||
@@ -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 '##############################################################'
|
|
||||||
}
|
|
||||||
40
scripts/patches/wco-shim.sh
Normal file
40
scripts/patches/wco-shim.sh
Normal 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
267
scripts/wco-shim.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -36,7 +36,9 @@ setup() {
|
|||||||
unset NIRI_SOCKET
|
unset NIRI_SOCKET
|
||||||
unset XDG_CURRENT_DESKTOP
|
unset XDG_CURRENT_DESKTOP
|
||||||
unset CLAUDE_MENU_BAR
|
unset CLAUDE_MENU_BAR
|
||||||
|
unset CLAUDE_TITLEBAR_STYLE
|
||||||
unset COWORK_VM_BACKEND
|
unset COWORK_VM_BACKEND
|
||||||
|
unset ELECTRON_USE_SYSTEM_TITLE_BAR
|
||||||
|
|
||||||
# shellcheck source=scripts/launcher-common.sh
|
# shellcheck source=scripts/launcher-common.sh
|
||||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||||
@@ -271,11 +273,115 @@ teardown() {
|
|||||||
[[ $ELECTRON_FORCE_IS_PACKAGED == 'true' ]]
|
[[ $ELECTRON_FORCE_IS_PACKAGED == 'true' ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR" {
|
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR in hybrid mode (default)" {
|
||||||
setup_electron_env
|
setup_electron_env
|
||||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR in native mode" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=native setup_electron_env
|
||||||
|
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "setup_electron_env: skips ELECTRON_USE_SYSTEM_TITLE_BAR in hidden mode" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=hidden setup_electron_env
|
||||||
|
[[ -z ${ELECTRON_USE_SYSTEM_TITLE_BAR:-} ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "setup_electron_env: skips ELECTRON_USE_SYSTEM_TITLE_BAR for invalid value (falls back to hybrid)" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=garbage setup_electron_env
|
||||||
|
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# _resolve_titlebar_style
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: returns 'hybrid' when unset" {
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: returns 'hybrid' for hybrid" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=hybrid
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: returns 'native' for native" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=native
|
||||||
|
[[ $(_resolve_titlebar_style) == 'native' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: returns 'hidden' for hidden" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=hidden
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hidden' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: case-insensitive (HYBRID)" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=HYBRID
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: case-insensitive (Native)" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=Native
|
||||||
|
[[ $(_resolve_titlebar_style) == 'native' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: case-insensitive (Hidden)" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=Hidden
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hidden' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: falls back to hybrid for invalid value" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=garbage
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "_resolve_titlebar_style: falls back to hybrid for empty value" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=''
|
||||||
|
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# build_electron_args: titlebar mode flag selection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@test "build_electron_args: hybrid mode (default) disables CustomTitlebar" {
|
||||||
|
is_wayland=false
|
||||||
|
setup_logging
|
||||||
|
build_electron_args deb
|
||||||
|
has_electron_arg '--disable-features=CustomTitlebar'
|
||||||
|
# shellcheck disable=SC2314
|
||||||
|
! has_electron_arg '--enable-features=WindowControlsOverlay'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "build_electron_args: native mode disables CustomTitlebar" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=native
|
||||||
|
is_wayland=false
|
||||||
|
setup_logging
|
||||||
|
build_electron_args deb
|
||||||
|
has_electron_arg '--disable-features=CustomTitlebar'
|
||||||
|
# shellcheck disable=SC2314
|
||||||
|
! has_electron_arg '--enable-features=WindowControlsOverlay'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "build_electron_args: hidden mode enables WindowControlsOverlay" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=hidden
|
||||||
|
is_wayland=false
|
||||||
|
setup_logging
|
||||||
|
build_electron_args deb
|
||||||
|
has_electron_arg '--enable-features=WindowControlsOverlay'
|
||||||
|
# shellcheck disable=SC2314
|
||||||
|
! has_electron_arg '--disable-features=CustomTitlebar'
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "build_electron_args: invalid titlebar value falls back to hybrid flags" {
|
||||||
|
CLAUDE_TITLEBAR_STYLE=garbage
|
||||||
|
is_wayland=false
|
||||||
|
setup_logging
|
||||||
|
build_electron_args deb
|
||||||
|
has_electron_arg '--disable-features=CustomTitlebar'
|
||||||
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# cleanup_stale_lock
|
# cleanup_stale_lock
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user