Skip to content

Commit 4facc03

Browse files
authored
perf(desktop): remember window bounds without electron-window-state (#49792)
1 parent 485a027 commit 4facc03

5 files changed

Lines changed: 188 additions & 24 deletions

File tree

‎bun.lock‎

Lines changed: 4 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/desktop/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"electron-log": "^5",
3131
"electron-store": "11.0.2",
3232
"electron-updater": "6.8.9",
33-
"electron-window-state": "^5.0.3",
3433
"lighthouse": "13.4.1"
3534
},
3635
"devDependencies": {

‎packages/desktop/src/main/windows/index.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import windowState from "electron-window-state"
21
import { randomUUID } from "node:crypto"
3-
import { app, BrowserWindow } from "electron"
2+
import { app, BrowserWindow, screen } from "electron"
43
import { Effect, FileSystem, Path } from "effect"
54
import { openExternalURL } from "../files"
65
import { scoped } from "../native/logging"
@@ -25,9 +24,15 @@ import {
2524
import { loadWindow, registerRendererProtocol } from "./protocol"
2625
import { createWindowRegistry } from "./registry"
2726
import { makeWindowRecovery } from "./recovery"
27+
import { manageWindowState, readWindowState, resolveWindowState } from "./window-state"
2828
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
2929

3030
const themeReady = new WeakMap<BrowserWindow, () => void>()
31+
const displays = {
32+
all: () => screen.getAllDisplays().map((display) => display.bounds),
33+
primary: () => screen.getPrimaryDisplay().bounds,
34+
matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds,
35+
}
3136
const registry = createWindowRegistry<BrowserWindow>({
3237
read: () => getStore().get(WINDOW_IDS_KEY),
3338
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
@@ -88,7 +93,8 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
8893
}
8994

9095
const create = (id: string = randomUUID()) => {
91-
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
96+
const stateFile = path.join(app.getPath("userData"), windowStateFile(id))
97+
const state = resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays)
9298
const appearance = windowAppearance(path, paths)
9399
const win = new BrowserWindow({
94100
x: state.x,
@@ -108,7 +114,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
108114
wireWindowRecovery(win, id, () => relaunchHandler())
109115
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
110116
wireRendererHeaders(win)
111-
state.manage(win)
117+
manageWindowState(win, stateFile, state, displays)
112118
register(win, id)
113119
wireFullscreen(win)
114120
loadWindow(win, "index.html")
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { mkdtempSync, writeFileSync } from "node:fs"
3+
import { tmpdir } from "node:os"
4+
import path from "node:path"
5+
import { readWindowState, resolveWindowState } from "./window-state"
6+
7+
const defaults = { width: 1280, height: 800 }
8+
const primary = { x: 0, y: 0, width: 1920, height: 1080 }
9+
const displays = {
10+
all: () => [primary, { x: 1920, y: 0, width: 2560, height: 1440 }],
11+
primary: () => primary,
12+
matching: () => primary,
13+
}
14+
15+
describe("window state", () => {
16+
test("keeps bounds that still fit on a display", () => {
17+
const saved = { x: 2000, y: 100, width: 1000, height: 700, displayBounds: { x: 1920, y: 0, width: 2560, height: 1440 } }
18+
expect(resolveWindowState(saved, defaults, displays)).toEqual(saved)
19+
})
20+
21+
test("falls back to the default size on the primary display when the saved display is gone", () => {
22+
const saved = { x: 5000, y: 100, width: 1000, height: 700, displayBounds: { x: 4480, y: 0, width: 1920, height: 1080 } }
23+
expect(resolveWindowState(saved, defaults, displays)).toEqual({ ...defaults, x: 0, y: 0, displayBounds: primary })
24+
})
25+
26+
test("uses defaults without a position when nothing usable was saved", () => {
27+
expect(resolveWindowState(undefined, defaults, displays)).toEqual(defaults)
28+
expect(resolveWindowState({ width: 0, height: -1 }, defaults, displays)).toEqual(defaults)
29+
expect(resolveWindowState("junk", defaults, displays)).toEqual(defaults)
30+
})
31+
32+
test("keeps a maximized flag even without bounds", () => {
33+
expect(resolveWindowState({ isMaximized: true }, defaults, displays)).toEqual({ ...defaults, isMaximized: true })
34+
})
35+
36+
test("reads the file electron-window-state wrote and ignores an unreadable one", () => {
37+
const dir = mkdtempSync(path.join(tmpdir(), "opencode-window-state-"))
38+
const file = path.join(dir, "window-state-a.json")
39+
writeFileSync(
40+
file,
41+
'{"width":1280,"height":800,"x":0,"y":0,"displayBounds":{"x":0,"y":0,"width":3440,"height":1440},"isMaximized":false,"isFullScreen":false}',
42+
)
43+
expect(resolveWindowState(readWindowState(file), defaults, displays)).toEqual({
44+
width: 1280,
45+
height: 800,
46+
x: 0,
47+
y: 0,
48+
displayBounds: { x: 0, y: 0, width: 3440, height: 1440 },
49+
isMaximized: false,
50+
isFullScreen: false,
51+
})
52+
writeFileSync(file, "{oops")
53+
expect(readWindowState(file)).toBeUndefined()
54+
expect(readWindowState(path.join(dir, "missing.json"))).toBeUndefined()
55+
})
56+
})
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2+
import path from "node:path"
3+
import type { BrowserWindow, Rectangle } from "electron"
4+
5+
// Remembers a window's bounds and maximized / fullscreen flags across launches in the same JSON
6+
// file electron-window-state wrote, so existing profiles restore where they were.
7+
8+
export type WindowState = {
9+
x?: number
10+
y?: number
11+
width: number
12+
height: number
13+
displayBounds?: Rectangle
14+
isMaximized?: boolean
15+
isFullScreen?: boolean
16+
}
17+
18+
export type Displays = {
19+
all: () => Rectangle[]
20+
primary: () => Rectangle
21+
matching: (bounds: Rectangle) => Rectangle
22+
}
23+
24+
// Bounds are only trusted when they still fit on one of the current displays; otherwise the window
25+
// falls back to the default size on the primary display. A saved maximized or fullscreen state
26+
// without bounds is kept so manage() can restore it.
27+
export function resolveWindowState(saved: unknown, defaults: { width: number; height: number }, displays: Displays) {
28+
const state = isState(saved) ? saved : undefined
29+
if (!state) return { width: defaults.width, height: defaults.height } satisfies WindowState
30+
if (!hasBounds(state)) {
31+
return state.isMaximized || state.isFullScreen
32+
? ({ ...state, width: defaults.width, height: defaults.height } satisfies WindowState)
33+
: ({ width: defaults.width, height: defaults.height } satisfies WindowState)
34+
}
35+
if (!state.displayBounds) return state
36+
const visible = displays.all().some((bounds) => within(state, bounds))
37+
if (visible) return state
38+
return {
39+
width: defaults.width,
40+
height: defaults.height,
41+
x: 0,
42+
y: 0,
43+
displayBounds: displays.primary(),
44+
} satisfies WindowState
45+
}
46+
47+
export function readWindowState(file: string): unknown {
48+
if (!existsSync(file)) return undefined
49+
try {
50+
return JSON.parse(readFileSync(file, "utf8"))
51+
} catch {
52+
return undefined
53+
}
54+
}
55+
56+
// Tracks the window until it closes and writes the final state then. Bounds are recorded while
57+
// the window is in its normal state so a maximized window restores to its previous size.
58+
export function manageWindowState(win: BrowserWindow, file: string, initial: WindowState, displays: Displays) {
59+
const state = { ...initial }
60+
if (state.isMaximized) win.maximize()
61+
if (state.isFullScreen) win.setFullScreen(true)
62+
let timer: ReturnType<typeof setTimeout> | undefined
63+
const update = () => {
64+
if (win.isDestroyed()) return
65+
const bounds = win.getBounds()
66+
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
67+
state.x = bounds.x
68+
state.y = bounds.y
69+
state.width = bounds.width
70+
state.height = bounds.height
71+
}
72+
state.isMaximized = win.isMaximized()
73+
state.isFullScreen = win.isFullScreen()
74+
state.displayBounds = displays.matching(bounds)
75+
}
76+
const changed = () => {
77+
clearTimeout(timer)
78+
timer = setTimeout(update, 100)
79+
}
80+
const closed = () => {
81+
clearTimeout(timer)
82+
win.off("resize", changed)
83+
win.off("move", changed)
84+
win.off("close", update)
85+
win.off("closed", closed)
86+
mkdirSync(path.dirname(file), { recursive: true })
87+
writeFileSync(`${file}.tmp`, JSON.stringify(state))
88+
renameSync(`${file}.tmp`, file)
89+
}
90+
win.on("resize", changed)
91+
win.on("move", changed)
92+
win.on("close", update)
93+
win.on("closed", closed)
94+
}
95+
96+
function isState(value: unknown): value is WindowState {
97+
return typeof value === "object" && value !== null
98+
}
99+
100+
function hasBounds(state: WindowState): state is WindowState & Required<Pick<WindowState, "x" | "y">> {
101+
return (
102+
Number.isInteger(state.x) &&
103+
Number.isInteger(state.y) &&
104+
Number.isInteger(state.width) &&
105+
state.width > 0 &&
106+
Number.isInteger(state.height) &&
107+
state.height > 0
108+
)
109+
}
110+
111+
function within(state: WindowState & { x: number; y: number }, bounds: Rectangle) {
112+
return (
113+
state.x >= bounds.x &&
114+
state.y >= bounds.y &&
115+
state.x + state.width <= bounds.x + bounds.width &&
116+
state.y + state.height <= bounds.y + bounds.height
117+
)
118+
}

0 commit comments

Comments
 (0)