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..a6e7fcc7af2 --- /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. `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()) 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(); + }); +};