import { join } from 'node:path' import type { Session, WebContents } from 'electron/main' import { BrowserWindow, WebContentsView, ipcMain, nativeTheme } from 'electron/main' import { STANDARD_USER_AGENT } from './user-agent' interface WorkbenchWindowOptions { title: string targetURL: string session: Session } interface WorkbenchNavigationState { canGoBack: boolean canGoForward: boolean loading: boolean url: string title: string host: string secure: boolean } interface WorkbenchWindowRecord { shellContentsId: number window: BrowserWindow view: WebContentsView title: string initialURL: string activeTargetURL: string pageTitle: string loading: boolean } const TOOLBAR_HEIGHT = 46 const workbenchWindows = new Map() let handlersRegistered = false function preloadPath(): string { return join(__dirname, '../preload/bridge.cjs') } function titleBarStyle(): 'hidden' | 'hiddenInset' { return process.platform === 'darwin' ? 'hiddenInset' : 'hidden' } function titleBarOverlay(): { color: string; symbolColor: string; height: number } | undefined { if (process.platform === 'darwin') { return undefined } return { color: nativeTheme.shouldUseDarkColors ? '#15191a' : '#fbfcfd', symbolColor: nativeTheme.shouldUseDarkColors ? '#f8fafc' : '#111827', height: TOOLBAR_HEIGHT, } } function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } function hostFromURL(rawURL: string): string { try { return new URL(rawURL).host } catch { return '' } } function isRemoteHTTPURL(rawURL: string): boolean { return /^https?:\/\//i.test(rawURL) } function isLocalWorkbenchPage(rawURL: string): boolean { return rawURL.startsWith('data:text/html') } function workbenchShellHTML(title: string, targetURL: string): string { const initialHost = hostFromURL(targetURL) const platformClass = `platform-${process.platform}` return ` ${escapeHtml(title)}
${escapeHtml(title)} ${escapeHtml(initialHost)}
` } function workbenchShellDataURL(title: string, targetURL: string): string { return `data:text/html;charset=UTF-8,${encodeURIComponent(workbenchShellHTML(title, targetURL))}` } function workbenchErrorDataURL(title: string, targetURL: string, message: string): string { const host = hostFromURL(targetURL) const html = ` ${escapeHtml(title)} - 加载失败

${escapeHtml(title)}加载失败

远程页面暂时无法打开。可以使用窗口左上方的刷新按钮重试。

${escapeHtml(host || targetURL)} ${escapeHtml(message)}
` return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}` } function stateForRecord(record: WorkbenchWindowRecord): WorkbenchNavigationState { const contents = record.view.webContents const currentURL = contents.isDestroyed() ? '' : contents.getURL() const displayURL = isLocalWorkbenchPage(currentURL) ? record.activeTargetURL : currentURL return { canGoBack: !contents.isDestroyed() && contents.canGoBack(), canGoForward: !contents.isDestroyed() && contents.canGoForward(), loading: record.loading || (!contents.isDestroyed() && contents.isLoading()), url: displayURL, title: record.pageTitle || record.title, host: hostFromURL(displayURL), secure: displayURL.startsWith('https://'), } } function sendNavigationState(record: WorkbenchWindowRecord): void { if (record.window.isDestroyed()) { return } record.window.webContents.send('desktop:workbench-navigation-state', stateForRecord(record)) } function getRecordForSender(sender: WebContents): WorkbenchWindowRecord | null { const record = workbenchWindows.get(sender.id) if (!record || record.window.isDestroyed() || record.view.webContents.isDestroyed()) { return null } return record } function getRecordForWindow(window: BrowserWindow): WorkbenchWindowRecord | null { const record = workbenchWindows.get(window.webContents.id) if ( !record || record.window !== window || record.window.isDestroyed() || record.view.webContents.isDestroyed() ) { return null } return record } function defaultNavigationState(): WorkbenchNavigationState { return { canGoBack: false, canGoForward: false, loading: false, url: '', title: '', host: '', secure: false, } } function registerWorkbenchNavigationHandlers(): void { if (handlersRegistered) { return } handlersRegistered = true ipcMain.handle('desktop:workbench-navigation:get-state', (event) => { const record = getRecordForSender(event.sender) return record ? stateForRecord(record) : defaultNavigationState() }) ipcMain.handle('desktop:workbench-navigation:go-back', (event) => { const record = getRecordForSender(event.sender) if (record?.view.webContents.canGoBack()) { record.view.webContents.goBack() } return null }) ipcMain.handle('desktop:workbench-navigation:go-forward', (event) => { const record = getRecordForSender(event.sender) if (record?.view.webContents.canGoForward()) { record.view.webContents.goForward() } return null }) ipcMain.handle('desktop:workbench-navigation:reload', (event) => { const record = getRecordForSender(event.sender) if (!record) { return null } const currentURL = record.view.webContents.getURL() if (isLocalWorkbenchPage(currentURL)) { void navigateWorkbenchRemote(record, record.activeTargetURL || record.initialURL) } else { record.view.webContents.reload() } return null }) } function layoutWorkbenchView(record: WorkbenchWindowRecord): void { if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) { return } const [width, height] = record.window.getContentSize() record.view.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width: Math.max(0, width), height: Math.max(0, height - TOOLBAR_HEIGHT), }) } function attachWorkbenchViewEvents(record: WorkbenchWindowRecord): void { const { webContents } = record.view webContents.on('did-start-loading', () => { record.loading = true sendNavigationState(record) }) webContents.on('did-stop-loading', () => { record.loading = false sendNavigationState(record) }) webContents.on('did-start-navigation', (_event, url, isInPlace, isMainFrame) => { if (isMainFrame && isRemoteHTTPURL(url)) { record.activeTargetURL = url record.loading = !isInPlace sendNavigationState(record) } }) webContents.on('did-navigate', (_event, url) => { if (isRemoteHTTPURL(url)) { record.activeTargetURL = url } sendNavigationState(record) }) webContents.on('did-navigate-in-page', (_event, url) => { if (isRemoteHTTPURL(url)) { record.activeTargetURL = url } sendNavigationState(record) }) webContents.on('page-title-updated', (_event, pageTitle) => { record.pageTitle = pageTitle || record.title sendNavigationState(record) }) webContents.on( 'did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { if (!isMainFrame || errorCode === -3 || errorCode === -20) { return } const targetURL = validatedURL || record.activeTargetURL || record.initialURL console.warn('[desktop-workbench] workbench page load failed', { title: record.title, targetURL, errorCode, errorDescription, }) void showWorkbenchError(record, targetURL, `${errorCode}: ${errorDescription}`) }, ) webContents.setWindowOpenHandler(({ url }) => { if (isRemoteHTTPURL(url)) { setImmediate(() => { void navigateWorkbenchRemote(record, url) }) } return { action: 'deny' } }) } async function showWorkbenchError( record: WorkbenchWindowRecord, targetURL: string, message: string, ): Promise { if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) { return } record.loading = false record.activeTargetURL = targetURL record.pageTitle = `${record.title}加载失败` await record.view.webContents.loadURL(workbenchErrorDataURL(record.title, targetURL, message)) sendNavigationState(record) } async function navigateWorkbenchRemote( record: WorkbenchWindowRecord, targetURL: string, ): Promise { if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) { return } record.activeTargetURL = targetURL record.loading = true sendNavigationState(record) try { await record.view.webContents.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT }) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (/ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|\((-3|-20)\) loading/i.test(message)) { return } await showWorkbenchError(record, targetURL, message) } } export function getWorkbenchWindowWebContents(window: BrowserWindow): WebContents | null { return getRecordForWindow(window)?.view.webContents ?? null } export function isWorkbenchWindowReadyForDetection(window: BrowserWindow): boolean { const record = getRecordForWindow(window) if (!record) { return false } const currentURL = record.view.webContents.getURL() return ( isRemoteHTTPURL(currentURL) && !isLocalWorkbenchPage(currentURL) && !record.loading && !record.view.webContents.isLoading() ) } export async function loadWorkbenchWindowURL( window: BrowserWindow, targetURL: string, ): Promise { const record = getRecordForWindow(window) if (!record) { return false } await navigateWorkbenchRemote(record, targetURL) return true } export async function openWorkbenchWindow(options: WorkbenchWindowOptions): Promise { registerWorkbenchNavigationHandlers() const window = new BrowserWindow({ show: false, width: 1320, height: 900, minWidth: 1100, minHeight: 760, title: options.title, autoHideMenuBar: true, titleBarStyle: titleBarStyle(), titleBarOverlay: titleBarOverlay(), trafficLightPosition: process.platform === 'darwin' ? { x: 14, y: 16 } : undefined, backgroundColor: nativeTheme.shouldUseDarkColors ? '#15191a' : '#fbfcfd', webPreferences: { preload: preloadPath(), sandbox: true, contextIsolation: true, nodeIntegration: false, }, }) const view = new WebContentsView({ webPreferences: { session: options.session, sandbox: true, contextIsolation: true, nodeIntegration: false, }, }) view.webContents.setUserAgent(STANDARD_USER_AGENT) window.contentView.addChildView(view) const record: WorkbenchWindowRecord = { shellContentsId: window.webContents.id, window, view, title: options.title, initialURL: options.targetURL, activeTargetURL: options.targetURL, pageTitle: options.title, loading: false, } workbenchWindows.set(record.shellContentsId, record) attachWorkbenchViewEvents(record) window.on('resize', () => { layoutWorkbenchView(record) }) window.on('closed', () => { workbenchWindows.delete(record.shellContentsId) if (!record.view.webContents.isDestroyed()) { record.view.webContents.close() } }) await window.webContents.loadURL(workbenchShellDataURL(options.title, options.targetURL)) layoutWorkbenchView(record) sendNavigationState(record) void navigateWorkbenchRemote(record, options.targetURL) return window }