From d73bc7f17affe8f6fa6bb071da905c147ab135e3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 22:13:08 -0500 Subject: [PATCH] fix(desktop): persist window geometry on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- apps/desktop/electron/main.ts | 13 ++++----- apps/desktop/electron/window-state.test.ts | 32 ++++++++++++++++++++++ apps/desktop/electron/window-state.ts | 21 ++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index aba35f09a1e3a..a1d3b705d2e4d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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()) diff --git a/apps/desktop/electron/window-state.test.ts b/apps/desktop/electron/window-state.test.ts index 40c8fe1798e2a..6a5100d2d6064 100644 --- a/apps/desktop/electron/window-state.test.ts +++ b/apps/desktop/electron/window-state.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/window-state.ts b/apps/desktop/electron/window-state.ts index 56510e8827367..c35c1072780f8 100644 --- a/apps/desktop/electron/window-state.ts +++ b/apps/desktop/electron/window-state.ts @@ -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,