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)
|
||||
│ │ ├── _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 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
build.sh
4
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
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)'
|
||||
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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
#===============================================================================
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 XDG_CURRENT_DESKTOP
|
||||
unset CLAUDE_MENU_BAR
|
||||
unset CLAUDE_TITLEBAR_STYLE
|
||||
unset COWORK_VM_BACKEND
|
||||
unset ELECTRON_USE_SYSTEM_TITLE_BAR
|
||||
|
||||
# shellcheck source=scripts/launcher-common.sh
|
||||
source "$SCRIPT_DIR/../scripts/launcher-common.sh"
|
||||
@@ -271,11 +273,115 @@ teardown() {
|
||||
[[ $ELECTRON_FORCE_IS_PACKAGED == 'true' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR" {
|
||||
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR in hybrid mode (default)" {
|
||||
setup_electron_env
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: sets ELECTRON_USE_SYSTEM_TITLE_BAR in native mode" {
|
||||
CLAUDE_TITLEBAR_STYLE=native setup_electron_env
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: skips ELECTRON_USE_SYSTEM_TITLE_BAR in hidden mode" {
|
||||
CLAUDE_TITLEBAR_STYLE=hidden setup_electron_env
|
||||
[[ -z ${ELECTRON_USE_SYSTEM_TITLE_BAR:-} ]]
|
||||
}
|
||||
|
||||
@test "setup_electron_env: skips ELECTRON_USE_SYSTEM_TITLE_BAR for invalid value (falls back to hybrid)" {
|
||||
CLAUDE_TITLEBAR_STYLE=garbage setup_electron_env
|
||||
[[ $ELECTRON_USE_SYSTEM_TITLE_BAR == '1' ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# _resolve_titlebar_style
|
||||
# =============================================================================
|
||||
|
||||
@test "_resolve_titlebar_style: returns 'hybrid' when unset" {
|
||||
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: returns 'hybrid' for hybrid" {
|
||||
CLAUDE_TITLEBAR_STYLE=hybrid
|
||||
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: returns 'native' for native" {
|
||||
CLAUDE_TITLEBAR_STYLE=native
|
||||
[[ $(_resolve_titlebar_style) == 'native' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: returns 'hidden' for hidden" {
|
||||
CLAUDE_TITLEBAR_STYLE=hidden
|
||||
[[ $(_resolve_titlebar_style) == 'hidden' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: case-insensitive (HYBRID)" {
|
||||
CLAUDE_TITLEBAR_STYLE=HYBRID
|
||||
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: case-insensitive (Native)" {
|
||||
CLAUDE_TITLEBAR_STYLE=Native
|
||||
[[ $(_resolve_titlebar_style) == 'native' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: case-insensitive (Hidden)" {
|
||||
CLAUDE_TITLEBAR_STYLE=Hidden
|
||||
[[ $(_resolve_titlebar_style) == 'hidden' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: falls back to hybrid for invalid value" {
|
||||
CLAUDE_TITLEBAR_STYLE=garbage
|
||||
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||
}
|
||||
|
||||
@test "_resolve_titlebar_style: falls back to hybrid for empty value" {
|
||||
CLAUDE_TITLEBAR_STYLE=''
|
||||
[[ $(_resolve_titlebar_style) == 'hybrid' ]]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# build_electron_args: titlebar mode flag selection
|
||||
# =============================================================================
|
||||
|
||||
@test "build_electron_args: hybrid mode (default) disables CustomTitlebar" {
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--disable-features=CustomTitlebar'
|
||||
# shellcheck disable=SC2314
|
||||
! has_electron_arg '--enable-features=WindowControlsOverlay'
|
||||
}
|
||||
|
||||
@test "build_electron_args: native mode disables CustomTitlebar" {
|
||||
CLAUDE_TITLEBAR_STYLE=native
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--disable-features=CustomTitlebar'
|
||||
# shellcheck disable=SC2314
|
||||
! has_electron_arg '--enable-features=WindowControlsOverlay'
|
||||
}
|
||||
|
||||
@test "build_electron_args: hidden mode enables WindowControlsOverlay" {
|
||||
CLAUDE_TITLEBAR_STYLE=hidden
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--enable-features=WindowControlsOverlay'
|
||||
# shellcheck disable=SC2314
|
||||
! has_electron_arg '--disable-features=CustomTitlebar'
|
||||
}
|
||||
|
||||
@test "build_electron_args: invalid titlebar value falls back to hybrid flags" {
|
||||
CLAUDE_TITLEBAR_STYLE=garbage
|
||||
is_wayland=false
|
||||
setup_logging
|
||||
build_electron_args deb
|
||||
has_electron_arg '--disable-features=CustomTitlebar'
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# cleanup_stale_lock
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user