diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3dba6016..d44fee39a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { attachWindowStatePersistence, loadWindowState } from "./windowState"; syncShellEnvironment(); @@ -75,6 +76,12 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const DEFAULT_WINDOW_BOUNDS = { + width: 1100, + height: 780, +} as const; +const MIN_WINDOW_WIDTH = 840; +const MIN_WINDOW_HEIGHT = 620; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; @@ -1221,11 +1228,24 @@ function getIconOption(): { icon: string } | Record { } function createWindow(): BrowserWindow { + const initialWindowState = loadWindowState({ + userDataPath: app.getPath("userData"), + defaultBounds: { + x: 0, + y: 0, + width: DEFAULT_WINDOW_BOUNDS.width, + height: DEFAULT_WINDOW_BOUNDS.height, + }, + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, + }); const window = new BrowserWindow({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, + x: initialWindowState.bounds.x, + y: initialWindowState.bounds.y, + width: initialWindowState.bounds.width, + height: initialWindowState.bounds.height, + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, show: false, autoHideMenuBar: true, ...getIconOption(), @@ -1239,6 +1259,10 @@ function createWindow(): BrowserWindow { sandbox: true, }, }); + attachWindowStatePersistence({ + window, + userDataPath: app.getPath("userData"), + }); window.webContents.on("context-menu", (event, params) => { event.preventDefault(); @@ -1285,6 +1309,13 @@ function createWindow(): BrowserWindow { emitUpdateState(); }); window.once("ready-to-show", () => { + if (initialWindowState.restoreMode === "fullscreen-origin") { + window.show(); + return; + } + if (initialWindowState.restoreMode === "maximized") { + window.maximize(); + } window.show(); }); diff --git a/apps/desktop/src/windowState.test.ts b/apps/desktop/src/windowState.test.ts new file mode 100644 index 000000000..6575338df --- /dev/null +++ b/apps/desktop/src/windowState.test.ts @@ -0,0 +1,407 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { screenMock } = vi.hoisted(() => ({ + screenMock: { + getPrimaryDisplay: vi.fn(), + getDisplayMatching: vi.fn(), + }, +})); + +vi.mock("electron", () => ({ + screen: screenMock, +})); + +import { attachWindowStatePersistence, loadWindowState } from "./windowState"; + +class FakeBrowserWindow { + private readonly listeners = new Map void>>(); + private currentBounds: { x: number; y: number; width: number; height: number }; + + constructor( + private normalBounds: { x: number; y: number; width: number; height: number }, + private fullscreen = false, + private maximized = false, + ) { + this.currentBounds = normalBounds; + } + + on(event: string, listener: () => void): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + emit(event: string): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(); + } + } + + getNormalBounds() { + return this.normalBounds; + } + + setNormalBounds(bounds: { x: number; y: number; width: number; height: number }): void { + this.normalBounds = bounds; + } + + getBounds() { + return this.currentBounds; + } + + setCurrentBounds(bounds: { x: number; y: number; width: number; height: number }): void { + this.currentBounds = bounds; + } + + isFullScreen(): boolean { + return this.fullscreen; + } + + isMaximized(): boolean { + return this.maximized; + } + + setModes({ fullscreen, maximized }: { fullscreen?: boolean; maximized?: boolean }): void { + if (fullscreen !== undefined) { + this.fullscreen = fullscreen; + } + if (maximized !== undefined) { + this.maximized = maximized; + } + } +} + +function createTempDir(): string { + return FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-window-state-")); +} + +function readPersistedWindowState(dir: string) { + return JSON.parse(FS.readFileSync(Path.join(dir, "window-state.json"), "utf8")); +} + +describe("windowState", () => { + const defaultBounds = { + x: 0, + y: 0, + width: 1100, + height: 780, + } as const; + const primaryWorkArea = { + x: 0, + y: 25, + width: 1440, + height: 875, + }; + + beforeEach(() => { + vi.useFakeTimers(); + screenMock.getPrimaryDisplay.mockReset(); + screenMock.getDisplayMatching.mockReset(); + screenMock.getPrimaryDisplay.mockReturnValue({ + workArea: primaryWorkArea, + }); + screenMock.getDisplayMatching.mockReturnValue({ + workArea: primaryWorkArea, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("loads centered defaults when the state file is missing", () => { + const result = loadWindowState({ + userDataPath: createTempDir(), + defaultBounds, + minWidth: 840, + minHeight: 620, + }); + + expect(result).toEqual({ + bounds: { + x: 170, + y: 73, + width: 1100, + height: 780, + }, + restoreMode: "normal", + }); + }); + + it("falls back to defaults for invalid JSON and unsupported versions", () => { + const invalidJsonDir = createTempDir(); + FS.writeFileSync(Path.join(invalidJsonDir, "window-state.json"), "{oops", "utf8"); + + expect( + loadWindowState({ + userDataPath: invalidJsonDir, + defaultBounds, + minWidth: 840, + minHeight: 620, + }).restoreMode, + ).toBe("normal"); + + const invalidVersionDir = createTempDir(); + FS.writeFileSync( + Path.join(invalidVersionDir, "window-state.json"), + JSON.stringify({ + version: 2, + normalBounds: { x: 10, y: 10, width: 1200, height: 800 }, + restoreMode: "maximized", + }), + "utf8", + ); + + expect( + loadWindowState({ + userDataPath: invalidVersionDir, + defaultBounds, + minWidth: 840, + minHeight: 620, + }).restoreMode, + ).toBe("normal"); + }); + + it("clamps bounds below the minimum size", () => { + const dir = createTempDir(); + FS.writeFileSync( + Path.join(dir, "window-state.json"), + JSON.stringify({ + version: 1, + normalBounds: { x: 10, y: 20, width: 300, height: 200 }, + restoreMode: "normal", + }), + "utf8", + ); + + const result = loadWindowState({ + userDataPath: dir, + defaultBounds, + minWidth: 840, + minHeight: 620, + }); + + expect(result.bounds).toEqual({ + x: 10, + y: 20, + width: 840, + height: 620, + }); + expect(result.restoreMode).toBe("normal"); + }); + + it("accepts partially visible bounds when enough of the window remains on-screen", () => { + const workArea = { x: 0, y: 0, width: 1000, height: 800 }; + screenMock.getPrimaryDisplay.mockReturnValue({ workArea }); + screenMock.getDisplayMatching.mockReturnValue({ workArea }); + + const dir = createTempDir(); + FS.writeFileSync( + Path.join(dir, "window-state.json"), + JSON.stringify({ + version: 1, + normalBounds: { x: 820, y: 50, width: 850, height: 700 }, + restoreMode: "maximized", + }), + "utf8", + ); + + const result = loadWindowState({ + userDataPath: dir, + defaultBounds, + minWidth: 840, + minHeight: 620, + }); + + expect(result).toEqual({ + bounds: { + x: 820, + y: 50, + width: 850, + height: 700, + }, + restoreMode: "maximized", + }); + }); + + it("falls back to defaults when persisted bounds are effectively off-screen", () => { + const workArea = { x: 0, y: 0, width: 1000, height: 800 }; + screenMock.getPrimaryDisplay.mockReturnValue({ workArea }); + screenMock.getDisplayMatching.mockReturnValue({ workArea }); + + const dir = createTempDir(); + FS.writeFileSync( + Path.join(dir, "window-state.json"), + JSON.stringify({ + version: 1, + normalBounds: { x: 950, y: 50, width: 400, height: 400 }, + restoreMode: "maximized", + }), + "utf8", + ); + + const result = loadWindowState({ + userDataPath: dir, + defaultBounds, + minWidth: 840, + minHeight: 620, + }); + + expect(result).toEqual({ + bounds: { + x: -50, + y: 10, + width: 1100, + height: 780, + }, + restoreMode: "normal", + }); + }); + + it("persists fullscreen-origin restores separately from normal maximized state", () => { + const fullscreenDir = createTempDir(); + const fullscreenWindow = new FakeBrowserWindow( + { x: 100, y: 120, width: 1200, height: 900 }, + true, + true, + ); + fullscreenWindow.setCurrentBounds({ x: 60, y: 40, width: 1400, height: 850 }); + attachWindowStatePersistence({ + window: fullscreenWindow as never, + userDataPath: fullscreenDir, + }); + fullscreenWindow.emit("close"); + + expect(readPersistedWindowState(fullscreenDir)).toEqual({ + version: 1, + normalBounds: { x: 100, y: 120, width: 1200, height: 900 }, + restoreMode: "fullscreen-origin", + fullscreenOriginBounds: { x: 60, y: 40, width: 1400, height: 850 }, + }); + }); + + it("preserves the last non-fullscreen bounds when closing from true fullscreen", () => { + const dir = createTempDir(); + const window = new FakeBrowserWindow({ x: 120, y: 90, width: 1280, height: 860 }, false, true); + window.setCurrentBounds({ x: 40, y: 32, width: 1392, height: 842 }); + attachWindowStatePersistence({ + window: window as never, + userDataPath: dir, + }); + + window.setModes({ fullscreen: true, maximized: true }); + window.setNormalBounds({ x: 0, y: 0, width: 1512, height: 982 }); + window.setCurrentBounds({ x: 0, y: 0, width: 1512, height: 982 }); + window.emit("enter-full-screen"); + window.emit("close"); + + expect(readPersistedWindowState(dir)).toEqual({ + version: 1, + normalBounds: { x: 120, y: 90, width: 1280, height: 860 }, + restoreMode: "fullscreen-origin", + fullscreenOriginBounds: { x: 40, y: 32, width: 1392, height: 842 }, + }); + }); + + it("persists maximized and normal modes using normal bounds", () => { + const maximizedDir = createTempDir(); + const maximizedWindow = new FakeBrowserWindow( + { x: 40, y: 60, width: 1280, height: 860 }, + false, + true, + ); + attachWindowStatePersistence({ + window: maximizedWindow as never, + userDataPath: maximizedDir, + }); + maximizedWindow.emit("close"); + + expect(readPersistedWindowState(maximizedDir)).toEqual({ + version: 1, + normalBounds: { x: 40, y: 60, width: 1280, height: 860 }, + restoreMode: "maximized", + }); + + const normalDir = createTempDir(); + const normalWindow = new FakeBrowserWindow({ x: 10, y: 20, width: 900, height: 700 }); + attachWindowStatePersistence({ + window: normalWindow as never, + userDataPath: normalDir, + }); + normalWindow.emit("close"); + + expect(readPersistedWindowState(normalDir)).toEqual({ + version: 1, + normalBounds: { x: 10, y: 20, width: 900, height: 700 }, + restoreMode: "normal", + }); + }); + + it("flushes pending resize persistence when the window closes", () => { + const dir = createTempDir(); + const window = new FakeBrowserWindow({ x: 22, y: 44, width: 1000, height: 720 }); + attachWindowStatePersistence({ + window: window as never, + userDataPath: dir, + }); + + window.emit("resize"); + expect(FS.existsSync(Path.join(dir, "window-state.json"))).toBe(false); + + window.emit("close"); + + expect(readPersistedWindowState(dir).normalBounds).toEqual({ + x: 22, + y: 44, + width: 1000, + height: 720, + }); + }); + + it("persists debounced move and resize updates", () => { + const dir = createTempDir(); + const window = new FakeBrowserWindow({ x: 33, y: 55, width: 1111, height: 777 }); + attachWindowStatePersistence({ + window: window as never, + userDataPath: dir, + }); + + window.emit("move"); + vi.advanceTimersByTime(249); + expect(FS.existsSync(Path.join(dir, "window-state.json"))).toBe(false); + + vi.advanceTimersByTime(1); + expect(readPersistedWindowState(dir).normalBounds).toEqual({ + x: 33, + y: 55, + width: 1111, + height: 777, + }); + }); + + it("logs write failures without throwing", () => { + const filePath = Path.join(createTempDir(), "occupied"); + FS.writeFileSync(filePath, "not a directory", "utf8"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const window = new FakeBrowserWindow({ x: 10, y: 20, width: 900, height: 700 }); + + expect(() => + attachWindowStatePersistence({ + window: window as never, + userDataPath: filePath, + }), + ).not.toThrow(); + + expect(() => window.emit("close")).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + "[desktop] failed to persist window state", + expect.any(Error), + ); + }); +}); diff --git a/apps/desktop/src/windowState.ts b/apps/desktop/src/windowState.ts new file mode 100644 index 000000000..fe1b3e6e6 --- /dev/null +++ b/apps/desktop/src/windowState.ts @@ -0,0 +1,304 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import { screen } from "electron"; +import type { BrowserWindow, Rectangle } from "electron"; + +const WINDOW_STATE_FILE_NAME = "window-state.json"; +const WINDOW_STATE_VERSION = 1; +const WINDOW_VISIBILITY_THRESHOLD = 0.2; +const WINDOW_STATE_PERSIST_DEBOUNCE_MS = 250; + +type PersistedWindowRestoreMode = "normal" | "maximized" | "fullscreen-origin"; + +interface PersistedWindowState { + readonly version: 1; + readonly normalBounds: Rectangle; + readonly restoreMode: PersistedWindowRestoreMode; + readonly fullscreenOriginBounds?: Rectangle; +} + +export interface ResolvedWindowState { + readonly bounds: Rectangle; + readonly restoreMode: PersistedWindowRestoreMode; +} + +interface LoadWindowStateParams { + readonly userDataPath: string; + readonly defaultBounds: Rectangle; + readonly minWidth: number; + readonly minHeight: number; +} + +interface AttachWindowStatePersistenceParams { + readonly window: BrowserWindow; + readonly userDataPath: string; +} + +function getWindowStateFilePath(userDataPath: string): string { + return Path.join(userDataPath, WINDOW_STATE_FILE_NAME); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isPersistedWindowRestoreMode(value: unknown): value is PersistedWindowRestoreMode { + return value === "normal" || value === "maximized" || value === "fullscreen-origin"; +} + +function parseRectangle(value: unknown): Rectangle | null { + if (typeof value !== "object" || value === null) { + return null; + } + + const { x, y, width, height } = value as Record; + if ( + !isFiniteNumber(x) || + !isFiniteNumber(y) || + !isFiniteNumber(width) || + !isFiniteNumber(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + + return { + x, + y, + width, + height, + }; +} + +function parsePersistedWindowState(raw: string): PersistedWindowState | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) { + return null; + } + + const { version, normalBounds, restoreMode, fullscreenOriginBounds } = parsed as { + version?: unknown; + normalBounds?: Record; + restoreMode?: unknown; + fullscreenOriginBounds?: Record; + }; + + if (version !== WINDOW_STATE_VERSION || !isPersistedWindowRestoreMode(restoreMode)) { + return null; + } + + const parsedNormalBounds = parseRectangle(normalBounds); + if (!parsedNormalBounds) { + return null; + } + + let parsedFullscreenOriginBounds: Rectangle | undefined; + if (fullscreenOriginBounds !== undefined) { + const parsedBounds = parseRectangle(fullscreenOriginBounds); + if (!parsedBounds) { + return null; + } + parsedFullscreenOriginBounds = parsedBounds; + } + + if (restoreMode === "fullscreen-origin" && parsedFullscreenOriginBounds === undefined) { + return null; + } + + return { + version: WINDOW_STATE_VERSION, + normalBounds: parsedNormalBounds, + restoreMode, + ...(parsedFullscreenOriginBounds + ? { fullscreenOriginBounds: parsedFullscreenOriginBounds } + : {}), + }; +} + +function sanitizeBounds(bounds: Rectangle, minWidth: number, minHeight: number): Rectangle { + return { + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(minWidth, Math.round(bounds.width)), + height: Math.max(minHeight, Math.round(bounds.height)), + }; +} + +function intersectionArea(a: Rectangle, b: Rectangle): number { + const left = Math.max(a.x, b.x); + const top = Math.max(a.y, b.y); + const right = Math.min(a.x + a.width, b.x + b.width); + const bottom = Math.min(a.y + a.height, b.y + b.height); + + if (right <= left || bottom <= top) { + return 0; + } + + return (right - left) * (bottom - top); +} + +function isWindowVisibleEnough(bounds: Rectangle): boolean { + const display = screen.getDisplayMatching(bounds); + const visibleArea = intersectionArea(bounds, display.workArea); + const totalArea = bounds.width * bounds.height; + + if (totalArea <= 0) { + return false; + } + + return visibleArea / totalArea >= WINDOW_VISIBILITY_THRESHOLD; +} + +function centerBoundsInDisplay(displayBounds: Rectangle, width: number, height: number): Rectangle { + return { + x: Math.round(displayBounds.x + (displayBounds.width - width) / 2), + y: Math.round(displayBounds.y + (displayBounds.height - height) / 2), + width, + height, + }; +} + +function buildDefaultWindowState( + defaultBounds: Rectangle, + minWidth: number, + minHeight: number, +): ResolvedWindowState { + const primaryDisplay = screen.getPrimaryDisplay(); + const width = Math.max(minWidth, Math.round(defaultBounds.width)); + const height = Math.max(minHeight, Math.round(defaultBounds.height)); + + return { + bounds: centerBoundsInDisplay(primaryDisplay.workArea, width, height), + restoreMode: "normal", + }; +} + +function readRestorableWindowState(window: BrowserWindow): PersistedWindowState { + return { + version: WINDOW_STATE_VERSION, + normalBounds: window.getNormalBounds(), + restoreMode: window.isMaximized() ? "maximized" : "normal", + }; +} + +function persistWindowState(state: PersistedWindowState, userDataPath: string): void { + try { + const filePath = getWindowStateFilePath(userDataPath); + FS.mkdirSync(Path.dirname(filePath), { recursive: true }); + FS.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + } catch (error) { + console.warn("[desktop] failed to persist window state", error); + } +} + +export function loadWindowState({ + userDataPath, + defaultBounds, + minWidth, + minHeight, +}: LoadWindowStateParams): ResolvedWindowState { + const fallback = buildDefaultWindowState(defaultBounds, minWidth, minHeight); + const filePath = getWindowStateFilePath(userDataPath); + + if (!FS.existsSync(filePath)) { + return fallback; + } + + let raw: string; + try { + raw = FS.readFileSync(filePath, "utf8"); + } catch { + return fallback; + } + + const parsed = parsePersistedWindowState(raw); + if (!parsed) { + return fallback; + } + + const normalBounds = sanitizeBounds(parsed.normalBounds, minWidth, minHeight); + if (!isWindowVisibleEnough(normalBounds)) { + return fallback; + } + + if (parsed.restoreMode === "fullscreen-origin") { + const fullscreenOriginBoundsRaw = parsed.fullscreenOriginBounds; + if (!fullscreenOriginBoundsRaw) { + return fallback; + } + + const fullscreenOriginBounds = sanitizeBounds(fullscreenOriginBoundsRaw, minWidth, minHeight); + if (!isWindowVisibleEnough(fullscreenOriginBounds)) { + return fallback; + } + + return { + bounds: fullscreenOriginBounds, + restoreMode: "fullscreen-origin", + }; + } + + return { + bounds: normalBounds, + restoreMode: parsed.restoreMode, + }; +} + +export function attachWindowStatePersistence({ + window, + userDataPath, +}: AttachWindowStatePersistenceParams): void { + let persistTimer: ReturnType | null = null; + let lastRestorableState = readRestorableWindowState(window); + let lastVisibleBounds = window.getBounds(); + + const resolvePersistedWindowState = (): PersistedWindowState => { + if (window.isFullScreen()) { + return { + ...lastRestorableState, + restoreMode: "fullscreen-origin", + fullscreenOriginBounds: lastVisibleBounds, + }; + } + + lastRestorableState = readRestorableWindowState(window); + lastVisibleBounds = window.getBounds(); + return lastRestorableState; + }; + + const clearPersistTimer = () => { + if (persistTimer === null) return; + clearTimeout(persistTimer); + persistTimer = null; + }; + + const persistNow = () => { + clearPersistTimer(); + persistWindowState(resolvePersistedWindowState(), userDataPath); + }; + + const schedulePersist = () => { + clearPersistTimer(); + persistTimer = setTimeout(() => { + persistTimer = null; + persistWindowState(resolvePersistedWindowState(), userDataPath); + }, WINDOW_STATE_PERSIST_DEBOUNCE_MS); + persistTimer.unref?.(); + }; + + window.on("resize", schedulePersist); + window.on("move", schedulePersist); + window.on("maximize", persistNow); + window.on("unmaximize", persistNow); + window.on("enter-full-screen", persistNow); + window.on("leave-full-screen", persistNow); + window.on("close", persistNow); +}