electron/main/sessionWindows.ts
Rama main · réplica de lectura del repositorio original.
// ========= 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';
import {
captureWindowState,
parseWindowState,
type PersistedWindowStateV2,
type WindowStateSource,
} from './windowState';
export const SESSION_WINDOWS_FILE_NAME = 'session-windows.json';
const SESSION_WINDOWS_VERSION = 1;
const MAX_SESSION_WINDOWS = 20;
const TARGET_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
/** The Session a window shows. */
export interface SessionWindowTarget {
spaceId: string;
projectId: string;
}
interface SavedSessionWindow extends SessionWindowTarget {
state?: PersistedWindowStateV2;
}
/** The subset of BrowserWindow the manager drives. */
export interface SessionWindowLike extends WindowStateSource {
readonly webContents: { readonly id: number };
isDestroyed(): boolean;
isMinimized(): boolean;
restore(): void;
focus(): void;
close(): void;
on(event: 'move' | 'resize' | 'closed', listener: () => void): unknown;
}
export interface SessionWindowManagerOptions<W extends SessionWindowLike> {
userDataPath: string;
/** Builds and loads the window. `state` is the saved placement, if any. */
createWindow: (
target: SessionWindowTarget,
state: PersistedWindowStateV2 | null
) => W;
/** Told after a window closed, unless the app is quitting. */
onClosed?: (target: SessionWindowTarget) => void;
io?: Pick<typeof fs, 'readFileSync' | 'writeFileSync' | 'mkdirSync'>;
scheduleSave?: (save: () => void) => void;
}
export function isSessionWindowTarget(
value: unknown
): value is SessionWindowTarget {
if (!value || typeof value !== 'object') return false;
const { spaceId, projectId } = value as Record<string, unknown>;
return (
typeof spaceId === 'string' &&
typeof projectId === 'string' &&
TARGET_ID_PATTERN.test(spaceId) &&
TARGET_ID_PATTERN.test(projectId)
);
}
/** Hash route of the Session window renderer. */
export function sessionWindowRoute({
spaceId,
projectId,
}: SessionWindowTarget) {
return `/session-window/${encodeURIComponent(spaceId)}/${encodeURIComponent(
projectId
)}`;
}
function parseSaved(raw: string): SavedSessionWindow[] {
try {
const parsed = JSON.parse(raw) as { version?: unknown; windows?: unknown };
if (parsed?.version !== SESSION_WINDOWS_VERSION) return [];
if (!Array.isArray(parsed.windows)) return [];
const seen = new Set<string>();
const result: SavedSessionWindow[] = [];
for (const entry of parsed.windows) {
if (!isSessionWindowTarget(entry) || seen.has(entry.projectId)) continue;
seen.add(entry.projectId);
const state = parseWindowState((entry as { state?: unknown }).state);
result.push({
spaceId: entry.spaceId,
projectId: entry.projectId,
...(state ? { state } : {}),
});
if (result.length >= MAX_SESSION_WINDOWS) break;
}
return result;
} catch {
return [];
}
}
/**
* Session windows: one per Session. Their list and placement are saved so the
* app reopens them after a restart, exactly as they were when the app quit.
*/
export class SessionWindowManager<W extends SessionWindowLike> {
private readonly windows = new Map<
string,
{ target: SessionWindowTarget; window: W }
>();
private quitting = false;
private restored = false;
private readonly io;
private readonly filePath: string;
constructor(private readonly options: SessionWindowManagerOptions<W>) {
this.io = options.io ?? fs;
this.filePath = path.join(options.userDataPath, SESSION_WINDOWS_FILE_NAME);
}
/** Whether `webContentsId` belongs to a Session window. */
ownsContents(webContentsId: number): boolean {
return this.targetForContents(webContentsId) !== null;
}
targetForContents(webContentsId: number): SessionWindowTarget | null {
for (const { target, window } of this.windows.values())
if (!window.isDestroyed() && window.webContents.id === webContentsId)
return target;
return null;
}
openProjectIds(): string[] {
return [...this.windows.keys()];
}
allWindows(): W[] {
return [...this.windows.values()]
.map(({ window }) => window)
.filter((window) => !window.isDestroyed());
}
/** Opens the Session's window, or focuses it when it is already open. */
open(
target: SessionWindowTarget,
state: PersistedWindowStateV2 | null = null
): W {
const existing = this.windows.get(target.projectId);
if (existing && !existing.window.isDestroyed()) {
if (existing.window.isMinimized()) existing.window.restore();
existing.window.focus();
return existing.window;
}
const window = this.options.createWindow(target, state);
this.windows.set(target.projectId, { target, window });
const save = () => this.scheduleSave();
window.on('move', save);
window.on('resize', save);
window.on('closed', () => {
if (this.windows.get(target.projectId)?.window !== window) return;
this.windows.delete(target.projectId);
if (this.quitting) return;
this.save();
this.options.onClosed?.(target);
});
this.save();
return window;
}
close(projectId: string): void {
const entry = this.windows.get(projectId);
if (entry && !entry.window.isDestroyed()) entry.window.close();
}
/** Reopens the windows that were open when the app last quit. Runs once. */
restore(): void {
if (this.restored) return;
this.restored = true;
let raw: string;
try {
raw = String(this.io.readFileSync(this.filePath, 'utf8'));
} catch {
return;
}
for (const saved of parseSaved(raw))
this.open(
{ spaceId: saved.spaceId, projectId: saved.projectId },
saved.state ?? null
);
}
/** Keeps the saved list while windows close during quit. */
markQuitting(): void {
if (this.quitting) return;
this.save();
this.quitting = true;
}
private scheduleSave() {
if (this.quitting) return;
if (this.options.scheduleSave) this.options.scheduleSave(() => this.save());
else this.save();
}
private save(): void {
if (this.quitting) return;
const windows: SavedSessionWindow[] = [];
for (const { target, window } of this.windows.values()) {
if (window.isDestroyed()) continue;
let state: PersistedWindowStateV2 | undefined;
try {
state = captureWindowState(window);
} catch {
state = undefined;
}
windows.push({ ...target, ...(state ? { state } : {}) });
}
try {
this.io.mkdirSync(path.dirname(this.filePath), { recursive: true });
this.io.writeFileSync(
this.filePath,
JSON.stringify({ version: SESSION_WINDOWS_VERSION, windows }),
'utf8'
);
} catch {
// Placement is a convenience; a failed write must not break the window.
}
}
}