feat: store main window pos
This commit is contained in:
170
src/main/main.ts
170
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<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)
|
||||
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<void> {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user