Ctrl K
ComunidadGuías…

electron/main/windowState.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// ========= 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;
}