import { WebContentsView, type BrowserWindow } from 'electron' import { randomUUID } from 'node:crypto' import { clampBrowserHostBounds, normalizeBrowserHostUrl, type BrowserHostBounds, type BrowserHostView } from '../shared/browser-host' interface BrowserRecord { id: string; owner: BrowserWindow; sessionId?: string; view: WebContentsView; visible: boolean; error?: string; navigationVersion: number } const browsers = new Map() const owners = new Set() function requireBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserRecord { const browser = browsers.get(id) if (!browser || browser.owner !== owner || browser.sessionId !== sessionId || owner.isDestroyed() || browser.view.webContents.isDestroyed()) throw new Error('浏览器标签不存在或不属于当前任务') return browser } function snapshot(browser: BrowserRecord): BrowserHostView { const contents = browser.view.webContents return { id: browser.id, sessionId: browser.sessionId, url: contents.getURL() || 'about:blank', title: contents.getTitle() || '新标签页', loading: contents.isLoading(), canGoBack: contents.navigationHistory.canGoBack(), canGoForward: contents.navigationHistory.canGoForward(), visible: browser.visible, error: browser.error } } function release(browser: BrowserRecord): void { browsers.delete(browser.id) browser.visible = false if (!browser.owner.isDestroyed()) browser.owner.contentView.removeChildView(browser.view) if (!browser.view.webContents.isDestroyed()) browser.view.webContents.close({ waitForBeforeUnload: false }) } function guardNavigation(browser: BrowserRecord, event: { preventDefault(): void }, url: string): void { try { normalizeBrowserHostUrl(url) } catch { event.preventDefault(); browser.error = '仅支持 HTTP 或 HTTPS 网页地址' } } function aborted(error: unknown): boolean { return !!error && typeof error === 'object' && 'code' in error && error.code === 'ERR_ABORTED' } async function load(browser: BrowserRecord, url: string): Promise { const version = ++browser.navigationVersion browser.error = undefined try { await browser.view.webContents.loadURL(url) } catch (error) { if (version === browser.navigationVersion && !browser.view.webContents.isDestroyed() && !aborted(error)) browser.error = error instanceof Error ? error.message : String(error) } } export function createBrowser(owner: BrowserWindow, sessionId?: string, initialUrl = 'about:blank'): BrowserHostView { if (owner.isDestroyed()) throw new Error('宿主窗口已关闭') if (sessionId !== undefined && (typeof sessionId !== 'string' || sessionId.length > 256 || !sessionId)) throw new Error('任务标识无效') const url = normalizeBrowserHostUrl(initialUrl) const id = randomUUID() const view = new WebContentsView({ webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, allowRunningInsecureContent: false, partition: `ycode-browser-${id}` } }) const browser: BrowserRecord = { id, owner, sessionId, view, visible: false, navigationVersion: 0 } browsers.set(id, browser) const contents = view.webContents contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)) contents.session.setPermissionCheckHandler(() => false) contents.session.on('will-download', event => event.preventDefault()) contents.on('will-navigate', (event, nextUrl) => guardNavigation(browser, event, nextUrl)) contents.on('will-redirect', (event, nextUrl) => guardNavigation(browser, event, nextUrl)) // No unmanaged popup escapes the owner/session. Exact original popup routing is unverified. contents.setWindowOpenHandler(({ url: nextUrl }) => { try { void load(browser, normalizeBrowserHostUrl(nextUrl)) } catch { browser.error = '仅支持 HTTP 或 HTTPS 网页地址' } return { action: 'deny' } }) contents.on('did-start-navigation', (_event, _nextUrl, _isInPlace, isMainFrame) => { if (isMainFrame) browser.error = undefined }) contents.on('did-fail-load', (_event, code, description, _url, isMainFrame) => { if (isMainFrame && code !== -3) browser.error = description }) contents.on('render-process-gone', (_event, details) => { browser.error = `浏览器进程已结束:${details.reason}` }) contents.on('destroyed', () => browsers.delete(id)) owner.contentView.addChildView(view) view.setVisible(false) if (!owners.has(owner.id)) { owners.add(owner.id) owner.once('closed', () => { owners.delete(owner.id); for (const record of [...browsers.values()]) if (record.owner === owner) release(record) }) owner.on('resize', () => { if (!owner.isDestroyed()) for (const record of browsers.values()) if (record.owner === owner && record.visible) { const [width, height] = owner.getContentSize(); const bounds = clampBrowserHostBounds(record.view.getBounds(), width, height) record.view.setBounds(bounds) } }) } if (url !== 'about:blank') void load(browser, url) return snapshot(browser) } export function getBrowserState(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView { return snapshot(requireBrowser(owner, id, sessionId)) } /** The IPC caller validates the destination YCode session before transferring a draft tab. */ export function rebindBrowser(owner: BrowserWindow, id: string, previousSessionId: string | undefined, nextSessionId: string): BrowserHostView { const browser = requireBrowser(owner, id, previousSessionId) if (typeof nextSessionId !== 'string' || !nextSessionId || nextSessionId.length > 256) throw new Error('目标任务标识无效') browser.sessionId = nextSessionId return snapshot(browser) } export async function navigateBrowser(owner: BrowserWindow, id: string, url: string, sessionId?: string): Promise { const browser = requireBrowser(owner, id, sessionId) const target = normalizeBrowserHostUrl(url) await load(browser, target) return snapshot(browser) } export function backBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView { const browser = requireBrowser(owner, id, sessionId) if (browser.view.webContents.navigationHistory.canGoBack()) browser.view.webContents.navigationHistory.goBack() return snapshot(browser) } export function forwardBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView { const browser = requireBrowser(owner, id, sessionId) if (browser.view.webContents.navigationHistory.canGoForward()) browser.view.webContents.navigationHistory.goForward() return snapshot(browser) } export function reloadBrowser(owner: BrowserWindow, id: string, sessionId?: string): BrowserHostView { const browser = requireBrowser(owner, id, sessionId) browser.error = undefined; browser.view.webContents.reload() return snapshot(browser) } export function setBrowserBounds(owner: BrowserWindow, id: string, bounds: BrowserHostBounds | null, sessionId?: string): void { // Component teardown can finish after the tab has already released its native view. if (bounds === null && !browsers.has(id)) return const browser = requireBrowser(owner, id, sessionId) if (bounds === null) { browser.view.setVisible(false); browser.visible = false; return } const [width, height] = owner.getContentSize() const clamped = clampBrowserHostBounds(bounds, width, height) browser.view.setBounds(clamped) browser.visible = clamped.width > 0 && clamped.height > 0 browser.view.setVisible(browser.visible) } export function hideBrowser(owner: BrowserWindow, id: string, sessionId?: string): void { setBrowserBounds(owner, id, null, sessionId) } export function closeBrowser(owner: BrowserWindow, id: string, sessionId?: string): void { if (!browsers.has(id)) return release(requireBrowser(owner, id, sessionId)) } export function disposeOwnerBrowsers(owner: BrowserWindow): void { for (const browser of [...browsers.values()]) if (browser.owner === owner) release(browser) }