fix(desktop): persist window geometry on Linux

`moved` and `resized` are macOS/Windows only — Electron tags them
`@platform darwin,win32` — so on Linux neither the main window nor the HUD
ever heard that it had been dragged or resized, and both reopened at their
default placement every launch. The main window had a `close` flush to fall
back on; the HUD had nothing, so its position was lost outright.

Bind `move`/`resize` instead. Those carry no platform tag and fire everywhere,
and the trailing debounce already collapses the mid-drag stream a settled event
would have saved us from.
This commit is contained in:
Brooklyn Nicholson 2026-08-08 22:13:08 -05:00
parent da3a0a852f
commit d73bc7f17a
3 changed files with 59 additions and 7 deletions

View File

@ -212,6 +212,7 @@ import { createWakeIndicatorWindowController } from './wake-indicator-window'
import { readWindowBelow } from './window-below'
import { createWindowRevealController } from './window-reveal'
import {
bindGeometryPersistence,
computeWindowOptions,
debounce,
sanitizeWindowState,
@ -2347,7 +2348,7 @@ function persistWindowState() {
}
}
// resized/moved fire many times mid-drag on Linux; debounce to one write.
// move/resize fire many times mid-drag; debounce to one write.
const schedulePersistWindowState = debounce(persistWindowState, 250)
// Zoom's primary store is a main-process JSON file. The renderer localStorage
@ -9220,8 +9221,7 @@ function spawnHudWindow(sessionId) {
// Remember where the user parks and sizes it (debounced — these fire many
// times mid-drag).
win.on('moved', schedulePersistHudState)
win.on('resized', schedulePersistHudState)
bindGeometryPersistence(win, schedulePersistHudState)
wireWindowReveal(win, {
show: () => {
@ -9619,10 +9619,9 @@ function createWindow() {
mainWindow.on('hide', () => sendWindowStateChanged())
mainWindow.on('show', () => sendWindowStateChanged())
// 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)
// Reopen where the user left off. close is the backstop, flushed
// synchronously before the window is gone.
bindGeometryPersistence(mainWindow, schedulePersistWindowState)
mainWindow.on('maximize', schedulePersistWindowState)
mainWindow.on('unmaximize', schedulePersistWindowState)
mainWindow.on('close', () => schedulePersistWindowState.flush())

View File

@ -5,10 +5,12 @@
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { test, vi } from 'vitest'
import {
bindGeometryPersistence,
computeWindowOptions,
debounce,
DEFAULT_HEIGHT,
@ -155,3 +157,33 @@ test('debounce.flush runs now and cancels the pending timer', () => {
vi.useRealTimers()
})
// ─── bindGeometryPersistence ───────────────────────────────────────────────
test('bindGeometryPersistence saves on drag and on resize', () => {
const win = new EventEmitter()
let saves = 0
bindGeometryPersistence(win, () => {
saves += 1
})
win.emit('move')
win.emit('resize')
assert.equal(saves, 2)
})
// The regression this exists for: `moved`/`resized` never fire on Linux, so a
// window bound only to those pretends to persist and silently forgets its place
// every launch. A window that emits nothing else must still save.
test('a window that never emits moved/resized still saves its geometry', () => {
const win = new EventEmitter()
let saves = 0
bindGeometryPersistence(win, () => {
saves += 1
})
win.emit('move')
assert.ok(saves > 0)
})

View File

@ -135,9 +135,30 @@ function debounce(fn, delayMs) {
return debounced
}
// The geometry events worth persisting from. `moved` and `resized` — the
// settled-once-per-drag pair — are macOS/Windows only (Electron tags them
// `@platform darwin,win32`), so a window bound to those alone never saves its
// place on Linux: the events simply never arrive. `move` and `resize` carry no
// platform tag and fire everywhere. They also fire continuously mid-drag, which
// is what the trailing debounce above is for — and once a burst collapses to a
// single trailing run, the settled events add nothing the debounce hasn't
// already given us.
const GEOMETRY_EVENTS = ['move', 'resize']
// Bind `schedule` to every geometry event, on a BrowserWindow or any emitter
// with `.on`. One call site per window so the platform reasoning above can't be
// half-applied to one window and not the other.
function bindGeometryPersistence(win, schedule) {
for (const event of GEOMETRY_EVENTS) {
win.on(event, schedule)
}
}
export {
bindGeometryPersistence,
computeWindowOptions,
debounce,
GEOMETRY_EVENTS,
DEFAULT_HEIGHT,
DEFAULT_WIDTH,
MIN_HEIGHT,