import type { BrowserWindow, Session, WebContents } from 'electron'; import { WebContentsView, session } from 'electron'; import type { AgentBrowserBounds } from '../../shared/agent-browser'; import type { AgentBrowserAdapter, AgentBrowserDebuggerPort, AgentBrowserNavigationPort, AgentBrowserViewPort, AgentBrowserWebContentsPort, PortListener, } from './adapter'; function isAllowedTopLevelUrl(value: string): boolean { if (value === 'about:blank') return true; try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } } function wrapDebugger(contents: WebContents): AgentBrowserDebuggerPort { const debuggerEvents = contents.debugger as unknown as { on(event: string, listener: PortListener): void; removeListener(event: string, listener: PortListener): void; }; return { attach: (version) => contents.debugger.attach(version), detach: () => contents.debugger.detach(), isAttached: () => contents.debugger.isAttached(), sendCommand: (method, params, sessionRef) => contents.debugger.sendCommand(method, params, sessionRef), on: (event, listener) => debuggerEvents.on(event, listener), removeListener: (event, listener) => debuggerEvents.removeListener(event, listener), }; } function wrapNavigation(contents: WebContents): AgentBrowserNavigationPort { return { canGoBack: () => contents.navigationHistory.canGoBack(), canGoForward: () => contents.navigationHistory.canGoForward(), goBack: () => { contents.navigationHistory.goBack(); }, goForward: () => { contents.navigationHistory.goForward(); }, clear: () => { contents.navigationHistory.clear(); }, }; } function wrapWebContents(contents: WebContents): AgentBrowserWebContentsPort { const contentsEvents = contents as unknown as { on(event: string, listener: PortListener): void; removeListener(event: string, listener: PortListener): void; }; return { debugger: wrapDebugger(contents), navigationHistory: wrapNavigation(contents), loadURL: (url) => contents.loadURL(url), getURL: () => contents.getURL(), getTitle: () => contents.getTitle(), isDestroyed: () => contents.isDestroyed(), isDevToolsOpened: () => contents.isDevToolsOpened(), reload: () => contents.reload(), denyWindowOpen: () => { contents.setWindowOpenHandler(() => ({ action: 'deny' })); }, on: (event: string, listener: PortListener) => { contentsEvents.on(event, listener); }, removeListener: (event: string, listener: PortListener) => { contentsEvents.removeListener(event, listener); }, }; } export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter { private readonly nativeViews = new WeakMap(); private readonly guardedSessions = new WeakSet(); constructor(private readonly mainWindow: BrowserWindow) {} createView(partition: string): AgentBrowserViewPort { const nativeView = new WebContentsView({ webPreferences: { partition, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, allowRunningInsecureContent: false, }, }); const contents = nativeView.webContents; contents.setWindowOpenHandler(() => ({ action: 'deny' })); contents.on('will-attach-webview', (event) => event.preventDefault()); const preventUnsafeNavigation = ( event: Electron.Event, navigationUrl: string, ) => { if (!isAllowedTopLevelUrl(navigationUrl)) event.preventDefault(); }; contents.on('will-navigate', preventUnsafeNavigation); contents.on('will-redirect', preventUnsafeNavigation); this.guardSession(contents.session); const view: AgentBrowserViewPort = { webContents: wrapWebContents(contents), setBounds: (bounds: AgentBrowserBounds) => nativeView.setBounds(this.toNativeBounds(bounds)), setVisible: (visible: boolean) => nativeView.setVisible(visible), }; this.nativeViews.set(view, nativeView); return view; } mount(view: AgentBrowserViewPort): void { const nativeView = this.requireNativeView(view); if (!this.mainWindow.isDestroyed()) { this.mainWindow.contentView.addChildView(nativeView); } } unmount(view: AgentBrowserViewPort): void { const nativeView = this.nativeViews.get(view); if (!nativeView || this.mainWindow.isDestroyed()) return; try { this.mainWindow.contentView.removeChildView(nativeView); } catch { // Removing an already detached view is harmless during shutdown. } } destroy(view: AgentBrowserViewPort): void { const nativeView = this.nativeViews.get(view); if (!nativeView) return; this.nativeViews.delete(view); if (!nativeView.webContents.isDestroyed()) { nativeView.webContents.close({ waitForBeforeUnload: false }); } } async resetPartition(partition: string): Promise { const browserSession = session.fromPartition(partition); await browserSession.clearStorageData(); await browserSession.clearCache(); } private requireNativeView(view: AgentBrowserViewPort): WebContentsView { const nativeView = this.nativeViews.get(view); if (!nativeView) throw new Error('Unknown Agent Browser view.'); return nativeView; } private guardSession(browserSession: Session): void { if (this.guardedSessions.has(browserSession)) return; this.guardedSessions.add(browserSession); browserSession.setPermissionCheckHandler(() => false); browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false); }); browserSession.on('will-download', (event) => { event.preventDefault(); }); } private toNativeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds { const zoomFactor = this.mainWindow.webContents.getZoomFactor(); const zoom = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; const contentBounds = this.mainWindow.getContentBounds(); const contentWidth = Math.max(0, Math.trunc(contentBounds.width)); const contentHeight = Math.max(0, Math.trunc(contentBounds.height)); const x = clamp(Math.round(bounds.x * zoom), 0, contentWidth); const y = clamp(Math.round(bounds.y * zoom), 0, contentHeight); return { x, y, width: clamp(Math.round(bounds.width * zoom), 0, contentWidth - x), height: clamp(Math.round(bounds.height * zoom), 0, contentHeight - y), }; } } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), Math.max(min, max)); }