From 6e45936fc9f5eb94dea0401febca86454068eb56 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 17:22:33 +0100 Subject: [PATCH] feat: store main window pos --- src/main/main.ts | 170 ++++++++++++++++++++- tests/engine/mainStartup.test.ts | 250 +++++++++++++++++++++++++++++++ 2 files changed, 415 insertions(+), 5 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index ed84da7..838f83e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net, shell } from 'electron'; +import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol, net, shell, screen } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import { getDatabase } from './database'; @@ -16,10 +16,161 @@ let previewServer: PreviewServer | null = null; let activePreviewPostId: string | null = null; const PREVIEW_SERVER_PORT = 4123; const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; +const WINDOW_MIN_WIDTH = 800; +const WINDOW_MIN_HEIGHT = 600; +const WINDOW_DEFAULT_WIDTH = 1400; +const WINDOW_DEFAULT_HEIGHT = 900; +const WINDOW_STATE_FILE_NAME = 'window-state.json'; + +interface PersistedWindowState { + x: number; + y: number; + width: number; + height: number; +} + +interface Rectangle { + x: number; + y: number; + width: number; + height: number; +} // Check if dev server is likely running (only in development) const isDev = process.env.NODE_ENV === 'development'; +function getWindowStatePath(): string | null { + if (typeof app.getPath !== 'function') { + return null; + } + + return path.join(app.getPath('userData'), WINDOW_STATE_FILE_NAME); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function parsePersistedWindowState(raw: unknown): PersistedWindowState | null { + if (typeof raw !== 'object' || raw === null) { + return null; + } + + const state = raw as Partial; + if (!isFiniteNumber(state.x) || !isFiniteNumber(state.y) || !isFiniteNumber(state.width) || !isFiniteNumber(state.height)) { + return null; + } + + if (state.width <= 0 || state.height <= 0) { + return null; + } + + return { + x: state.x, + y: state.y, + width: state.width, + height: state.height, + }; +} + +function readPersistedWindowState(): PersistedWindowState | null { + const windowStatePath = getWindowStatePath(); + if (!windowStatePath || !fs.existsSync(windowStatePath)) { + return null; + } + + try { + const content = fs.readFileSync(windowStatePath, 'utf8'); + const parsed = JSON.parse(content); + return parsePersistedWindowState(parsed); + } catch { + return null; + } +} + +function writePersistedWindowState(state: PersistedWindowState): void { + const windowStatePath = getWindowStatePath(); + if (!windowStatePath) { + return; + } + + try { + fs.writeFileSync(windowStatePath, JSON.stringify(state), 'utf8'); + } catch { + // best effort persistence, ignore write errors + } +} + +function getWorkAreaForState(state: PersistedWindowState): Rectangle { + if (screen && typeof screen.getDisplayMatching === 'function') { + const matchingDisplay = screen.getDisplayMatching({ + x: state.x, + y: state.y, + width: state.width, + height: state.height, + }); + + if (matchingDisplay?.workArea) { + return matchingDisplay.workArea; + } + } + + return { + x: 0, + y: 0, + width: WINDOW_DEFAULT_WIDTH, + height: WINDOW_DEFAULT_HEIGHT, + }; +} + +function clampWindowStateToWorkArea(state: PersistedWindowState, workArea: Rectangle): PersistedWindowState { + const width = Math.max(WINDOW_MIN_WIDTH, Math.min(state.width, workArea.width)); + const height = Math.max(WINDOW_MIN_HEIGHT, Math.min(state.height, workArea.height)); + + const maxX = workArea.x + workArea.width - width; + const maxY = workArea.y + workArea.height - height; + + return { + x: Math.min(Math.max(state.x, workArea.x), maxX), + y: Math.min(Math.max(state.y, workArea.y), maxY), + width, + height, + }; +} + +function resolveInitialWindowState(): PersistedWindowState { + const persistedState = readPersistedWindowState(); + if (!persistedState) { + return { + x: 0, + y: 0, + width: WINDOW_DEFAULT_WIDTH, + height: WINDOW_DEFAULT_HEIGHT, + }; + } + + const workArea = getWorkAreaForState(persistedState); + return clampWindowStateToWorkArea(persistedState, workArea); +} + +function persistMainWindowState(windowToPersist: BrowserWindow): void { + if (typeof windowToPersist.isFullScreen === 'function' && windowToPersist.isFullScreen()) { + return; + } + + let bounds = windowToPersist.getBounds(); + if (typeof windowToPersist.isMaximized === 'function' && windowToPersist.isMaximized() && typeof windowToPersist.getNormalBounds === 'function') { + bounds = windowToPersist.getNormalBounds(); + } + + writePersistedWindowState({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); +} + // Register custom protocol scheme as privileged (must be done before app is ready) protocol.registerSchemesAsPrivileged([ { @@ -44,11 +195,14 @@ protocol.registerSchemesAsPrivileged([ function createWindow(): void { const isMac = process.platform === 'darwin'; + const initialWindowState = resolveInitialWindowState(); mainWindow = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 800, - minHeight: 600, + x: initialWindowState.x, + y: initialWindowState.y, + width: initialWindowState.width, + height: initialWindowState.height, + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, title: 'Blogging Desktop Server', backgroundColor: '#1e1e1e', // VS Code dark background titleBarStyle: isMac ? 'hiddenInset' : 'hidden', @@ -115,6 +269,12 @@ function createWindow(): void { mainWindow.on('closed', () => { mainWindow = null; }); + + mainWindow.on('close', () => { + if (mainWindow) { + persistMainWindowState(mainWindow); + } + }); } async function openPreviewInBrowser(): Promise { diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index f784c3c..1134401 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -397,4 +397,254 @@ describe('main bootstrap preview behavior', () => { await setPreviewTargetHandler({}, null); expect(previewMenuItem.enabled).toBe(false); }); + + it('restores previous window bounds unchanged when they fit the current display work area', async () => { + const mockApp = { + name: 'bDS', + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn(), + quit: vi.fn(), + getPath: vi.fn((name: string) => (name === 'userData' ? '/tmp/bds-user-data' : '/tmp')), + }; + + const browserWindowCalls: any[] = []; + + class MockBrowserWindow { + static getAllWindows = vi.fn(() => [{ id: 1 }]); + + loadURL = vi.fn(); + loadFile = vi.fn(); + on = vi.fn(); + isDestroyed = vi.fn(() => false); + webContents = { + on: vi.fn(), + send: vi.fn(), + openDevTools: vi.fn(), + toggleDevTools: vi.fn(), + }; + + constructor(options: any) { + browserWindowCalls.push(options); + } + } + + vi.doMock('electron', () => ({ + app: mockApp, + BrowserWindow: MockBrowserWindow, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + ipcMain: { + on: vi.fn(), + handle: vi.fn(), + removeHandler: vi.fn(), + }, + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + }, + net: { + fetch: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + openPath: vi.fn(), + }, + screen: { + getDisplayMatching: vi.fn(() => ({ + workArea: { x: 0, y: 25, width: 1920, height: 1055 }, + })), + }, + })); + + class MockPreviewServer { + start = vi.fn().mockResolvedValue(4123); + stop = vi.fn().mockResolvedValue(undefined); + getBaseUrl = vi.fn(() => 'http://127.0.0.1:4123'); + } + + vi.doMock('../../src/main/engine/PreviewServer', () => ({ + PreviewServer: MockPreviewServer, + })); + + vi.doMock('fs', () => ({ + existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')), + readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })), + writeFileSync: vi.fn(), + })); + + vi.doMock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + initializeLocal: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + getLocal: vi.fn(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue(null), + })), + })), + })), + })), + getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })), + })), + })); + + vi.doMock('../../src/main/ipc', () => ({ + registerIpcHandlers: vi.fn(), + registerChatHandlers: vi.fn(), + initializeChatHandlers: vi.fn(), + cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), + })); + + vi.doMock('../../src/main/database/schema', () => ({ + media: {}, + })); + + vi.doMock('drizzle-orm', () => ({ + eq: vi.fn(), + })); + + vi.doMock('../../src/main/engine/MediaEngine', () => ({ + getMediaEngine: vi.fn(() => ({ + getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }), + })), + })); + + await import('../../src/main/main'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(browserWindowCalls[0]).toEqual(expect.objectContaining({ + x: 120, + y: 80, + width: 1280, + height: 820, + })); + }); + + it('clamps restored window bounds only when they overflow the current display work area', async () => { + const mockApp = { + name: 'bDS', + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn(), + quit: vi.fn(), + getPath: vi.fn((name: string) => (name === 'userData' ? '/tmp/bds-user-data' : '/tmp')), + }; + + const browserWindowCalls: any[] = []; + + class MockBrowserWindow { + static getAllWindows = vi.fn(() => [{ id: 1 }]); + + loadURL = vi.fn(); + loadFile = vi.fn(); + on = vi.fn(); + isDestroyed = vi.fn(() => false); + webContents = { + on: vi.fn(), + send: vi.fn(), + openDevTools: vi.fn(), + toggleDevTools: vi.fn(), + }; + + constructor(options: any) { + browserWindowCalls.push(options); + } + } + + vi.doMock('electron', () => ({ + app: mockApp, + BrowserWindow: MockBrowserWindow, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + ipcMain: { + on: vi.fn(), + handle: vi.fn(), + removeHandler: vi.fn(), + }, + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + }, + net: { + fetch: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + openPath: vi.fn(), + }, + screen: { + getDisplayMatching: vi.fn(() => ({ + workArea: { x: 0, y: 25, width: 1280, height: 775 }, + })), + }, + })); + + class MockPreviewServer { + start = vi.fn().mockResolvedValue(4123); + stop = vi.fn().mockResolvedValue(undefined); + getBaseUrl = vi.fn(() => 'http://127.0.0.1:4123'); + } + + vi.doMock('../../src/main/engine/PreviewServer', () => ({ + PreviewServer: MockPreviewServer, + })); + + vi.doMock('fs', () => ({ + existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')), + readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })), + writeFileSync: vi.fn(), + })); + + vi.doMock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + initializeLocal: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + getLocal: vi.fn(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue(null), + })), + })), + })), + })), + getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })), + })), + })); + + vi.doMock('../../src/main/ipc', () => ({ + registerIpcHandlers: vi.fn(), + registerChatHandlers: vi.fn(), + initializeChatHandlers: vi.fn(), + cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), + })); + + vi.doMock('../../src/main/database/schema', () => ({ + media: {}, + })); + + vi.doMock('drizzle-orm', () => ({ + eq: vi.fn(), + })); + + vi.doMock('../../src/main/engine/MediaEngine', () => ({ + getMediaEngine: vi.fn(() => ({ + getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }), + })), + })); + + await import('../../src/main/main'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(browserWindowCalls[0]).toEqual(expect.objectContaining({ + x: 0, + y: 25, + width: 1280, + height: 775, + })); + }); });