mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 00:52:07 +00:00
503 lines
14 KiB
TypeScript
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;
|
|
}
|