Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<typeof setTimeout> | 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']) {
Expand Down Expand Up @@ -73,6 +116,7 @@ app.whenReady().then(() => {
connectionStore.init()
queryStore.init()
settingsStore.init()
windowStateStore.init()
registerIpc()
createWindow()

Expand Down
95 changes: 95 additions & 0 deletions src/main/store/windowStateCore.ts
Original file line number Diff line number Diff line change
@@ -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<WindowBounds> | 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 }
}
55 changes: 55 additions & 0 deletions src/main/store/windowStateStore.ts
Original file line number Diff line number Diff line change
@@ -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<WindowStateFile>
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()
81 changes: 81 additions & 0 deletions test/unit/main/windowStateCore.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
})