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);