mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): remember window size/position/maximized across launches (salvage #39154)
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 <jeffrobodie@gmail.com>
This commit is contained in:
parent
35e9c63d89
commit
2de7549fe0
4 changed files with 306 additions and 5 deletions
|
|
@ -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 <name>
|
||||
// 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).
|
||||
|
|
|
|||
117
apps/desktop/electron/window-state.cjs
Normal file
117
apps/desktop/electron/window-state.cjs
Normal file
|
|
@ -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
|
||||
}
|
||||
135
apps/desktop/electron/window-state.test.cjs
Normal file
135
apps/desktop/electron/window-state.test.cjs
Normal file
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue