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
@@ -0,0 +1,205 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
MIN_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
type WindowState,
type WorkArea,
clampWindowStateToWorkAreas,
createWindowStateStore,
} from "./window-state";
async function createTempUserDataDir(): Promise<string> {
return await mkdtemp(path.join(os.tmpdir(), "paseo-window-state-"));
}
function stateFilePath(userDataPath: string): string {
return path.join(userDataPath, "window-state.json");
}
const PRIMARY: WorkArea = { x: 0, y: 0, width: 1920, height: 1080 };
describe("window-state store", () => {
const directories = new Set<string>();
afterEach(async () => {
await Promise.all(
[...directories].map(async (directory) => {
await rm(directory, { recursive: true, force: true });
}),
);
directories.clear();
});
it("returns null when no state has been persisted yet", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
expect(await store.load()).toBeNull();
});
it("round-trips a saved state through disk", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
const state: WindowState = { x: 100, y: 200, width: 1000, height: 700, isMaximized: false };
await store.save(state);
expect(await store.load()).toEqual(state);
});
it("persists the maximized flag", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
await store.save({ x: 0, y: 0, width: 1280, height: 800, isMaximized: true });
expect((await store.load())?.isMaximized).toBe(true);
});
it("leaves no temp files behind after an async save", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
await store.save({ x: 10, y: 10, width: 800, height: 600, isMaximized: false });
expect(await readdir(userDataPath)).toEqual(["window-state.json"]);
});
it("writes atomically and synchronously via saveSync", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
const store = createWindowStateStore({ userDataPath });
store.saveSync({ x: 5, y: 6, width: 900, height: 650, isMaximized: false });
const persisted = JSON.parse(await readFile(stateFilePath(userDataPath), "utf8")) as {
version: number;
state: WindowState;
};
expect(persisted.state).toEqual({ x: 5, y: 6, width: 900, height: 650, isMaximized: false });
expect(await readdir(userDataPath)).toEqual(["window-state.json"]);
});
it("returns null for corrupted JSON instead of throwing", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(stateFilePath(userDataPath), "{ not valid json");
const store = createWindowStateStore({ userDataPath });
expect(await store.load()).toBeNull();
});
it("returns null when persisted state lacks usable dimensions", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
stateFilePath(userDataPath),
JSON.stringify({ version: 1, state: { isMaximized: true } }),
);
const store = createWindowStateStore({ userDataPath });
expect(await store.load()).toBeNull();
});
it("clamps persisted dimensions up to the minimum size", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
stateFilePath(userDataPath),
JSON.stringify({ version: 1, state: { width: 100, height: 50, isMaximized: false } }),
);
const store = createWindowStateStore({ userDataPath });
const loaded = await store.load();
expect(loaded?.width).toBe(MIN_WINDOW_WIDTH);
expect(loaded?.height).toBe(MIN_WINDOW_HEIGHT);
});
it("drops non-finite coordinates while keeping valid dimensions", async () => {
const userDataPath = await createTempUserDataDir();
directories.add(userDataPath);
await writeFile(
stateFilePath(userDataPath),
JSON.stringify({
version: 1,
state: { x: "nope", y: null, width: 1000, height: 700, isMaximized: false },
}),
);
const store = createWindowStateStore({ userDataPath });
const loaded = await store.load();
expect(loaded).toEqual({ width: 1000, height: 700, isMaximized: false });
});
});
describe("clampWindowStateToWorkAreas", () => {
it("keeps a state fully inside the primary display unchanged", () => {
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: false };
expect(clampWindowStateToWorkAreas(state, [PRIMARY])).toEqual(state);
});
it("keeps valid negative coordinates from a left-side secondary monitor", () => {
const left: WorkArea = { x: -1920, y: 0, width: 1920, height: 1080 };
const state: WindowState = { x: -1800, y: 80, width: 1000, height: 700, isMaximized: false };
expect(clampWindowStateToWorkAreas(state, [left, PRIMARY])).toEqual(state);
});
it("drops x/y when the window does not meaningfully intersect any display", () => {
const state: WindowState = { x: 5000, y: 5000, width: 1000, height: 700, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
expect(clamped.x).toBeUndefined();
expect(clamped.y).toBeUndefined();
expect(clamped.width).toBe(1000);
expect(clamped.height).toBe(700);
});
it("shrinks an oversized window to the target work area", () => {
const state: WindowState = { x: 0, y: 0, width: 5000, height: 4000, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
expect(clamped.width).toBe(PRIMARY.width);
expect(clamped.height).toBe(PRIMARY.height);
});
it("repositions an oversized edge window so it stays fully on-screen after shrinking", () => {
// Saved near the right edge and larger than the display: a naive clamp would
// keep x=1820 and shrink to 1920 wide, leaving the window mostly off-screen.
const state: WindowState = { x: 1820, y: 0, width: 3000, height: 2000, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, [PRIMARY]);
expect(clamped.width).toBe(PRIMARY.width);
expect(clamped.height).toBe(PRIMARY.height);
expect(clamped.x).toBe(0);
expect(clamped.y).toBe(0);
});
it("drops position when there are no known displays", () => {
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: false };
const clamped = clampWindowStateToWorkAreas(state, []);
expect(clamped.x).toBeUndefined();
expect(clamped.y).toBeUndefined();
expect(clamped.width).toBe(1000);
expect(clamped.height).toBe(700);
});
it("preserves the maximized flag through clamping", () => {
const state: WindowState = { x: 100, y: 100, width: 1000, height: 700, isMaximized: true };
expect(clampWindowStateToWorkAreas(state, [PRIMARY]).isMaximized).toBe(true);
});
});
@@ -0,0 +1,230 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
export const MIN_WINDOW_WIDTH = 400;
export const MIN_WINDOW_HEIGHT = 300;
// Smallest slice of the window that must remain on a display for the saved
// position to count as "still reachable" after the monitor layout changes.
const MIN_VISIBLE_WIDTH = 100;
const MIN_VISIBLE_HEIGHT = 80;
export interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
/** A display's usable area (excludes the menu bar / taskbar), in DIP coordinates. */
export interface WorkArea {
x: number;
y: number;
width: number;
height: number;
}
interface PersistedWindowStateDocument {
version: 1;
state: WindowState;
}
export interface WindowStateStore {
/** Returns the persisted state, or null when nothing usable is stored. */
load(): Promise<WindowState | null>;
/** Persists the state atomically off the main thread (serialized writes). */
save(state: WindowState): Promise<void>;
/** Persists the state synchronously — used as the final writer on close/quit. */
saveSync(state: WindowState): void;
}
const WINDOW_STATE_FILENAME = "window-state.json";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error;
}
function coerceFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : null;
}
function coerceDimension(value: unknown, minimum: number): number | null {
const rounded = coerceFiniteNumber(value);
if (rounded === null) {
return null;
}
return Math.max(minimum, rounded);
}
export function coerceWindowState(input: unknown): WindowState | null {
if (!isRecord(input)) {
return null;
}
const width = coerceDimension(input.width, MIN_WINDOW_WIDTH);
const height = coerceDimension(input.height, MIN_WINDOW_HEIGHT);
if (width === null || height === null) {
return null;
}
const state: WindowState = { width, height, isMaximized: input.isMaximized === true };
const x = coerceFiniteNumber(input.x);
const y = coerceFiniteNumber(input.y);
// Only trust a position when both coordinates are present; a half-known
// position is worse than letting the OS place the window.
if (x !== null && y !== null) {
state.x = x;
state.y = y;
}
return state;
}
function serializeDocument(state: WindowState): string {
const document: PersistedWindowStateDocument = { version: 1, state };
return `${JSON.stringify(document, null, 2)}\n`;
}
/**
* Adjust a saved window state to the current display layout so the window never
* opens off-screen. Drops the saved position when it would not be reachable on
* any connected display, and shrinks oversized windows to the target work area.
* Pure: the caller supplies the display work areas (from Electron's `screen`).
*/
export function clampWindowStateToWorkAreas(
state: WindowState,
workAreas: WorkArea[],
): WindowState {
const primary = workAreas[0];
if (!primary) {
// No display info — keep the size, let the OS place the window.
return { width: state.width, height: state.height, isMaximized: state.isMaximized };
}
let target: WorkArea = primary;
const { x, y } = state;
let positioned = false;
if (x !== undefined && y !== undefined) {
const requiredWidth = Math.min(MIN_VISIBLE_WIDTH, state.width);
const requiredHeight = Math.min(MIN_VISIBLE_HEIGHT, state.height);
let bestOverlap = 0;
for (const workArea of workAreas) {
const overlapWidth = Math.max(
0,
Math.min(x + state.width, workArea.x + workArea.width) - Math.max(x, workArea.x),
);
const overlapHeight = Math.max(
0,
Math.min(y + state.height, workArea.y + workArea.height) - Math.max(y, workArea.y),
);
const overlap = overlapWidth * overlapHeight;
const isVisibleEnough = overlapWidth >= requiredWidth && overlapHeight >= requiredHeight;
if (isVisibleEnough && overlap > bestOverlap) {
bestOverlap = overlap;
target = workArea;
positioned = true;
}
}
}
const width = Math.min(Math.max(state.width, MIN_WINDOW_WIDTH), target.width);
const height = Math.min(Math.max(state.height, MIN_WINDOW_HEIGHT), target.height);
if (positioned && x !== undefined && y !== undefined) {
// Keep the window inside the chosen display after the size clamp so an
// oversized saved state cannot end up mostly off-screen.
const clampedX = Math.min(Math.max(x, target.x), target.x + target.width - width);
const clampedY = Math.min(Math.max(y, target.y), target.y + target.height - height);
return { x: clampedX, y: clampedY, width, height, isMaximized: state.isMaximized };
}
return { width, height, isMaximized: state.isMaximized };
}
export function createWindowStateStore({
userDataPath,
}: {
userDataPath: string;
}): WindowStateStore {
const filePath = path.join(userDataPath, WINDOW_STATE_FILENAME);
let persistQueue: Promise<void> = Promise.resolve();
// Once the synchronous final write lands (on close/quit), pending async
// writes must not clobber it with an older snapshot.
let finalized = false;
function tempFilePath(): string {
return `${filePath}.tmp.${process.pid}.${randomUUID()}`;
}
return {
async load(): Promise<WindowState | null> {
let raw: string;
try {
raw = await readFile(filePath, "utf8");
} catch (error) {
// No file yet (first launch) is expected; surface anything else.
if (isNodeError(error) && error.code === "ENOENT") {
return null;
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
// A corrupted file shouldn't block launch; non-critical state falls back.
if (error instanceof SyntaxError) {
return null;
}
throw error;
}
if (!isRecord(parsed)) {
return null;
}
return coerceWindowState(parsed.state);
},
async save(state: WindowState): Promise<void> {
const contents = serializeDocument(state);
async function write(): Promise<void> {
if (finalized) {
return;
}
await mkdir(userDataPath, { recursive: true });
const tempPath = tempFilePath();
await writeFile(tempPath, contents, "utf8");
if (finalized) {
// A synchronous final write (saveSync) landed while this one was in
// flight. Keep it as the last writer instead of overwriting it, and
// discard our now-stale temp file so it can't accumulate in userData.
await unlink(tempPath).catch(() => undefined);
return;
}
await rename(tempPath, filePath);
}
const queued = persistQueue.then(write, write);
persistQueue = queued.catch(() => undefined);
await queued;
},
saveSync(state: WindowState): void {
finalized = true;
const contents = serializeDocument(state);
mkdirSync(userDataPath, { recursive: true });
const tempPath = tempFilePath();
writeFileSync(tempPath, contents, "utf8");
renameSync(tempPath, filePath);
},
};
}