From 2de7549fe0fe06954dd7b72528ca37953d62ada1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 13:32:05 -0500 Subject: [PATCH] feat(desktop): remember window size/position/maximized across launches (salvage #39154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop window opened at a hardcoded 1220×800 every launch, discarding whatever size and position the user left it at (#39101) — on macOS the dock reopen was the most visible case, but every restart reset it. A small window-state.json under userData (same pattern as connection.json / updates.json) records the window's normal bounds plus its maximized flag, written debounced on resize/move/maximize and flushed on close, applied on the next createWindow(). getNormalBounds() captures the pre-maximize size so an un-maximize next session lands where the user actually sized it. Restore is defensive: sanitize rejects garbage, drops off-screen positions (window falls back to Electron centering), and caps a size saved on a since-disconnected larger monitor to the largest current display. The geometry math lives in a side-effect-free window-state.cjs so it unit-tests with node --test, no Electron boot. No new dependency. Salvages #39154 by @jeffrobodie-glitch — same userData approach and validation intent, reimplemented tighter and folded into one module. Co-authored-by: jeffrobodie-glitch --- apps/desktop/electron/main.cjs | 57 ++++++++- apps/desktop/electron/window-state.cjs | 117 +++++++++++++++++ apps/desktop/electron/window-state.test.cjs | 135 ++++++++++++++++++++ apps/desktop/package.json | 2 +- 4 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/electron/window-state.cjs create mode 100644 apps/desktop/electron/window-state.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index daefed4afdd..4ecefa4604b 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -12,6 +12,7 @@ const { powerMonitor, protocol, safeStorage, + screen, session, shell, systemPreferences @@ -67,6 +68,13 @@ const { uninstallArgsForMode } = require('./desktop-uninstall.cjs') const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') +const { + MIN_WIDTH: WINDOW_MIN_WIDTH, + MIN_HEIGHT: WINDOW_MIN_HEIGHT, + sanitizeWindowState, + computeWindowOptions, + debounce +} = require('./window-state.cjs') const { authModeFromStatus, buildGatewayWsUrl, @@ -320,6 +328,7 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') +const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json') // active-profile.json records which Hermes profile the desktop launches its // local backend as. When set, startHermes() passes `hermes --profile // dashboard …`, which deterministically pins HERMES_HOME (see @@ -1522,6 +1531,36 @@ function writeDesktopUpdateConfig(config) { writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2)) } +// ─── Main-window geometry persistence (window-state.json) ────────────────── + +function readWindowState() { + try { + return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8'))) + } catch { + return null + } +} + +// Persist the window's restored (non-maximized) bounds plus its maximized flag. +// getNormalBounds() keeps the pre-maximize size, so un-maximizing next session +// lands back where the user actually sized the window. +function persistWindowState() { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) return + try { + const { x, y, width, height } = mainWindow.getNormalBounds() + fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) + writeFileAtomic( + DESKTOP_WINDOW_STATE_PATH, + JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2) + ) + } catch (err) { + rememberLog(`[window-state] persist failed: ${err?.message || err}`) + } +} + +// resized/moved fire many times mid-drag on Linux; debounce to one write. +const schedulePersistWindowState = debounce(persistWindowState, 250) + // Match the backend's source resolution but bias toward a real git checkout. // Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT. // HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree. @@ -5523,11 +5562,11 @@ function closePetOverlay() { function createWindow() { const icon = getAppIconPath() + const savedWindowState = readWindowState() mainWindow = new BrowserWindow({ - width: 1220, - height: 800, - minWidth: 400, - minHeight: 620, + ...computeWindowOptions(savedWindowState, screen.getAllDisplays()), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, title: 'Hermes', // Frameless title bar on every platform so the renderer can paint the // "hide sidebar" button (and other left-side titlebar tools) flush with @@ -5569,6 +5608,8 @@ function createWindow() { } } + if (savedWindowState?.isMaximized) mainWindow.maximize() + mainWindow.once('ready-to-show', () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show() }) @@ -5578,6 +5619,14 @@ function createWindow() { mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + // Reopen where the user left off. resized/moved settle once per drag; close is + // 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('close', () => schedulePersistWindowState.flush()) + // The overlay rides the main window — closing the app's primary window must // tear it down too (otherwise it strands as an orphan that blocks // window-all-closed from quitting on Windows/Linux). diff --git a/apps/desktop/electron/window-state.cjs b/apps/desktop/electron/window-state.cjs new file mode 100644 index 00000000000..6157e469b24 --- /dev/null +++ b/apps/desktop/electron/window-state.cjs @@ -0,0 +1,117 @@ +/** + * Pure geometry helpers for window-state.json — restoring the main window's + * size, position, and maximized flag across launches. Side-effect-free so the + * part that actually matters (rejecting garbage + off-screen bounds) is + * unit-testable without booting Electron; main.cjs owns the file I/O and the + * live `screen` displays. + */ + +// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its +// minWidth/minHeight so a restored size never undershoots what the live window +// allows. A fresh install (no saved state) is byte-identical to before. +const DEFAULT_WIDTH = 1220 +const DEFAULT_HEIGHT = 800 +const MIN_WIDTH = 400 +const MIN_HEIGHT = 620 + +// Keep at least this much of the window over a display work area before we trust +// a saved position, so the title bar stays grabbable after a monitor unplugs. +const MIN_VISIBLE = 48 + +const finite = v => typeof v === 'number' && Number.isFinite(v) +const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi)) + +// Parse raw JSON → clean state, or null if garbage. width/height are required +// and floored; x/y survive only as a finite pair; isMaximized is strict. +function sanitizeWindowState(raw) { + if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null + + const state = { + width: Math.max(MIN_WIDTH, Math.round(raw.width)), + height: Math.max(MIN_HEIGHT, Math.round(raw.height)), + isMaximized: raw.isMaximized === true + } + if (finite(raw.x) && finite(raw.y)) { + state.x = Math.round(raw.x) + state.y = Math.round(raw.y) + } + return state +} + +// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both +// axes. `displays` is Electron's screen.getAllDisplays() shape. +function onScreen(bounds, displays) { + if (!Array.isArray(displays)) return false + return displays.some(({ workArea: a } = {}) => { + if (!a) return false + const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x) + const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y) + return x >= MIN_VISIBLE && y >= MIN_VISIBLE + }) +} + +// Sanitized state (or null) → BrowserWindow size/position options. Always sets +// width/height, capped to the largest current display so a size saved on a +// since-disconnected bigger monitor can't exceed any screen the user now has. +// Sets x/y only when still on-screen; otherwise Electron centers the window. +function computeWindowOptions(state, displays) { + const opts = { + width: finite(state?.width) ? state.width : DEFAULT_WIDTH, + height: finite(state?.height) ? state.height : DEFAULT_HEIGHT + } + + const cap = (Array.isArray(displays) ? displays : []).reduce( + (m, { workArea: a } = {}) => + a && finite(a.width) && finite(a.height) + ? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) } + : m, + { width: 0, height: 0 } + ) + if (cap.width && cap.height) { + opts.width = clamp(opts.width, MIN_WIDTH, cap.width) + opts.height = clamp(opts.height, MIN_HEIGHT, cap.height) + } + + if ( + state && + finite(state.x) && + finite(state.y) && + onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays) + ) { + opts.x = state.x + opts.y = state.y + } + return opts +} + +// 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. +function debounce(fn, delayMs) { + let timer = null + const debounced = () => { + clearTimeout(timer) + timer = setTimeout(() => { + timer = null + fn() + }, delayMs) + } + debounced.flush = () => { + clearTimeout(timer) + timer = null + fn() + } + return debounced +} + +module.exports = { + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + MIN_WIDTH, + MIN_HEIGHT, + MIN_VISIBLE, + sanitizeWindowState, + onScreen, + computeWindowOptions, + debounce +} diff --git a/apps/desktop/electron/window-state.test.cjs b/apps/desktop/electron/window-state.test.cjs new file mode 100644 index 00000000000..2f3ea6ca52a --- /dev/null +++ b/apps/desktop/electron/window-state.test.cjs @@ -0,0 +1,135 @@ +/** + * Unit tests for the pure window-state geometry helpers. These cover the logic + * that protects the user: garbage rejection, off-screen fallback, oversized + * clamping, and the debounce that collapses mid-drag write storms. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + MIN_WIDTH, + MIN_HEIGHT, + sanitizeWindowState, + onScreen, + computeWindowOptions, + debounce +} = require('./window-state.cjs') + +// A single 1920×1080 monitor (work area trimmed for the taskbar). +const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }] +// A laptop panel left behind after a bigger external monitor is unplugged. +const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }] + +// ─── sanitizeWindowState ─────────────────────────────────────────────────── + +test('sanitizeWindowState rejects missing/garbage input', () => { + for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) { + assert.equal(sanitizeWindowState(bad), null) + } +}) + +test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => { + assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), { + x: 101, + y: 50, + width: 1400, + height: 901, + isMaximized: true + }) +}) + +test('sanitizeWindowState floors size to the minimums', () => { + const state = sanitizeWindowState({ width: 10, height: 10 }) + assert.equal(state.width, MIN_WIDTH) + assert.equal(state.height, MIN_HEIGHT) +}) + +test('sanitizeWindowState drops a partial position but keeps the size', () => { + assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), { + width: 1400, + height: 900, + isMaximized: false + }) +}) + +test('sanitizeWindowState treats isMaximized strictly', () => { + assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false) +}) + +// ─── onScreen ────────────────────────────────────────────────────────────── + +test('onScreen accepts a window on the primary or a secondary display', () => { + const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }] + assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true) + assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true) +}) + +test('onScreen rejects off-screen, slivers, and bad input', () => { + assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge + assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top + assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver + assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false) + assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false) +}) + +// ─── computeWindowOptions ────────────────────────────────────────────────── + +test('computeWindowOptions falls back to defaults with no saved state', () => { + assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }) +}) + +test('computeWindowOptions restores an on-screen position', () => { + const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 }) + assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 }) +}) + +test('computeWindowOptions keeps the size but drops an off-screen position', () => { + const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 }) + assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 }) +}) + +test('computeWindowOptions clamps a size larger than the only display', () => { + const saved = sanitizeWindowState({ width: 2560, height: 1440 }) + assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 }) +}) + +test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => { + const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }] + const saved = sanitizeWindowState({ width: 2000, height: 1500 }) + assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT }) +}) + +test('computeWindowOptions does not clamp when displays are unknown', () => { + const saved = sanitizeWindowState({ width: 2560, height: 1440 }) + assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 }) +}) + +// ─── debounce ────────────────────────────────────────────────────────────── + +test('debounce coalesces a burst into one trailing run', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let calls = 0 + const d = debounce(() => { calls += 1 }, 250) + + d(); d(); d() + assert.equal(calls, 0) + t.mock.timers.tick(249) + assert.equal(calls, 0) + t.mock.timers.tick(1) + assert.equal(calls, 1) +}) + +test('debounce.flush runs now and cancels the pending timer', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + let calls = 0 + const d = debounce(() => { calls += 1 }, 250) + + d() + d.flush() + assert.equal(calls, 1) + t.mock.timers.tick(1000) + assert.equal(calls, 1) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81e855451f8..2661f8af18b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",