diff --git a/electron/editorWindowState.test.ts b/electron/editorWindowState.test.ts new file mode 100644 index 000000000..a9dbcdcad --- /dev/null +++ b/electron/editorWindowState.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + clampRectToWorkArea, + loadEditorWindowState, + resolveEditorCreation, + saveEditorWindowState, + shouldTrackEditorWindow, +} from "./editorWindowState"; + +const temps: string[] = []; + +afterEach(() => { + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); + temps.length = 0; +}); + +function tmp(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "os-editor-win-")); + temps.push(dir); + return dir; +} + +describe("editor window state", () => { + it("returns null when the file is missing", () => { + expect(loadEditorWindowState(tmp())).toBeNull(); + }); + + it("clamps an off-screen rect onto the display workArea", () => { + const clamped = clampRectToWorkArea( + { x: -4000, y: 50, width: 1200, height: 800 }, + { x: 0, y: 0, width: 1920, height: 1080 }, + ); + expect(clamped.x).toBe(0); + expect(clamped.y).toBe(50); + expect(clamped.width).toBe(1200); + expect(clamped.height).toBe(800); + }); + + it("still maximizes when nothing is saved, and never loads or saves the bench", () => { + const missing = resolveEditorCreation({ isBench: false, saved: null }); + expect(missing.maximize).toBe(true); + expect(missing.persist).toBe(true); + expect(missing.bounds).toEqual({ width: 1200, height: 800 }); + + expect(shouldTrackEditorWindow({ windowType: "bench" })).toBe(false); + const bench = resolveEditorCreation({ isBench: true, saved: null }); + expect(bench.persist).toBe(false); + expect(bench.maximize).toBe(true); + + const dir = tmp(); + saveEditorWindowState(dir, { x: 10, y: 20, width: 1280, height: 720, maximized: false }); + expect(shouldTrackEditorWindow({ windowType: "bench" })).toBe(false); + expect(shouldTrackEditorWindow({})).toBe(true); + }); + + it("round-trips a save through load", () => { + const dir = tmp(); + const state = { x: 10, y: 20, width: 1280, height: 720, maximized: true }; + saveEditorWindowState(dir, state); + expect(loadEditorWindowState(dir)).toEqual(state); + }); + + it("returns null on a corrupted file instead of throwing", () => { + const dir = tmp(); + writeFileSync(path.join(dir, "editor-window.json"), "{ not json"); + expect(loadEditorWindowState(dir)).toBeNull(); + }); + + it("rejects garbage fields rather than restoring a broken rect", () => { + const dir = tmp(); + writeFileSync( + path.join(dir, "editor-window.json"), + JSON.stringify({ x: Number.NaN, y: 20, width: "1280", height: 720, maximized: false }), + ); + expect(loadEditorWindowState(dir)).toBeNull(); + }); + + it("rejects zero or negative dimensions instead of clamping them up", () => { + // A width:0 record is garbage the app never writes; letting it through + // would restore a min-size non-maximized window instead of the default. + for (const dims of [ + { width: 0, height: 720 }, + { width: 1280, height: 0 }, + { width: -1280, height: 720 }, + ]) { + const dir = tmp(); + writeFileSync( + path.join(dir, "editor-window.json"), + JSON.stringify({ x: 10, y: 20, ...dims, maximized: false }), + ); + expect(loadEditorWindowState(dir)).toBeNull(); + } + }); + + it("rejects a non-boolean maximized rather than coercing it", () => { + for (const maximized of ["true", 1, null, undefined]) { + const dir = tmp(); + writeFileSync( + path.join(dir, "editor-window.json"), + JSON.stringify({ x: 10, y: 20, width: 1280, height: 720, maximized }), + ); + expect(loadEditorWindowState(dir)).toBeNull(); + } + }); +}); diff --git a/electron/editorWindowState.ts b/electron/editorWindowState.ts new file mode 100644 index 000000000..6597e3736 --- /dev/null +++ b/electron/editorWindowState.ts @@ -0,0 +1,126 @@ +// Persist/restore the editor window's normal bounds and maximized flag. +// The export bench must never load or save this file — it measures a 1200×800 +// maximized window on purpose. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +export const DEFAULT_EDITOR_SIZE = { width: 1200, height: 800 }; +export const EDITOR_WINDOW_MIN = { width: 800, height: 600 }; + +export interface EditorWindowRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface EditorWindowState extends EditorWindowRect { + maximized: boolean; +} + +export interface DisplayWorkArea { + x: number; + y: number; + width: number; + height: number; +} + +export function editorWindowStatePath(userData: string): string { + return path.join(userData, "editor-window.json"); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function parseEditorWindowState(raw: unknown): EditorWindowState | null { + if (!raw || typeof raw !== "object") return null; + const rec = raw as Record; + // Zero/negative dimensions and a non-boolean `maximized` are garbage the + // app never writes; restoring them would clamp into a small non-maximized + // window instead of the documented default. Reject the record whole. + if ( + !isFiniteNumber(rec.x) || + !isFiniteNumber(rec.y) || + !isFiniteNumber(rec.width) || + !isFiniteNumber(rec.height) || + rec.width <= 0 || + rec.height <= 0 || + typeof rec.maximized !== "boolean" + ) { + return null; + } + return { + x: rec.x, + y: rec.y, + width: rec.width, + height: rec.height, + maximized: rec.maximized, + }; +} + +export function loadEditorWindowState(userData: string): EditorWindowState | null { + const file = editorWindowStatePath(userData); + if (!existsSync(file)) return null; + try { + return parseEditorWindowState(JSON.parse(readFileSync(file, "utf8"))); + } catch { + return null; + } +} + +export function saveEditorWindowState(userData: string, state: EditorWindowState): void { + try { + writeFileSync(editorWindowStatePath(userData), `${JSON.stringify(state)}\n`, "utf8"); + } catch { + // Best-effort; a failed write must not block close. + } +} + +export function clampRectToWorkArea( + rect: EditorWindowRect, + workArea: DisplayWorkArea, + min = EDITOR_WINDOW_MIN, +): EditorWindowRect { + const width = Math.min(Math.max(rect.width, min.width), Math.max(min.width, workArea.width)); + const height = Math.min(Math.max(rect.height, min.height), Math.max(min.height, workArea.height)); + const maxX = workArea.x + Math.max(0, workArea.width - width); + const maxY = workArea.y + Math.max(0, workArea.height - height); + return { + x: Math.min(Math.max(rect.x, workArea.x), maxX), + y: Math.min(Math.max(rect.y, workArea.y), maxY), + width, + height, + }; +} + +export function shouldTrackEditorWindow(query: Record): boolean { + return query.windowType !== "bench"; +} + +export function resolveEditorCreation(input: { + isBench: boolean; + saved: EditorWindowState | null; +}): { + bounds: { x?: number; y?: number; width: number; height: number }; + maximize: boolean; + persist: boolean; +} { + if (input.isBench) { + return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, persist: false }; + } + if (!input.saved) { + return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, persist: true }; + } + return { + bounds: { + x: input.saved.x, + y: input.saved.y, + width: input.saved.width, + height: input.saved.height, + }, + maximize: input.saved.maximized, + persist: true, + }; +} diff --git a/electron/windows.ts b/electron/windows.ts index 4b5ceb7fe..a9f51e41e 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,13 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { BrowserWindow, ipcMain, screen } from "electron"; +import { app, BrowserWindow, ipcMain, screen } from "electron"; +import { + clampRectToWorkArea, + loadEditorWindowState, + resolveEditorCreation, + saveEditorWindowState, + shouldTrackEditorWindow, +} from "./editorWindowState"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -392,10 +399,18 @@ export function createHudOverlayWindow(): BrowserWindow { */ export function createEditorWindow(query: Record = {}): BrowserWindow { const isMac = process.platform === "darwin"; + const persist = shouldTrackEditorWindow(query); + const loaded = persist ? loadEditorWindowState(app.getPath("userData")) : null; + const saved = loaded + ? { + ...clampRectToWorkArea(loaded, screen.getDisplayMatching(loaded).workArea), + maximized: loaded.maximized, + } + : null; + const creation = resolveEditorCreation({ isBench: query.windowType === "bench", saved }); const win = new BrowserWindow({ - width: 1200, - height: 800, + ...creation.bounds, minWidth: 800, minHeight: 600, // Seamless titlebar on every platform: the app's own topbar IS the titlebar @@ -425,7 +440,23 @@ export function createEditorWindow(query: Record = {}): BrowserW }, }); - win.maximize(); + if (creation.maximize) win.maximize(); + if (creation.persist) { + const persistState = () => { + if (win.isDestroyed()) return; + const bounds = win.getNormalBounds(); + saveEditorWindowState(app.getPath("userData"), { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + maximized: win.isMaximized(), + }); + }; + win.on("moved", persistState); + win.on("resized", persistState); + win.on("close", persistState); + } // The editor renders its own File/Edit/View menu bar in the custom titlebar, // so hide the native OS menu bar on Windows/Linux (it stays reachable via Alt).