diff --git a/Client/tauri-client/src-tauri/capabilities/default.json b/Client/tauri-client/src-tauri/capabilities/default.json index 9f7831bf..4c0cd59b 100644 --- a/Client/tauri-client/src-tauri/capabilities/default.json +++ b/Client/tauri-client/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "core:window:allow-is-maximized", "core:window:allow-outer-position", "core:window:allow-outer-size", + "core:window:allow-available-monitors", "store:default", "global-shortcut:default", "global-shortcut:allow-register", diff --git a/Client/tauri-client/src/lib/window-state.ts b/Client/tauri-client/src/lib/window-state.ts index 98e2cf0e..6911c59b 100644 --- a/Client/tauri-client/src/lib/window-state.ts +++ b/Client/tauri-client/src/lib/window-state.ts @@ -18,6 +18,34 @@ export interface WindowState { const STORAGE_KEY = "windowState"; const SAVE_DEBOUNCE_MS = 500; +/** Minimum horizontal overlap (physical px) required with some monitor. */ +const MIN_VISIBLE_WIDTH = 100; +/** Allow the title bar to sit slightly above a monitor's top edge. */ +const TITLEBAR_TOP_TOLERANCE = 8; +/** The title bar must be at least this far above a monitor's bottom edge. */ +const TITLEBAR_GRAB_MARGIN = 40; + +interface MonitorRect { + readonly position: { x: number; y: number }; + readonly size: { width: number; height: number }; +} + +/** + * Check whether a saved window rect is reachable on one of the given + * monitors: enough horizontal overlap to grab, and the title bar row within + * the monitor's vertical range. All values are physical pixels. + */ +export function isRectOnScreen(monitors: readonly MonitorRect[], rect: WindowState): boolean { + return monitors.some((m) => { + const overlapX = + Math.min(rect.x + rect.width, m.position.x + m.size.width) - Math.max(rect.x, m.position.x); + const titleBarReachable = + rect.y >= m.position.y - TITLEBAR_TOP_TOLERANCE && + rect.y <= m.position.y + m.size.height - TITLEBAR_GRAB_MARGIN; + return overlapX >= MIN_VISIBLE_WIDTH && titleBarReachable; + }); +} + const invokePromise: Promise< ((cmd: string, args?: Record) => Promise) | null > = import("@tauri-apps/api/core") @@ -56,7 +84,13 @@ async function loadState(): Promise { typeof s.y === "number" && typeof s.width === "number" && typeof s.height === "number" && - typeof s.maximized === "boolean" + typeof s.maximized === "boolean" && + Number.isFinite(s.x) && + Number.isFinite(s.y) && + Number.isFinite(s.width) && + Number.isFinite(s.height) && + s.width >= 1 && + s.height >= 1 ) { return { x: s.x, @@ -74,6 +108,27 @@ async function loadState(): Promise { } } +/** + * Check whether the saved rect is visible on a connected monitor. Fails open: + * if monitors cannot be queried, restore proceeds as before. + */ +async function isSavedRectVisible( + tauriWindow: typeof import("@tauri-apps/api/window"), + saved: WindowState, +): Promise { + let monitors: MonitorRect[]; + try { + monitors = await tauriWindow.availableMonitors(); + } catch (err) { + log.warn("Could not query monitors; restoring window state unchecked", { + error: String(err), + }); + return true; + } + if (monitors.length === 0) return true; + return isRectOnScreen(monitors, saved); +} + /** * Initialize window state persistence. * Restores saved position/size on startup and listens for changes. @@ -96,18 +151,28 @@ export async function initWindowState(): Promise<() => void> { try { if (saved.maximized) { await win.maximize(); - } else { + log.info("Restored window state (maximized)"); + } else if (await isSavedRectVisible(tauriWindow, saved)) { const pos = new tauriWindow.PhysicalPosition(saved.x, saved.y); const size = new tauriWindow.PhysicalSize(saved.width, saved.height); await win.setPosition(pos); await win.setSize(size); + log.info("Restored window state", { + x: saved.x, + y: saved.y, + width: saved.width, + height: saved.height, + }); + } else { + // Saved rect is not reachable on any connected monitor (e.g. a + // disconnected display) — keep the default centered placement. + log.warn("Saved window position is off-screen; using default placement", { + x: saved.x, + y: saved.y, + width: saved.width, + height: saved.height, + }); } - log.info("Restored window state", { - x: saved.x, - y: saved.y, - width: saved.width, - height: saved.height, - }); } catch (err) { log.warn("Failed to restore window state", { error: String(err) }); } diff --git a/Client/tauri-client/tests/unit/window-state-restore.test.ts b/Client/tauri-client/tests/unit/window-state-restore.test.ts new file mode 100644 index 00000000..77c3fa4a --- /dev/null +++ b/Client/tauri-client/tests/unit/window-state-restore.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Shared mutable state read lazily by the mock factories below. +const h = vi.hoisted(() => ({ + settings: {} as Record, + monitors: [] as Array<{ + position: { x: number; y: number }; + size: { width: number; height: number }; + }>, + monitorsError: null as Error | null, + setPosition: vi.fn(), + setSize: vi.fn(), + maximize: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string) => { + if (cmd === "get_settings") return Promise.resolve(h.settings); + return Promise.resolve(undefined); + }, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + maximize: h.maximize, + setPosition: h.setPosition, + setSize: h.setSize, + onMoved: vi.fn().mockResolvedValue(() => {}), + onResized: vi.fn().mockResolvedValue(() => {}), + outerPosition: vi.fn(), + outerSize: vi.fn(), + isMaximized: vi.fn().mockResolvedValue(false), + }), + availableMonitors: () => + h.monitorsError !== null ? Promise.reject(h.monitorsError) : Promise.resolve(h.monitors), + PhysicalPosition: class { + constructor( + public x: number, + public y: number, + ) {} + }, + PhysicalSize: class { + constructor( + public width: number, + public height: number, + ) {} + }, +})); + +const PRIMARY = { position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 } }; + +function setSaved(state: Record): void { + h.settings = { windowState: state }; +} + +describe("window-state restore validation", () => { + beforeEach(() => { + vi.resetModules(); + h.settings = {}; + h.monitors = [PRIMARY]; + h.monitorsError = null; + h.setPosition.mockClear(); + h.setSize.mockClear(); + h.maximize.mockClear(); + }); + + describe("isRectOnScreen", () => { + it("accepts a rect fully inside a monitor", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY], { x: 100, y: 100, width: 1280, height: 720, maximized: false }), + ).toBe(true); + }); + + it("rejects a rect far off-screen", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY], { x: -5000, y: 100, width: 1280, height: 720, maximized: false }), + ).toBe(false); + }); + + it("accepts a rect on a secondary monitor left of primary", async () => { + const secondary = { position: { x: -1920, y: 0 }, size: { width: 1920, height: 1080 } }; + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY, secondary], { + x: -1800, + y: 50, + width: 1280, + height: 720, + maximized: false, + }), + ).toBe(true); + }); + + it("rejects a rect whose title bar is below every monitor", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + expect( + isRectOnScreen([PRIMARY], { x: 100, y: 1075, width: 1280, height: 720, maximized: false }), + ).toBe(false); + }); + + it("rejects a rect with too little horizontal overlap", async () => { + const { isRectOnScreen } = await import("@lib/window-state"); + // Only 50px of the window remains on-screen at the right edge. + expect( + isRectOnScreen([PRIMARY], { x: 1870, y: 100, width: 1280, height: 720, maximized: false }), + ).toBe(false); + }); + }); + + describe("initWindowState", () => { + it("restores an on-screen saved position", async () => { + setSaved({ x: 200, y: 150, width: 1280, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).toHaveBeenCalledTimes(1); + expect(h.setPosition.mock.calls[0]?.[0]).toMatchObject({ x: 200, y: 150 }); + expect(h.setSize).toHaveBeenCalledTimes(1); + expect(h.setSize.mock.calls[0]?.[0]).toMatchObject({ width: 1280, height: 720 }); + }); + + it("skips restore when the saved position is off-screen", async () => { + setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).not.toHaveBeenCalled(); + expect(h.setSize).not.toHaveBeenCalled(); + }); + + it("restores unchecked when availableMonitors fails", async () => { + setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: false }); + h.monitorsError = new Error("not supported"); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).toHaveBeenCalledTimes(1); + expect(h.setSize).toHaveBeenCalledTimes(1); + }); + + it("restores unchecked when no monitors are reported", async () => { + setSaved({ x: 300, y: 300, width: 1280, height: 720, maximized: false }); + h.monitors = []; + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).toHaveBeenCalledTimes(1); + }); + + it("maximizes without querying position when saved maximized", async () => { + setSaved({ x: -5000, y: -5000, width: 1280, height: 720, maximized: true }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.maximize).toHaveBeenCalledTimes(1); + expect(h.setPosition).not.toHaveBeenCalled(); + }); + + it("ignores saved state with non-finite coordinates", async () => { + setSaved({ x: NaN, y: 100, width: 1280, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).not.toHaveBeenCalled(); + expect(h.maximize).not.toHaveBeenCalled(); + }); + + it("ignores saved state with non-positive size", async () => { + setSaved({ x: 100, y: 100, width: 0, height: 720, maximized: false }); + const { initWindowState } = await import("@lib/window-state"); + (await initWindowState())(); + expect(h.setPosition).not.toHaveBeenCalled(); + }); + }); +});