-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat: persist and restore main window size and position #3673
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import * as Option from "effect/Option"; | |
| import * as Ref from "effect/Ref"; | ||
|
|
||
| import type * as Electron from "electron"; | ||
| import { screen } from "electron"; | ||
|
|
||
| import * as DesktopAssets from "../app/DesktopAssets.ts"; | ||
| import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
|
|
@@ -17,7 +18,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_SIZE = { width: 1100, height: 780 } as const; | ||
| // Resize/move fire continuously while dragging; coalesce persistence so we write | ||
| // once the gesture settles instead of on every pixel. | ||
| const WINDOW_STATE_SAVE_DEBOUNCE_MS = 400; | ||
| const TITLEBAR_HEIGHT = 40; | ||
| const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | ||
| const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | ||
|
|
@@ -45,6 +51,7 @@ type DesktopWindowRuntimeServices = | |
| | ElectronShell.ElectronShell | ||
| | ElectronTheme.ElectronTheme | ||
| | ElectronWindow.ElectronWindow | ||
| | DesktopWindowState.DesktopWindowState | ||
| | PreviewManager.PreviewManager; | ||
|
|
||
| export type DesktopWindowError = | ||
|
|
@@ -201,6 +208,7 @@ export const make = Effect.gen(function* () { | |
| const electronShell = yield* ElectronShell.ElectronShell; | ||
| const electronTheme = yield* ElectronTheme.ElectronTheme; | ||
| const electronWindow = yield* ElectronWindow.ElectronWindow; | ||
| const windowState = yield* DesktopWindowState.DesktopWindowState; | ||
| const previewManager = yield* PreviewManager.PreviewManager; | ||
| // Window-side latch for the primary backend's readiness. Set by | ||
| // handleBackendReady (driven by the pool's onReady callback), cleared | ||
|
|
@@ -250,9 +258,14 @@ export const make = Effect.gen(function* () { | |
| const iconPaths = yield* assets.iconPaths; | ||
| const iconOption = getIconOption(iconPaths, environment.platform); | ||
| const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | ||
| const savedWindowState = yield* windowState.load; | ||
| const initialWindowBounds = DesktopWindowState.resolveInitialWindowBounds( | ||
| savedWindowState, | ||
| screen.getAllDisplays().map((display) => display.workArea), | ||
| DEFAULT_WINDOW_SIZE, | ||
| ); | ||
| const window = yield* electronWindow.create({ | ||
| width: 1100, | ||
| height: 780, | ||
| ...initialWindowBounds.bounds, | ||
| minWidth: 840, | ||
| minHeight: 620, | ||
| show: false, | ||
|
|
@@ -275,6 +288,66 @@ export const make = Effect.gen(function* () { | |
| window.setAutoHideCursor(false); | ||
| } | ||
|
|
||
| if (initialWindowBounds.maximize) { | ||
| window.maximize(); | ||
| } | ||
|
|
||
| // Persist geometry so the next launch restores it. Saves are debounced via a | ||
| // restartable fiber (mirroring the development-load retry below) while the | ||
| // user drags/resizes, and flushed on close; a failed write is logged and | ||
| // otherwise ignored since it only costs the remembered size, nothing more. | ||
| let windowStateSaveFiber: Fiber.Fiber<void, never> | undefined; | ||
| const persistWindowState = () => { | ||
| if (window.isDestroyed() || window.isMinimized() || window.isFullScreen()) { | ||
| return; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minimized window skips close persistMedium Severity When the main window closes while minimized, Reviewed by Cursor Bugbot for commit cd5a7ba. Configure here. |
||
| } | ||
| const normalBounds = window.getNormalBounds(); | ||
| runFork( | ||
| windowState | ||
| .save({ | ||
| x: normalBounds.x, | ||
| y: normalBounds.y, | ||
| width: normalBounds.width, | ||
| height: normalBounds.height, | ||
| maximized: window.isMaximized(), | ||
| }) | ||
| .pipe( | ||
| Effect.catch((error) => | ||
| logWindowWarning("failed to persist window state", { path: error.path }), | ||
| ), | ||
| ), | ||
| ); | ||
| }; | ||
| const cancelScheduledWindowStateSave = () => { | ||
| if (windowStateSaveFiber === undefined) { | ||
| return; | ||
| } | ||
| const saveFiber = windowStateSaveFiber; | ||
| windowStateSaveFiber = undefined; | ||
| runFork(Fiber.interrupt(saveFiber)); | ||
| }; | ||
| const scheduleWindowStateSave = () => { | ||
| cancelScheduledWindowStateSave(); | ||
| windowStateSaveFiber = runFork( | ||
| Effect.sleep(WINDOW_STATE_SAVE_DEBOUNCE_MS).pipe( | ||
| Effect.andThen( | ||
| Effect.sync(() => { | ||
| windowStateSaveFiber = undefined; | ||
| persistWindowState(); | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| }; | ||
| window.on("resize", scheduleWindowStateSave); | ||
| window.on("move", scheduleWindowStateSave); | ||
| window.on("maximize", scheduleWindowStateSave); | ||
| window.on("unmaximize", scheduleWindowStateSave); | ||
| window.on("close", () => { | ||
| cancelScheduledWindowStateSave(); | ||
| persistWindowState(); | ||
| }); | ||
|
|
||
| yield* previewManager.setMainWindow(window); | ||
| window.webContents.on("will-attach-webview", (event, webPreferences, params) => { | ||
| if ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { assert, describe, it } from "@effect/vitest"; | ||
| import * as Option from "effect/Option"; | ||
|
|
||
| import * as DesktopWindowState from "./DesktopWindowState.ts"; | ||
|
|
||
| const DEFAULTS = { width: 1100, height: 780 } as const; | ||
| const DISPLAYS = [{ x: 0, y: 0, width: 1920, height: 1080 }] as const; | ||
|
|
||
| describe("resolveInitialWindowBounds", () => { | ||
| it("falls back to defaults when there is no saved state", () => { | ||
| assert.deepStrictEqual( | ||
| DesktopWindowState.resolveInitialWindowBounds(Option.none(), DISPLAYS, DEFAULTS), | ||
| { bounds: { width: 1100, height: 780 }, maximize: false }, | ||
| ); | ||
| }); | ||
|
|
||
| it("restores a saved position that is visible on a display", () => { | ||
| assert.deepStrictEqual( | ||
| DesktopWindowState.resolveInitialWindowBounds( | ||
| Option.some({ x: 100, y: 120, width: 800, height: 600, maximized: true }), | ||
| DISPLAYS, | ||
| DEFAULTS, | ||
| ), | ||
| { bounds: { x: 100, y: 120, width: 800, height: 600 }, maximize: true }, | ||
| ); | ||
| }); | ||
|
|
||
| it("drops an off-screen position but keeps the size and maximized flag", () => { | ||
| assert.deepStrictEqual( | ||
| DesktopWindowState.resolveInitialWindowBounds( | ||
| Option.some({ x: 5000, y: 5000, width: 800, height: 600, maximized: true }), | ||
| DISPLAYS, | ||
| DEFAULTS, | ||
| ), | ||
| { bounds: { width: 800, height: 600 }, maximize: true }, | ||
| ); | ||
| }); | ||
|
|
||
| it("uses size only when the saved state has no position", () => { | ||
| assert.deepStrictEqual( | ||
| DesktopWindowState.resolveInitialWindowBounds( | ||
| Option.some({ width: 800, height: 600 }), | ||
| DISPLAYS, | ||
| DEFAULTS, | ||
| ), | ||
| { bounds: { width: 800, height: 600 }, maximize: false }, | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { fromLenientJson } from "@t3tools/shared/schemaJson"; | ||
| import * as Context from "effect/Context"; | ||
| 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 Path from "effect/Path"; | ||
| import * as Schema from "effect/Schema"; | ||
|
|
||
| import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
|
|
||
| // Persisted geometry of the main window. `width`/`height` are the un-maximized | ||
| // ("normal") content bounds; `maximized` restores the maximized state on top of | ||
| // them. `x`/`y` are optional so a document written before we tracked position | ||
| // (or one whose position is off-screen on load) still restores the size. | ||
| const WindowStateDocument = Schema.Struct({ | ||
| x: Schema.optionalKey(Schema.Number), | ||
| y: Schema.optionalKey(Schema.Number), | ||
| width: Schema.Number, | ||
| height: Schema.Number, | ||
| maximized: Schema.optionalKey(Schema.Boolean), | ||
| }); | ||
|
|
||
| export type WindowState = typeof WindowStateDocument.Type; | ||
|
|
||
| const WindowStateJson = fromLenientJson(WindowStateDocument); | ||
| const decodeWindowStateJson = Schema.decodeEffect(WindowStateJson); | ||
| const encodeWindowStateJson = Schema.encodeEffect(WindowStateJson); | ||
|
|
||
| export class DesktopWindowStateWriteError extends Schema.TaggedErrorClass<DesktopWindowStateWriteError>()( | ||
| "DesktopWindowStateWriteError", | ||
| { | ||
| path: Schema.String, | ||
| cause: Schema.Defect(), | ||
| }, | ||
| ) { | ||
| override get message(): string { | ||
| return `Failed to persist desktop window state at ${this.path}.`; | ||
| } | ||
| } | ||
|
|
||
| export interface WindowRect { | ||
| readonly x: number; | ||
| readonly y: number; | ||
| readonly width: number; | ||
| readonly height: number; | ||
| } | ||
|
|
||
| export interface InitialWindowBounds { | ||
| // Spread straight into BrowserWindow options. `x`/`y` are dropped when the | ||
| // saved position lands outside every display so the window can't open | ||
| // off-screen (e.g. after an external monitor is unplugged). | ||
| readonly bounds: { x?: number; y?: number; width: number; height: number }; | ||
| readonly maximize: boolean; | ||
| } | ||
|
|
||
| const isUsableSize = (state: WindowState): boolean => | ||
| Number.isFinite(state.width) && | ||
| state.width > 0 && | ||
| Number.isFinite(state.height) && | ||
| state.height > 0; | ||
|
|
||
| const rectsIntersect = (a: WindowRect, b: WindowRect): boolean => | ||
| a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; | ||
|
|
||
| // Pure so it can be unit-tested without Electron. `displays` are the work areas | ||
| // of the connected screens; pass `screen.getAllDisplays().map((d) => d.workArea)`. | ||
| export function resolveInitialWindowBounds( | ||
| saved: Option.Option<WindowState>, | ||
| displays: readonly WindowRect[], | ||
| defaults: { readonly width: number; readonly height: number }, | ||
| ): InitialWindowBounds { | ||
| if (Option.isNone(saved)) { | ||
| return { bounds: { ...defaults }, maximize: false }; | ||
| } | ||
|
|
||
| const state = saved.value; | ||
| const size = { width: state.width, height: state.height }; | ||
| const maximize = state.maximized === true; | ||
|
|
||
| if (state.x === undefined || state.y === undefined) { | ||
| return { bounds: size, maximize }; | ||
| } | ||
|
|
||
| const rect: WindowRect = { x: state.x, y: state.y, width: state.width, height: state.height }; | ||
| const onScreen = displays.some((display) => rectsIntersect(display, rect)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium
🤖 Copy this AI Prompt to have your agent fix this: |
||
| return onScreen ? { bounds: rect, maximize } : { bounds: size, maximize }; | ||
| } | ||
|
|
||
| export class DesktopWindowState extends Context.Service< | ||
| DesktopWindowState, | ||
| { | ||
| readonly load: Effect.Effect<Option.Option<WindowState>>; | ||
| readonly save: (state: WindowState) => Effect.Effect<void, DesktopWindowStateWriteError>; | ||
| } | ||
| >()("@t3tools/desktop/window/DesktopWindowState") {} | ||
|
|
||
| export const make = Effect.gen(function* () { | ||
| const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
|
|
||
| const load = fileSystem.readFileString(environment.windowStatePath).pipe( | ||
| Effect.flatMap(decodeWindowStateJson), | ||
| Effect.map((state) => (isUsableSize(state) ? Option.some(state) : Option.none<WindowState>())), | ||
| // Missing file, unreadable file, or a malformed document all fall back to | ||
| // "no saved state" so a first launch (or a corrupt file) opens at defaults. | ||
| Effect.orElseSucceed(() => Option.none<WindowState>()), | ||
| Effect.withSpan("desktop.windowState.load"), | ||
| ); | ||
|
|
||
| const save = (state: WindowState) => | ||
| encodeWindowStateJson(state).pipe( | ||
| Effect.flatMap((encoded) => | ||
| fileSystem | ||
| .makeDirectory(path.dirname(environment.windowStatePath), { recursive: true }) | ||
| .pipe( | ||
| Effect.andThen(fileSystem.writeFileString(environment.windowStatePath, `${encoded}\n`)), | ||
| ), | ||
| ), | ||
| Effect.mapError( | ||
| (cause) => new DesktopWindowStateWriteError({ path: environment.windowStatePath, cause }), | ||
| ), | ||
| Effect.withSpan("desktop.windowState.save"), | ||
| ); | ||
|
|
||
| return DesktopWindowState.of({ load, save }); | ||
| }); | ||
|
|
||
| export const layer = Layer.effect(DesktopWindowState, make); | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
window/DesktopWindow.ts:301persistWindowStatebails out whenwindow.isMinimized()is true, and theclosehandler calls it after canceling the pending debounce. When the user quits the app while the window is minimized, no state is written even thoughgetNormalBounds()still returns valid restorable bounds in that state, so the next launch restores stale geometry. Consider removing theisMinimized()guard frompersistWindowStateso the final close always writes the current bounds.🤖 Copy this AI Prompt to have your agent fix this: