eigent/electron/main/windowState.ts
Douglas Lai 8f4f575db9
feat: add native menus, shortcuts, and window chrome (#1858)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:39:55 +08:00

503 lines
14 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import fs from 'node:fs';
import path from 'node:path';
// Version 2 invalidates pre-release state written from the legacy 4:3 fallback.
export const WINDOW_STATE_VERSION = 2 as const;
export const WINDOW_STATE_FILE_NAME = 'window-state.json';
export const DEFAULT_WINDOW_SIZE = {
width: 1440,
height: 810,
} as const;
export const DEFAULT_MINIMUM_WINDOW_SIZE = {
width: 1100,
height: 700,
} as const;
/** Keep fallback windows away from work-area edges on first/default launch. */
export const DEFAULT_WORK_AREA_FRACTION = 0.94;
const INTEGERISH_TOLERANCE = 0.001;
export interface WindowRectangle {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowSize {
width: number;
height: number;
}
/** The subset of Electron.Display used by the pure placement functions. */
export interface WindowDisplayLike {
id?: number | string;
bounds: WindowRectangle;
workArea: WindowRectangle;
}
/** The subset of BrowserWindow required to take a restorable snapshot. */
export interface WindowStateSource {
getNormalBounds(): WindowRectangle;
isMaximized(): boolean;
}
export interface PersistedWindowStateV2 {
version: typeof WINDOW_STATE_VERSION;
bounds: WindowRectangle;
isMaximized: boolean;
}
export interface WindowStateIO {
readText(filePath: string): string;
writeText(filePath: string, contents: string): void;
rename(fromPath: string, toPath: string): void;
remove(filePath: string): void;
}
export type WindowStartupSource = 'persisted' | 'default';
export interface ResolveWindowStartupStateOptions {
persistedState: PersistedWindowStateV2 | null;
displays: readonly WindowDisplayLike[];
primaryDisplay: WindowDisplayLike;
minimumSize?: WindowSize;
}
export interface LoadWindowStartupStateOptions extends Omit<
ResolveWindowStartupStateOptions,
'persistedState'
> {
userDataPath: string;
io?: WindowStateIO;
}
export interface WindowStartupState {
bounds: WindowRectangle;
minimumSize: WindowSize;
shouldMaximize: boolean;
source: WindowStartupSource;
}
const NODE_WINDOW_STATE_IO: WindowStateIO = {
readText: (filePath) => fs.readFileSync(filePath, 'utf8'),
writeText: (filePath, contents) =>
fs.writeFileSync(filePath, contents, {
encoding: 'utf8',
mode: 0o600,
}),
rename: (fromPath, toPath) => fs.renameSync(fromPath, toPath),
remove: (filePath) => fs.rmSync(filePath, { force: true }),
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeIntegerish(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const rounded = Math.round(value);
if (
!Number.isSafeInteger(rounded) ||
Math.abs(value - rounded) > INTEGERISH_TOLERANCE
) {
return null;
}
return rounded;
}
export function normalizeWindowRectangle(
value: unknown
): WindowRectangle | null {
if (!isRecord(value)) return null;
const x = normalizeIntegerish(value.x);
const y = normalizeIntegerish(value.y);
const width = normalizeIntegerish(value.width);
const height = normalizeIntegerish(value.height);
if (
x === null ||
y === null ||
width === null ||
height === null ||
width <= 0 ||
height <= 0
) {
return null;
}
return { x, y, width, height };
}
function normalizeWindowSize(value: unknown): WindowSize | null {
if (!isRecord(value)) return null;
const width = normalizeIntegerish(value.width);
const height = normalizeIntegerish(value.height);
if (width === null || height === null || width <= 0 || height <= 0) {
return null;
}
return { width, height };
}
export function parseWindowState(
value: unknown
): PersistedWindowStateV2 | null {
if (
!isRecord(value) ||
value.version !== WINDOW_STATE_VERSION ||
typeof value.isMaximized !== 'boolean'
) {
return null;
}
const bounds = normalizeWindowRectangle(value.bounds);
if (!bounds) return null;
return {
version: WINDOW_STATE_VERSION,
bounds,
isMaximized: value.isMaximized,
};
}
export function getWindowStatePath(userDataPath: string): string {
return path.join(userDataPath, WINDOW_STATE_FILE_NAME);
}
/** Missing, unreadable, unknown-version, and corrupt files all mean no state. */
export function readWindowState(
userDataPath: string,
io: WindowStateIO = NODE_WINDOW_STATE_IO
): PersistedWindowStateV2 | null {
try {
const parsed: unknown = JSON.parse(
io.readText(getWindowStatePath(userDataPath))
);
return parseWindowState(parsed);
} catch {
return null;
}
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
function intersectionArea(a: WindowRectangle, b: WindowRectangle): number {
const left = Math.max(a.x, b.x);
const top = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return Math.max(0, right - left) * Math.max(0, bottom - top);
}
function sameDisplay(a: WindowDisplayLike, b: WindowDisplayLike): boolean {
if (a.id !== undefined && b.id !== undefined) return a.id === b.id;
return (
a.bounds.x === b.bounds.x &&
a.bounds.y === b.bounds.y &&
a.bounds.width === b.bounds.width &&
a.bounds.height === b.bounds.height
);
}
function normalizeDisplay(
display: WindowDisplayLike
): WindowDisplayLike | null {
const bounds = normalizeWindowRectangle(display.bounds);
const workArea = normalizeWindowRectangle(display.workArea);
if (!bounds || !workArea) return null;
return { id: display.id, bounds, workArea };
}
function normalizedDisplays(
displays: readonly WindowDisplayLike[],
primaryDisplay: WindowDisplayLike
): { displays: WindowDisplayLike[]; primary: WindowDisplayLike } {
const primary = normalizeDisplay(primaryDisplay);
if (!primary) throw new Error('Primary display has invalid bounds');
const result = [primary];
for (const display of displays) {
const normalized = normalizeDisplay(display);
if (
!normalized ||
result.some((candidate) => sameDisplay(candidate, normalized))
) {
continue;
}
result.push(normalized);
}
return { displays: result, primary };
}
function effectiveMinimumSize(
requested: WindowSize,
workArea: WindowRectangle
): WindowSize {
return {
width: Math.min(requested.width, workArea.width),
height: Math.min(requested.height, workArea.height),
};
}
/**
* Fit a preferred fallback size into roughly 94% of a work area while always
* preserving its aspect ratio. On smaller displays the effective minimum must
* shrink with the fitted size, otherwise BrowserWindow would expand one axis
* and turn the 16:9 default back into a distorted shape. Persisted bounds use a
* separate path and may occupy the full work area.
*/
export function fitPreferredWindowSize(
preferredSize: WindowSize,
workArea: WindowRectangle,
minimumSize: WindowSize = DEFAULT_MINIMUM_WINDOW_SIZE
): { size: WindowSize; minimumSize: WindowSize } {
const preferred = normalizeWindowSize(preferredSize);
const normalizedWorkArea = normalizeWindowRectangle(workArea);
const requestedMinimum = normalizeWindowSize(minimumSize);
if (!preferred || !normalizedWorkArea || !requestedMinimum) {
throw new Error(
'Window sizing inputs must be positive integer-like values'
);
}
const defaultArea = {
width: Math.max(
1,
Math.floor(normalizedWorkArea.width * DEFAULT_WORK_AREA_FRACTION)
),
height: Math.max(
1,
Math.floor(normalizedWorkArea.height * DEFAULT_WORK_AREA_FRACTION)
),
};
const scale = Math.min(
1,
defaultArea.width / preferred.width,
defaultArea.height / preferred.height
);
const ratioFitted = {
width: Math.max(1, Math.floor(preferred.width * scale)),
height: Math.max(1, Math.floor(preferred.height * scale)),
};
const effectiveMinimum = effectiveMinimumSize(requestedMinimum, {
x: 0,
y: 0,
...ratioFitted,
});
return {
size: ratioFitted,
minimumSize: effectiveMinimum,
};
}
function centerBounds(
size: WindowSize,
workArea: WindowRectangle
): WindowRectangle {
return {
x: Math.round(workArea.x + (workArea.width - size.width) / 2),
y: Math.round(workArea.y + (workArea.height - size.height) / 2),
width: size.width,
height: size.height,
};
}
function displayForBounds(
bounds: WindowRectangle,
displays: readonly WindowDisplayLike[],
primary: WindowDisplayLike
): { display: WindowDisplayLike; intersects: boolean } {
let selected = primary;
let largestIntersection = 0;
for (const display of displays) {
const area = intersectionArea(bounds, display.bounds);
if (area > largestIntersection) {
selected = display;
largestIntersection = area;
}
}
return { display: selected, intersects: largestIntersection > 0 };
}
function fitPersistedBounds(
bounds: WindowRectangle,
workArea: WindowRectangle,
minimumSize: WindowSize,
center: boolean
): { bounds: WindowRectangle; minimumSize: WindowSize } {
const effectiveMinimum = effectiveMinimumSize(minimumSize, workArea);
const size = {
width: clamp(bounds.width, effectiveMinimum.width, workArea.width),
height: clamp(bounds.height, effectiveMinimum.height, workArea.height),
};
if (center) {
return {
bounds: centerBounds(size, workArea),
minimumSize: effectiveMinimum,
};
}
return {
bounds: {
x: clamp(bounds.x, workArea.x, workArea.x + workArea.width - size.width),
y: clamp(
bounds.y,
workArea.y,
workArea.y + workArea.height - size.height
),
...size,
},
minimumSize: effectiveMinimum,
};
}
export function resolveWindowStartupState(
options: ResolveWindowStartupStateOptions
): WindowStartupState {
const { displays, primary } = normalizedDisplays(
options.displays,
options.primaryDisplay
);
const minimumSize =
normalizeWindowSize(options.minimumSize ?? DEFAULT_MINIMUM_WINDOW_SIZE) ??
DEFAULT_MINIMUM_WINDOW_SIZE;
if (options.persistedState) {
const persistedState = parseWindowState(options.persistedState);
if (persistedState) {
const match = displayForBounds(persistedState.bounds, displays, primary);
const fitted = fitPersistedBounds(
persistedState.bounds,
match.display.workArea,
minimumSize,
!match.intersects
);
return {
...fitted,
shouldMaximize: persistedState.isMaximized,
source: 'persisted',
};
}
}
const fitted = fitPreferredWindowSize(
DEFAULT_WINDOW_SIZE,
primary.workArea,
minimumSize
);
return {
bounds: centerBounds(fitted.size, primary.workArea),
minimumSize: fitted.minimumSize,
shouldMaximize: false,
source: 'default',
};
}
/**
* Lenient startup helper: invalid or missing disk state resolves to the
* 1440x810 default, fitted to the primary display while preserving 16:9.
*
* Integration outline (call only after `app.whenReady()` so `screen` is ready):
*
* ```ts
* const startup = loadWindowStartupState({
* userDataPath: app.getPath('userData'),
* displays: screen.getAllDisplays(),
* primaryDisplay: screen.getPrimaryDisplay(),
* });
* win = new BrowserWindow({
* ...startup.bounds,
* minWidth: startup.minimumSize.width,
* minHeight: startup.minimumSize.height,
* show: false,
* });
* // Only maximize after content is ready: maximize() also shows the window.
* if (startup.shouldMaximize) win.maximize();
* else win.show();
* ```
*
* Call `persistWindowState()` before the app's quit cleanup destroys the
* BrowserWindow. It is also safe to call from a debounced move/resize handler.
*/
export function loadWindowStartupState(
options: LoadWindowStartupStateOptions
): WindowStartupState {
return resolveWindowStartupState({
...options,
persistedState: readWindowState(options.userDataPath, options.io),
});
}
export function captureWindowState(
source: WindowStateSource
): PersistedWindowStateV2 {
const bounds = normalizeWindowRectangle(source.getNormalBounds());
if (!bounds) throw new Error('BrowserWindow returned invalid normal bounds');
// Deliberately persist neither minimized nor fullscreen state. Normal bounds
// plus maximized is the complete restorable schema for this release.
return {
version: WINDOW_STATE_VERSION,
bounds,
isMaximized: Boolean(source.isMaximized()),
};
}
/** Write to a same-directory temporary file, then atomically replace the state. */
export function writeWindowState(
userDataPath: string,
state: PersistedWindowStateV2,
io: WindowStateIO = NODE_WINDOW_STATE_IO
): void {
const normalized = parseWindowState(state);
if (!normalized) throw new Error('Cannot persist invalid window state');
const statePath = getWindowStatePath(userDataPath);
const temporaryPath = `${statePath}.tmp`;
try {
io.writeText(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`);
io.rename(temporaryPath, statePath);
} catch (error) {
try {
io.remove(temporaryPath);
} catch {
// Keep the original persistence error; a stale temp file is recoverable.
}
throw error;
}
}
export function persistWindowState(
userDataPath: string,
source: WindowStateSource,
io: WindowStateIO = NODE_WINDOW_STATE_IO
): PersistedWindowStateV2 {
const state = captureWindowState(source);
writeWindowState(userDataPath, state, io);
return state;
}