From 5dde09fb5f1d3b2536f6088aec471e7ee2202055 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 19:04:18 +0000 Subject: [PATCH 1/2] feat(macos): persist + restore main window geometry across launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createWindow()` now spreads `restoreBounds("main", ...)` into the BrowserWindow constructor and registers `trackWindowState("main", win)` so the next launch comes up where the user left it. Geometry lives in its own `electron-store` instance (`window-state.json` under userData) rather than the renderer-facing settings store — which keeps the strict-schema'd settings file clean of main-process-only data and avoids exposing window position through the contextBridge to a renderer that has no business reading or writing it. Updated `settings.ts`'s docstring to point at the new module. The persistence shape (~80 lines in `window-state.ts`): - `restoreBounds(key, defaults)` — falls through to defaults on first run. When state exists, clamps it into the closest still-connected display's `workArea` via `screen.getDisplayMatching`, so unplugging the external monitor since last launch doesn't strand the window off-screen. - `track(key, win)` — saves on `close` synchronously (normal exit) and on `resize`/`move` with a 500ms debounce (crash recovery — covers the case where `close` never fires). Reads `getNormalBounds()` so un-fullscreening on the next launch doesn't reveal a 1×1 sliver, and tracks `isFullScreen()` separately as a flag passed back to the BrowserWindow constructor. Designed for reuse: future thread pop-outs, the About window, and the onboarding window can all `track("thread.", win)` / `track("about", win)` / etc. against the same store without clobbering each other. Default size (1280×800) kept unchanged — moving to Swift's 1200×900 target lives in LUM-1965 alongside the tray icon work. --- apps/macos/src/main/index.ts | 10 ++- apps/macos/src/main/settings.ts | 7 +- apps/macos/src/main/window-state.ts | 132 ++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 apps/macos/src/main/window-state.ts diff --git a/apps/macos/src/main/index.ts b/apps/macos/src/main/index.ts index 763fb620ce0..aa676edd48e 100644 --- a/apps/macos/src/main/index.ts +++ b/apps/macos/src/main/index.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { installApplicationMenu } from "./menu"; import { readSetting, writeSetting } from "./settings"; +import { restoreBounds, track as trackWindowState } from "./window-state"; // Dev-mode renderer URL. Honors `VELLUM_DEV_URL` so the launcher can // point the BrowserWindow at whichever Vite-or-equivalent is actually @@ -82,8 +83,7 @@ let mainWindow: BrowserWindow | null = null; const createWindow = (): void => { mainWindow = new BrowserWindow({ - width: 1280, - height: 800, + ...restoreBounds("main", { width: 1280, height: 800 }), show: false, webPreferences: { preload: path.join(__dirname, "../preload/index.js"), @@ -97,6 +97,12 @@ const createWindow = (): void => { }, }); + // Subscribe to resize/move/close so the next launch can restore the + // user's last geometry. See `window-state.ts` for the persistence + // model (separate electron-store file, debounced saves, fullscreen + // tracked as a flag rather than as bounds). + trackWindowState("main", mainWindow); + mainWindow.once("ready-to-show", () => { mainWindow?.show(); }); diff --git a/apps/macos/src/main/settings.ts b/apps/macos/src/main/settings.ts index 66f9ea5f8b7..187297c4c2c 100644 --- a/apps/macos/src/main/settings.ts +++ b/apps/macos/src/main/settings.ts @@ -9,9 +9,10 @@ import Store, { type Schema } from "electron-store"; * * Note: window geometry (position, size) is intentionally NOT here. It's a * main-process-managed concern in Electron (system-managed on iOS, - * browser-managed on web), and the renderer never reads or writes it. If - * window-state restore is wired in a future ticket, it lives in its own - * keyspace or via a dedicated library (e.g. `electron-window-state`). + * browser-managed on web), and the renderer never reads or writes it. + * The persistence for that lives in `./window-state.ts`, which uses its + * own `electron-store` instance keyed by window kind so it doesn't have + * to share this file's strict schema. */ export interface AppSettings { hotkeys: Record; diff --git a/apps/macos/src/main/window-state.ts b/apps/macos/src/main/window-state.ts new file mode 100644 index 00000000000..5a03ce226bd --- /dev/null +++ b/apps/macos/src/main/window-state.ts @@ -0,0 +1,132 @@ +import { BrowserWindow, screen, type Rectangle } from "electron"; +import Store from "electron-store"; + +/** + * Window-geometry persistence. Kept in its own `electron-store` instance + * (`window-state.json`) so it doesn't collide with the renderer-facing + * `settings` store, which has `additionalProperties: false` at the root + * and a strict per-key schema. Window state is a main-process concern + * the renderer never reads or writes — it doesn't belong on the + * `window.vellum.settings.*` bridge. + * + * `key` namespaces the stored shape, so future windows (thread pop-outs, + * About, onboarding) can persist alongside the main window without + * clobbering each other — `track("main", win)`, + * `track("thread.", win)`, etc. + */ + +interface SavedWindowState extends Rectangle { + isFullScreen: boolean; +} + +interface StoreSchema { + windows: Record; +} + +let instance: Store | null = null; + +const store = (): Store => { + if (!instance) { + instance = new Store({ + name: "window-state", + defaults: { windows: {} }, + }); + } + return instance; +}; + +interface Defaults { + width: number; + height: number; +} + +export interface RestoredWindowState { + x?: number; + y?: number; + width: number; + height: number; + fullscreen?: boolean; +} + +/** + * Resolve the bounds to construct a `BrowserWindow` with, falling through + * to the supplied defaults when no state has been persisted for `key`. + * + * When state IS present, the saved rectangle is matched to the closest + * still-connected display via `screen.getDisplayMatching` and clamped + * into that display's work area, so: + * + * - An external monitor that was unplugged since the last run doesn't + * leave the window 100% off-screen — it shows up on whatever's left. + * - A monitor that shrunk (resolution change) doesn't leave the window + * extending past the new edge. + * + * Omitting `x` / `y` when no state exists is intentional — Electron + * centers the window in that case, which is the right first-run UX. + */ +export const restoreBounds = ( + key: string, + defaults: Defaults, +): RestoredWindowState => { + const saved = store().get("windows", {})[key]; + if (!saved) return defaults; + + const display = screen.getDisplayMatching(saved); + const wa = display.workArea; + + const width = Math.min(saved.width, wa.width); + const height = Math.min(saved.height, wa.height); + const x = Math.max(wa.x, Math.min(saved.x, wa.x + wa.width - width)); + const y = Math.max(wa.y, Math.min(saved.y, wa.y + wa.height - height)); + + return { x, y, width, height, fullscreen: saved.isFullScreen }; +}; + +/** + * Persist this window's geometry under `key` so the next launch can + * restore it. Saves on: + * + * - `close` — synchronous, the normal-exit path. Captures whatever + * state the user left the window in. + * - `resize` / `move` — debounced 500ms. Covers the crash case where + * `close` never fires; users lose at most half a second of drag. + * + * Reads `getNormalBounds()` rather than `getBounds()` so a maximized or + * fullscreen window persists its restored-size geometry instead of the + * full-display rectangle — otherwise un-maximizing on the next run + * would leave a tiny window. `isFullScreen()` is tracked separately and + * passed through to the `BrowserWindow` constructor on restore, so the + * window comes back in the same display mode it was left in. + * + * Skips persisting when the window is minimized — the bounds at that + * moment are not meaningful. + */ +export const track = (key: string, win: BrowserWindow): void => { + const SAVE_DEBOUNCE_MS = 500; + let saveTimer: NodeJS.Timeout | null = null; + + const persist = (): void => { + if (win.isDestroyed() || win.isMinimized()) return; + const bounds = win.getNormalBounds(); + const existing = store().get("windows", {}); + store().set("windows", { + ...existing, + [key]: { ...bounds, isFullScreen: win.isFullScreen() }, + }); + }; + + const schedulePersist = (): void => { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(persist, SAVE_DEBOUNCE_MS); + }; + + win.on("resize", schedulePersist); + win.on("move", schedulePersist); + win.on("close", () => { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + persist(); + }); +}; From 3782a38516f581f50a1d1d04bb0e71005e84c7f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 19:19:58 +0000 Subject: [PATCH 2/2] fix(macos): drop redundant isMinimized() guard in window-state.persist() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin review caught a real bug. `getNormalBounds()` per Electron's docs returns the position and size of the window "in normal state" — regardless of minimized / maximized / fullscreen. The extra `isMinimized()` guard wasn't protecting anything (the bounds are already correct), and it was actively dropping state in two scenarios: 1. **Quit while minimized.** Common macOS flow: minimize to dock, later Cmd+Q. The `close` handler called `persist()` which then skipped saving because the window was still minimized — losing whatever position the user moved to since the last debounced save. 2. **Resize/move within 500ms of minimize.** Debounced timer fires while minimized, persist skips, latest geometry never saved. Switched the guard to just `isDestroyed()` (the only condition where the bounds API would actually throw) and updated the docstring to call out the `getNormalBounds()`-handles-minimized-correctly point so the next reader doesn't re-introduce the check. --- apps/macos/src/main/window-state.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/macos/src/main/window-state.ts b/apps/macos/src/main/window-state.ts index 5a03ce226bd..a6e7fcc7af2 100644 --- a/apps/macos/src/main/window-state.ts +++ b/apps/macos/src/main/window-state.ts @@ -94,19 +94,19 @@ export const restoreBounds = ( * Reads `getNormalBounds()` rather than `getBounds()` so a maximized or * fullscreen window persists its restored-size geometry instead of the * full-display rectangle — otherwise un-maximizing on the next run - * would leave a tiny window. `isFullScreen()` is tracked separately and - * passed through to the `BrowserWindow` constructor on restore, so the - * window comes back in the same display mode it was left in. - * - * Skips persisting when the window is minimized — the bounds at that - * moment are not meaningful. + * would leave a tiny window. `getNormalBounds()` also returns the + * pre-minimize bounds when the window is minimized, so no special + * handling is needed for the common macOS "minimize to dock, then + * Cmd+Q" path. `isFullScreen()` is tracked separately and passed + * through to the `BrowserWindow` constructor on restore, so the window + * comes back in the same display mode it was left in. */ export const track = (key: string, win: BrowserWindow): void => { const SAVE_DEBOUNCE_MS = 500; let saveTimer: NodeJS.Timeout | null = null; const persist = (): void => { - if (win.isDestroyed() || win.isMinimized()) return; + if (win.isDestroyed()) return; const bounds = win.getNormalBounds(); const existing = store().get("windows", {}); store().set("windows", {