feat(desktop): persist and restore window state

Fixes #1223
This commit is contained in:
Éverton Toffanetto
2026-06-01 12:04:58 +00:00
committed by GitHub
parent a5c2f39f65
commit 1ede1bccf7
5 changed files with 608 additions and 3 deletions
@@ -3,12 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import {
applyWindowControlsOverlayUpdate,
createWindowControlsOverlayState,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_WIDTH,
getMainWindowChromeOptions,
getTitleBarOverlayOptions,
readBadgeCount,
readWindowControlsOverlayUpdate,
readWindowTheme,
resolveRuntimeTitleBarOverlayOptions,
resolveWindowBounds,
} from "./window-manager";
describe("window-manager", () => {
@@ -186,4 +189,26 @@ describe("window-manager", () => {
});
});
});
describe("resolveWindowBounds", () => {
it("falls back to the default size when no state is saved", () => {
expect(resolveWindowBounds(null)).toEqual({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
});
});
it("restores the full size and position", () => {
expect(
resolveWindowBounds({ x: 120, y: 80, width: 1024, height: 720, isMaximized: false }),
).toEqual({ width: 1024, height: 720, x: 120, y: 80 });
});
it("omits the position when only the size was persisted", () => {
expect(resolveWindowBounds({ width: 1024, height: 720, isMaximized: true })).toEqual({
width: 1024,
height: 720,
});
});
});
});
@@ -10,6 +10,10 @@ import {
shell,
} from "electron";
import type { WindowState, WindowStateStore } from "../settings/window-state.js";
const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400;
export function readBadgeCount(input: unknown): number {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
return 0;
@@ -87,6 +91,26 @@ export function getMainWindowChromeOptions(input: {
};
}
export const DEFAULT_WINDOW_WIDTH = 1200;
export const DEFAULT_WINDOW_HEIGHT = 800;
/**
* Window size/position options for the BrowserWindow constructor, derived from
* a restored state when available. Falls back to the default size, and only
* sets x/y when a full position was persisted (a partial state lets the OS
* place the window).
*/
export function resolveWindowBounds(
state: WindowState | null,
): Pick<Electron.BrowserWindowConstructorOptions, "width" | "height" | "x" | "y"> {
const width = state?.width ?? DEFAULT_WINDOW_WIDTH;
const height = state?.height ?? DEFAULT_WINDOW_HEIGHT;
if (state?.x !== undefined && state?.y !== undefined) {
return { width, height, x: state.x, y: state.y };
}
return { width, height };
}
function readFiniteOverlayHeight(input: unknown): number | null {
if (typeof input !== "number" || !Number.isFinite(input)) {
return null;
@@ -229,6 +253,96 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
});
}
/**
* Persist the window's size/position/maximized state so it can be restored on
* the next launch. Debounces disk writes on resize/move, writes immediately on
* maximize/unmaximize, and flushes synchronously on close so the final state
* survives quit/reboot. The latest geometry is captured into memory on every
* event so a queued async write can never overwrite the close-time snapshot.
*/
export function setupWindowStatePersistence(win: BrowserWindow, store: WindowStateStore): void {
let latestState: WindowState | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let flushed = false;
function clearTimer(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
function captureState(): void {
// Skip transient geometry: maximized/fullscreen bounds aren't the size we
// want to restore to, and a minimized window reports misleading bounds.
if (win.isMinimized() || win.isFullScreen()) {
return;
}
const bounds = win.getNormalBounds();
latestState = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized(),
};
}
function persist(): void {
if (latestState) {
void store.save(latestState).catch((error) => {
console.warn("[window-manager] Failed to persist window state", error);
});
}
}
function scheduleSave(): void {
captureState();
clearTimer();
saveTimer = setTimeout(() => {
saveTimer = null;
persist();
}, WINDOW_STATE_SAVE_DEBOUNCE_MS);
}
function saveNow(): void {
captureState();
clearTimer();
persist();
}
// Final synchronous flush. Runs on window close AND on app quit: the app's
// before-quit handler calls app.exit(0), which bypasses the window close
// event (see daemon/quit-lifecycle.ts), so close alone would miss Cmd+Q.
function flushFinal(): void {
if (flushed) {
return;
}
flushed = true;
clearTimer();
captureState();
if (latestState) {
try {
store.saveSync(latestState);
} catch (error) {
console.warn("[window-manager] Failed to persist window state on exit", error);
}
}
}
win.on("resize", scheduleSave);
win.on("move", scheduleSave);
win.on("maximize", saveNow);
win.on("unmaximize", saveNow);
win.on("close", flushFinal);
app.on("before-quit", flushFinal);
win.on("closed", () => {
clearTimer();
app.removeListener("before-quit", flushFinal);
});
}
export function buildStandardContextMenuItems(
contents: WebContents,
params: Electron.ContextMenuParams,