@@ -10,7 +10,17 @@ import path from "node:path";
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { app, BrowserWindow, Menu, ipcMain, nativeImage, net, protocol, session } from "electron";
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
Menu,
|
||||||
|
ipcMain,
|
||||||
|
nativeImage,
|
||||||
|
net,
|
||||||
|
protocol,
|
||||||
|
screen,
|
||||||
|
session,
|
||||||
|
} from "electron";
|
||||||
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
|
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||||
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
|
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
|
||||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||||
@@ -19,7 +29,9 @@ import {
|
|||||||
getMainWindowChromeOptions,
|
getMainWindowChromeOptions,
|
||||||
getWindowBackgroundColor,
|
getWindowBackgroundColor,
|
||||||
resolveSystemWindowTheme,
|
resolveSystemWindowTheme,
|
||||||
|
resolveWindowBounds,
|
||||||
setupWindowResizeEvents,
|
setupWindowResizeEvents,
|
||||||
|
setupWindowStatePersistence,
|
||||||
setupDefaultContextMenu,
|
setupDefaultContextMenu,
|
||||||
setupDragDropPrevention,
|
setupDragDropPrevention,
|
||||||
buildStandardContextMenuItems,
|
buildStandardContextMenuItems,
|
||||||
@@ -41,6 +53,7 @@ import {
|
|||||||
} from "./features/browser-webviews.js";
|
} from "./features/browser-webviews.js";
|
||||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||||
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
|
||||||
|
import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js";
|
||||||
import {
|
import {
|
||||||
isDesktopManagedDaemonRunningSync,
|
isDesktopManagedDaemonRunningSync,
|
||||||
stopDesktopDaemonViaCli,
|
stopDesktopDaemonViaCli,
|
||||||
@@ -372,15 +385,28 @@ function applyAppIcon(): void {
|
|||||||
app.dock?.setIcon(icon);
|
app.dock?.setIcon(icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Work areas with the primary display first, so window-state clamping treats
|
||||||
|
// it as the fallback. getAllDisplays() order is not guaranteed to lead with it.
|
||||||
|
function getWorkAreasPrimaryFirst(): Electron.Rectangle[] {
|
||||||
|
const primary = screen.getPrimaryDisplay();
|
||||||
|
const others = screen.getAllDisplays().filter((display) => display.id !== primary.id);
|
||||||
|
return [primary, ...others].map((display) => display.workArea);
|
||||||
|
}
|
||||||
|
|
||||||
async function createMainWindow(): Promise<void> {
|
async function createMainWindow(): Promise<void> {
|
||||||
const iconPath = getWindowIconPath();
|
const iconPath = getWindowIconPath();
|
||||||
const systemTheme = resolveSystemWindowTheme();
|
const systemTheme = resolveSystemWindowTheme();
|
||||||
|
|
||||||
|
const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") });
|
||||||
|
const savedWindowState = await windowStateStore.load();
|
||||||
|
const restoredWindowState = savedWindowState
|
||||||
|
? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst())
|
||||||
|
: null;
|
||||||
|
|
||||||
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
|
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
title,
|
title,
|
||||||
width: 1200,
|
...resolveWindowBounds(restoredWindowState),
|
||||||
height: 800,
|
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: getWindowBackgroundColor(systemTheme),
|
backgroundColor: getWindowBackgroundColor(systemTheme),
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
@@ -400,8 +426,13 @@ async function createMainWindow(): Promise<void> {
|
|||||||
app.dock?.setBadge(devWorktreeName);
|
app.dock?.setBadge(devWorktreeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (restoredWindowState?.isMaximized) {
|
||||||
|
mainWindow.maximize();
|
||||||
|
}
|
||||||
|
|
||||||
setupDarwinCompositorWatchdog(mainWindow);
|
setupDarwinCompositorWatchdog(mainWindow);
|
||||||
setupWindowResizeEvents(mainWindow);
|
setupWindowResizeEvents(mainWindow);
|
||||||
|
setupWindowStatePersistence(mainWindow, windowStateStore);
|
||||||
setupDefaultContextMenu(mainWindow);
|
setupDefaultContextMenu(mainWindow);
|
||||||
setupDragDropPrevention(mainWindow);
|
setupDragDropPrevention(mainWindow);
|
||||||
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
applyWindowControlsOverlayUpdate,
|
applyWindowControlsOverlayUpdate,
|
||||||
createWindowControlsOverlayState,
|
createWindowControlsOverlayState,
|
||||||
|
DEFAULT_WINDOW_HEIGHT,
|
||||||
|
DEFAULT_WINDOW_WIDTH,
|
||||||
getMainWindowChromeOptions,
|
getMainWindowChromeOptions,
|
||||||
getTitleBarOverlayOptions,
|
getTitleBarOverlayOptions,
|
||||||
readBadgeCount,
|
readBadgeCount,
|
||||||
readWindowControlsOverlayUpdate,
|
readWindowControlsOverlayUpdate,
|
||||||
readWindowTheme,
|
readWindowTheme,
|
||||||
resolveRuntimeTitleBarOverlayOptions,
|
resolveRuntimeTitleBarOverlayOptions,
|
||||||
|
resolveWindowBounds,
|
||||||
} from "./window-manager";
|
} from "./window-manager";
|
||||||
|
|
||||||
describe("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,
|
shell,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
|
|
||||||
|
import type { WindowState, WindowStateStore } from "../settings/window-state.js";
|
||||||
|
|
||||||
|
const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
export function readBadgeCount(input: unknown): number {
|
export function readBadgeCount(input: unknown): number {
|
||||||
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
||||||
return 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 {
|
function readFiniteOverlayHeight(input: unknown): number | null {
|
||||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||||
return null;
|
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(
|
export function buildStandardContextMenuItems(
|
||||||
contents: WebContents,
|
contents: WebContents,
|
||||||
params: Electron.ContextMenuParams,
|
params: Electron.ContextMenuParams,
|
||||||
|
|||||||
Reference in New Issue
Block a user