@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user