Merge pull request #1555 from BradGroux/fix/window-bounds-1530

fix: restore window bounds within available displays
This commit is contained in:
Brad Groux 2026-09-07 18:59:50 -05:00 • committed by GitHub
commit 6abea64f04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 136 additions and 9 deletions

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
import { createDesktopPaths } from '../paths.js';
import {
applyDesktopWindowState,
captureDesktopWindowState,
readDesktopWindowState,
writeDesktopWindowState,
writeDesktopWindowStateSync,
@ -68,3 +69,52 @@ describe('desktop window state', () => {
});
});
});
describe('visible display restoration', () => {
const primary = { x: 0, y: 25, width: 1440, height: 875 };
const left = { x: -1920, y: -200, width: 1920, height: 1080 };
it('moves disconnected-monitor windows to the primary work area', () => {
expect(
applyDesktopWindowState({ x: 5000, y: 300, width: 1200, height: 800 }, undefined, [primary])
).toEqual({ x: 120, y: 63, width: 1200, height: 800 });
});
it('preserves valid negative monitor coordinates', () => {
expect(
applyDesktopWindowState({ x: -1800, y: -100, width: 1200, height: 800 }, undefined, [
primary,
left,
])
).toEqual({ x: -1800, y: -100, width: 1200, height: 800 });
});
it('clamps dimensions and titlebar to a smaller scaled work area', () => {
expect(
applyDesktopWindowState({ x: -300, y: -200, width: 4000, height: 4000 }, undefined, [
{ x: 0, y: 24, width: 1024, height: 700 },
])
).toEqual({ x: 0, y: 24, width: 1024, height: 700 });
});
it('recovers malformed coordinates on a single screen', () => {
expect(
applyDesktopWindowState(
{ x: Number.NaN, y: Number.POSITIVE_INFINITY, width: 0, height: Number.NaN },
undefined,
[primary]
)
).toEqual({ x: 130, y: 25, width: 1180, height: 875 });
});
it('captures normal bounds independently of maximized bounds', () => {
const window = {
getNormalBounds: vi.fn(() => ({ x: 30, y: 40, width: 1200, height: 800 })),
getBounds: vi.fn(() => primary),
isMaximized: () => true,
};
expect(captureDesktopWindowState(window as never)).toEqual({
x: 30,
y: 40,
width: 1200,
height: 800,
maximized: true,
});
expect(window.getBounds).not.toHaveBeenCalled();
});
});

View file

