From ebb1949afc0c1b79d1a813f6d79265825b3010c8 Mon Sep 17 00:00:00 2001 From: Rushil Rai <58342835+rushilrai@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:44:53 +0530 Subject: [PATCH 1/5] persist desktop window size and restore macOS fullscreen exits cleanly --- apps/desktop/src/app/DesktopEnvironment.ts | 2 + apps/desktop/src/main.ts | 2 + apps/desktop/src/window/DesktopWindow.test.ts | 13 + apps/desktop/src/window/DesktopWindow.ts | 29 +- .../src/window/DesktopWindowState.test.ts | 301 +++++++++++++++++ apps/desktop/src/window/DesktopWindowState.ts | 305 ++++++++++++++++++ 6 files changed, 647 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/window/DesktopWindowState.test.ts create mode 100644 apps/desktop/src/window/DesktopWindowState.ts diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 061a9368c53..b6f0ab14ff6 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -47,6 +47,7 @@ export class DesktopEnvironment extends Context.Service< readonly clientSettingsPath: string; readonly savedEnvironmentRegistryPath: string; readonly serverSettingsPath: string; + readonly windowStatePath: string; readonly logDir: string; readonly browserArtifactsDir: string; readonly rootDir: string; @@ -178,6 +179,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( clientSettingsPath: path.join(stateDir, "client-settings.json"), savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), + windowStatePath: path.join(stateDir, "window-state.json"), logDir: path.join(stateDir, "logs"), browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7a51700e0fd..bb428029d99 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -50,6 +50,7 @@ import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopWindowState from "./window/DesktopWindowState.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; @@ -124,6 +125,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, + DesktopWindowState.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); const desktopSshLayer = desktopSshEnvironmentLayer.pipe( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..2dc3d0f89cf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -31,6 +31,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +import * as DesktopWindowState from "./DesktopWindowState.ts"; import * as PreviewManager from "../preview/Manager.ts"; const environmentInput = { @@ -91,6 +92,16 @@ function makeFakeBrowserWindow() { }; } +// Stubbed so these tests stay filesystem-free. DesktopWindowState has its own suite. +const desktopWindowStateLayer = Layer.succeed(DesktopWindowState.DesktopWindowState, { + load: () => + Effect.succeed({ + bounds: { x: 0, y: 0, width: 1100, height: 780 }, + restoreMode: "normal" as const, + }), + attach: () => Effect.void, +} satisfies DesktopWindowState.DesktopWindowState["Service"]); + const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { iconPaths: Effect.succeed({ ico: Option.none(), @@ -171,6 +182,7 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopServerExposureLayer, + desktopWindowStateLayer, DesktopState.layer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { @@ -268,6 +280,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopAssetsLayer, desktopEnvironmentLayer, desktopServerExposureLayer, + desktopWindowStateLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..6dd14ca39df 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -17,7 +17,12 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as DesktopWindowState from "./DesktopWindowState.ts"; +const DEFAULT_WINDOW_WIDTH = 1100; +const DEFAULT_WINDOW_HEIGHT = 780; +const MIN_WINDOW_WIDTH = 840; +const MIN_WINDOW_HEIGHT = 620; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; @@ -45,7 +50,8 @@ type DesktopWindowRuntimeServices = | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme | ElectronWindow.ElectronWindow - | PreviewManager.PreviewManager; + | PreviewManager.PreviewManager + | DesktopWindowState.DesktopWindowState; export type DesktopWindowError = | ElectronWindow.ElectronWindowCreateError @@ -202,6 +208,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const windowState = yield* DesktopWindowState.DesktopWindowState; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -250,11 +257,18 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const initialWindowState = yield* windowState.load({ + defaultBounds: { x: 0, y: 0, width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT }, + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, + }); const window = yield* electronWindow.create({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, + x: initialWindowState.bounds.x, + y: initialWindowState.bounds.y, + width: initialWindowState.bounds.width, + height: initialWindowState.bounds.height, + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), @@ -275,6 +289,7 @@ export const make = Effect.gen(function* () { window.setAutoHideCursor(false); } + yield* windowState.attach(window); yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( @@ -461,6 +476,10 @@ export const make = Effect.gen(function* () { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { + // Re-maximize before showing so the window doesn't flash at normal bounds first. + if (initialWindowState.restoreMode === "maximized" && !window.isDestroyed()) { + window.maximize(); + } // Reveal the real window, then close the connecting splash (if any) so the // two don't overlap and there's no blank gap between them. void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); diff --git a/apps/desktop/src/window/DesktopWindowState.test.ts b/apps/desktop/src/window/DesktopWindowState.test.ts new file mode 100644 index 00000000000..65d81fad26d --- /dev/null +++ b/apps/desktop/src/window/DesktopWindowState.test.ts @@ -0,0 +1,301 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { vi } from "vite-plus/test"; + +// Pin a single 1920x1080 display so the off-screen check is deterministic. +// Inlined because vi.mock factories are hoisted above module-level bindings. +vi.mock("electron", () => ({ + screen: { + getPrimaryDisplay: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), + getAllDisplays: () => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }], + }, +})); + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopWindowState from "./DesktopWindowState.ts"; + +const PRIMARY_WORK_AREA = { x: 0, y: 0, width: 1920, height: 1080 } as const; + +const DEFAULTS: DesktopWindowState.WindowStateDefaults = { + defaultBounds: { x: 0, y: 0, width: 1100, height: 780 }, + minWidth: 840, + minHeight: 620, +}; + +// 1100x780 centered in a 1920x1080 work area. +const EXPECTED_DEFAULT_BOUNDS = { x: 410, y: 150, width: 1100, height: 780 } as const; + +// Permissive on purpose so tests can author version mismatches / partial docs. +const TestRectangle = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); +const TestWindowStateDocument = Schema.Struct({ + version: Schema.Number, + normalBounds: TestRectangle, + restoreMode: Schema.String, + fullscreenOriginBounds: Schema.optionalKey(TestRectangle), +}); +const encodeTestWindowStateDocument = Schema.encodeEffect( + Schema.fromJsonString(TestWindowStateDocument), +); + +function makeEnvironmentLayer(baseDir: string) { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "0.0.27", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +const withWindowState = ( + effect: Effect.Effect< + A, + E, + R | DesktopWindowState.DesktopWindowState | DesktopEnvironment.DesktopEnvironment + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-window-state-test-", + }); + return yield* effect.pipe( + Effect.provide( + DesktopWindowState.layer.pipe( + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +function writeRawWindowStateFile(content: string) { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.windowStatePath, content); + }); +} + +function writeWindowStateDocument(document: typeof TestWindowStateDocument.Type) { + return Effect.gen(function* () { + const encoded = yield* encodeTestWindowStateDocument(document); + yield* writeRawWindowStateFile(`${encoded}\n`); + }); +} + +const loadResolved = Effect.gen(function* () { + const service = yield* DesktopWindowState.DesktopWindowState; + return yield* service.load(DEFAULTS); +}); + +describe("DesktopWindowState geometry helpers", () => { + it("rejects rectangles with non-positive or non-finite dimensions", () => { + assert.isTrue(DesktopWindowState.hasUsableDimensions({ x: 0, y: 0, width: 10, height: 10 })); + assert.isFalse(DesktopWindowState.hasUsableDimensions({ x: 0, y: 0, width: 0, height: 10 })); + assert.isFalse(DesktopWindowState.hasUsableDimensions({ x: 0, y: 0, width: -5, height: 10 })); + assert.isFalse( + DesktopWindowState.hasUsableDimensions({ x: Number.NaN, y: 0, width: 10, height: 10 }), + ); + }); + + it("rounds position and clamps dimensions up to the minimums", () => { + assert.deepEqual( + DesktopWindowState.sanitizeBounds({ x: 12.4, y: 8.6, width: 200, height: 100 }, 840, 620), + { x: 12, y: 9, width: 840, height: 620 }, + ); + assert.deepEqual( + DesktopWindowState.sanitizeBounds({ x: 100, y: 100, width: 1300.2, height: 850.9 }, 840, 620), + { x: 100, y: 100, width: 1300, height: 851 }, + ); + }); + + it("computes intersection area, returning zero for disjoint rectangles", () => { + assert.equal( + DesktopWindowState.intersectionArea( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 50, y: 50, width: 100, height: 100 }, + ), + 2_500, + ); + assert.equal( + DesktopWindowState.intersectionArea( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 200, y: 200, width: 100, height: 100 }, + ), + 0, + ); + }); + + it("treats a window as visible only when it overlaps a display enough", () => { + assert.isTrue( + DesktopWindowState.isWindowVisibleEnough({ x: 100, y: 100, width: 800, height: 600 }, [ + PRIMARY_WORK_AREA, + ]), + ); + assert.isFalse( + DesktopWindowState.isWindowVisibleEnough({ x: 6_000, y: 6_000, width: 800, height: 600 }, [ + PRIMARY_WORK_AREA, + ]), + ); + }); + + it("centers bounds within a work area", () => { + assert.deepEqual( + DesktopWindowState.centerBoundsInWorkArea(PRIMARY_WORK_AREA, 1100, 780), + EXPECTED_DEFAULT_BOUNDS, + ); + }); +}); + +describe("DesktopWindowState.load", () => { + it.effect("returns the centered default when no window-state file exists", () => + withWindowState( + Effect.gen(function* () { + const resolved = yield* loadResolved; + assert.deepEqual(resolved, { + bounds: EXPECTED_DEFAULT_BOUNDS, + restoreMode: "normal", + }); + }), + ), + ); + + it.effect("falls back to the default when the file is malformed", () => + withWindowState( + Effect.gen(function* () { + yield* writeRawWindowStateFile("{ this is not json"); + const resolved = yield* loadResolved; + assert.deepEqual(resolved.bounds, EXPECTED_DEFAULT_BOUNDS); + assert.equal(resolved.restoreMode, "normal"); + }), + ), + ); + + it.effect("falls back to the default on a version mismatch", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 2, + normalBounds: { x: 100, y: 100, width: 1300, height: 850 }, + restoreMode: "normal", + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved.bounds, EXPECTED_DEFAULT_BOUNDS); + }), + ), + ); + + it.effect("restores persisted normal bounds", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 120, y: 90, width: 1300, height: 850 }, + restoreMode: "normal", + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved, { + bounds: { x: 120, y: 90, width: 1300, height: 850 }, + restoreMode: "normal", + }); + }), + ), + ); + + it.effect("clamps restored bounds up to the minimum window size", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 120, y: 90, width: 300, height: 200 }, + restoreMode: "normal", + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved.bounds, { x: 120, y: 90, width: 840, height: 620 }); + }), + ), + ); + + it.effect("restores the maximized restore mode", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 120, y: 90, width: 1300, height: 850 }, + restoreMode: "maximized", + }); + const resolved = yield* loadResolved; + assert.equal(resolved.restoreMode, "maximized"); + assert.deepEqual(resolved.bounds, { x: 120, y: 90, width: 1300, height: 850 }); + }), + ), + ); + + it.effect("restores fullscreen-origin using the saved visible frame", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 0, y: 0, width: 1920, height: 1080 }, + restoreMode: "fullscreen-origin", + fullscreenOriginBounds: { x: 60, y: 50, width: 1200, height: 800 }, + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved, { + bounds: { x: 60, y: 50, width: 1200, height: 800 }, + restoreMode: "fullscreen-origin", + }); + }), + ), + ); + + it.effect("falls back when fullscreen-origin bounds are missing", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 120, y: 90, width: 1300, height: 850 }, + restoreMode: "fullscreen-origin", + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved.bounds, EXPECTED_DEFAULT_BOUNDS); + assert.equal(resolved.restoreMode, "normal"); + }), + ), + ); + + it.effect("falls back when persisted bounds are off-screen", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 6_000, y: 6_000, width: 1100, height: 780 }, + restoreMode: "normal", + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved.bounds, EXPECTED_DEFAULT_BOUNDS); + }), + ), + ); +}); diff --git a/apps/desktop/src/window/DesktopWindowState.ts b/apps/desktop/src/window/DesktopWindowState.ts new file mode 100644 index 00000000000..c0573e481f2 --- /dev/null +++ b/apps/desktop/src/window/DesktopWindowState.ts @@ -0,0 +1,305 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as Electron from "electron"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; + +const WINDOW_STATE_VERSION = 1; +const WINDOW_VISIBILITY_THRESHOLD = 0.2; +const WINDOW_STATE_PERSIST_DEBOUNCE_MS = 250; + +export interface WindowRectangle { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export type PersistedWindowRestoreMode = "normal" | "maximized" | "fullscreen-origin"; + +export interface ResolvedWindowState { + readonly bounds: WindowRectangle; + readonly restoreMode: PersistedWindowRestoreMode; +} + +export interface WindowStateDefaults { + readonly defaultBounds: WindowRectangle; + readonly minWidth: number; + readonly minHeight: number; +} + +const WindowRectangleSchema = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + +const PersistedWindowRestoreModeSchema = Schema.Literals([ + "normal", + "maximized", + "fullscreen-origin", +]); + +const PersistedWindowStateDocument = Schema.Struct({ + version: Schema.Literal(WINDOW_STATE_VERSION), + normalBounds: WindowRectangleSchema, + restoreMode: PersistedWindowRestoreModeSchema, + // Set only for "fullscreen-origin": the pre-fullscreen frame we reopen at to + // avoid re-entering macOS fullscreen (and its white startup flash). + fullscreenOriginBounds: Schema.optionalKey(WindowRectangleSchema), +}); +type PersistedWindowStateDocument = typeof PersistedWindowStateDocument.Type; + +const PersistedWindowStateJson = fromLenientJson(PersistedWindowStateDocument); +const decodePersistedWindowStateJson = Schema.decodeEffect(PersistedWindowStateJson); +const encodePersistedWindowStateJson = Schema.encodeEffect(PersistedWindowStateJson); + +function isFiniteNumber(value: number): boolean { + return Number.isFinite(value); +} + +export function hasUsableDimensions(rect: WindowRectangle): boolean { + return ( + isFiniteNumber(rect.x) && + isFiniteNumber(rect.y) && + isFiniteNumber(rect.width) && + isFiniteNumber(rect.height) && + rect.width > 0 && + rect.height > 0 + ); +} + +export function sanitizeBounds( + bounds: WindowRectangle, + minWidth: number, + minHeight: number, +): WindowRectangle { + return { + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(minWidth, Math.round(bounds.width)), + height: Math.max(minHeight, Math.round(bounds.height)), + }; +} + +export function intersectionArea(a: WindowRectangle, b: WindowRectangle): number { + const left = Math.max(a.x, b.x); + const top = Math.max(a.y, b.y); + const right = Math.min(a.x + a.width, b.x + b.width); + const bottom = Math.min(a.y + a.height, b.y + b.height); + + if (right <= left || bottom <= top) { + return 0; + } + + return (right - left) * (bottom - top); +} + +export function isWindowVisibleEnough( + bounds: WindowRectangle, + workAreas: readonly WindowRectangle[], +): boolean { + const totalArea = bounds.width * bounds.height; + if (totalArea <= 0) { + return false; + } + + const bestVisibleArea = workAreas.reduce( + (best, workArea) => Math.max(best, intersectionArea(bounds, workArea)), + 0, + ); + + return bestVisibleArea / totalArea >= WINDOW_VISIBILITY_THRESHOLD; +} + +export function centerBoundsInWorkArea( + workArea: WindowRectangle, + width: number, + height: number, +): WindowRectangle { + return { + x: Math.round(workArea.x + (workArea.width - width) / 2), + y: Math.round(workArea.y + (workArea.height - height) / 2), + width, + height, + }; +} + +function getDisplayWorkAreas(): readonly WindowRectangle[] { + return Electron.screen.getAllDisplays().map((display) => display.workArea); +} + +function getPrimaryWorkArea(): WindowRectangle { + return Electron.screen.getPrimaryDisplay().workArea; +} + +function readRestorableState(window: Electron.BrowserWindow): PersistedWindowStateDocument { + return { + version: WINDOW_STATE_VERSION, + normalBounds: window.getNormalBounds(), + restoreMode: window.isMaximized() ? "maximized" : "normal", + }; +} + +export class DesktopWindowState extends Context.Service< + DesktopWindowState, + { + readonly load: (defaults: WindowStateDefaults) => Effect.Effect; + readonly attach: (window: Electron.BrowserWindow) => Effect.Effect; + } +>()("@t3tools/desktop/window/DesktopWindowState") {} + +const { logWarning } = makeComponentLogger("desktop-window-state"); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* Effect.context< + DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | Path.Path + >(); + const runFork = Effect.runForkWith(context); + + const windowStatePath = environment.windowStatePath; + + const buildDefault = (defaults: WindowStateDefaults): ResolvedWindowState => { + const width = Math.max(defaults.minWidth, Math.round(defaults.defaultBounds.width)); + const height = Math.max(defaults.minHeight, Math.round(defaults.defaultBounds.height)); + return { + bounds: centerBoundsInWorkArea(getPrimaryWorkArea(), width, height), + restoreMode: "normal", + }; + }; + + const load = (defaults: WindowStateDefaults): Effect.Effect => + Effect.gen(function* () { + const fallback = buildDefault(defaults); + + const raw = yield* fileSystem.readFileString(windowStatePath).pipe(Effect.option); + if (Option.isNone(raw)) { + return fallback; + } + + const decoded = yield* decodePersistedWindowStateJson(raw.value).pipe(Effect.option); + if (Option.isNone(decoded)) { + return fallback; + } + const parsed = decoded.value; + + if (!hasUsableDimensions(parsed.normalBounds)) { + return fallback; + } + + const workAreas = getDisplayWorkAreas(); + const normalBounds = sanitizeBounds( + parsed.normalBounds, + defaults.minWidth, + defaults.minHeight, + ); + if (!isWindowVisibleEnough(normalBounds, workAreas)) { + return fallback; + } + + if (parsed.restoreMode === "fullscreen-origin") { + const originBounds = parsed.fullscreenOriginBounds; + if (originBounds === undefined || !hasUsableDimensions(originBounds)) { + return fallback; + } + const sanitizedOrigin = sanitizeBounds(originBounds, defaults.minWidth, defaults.minHeight); + if (!isWindowVisibleEnough(sanitizedOrigin, workAreas)) { + return fallback; + } + return { bounds: sanitizedOrigin, restoreMode: "fullscreen-origin" }; + } + + return { bounds: normalBounds, restoreMode: parsed.restoreMode }; + }); + + const persist = (document: PersistedWindowStateDocument): Effect.Effect => + Effect.gen(function* () { + const directory = path.dirname(windowStatePath); + const tempPath = `${windowStatePath}.${process.pid}.tmp`; + const encoded = yield* encodePersistedWindowStateJson(document); + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* fileSystem.rename(tempPath, windowStatePath); + }).pipe( + Effect.catch((error) => + logWarning("failed to persist window state", { error: error.message }), + ), + ); + + const attach = (window: Electron.BrowserWindow): Effect.Effect => + Effect.sync(() => { + let debounceFiber: Fiber.Fiber | undefined; + // In fullscreen, getNormalBounds() returns the fullscreen frame, so keep + // the last non-fullscreen frame around to persist instead. + let lastRestorable: PersistedWindowStateDocument = readRestorableState(window); + let lastVisibleBounds: WindowRectangle = window.getBounds(); + + const resolveDocument = (): PersistedWindowStateDocument => { + if (window.isFullScreen()) { + return { + ...lastRestorable, + restoreMode: "fullscreen-origin", + fullscreenOriginBounds: lastVisibleBounds, + }; + } + lastRestorable = readRestorableState(window); + lastVisibleBounds = window.getBounds(); + return lastRestorable; + }; + + const persistEffect = Effect.suspend(() => persist(resolveDocument())); + + const cancelDebounce = () => { + if (debounceFiber === undefined) { + return; + } + const fiber = debounceFiber; + debounceFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + + const persistNow = () => { + cancelDebounce(); + runFork(persistEffect); + }; + + const schedulePersist = () => { + cancelDebounce(); + debounceFiber = runFork( + Effect.sleep(WINDOW_STATE_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen(persistEffect), + Effect.ensuring( + Effect.sync(() => { + debounceFiber = undefined; + }), + ), + ), + ); + }; + + window.on("resize", schedulePersist); + window.on("move", schedulePersist); + window.on("maximize", persistNow); + window.on("unmaximize", persistNow); + window.on("enter-full-screen", persistNow); + window.on("leave-full-screen", persistNow); + window.on("close", persistNow); + }); + + return DesktopWindowState.of({ load, attach }); +}); + +export const layer = Layer.effect(DesktopWindowState, make); From c5a049c0a6eb005a84236a76db7e5e64bee661bf Mon Sep 17 00:00:00 2001 From: Rushil Rai <58342835+rushilrai@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:51:11 +0530 Subject: [PATCH 2/5] harden window-state persistence against startup and quit races --- .../src/window/DesktopWindowState.test.ts | 228 +++++++++++++++++- apps/desktop/src/window/DesktopWindowState.ts | 111 ++++++--- 2 files changed, 295 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindowState.test.ts b/apps/desktop/src/window/DesktopWindowState.test.ts index 65d81fad26d..e2ae514ef45 100644 --- a/apps/desktop/src/window/DesktopWindowState.test.ts +++ b/apps/desktop/src/window/DesktopWindowState.test.ts @@ -3,8 +3,10 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import type * as Electron from "electron"; import { vi } from "vite-plus/test"; // Pin a single 1920x1080 display so the off-screen check is deterministic. @@ -44,9 +46,9 @@ const TestWindowStateDocument = Schema.Struct({ restoreMode: Schema.String, fullscreenOriginBounds: Schema.optionalKey(TestRectangle), }); -const encodeTestWindowStateDocument = Schema.encodeEffect( - Schema.fromJsonString(TestWindowStateDocument), -); +const TestWindowStateDocumentJson = Schema.fromJsonString(TestWindowStateDocument); +const encodeTestWindowStateDocument = Schema.encodeEffect(TestWindowStateDocumentJson); +const decodeTestWindowStateDocument = Schema.decodeEffect(TestWindowStateDocumentJson); function makeEnvironmentLayer(baseDir: string) { return DesktopEnvironment.layer({ @@ -109,6 +111,80 @@ const loadResolved = Effect.gen(function* () { return yield* service.load(DEFAULTS); }); +interface FakeWindowState { + bounds: DesktopWindowState.WindowRectangle; + maximized: boolean; + fullScreen: boolean; + visible: boolean; +} + +function makeFakeWindow(initial: Partial = {}) { + const state: FakeWindowState = { + bounds: { x: 100, y: 100, width: 1200, height: 800 }, + maximized: false, + fullScreen: false, + visible: true, + ...initial, + }; + const listeners = new Map void>>(); + const addListener = (event: string, listener: () => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + }; + const fake = { + on: addListener, + once: (event: string, listener: () => void) => { + const wrapped = () => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((existing) => existing !== wrapped), + ); + listener(); + }; + addListener(event, wrapped); + }, + isVisible: () => state.visible, + isFullScreen: () => state.fullScreen, + isMaximized: () => state.maximized, + getBounds: () => state.bounds, + getNormalBounds: () => state.bounds, + isDestroyed: () => false, + }; + const emit = (event: string) => { + // Copy so once-listeners that remove themselves don't skip the next entry. + for (const listener of (listeners.get(event) ?? []).slice()) { + listener(); + } + }; + return { window: fake as unknown as Electron.BrowserWindow, state, emit }; +} + +const attachWindow = (window: Electron.BrowserWindow) => + Effect.gen(function* () { + const service = yield* DesktopWindowState.DesktopWindowState; + yield* service.attach(window); + }); + +const readPersistedDocument = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const raw = yield* fileSystem.readFileString(environment.windowStatePath); + return yield* decodeTestWindowStateDocument(raw); +}); + +const awaitPersistedDocument = ( + predicate: (document: typeof TestWindowStateDocument.Type) => boolean, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 200; attempt += 1) { + const document = yield* readPersistedDocument.pipe(Effect.option); + if (Option.isSome(document) && predicate(document.value)) { + return document.value; + } + yield* Effect.sleep(10); + } + return yield* Effect.die(new Error("window state document was not persisted in time")); + }); + describe("DesktopWindowState geometry helpers", () => { it("rejects rectangles with non-positive or non-finite dimensions", () => { assert.isTrue(DesktopWindowState.hasUsableDimensions({ x: 0, y: 0, width: 10, height: 10 })); @@ -252,35 +328,50 @@ describe("DesktopWindowState.load", () => { ), ); - it.effect("restores fullscreen-origin using the saved visible frame", () => + it.effect("restores the fullscreen origin frame with the underlying restore mode", () => withWindowState( Effect.gen(function* () { yield* writeWindowStateDocument({ version: 1, - normalBounds: { x: 0, y: 0, width: 1920, height: 1080 }, - restoreMode: "fullscreen-origin", + normalBounds: { x: 0, y: 0, width: 1400, height: 900 }, + restoreMode: "maximized", fullscreenOriginBounds: { x: 60, y: 50, width: 1200, height: 800 }, }); const resolved = yield* loadResolved; assert.deepEqual(resolved, { bounds: { x: 60, y: 50, width: 1200, height: 800 }, - restoreMode: "fullscreen-origin", + restoreMode: "maximized", }); }), ), ); - it.effect("falls back when fullscreen-origin bounds are missing", () => + it.effect("restores the fullscreen origin frame even when normal bounds are off-screen", () => + withWindowState( + Effect.gen(function* () { + yield* writeWindowStateDocument({ + version: 1, + normalBounds: { x: 6_000, y: 6_000, width: 1400, height: 900 }, + restoreMode: "normal", + fullscreenOriginBounds: { x: 60, y: 50, width: 1200, height: 800 }, + }); + const resolved = yield* loadResolved; + assert.deepEqual(resolved.bounds, { x: 60, y: 50, width: 1200, height: 800 }); + }), + ), + ); + + it.effect("ignores off-screen fullscreen origin bounds and restores normal bounds", () => withWindowState( Effect.gen(function* () { yield* writeWindowStateDocument({ version: 1, normalBounds: { x: 120, y: 90, width: 1300, height: 850 }, - restoreMode: "fullscreen-origin", + restoreMode: "normal", + fullscreenOriginBounds: { x: 6_000, y: 6_000, width: 1200, height: 800 }, }); const resolved = yield* loadResolved; - assert.deepEqual(resolved.bounds, EXPECTED_DEFAULT_BOUNDS); - assert.equal(resolved.restoreMode, "normal"); + assert.deepEqual(resolved.bounds, { x: 120, y: 90, width: 1300, height: 850 }); }), ), ); @@ -299,3 +390,118 @@ describe("DesktopWindowState.load", () => { ), ); }); + +// Persists are forked from window-event callbacks and the debounce uses the +// wall clock, so these run under it.live and poll the state file. +describe("DesktopWindowState.attach", () => { + it.live("drops persist events fired before the window is first shown", () => + withWindowState( + Effect.gen(function* () { + const fake = makeFakeWindow({ visible: false, maximized: true }); + yield* attachWindow(fake.window); + + fake.emit("resize"); + fake.emit("close"); + yield* Effect.sleep(100); + assert.isTrue(Option.isNone(yield* readPersistedDocument.pipe(Effect.option))); + + fake.state.visible = true; + fake.emit("show"); + fake.emit("close"); + const document = yield* awaitPersistedDocument(() => true); + assert.equal(document.restoreMode, "maximized"); + }), + ), + ); + + it.live("persists bounds and restore mode on close", () => + withWindowState( + Effect.gen(function* () { + const fake = makeFakeWindow({ bounds: { x: 40, y: 30, width: 1000, height: 700 } }); + yield* attachWindow(fake.window); + + fake.emit("close"); + const document = yield* awaitPersistedDocument(() => true); + assert.deepEqual(document.normalBounds, { x: 40, y: 30, width: 1000, height: 700 }); + assert.equal(document.restoreMode, "normal"); + assert.isUndefined(document.fullscreenOriginBounds); + }), + ), + ); + + it.live("quitting while in fullscreen keeps the pre-fullscreen frame and mode", () => + withWindowState( + Effect.gen(function* () { + const preFullscreen = { x: 60, y: 50, width: 1200, height: 800 }; + const fake = makeFakeWindow({ bounds: preFullscreen, maximized: true }); + yield* attachWindow(fake.window); + + fake.state.fullScreen = true; + fake.state.bounds = { x: 0, y: 0, width: 1920, height: 1080 }; + fake.emit("enter-full-screen"); + fake.emit("close"); + + const document = yield* awaitPersistedDocument( + (candidate) => candidate.fullscreenOriginBounds !== undefined, + ); + assert.deepEqual(document.fullscreenOriginBounds, preFullscreen); + assert.deepEqual(document.normalBounds, preFullscreen); + assert.equal(document.restoreMode, "maximized"); + }), + ), + ); + + it.live("close right after leave-full-screen keeps the fullscreen snapshot", () => + withWindowState( + Effect.gen(function* () { + const preFullscreen = { x: 60, y: 50, width: 1200, height: 800 }; + const fake = makeFakeWindow({ bounds: preFullscreen }); + yield* attachWindow(fake.window); + + fake.state.fullScreen = true; + fake.state.bounds = { x: 0, y: 0, width: 1920, height: 1080 }; + fake.emit("enter-full-screen"); + + // macOS quit sequence: exit transition starts, close lands before the + // debounced post-exit save. + fake.state.fullScreen = false; + fake.state.bounds = { x: 12, y: 8, width: 1740, height: 1002 }; + fake.emit("leave-full-screen"); + fake.emit("close"); + + const document = yield* awaitPersistedDocument( + (candidate) => candidate.fullscreenOriginBounds !== undefined, + ); + assert.deepEqual(document.fullscreenOriginBounds, preFullscreen); + yield* Effect.sleep(350); + const settled = yield* readPersistedDocument; + assert.deepEqual(settled.fullscreenOriginBounds, preFullscreen); + }), + ), + ); + + it.live("a window that stays open after leaving fullscreen persists its live state", () => + withWindowState( + Effect.gen(function* () { + const preFullscreen = { x: 60, y: 50, width: 1200, height: 800 }; + const fake = makeFakeWindow({ bounds: preFullscreen }); + yield* attachWindow(fake.window); + + fake.state.fullScreen = true; + fake.state.bounds = { x: 0, y: 0, width: 1920, height: 1080 }; + fake.emit("enter-full-screen"); + + const postExit = { x: 80, y: 70, width: 1100, height: 750 }; + fake.state.fullScreen = false; + fake.state.bounds = postExit; + fake.emit("leave-full-screen"); + + const document = yield* awaitPersistedDocument( + (candidate) => candidate.fullscreenOriginBounds === undefined, + ); + assert.deepEqual(document.normalBounds, postExit); + assert.equal(document.restoreMode, "normal"); + }), + ), + ); +}); diff --git a/apps/desktop/src/window/DesktopWindowState.ts b/apps/desktop/src/window/DesktopWindowState.ts index c0573e481f2..9d9ce367a87 100644 --- a/apps/desktop/src/window/DesktopWindowState.ts +++ b/apps/desktop/src/window/DesktopWindowState.ts @@ -24,7 +24,7 @@ export interface WindowRectangle { readonly height: number; } -export type PersistedWindowRestoreMode = "normal" | "maximized" | "fullscreen-origin"; +export type PersistedWindowRestoreMode = "normal" | "maximized"; export interface ResolvedWindowState { readonly bounds: WindowRectangle; @@ -44,18 +44,16 @@ const WindowRectangleSchema = Schema.Struct({ height: Schema.Number, }); -const PersistedWindowRestoreModeSchema = Schema.Literals([ - "normal", - "maximized", - "fullscreen-origin", -]); +const PersistedWindowRestoreModeSchema = Schema.Literals(["normal", "maximized"]); const PersistedWindowStateDocument = Schema.Struct({ version: Schema.Literal(WINDOW_STATE_VERSION), normalBounds: WindowRectangleSchema, restoreMode: PersistedWindowRestoreModeSchema, - // Set only for "fullscreen-origin": the pre-fullscreen frame we reopen at to - // avoid re-entering macOS fullscreen (and its white startup flash). + // Present when the window was in macOS fullscreen: the pre-fullscreen frame + // we reopen at to avoid re-entering fullscreen (and its white startup flash). + // Kept separate from restoreMode so a maximized window that entered + // fullscreen still restores as maximized. fullscreenOriginBounds: Schema.optionalKey(WindowRectangleSchema), }); type PersistedWindowStateDocument = typeof PersistedWindowStateDocument.Type; @@ -195,12 +193,25 @@ export const make = Effect.gen(function* () { return fallback; } const parsed = decoded.value; + const workAreas = getDisplayWorkAreas(); + + if ( + parsed.fullscreenOriginBounds !== undefined && + hasUsableDimensions(parsed.fullscreenOriginBounds) + ) { + const originBounds = sanitizeBounds( + parsed.fullscreenOriginBounds, + defaults.minWidth, + defaults.minHeight, + ); + if (isWindowVisibleEnough(originBounds, workAreas)) { + return { bounds: originBounds, restoreMode: parsed.restoreMode }; + } + } if (!hasUsableDimensions(parsed.normalBounds)) { return fallback; } - - const workAreas = getDisplayWorkAreas(); const normalBounds = sanitizeBounds( parsed.normalBounds, defaults.minWidth, @@ -209,26 +220,16 @@ export const make = Effect.gen(function* () { if (!isWindowVisibleEnough(normalBounds, workAreas)) { return fallback; } - - if (parsed.restoreMode === "fullscreen-origin") { - const originBounds = parsed.fullscreenOriginBounds; - if (originBounds === undefined || !hasUsableDimensions(originBounds)) { - return fallback; - } - const sanitizedOrigin = sanitizeBounds(originBounds, defaults.minWidth, defaults.minHeight); - if (!isWindowVisibleEnough(sanitizedOrigin, workAreas)) { - return fallback; - } - return { bounds: sanitizedOrigin, restoreMode: "fullscreen-origin" }; - } - return { bounds: normalBounds, restoreMode: parsed.restoreMode }; }); + let persistSequence = 0; + const persist = (document: PersistedWindowStateDocument): Effect.Effect => Effect.gen(function* () { const directory = path.dirname(windowStatePath); - const tempPath = `${windowStatePath}.${process.pid}.tmp`; + persistSequence += 1; + const tempPath = `${windowStatePath}.${process.pid}.${persistSequence}.tmp`; const encoded = yield* encodePersistedWindowStateJson(document); yield* fileSystem.makeDirectory(directory, { recursive: true }); yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); @@ -242,16 +243,31 @@ export const make = Effect.gen(function* () { const attach = (window: Electron.BrowserWindow): Effect.Effect => Effect.sync(() => { let debounceFiber: Fiber.Fiber | undefined; + // Saved state is applied at first reveal, so drop persists from a window + // that was never shown — they would clobber good on-disk state with the + // pre-restore defaults (e.g. quit during the connecting splash). + let armed = window.isVisible(); // In fullscreen, getNormalBounds() returns the fullscreen frame, so keep // the last non-fullscreen frame around to persist instead. let lastRestorable: PersistedWindowStateDocument = readRestorableState(window); let lastVisibleBounds: WindowRectangle = window.getBounds(); + // macOS quit-from-fullscreen fires leave-full-screen before close; hold + // the fullscreen snapshot through the exit transition so the close-time + // save doesn't demote it with mid-transition bounds. + let fullscreenExitPending = false; + + if (!armed) { + window.once("show", () => { + armed = true; + lastRestorable = readRestorableState(window); + lastVisibleBounds = window.getBounds(); + }); + } const resolveDocument = (): PersistedWindowStateDocument => { - if (window.isFullScreen()) { + if (window.isFullScreen() || fullscreenExitPending) { return { ...lastRestorable, - restoreMode: "fullscreen-origin", fullscreenOriginBounds: lastVisibleBounds, }; } @@ -271,15 +287,18 @@ export const make = Effect.gen(function* () { runFork(Fiber.interrupt(fiber)); }; - const persistNow = () => { - cancelDebounce(); - runFork(persistEffect); - }; - const schedulePersist = () => { + if (!armed) { + return; + } cancelDebounce(); debounceFiber = runFork( Effect.sleep(WINDOW_STATE_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + fullscreenExitPending = false; + }), + ), Effect.andThen(persistEffect), Effect.ensuring( Effect.sync(() => { @@ -290,13 +309,39 @@ export const make = Effect.gen(function* () { ); }; + const persistNow = () => { + if (!armed) { + return; + } + fullscreenExitPending = false; + cancelDebounce(); + runFork(persistEffect); + }; + + // Unlike persistNow, keeps fullscreenExitPending: a close right after + // leave-full-screen is the quit sequence, and must save the fullscreen + // snapshot rather than the half-transitioned window. + const persistOnClose = () => { + if (!armed) { + return; + } + cancelDebounce(); + runFork(persistEffect); + }; + window.on("resize", schedulePersist); window.on("move", schedulePersist); window.on("maximize", persistNow); window.on("unmaximize", persistNow); window.on("enter-full-screen", persistNow); - window.on("leave-full-screen", persistNow); - window.on("close", persistNow); + window.on("leave-full-screen", () => { + if (!armed) { + return; + } + fullscreenExitPending = true; + schedulePersist(); + }); + window.on("close", persistOnClose); }); return DesktopWindowState.of({ load, attach }); From 6da8a882e30e650d41068e3a721e275a86f5798b Mon Sep 17 00:00:00 2001 From: Rushil Rai <58342835+rushilrai@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:11:12 +0530 Subject: [PATCH 3/5] capture pre-fullscreen bounds when resize lands inside the debounce --- .../src/window/DesktopWindowState.test.ts | 25 +++++++++++++++++++ apps/desktop/src/window/DesktopWindowState.ts | 23 ++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindowState.test.ts b/apps/desktop/src/window/DesktopWindowState.test.ts index e2ae514ef45..c29e79bb63b 100644 --- a/apps/desktop/src/window/DesktopWindowState.test.ts +++ b/apps/desktop/src/window/DesktopWindowState.test.ts @@ -451,6 +451,31 @@ describe("DesktopWindowState.attach", () => { ), ); + it.live("captures a resize that lands inside the debounce window before fullscreen", () => + withWindowState( + Effect.gen(function* () { + const fake = makeFakeWindow({ bounds: { x: 100, y: 100, width: 1000, height: 700 } }); + yield* attachWindow(fake.window); + + // Resize, then enter fullscreen before the 250ms debounced save fires: + // the resized frame must still be captured as the fullscreen origin. + const resized = { x: 60, y: 50, width: 1200, height: 800 }; + fake.state.bounds = resized; + fake.emit("resize"); + fake.state.fullScreen = true; + fake.state.bounds = { x: 0, y: 0, width: 1920, height: 1080 }; + fake.emit("enter-full-screen"); + fake.emit("close"); + + const document = yield* awaitPersistedDocument( + (candidate) => candidate.fullscreenOriginBounds !== undefined, + ); + assert.deepEqual(document.fullscreenOriginBounds, resized); + assert.deepEqual(document.normalBounds, resized); + }), + ), + ); + it.live("close right after leave-full-screen keeps the fullscreen snapshot", () => withWindowState( Effect.gen(function* () { diff --git a/apps/desktop/src/window/DesktopWindowState.ts b/apps/desktop/src/window/DesktopWindowState.ts index 9d9ce367a87..38b6f3a6001 100644 --- a/apps/desktop/src/window/DesktopWindowState.ts +++ b/apps/desktop/src/window/DesktopWindowState.ts @@ -264,6 +264,17 @@ export const make = Effect.gen(function* () { }); } + // Refreshed eagerly on resize/move (not just when a persist runs) so a + // resize followed within the debounce window by fullscreen entry still + // captures the pre-fullscreen frame. + const refreshSnapshots = () => { + if (window.isFullScreen() || fullscreenExitPending) { + return; + } + lastRestorable = readRestorableState(window); + lastVisibleBounds = window.getBounds(); + }; + const resolveDocument = (): PersistedWindowStateDocument => { if (window.isFullScreen() || fullscreenExitPending) { return { @@ -271,8 +282,7 @@ export const make = Effect.gen(function* () { fullscreenOriginBounds: lastVisibleBounds, }; } - lastRestorable = readRestorableState(window); - lastVisibleBounds = window.getBounds(); + refreshSnapshots(); return lastRestorable; }; @@ -329,8 +339,13 @@ export const make = Effect.gen(function* () { runFork(persistEffect); }; - window.on("resize", schedulePersist); - window.on("move", schedulePersist); + const handleBoundsChange = () => { + refreshSnapshots(); + schedulePersist(); + }; + + window.on("resize", handleBoundsChange); + window.on("move", handleBoundsChange); window.on("maximize", persistNow); window.on("unmaximize", persistNow); window.on("enter-full-screen", persistNow); From 1ec93f5faeff7058b375ac54fe6ecf2e9f6666a0 Mon Sep 17 00:00:00 2001 From: Rushil Rai <58342835+rushilrai@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:29:01 +0530 Subject: [PATCH 4/5] write the close-time window-state save synchronously --- .../src/window/DesktopWindowState.test.ts | 6 ++-- apps/desktop/src/window/DesktopWindowState.ts | 30 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindowState.test.ts b/apps/desktop/src/window/DesktopWindowState.test.ts index c29e79bb63b..dc210b157e4 100644 --- a/apps/desktop/src/window/DesktopWindowState.test.ts +++ b/apps/desktop/src/window/DesktopWindowState.test.ts @@ -414,14 +414,16 @@ describe("DesktopWindowState.attach", () => { ), ); - it.live("persists bounds and restore mode on close", () => + it.live("persists bounds and restore mode durably before the close handler returns", () => withWindowState( Effect.gen(function* () { const fake = makeFakeWindow({ bounds: { x: 40, y: 30, width: 1000, height: 700 } }); yield* attachWindow(fake.window); fake.emit("close"); - const document = yield* awaitPersistedDocument(() => true); + // Read immediately, no polling: the close path must write synchronously + // so the save can't race process exit. + const document = yield* readPersistedDocument; assert.deepEqual(document.normalBounds, { x: 40, y: 30, width: 1000, height: 700 }); assert.equal(document.restoreMode, "normal"); assert.isUndefined(document.fullscreenOriginBounds); diff --git a/apps/desktop/src/window/DesktopWindowState.ts b/apps/desktop/src/window/DesktopWindowState.ts index 38b6f3a6001..5d17c71b127 100644 --- a/apps/desktop/src/window/DesktopWindowState.ts +++ b/apps/desktop/src/window/DesktopWindowState.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - the close-time save writes synchronously so it can't race process exit +import * as NodeFS from "node:fs"; + import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -61,6 +64,7 @@ type PersistedWindowStateDocument = typeof PersistedWindowStateDocument.Type; const PersistedWindowStateJson = fromLenientJson(PersistedWindowStateDocument); const decodePersistedWindowStateJson = Schema.decodeEffect(PersistedWindowStateJson); const encodePersistedWindowStateJson = Schema.encodeEffect(PersistedWindowStateJson); +const encodePersistedWindowStateJsonSync = Schema.encodeSync(PersistedWindowStateJson); function isFiniteNumber(value: number): boolean { return Number.isFinite(value); @@ -240,6 +244,27 @@ export const make = Effect.gen(function* () { ), ); + const runSync = Effect.runSyncWith(context); + + // The close-time save must complete before the process can exit, so it + // bypasses the async FileSystem layer and writes synchronously. + const persistSync = (document: PersistedWindowStateDocument): void => { + try { + const encoded = encodePersistedWindowStateJsonSync(document); + persistSequence += 1; + const tempPath = `${windowStatePath}.${process.pid}.${persistSequence}.tmp`; + NodeFS.mkdirSync(path.dirname(windowStatePath), { recursive: true }); + NodeFS.writeFileSync(tempPath, `${encoded}\n`, "utf8"); + NodeFS.renameSync(tempPath, windowStatePath); + } catch (error) { + try { + runSync(logWarning("failed to persist window state on close", { error: String(error) })); + } catch { + // logging is best-effort during teardown + } + } + }; + const attach = (window: Electron.BrowserWindow): Effect.Effect => Effect.sync(() => { let debounceFiber: Fiber.Fiber | undefined; @@ -330,13 +355,14 @@ export const make = Effect.gen(function* () { // Unlike persistNow, keeps fullscreenExitPending: a close right after // leave-full-screen is the quit sequence, and must save the fullscreen - // snapshot rather than the half-transitioned window. + // snapshot rather than the half-transitioned window. Written + // synchronously so the save can't race process exit. const persistOnClose = () => { if (!armed) { return; } cancelDebounce(); - runFork(persistEffect); + persistSync(resolveDocument()); }; const handleBoundsChange = () => { From 669df61d32180eb698065b10399d038e2df4e4fa Mon Sep 17 00:00:00 2001 From: Rushil Rai <58342835+rushilrai@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:04:36 +0530 Subject: [PATCH 5/5] serialize window-state writes and close races --- .../src/window/DesktopWindowState.test.ts | 103 +++++++++++++++--- apps/desktop/src/window/DesktopWindowState.ts | 84 +++++++------- 2 files changed, 126 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindowState.test.ts b/apps/desktop/src/window/DesktopWindowState.test.ts index dc210b157e4..5b6eff7328a 100644 --- a/apps/desktop/src/window/DesktopWindowState.test.ts +++ b/apps/desktop/src/window/DesktopWindowState.test.ts @@ -9,8 +9,7 @@ import * as Schema from "effect/Schema"; import type * as Electron from "electron"; import { vi } from "vite-plus/test"; -// Pin a single 1920x1080 display so the off-screen check is deterministic. -// Inlined because vi.mock factories are hoisted above module-level bindings. +// Single 1920x1080 display; inlined because vi.mock factories are hoisted. vi.mock("electron", () => ({ screen: { getPrimaryDisplay: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), @@ -114,6 +113,7 @@ const loadResolved = Effect.gen(function* () { interface FakeWindowState { bounds: DesktopWindowState.WindowRectangle; maximized: boolean; + minimized: boolean; fullScreen: boolean; visible: boolean; } @@ -122,6 +122,7 @@ function makeFakeWindow(initial: Partial = {}) { const state: FakeWindowState = { bounds: { x: 100, y: 100, width: 1200, height: 800 }, maximized: false, + minimized: false, fullScreen: false, visible: true, ...initial, @@ -144,13 +145,14 @@ function makeFakeWindow(initial: Partial = {}) { }, isVisible: () => state.visible, isFullScreen: () => state.fullScreen, + isMinimized: () => state.minimized, isMaximized: () => state.maximized, getBounds: () => state.bounds, getNormalBounds: () => state.bounds, isDestroyed: () => false, }; const emit = (event: string) => { - // Copy so once-listeners that remove themselves don't skip the next entry. + // Copy: once-listeners remove themselves mid-iteration. for (const listener of (listeners.get(event) ?? []).slice()) { listener(); } @@ -328,18 +330,20 @@ describe("DesktopWindowState.load", () => { ), ); - it.effect("restores the fullscreen origin frame with the underlying restore mode", () => + it.effect("restores a maximized fullscreen session at its normal bounds", () => withWindowState( Effect.gen(function* () { + // Origin bounds must not become the creation bounds here, or they would + // replace the real normal bounds once the window re-maximizes. yield* writeWindowStateDocument({ version: 1, - normalBounds: { x: 0, y: 0, width: 1400, height: 900 }, + normalBounds: { x: 10, y: 20, width: 1400, height: 900 }, restoreMode: "maximized", - fullscreenOriginBounds: { x: 60, y: 50, width: 1200, height: 800 }, + fullscreenOriginBounds: { x: 0, y: 0, width: 1920, height: 1080 }, }); const resolved = yield* loadResolved; assert.deepEqual(resolved, { - bounds: { x: 60, y: 50, width: 1200, height: 800 }, + bounds: { x: 10, y: 20, width: 1400, height: 900 }, restoreMode: "maximized", }); }), @@ -391,8 +395,7 @@ describe("DesktopWindowState.load", () => { ); }); -// Persists are forked from window-event callbacks and the debounce uses the -// wall clock, so these run under it.live and poll the state file. +// The debounce uses the wall clock, so these run under it.live. describe("DesktopWindowState.attach", () => { it.live("drops persist events fired before the window is first shown", () => withWindowState( @@ -421,8 +424,7 @@ describe("DesktopWindowState.attach", () => { yield* attachWindow(fake.window); fake.emit("close"); - // Read immediately, no polling: the close path must write synchronously - // so the save can't race process exit. + // No polling: the close save must be synchronous. const document = yield* readPersistedDocument; assert.deepEqual(document.normalBounds, { x: 40, y: 30, width: 1000, height: 700 }); assert.equal(document.restoreMode, "normal"); @@ -459,8 +461,8 @@ describe("DesktopWindowState.attach", () => { const fake = makeFakeWindow({ bounds: { x: 100, y: 100, width: 1000, height: 700 } }); yield* attachWindow(fake.window); - // Resize, then enter fullscreen before the 250ms debounced save fires: - // the resized frame must still be captured as the fullscreen origin. + // Fullscreen entry before the debounce fires must still capture the + // resized frame as the origin. const resized = { x: 60, y: 50, width: 1200, height: 800 }; fake.state.bounds = resized; fake.emit("resize"); @@ -489,8 +491,7 @@ describe("DesktopWindowState.attach", () => { fake.state.bounds = { x: 0, y: 0, width: 1920, height: 1080 }; fake.emit("enter-full-screen"); - // macOS quit sequence: exit transition starts, close lands before the - // debounced post-exit save. + // Quit sequence: close lands before the debounced post-exit save. fake.state.fullScreen = false; fake.state.bounds = { x: 12, y: 8, width: 1740, height: 1002 }; fake.emit("leave-full-screen"); @@ -507,6 +508,78 @@ describe("DesktopWindowState.attach", () => { ), ); + it.live("keeps the fullscreen snapshot through exit-transition resizes", () => + withWindowState( + Effect.gen(function* () { + const preFullscreen = { x: 60, y: 50, width: 1200, height: 800 }; + const fake = makeFakeWindow({ bounds: preFullscreen }); + yield* attachWindow(fake.window); + + fake.state.fullScreen = true; + fake.state.bounds = { x: 0, y: 0, width: 1920, height: 1080 }; + fake.emit("enter-full-screen"); + + // Quit sequence with the macOS exit animation emitting resize/move + // before close lands: none of it may demote the snapshot. + fake.state.fullScreen = false; + fake.emit("leave-full-screen"); + fake.state.bounds = { x: 30, y: 25, width: 1560, height: 940 }; + fake.emit("resize"); + fake.emit("move"); + fake.emit("close"); + + yield* Effect.sleep(350); + const settled = yield* readPersistedDocument; + assert.deepEqual(settled.fullscreenOriginBounds, preFullscreen); + assert.deepEqual(settled.normalBounds, preFullscreen); + }), + ), + ); + + it.live("does not demote a maximized window persisted while minimized", () => + withWindowState( + Effect.gen(function* () { + const frame = { x: 40, y: 30, width: 1000, height: 700 }; + const fake = makeFakeWindow({ bounds: frame, maximized: true }); + yield* attachWindow(fake.window); + fake.emit("maximize"); + + // Windows: isMaximized() is false while minimized and the window is + // parked off-screen; neither may reach the saved state. + fake.state.minimized = true; + fake.state.maximized = false; + fake.state.bounds = { x: -32_000, y: -32_000, width: 160, height: 28 }; + fake.emit("move"); + fake.emit("close"); + + yield* Effect.sleep(350); + const settled = yield* readPersistedDocument; + assert.equal(settled.restoreMode, "maximized"); + assert.deepEqual(settled.normalBounds, frame); + }), + ), + ); + + it.live("an immediate save is never overwritten by an earlier pending one", () => + withWindowState( + Effect.gen(function* () { + const fake = makeFakeWindow({ bounds: { x: 100, y: 100, width: 1000, height: 700 } }); + yield* attachWindow(fake.window); + + // A pending debounced resize save must not overwrite the maximize save. + fake.emit("resize"); + fake.state.maximized = true; + fake.emit("maximize"); + + const immediate = yield* readPersistedDocument; + assert.equal(immediate.restoreMode, "maximized"); + yield* Effect.sleep(350); + const settled = yield* readPersistedDocument; + assert.equal(settled.restoreMode, "maximized"); + }), + ), + ); + it.live("a window that stays open after leaving fullscreen persists its live state", () => withWindowState( Effect.gen(function* () { diff --git a/apps/desktop/src/window/DesktopWindowState.ts b/apps/desktop/src/window/DesktopWindowState.ts index 5d17c71b127..091a83efaeb 100644 --- a/apps/desktop/src/window/DesktopWindowState.ts +++ b/apps/desktop/src/window/DesktopWindowState.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off - the close-time save writes synchronously so it can't race process exit +// @effect-diagnostics-next-line nodeBuiltinImport:off - sync writes keep saves ordered and durable at exit import * as NodeFS from "node:fs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -53,17 +53,14 @@ const PersistedWindowStateDocument = Schema.Struct({ version: Schema.Literal(WINDOW_STATE_VERSION), normalBounds: WindowRectangleSchema, restoreMode: PersistedWindowRestoreModeSchema, - // Present when the window was in macOS fullscreen: the pre-fullscreen frame - // we reopen at to avoid re-entering fullscreen (and its white startup flash). - // Kept separate from restoreMode so a maximized window that entered - // fullscreen still restores as maximized. + // Pre-fullscreen frame to reopen at without re-entering fullscreen. Separate + // from restoreMode so a maximized window survives a fullscreen round trip. fullscreenOriginBounds: Schema.optionalKey(WindowRectangleSchema), }); type PersistedWindowStateDocument = typeof PersistedWindowStateDocument.Type; const PersistedWindowStateJson = fromLenientJson(PersistedWindowStateDocument); const decodePersistedWindowStateJson = Schema.decodeEffect(PersistedWindowStateJson); -const encodePersistedWindowStateJson = Schema.encodeEffect(PersistedWindowStateJson); const encodePersistedWindowStateJsonSync = Schema.encodeSync(PersistedWindowStateJson); function isFiniteNumber(value: number): boolean { @@ -199,7 +196,11 @@ export const make = Effect.gen(function* () { const parsed = decoded.value; const workAreas = getDisplayWorkAreas(); + // Origin bounds only drive normal restores: a maximized session opens at + // its normalBounds and re-maximizes, otherwise the fullscreen frame would + // replace the real normal bounds and unmaximize would stop shrinking. if ( + parsed.restoreMode === "normal" && parsed.fullscreenOriginBounds !== undefined && hasUsableDimensions(parsed.fullscreenOriginBounds) ) { @@ -229,25 +230,10 @@ export const make = Effect.gen(function* () { let persistSequence = 0; - const persist = (document: PersistedWindowStateDocument): Effect.Effect => - Effect.gen(function* () { - const directory = path.dirname(windowStatePath); - persistSequence += 1; - const tempPath = `${windowStatePath}.${process.pid}.${persistSequence}.tmp`; - const encoded = yield* encodePersistedWindowStateJson(document); - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* fileSystem.rename(tempPath, windowStatePath); - }).pipe( - Effect.catch((error) => - logWarning("failed to persist window state", { error: error.message }), - ), - ); - const runSync = Effect.runSyncWith(context); - // The close-time save must complete before the process can exit, so it - // bypasses the async FileSystem layer and writes synchronously. + // Single writer: synchronous writes are totally ordered on the main thread + // (a slow save can't overwrite a newer one) and durable before process exit. const persistSync = (document: PersistedWindowStateDocument): void => { try { const encoded = encodePersistedWindowStateJsonSync(document); @@ -258,7 +244,7 @@ export const make = Effect.gen(function* () { NodeFS.renameSync(tempPath, windowStatePath); } catch (error) { try { - runSync(logWarning("failed to persist window state on close", { error: String(error) })); + runSync(logWarning("failed to persist window state", { error: String(error) })); } catch { // logging is best-effort during teardown } @@ -268,17 +254,15 @@ export const make = Effect.gen(function* () { const attach = (window: Electron.BrowserWindow): Effect.Effect => Effect.sync(() => { let debounceFiber: Fiber.Fiber | undefined; - // Saved state is applied at first reveal, so drop persists from a window - // that was never shown — they would clobber good on-disk state with the - // pre-restore defaults (e.g. quit during the connecting splash). + // Saved state is applied at first reveal — drop persists from a + // never-shown window so they can't clobber good on-disk state. let armed = window.isVisible(); // In fullscreen, getNormalBounds() returns the fullscreen frame, so keep // the last non-fullscreen frame around to persist instead. let lastRestorable: PersistedWindowStateDocument = readRestorableState(window); let lastVisibleBounds: WindowRectangle = window.getBounds(); - // macOS quit-from-fullscreen fires leave-full-screen before close; hold - // the fullscreen snapshot through the exit transition so the close-time - // save doesn't demote it with mid-transition bounds. + // macOS quit-from-fullscreen fires leave-full-screen before close; keep + // the fullscreen snapshot so close doesn't save mid-transition bounds. let fullscreenExitPending = false; if (!armed) { @@ -289,11 +273,12 @@ export const make = Effect.gen(function* () { }); } - // Refreshed eagerly on resize/move (not just when a persist runs) so a - // resize followed within the debounce window by fullscreen entry still - // captures the pre-fullscreen frame. + // Sampled eagerly on resize/move so fullscreen entry inside the debounce + // window still captures the pre-fullscreen frame. Skipped while minimized: + // isMaximized() reports false there (Windows) and bounds are parked + // off-screen, so sampling would demote the saved state. const refreshSnapshots = () => { - if (window.isFullScreen() || fullscreenExitPending) { + if (window.isFullScreen() || window.isMinimized() || fullscreenExitPending) { return; } lastRestorable = readRestorableState(window); @@ -311,8 +296,6 @@ export const make = Effect.gen(function* () { return lastRestorable; }; - const persistEffect = Effect.suspend(() => persist(resolveDocument())); - const cancelDebounce = () => { if (debounceFiber === undefined) { return; @@ -327,38 +310,44 @@ export const make = Effect.gen(function* () { return; } cancelDebounce(); - debounceFiber = runFork( + const fiber = runFork( Effect.sleep(WINDOW_STATE_PERSIST_DEBOUNCE_MS).pipe( Effect.andThen( Effect.sync(() => { + // Interruption is async — a cancelled fiber can fire after close. + if (window.isDestroyed()) { + return; + } fullscreenExitPending = false; + persistSync(resolveDocument()); }), ), - Effect.andThen(persistEffect), Effect.ensuring( Effect.sync(() => { - debounceFiber = undefined; + // Interrupts finalize late — don't clear a newer fiber's slot. + if (debounceFiber === fiber) { + debounceFiber = undefined; + } }), ), ), ); + debounceFiber = fiber; }; const persistNow = () => { - if (!armed) { + if (!armed || window.isDestroyed()) { return; } fullscreenExitPending = false; cancelDebounce(); - runFork(persistEffect); + persistSync(resolveDocument()); }; - // Unlike persistNow, keeps fullscreenExitPending: a close right after - // leave-full-screen is the quit sequence, and must save the fullscreen - // snapshot rather than the half-transitioned window. Written - // synchronously so the save can't race process exit. + // Keeps fullscreenExitPending (unlike persistNow): close right after + // leave-full-screen is the quit sequence — save the fullscreen snapshot. const persistOnClose = () => { - if (!armed) { + if (!armed || window.isDestroyed()) { return; } cancelDebounce(); @@ -366,6 +355,9 @@ export const make = Effect.gen(function* () { }; const handleBoundsChange = () => { + if (!armed || window.isDestroyed()) { + return; + } refreshSnapshots(); schedulePersist(); };