feat: store main window pos

This commit is contained in:
2026-02-22 17:22:33 +01:00
parent bfbd57a365
commit 6e45936fc9
2 changed files with 415 additions and 5 deletions

View File

@@ -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 path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { getDatabase } from './database'; import { getDatabase } from './database';
@@ -16,10 +16,161 @@ let previewServer: PreviewServer | null = null;
let activePreviewPostId: string | null = null; let activePreviewPostId: string | null = null;
const PREVIEW_SERVER_PORT = 4123; const PREVIEW_SERVER_PORT = 4123;
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; 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) // Check if dev server is likely running (only in development)
const isDev = process.env.NODE_ENV === '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<PersistedWindowState>;
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) // Register custom protocol scheme as privileged (must be done before app is ready)
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
{ {
@@ -44,11 +195,14 @@ protocol.registerSchemesAsPrivileged([
function createWindow(): void { function createWindow(): void {
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const initialWindowState = resolveInitialWindowState();
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, x: initialWindowState.x,
height: 900, y: initialWindowState.y,
minWidth: 800, width: initialWindowState.width,
minHeight: 600, height: initialWindowState.height,
minWidth: WINDOW_MIN_WIDTH,
minHeight: WINDOW_MIN_HEIGHT,
title: 'Blogging Desktop Server', title: 'Blogging Desktop Server',
backgroundColor: '#1e1e1e', // VS Code dark background backgroundColor: '#1e1e1e', // VS Code dark background
titleBarStyle: isMac ? 'hiddenInset' : 'hidden', titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
@@ -115,6 +269,12 @@ function createWindow(): void {
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null; mainWindow = null;
}); });
mainWindow.on('close', () => {
if (mainWindow) {
persistMainWindowState(mainWindow);
}
});
} }
async function openPreviewInBrowser(): Promise<void> { async function openPreviewInBrowser(): Promise<void> {

View File

@@ -397,4 +397,254 @@ describe('main bootstrap preview behavior', () => {
await setPreviewTargetHandler({}, null); await setPreviewTargetHandler({}, null);
expect(previewMenuItem.enabled).toBe(false); 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,
}));
});
}); });