diff --git a/src/main/index.ts b/src/main/index.ts index cae9d7c..2160e4c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,19 +1,39 @@ import { join } from 'node:path' -import { app, BrowserWindow, shell, nativeImage } from 'electron' +import { app, BrowserWindow, shell, nativeImage, screen } from 'electron' import appIcon from '../../resources/icon.png?asset' import { connectionStore } from './store/connectionStore' import { queryStore } from './store/queryStore' import { settingsStore } from './store/settingsStore' +import { windowStateStore } from './store/windowStateStore' +import { resolveWindowBounds } from './store/windowStateCore' import { sessionManager } from './mongo/sessionManager' import { serializerPool } from './workers/serializerPool' import { registerIpc } from './ipc/registerIpc' +// Default geometry, also the floor on size. Saved bounds are reconciled against +// these + the connected displays in windowStateCore (off-screen safety). +const WINDOW_DEFAULTS = { width: 1440, height: 920, minWidth: 980, minHeight: 620 } + function createWindow(): void { + const saved = windowStateStore.get() + const bounds = resolveWindowBounds( + saved.bounds, + screen.getAllDisplays().map((d) => d.workArea), + { + minWidth: WINDOW_DEFAULTS.minWidth, + minHeight: WINDOW_DEFAULTS.minHeight, + defaultWidth: WINDOW_DEFAULTS.width, + defaultHeight: WINDOW_DEFAULTS.height + } + ) + const win = new BrowserWindow({ - width: 1440, - height: 920, - minWidth: 980, - minHeight: 620, + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + minWidth: WINDOW_DEFAULTS.minWidth, + minHeight: WINDOW_DEFAULTS.minHeight, show: false, titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', // Vertically center the traffic lights within the slim 30px title bar @@ -30,8 +50,31 @@ function createWindow(): void { } }) + if (saved.isMaximized) win.maximize() + win.on('ready-to-show', () => win.show()) + // Remember the window geometry across launches. getNormalBounds() returns the + // restored (un-maximized) rect, so re-maximizing next launch still restores to + // a sane size. Debounced because resize/move fire in bursts while dragging. + let persistTimer: ReturnType | null = null + const persistBounds = (): void => { + if (win.isDestroyed()) return + windowStateStore.save({ bounds: win.getNormalBounds(), isMaximized: win.isMaximized() }) + } + const schedulePersist = (): void => { + if (persistTimer) clearTimeout(persistTimer) + persistTimer = setTimeout(persistBounds, 400) + } + win.on('resize', schedulePersist) + win.on('move', schedulePersist) + win.on('maximize', schedulePersist) + win.on('unmaximize', schedulePersist) + win.on('close', () => { + if (persistTimer) clearTimeout(persistTimer) + persistBounds() // flush synchronously before the window goes away + }) + // Dev diagnostics: surface renderer console + crashes in the terminal. // (Open DevTools yourself with Cmd/Ctrl+Alt+I when you need them.) if (process.env['ELECTRON_RENDERER_URL']) { @@ -73,6 +116,7 @@ app.whenReady().then(() => { connectionStore.init() queryStore.init() settingsStore.init() + windowStateStore.init() registerIpc() createWindow() diff --git a/src/main/store/windowStateCore.ts b/src/main/store/windowStateCore.ts new file mode 100644 index 0000000..6bc5040 --- /dev/null +++ b/src/main/store/windowStateCore.ts @@ -0,0 +1,95 @@ +/** + * Pure logic for restoring the main window's size/position across launches. + * + * Saved bounds can go stale between sessions — an external monitor gets + * unplugged, the resolution changes, the window was on a display that no longer + * exists. Restoring them blindly can drop the window fully off-screen where the + * user can't grab it. `resolveWindowBounds` reconciles the saved geometry with + * the *currently connected* displays and guarantees a reachable result. + * + * No electron / fs imports → unit-testable in isolation (see + * test/unit/main/windowStateCore.test.ts). The thin side-effecting wrapper that + * reads/writes disk lives in windowStateStore.ts; the screen plumbing lives in + * main/index.ts. + */ + +export interface WindowBounds { + x: number + y: number + width: number + height: number +} + +/** A display's *work area* (screen minus dock/taskbar), in global coords. */ +export interface DisplayArea { + x: number + y: number + width: number + height: number +} + +export interface WindowSizeConstraints { + minWidth: number + minHeight: number + defaultWidth: number + defaultHeight: number +} + +/** What `BrowserWindow` is opened with. x/y omitted → OS centers the window. */ +export interface ResolvedBounds { + width: number + height: number + x?: number + y?: number +} + +// A window is "reachable" only if at least this big a patch of it lands on some +// display — enough to grab the title bar / traffic lights and drag it back. +const MIN_VISIBLE_W = 120 +const MIN_VISIBLE_H = 48 + +function clampSize( + value: number | undefined, + fallback: number, + min: number, + max: number | undefined +): number { + // Only truly invalid values (missing / NaN / Infinity / non-positive) fall + // back to the default. A small-but-real value is clamped up to the floor + // instead of discarded — e.g. if minWidth was raised between versions. + let v = value == null || !Number.isFinite(value) || value <= 0 ? fallback : value + if (max != null && v > max) v = max + return Math.max(v, min) // floor wins last — e.g. minWidth > a tiny display's width +} + +/** Does the rect overlap any display by at least the min visible patch? */ +function isReachable(rect: WindowBounds, displays: DisplayArea[]): boolean { + return displays.some((d) => { + const overlapW = Math.min(rect.x + rect.width, d.x + d.width) - Math.max(rect.x, d.x) + const overlapH = Math.min(rect.y + rect.height, d.y + d.height) - Math.max(rect.y, d.y) + return overlapW >= MIN_VISIBLE_W && overlapH >= MIN_VISIBLE_H + }) +} + +/** + * Reconcile saved bounds with the connected displays. Always returns a usable + * size (clamped to [min, largest display], falling back to the default when the + * saved value is missing/garbage). Keeps the saved x/y only when the window + * would land on-screen; otherwise drops them so the OS re-centers it. + */ +export function resolveWindowBounds( + saved: Partial | null | undefined, + displays: DisplayArea[], + c: WindowSizeConstraints +): ResolvedBounds { + const maxW = displays.length ? Math.max(...displays.map((d) => d.width)) : undefined + const maxH = displays.length ? Math.max(...displays.map((d) => d.height)) : undefined + const width = clampSize(saved?.width, c.defaultWidth, c.minWidth, maxW) + const height = clampSize(saved?.height, c.defaultHeight, c.minHeight, maxH) + + const hasPos = saved != null && Number.isFinite(saved.x) && Number.isFinite(saved.y) + if (!hasPos) return { width, height } + + const rect: WindowBounds = { x: saved.x as number, y: saved.y as number, width, height } + return isReachable(rect, displays) ? { width, height, x: rect.x, y: rect.y } : { width, height } +} diff --git a/src/main/store/windowStateStore.ts b/src/main/store/windowStateStore.ts new file mode 100644 index 0000000..e14ce8c --- /dev/null +++ b/src/main/store/windowStateStore.ts @@ -0,0 +1,55 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { app } from 'electron' +import type { WindowBounds } from './windowStateCore' + +interface WindowStateFile { + version: 1 + /** Last *normal* (un-maximized) bounds; null until the user resizes once. */ + bounds: WindowBounds | null + /** Re-maximize on next launch if the window was maximized at close. */ + isMaximized: boolean +} + +/** + * Persists the main window's geometry to window-state.json in userData (see + * ADR-0006: plain JSON, no SQLite). Kept out of settings.json on purpose: + * window bounds are main-process-only, change on every drag, and never cross + * IPC — folding them into the renderer-facing settings would churn that file. + */ +class WindowStateStore { + private filePath = '' + private data: WindowStateFile = { version: 1, bounds: null, isMaximized: false } + + init(): void { + this.filePath = join(app.getPath('userData'), 'window-state.json') + if (existsSync(this.filePath)) { + try { + const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial + this.data = { + version: 1, + bounds: parsed.bounds ?? null, + isMaximized: parsed.isMaximized ?? false + } + } catch { + // Corrupt file → fall back to defaults (window state is non-critical). + } + } + } + + get(): WindowStateFile { + return this.data + } + + save(state: { bounds: WindowBounds; isMaximized: boolean }): void { + this.data = { version: 1, bounds: state.bounds, isMaximized: state.isMaximized } + try { + writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf8') + } catch { + // Best-effort: a failed write just means we restore the default size next + // launch — never worth crashing or blocking quit over. + } + } +} + +export const windowStateStore = new WindowStateStore() diff --git a/test/unit/main/windowStateCore.test.ts b/test/unit/main/windowStateCore.test.ts new file mode 100644 index 0000000..7abc453 --- /dev/null +++ b/test/unit/main/windowStateCore.test.ts @@ -0,0 +1,81 @@ +/** + * Window-restore reconciliation: clamps saved size to [min, largest display], + * falls back to centered defaults when missing/garbage, and drops the saved + * position when the window would land off-screen (monitor unplugged / display + * layout changed). Pure — no electron, no fs. + */ +import { describe, it, expect } from 'vitest' +import { resolveWindowBounds, type DisplayArea } from '../../../src/main/store/windowStateCore' + +const C = { minWidth: 980, minHeight: 620, defaultWidth: 1440, defaultHeight: 920 } +// A single 1920×1080 display at the origin (work area, dock-trimmed height). +const PRIMARY: DisplayArea[] = [{ x: 0, y: 0, width: 1920, height: 1040 }] + +describe('resolveWindowBounds', () => { + it('falls back to centered defaults when nothing is saved', () => { + expect(resolveWindowBounds(null, PRIMARY, C)).toEqual({ width: 1440, height: 920 }) + }) + + it('restores a saved on-screen size + position verbatim', () => { + const saved = { x: 100, y: 80, width: 1200, height: 800 } + expect(resolveWindowBounds(saved, PRIMARY, C)).toEqual({ + width: 1200, + height: 800, + x: 100, + y: 80 + }) + }) + + it('drops an off-screen position but keeps the size (monitor unplugged)', () => { + // Saved on a second monitor at x=2400 that no longer exists. + const saved = { x: 2400, y: 200, width: 1200, height: 800 } + expect(resolveWindowBounds(saved, PRIMARY, C)).toEqual({ width: 1200, height: 800 }) + }) + + it('keeps the position when only a grabbable sliver overlaps a display', () => { + // Window pushed mostly off the right edge but ~170px + full title bar remain. + const saved = { x: 1750, y: 50, width: 1200, height: 800 } + const r = resolveWindowBounds(saved, PRIMARY, C) + expect(r.x).toBe(1750) + expect(r.y).toBe(50) + }) + + it('drops the position when the visible overlap is too thin to grab', () => { + // Only ~20px peek over the left edge — not reachable. + const saved = { x: -1180, y: 50, width: 1200, height: 800 } + expect(resolveWindowBounds(saved, PRIMARY, C)).toEqual({ width: 1200, height: 800 }) + }) + + it('clamps a saved size below the minimum up to the floor', () => { + const saved = { x: 0, y: 0, width: 400, height: 300 } + const r = resolveWindowBounds(saved, PRIMARY, C) + expect(r.width).toBe(980) + expect(r.height).toBe(620) + }) + + it('caps a saved size larger than the largest display (external monitor gone)', () => { + const saved = { x: 0, y: 0, width: 3000, height: 1600 } + const r = resolveWindowBounds(saved, PRIMARY, C) + expect(r.width).toBe(1920) + expect(r.height).toBe(1040) + }) + + it('ignores garbage numbers and uses defaults', () => { + const saved = { x: NaN, y: 10, width: Infinity, height: -5 } + expect(resolveWindowBounds(saved, PRIMARY, C)).toEqual({ width: 1440, height: 920 }) + }) + + it('restores onto a secondary display to the right', () => { + const displays: DisplayArea[] = [ + { x: 0, y: 0, width: 1920, height: 1040 }, + { x: 1920, y: 0, width: 2560, height: 1400 } + ] + const saved = { x: 2000, y: 100, width: 1600, height: 1000 } + expect(resolveWindowBounds(saved, displays, C)).toEqual({ + width: 1600, + height: 1000, + x: 2000, + y: 100 + }) + }) +})