Files
hermes-agent/apps/desktop/electron/window-controls.ts
Austin Pickett 8ffc2f0369 fix(desktop): working WSLg window controls and native Wayland launch (#113247)
* refactor(desktop): extract titleBarOverlayOptions into a tested helper

getTitleBarOverlayOptions in main.ts branched inline on mac/windows/wsl.
Move the decision into titlebar-overlay-width.ts so the WSLg → false rule
(renderer paints its own controls there) is unit-tested next to the width
reservation it pairs with. main.ts keeps a thin caller.

Co-authored-by: null-runner <nicholas.mariani@hotmail.it>

* fix(desktop): renderer-drawn window controls on WSLg

Under WSLg the frameless window (titleBarStyle: 'hidden') had no
minimize/maximize/close. getTitleBarOverlayOptions returned false on the
premise that the RDP host paints replacement controls; it does not for a
frameless window. Electron's native overlay is not the fix either: its
cluster's hit-region drifts from the rendered buttons under the RAIL
compositor.

The renderer now paints Windows-style min/max/close (wslg-window-controls)
routed over a hermes:window-control IPC channel (window-controls.ts →
preload → main). getWindowState reports customWindowControls/isMaximized so
the renderer knows when to mount them and which glyph to show. The cluster
is pinned to TITLEBAR_HEIGHT px (the contrib shell zeroes --titlebar-height
for content subtrees) with 46px native-width caption buttons.

Co-authored-by: Austin Pickett <pickett.austin@gmail.com>

* fix(desktop): wire WSLg window-control IPC and snap maximize onto the work area

Register hermes:window-control in main, report windowControlState from
getWindowState, and re-send window state on maximize/unmaximize so the
renderer's maximize/restore glyph tracks the window.

maximizedBoundsCorrection snaps a WSLg-maximized frameless window back onto
the display work area (RAIL can settle it offset — reported on WSLg 1.0.65).
It is a no-op wherever native maximize already fills the work area, so
healthy compositors are never fought and setBounds cannot loop.

Co-authored-by: null-runner <nicholas.mariani@hotmail.it>

* fix(desktop): call WSLg window-control bridge without the click event

contextBridge structured-clones every argument, and a React SyntheticEvent
is not cloneable, so onClick={controls.minimize} threw "An object could
not be cloned" before ipcRenderer.send ran. The buttons rendered but did
nothing. Wrap the handlers so the bridge is called with no arguments, and
pin that in the test.

* fix(desktop): remove recursive WSLg maximize bounds correction

* fix(desktop): select native Wayland before Electron initialization on WSLg

* fix(desktop): preserve ready pipe and debugger across WSLg launch

* fix(desktop): keep WSLg caption controls scoped to every window

* style(desktop): order window chrome event import

---------

Co-authored-by: null-runner <nicholas.mariani@hotmail.it>
2026-09-17 14:51:18 -04:00

102 lines
3.2 KiB
TypeScript

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 : '')
})
}