@ -1,4 +1,13 @@
import { app, BrowserWindow, clipboard, ipcMain, Notification, safeStorage, shell } from 'electron';
import {
app,
BrowserWindow,
clipboard,
ipcMain,
Notification,
safeStorage,
shell,
screen,
} from 'electron';
import path from 'node:path';
import { mkdirSync } from 'node:fs';
import { createRequire } from 'node:module';
@ -97,12 +106,17 @@ if (!app.requestSingleInstanceLock()) {
function createMainWindow(savedState: DesktopWindowState): BrowserWindow {
const preloadPath = path.join(__dirname, '../preload/index.cjs');
const windowBounds = applyDesktopWindowState(savedState);
const primary = screen.getPrimaryDisplay();
const workAreas = [
primary,
...screen.getAllDisplays().filter((display) => display.id !== primary.id),
].map((display) => display.workArea);
const windowBounds = applyDesktopWindowState(savedState, undefined, workAreas);
const window = new BrowserWindow({
title: DESKTOP_APP_NAME,
minWidth: DESKTOP_MIN_WINDOW.width,
minHeight: DESKTOP_MIN_WINDOW.height,
minWidth: Math.min(DESKTOP_MIN_WINDOW.width, windowBounds.width),
minHeight: Math.min(DESKTOP_MIN_WINDOW.height, windowBounds.height),
...windowBounds,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 18 } : undefined,

View file

@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { DesktopPaths } from './types.js';
import { DESKTOP_MIN_WINDOW } from './app-metadata.js';
export interface DesktopWindowState {
width: number;
@ -45,7 +46,7 @@ export function writeDesktopWindowStateSync(paths: DesktopPaths, state: DesktopW
}
export function captureDesktopWindowState(window: BrowserWindow): DesktopWindowState {
const bounds = window.getBounds();
const bounds = window.getNormalBounds();
return {
...boundsToWindowState(bounds),
maximized: window.isMaximized(),
@ -54,15 +55,57 @@ export function captureDesktopWindowState(window: BrowserWindow): DesktopWindowS
export function applyDesktopWindowState(
state: DesktopWindowState,
fallback = DEFAULT_DESKTOP_WINDOW_STATE
fallback = DEFAULT_DESKTOP_WINDOW_STATE,
workAreas: readonly Rectangle[] = []
): Required<Pick<DesktopWindowState, 'width' | 'height'>> & Pick<DesktopWindowState, 'x' | 'y'> {
const sanitized = sanitizeWindowState(state);
return {
const bounds = {
width: sanitized.width || fallback.width,
height: sanitized.height || fallback.height,
x: sanitized.x,
y: sanitized.y,
};
const areas = workAreas.filter(
(area) =>
[area.x, area.y, area.width, area.height].every(Number.isFinite) &&
area.width > 0 &&
area.height > 0
);
if (!areas.length) return bounds;
// Electron's bounds and display work areas both use device-independent pixels.
// Keep the monitor containing the largest part of the saved window. With no
// intersection (disconnected display), use the first, primary work area.
let area = areas[0];
let largest = 0;
if (bounds.x !== undefined && bounds.y !== undefined) {
for (const candidate of areas) {
const overlap =
Math.max(
0,
Math.min(bounds.x + bounds.width, candidate.x + candidate.width) -
Math.max(bounds.x, candidate.x)
) *
Math.max(
0,
Math.min(bounds.y + bounds.height, candidate.y + candidate.height) -
Math.max(bounds.y, candidate.y)
);
if (overlap > largest) {
largest = overlap;
area = candidate;
}
}
}
const width = Math.min(area.width, Math.max(DESKTOP_MIN_WINDOW.width, bounds.width));
const height = Math.min(area.height, Math.max(DESKTOP_MIN_WINDOW.height, bounds.height));
const x = largest > 0 && bounds.x !== undefined ? bounds.x : area.x + (area.width - width) / 2;
const y = largest > 0 && bounds.y !== undefined ? bounds.y : area.y + (area.height - height) / 2;
return {
width,
height,
x: Math.round(Math.max(area.x, Math.min(x, area.x + area.width - width))),
y: Math.round(Math.max(area.y, Math.min(y, area.y + area.height - height))),
};
}
function boundsToWindowState(bounds: Rectangle): DesktopWindowState {

View file

@ -322,3 +322,7 @@ On macOS, Command-W closes the window while the managed server stays running.
Reopen the window from the Dock. Command-Q quits and stops the managed server.
Standard Hide, Hide Others, Show All and Services commands are available in the
application menu; View provides text zoom and full screen.
Saved window bounds are fitted to the current display work areas at launch or
reopen. If a monitor was disconnected, the window returns to the primary display.
Maximized windows retain their previous normal bounds for unmaximizing.

View file

@ -126,6 +126,15 @@ export async function verifyNativeWindowMenu(app, page) {
app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen())
)
.toBe(false);
const normalBounds = await app.evaluate(({ BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0];
const bounds = window.getNormalBounds();
window.maximize();
return bounds;
});
await expect
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized()))
.toBe(true);
const before = await page.evaluate(() => window.veritasDesktop.getConnectionStatus());
const closed = page.waitForEvent('close');
await clickRole('close');
@ -141,5 +150,12 @@ export async function verifyNativeWindowMenu(app, page) {
before.server.pid,
'Closing the window restarted the managed server'
);
return { page: reopened, roles: items };
await expect
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized()))
.toBe(true);
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].unmaximize());
await expect
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getBounds()))
.toEqual(normalBounds);
return { page: reopened, roles: items, normalBounds };
}