From 93dfb6314d0f6a3f0ab25aabf8adefa10e4253de Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 14:06:25 +0530 Subject: [PATCH 1/4] fix(desktop): preserve main window bounds --- .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/settings/DesktopAppSettings.test.ts | 35 +++++ .../src/settings/DesktopAppSettings.ts | 66 ++++++++ .../src/updates/DesktopUpdates.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 141 +++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 112 +++++++++++++- 6 files changed, 352 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1a107c5c856..dcfee93778d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -250,6 +250,7 @@ describe("DesktopServerExposure", () => { const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 70b26798266..13960d07982 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,16 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + mainWindowBounds: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), + ), + ), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -91,6 +101,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -116,6 +127,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -215,10 +227,12 @@ describe("DesktopSettings", () => { "serverExposureMode": "network-accessible", "tailscaleServeEnabled": true, "tailscaleServePort": 8443, + "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 }, }\n`, ); assert.deepEqual(yield* settings.load, { + mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -232,6 +246,22 @@ describe("DesktopSettings", () => { ), ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, + serverExposureMode: "network-accessible", + }); + + const loaded = yield* settings.load; + assert.isNull(loaded.mainWindowBounds); + assert.equal(loaded.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -239,12 +269,14 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); }), @@ -261,6 +293,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -286,6 +319,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -310,6 +344,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6a26bf5a6e2..b4262eee484 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -20,6 +20,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly mainWindowBounds: DesktopWindowBounds | null; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -48,8 +49,26 @@ export interface DesktopSettingsChange { } export const DEFAULT_TAILSCALE_SERVE_PORT = 443; +const MIN_MAIN_WINDOW_SIZE = { + width: 840, + height: 620, +} as const; +export const DesktopWindowBoundsSchema = Schema.Struct({ + x: Schema.Int, + y: Schema.Int, + width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.width)), + height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), +}); +export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; +export const DEFAULT_MAIN_WINDOW_BOUNDS: DesktopWindowBounds = { + x: 0, + y: 0, + width: 1100, + height: 780, +}; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + mainWindowBounds: null, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -60,7 +79,15 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslOnly: false, }; +const DesktopWindowBoundsDocument = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + const DesktopSettingsDocument = Schema.Struct({ + mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -81,6 +108,8 @@ type Mutable = { -readonly [K in keyof T]: T[K] }; const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); +const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); +const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -114,6 +143,9 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setMainWindowBounds: ( + bounds: DesktopWindowBounds, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -158,6 +190,10 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { + return Option.getOrNull(decodeDesktopWindowBounds(value)); +} + function normalizeDesktopSettingsDocument( parsed: DesktopSettingsDocument, appVersion: string, @@ -177,6 +213,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + mainWindowBounds: normalizeMainWindowBounds(parsed.mainWindowBounds), serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -197,6 +234,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.mainWindowBounds !== null) { + document.mainWindowBounds = settings.mainWindowBounds; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } @@ -237,6 +277,19 @@ function setServerExposureMode( }; } +function setMainWindowBounds( + settings: DesktopSettings, + bounds: DesktopWindowBounds, +): DesktopSettings { + return settings.mainWindowBounds !== null && + desktopWindowBoundsEquivalence(settings.mainWindowBounds, bounds) + ? settings + : { + ...settings, + mainWindowBounds: bounds, + }; +} + function setTailscaleServe( settings: DesktopSettings, input: { readonly enabled: boolean; readonly port: Option.Option }, @@ -431,6 +484,17 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), + setMainWindowBounds: (bounds) => + persist((settings) => setMainWindowBounds(settings, bounds)).pipe( + Effect.withSpan("desktop.settings.setMainWindowBounds", { + attributes: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }, + }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -488,6 +552,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), + setMainWindowBounds: (bounds) => + update((settings) => setMainWindowBounds(settings, bounds)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506..6521e671e70 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -153,6 +153,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..c96ee99b501 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -18,12 +19,20 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, + screen: { + getAllDisplays: vi.fn(() => [ + { + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + }, + ]), + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -47,6 +56,7 @@ const environmentInput = { function makeFakeBrowserWindow() { const webContentsListeners = new Map void>(); + const windowListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), @@ -65,11 +75,17 @@ function makeFakeBrowserWindow() { const window = { close: vi.fn(), focus: vi.fn(), + getBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), + getNormalBounds: vi.fn(() => ({ x: 0, y: 0, width: 1100, height: 780 })), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -83,11 +99,13 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, + getBounds: window.getBounds, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -139,13 +157,47 @@ const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( ), ); +const desktopWindowBoundsEquivalence = Schema.toEquivalence( + DesktopAppSettings.DesktopWindowBoundsSchema, +); + function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; + readonly desktopSettings?: DesktopAppSettings.DesktopSettings; + readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; readonly openedExternalUrls?: unknown[]; }) { + let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; + const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => desktopSettings), + load: Effect.sync(() => desktopSettings), + setMainWindowBounds: (bounds) => + Effect.sync(() => { + const changed = + desktopSettings.mainWindowBounds === null || + !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds); + if (changed) { + desktopSettings = { + ...desktopSettings, + mainWindowBounds: bounds, + }; + input.mainWindowBoundsUpdates?.push(bounds); + } + return { settings: desktopSettings, changed }; + }), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: (options) => Effect.sync(() => { @@ -170,6 +222,7 @@ function makeTestLayer(input: { Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + desktopAppSettingsLayer, desktopServerExposureLayer, DesktopState.layer, electronMenuLayer, @@ -267,6 +320,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.mergeAll( desktopAssetsLayer, desktopEnvironmentLayer, + DesktopAppSettings.layerTest(), desktopServerExposureLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { @@ -289,6 +343,23 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }); describe("DesktopWindow", () => { + it("restores bounds only when the window fits within a connected display", () => { + const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; + const displays = [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 1920, y: 0, width: 2560, height: 1440 }, + ]; + + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, displays), + persistedBounds, + ); + assert.deepEqual( + DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), + DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS, + ); + }); + it("recognizes only same-origin renderer navigations", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ @@ -330,6 +401,8 @@ describe("DesktopWindow", () => { yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -338,6 +411,72 @@ describe("DesktopWindow", () => { }), ); + it.effect("uses the persisted main window bounds when opening the window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, + }, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + assert.equal(createdWindowOptions[0]?.width, 1320); + assert.equal(createdWindowOptions[0]?.height, 880); + assert.equal(createdWindowOptions[0]?.x, 120); + assert.equal(createdWindowOptions[0]?.y, 80); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists the current main window bounds before the window closes", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.getBounds.mockReturnValue({ x: 240, y: 160, width: 1410, height: 930 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [ + { + x: 240, + y: 160, + width: 1410, + height: 930, + }, + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..a9fbab69efd 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -17,11 +17,13 @@ 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 DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -41,6 +43,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets + | DesktopAppSettings.DesktopAppSettings | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -99,6 +102,33 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +type DisplayBounds = Pick; + +function windowFitsWithinDisplay( + windowBounds: DesktopAppSettings.DesktopWindowBounds, + displayBounds: DisplayBounds, +): boolean { + return ( + windowBounds.x >= displayBounds.x && + windowBounds.y >= displayBounds.y && + windowBounds.x + windowBounds.width <= displayBounds.x + displayBounds.width && + windowBounds.y + windowBounds.height <= displayBounds.y + displayBounds.height + ); +} + +export function resolveInitialMainWindowBounds( + persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, + displays: readonly DisplayBounds[], +): DesktopAppSettings.DesktopWindowBounds { + if ( + persistedBounds !== null && + displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) + ) { + return persistedBounds; + } + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS; +} + // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only // mode while the WSL backend (which serves the renderer) cold-boots. Inlined as // a data URL so it needs no bundled asset and no backend — pure CSS, no JS. @@ -202,6 +232,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; // 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,9 +281,23 @@ export const make = Effect.gen(function* () { const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const persistedBounds = (yield* desktopSettings.get).mainWindowBounds; + const displayBounds = yield* Effect.sync(() => { + try { + return Electron.screen.getAllDisplays().map((display) => display.bounds); + } catch { + return []; + } + }); + const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); + if ( + persistedBounds !== null && + initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS + ) { + yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); + } const window = yield* electronWindow.create({ - width: 1100, - height: 780, + ...initialBounds, minWidth: 840, minHeight: 620, show: false, @@ -275,6 +320,60 @@ export const make = Effect.gen(function* () { window.setAutoHideCursor(false); } + let boundsPersistFiber: Fiber.Fiber | undefined; + const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + if (window.isDestroyed() || window.isFullScreen()) { + return null; + } + const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds(); + const x = Math.round(bounds.x); + const y = Math.round(bounds.y); + const width = Math.round(bounds.width); + const height = Math.round(bounds.height); + return { x, y, width, height }; + }; + const persistCurrentBounds = () => { + const bounds = readPersistableBounds(); + if (bounds === null) { + return; + } + void runPromise( + desktopSettings.setMainWindowBounds(bounds).pipe( + Effect.asVoid, + Effect.catch((error) => + logWindowWarning("failed to persist main window bounds", { + message: error.message, + }), + ), + ), + ); + }; + const scheduleBoundsPersist = () => { + if (boundsPersistFiber !== undefined) { + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + } + boundsPersistFiber = runFork( + Effect.sleep(MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + boundsPersistFiber = undefined; + persistCurrentBounds(); + }), + ), + ), + ); + }; + const clearBoundsPersist = () => { + if (boundsPersistFiber === undefined) { + return; + } + const fiber = boundsPersistFiber; + boundsPersistFiber = undefined; + runFork(Fiber.interrupt(fiber)); + }; + yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( @@ -364,6 +463,12 @@ export const make = Effect.gen(function* () { event.preventDefault(); window.setTitle(environment.displayName); }); + window.on("resize", scheduleBoundsPersist); + window.on("move", scheduleBoundsPersist); + window.on("close", () => { + clearBoundsPersist(); + persistCurrentBounds(); + }); let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; @@ -473,6 +578,7 @@ export const make = Effect.gen(function* () { window.on("closed", () => { clearDevelopmentLoadRetry(); + clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); }); From 271e8bb40ca5c3bdab79f52cb767212835564b98 Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 15:08:02 +0530 Subject: [PATCH 2/4] fix(desktop): refine window bounds fallback --- .../src/settings/DesktopAppSettings.ts | 6 +- apps/desktop/src/window/DesktopWindow.test.ts | 166 +++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 26 +-- 3 files changed, 181 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index b4262eee484..c518db61937 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -60,12 +60,10 @@ export const DesktopWindowBoundsSchema = Schema.Struct({ height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(MIN_MAIN_WINDOW_SIZE.height)), }); export type DesktopWindowBounds = typeof DesktopWindowBoundsSchema.Type; -export const DEFAULT_MAIN_WINDOW_BOUNDS: DesktopWindowBounds = { - x: 0, - y: 0, +export const DEFAULT_MAIN_WINDOW_SIZE = { width: 1100, height: 780, -}; +} as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { mainWindowBounds: null, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c96ee99b501..cdad83b8d89 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -2,12 +2,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import type * as Electron from "electron"; +import * as Electron from "electron"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -98,8 +100,11 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, - loadURL: window.loadURL, getBounds: window.getBounds, + getNormalBounds: window.getNormalBounds, + isFullScreen: window.isFullScreen, + isMaximized: window.isMaximized, + loadURL: window.loadURL, openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, @@ -356,7 +361,7 @@ describe("DesktopWindow", () => { ); assert.deepEqual( DesktopWindow.resolveInitialMainWindowBounds(persistedBounds, [displays[0]!]), - DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS, + DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE, ); }); @@ -403,6 +408,8 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(createCount), 1); assert.equal(createdWindowOptions[0]?.width, 1100); assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); @@ -440,6 +447,159 @@ describe("DesktopWindow", () => { }), ); + it.effect("debounces move and resize bounds updates", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!move || !resize) { + return yield* Effect.die("window bounds listeners were not registered"); + } + + fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); + move(); + yield* TestClock.adjust(250); + + fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 1360, height: 900 }); + resize(); + yield* TestClock.adjust(499); + assert.deepEqual(mainWindowBoundsUpdates, []); + + yield* TestClock.adjust(1); + yield* Effect.promise(() => Promise.resolve()); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 1360, height: 900 }]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("persists normal bounds for a maximized window", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not persist fullscreen bounds", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isFullScreen.mockReturnValue(true); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* Effect.promise(() => Promise.resolve()); + + assert.deepEqual(mainWindowBoundsUpdates, []); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("logs display lookup failures before falling back to the default size", () => + Effect.gen(function* () { + const displayLookupFailure = new Error("screen API unavailable"); + vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { + throw displayLookupFailure; + }); + const logRecords: Array<{ + readonly message: unknown; + readonly annotations: Readonly>; + }> = []; + const logger = Logger.make(({ fiber, message }) => { + logRecords.push({ + message, + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + createdWindowOptions, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + }).pipe( + Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), + ); + + const warning = logRecords.find( + (record) => + Array.isArray(record.message) && + record.message[0] === "failed to read connected displays; using defaults", + ); + assert.isDefined(warning); + assert.strictEqual(warning.annotations.cause, displayLookupFailure); + assert.equal(createdWindowOptions[0]?.width, 1100); + assert.equal(createdWindowOptions[0]?.height, 780); + assert.isUndefined(createdWindowOptions[0]?.x); + assert.isUndefined(createdWindowOptions[0]?.y); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index a9fbab69efd..7eb749c53ba 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -119,14 +119,14 @@ function windowFitsWithinDisplay( export function resolveInitialMainWindowBounds( persistedBounds: DesktopAppSettings.DesktopWindowBounds | null, displays: readonly DisplayBounds[], -): DesktopAppSettings.DesktopWindowBounds { +): DesktopAppSettings.DesktopWindowBounds | typeof DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE { if ( persistedBounds !== null && displays.some((display) => windowFitsWithinDisplay(persistedBounds, display)) ) { return persistedBounds; } - return DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS; + return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } // A self-contained "Connecting to WSL" splash, shown immediately in wsl-only @@ -282,18 +282,24 @@ export const make = Effect.gen(function* () { const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const persistedBounds = (yield* desktopSettings.get).mainWindowBounds; - const displayBounds = yield* Effect.sync(() => { + const displayBoundsResult = yield* Effect.sync(() => { try { - return Electron.screen.getAllDisplays().map((display) => display.bounds); - } catch { - return []; + return { + _tag: "Success" as const, + bounds: Electron.screen.getAllDisplays().map((display) => display.bounds), + }; + } catch (cause) { + return { _tag: "Failure" as const, cause }; } }); + const displayBounds = + displayBoundsResult._tag === "Success" + ? displayBoundsResult.bounds + : yield* logWindowWarning("failed to read connected displays; using defaults", { + cause: displayBoundsResult.cause, + }).pipe(Effect.as([])); const initialBounds = resolveInitialMainWindowBounds(persistedBounds, displayBounds); - if ( - persistedBounds !== null && - initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_BOUNDS - ) { + if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } const window = yield* electronWindow.create({ From 3e81c69d92cb48fc5a8d48ade9127899ef4bee21 Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 16:28:11 +0530 Subject: [PATCH 3/4] fix(desktop): handle bounds persistence on shutdown --- apps/desktop/src/app/DesktopLifecycle.ts | 8 ++- .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 67 ++++++++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 29 ++++++-- 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index c5264332b66..f8e05915718 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -73,8 +73,14 @@ function addScopedListener>( } const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( - function* (): Effect.fn.Return { + function* (): Effect.fn.Return< + void, + never, + DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow + > { const shutdown = yield* DesktopShutdown.DesktopShutdown; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; }, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5e6a3f5164d..fa0811d5df7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -76,6 +76,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ba77292fdc8..168846466ed 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -76,6 +76,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cdad83b8d89..e2e89563d5a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; @@ -102,8 +104,10 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, + isDestroyed: window.isDestroyed, isFullScreen: window.isFullScreen, isMaximized: window.isMaximized, + isMinimized: window.isMinimized, loadURL: window.loadURL, openDevTools: webContents.openDevTools, reload: webContents.reload, @@ -173,6 +177,9 @@ function makeTestLayer(input: { readonly createdWindowOptions?: Electron.BrowserWindowConstructorOptions[]; readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; + readonly beforeMainWindowBoundsUpdate?: ( + bounds: DesktopAppSettings.DesktopWindowBounds, + ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; @@ -180,7 +187,10 @@ function makeTestLayer(input: { get: Effect.sync(() => desktopSettings), load: Effect.sync(() => desktopSettings), setMainWindowBounds: (bounds) => - Effect.sync(() => { + Effect.gen(function* () { + if (input.beforeMainWindowBoundsUpdate) { + yield* input.beforeMainWindowBoundsUpdate(bounds); + } const changed = desktopSettings.mainWindowBounds === null || !desktopWindowBoundsEquivalence(desktopSettings.mainWindowBounds, bounds); @@ -552,6 +562,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("does not persist minimized window bounds", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMinimized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + close(); + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, []); + assert.equal(fakeWindow.getBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("logs display lookup failures before falling back to the default size", () => Effect.gen(function* () { const displayLookupFailure = new Error("screen API unavailable"); @@ -607,11 +650,19 @@ describe("DesktopWindow", () => { const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const writeStarted = yield* Deferred.make(); + const allowWrite = yield* Deferred.make(); + const flushCompleted = yield* Deferred.make(); const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow, mainWindowBoundsUpdates, + beforeMainWindowBoundsUpdate: () => + Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowWrite)), + Effect.asVoid, + ), }); yield* Effect.gen(function* () { @@ -623,7 +674,19 @@ describe("DesktopWindow", () => { return yield* Effect.die("window close listener was not registered"); } close(); - yield* Effect.promise(() => Promise.resolve()); + yield* Deferred.await(writeStarted); + fakeWindow.isDestroyed.mockReturnValue(true); + + const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( + Effect.andThen(Deferred.succeed(flushCompleted, undefined)), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + assert.isFalse(yield* Deferred.isDone(flushCompleted)); + + yield* Deferred.succeed(allowWrite, undefined); + yield* Fiber.join(flushFiber); + assert.isTrue(yield* Deferred.isDone(flushCompleted)); assert.deepEqual(mainWindowBoundsUpdates, [ { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7eb749c53ba..2e98d0e7204 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -78,6 +78,7 @@ export class DesktopWindow extends Context.Service< // window so a "macOS dock click" while the backend is down doesn't // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; + readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -245,6 +246,7 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); @@ -327,8 +329,9 @@ export const make = Effect.gen(function* () { } let boundsPersistFiber: Fiber.Fiber | undefined; + let pendingBoundsPersistFiber: Fiber.Fiber | undefined; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { - if (window.isDestroyed() || window.isFullScreen()) { + if (window.isDestroyed() || window.isFullScreen() || window.isMinimized()) { return null; } const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds(); @@ -338,12 +341,12 @@ export const make = Effect.gen(function* () { const height = Math.round(bounds.height); return { x, y, width, height }; }; - const persistCurrentBounds = () => { + const persistCurrentBounds = (): Fiber.Fiber | undefined => { const bounds = readPersistableBounds(); if (bounds === null) { - return; + return pendingBoundsPersistFiber; } - void runPromise( + pendingBoundsPersistFiber = runFork( desktopSettings.setMainWindowBounds(bounds).pipe( Effect.asVoid, Effect.catch((error) => @@ -353,6 +356,7 @@ export const make = Effect.gen(function* () { ), ), ); + return pendingBoundsPersistFiber; }; const scheduleBoundsPersist = () => { if (boundsPersistFiber !== undefined) { @@ -365,7 +369,7 @@ export const make = Effect.gen(function* () { Effect.andThen( Effect.sync(() => { boundsPersistFiber = undefined; - persistCurrentBounds(); + void persistCurrentBounds(); }), ), ), @@ -379,6 +383,15 @@ export const make = Effect.gen(function* () { boundsPersistFiber = undefined; runFork(Fiber.interrupt(fiber)); }; + const flushBoundsPersist = Effect.sync(() => { + clearBoundsPersist(); + return persistCurrentBounds(); + }).pipe( + Effect.flatMap((fiber) => + fiber === undefined ? Effect.void : Fiber.join(fiber).pipe(Effect.asVoid), + ), + ); + flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -472,8 +485,7 @@ export const make = Effect.gen(function* () { window.on("resize", scheduleBoundsPersist); window.on("move", scheduleBoundsPersist); window.on("close", () => { - clearBoundsPersist(); - persistCurrentBounds(); + runFork(flushBoundsPersist); }); let developmentLoadRetryIndex = 0; @@ -701,6 +713,9 @@ export const make = Effect.gen(function* () { handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), + flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( + Effect.withSpan("desktop.window.flushMainWindowBounds"), + ), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; From b11feec4abf8a69c1dfcf66907963de4e6ff836c Mon Sep 17 00:00:00 2001 From: Anirudh Coontoor Date: Fri, 10 Jul 2026 17:14:15 +0530 Subject: [PATCH 4/4] fix(desktop): preserve minimized window bounds --- apps/desktop/src/window/DesktopWindow.test.ts | 19 +++++++++++-------- apps/desktop/src/window/DesktopWindow.ts | 7 +++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e2e89563d5a..e4e64b0eeb4 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -562,11 +562,11 @@ describe("DesktopWindow", () => { }), ); - it.effect("does not persist minimized window bounds", () => + it.effect("flushes normal bounds when minimized before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.isMinimized.mockReturnValue(true); fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; @@ -581,16 +581,19 @@ describe("DesktopWindow", () => { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const close = fakeWindow.windowListeners.get("close"); - if (!close) { - return yield* Effect.die("window close listener was not registered"); + const resize = fakeWindow.windowListeners.get("resize"); + if (!resize) { + return yield* Effect.die("window resize listener was not registered"); } - close(); + resize(); + yield* TestClock.adjust(250); + fakeWindow.isMinimized.mockReturnValue(true); + yield* desktopWindow.flushMainWindowBounds; - assert.deepEqual(mainWindowBoundsUpdates, []); + assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); assert.equal(fakeWindow.getBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 0); + assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2e98d0e7204..4bfa380d002 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -331,10 +331,13 @@ export const make = Effect.gen(function* () { let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { - if (window.isDestroyed() || window.isFullScreen() || window.isMinimized()) { + if (window.isDestroyed() || window.isFullScreen()) { return null; } - const bounds = window.isMaximized() ? window.getNormalBounds() : window.getBounds(); + const bounds = + window.isMaximized() || window.isMinimized() + ? window.getNormalBounds() + : window.getBounds(); const x = Math.round(bounds.x); const y = Math.round(bounds.y); const width = Math.round(bounds.width);