import { BrowserView, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron' import { randomUUID } from 'crypto' import { IPC_EVENTS } from '@runtime/lib/constants' import path from 'node:path' declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; type TabId = string type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean } const UI_HEIGHT = 88 const preloadEntryPath = MAIN_WINDOW_VITE_DEV_SERVER_URL ? path.join(process.cwd(), 'dist-electron', 'preload', 'preload.js') : path.join(__dirname, '..', 'preload', 'preload.js') export class TabManager { private win: BrowserWindow private views: Map = new Map() private activeId: TabId | null = null private skipNextNavigate: Map = new Map() private enabled = false constructor(win: BrowserWindow) { this.win = win this.win.on('resize', () => this.updateActiveBounds()) this._setupIpcEvents() } private _setupIpcEvents() { ipcMain.handle(IPC_EVENTS.TAB_CREATE, (_e, url?: string) => { const info = this.create(url) return info }) ipcMain.handle(IPC_EVENTS.TAB_LIST, () => { return this.list() }) ipcMain.handle(IPC_EVENTS.TAB_NAVIGATE, (_e, { tabId, url }: { tabId: TabId; url: string }) => { this.navigate(tabId, url) }) ipcMain.handle(IPC_EVENTS.TAB_RELOAD, (_e, tabId: TabId) => { this.reload(tabId) }) ipcMain.handle(IPC_EVENTS.TAB_BACK, (_e, tabId: TabId) => { this.goBack(tabId) }) ipcMain.handle(IPC_EVENTS.TAB_FORWARD, (_e, tabId: TabId) => { this.goForward(tabId) }) ipcMain.handle(IPC_EVENTS.TAB_SWITCH, (_e, tabId: TabId) => { this.switch(tabId) }) ipcMain.handle(IPC_EVENTS.TAB_CLOSE, (_e, tabId: TabId) => { this.close(tabId) }) } enable() { this.enabled = true this.updateActiveBounds() if (this.activeId) this.attach(this.activeId) } disable() { this.enabled = false const view = this.activeId ? this.views.get(this.activeId) : null if (view) this.win.removeBrowserView(view) } destroy() { this.disable() this.views.forEach(view => { // @ts-ignore view.webContents.destroy() }) this.views.clear() ipcMain.removeHandler(IPC_EVENTS.TAB_CREATE) ipcMain.removeHandler(IPC_EVENTS.TAB_LIST) ipcMain.removeHandler(IPC_EVENTS.TAB_NAVIGATE) ipcMain.removeHandler(IPC_EVENTS.TAB_RELOAD) ipcMain.removeHandler(IPC_EVENTS.TAB_BACK) ipcMain.removeHandler(IPC_EVENTS.TAB_FORWARD) ipcMain.removeHandler(IPC_EVENTS.TAB_SWITCH) ipcMain.removeHandler(IPC_EVENTS.TAB_CLOSE) } list(): TabInfo[] { return Array.from(this.views.entries()).map(([id, view]) => this.info(id, view)) } create(url?: string, active = true): TabInfo { const id = randomUUID() const view = new BrowserView({ webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, preload: preloadEntryPath, }, }) this.views.set(id, view) if (this.enabled && active) this.attach(id) const target = url && url.length > 0 ? url : 'about:blank' view.webContents.loadURL(target) this.bindEvents(id, view) const info = this.info(id, view) this.win.webContents.send('tab-created', info) return info } switch(tabId: TabId): void { if (!this.views.has(tabId)) return if (this.enabled) this.attach(tabId) this.win.webContents.send('tab-switched', { tabId }) } close(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (this.activeId === tabId) { this.win.removeBrowserView(view) this.activeId = null } // @ts-ignore view.webContents.destroy() this.views.delete(tabId) this.win.webContents.send('tab-closed', { tabId }) const next = this.views.keys().next().value as TabId | undefined if (next) this.switch(next) } navigate(tabId: TabId, url: string): void { const view = this.views.get(tabId) if (!view) return this.skipNextNavigate.set(tabId, true) view.webContents.loadURL(url) } reload(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return view.webContents.reload() } goBack(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (view.webContents.canGoBack()) view.webContents.goBack() } goForward(tabId: TabId): void { const view = this.views.get(tabId) if (!view) return if (view.webContents.canGoForward()) view.webContents.goForward() } private attach(tabId: TabId): void { if (!this.enabled) return const view = this.views.get(tabId) if (!view) return if (this.activeId && this.views.get(this.activeId)) { const prev = this.views.get(this.activeId)! this.win.removeBrowserView(prev) } this.activeId = tabId this.win.addBrowserView(view) this.updateActiveBounds() } private updateActiveBounds(): void { if (!this.enabled || !this.activeId) return const view = this.views.get(this.activeId) if (!view) return const [winWidth, winHeight] = this.win.getContentSize() const HEADER_HEIGHT = 88 const PADDING = 8 const RIGHT_PANEL_WIDTH = 392 + 80 + 8 + 8 // TaskList + SideMenu + Gap + RightPadding const x = PADDING const y = HEADER_HEIGHT + PADDING const width = winWidth - RIGHT_PANEL_WIDTH - PADDING const height = winHeight - HEADER_HEIGHT - (PADDING * 2) view.setBounds({ x, y, width: Math.max(0, width), height: Math.max(0, height) }) } private bindEvents(id: TabId, view: BrowserView): void { const send = () => this.win.webContents.send('tab-updated', this.info(id, view)) view.webContents.on('did-start-loading', send) view.webContents.on('did-stop-loading', send) view.webContents.on('did-finish-load', send) view.webContents.on('page-title-updated', send) view.webContents.on('did-navigate', send) view.webContents.on('did-navigate-in-page', send) view.webContents.on('will-navigate', (event, url) => { if (this.skipNextNavigate.get(id)) { this.skipNextNavigate.set(id, false) return } event.preventDefault() this.create(url) }) view.webContents.setWindowOpenHandler(({ url }) => { this.create(url) return { action: 'deny' } }) } private info(id: TabId, view: BrowserView): TabInfo { const wc = view.webContents return { id, url: wc.getURL(), title: wc.getTitle(), isLoading: wc.isLoading(), canGoBack: wc.canGoBack(), canGoForward: wc.canGoForward() } } }