diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c448e3ac031..50f69b38339 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -165,9 +165,11 @@ import { import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { spawnUpdaterProcess } from './updater-process' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' +import { registerWindowControlIpc, windowControlState } from './window-controls' import { computeWindowOptions, debounce, + maximizedBoundsCorrection, sanitizeWindowState, MIN_HEIGHT as WINDOW_MIN_HEIGHT, MIN_WIDTH as WINDOW_MIN_WIDTH @@ -204,6 +206,14 @@ import { readWindowsUserEnvVar } from './windows-user-env' import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' import { readWslWindowsClipboardImage } from './wsl-clipboard-image' import { resolvePickerDefaultPath } from './wsl-path-bridge' +import { + decideWslgGpuLaunch, + gpuCrashEngagesFallback, + isGpuChildCrash, + readWslgGpuMarker, + writeWslgGpuMarker, + wslgGpuMarkerAfterSuccessfulBoot +} from './wslg-gpu-fallback' const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR @@ -250,13 +260,38 @@ if (REMOTE_DISPLAY_REASON) { } // WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag. -// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote -// display already forced software (SSH'd-into-WSL). +// /dev/dxg means a real GPU is available; un-blocklist it for a snappier UI. +// Skipped when a remote display already forced software (SSH'd-into-WSL), or +// when a prior session's un-blocklisted GPU crash-looped — decideWslgGpuLaunch +// consults a sticky per-version marker so we don't re-enter the crash loop on +// every launch. Runtime GPU-crash detection (child-process-gone below) arms the +// fallback; a successful boot / app update re-probes it. +let wslgGpuFallbackActive = false +let wslgGpuCrashCount = 0 + if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { - app.commandLine.appendSwitch('ignore-gpu-blocklist') - app.commandLine.appendSwitch('enable-gpu-rasterization') - app.commandLine.appendSwitch('enable-zero-copy') - console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration') + const wslgUserData = app.getPath('userData') + + const gpuDecision = decideWslgGpuLaunch({ + marker: readWslgGpuMarker(wslgUserData), + appVersion: app.getVersion() + }) + + writeWslgGpuMarker(wslgUserData, gpuDecision.nextMarker) + + if (gpuDecision.enableGpu) { + app.commandLine.appendSwitch('ignore-gpu-blocklist') + app.commandLine.appendSwitch('enable-gpu-rasterization') + app.commandLine.appendSwitch('enable-zero-copy') + console.log( + `[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration${gpuDecision.reason ? ` (${gpuDecision.reason})` : ''}` + ) + } else { + wslgGpuFallbackActive = true + console.log( + `[hermes] WSL GPU passthrough disabled (${gpuDecision.reason}); a prior session crash-looped the vGPU — using software compositing` + ) + } } // Windows sandbox / GPU breakpoint crash recovery (#38216). @@ -360,6 +395,37 @@ if (IS_WINDOWS) { }) } +// WSLg GPU crash-loop guard. When this session un-blocklisted the vGPU +// (wslgGpuFallbackActive === false) but the GPU process keeps segfaulting +// (exit_code=139, "samplerYcbcrConversion is not supported"), count the crashes +// and, past the threshold, persist a sticky fallback so the NEXT launch skips +// the un-blocklist and rides Chromium's stable software path. Unlike the +// Windows breakpoint path we do NOT relaunch this session — Chromium already +// disables the GPU after repeated crashes, so we just make the fix stick. +if (IS_WSL && !wslgGpuFallbackActive) { + app.on('child-process-gone', (_event, details) => { + if (!isGpuChildCrash(details)) { + return + } + + wslgGpuCrashCount += 1 + + const fallback = gpuCrashEngagesFallback({ + crashCount: wslgGpuCrashCount, + appVersion: app.getVersion() + }) + + if (fallback && !wslgGpuFallbackActive) { + wslgGpuFallbackActive = true + writeWslgGpuMarker(app.getPath('userData'), fallback) + console.warn( + `[hermes] WSLg GPU crashed ${wslgGpuCrashCount}x (exit=${details?.exitCode}); ` + + 'disabling GPU passthrough on next launch to stop the crash loop' + ) + } + }) +} + ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer running at full speed while the window is in the background @@ -4655,7 +4721,8 @@ function getWindowState(win = mainWindow) { return { isFullscreen: Boolean(win?.isFullScreen?.()), nativeOverlayWidth: getNativeOverlayWidth(), - windowButtonPosition: getWindowButtonPosition() + windowButtonPosition: getWindowButtonPosition(), + ...windowControlState(win, IS_WSL) } } @@ -4777,6 +4844,30 @@ function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) webContents.send('hermes:window-state-changed', state) } +// WSLg's RAIL compositor can settle a frameless window's native maximize offset +// from the display work area (a desktop strip at top/left, content clipped +// bottom/right — reported on WSLg 1.0.65). Snap it back onto the work area. +// WSL-only and a no-op when the window already fills the work area, so healthy +// compositors (plain Linux, most WSLg versions) are never fought and it cannot +// loop on setBounds. See maximizedBoundsCorrection in window-state.ts. +function correctWslgMaximizeGap(win = mainWindow) { + if (!IS_WSL || !win || win.isDestroyed() || !win.isMaximized?.()) { + return + } + + try { + const bounds = win.getBounds() + const workArea = screen.getDisplayMatching(bounds)?.workArea + const correction = maximizedBoundsCorrection(bounds, workArea) + + if (correction) { + win.setBounds(correction) + } + } catch (error) { + rememberLog(`[wslg] maximize gap correction failed: ${error?.message || error}`) + } +} + function buildApplicationMenu() { const template = [] @@ -4939,12 +5030,13 @@ function installPreviewShortcut(window) { }) } -// Zoom level is persisted in the renderer's own localStorage (per-origin, -// survives reloads/restarts) rather than a main-process JSON file. The main -// process owns setZoomLevel, so we mirror each change into localStorage and -// read it back on did-finish-load to re-apply after reloads or crash recovery. +// Zoom level is persisted primarily in a main-process JSON file so crash +// recovery cannot wipe it. Renderer localStorage remains a compatibility +// mirror for older installs and downgrades. The main process owns setZoomLevel +// and re-applies the coordinated value after reloads and window transitions. import { applyZoomLevel, + createZoomCoordinator, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_STORAGE_KEY, @@ -4952,6 +5044,8 @@ import { zoomWiringForWindowKind } from './zoom' +const zoomCoordinator = createZoomCoordinator() + function setAndPersistZoomLevel(window, zoomLevel) { if (!window || window.isDestroyed()) { return @@ -4959,7 +5053,8 @@ function setAndPersistZoomLevel(window, zoomLevel) { // Apply + notify in one funnel so the settings UI stays in sync, including // changes made via the keyboard shortcuts or the View menu. - const next = applyZoomLevel(window.webContents, zoomLevel) + const next = zoomCoordinator.setDesired(zoomLevel) + applyZoomLevel(window.webContents, next) // Primary store: main-process JSON (survives crash recovery — #56726). writeZoomState(next) @@ -4979,19 +5074,29 @@ function restorePersistedZoomLevel(window) { return } + const cached = zoomCoordinator.getDesired() + + if (cached !== undefined) { + applyZoomLevel(window.webContents, cached) + + return + } + // Prefer the JSON file — it survives crash recovery wiping Electron's // cache/storage folders (#56726). applyZoomLevel notifies the renderer so // the Appearance UI Scale control stays in sync. const saved = readZoomState() if (saved != null) { - applyZoomLevel(window.webContents, saved) + applyZoomLevel(window.webContents, zoomCoordinator.setDesired(saved)) return } // Fall back to localStorage for installs that predate zoom-state.json, // migrating the value into the JSON store on first read. + const commitRestore = zoomCoordinator.beginRestore() + window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` @@ -5001,10 +5106,16 @@ function restorePersistedZoomLevel(window) { return } + const desired = commitRestore(Number(stored)) + + if (desired === undefined) { + return + } + // Notify the renderer too — otherwise the Appearance UI Scale control // can stay stuck at 100% even though the window zoom was restored. - const applied = applyZoomLevel(window.webContents, Number(stored)) - writeZoomState(applied) + applyZoomLevel(window.webContents, desired) + writeZoomState(desired) }) .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) } @@ -8431,6 +8542,28 @@ function createWindow() { rememberLog(`[sandbox] marker update after ready-to-show failed: ${error?.message || error}`) } } + + // WSLg GPU: confirm a clean boot only after the window has survived a few + // seconds of real compositing. The vGPU crash loop fires within the first + // ~2s (see the child-process-gone guard above), so a short delay lets the + // crash detector win the race and keep the sticky `fallback` instead of a + // premature `ok`. Skipped once the fallback has already engaged this run. + if (IS_WSL) { + setTimeout(() => { + if (wslgGpuFallbackActive) { + return + } + + try { + writeWslgGpuMarker( + app.getPath('userData'), + wslgGpuMarkerAfterSuccessfulBoot({ fallbackActive: false, appVersion: app.getVersion() }) + ) + } catch (error) { + rememberLog(`[wslg] gpu marker update after boot failed: ${error?.message || error}`) + } + }, 5000).unref?.() + } }) // Under Playright testing, instantly show the window. @@ -8450,8 +8583,16 @@ function createWindow() { // the cross-platform backstop, flushed synchronously before the window is gone. mainWindow.on('resized', schedulePersistWindowState) mainWindow.on('moved', schedulePersistWindowState) - mainWindow.on('maximize', schedulePersistWindowState) - mainWindow.on('unmaximize', schedulePersistWindowState) + mainWindow.on('maximize', () => { + correctWslgMaximizeGap(mainWindow) + // Renderer-drawn WSLg controls swap the maximize/restore glyph off this. + sendWindowStateChanged(undefined, mainWindow) + schedulePersistWindowState() + }) + mainWindow.on('unmaximize', () => { + sendWindowStateChanged(undefined, mainWindow) + schedulePersistWindowState() + }) mainWindow.on('close', () => schedulePersistWindowState.flush()) // the closed wrapper remains truthy, so clear only the window this callback owns. @@ -8642,6 +8783,7 @@ ipcMain.handle('hermes:window:openInstance', async () => { return { ok: true } }) +registerWindowControlIpc(ipcMain, sender => BrowserWindow.fromWebContents(sender)) // --- Text size (zoom) ------------------------------------------------------- // The settings UI drives the same clamped zoom scale as the Ctrl/Cmd diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd..c665e23083b 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,5 +1,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' +import { customWindowControlsEnabled } from './window-controls' + contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), @@ -8,6 +10,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', { openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), + windowControls: { + custom: customWindowControlsEnabled(), + minimize: () => ipcRenderer.send('hermes:window-control', 'minimize'), + toggleMaximize: () => ipcRenderer.send('hermes:window-control', 'toggle-maximize'), + close: () => ipcRenderer.send('hermes:window-control', 'close') + }, petOverlay: { // Main renderer → main process: window lifecycle + drag. `request` is // `{ bounds, screen }`; resolves with the screen bounds it actually used. diff --git a/apps/desktop/electron/titlebar-overlay-width.test.ts b/apps/desktop/electron/titlebar-overlay-width.test.ts index 0e424fd5c3e..72aa8e2e87a 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.ts +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -6,7 +6,8 @@ import { MACOS_TAHOE_DARWIN_MAJOR, macTitleBarOverlayHeight, nativeOverlayWidth, - OVERLAY_FALLBACK_WIDTH + OVERLAY_FALLBACK_WIDTH, + titleBarOverlayOptions } from './titlebar-overlay-width' // This static reservation is only the pre-layout FALLBACK. Once laid out the @@ -18,12 +19,60 @@ test('Windows reserves the overlay fallback width', () => { assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH) }) -test('WSLg paints the same WCO, so it reserves the same fallback width', () => { +test('WSLg custom controls reserve the same fallback width', () => { // The original bug: WSL fell through to 0, so the right tools sat under the // controls and the title overran into them. assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH) }) +test('WSLg disables the undersized native overlay in favor of renderer controls', () => { + assert.equal( + titleBarOverlayOptions({ + platform: 'wslg', + titlebarHeight: 34, + color: 'transparent', + foreground: '#ffffff', + dark: true + }), + false + ) +}) + +test('native Windows and Linux keep the same window-controls overlay', () => { + const input = { titlebarHeight: 34, color: 'transparent', foreground: '#ffffff', dark: false } + const expected = { color: 'transparent', height: 34, symbolColor: '#ffffff' } + + for (const platform of ['windows', 'linux'] as const) { + assert.deepEqual(titleBarOverlayOptions({ platform, ...input }), expected) + } +}) + +test('macOS keeps its height-only traffic-light overlay', () => { + assert.deepEqual( + titleBarOverlayOptions({ + platform: 'mac', + darwinMajor: MACOS_TAHOE_DARWIN_MAJOR, + titlebarHeight: 34, + color: 'transparent', + foreground: '#ffffff' + }), + { height: 0 } + ) +}) + +test('native overlays fall back to the active theme color', () => { + assert.deepEqual(titleBarOverlayOptions({ platform: 'windows', foreground: null, dark: true }), { + color: undefined, + height: 0, + symbolColor: '#f7f7f7' + }) + assert.deepEqual(titleBarOverlayOptions({ platform: 'linux', foreground: null, dark: false }), { + color: undefined, + height: 0, + symbolColor: '#242424' + }) +}) + test('plain Linux paints the WCO too, so it reserves the fallback width', () => { // Regression #53185: re-enabling the overlay on plain Linux (KDE/GNOME) // without reserving its width left the native min/max/close buttons painting diff --git a/apps/desktop/electron/titlebar-overlay-width.ts b/apps/desktop/electron/titlebar-overlay-width.ts index d6a4c5d1f24..ddb65e36ef7 100644 --- a/apps/desktop/electron/titlebar-overlay-width.ts +++ b/apps/desktop/electron/titlebar-overlay-width.ts @@ -1,5 +1,14 @@ export const OVERLAY_FALLBACK_WIDTH = 144 +interface TitleBarOverlayOptionsInput { + platform?: 'linux' | 'mac' | 'windows' | 'wslg' + darwinMajor?: number + titlebarHeight?: number + color?: string + foreground?: string | null + dark?: boolean +} + /** * Static pre-layout reservation (px) for the right-side native window-controls * overlay (min/max/close). Only a FALLBACK — once laid out the renderer reads @@ -8,9 +17,9 @@ export const OVERLAY_FALLBACK_WIDTH = 144 * API is unavailable. * * macOS uses traffic lights positioned via trafficLightPosition, not a WCO - * overlay, so it reserves nothing here. Every other desktop platform now paints - * the Electron overlay (Windows, WSLg, and plain Linux KDE/GNOME), so they all - * reserve the fallback width — the split is simply mac vs. not. + * overlay, so it reserves nothing here. Every other desktop platform reserves + * the same right-side footprint: Electron paints it on Windows/plain Linux, + * while the renderer paints larger controls on WSLg. * * @param {{ isMac?: boolean }} opts */ @@ -22,6 +31,38 @@ export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = f return OVERLAY_FALLBACK_WIDTH } +/** + * Build Electron's Window Controls Overlay options for every desktop host. + * With `titleBarStyle: hidden`, Windows and Linux show no window controls + * unless an overlay object is provided. WSLg deliberately returns false so + * the renderer can paint correctly scaled Windows-style controls instead. + */ +export function titleBarOverlayOptions({ + platform = 'linux', + darwinMajor = 0, + titlebarHeight = 0, + color, + foreground, + dark = false +}: TitleBarOverlayOptionsInput = {}) { + // Electron's Linux overlay keeps a narrow, unscaled three-button cluster + // under WSLg. The renderer owns larger Windows-shaped controls there while + // the host's RAIL local-move path continues to own edge dragging and Snap. + if (platform === 'wslg') { + return false + } + + if (platform === 'mac') { + return { height: macTitleBarOverlayHeight({ darwinMajor, titlebarHeight }) } + } + + return { + color, + height: titlebarHeight, + symbolColor: foreground || (dark ? '#f7f7f7' : '#242424') + } +} + // macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful, // unlike the product version which macOS reports as 16 or 26 depending on the // build SDK. diff --git a/apps/desktop/electron/window-controls.test.ts b/apps/desktop/electron/window-controls.test.ts new file mode 100644 index 00000000000..a7fc9c873fe --- /dev/null +++ b/apps/desktop/electron/window-controls.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' + +import { describe, test } from 'vitest' + +import { + customWindowControlsEnabled, + performWindowControl, + registerWindowControlIpc, + windowControlState +} from './window-controls' + +class FakeWindow { + closed = false + focusCalls = 0 + maximized = false + minimized = false + + close() { + this.closed = true + } + + focus() { + this.focusCalls += 1 + } + + isDestroyed() { + return false + } + + isMaximized() { + return this.maximized + } + + maximize() { + this.maximized = true + } + + minimize() { + this.minimized = true + } + + unmaximize() { + this.maximized = false + } +} + +describe('performWindowControl', () => { + test('minimizes the sender window', () => { + const win = new FakeWindow() + + assert.equal(performWindowControl(win, 'minimize'), true) + assert.equal(win.minimized, true) + }) + + test('toggles maximize and restore', () => { + const win = new FakeWindow() + + assert.equal(performWindowControl(win, 'toggle-maximize'), true) + assert.equal(win.maximized, true) + + assert.equal(performWindowControl(win, 'toggle-maximize'), true) + assert.equal(win.maximized, false) + }) + + test('restores keyboard focus after maximize and restore', () => { + const win = new FakeWindow() + + performWindowControl(win, 'toggle-maximize') + assert.equal(win.focusCalls, 1) + + performWindowControl(win, 'toggle-maximize') + assert.equal(win.focusCalls, 2) + }) + + test('closes the sender window', () => { + const win = new FakeWindow() + + assert.equal(performWindowControl(win, 'close'), true) + assert.equal(win.closed, true) + }) + + test('rejects unknown actions and destroyed windows', () => { + const win = new FakeWindow() + + assert.equal(performWindowControl(win, 'unknown'), false) + assert.equal(performWindowControl({ ...win, isDestroyed: () => true }, 'minimize'), false) + }) +}) + +test('windowControlState exposes the custom-control and maximize state', () => { + const win = new FakeWindow() + win.maximized = true + + assert.deepEqual(windowControlState(win, true), { + customWindowControls: true, + isMaximized: true + }) +}) + +test('custom window controls use the same WSL kernel fallback as the main process', () => { + assert.equal( + customWindowControlsEnabled({ env: {}, kernelRelease: '6.6.87.2-microsoft-standard-WSL2', platform: 'linux' }), + true + ) +}) + +test('custom window controls read WSL env vars without touching the filesystem', () => { + assert.equal(customWindowControlsEnabled({ env: { WSL_INTEROP: '/run/WSL/1_interop' }, platform: 'linux' }), true) + assert.equal(customWindowControlsEnabled({ env: {}, platform: 'linux' }), false) + assert.equal(customWindowControlsEnabled({ env: { WSL_DISTRO_NAME: 'Ubuntu' }, platform: 'darwin' }), false) +}) + +describe('registerWindowControlIpc', () => { + function fakeIpc() { + const handlers = new Map void>() + + return { + handlers, + on(channel: string, listener: (event: { sender: unknown }, action: unknown) => void) { + handlers.set(channel, listener) + } + } + } + + test('registers the hermes:window-control channel', () => { + const ipc = fakeIpc() + + registerWindowControlIpc(ipc as Parameters[0], () => new FakeWindow()) + + assert.equal(ipc.handlers.has('hermes:window-control'), true) + }) + + test('dispatches the incoming action to performWindowControl on the resolved window', () => { + const ipc = fakeIpc() + const win = new FakeWindow() + const senders: unknown[] = [] + + registerWindowControlIpc(ipc as Parameters[0], sender => { + senders.push(sender) + + return win + }) + + const handler = ipc.handlers.get('hermes:window-control') + + assert.ok(handler) + + const sender = { id: 7 } + + handler({ sender }, 'toggle-maximize') + assert.equal(win.maximized, true) + assert.deepEqual(senders, [sender]) + + handler({ sender }, 'minimize') + assert.equal(win.minimized, true) + }) + + test('ignores non-string actions instead of dispatching them', () => { + const ipc = fakeIpc() + const win = new FakeWindow() + + registerWindowControlIpc(ipc as Parameters[0], () => win) + ipc.handlers.get('hermes:window-control')({ sender: {} }, { action: 'minimize' }) + assert.equal(win.minimized, false) + }) +}) diff --git a/apps/desktop/electron/window-controls.ts b/apps/desktop/electron/window-controls.ts new file mode 100644 index 00000000000..bdab7f6c98f --- /dev/null +++ b/apps/desktop/electron/window-controls.ts @@ -0,0 +1,101 @@ +export type WindowControlAction = 'close' | 'minimize' | 'toggle-maximize' + +interface CustomWindowControlsOptions { + env?: NodeJS.ProcessEnv + kernelRelease?: string | null + platform?: NodeJS.Platform +} + +// WSL detection kept fs-free on purpose: this module is bundled into the +// sandboxed preload (sandbox: true), where importing node:fs — even +// transitively via bootstrap-platform — throws when the preload module loads +// and tears down the whole `window.hermesDesktop` bridge. The preload only +// needs the env-var signal (WSLg always sets WSL_INTEROP/WSL_DISTRO_NAME), and +// the authoritative flag still reaches the renderer through the main process's +// getWindowState (IS_WSL, which keeps the /proc kernel-release fallback). The +// kernelRelease branch mirrors bootstrap-platform's isWslEnvironment so tests +// and the main process agree; window-controls.test.ts guards that parity. +export function customWindowControlsEnabled(options: CustomWindowControlsOptions = {}): boolean { + const platform = options.platform ?? process.platform + + if (platform !== 'linux') { + return false + } + + const env = options.env ?? process.env + + if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) { + return true + } + + return options.kernelRelease ? /microsoft|wsl/i.test(options.kernelRelease) : false +} + +interface ControllableWindow { + close?: () => void + focus?: () => void + isDestroyed?: () => boolean + isMaximized?: () => boolean + maximize?: () => void + minimize?: () => void + unmaximize?: () => void +} + +export function performWindowControl(win: ControllableWindow | null | undefined, action: string): boolean { + if (!win || win.isDestroyed?.()) { + return false + } + + if (action === 'minimize' && win.minimize) { + win.minimize() + + return true + } + + if (action === 'toggle-maximize' && win.maximize && win.unmaximize) { + if (win.isMaximized?.()) { + win.unmaximize() + } else { + win.maximize() + } + + // WSLg's RAIL host can keep pointer activation while dropping the keyboard + // focus after a renderer-owned maximize/restore button invokes Electron. + // Reassert the BrowserWindow immediately so typing and Ctrl+V continue to + // reach the existing focused editor instead of requiring an app restart. + win.focus?.() + + return true + } + + if (action === 'close' && win.close) { + win.close() + + return true + } + + return false +} + +export function windowControlState(win: ControllableWindow | null | undefined, customWindowControls: boolean) { + return { + customWindowControls, + isMaximized: Boolean(win && !win.isDestroyed?.() && win.isMaximized?.()) + } +} + +interface WindowControlIpc { + on(channel: string, listener: (event: { sender: Electron.WebContents }, action: unknown) => void): unknown +} + +// Registers the renderer → main channel that the WSLg window-control buttons +// send on. Kept here (not inline in main.ts) so the registration + dispatch is +// unit-testable without importing the electron entry module. +export function registerWindowControlIpc( + ipcMain: WindowControlIpc, + resolveWindow: (sender: Electron.WebContents) => ControllableWindow | null | undefined +): void { + ipcMain.on('hermes:window-control', (event, action) => { + performWindowControl(resolveWindow(event.sender), typeof action === 'string' ? action : '') + }) +} diff --git a/apps/desktop/electron/window-state.test.ts b/apps/desktop/electron/window-state.test.ts index 40c8fe1798e..3857cf7fbed 100644 --- a/apps/desktop/electron/window-state.test.ts +++ b/apps/desktop/electron/window-state.test.ts @@ -13,6 +13,7 @@ import { debounce, DEFAULT_HEIGHT, DEFAULT_WIDTH, + maximizedBoundsCorrection, MIN_HEIGHT, MIN_WIDTH, onScreen, @@ -117,6 +118,32 @@ test('computeWindowOptions does not clamp when displays are unknown', () => { assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 }) }) +// ─── maximizedBoundsCorrection ─────────────────────────────────────────────── + +const WORK_AREA = { x: 0, y: 0, width: 1920, height: 1040 } + +test('maximizedBoundsCorrection is a no-op when the maximized window already fills the work area', () => { + // The healthy path (e.g. plain Linux, and WSLg on most versions): native + // maximize lands exactly on the work area, so we must not touch it. + assert.equal(maximizedBoundsCorrection({ x: 0, y: 0, width: 1920, height: 1040 }, WORK_AREA), null) +}) + +test('maximizedBoundsCorrection snaps a WSLg maximize that lands offset (gap at top/left)', () => { + // RAIL leaves the frameless surface shifted down-right: the origin drifts off + // the work area, exposing the desktop at the top/left. + assert.deepEqual(maximizedBoundsCorrection({ x: 32, y: 32, width: 1920, height: 1040 }, WORK_AREA), WORK_AREA) +}) + +test('maximizedBoundsCorrection snaps when the maximized size does not match the work area', () => { + assert.deepEqual(maximizedBoundsCorrection({ x: 0, y: 0, width: 1888, height: 1008 }, WORK_AREA), WORK_AREA) +}) + +test('maximizedBoundsCorrection returns null on missing geometry', () => { + assert.equal(maximizedBoundsCorrection(null, WORK_AREA), null) + assert.equal(maximizedBoundsCorrection({ x: 0, y: 0, width: 1920, height: 1040 }, null), null) + assert.equal(maximizedBoundsCorrection({ x: 0, y: 0, width: 1920, height: 1040 }, { x: 0, y: 0 }), null) +}) + // ─── debounce ────────────────────────────────────────────────────────────── test('debounce coalesces a burst into one trailing run', () => { diff --git a/apps/desktop/electron/window-state.ts b/apps/desktop/electron/window-state.ts index 56510e88273..4db9625d46e 100644 --- a/apps/desktop/electron/window-state.ts +++ b/apps/desktop/electron/window-state.ts @@ -112,6 +112,27 @@ function computeWindowOptions(state, displays): WindowOptions { return opts } +// Under WSLg's RAIL compositor a frameless window's native maximize can settle +// offset from the display work area — a strip of desktop shows at the top/left +// and the content is clipped bottom/right (reported on WSLg 1.0.65). Return the +// work-area bounds to snap the window onto, or null when it already fills the +// work area so we never fight a healthy compositor (plain Linux, most WSLg +// versions) or loop on setBounds. Environment-dependent: a no-op wherever the +// native maximize is already correct. +function maximizedBoundsCorrection(bounds, workArea) { + if (!bounds || !workArea || !finite(workArea.x) || !finite(workArea.y) || !finite(workArea.width) || !finite(workArea.height)) { + return null + } + + const fillsWorkArea = + bounds.x === workArea.x && + bounds.y === workArea.y && + bounds.width === workArea.width && + bounds.height === workArea.height + + return fillsWorkArea ? null : { x: workArea.x, y: workArea.y, width: workArea.width, height: workArea.height } +} + // Trailing debounce: collapse a burst of resize/move events (Linux fires many // mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and // cancels the pending timer — used on close, before the window is gone. @@ -140,6 +161,7 @@ export { debounce, DEFAULT_HEIGHT, DEFAULT_WIDTH, + maximizedBoundsCorrection, MIN_HEIGHT, MIN_VISIBLE, MIN_WIDTH, diff --git a/apps/desktop/electron/wslg-gpu-fallback.test.ts b/apps/desktop/electron/wslg-gpu-fallback.test.ts new file mode 100644 index 00000000000..31ebc9d31c7 --- /dev/null +++ b/apps/desktop/electron/wslg-gpu-fallback.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict' + +import { describe, test } from 'vitest' + +import { + decideWslgGpuLaunch, + GPU_CRASHES_BEFORE_FALLBACK, + gpuCrashEngagesFallback, + isGpuChildCrash, + parseWslgGpuMarker, + readWslgGpuMarker, + writeWslgGpuMarker, + wslgGpuFallbackMarker, + wslgGpuMarkerAfterSuccessfulBoot, + wslgGpuMarkerPath +} from './wslg-gpu-fallback' + +describe('parseWslgGpuMarker', () => { + test('accepts the three known states and drops junk', () => { + assert.deepEqual(parseWslgGpuMarker({ state: 'ok' }), { state: 'ok' }) + assert.deepEqual(parseWslgGpuMarker({ state: 'probing', reprobe: true }), { state: 'probing', reprobe: true }) + assert.deepEqual(parseWslgGpuMarker({ state: 'fallback', version: '1.2.3' }), { + state: 'fallback', + version: '1.2.3' + }) + }) + + test('rejects unknown/malformed input', () => { + assert.equal(parseWslgGpuMarker(null), null) + assert.equal(parseWslgGpuMarker('ok'), null) + assert.equal(parseWslgGpuMarker({ state: 'weird' }), null) + assert.equal(parseWslgGpuMarker({}), null) + }) +}) + +describe('read/write round-trip', () => { + test('writes then reads back the same marker via injected fs', () => { + const store = new Map() + const writeFileSync = ((p: string, data: string) => store.set(String(p), String(data))) as never + + const readFileSync = ((p: string) => { + const v = store.get(String(p)) + + if (v === undefined) { + throw new Error('ENOENT') + } + + return v + }) as never + + const mkdirSync = (() => undefined) as never + + writeWslgGpuMarker('/data', { state: 'fallback', version: '9.9.9' }, { mkdirSync, writeFileSync }) + + assert.ok(store.has(wslgGpuMarkerPath('/data'))) + assert.deepEqual(readWslgGpuMarker('/data', { readFileSync }), { state: 'fallback', version: '9.9.9' }) + }) + + test('missing file reads back null, not a throw', () => { + const readFileSync = (() => { + throw new Error('ENOENT') + }) as never + + assert.equal(readWslgGpuMarker('/data', { readFileSync }), null) + }) + + test('write is best-effort — a throwing fs never propagates', () => { + const mkdirSync = (() => { + throw new Error('EACCES') + }) as never + + assert.doesNotThrow(() => writeWslgGpuMarker('/data', { state: 'ok' }, { mkdirSync })) + }) +}) + +describe('decideWslgGpuLaunch', () => { + test('no marker → probe the GPU (enable), next marker is probing', () => { + const d = decideWslgGpuLaunch({ marker: null, appVersion: '1.0.0' }) + + assert.equal(d.enableGpu, true) + assert.equal(d.reason, null) + assert.deepEqual(d.nextMarker, { state: 'probing' }) + }) + + test('clean ok → probe again', () => { + const d = decideWslgGpuLaunch({ marker: { state: 'ok' }, appVersion: '1.0.0' }) + + assert.equal(d.enableGpu, true) + assert.deepEqual(d.nextMarker, { state: 'probing' }) + }) + + test('sticky fallback on the same version → GPU stays disabled', () => { + const d = decideWslgGpuLaunch({ marker: { state: 'fallback', version: '1.0.0' }, appVersion: '1.0.0' }) + + assert.equal(d.enableGpu, false) + assert.equal(d.reason, 'sticky-fallback') + assert.equal(d.nextMarker.state, 'fallback') + assert.equal(d.nextMarker.version, '1.0.0') + }) + + test('fallback from an older version → re-probe once after an app update', () => { + const d = decideWslgGpuLaunch({ marker: { state: 'fallback', version: '0.9.0' }, appVersion: '1.0.0' }) + + assert.equal(d.enableGpu, true) + assert.equal(d.reason, 'reprobe-after-update') + assert.deepEqual(d.nextMarker, { state: 'probing', reprobe: true }) + }) + + test('fallback with no recorded version stays sticky (adopts current version)', () => { + const d = decideWslgGpuLaunch({ marker: { state: 'fallback' }, appVersion: '1.0.0' }) + + assert.equal(d.enableGpu, false) + assert.equal(d.nextMarker.version, '1.0.0') + }) +}) + +describe('gpuCrashEngagesFallback', () => { + test('under the threshold → no fallback yet', () => { + assert.equal(gpuCrashEngagesFallback({ crashCount: GPU_CRASHES_BEFORE_FALLBACK - 1 }), null) + assert.equal(gpuCrashEngagesFallback({ crashCount: 0 }), null) + }) + + test('at/over the threshold → sticky fallback marker with version', () => { + const m = gpuCrashEngagesFallback({ crashCount: GPU_CRASHES_BEFORE_FALLBACK, appVersion: '2.0.0' }) + + assert.deepEqual(m, { state: 'fallback', version: '2.0.0' }) + }) + + test('custom threshold is honored', () => { + assert.equal(gpuCrashEngagesFallback({ crashCount: 1, threshold: 2 }), null) + assert.deepEqual(gpuCrashEngagesFallback({ crashCount: 2, threshold: 2 }), { state: 'fallback' }) + }) +}) + +describe('isGpuChildCrash', () => { + test('a crashed GPU child counts', () => { + assert.equal(isGpuChildCrash({ type: 'GPU', reason: 'crashed', exitCode: 139 }), true) + assert.equal(isGpuChildCrash({ type: 'gpu', reason: 'abnormal-exit', exitCode: 139 }), true) + }) + + test('a clean GPU shutdown does not count', () => { + assert.equal(isGpuChildCrash({ type: 'GPU', reason: 'clean-exit', exitCode: 0 }), false) + }) + + test('non-GPU children never count', () => { + assert.equal(isGpuChildCrash({ type: 'renderer', reason: 'crashed', exitCode: 139 }), false) + assert.equal(isGpuChildCrash({ type: 'utility', reason: 'crashed' }), false) + }) + + test('null details never count', () => { + assert.equal(isGpuChildCrash(null), false) + }) +}) + +describe('wslgGpuMarkerAfterSuccessfulBoot', () => { + test('clean boot → ok', () => { + assert.deepEqual(wslgGpuMarkerAfterSuccessfulBoot({ fallbackActive: false }), { state: 'ok' }) + }) + + test('boot with fallback engaged → keep sticky fallback', () => { + assert.deepEqual(wslgGpuMarkerAfterSuccessfulBoot({ fallbackActive: true, appVersion: '3.0.0' }), { + state: 'fallback', + version: '3.0.0' + }) + }) +}) + +describe('crash-loop lifecycle (integration of the pure pieces)', () => { + test('probe → crash-loop → next launch disables → app update re-probes', () => { + const version = '1.0.0' + + // Launch 1: no marker → probe. + const launch1 = decideWslgGpuLaunch({ marker: null, appVersion: version }) + assert.equal(launch1.enableGpu, true) + + // GPU crashes past the threshold this session → fallback marker. + const engaged = gpuCrashEngagesFallback({ crashCount: GPU_CRASHES_BEFORE_FALLBACK, appVersion: version }) + assert.deepEqual(engaged, wslgGpuFallbackMarker(version)) + + // Launch 2: fallback same version → GPU disabled. + const launch2 = decideWslgGpuLaunch({ marker: engaged, appVersion: version }) + assert.equal(launch2.enableGpu, false) + + // Launch 3: app updated → re-probe the GPU once. + const launch3 = decideWslgGpuLaunch({ marker: engaged, appVersion: '1.1.0' }) + assert.equal(launch3.enableGpu, true) + assert.equal(launch3.nextMarker.reprobe, true) + }) +}) diff --git a/apps/desktop/electron/wslg-gpu-fallback.ts b/apps/desktop/electron/wslg-gpu-fallback.ts new file mode 100644 index 00000000000..f29be0daa1b --- /dev/null +++ b/apps/desktop/electron/wslg-gpu-fallback.ts @@ -0,0 +1,217 @@ +/** + * WSLg GPU crash-loop recovery. + * + * Chromium blocklists the WSLg Mesa/D3D12 vGPU, so by default it composites in + * software (steady, but typing lags). When `/dev/dxg` is present the app + * un-blocklists the GPU for a snappier UI — but on some WSLg driver stacks the + * un-blocklisted GPU process can't initialize (e.g. `samplerYcbcrConversion is + * not supported`) and segfaults (`exit_code=139`) in a tight loop, taking the + * whole app down. + * + * The un-blocklist is decided PRE-LAUNCH (before app `ready`), but the crash is + * a RUNTIME event, so a simple try/catch can't guard it. Instead we mirror the + * Windows sandbox-fallback pattern (windows-sandbox-fallback.ts): persist a + * marker across launches. When the un-blocklisted GPU crashes repeatedly in a + * session we record a sticky `fallback`, and the next launch skips the + * un-blocklist and rides Chromium's stable software path instead. + * + * The fallback is sticky per app version: an app update re-probes the GPU once + * (a new Electron / Mesa may have fixed the host) before degrading again. + * + * Pure helpers stay injectable so tests never boot Electron or touch real files. + */ + +import fs from 'node:fs' +import path from 'node:path' + +export const WSLG_GPU_MARKER_FILENAME = 'wslg-gpu-fallback.json' + +/** GPU-process crashes in one session before the sticky fallback engages. */ +export const GPU_CRASHES_BEFORE_FALLBACK = 3 + +export type WslgGpuMarkerState = 'fallback' | 'ok' | 'probing' + +export interface WslgGpuMarker { + state: WslgGpuMarkerState + /** App version that entered fallback — a version change triggers a re-probe. */ + version?: string + /** This launch is a post-update GPU re-probe after a prior fallback. */ + reprobe?: boolean +} + +export function wslgGpuMarkerPath(userDataDir: string): string { + return path.join(String(userDataDir || ''), WSLG_GPU_MARKER_FILENAME) +} + +export function parseWslgGpuMarker(raw: unknown): WslgGpuMarker | null { + if (!raw || typeof raw !== 'object') { + return null + } + + const record = raw as Record + const state = record.state + + if (state !== 'probing' && state !== 'fallback' && state !== 'ok') { + return null + } + + const marker: WslgGpuMarker = { state } + + if (typeof record.version === 'string' && record.version) { + marker.version = record.version + } + + if (record.reprobe === true) { + marker.reprobe = true + } + + return marker +} + +export function readWslgGpuMarker( + userDataDir: string, + { readFileSync = fs.readFileSync } = {} +): WslgGpuMarker | null { + try { + return parseWslgGpuMarker(JSON.parse(readFileSync(wslgGpuMarkerPath(userDataDir), 'utf8'))) + } catch { + return null + } +} + +export function writeWslgGpuMarker( + userDataDir: string, + marker: WslgGpuMarker, + { + mkdirSync = fs.mkdirSync, + writeFileSync = fs.writeFileSync + }: { + mkdirSync?: typeof fs.mkdirSync + writeFileSync?: typeof fs.writeFileSync + } = {} +): void { + const dir = String(userDataDir || '') + + if (!dir) { + return + } + + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(wslgGpuMarkerPath(dir), `${JSON.stringify(marker)}\n`, 'utf8') + } catch { + // Best-effort: a marker we can't persist just means the next launch + // re-probes the GPU. Never let it break boot. + } +} + +export function wslgGpuFallbackMarker(appVersion?: string): WslgGpuMarker { + const marker: WslgGpuMarker = { state: 'fallback' } + + if (appVersion) { + marker.version = appVersion + } + + return marker +} + +export interface WslgGpuLaunchDecision { + /** Whether to un-blocklist the WSLg vGPU this launch. */ + enableGpu: boolean + /** Short reason for the decision, for logging (null when GPU stays on). */ + reason: string | null + /** Marker to persist immediately, before the GPU process starts. */ + nextMarker: WslgGpuMarker +} + +/** + * Single launch-time transition: decide whether this WSLg launch un-blocklists + * the vGPU AND what the marker becomes for crash detection on the next launch. + * + * - No marker / clean `ok` → probe the GPU (`probing`). + * - `probing` left behind → last launch crashed before it could mark `ok`, but + * a single `probing` is tolerated (could be a manual kill / power loss); + * the runtime crash counter is what actually trips the sticky fallback. + * - `fallback` is sticky within one app version. A version change re-probes the + * GPU once (`reprobe`) so a fixed host returns to acceleration; if that + * re-probe launch also crashes, the runtime counter re-arms the fallback. + */ +export function decideWslgGpuLaunch( + options: { marker?: WslgGpuMarker | null; appVersion?: string } = {} +): WslgGpuLaunchDecision { + const appVersion = String(options.appVersion || '') + const marker = options.marker ?? null + + if (marker?.state === 'fallback') { + if (marker.version && appVersion && marker.version !== appVersion) { + // App updated since the fallback engaged — re-probe the GPU once. + return { enableGpu: true, reason: 'reprobe-after-update', nextMarker: { state: 'probing', reprobe: true } } + } + + return { + enableGpu: false, + reason: 'sticky-fallback', + nextMarker: { ...marker, version: marker.version || appVersion || undefined } + } + } + + // No marker, a clean `ok`, or a prior `probing` — try the GPU. Runtime crash + // detection (see gpuCrashEngagesFallback) trips the sticky fallback. + return { enableGpu: true, reason: null, nextMarker: { state: 'probing' } } +} + +/** + * After enough GPU-process crashes in one session, engage the sticky fallback + * so the NEXT launch skips the un-blocklist. Returns the marker to persist, or + * null when the crash count is still under the threshold. + */ +export function gpuCrashEngagesFallback(options: { + crashCount: number + appVersion?: string + threshold?: number +}): WslgGpuMarker | null { + const threshold = options.threshold ?? GPU_CRASHES_BEFORE_FALLBACK + + if (!Number.isFinite(options.crashCount) || options.crashCount < threshold) { + return null + } + + return wslgGpuFallbackMarker(options.appVersion) +} + +/** + * A GPU child died. Only crashes (segfault / abnormal exit) count toward the + * fallback threshold — a clean GPU shutdown (`clean-exit`, exit 0) does not. + */ +export function isGpuChildCrash(details: { type?: string; reason?: string; exitCode?: number | string } | null): boolean { + if (!details) { + return false + } + + if (String(details.type || '').toLowerCase() !== 'gpu') { + return false + } + + const reason = String(details.reason || '').toLowerCase() + + // Electron's child-process-gone reasons: 'crashed' | 'killed' | + // 'oom' | 'abnormal-exit' | 'clean-exit' | 'launch-failed' | ... + // A clean exit (code 0) is a normal GPU teardown, not a crash. + if (reason === 'clean-exit' && Number(details.exitCode) === 0) { + return false + } + + return true +} + +/** + * After the main window reaches ready-to-show without a GPU crash loop: mark a + * clean boot so future launches keep trusting the GPU. If the fallback engaged + * this session, keep it sticky. + */ +export function wslgGpuMarkerAfterSuccessfulBoot(options: { + fallbackActive: boolean + appVersion?: string +}): WslgGpuMarker { + return options.fallbackActive ? wslgGpuFallbackMarker(options.appVersion) : { state: 'ok' } +} diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index d32456e9254..1229fff280f 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -11,6 +11,7 @@ import { test, vi } from 'vitest' import { applyZoomLevel, clampZoomLevel, + createZoomCoordinator, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_RESIZE_REASSERT_DELAY_MS, @@ -65,6 +66,34 @@ test('extreme percentages clamp to the level bounds', () => { assert.equal(percentToZoomLevel(1_000_000), 9) }) +test('zoom coordinator commits the initial persisted restore', () => { + const coordinator = createZoomCoordinator() + const commitRestore = coordinator.beginRestore() + + assert.equal(commitRestore(1), 1) + assert.equal(coordinator.getDesired(), 1) +}) + +test('zoom coordinator rejects a stale restore after a global zoom change', () => { + const coordinator = createZoomCoordinator() + const commitRestore = coordinator.beginRestore() + + coordinator.setDesired(2) + + assert.equal(commitRestore(1), undefined) + assert.equal(coordinator.getDesired(), 2) +}) + +test('zoom coordinator rejects restores started after global zoom is known', () => { + const coordinator = createZoomCoordinator() + coordinator.setDesired(2) + + const commitRestore = coordinator.beginRestore() + + assert.equal(commitRestore(1), undefined) + assert.equal(coordinator.getDesired(), 2) +}) + test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-display moves on macOS and Windows', () => { const handlers = new Map() diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 7d1d80d974f..52c085f1282 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -48,6 +48,41 @@ export function applyZoomLevel(webContents, level) { return clamped } +/** + * One desired zoom shared by every chat window. A revision rejects an async + * localStorage read when a newer in-memory choice already exists. + */ +export function createZoomCoordinator() { + let desiredLevel + let revision = 0 + + return { + beginRestore() { + const restoreRevision = revision + const canRestore = desiredLevel === undefined + + return level => { + if (!canRestore || revision !== restoreRevision) { + return undefined + } + + desiredLevel = clampZoomLevel(level) + + return desiredLevel + } + }, + getDesired() { + return desiredLevel + }, + setDesired(level) { + desiredLevel = clampZoomLevel(level) + revision += 1 + + return desiredLevel + } + } +} + // Chromium can drop webContents zoom when a BrowserWindow is resized, minimized // and restored, or crosses onto a monitor with different display scaling. macOS // and Windows provide trailing `resized`/`moved` events; Linux only provides the diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 978d328e228..2629c2da98c 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -94,6 +94,7 @@ import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' import { titlebarControlsPosition } from '../shell/titlebar' import { TitlebarControls } from '../shell/titlebar-controls' +import { WslgWindowControls } from '../shell/wslg-window-controls' import { UpdatesOverlay } from '../updates-overlay' import { ContribWiringContext } from './context' @@ -888,6 +889,13 @@ export function ContribWiring({ children }: { children: ReactNode }) { const measuredOverlayWidth = useWindowControlsOverlayWidth() const nativeOverlayWidth = measuredOverlayWidth ?? connection?.nativeOverlayWidth ?? 0 const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem' + + // WSLg opts into renderer-owned controls: Electron's native overlay is an + // unscaled cluster whose input hit-region drifts from the rendered buttons + // under the RAIL compositor, so the renderer paints its own min/max/close. + const customWindowControls = + connection?.customWindowControls ?? window.hermesDesktop?.windowControls?.custom ?? false + // Pane-registered tools (preview's monitor/devtools cluster) anchor flush // against the static system cluster — in the tree layout the titlebar band // sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply. @@ -919,6 +927,12 @@ export function ContribWiring({ children }: { children: ReactNode }) { onOpenSettings={() => navigate(SETTINGS_ROUTE)} tools={rightTitlebarTools} /> + {!isSecondaryWindow() && customWindowControls && ( + + )} {children} diff --git a/apps/desktop/src/app/shell/wslg-window-controls.test.tsx b/apps/desktop/src/app/shell/wslg-window-controls.test.tsx new file mode 100644 index 00000000000..cec553d258f --- /dev/null +++ b/apps/desktop/src/app/shell/wslg-window-controls.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { WslgWindowControls } from './wslg-window-controls' + +const windowControls = { + close: vi.fn(), + custom: true, + minimize: vi.fn(), + toggleMaximize: vi.fn() +} + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const originalHermesDesktop = desktopWindow.hermesDesktop + +function renderControls(isMaximized = false, path = '/', isFullscreen = false) { + return render( + + + + ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() + + if (originalHermesDesktop) { + desktopWindow.hermesDesktop = originalHermesDesktop + } else { + delete desktopWindow.hermesDesktop + } +}) + +describe('WslgWindowControls', () => { + it('routes minimize, maximize and close through the desktop bridge', () => { + desktopWindow.hermesDesktop = { windowControls } as unknown as Window['hermesDesktop'] + + renderControls() + + fireEvent.click(screen.getByRole('button', { name: 'Minimize window' })) + fireEvent.click(screen.getByRole('button', { name: 'Maximize window' })) + fireEvent.click(screen.getByRole('button', { name: 'Close window' })) + + expect(windowControls.minimize).toHaveBeenCalledOnce() + expect(windowControls.toggleMaximize).toHaveBeenCalledOnce() + expect(windowControls.close).toHaveBeenCalledOnce() + }) + + it('exposes restore semantics while maximized', () => { + desktopWindow.hermesDesktop = { windowControls } as unknown as Window['hermesDesktop'] + + renderControls(true) + + expect(screen.getByRole('button', { name: 'Restore window' })).toBeTruthy() + }) + + it('stays hidden while a full-screen overlay owns the window chrome', () => { + desktopWindow.hermesDesktop = { windowControls } as unknown as Window['hermesDesktop'] + + renderControls(false, '/settings') + + expect(screen.queryByLabelText('Window controls')).toBeNull() + }) + + it('stays hidden while the BrowserWindow is fullscreen', () => { + desktopWindow.hermesDesktop = { windowControls } as unknown as Window['hermesDesktop'] + + renderControls(false, '/', true) + + expect(screen.queryByLabelText('Window controls')).toBeNull() + }) + + it('prevents pointer activation from stealing renderer focus', () => { + desktopWindow.hermesDesktop = { windowControls } as unknown as Window['hermesDesktop'] + renderControls() + const event = new MouseEvent('pointerdown', { bubbles: true, cancelable: true }) + + const button = screen.getByRole('button', { name: 'Maximize window' }) + fireEvent(button, event) + fireEvent.click(button) + + expect(event.defaultPrevented).toBe(true) + expect(windowControls.toggleMaximize).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/app/shell/wslg-window-controls.tsx b/apps/desktop/src/app/shell/wslg-window-controls.tsx new file mode 100644 index 00000000000..73c4b121d7e --- /dev/null +++ b/apps/desktop/src/app/shell/wslg-window-controls.tsx @@ -0,0 +1,64 @@ +import type { PointerEvent } from 'react' +import { useLocation } from 'react-router-dom' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +import { appViewForPath, isOverlayView } from '../routes' + +interface WslgWindowControlsProps { + isFullscreen: boolean + isMaximized: boolean +} + +const buttonClass = + 'grid h-(--titlebar-height) w-11 place-items-center border-0 bg-transparent p-0 text-muted-foreground transition-colors duration-75 select-none [-webkit-app-region:no-drag] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring hover:bg-white/10 hover:text-foreground active:bg-white/15' + +const preserveRendererFocus = (event: PointerEvent) => event.preventDefault() + +export function WslgWindowControls({ isFullscreen, isMaximized }: WslgWindowControlsProps) { + const location = useLocation() + const controls = window.hermesDesktop?.windowControls + + if (!controls || isFullscreen || isOverlayView(appViewForPath(location.pathname))) { + return null + } + + return ( +
+ + + +
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d9d9af1ee13..3d151c6bedc 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -41,6 +41,12 @@ declare global { // reply). Resolves true for the first window to claim a key, false for // peers — so N open windows don't all fire the same cue. claimAmbientCue: (key: string) => Promise + windowControls: { + custom: boolean + minimize: () => void + toggleMaximize: () => void + close: () => void + } // The pop-out pet overlay: a transparent always-on-top window hosting only // the mascot. The main renderer drives it (open/close/drag + state push); // the overlay sends control messages back (pop-in, composer submit). @@ -394,7 +400,9 @@ export interface DesktopUpdateProgress { export interface HermesConnection { baseUrl: string + customWindowControls?: boolean isFullscreen: boolean + isMaximized?: boolean // The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a // 'cloud' saved-config entry resolves to a 'remote' connection under the hood // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'. @@ -421,7 +429,9 @@ export interface HermesTitleBarTheme { } export interface HermesWindowState { + customWindowControls?: boolean isFullscreen: boolean + isMaximized?: boolean nativeOverlayWidth: number windowButtonPosition: { x: number; y: number } | null }