diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 1ff035e71..3076ddb0f 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -10,7 +10,17 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; 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 { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js"; import { closeAllTransportSessions } from "./daemon/local-transport.js"; @@ -19,7 +29,9 @@ import { getMainWindowChromeOptions, getWindowBackgroundColor, resolveSystemWindowTheme, + resolveWindowBounds, setupWindowResizeEvents, + setupWindowStatePersistence, setupDefaultContextMenu, setupDragDropPrevention, buildStandardContextMenuItems, @@ -41,6 +53,7 @@ import { } from "./features/browser-webviews.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; +import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js"; import { isDesktopManagedDaemonRunningSync, stopDesktopDaemonViaCli, @@ -372,15 +385,28 @@ function applyAppIcon(): void { 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 { const iconPath = getWindowIconPath(); 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 mainWindow = new BrowserWindow({ title, - width: 1200, - height: 800, + ...resolveWindowBounds(restoredWindowState), show: false, backgroundColor: getWindowBackgroundColor(systemTheme), ...(iconPath ? { icon: iconPath } : {}), @@ -400,8 +426,13 @@ async function createMainWindow(): Promise { app.dock?.setBadge(devWorktreeName); } + if (restoredWindowState?.isMaximized) { + mainWindow.maximize(); + } + setupDarwinCompositorWatchdog(mainWindow); setupWindowResizeEvents(mainWindow); + setupWindowStatePersistence(mainWindow, windowStateStore); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => { diff --git a/packages/desktop/src/settings/window-state.test.ts b/packages/desktop/src/settings/window-state.test.ts new file mode 100644 index 000000000..77419554f --- /dev/null +++ b/packages/desktop/src/settings/window-state.test.ts @@ -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 { + 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(); + + 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); + }); +}); diff --git a/packages/desktop/src/settings/window-state.ts b/packages/desktop/src/settings/window-state.ts new file mode 100644 index 000000000..dd482cea3 --- /dev/null +++ b/packages/desktop/src/settings/window-state.ts @@ -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; + /** Persists the state atomically off the main thread (serialized writes). */ + save(state: WindowState): Promise; + /** 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 { + 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 = 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 { + 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 { + const contents = serializeDocument(state); + async function write(): Promise { + 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); + }, + }; +} diff --git a/packages/desktop/src/window/window-manager.test.ts b/packages/desktop/src/window/window-manager.test.ts index f095d6789..b90f50993 100644 --- a/packages/desktop/src/window/window-manager.test.ts +++ b/packages/desktop/src/window/window-manager.test.ts @@ -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, + }); + }); + }); }); diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index 56697685d..f5ee448a0 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -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 { + 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 | 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,