Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/macos/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand All @@ -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();
});
Expand Down
7 changes: 4 additions & 3 deletions apps/macos/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down
132 changes: 132 additions & 0 deletions apps/macos/src/main/window-state.ts
Original file line number Diff line number Diff line change
@@ -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.<id>", win)`, etc.
*/

interface SavedWindowState extends Rectangle {
isFullScreen: boolean;
}

interface StoreSchema {
windows: Record<string, SavedWindowState>;
}

let instance: Store<StoreSchema> | null = null;

const store = (): Store<StoreSchema> => {
if (!instance) {
instance = new Store<StoreSchema>({
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();
});
};