diff --git a/bun.lock b/bun.lock index e71a24e8af5..a8197a3c41a 100644 --- a/bun.lock +++ b/bun.lock @@ -416,7 +416,6 @@ "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", - "electron-window-state": "^5.0.3", "lighthouse": "13.4.1", }, "devDependencies": { @@ -3915,8 +3914,6 @@ "electron-vite": ["electron-vite@6.0.0-beta.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^7.0.0", "esbuild": "^0.25.11", "magic-string": "^0.30.21", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-jltST77AwNxIeTTDtYhnIEA8ZM0RW9jmSJcqS8x/dQcgeeLlxlcJoYNNJ1tv7/Swor0AbXMYteBGdezoAOt+Nw=="], - "electron-window-state": ["electron-window-state@5.0.3", "", { "dependencies": { "jsonfile": "^4.0.0", "mkdirp": "^0.5.1" } }, "sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg=="], - "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], @@ -4473,7 +4470,7 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], @@ -6521,8 +6518,6 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], @@ -6925,18 +6920,12 @@ "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], - "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - "@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -7013,8 +7002,6 @@ "@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], @@ -7419,6 +7406,8 @@ "electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], @@ -7567,8 +7556,6 @@ "tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], - "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], @@ -7577,8 +7564,6 @@ "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - "workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1db55a02e90..4167de25ee7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -30,7 +30,6 @@ "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", - "electron-window-state": "^5.0.3", "lighthouse": "13.4.1" }, "devDependencies": { diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 8f2452a33d5..27bd708280b 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -1,6 +1,5 @@ -import windowState from "electron-window-state" import { randomUUID } from "node:crypto" -import { app, BrowserWindow } from "electron" +import { app, BrowserWindow, screen } from "electron" import { Effect, FileSystem, Path } from "effect" import { openExternalURL } from "../files" import { scoped } from "../native/logging" @@ -25,9 +24,15 @@ import { import { loadWindow, registerRendererProtocol } from "./protocol" import { createWindowRegistry } from "./registry" import { makeWindowRecovery } from "./recovery" +import { manageWindowState, readWindowState, resolveWindowState } from "./window-state" import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security" const themeReady = new WeakMap void>() +const displays = { + all: () => screen.getAllDisplays().map((display) => display.bounds), + primary: () => screen.getPrimaryDisplay().bounds, + matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds, +} const registry = createWindowRegistry({ read: () => getStore().get(WINDOW_IDS_KEY), write: (ids) => getStore().set(WINDOW_IDS_KEY, ids), @@ -88,7 +93,8 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { } const create = (id: string = randomUUID()) => { - const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 }) + const stateFile = path.join(app.getPath("userData"), windowStateFile(id)) + const state = resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays) const appearance = windowAppearance(path, paths) const win = new BrowserWindow({ x: state.x, @@ -108,7 +114,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { wireWindowRecovery(win, id, () => relaunchHandler()) wireNavigationPolicy(win, (url) => runFork(openExternalURL(url))) wireRendererHeaders(win) - state.manage(win) + manageWindowState(win, stateFile, state, displays) register(win, id) wireFullscreen(win) loadWindow(win, "index.html") diff --git a/packages/desktop/src/main/windows/window-state.test.ts b/packages/desktop/src/main/windows/window-state.test.ts new file mode 100644 index 00000000000..0d826334dd2 --- /dev/null +++ b/packages/desktop/src/main/windows/window-state.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { readWindowState, resolveWindowState } from "./window-state" + +const defaults = { width: 1280, height: 800 } +const primary = { x: 0, y: 0, width: 1920, height: 1080 } +const displays = { + all: () => [primary, { x: 1920, y: 0, width: 2560, height: 1440 }], + primary: () => primary, + matching: () => primary, +} + +describe("window state", () => { + test("keeps bounds that still fit on a display", () => { + const saved = { x: 2000, y: 100, width: 1000, height: 700, displayBounds: { x: 1920, y: 0, width: 2560, height: 1440 } } + expect(resolveWindowState(saved, defaults, displays)).toEqual(saved) + }) + + test("falls back to the default size on the primary display when the saved display is gone", () => { + const saved = { x: 5000, y: 100, width: 1000, height: 700, displayBounds: { x: 4480, y: 0, width: 1920, height: 1080 } } + expect(resolveWindowState(saved, defaults, displays)).toEqual({ ...defaults, x: 0, y: 0, displayBounds: primary }) + }) + + test("uses defaults without a position when nothing usable was saved", () => { + expect(resolveWindowState(undefined, defaults, displays)).toEqual(defaults) + expect(resolveWindowState({ width: 0, height: -1 }, defaults, displays)).toEqual(defaults) + expect(resolveWindowState("junk", defaults, displays)).toEqual(defaults) + }) + + test("keeps a maximized flag even without bounds", () => { + expect(resolveWindowState({ isMaximized: true }, defaults, displays)).toEqual({ ...defaults, isMaximized: true }) + }) + + test("reads the file electron-window-state wrote and ignores an unreadable one", () => { + const dir = mkdtempSync(path.join(tmpdir(), "opencode-window-state-")) + const file = path.join(dir, "window-state-a.json") + writeFileSync( + file, + '{"width":1280,"height":800,"x":0,"y":0,"displayBounds":{"x":0,"y":0,"width":3440,"height":1440},"isMaximized":false,"isFullScreen":false}', + ) + expect(resolveWindowState(readWindowState(file), defaults, displays)).toEqual({ + width: 1280, + height: 800, + x: 0, + y: 0, + displayBounds: { x: 0, y: 0, width: 3440, height: 1440 }, + isMaximized: false, + isFullScreen: false, + }) + writeFileSync(file, "{oops") + expect(readWindowState(file)).toBeUndefined() + expect(readWindowState(path.join(dir, "missing.json"))).toBeUndefined() + }) +}) diff --git a/packages/desktop/src/main/windows/window-state.ts b/packages/desktop/src/main/windows/window-state.ts new file mode 100644 index 00000000000..52731b8cc88 --- /dev/null +++ b/packages/desktop/src/main/windows/window-state.ts @@ -0,0 +1,118 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" +import path from "node:path" +import type { BrowserWindow, Rectangle } from "electron" + +// Remembers a window's bounds and maximized / fullscreen flags across launches in the same JSON +// file electron-window-state wrote, so existing profiles restore where they were. + +export type WindowState = { + x?: number + y?: number + width: number + height: number + displayBounds?: Rectangle + isMaximized?: boolean + isFullScreen?: boolean +} + +export type Displays = { + all: () => Rectangle[] + primary: () => Rectangle + matching: (bounds: Rectangle) => Rectangle +} + +// Bounds are only trusted when they still fit on one of the current displays; otherwise the window +// falls back to the default size on the primary display. A saved maximized or fullscreen state +// without bounds is kept so manage() can restore it. +export function resolveWindowState(saved: unknown, defaults: { width: number; height: number }, displays: Displays) { + const state = isState(saved) ? saved : undefined + if (!state) return { width: defaults.width, height: defaults.height } satisfies WindowState + if (!hasBounds(state)) { + return state.isMaximized || state.isFullScreen + ? ({ ...state, width: defaults.width, height: defaults.height } satisfies WindowState) + : ({ width: defaults.width, height: defaults.height } satisfies WindowState) + } + if (!state.displayBounds) return state + const visible = displays.all().some((bounds) => within(state, bounds)) + if (visible) return state + return { + width: defaults.width, + height: defaults.height, + x: 0, + y: 0, + displayBounds: displays.primary(), + } satisfies WindowState +} + +export function readWindowState(file: string): unknown { + if (!existsSync(file)) return undefined + try { + return JSON.parse(readFileSync(file, "utf8")) + } catch { + return undefined + } +} + +// Tracks the window until it closes and writes the final state then. Bounds are recorded while +// the window is in its normal state so a maximized window restores to its previous size. +export function manageWindowState(win: BrowserWindow, file: string, initial: WindowState, displays: Displays) { + const state = { ...initial } + if (state.isMaximized) win.maximize() + if (state.isFullScreen) win.setFullScreen(true) + let timer: ReturnType | undefined + const update = () => { + if (win.isDestroyed()) return + const bounds = win.getBounds() + if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) { + state.x = bounds.x + state.y = bounds.y + state.width = bounds.width + state.height = bounds.height + } + state.isMaximized = win.isMaximized() + state.isFullScreen = win.isFullScreen() + state.displayBounds = displays.matching(bounds) + } + const changed = () => { + clearTimeout(timer) + timer = setTimeout(update, 100) + } + const closed = () => { + clearTimeout(timer) + win.off("resize", changed) + win.off("move", changed) + win.off("close", update) + win.off("closed", closed) + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(`${file}.tmp`, JSON.stringify(state)) + renameSync(`${file}.tmp`, file) + } + win.on("resize", changed) + win.on("move", changed) + win.on("close", update) + win.on("closed", closed) +} + +function isState(value: unknown): value is WindowState { + return typeof value === "object" && value !== null +} + +function hasBounds(state: WindowState): state is WindowState & Required> { + return ( + Number.isInteger(state.x) && + Number.isInteger(state.y) && + Number.isInteger(state.width) && + state.width > 0 && + Number.isInteger(state.height) && + state.height > 0 + ) +} + +function within(state: WindowState & { x: number; y: number }, bounds: Rectangle) { + return ( + state.x >= bounds.x && + state.y >= bounds.y && + state.x + state.width <= bounds.x + bounds.width && + state.y + state.height <= bounds.y + bounds.height + ) +}