diff --git a/desktop/src/main/__tests__/window-state.test.ts b/desktop/src/main/__tests__/window-state.test.ts index 53f28b25..160cc09b 100644 --- a/desktop/src/main/__tests__/window-state.test.ts +++ b/desktop/src/main/__tests__/window-state.test.ts @@ -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(); + }); +}); diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index f570a8bf..100429e4 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -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, diff --git a/desktop/src/main/window-state.ts b/desktop/src/main/window-state.ts index 0a01c670..4f811db6 100644 --- a/desktop/src/main/window-state.ts +++ b/desktop/src/main/window-state.ts @@ -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 { 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 { diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index 87cac51c..f66ee1b2 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -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. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 393dd8fd..75886e77 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -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 }; }