import { WebContentsView } from "electron/main"; import type { WebContentsView as ElectronWebContentsView } from "electron/main"; import { maybeOpenExecutionDevtools } from "./execution-devtools"; export interface HotViewHandle { accountId: string; view: ElectronWebContentsView; activatedAt: number; lastReleasedAt: number | null; retainCount: number; } const hotViews = new Map(); const HOT_VIEW_IDLE_TTL_MS = 5 * 60_000; const HOT_VIEW_REAPER_INTERVAL_MS = 60_000; let reaperHandle: ReturnType | null = null; function closeView(handle: HotViewHandle): void { if (!handle.view.webContents.isDestroyed()) { handle.view.webContents.close(); } } function touchHandle(handle: HotViewHandle): HotViewHandle { handle.activatedAt = Date.now(); return handle; } function demoteViewInternal(accountId: string): boolean { const handle = hotViews.get(accountId); if (!handle) { return false; } closeView(handle); hotViews.delete(accountId); return true; } export function startHotViewReaper(): void { if (reaperHandle) { return; } reaperHandle = setInterval(() => { reapIdleHotViews(); }, HOT_VIEW_REAPER_INTERVAL_MS); reaperHandle.unref?.(); } export function acquireHotView(accountId: string): HotViewHandle { startHotViewReaper(); const existing = hotViews.get(accountId); if (existing) { return touchHandle(existing); } const handle: HotViewHandle = { accountId, view: new WebContentsView({ webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, }, }), activatedAt: Date.now(), lastReleasedAt: Date.now(), retainCount: 0, }; maybeOpenExecutionDevtools(handle.view.webContents, `hot-view:${accountId}`, { background: true }); hotViews.set(accountId, handle); return handle; } export function retainHotView(accountId: string): HotViewHandle { const handle = touchHandle(acquireHotView(accountId)); handle.retainCount += 1; handle.lastReleasedAt = null; return handle; } export function releaseHotView(accountId: string): void { const handle = hotViews.get(accountId); if (!handle) { return; } if (handle.retainCount > 0) { handle.retainCount -= 1; } if (handle.retainCount === 0) { handle.lastReleasedAt = Date.now(); } } export function demoteView(accountId: string): boolean { const handle = hotViews.get(accountId); if (!handle || handle.retainCount > 0) { return false; } return demoteViewInternal(accountId); } export function reapIdleHotViews( now = Date.now(), idleTTLms = HOT_VIEW_IDLE_TTL_MS, ): string[] { const reclaimed: string[] = []; for (const [accountId, handle] of hotViews.entries()) { if (handle.retainCount > 0) { continue; } const idleSince = handle.lastReleasedAt ?? handle.activatedAt; if (now - idleSince < idleTTLms) { continue; } if (demoteViewInternal(accountId)) { reclaimed.push(accountId); } } return reclaimed; } export function listHotViews(): Array> { return [...hotViews.values()].map(({ accountId, activatedAt, lastReleasedAt, retainCount }) => ({ accountId, activatedAt, lastReleasedAt, retainCount, })); } export function getHotViewPoolPolicy() { return { idleTTLms: HOT_VIEW_IDLE_TTL_MS, reapIntervalMs: HOT_VIEW_REAPER_INTERVAL_MS, }; }