mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): WSLg window controls + GPU crash-loop fallback
Two WSLg-specific desktop bugs.
Window controls: getTitleBarOverlayOptions() returned false on WSLg while
the window stayed titleBarStyle:'hidden', so the frameless window had no
min/max/close and the frameless maximize settled offset from the work area.
The RDP host only decorates windows that keep a native frame, so it painted
nothing. Electron's native overlay isn't the fix either: its cluster's hit
region drifts from the rendered buttons under the RAIL compositor. The
renderer now paints its own Windows-style min/max/close driven over a
hermes:window-control IPC channel, and maximizedBoundsCorrection snaps a
maximized window back onto the display work area. Extracts titleBarOverlayOptions
into a tested helper.
Dropped an openNewSessionWindow / hermes🪟openNewSession declaration
with no handler, wired maximizedBoundsCorrection into the maximize event
(it was exported and tested but never called), and passed the correct window
to windowControlState in getWindowState.
GPU crash loop: on WSLg the /dev/dxg un-blocklist forced the vGPU on
unconditionally, and on driver stacks that fail Vulkan init
(samplerYcbcrConversion unsupported) the GPU process segfaulted (exit 139)
in a loop on every launch. decideWslgGpuLaunch now consults a sticky
per-version marker: a session that crash-loops the vGPU records a fallback
so the next launch skips the un-blocklist and rides Chromium's software
path. An app update re-probes the GPU once. New wslg-gpu-fallback.ts holds
the pure, tested marker logic.
Co-authored-by: null-runner <nicholas.mariani@hotmail.it>
This commit is contained in:
parent
a61183b56f
commit
68df6d3d50
16 changed files with 1223 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
166
apps/desktop/electron/window-controls.test.ts
Normal file
166
apps/desktop/electron/window-controls.test.ts
Normal file
|
|
@ -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<string, (event: { sender: unknown }, action: unknown) => 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<typeof registerWindowControlIpc>[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<typeof registerWindowControlIpc>[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<typeof registerWindowControlIpc>[0], () => win)
|
||||
ipc.handlers.get('hermes:window-control')({ sender: {} }, { action: 'minimize' })
|
||||
assert.equal(win.minimized, false)
|
||||
})
|
||||
})
|
||||
101
apps/desktop/electron/window-controls.ts
Normal file
101
apps/desktop/electron/window-controls.ts
Normal file
|
|
@ -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 : '')
|
||||
})
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
189
apps/desktop/electron/wslg-gpu-fallback.test.ts
Normal file
189
apps/desktop/electron/wslg-gpu-fallback.test.ts
Normal file
|
|
@ -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<string, string>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
217
apps/desktop/electron/wslg-gpu-fallback.ts
Normal file
217
apps/desktop/electron/wslg-gpu-fallback.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
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' }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<WslgWindowControls
|
||||
isFullscreen={Boolean(connection?.isFullscreen)}
|
||||
isMaximized={Boolean(connection?.isMaximized)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
87
apps/desktop/src/app/shell/wslg-window-controls.test.tsx
Normal file
87
apps/desktop/src/app/shell/wslg-window-controls.test.tsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<WslgWindowControls isFullscreen={isFullscreen} isMaximized={isMaximized} />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
64
apps/desktop/src/app/shell/wslg-window-controls.tsx
Normal file
64
apps/desktop/src/app/shell/wslg-window-controls.tsx
Normal file
|
|
@ -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<HTMLButtonElement>) => 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 (
|
||||
<div
|
||||
aria-label="Window controls"
|
||||
className="fixed right-0 top-0 z-80 flex h-(--titlebar-height) items-stretch overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) text-[10px]"
|
||||
>
|
||||
<button
|
||||
aria-label="Minimize window"
|
||||
className={buttonClass}
|
||||
onClick={controls.minimize}
|
||||
onPointerDown={preserveRendererFocus}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="chrome-minimize" size={10} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={isMaximized ? 'Restore window' : 'Maximize window'}
|
||||
className={buttonClass}
|
||||
onClick={controls.toggleMaximize}
|
||||
onPointerDown={preserveRendererFocus}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={isMaximized ? 'chrome-restore' : 'chrome-maximize'} size={10} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Close window"
|
||||
className={cn(
|
||||
buttonClass,
|
||||
'hover:bg-[#c42b1c] hover:text-white active:bg-[#b3271a] active:text-white dark:hover:bg-[#c42b1c]'
|
||||
)}
|
||||
onClick={controls.close}
|
||||
onPointerDown={preserveRendererFocus}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="chrome-close" size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
apps/desktop/src/global.d.ts
vendored
10
apps/desktop/src/global.d.ts
vendored
|
|
@ -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<boolean>
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue