🐛 fix: restore window position safely

🐛 fix: restore window position safely
This commit is contained in:
Innei
2026-01-05 15:49:13 +08:00
committed by GitHub
parent 583258b1f7
commit e0b555e92a
2 changed files with 129 additions and 14 deletions

View File

@@ -42,6 +42,13 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
width?: number;
}
interface WindowState {
height?: number;
width?: number;
x?: number;
y?: number;
}
export default class Browser {
private app: App;
private _browserWindow?: BrowserWindow;
@@ -152,6 +159,46 @@ export default class Browser {
this._browserWindow.setTitleBarOverlay(config.titleBarOverlay);
}
private clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
private resolveWindowState(
savedState: WindowState | undefined,
fallbackState: { height?: number; width?: number },
): WindowState {
const width = savedState?.width ?? fallbackState.width;
const height = savedState?.height ?? fallbackState.height;
const resolvedState: WindowState = { height, width };
const hasPosition = Number.isFinite(savedState?.x) && Number.isFinite(savedState?.y);
if (!hasPosition) return resolvedState;
const x = savedState?.x as number;
const y = savedState?.y as number;
const targetDisplay = screen.getDisplayMatching({
height: height ?? 0,
width: width ?? 0,
x,
y,
});
const workArea = targetDisplay?.workArea ?? screen.getPrimaryDisplay().workArea;
const resolvedWidth = typeof width === 'number' ? Math.min(width, workArea.width) : width;
const resolvedHeight = typeof height === 'number' ? Math.min(height, workArea.height) : height;
const maxX = workArea.x + Math.max(0, workArea.width - (resolvedWidth ?? 0));
const maxY = workArea.y + Math.max(0, workArea.height - (resolvedHeight ?? 0));
return {
height: resolvedHeight,
width: resolvedWidth,
x: this.clampNumber(x, workArea.x, maxX),
y: this.clampNumber(y, workArea.y, maxY),
};
}
private cleanupThemeListener(): void {
if (this.themeListenerSetup) {
// Note: nativeTheme listeners are global, consider using a centralized theme manager
@@ -323,12 +370,17 @@ export default class Browser {
// Load window state
const savedState = this.app.storeManager.get(this.windowStateKey as any) as
| { height?: number; width?: number }
| undefined; // Keep type for now, but only use w/h
| WindowState
| undefined;
logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
logger.debug(`[${this.identifier}] Options for new window: ${JSON.stringify(this.options)}`);
logger.debug(
`[${this.identifier}] Saved window state (only size used): ${JSON.stringify(savedState)}`,
`[${this.identifier}] Saved window state: ${JSON.stringify(savedState)}`,
);
const resolvedState = this.resolveWindowState(savedState, { height, width });
logger.debug(
`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`,
);
const browserWindow = new BrowserWindow({
@@ -337,7 +389,7 @@ export default class Browser {
backgroundColor: '#00000000',
darkTheme: this.isDarkMode,
frame: false,
height: savedState?.height || height,
height: resolvedState.height,
show: false,
title,
vibrancy: 'sidebar',
@@ -348,7 +400,9 @@ export default class Browser {
preload: join(preloadDir, 'index.js'),
sandbox: false,
},
width: savedState?.width || width,
width: resolvedState.width,
x: resolvedState.x,
y: resolvedState.y,
...this.getPlatformThemeConfig(),
});
@@ -405,12 +459,17 @@ export default class Browser {
logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`);
// Save state before quitting
try {
const { width, height } = browserWindow.getBounds(); // Get only width and height
const sizeState = { height, width };
const bounds = browserWindow.getBounds();
const sizeState = {
height: bounds.height,
width: bounds.width,
x: bounds.x,
y: bounds.y,
};
logger.debug(
`[${this.identifier}] Saving window size on quit: ${JSON.stringify(sizeState)}`,
`[${this.identifier}] Saving window state on quit: ${JSON.stringify(sizeState)}`,
);
this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size
this.app.storeManager.set(this.windowStateKey as any, sizeState);
} catch (error) {
logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
}
@@ -437,15 +496,20 @@ export default class Browser {
} else {
// Window is actually closing (not keepAlive)
logger.debug(
`[${this.identifier}] keepAlive is false, allowing window to close. Saving size...`, // Updated log message
`[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
);
try {
const { width, height } = browserWindow.getBounds(); // Get only width and height
const sizeState = { height, width };
const bounds = browserWindow.getBounds();
const sizeState = {
height: bounds.height,
width: bounds.width,
x: bounds.x,
y: bounds.y,
};
logger.debug(
`[${this.identifier}] Saving window size on close: ${JSON.stringify(sizeState)}`,
`[${this.identifier}] Saving window state on close: ${JSON.stringify(sizeState)}`,
);
this.app.storeManager.set(this.windowStateKey as any, sizeState); // Save only size
this.app.storeManager.set(this.windowStateKey as any, sizeState);
} catch (error) {
logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
}

View File

@@ -56,9 +56,15 @@ const { mockBrowserWindow, mockNativeTheme, mockIpcMain, mockScreen, MockBrowser
themeSource: 'system',
},
mockScreen: {
getDisplayMatching: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
getDisplayNearestPoint: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
getPrimaryDisplay: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
},
};
});
@@ -240,6 +246,47 @@ describe('Browser', () => {
);
});
it('should restore window position from store and clamp within display', () => {
mockStoreManagerGet.mockImplementation((key: string) => {
if (key === 'windowSize_test-window') {
return { height: 700, width: 900, x: 1800, y: 900 };
}
return undefined;
});
new Browser(defaultOptions, mockApp);
expect(MockBrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
height: 700,
width: 900,
x: 1020,
y: 380,
}),
);
});
it('should clamp saved size when it exceeds current display bounds', () => {
mockScreen.getDisplayMatching.mockReturnValueOnce({
workArea: { height: 800, width: 1200, x: 0, y: 0 },
});
mockStoreManagerGet.mockImplementation((key: string) => {
if (key === 'windowSize_test-window') {
return { height: 1200, width: 2000, x: 0, y: 0 };
}
return undefined;
});
new Browser(defaultOptions, mockApp);
expect(MockBrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
height: 800,
width: 1200,
}),
);
});
it('should use default size when no saved state', () => {
mockStoreManagerGet.mockReturnValue(undefined);
@@ -541,6 +588,8 @@ describe('Browser', () => {
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
height: 600,
width: 800,
x: 0,
y: 0,
});
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
@@ -572,6 +621,8 @@ describe('Browser', () => {
expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
height: 600,
width: 800,
x: 0,
y: 0,
});
});
});