/** * WebTab 窗口管理模块 * * 负责管理多标签浏览器窗口,支持: * - tab 模式:标签页模式(有导航栏) * - window 模式:独立窗口模式(无导航栏) */ const path = require('path') const os = require('os') const { ipcMain, clipboard, nativeTheme, screen, Menu, WebContentsView, BrowserWindow } = require('electron') const utils = require('./utils') const navigation = require('./navigation') const { allowedCalls, isMac } = require('./other') const faviconCache = require('./favicon-cache') const { renderer } = require('./renderer') // ============================================================ // 状态变量 // ============================================================ // Map let webTabWindows = new Map() let webTabWindowIdCounter = 1 // 标签名称到标签位置的映射,用于复用已存在的标签 // Map let webTabNameMap = new Map() // 标签栏高度 const webTabHeight = 40 // 快捷键关闭状态 Map let webTabClosedByShortcut = new Map() // ============================================================ // 预加载池 // ============================================================ // 预加载 view 池 let preloadViewPool = [] // 预加载配置 const PRELOAD_CONFIG = { poolSize: 1, // 池大小 warmupDelay: 1000, // 启动后延迟创建时间(ms) refillDelay: 500, // 取走后补充延迟时间(ms) } // 预加载定时器(用于防抖补充) let preloadRefillTimer = null // ============================================================ // 依赖注入 // ============================================================ let _context = null /** * 初始化模块 * @param {Object} context * @param {Function} context.getServerUrl - 获取服务器URL * @param {Function} context.getUserConf - 获取用户配置 * @param {Function} context.isWillQuitApp - 是否即将退出应用 * @param {Object} context.electronMenu - 菜单模块 */ function init(context) { _context = context } /** * 获取服务器URL */ function getServerUrl() { return _context?.getServerUrl?.() || '' } /** * 获取用户配置 */ function getUserConf() { return _context?.getUserConf?.() } /** * 是否即将退出应用 */ function isWillQuitApp() { return _context?.isWillQuitApp?.() || false } /** * 获取菜单模块 */ function getElectronMenu() { return _context?.electronMenu } // ============================================================ // 预加载函数 // ============================================================ /** * 创建预加载 view * 预加载 /preload 路由,完成基础 JS 文件加载 * @returns {WebContentsView} */ function createPreloadView() { const serverUrl = getServerUrl() if (!serverUrl) { return null } const browserView = new WebContentsView({ webPreferences: { preload: path.join(__dirname, '..', 'electron-preload.js'), nodeIntegration: true, contextIsolation: true, } }) const originalUA = browserView.webContents.session.getUserAgent() || browserView.webContents.getUserAgent() browserView.webContents.setUserAgent(originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0") utils.loadUrl(browserView.webContents, serverUrl, '/preload') browserView._isPreloaded = true browserView._preloadReady = false browserView.webContents.on('did-finish-load', () => { browserView._preloadReady = true }) return browserView } /** * 预热预加载池(延迟创建,避免影响主窗口加载) */ function warmupPreloadPool() { if (!getServerUrl()) return setTimeout(() => { while (preloadViewPool.length < PRELOAD_CONFIG.poolSize) { const view = createPreloadView() if (view) { preloadViewPool.push(view) } else { break } } }, PRELOAD_CONFIG.warmupDelay) } /** * 从池中获取预加载 view(优先取已就绪的) * @returns {WebContentsView|null} */ function getPreloadedView() { // 优先取已就绪的 const readyIndex = preloadViewPool.findIndex(v => v._preloadReady && !v.webContents.isDestroyed()) if (readyIndex >= 0) { const view = preloadViewPool.splice(readyIndex, 1)[0] scheduleRefillPool() return view } // 次选任意可用(可能还在加载中) const availableIndex = preloadViewPool.findIndex(v => !v.webContents.isDestroyed()) if (availableIndex >= 0) { const view = preloadViewPool.splice(availableIndex, 1)[0] scheduleRefillPool() return view } return null } /** * 延迟补充预加载池 */ function scheduleRefillPool() { if (preloadRefillTimer) clearTimeout(preloadRefillTimer) preloadRefillTimer = setTimeout(() => { preloadRefillTimer = null while (preloadViewPool.length < PRELOAD_CONFIG.poolSize) { const view = createPreloadView() if (view) { preloadViewPool.push(view) } else { break } } }, PRELOAD_CONFIG.refillDelay) } /** * 清理预加载池 */ function clearPreloadPool() { if (preloadRefillTimer) { clearTimeout(preloadRefillTimer) preloadRefillTimer = null } preloadViewPool.forEach(view => { try { if (!view.webContents.isDestroyed()) { view.webContents.close() } } catch (e) { // ignore } }) preloadViewPool = [] } // ============================================================ // 核心函数 // ============================================================ /** * 创建内置浏览器窗口(支持多窗口) * @param args {url, windowId, position, afterId, insertIndex, name, force, userAgent, title, titleFixed, webPreferences, mode, ...} * - mode: 'tab' | 'window' * - 'window': 独立窗口模式(无导航栏) * - 'tab': 标签页模式(默认,有导航栏) * @returns {number} 窗口ID */ function createWebTabWindow(args) { if (!args) { return } if (!utils.isJson(args)) { args = { url: args } } const mode = args.mode || 'tab' const isWindowMode = mode === 'window' // 查找同名标签/窗口 if (args.name) { const existing = webTabNameMap.get(args.name) if (existing) { const existingWindowData = webTabWindows.get(existing.windowId) if (existingWindowData && existingWindowData.window && !existingWindowData.window.isDestroyed()) { const viewItem = existingWindowData.views.find(v => v.id === existing.tabId) if (viewItem && viewItem.view && !viewItem.view.webContents.isDestroyed()) { if (existingWindowData.window.isMinimized()) { existingWindowData.window.restore() } existingWindowData.window.focus() existingWindowData.window.show() activateWebTabInWindow(existing.windowId, existing.tabId) if (args.force === true && args.url) { utils.loadContentUrl(viewItem.view.webContents, getServerUrl(), args.url) } return existing.windowId } } webTabNameMap.delete(args.name) } } let windowId = args.windowId let windowData = windowId ? webTabWindows.get(windowId) : null let webTabWindow = windowData ? windowData.window : null // window 模式创建新窗口;tab 模式尝试复用已有窗口 if (!webTabWindow) { if (!isWindowMode && !windowId) { for (const [id, data] of webTabWindows) { if (data.window && !data.window.isDestroyed() && data.mode !== 'window') { windowId = id windowData = data webTabWindow = data.window break } } } if (!webTabWindow) { windowId = webTabWindowIdCounter++ const position = { x: args.x, y: args.y, width: args.width, height: args.height, minWidth: args.minWidth, minHeight: args.minHeight, } webTabWindow = createWebTabWindowInstance(windowId, position, mode) windowData = { window: webTabWindow, views: [], activeTabId: null, mode: mode } webTabWindows.set(windowId, windowData) } } if (webTabWindow.isMinimized()) { webTabWindow.restore() } webTabWindow.focus() webTabWindow.show() const browserView = createWebTabView(windowId, args) let insertIndex = windowData.views.length if (args.afterId) { const afterIndex = windowData.views.findIndex(item => item.id === args.afterId) if (afterIndex > -1) insertIndex = afterIndex + 1 } if (typeof args.insertIndex === 'number') { insertIndex = Math.max(0, Math.min(args.insertIndex, windowData.views.length)) } windowData.views.splice(insertIndex, 0, { id: browserView.webContents.id, view: browserView, name: args.name || null }) if (args.name) { webTabNameMap.set(args.name, { windowId: windowId, tabId: browserView.webContents.id }) } if (isWindowMode) { if (args.title) webTabWindow.setTitle(args.title) } else { const domain = faviconCache.extractDomain(args.url) const cachedFavicon = domain ? faviconCache.getByDomain(domain) : null if (cachedFavicon) { const viewItem = windowData.views.find(v => v.id === browserView.webContents.id) if (viewItem) viewItem.favicon = cachedFavicon } utils.onDispatchEvent(webTabWindow.webContents, { event: 'create', id: browserView.webContents.id, url: args.url, afterId: args.afterId, windowId: windowId, title: args.title, favicon: cachedFavicon || '', }).then(_ => { }) } activateWebTabInWindow(windowId, browserView.webContents.id) return windowId } /** * 创建 WebTabWindow 实例 * @param windowId * @param position {x, y, width, height} * @param mode 'tab' | 'window' * @returns {BrowserWindow} */ function createWebTabWindowInstance(windowId, position, mode = 'tab') { const isWindowMode = mode === 'window' const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize const isHighRes = screenWidth >= 2560 // 根据屏幕分辨率计算默认尺寸 const screenDefault = { width: isHighRes ? 1440 : 1024, height: isHighRes ? 900 : 768, minWidth: 400, minHeight: 300, } // 计算窗口尺寸和位置 const windowOptions = { show: false, autoHideMenuBar: true, webPreferences: { preload: path.join(__dirname, '..', 'electron-preload.js'), webSecurity: true, nodeIntegration: true, contextIsolation: true, }, } const userConf = getUserConf() if (isWindowMode) { // window 模式:使用 position 参数或屏幕默认值,永远居中 Object.assign(windowOptions, { width: Math.floor(position?.width ?? screenDefault.width), height: Math.floor(position?.height ?? screenDefault.height), minWidth: Math.floor(position?.minWidth ?? screenDefault.minWidth), minHeight: Math.floor(position?.minHeight ?? screenDefault.minHeight), backgroundColor: utils.getDefaultBackgroundColor(), center: true, }) } else { // tab 模式:使用 savedBounds 或屏幕默认值 const savedBounds = userConf?.get('webTabWindow') || {} const maxX = Math.floor(screenWidth * 0.9) const maxY = Math.floor(screenHeight * 0.9) Object.assign(windowOptions, { width: savedBounds.width ?? screenDefault.width, height: savedBounds.height ?? screenDefault.height, minWidth: screenDefault.minWidth, minHeight: screenDefault.minHeight, backgroundColor: nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF', center: true, }) // 恢复保存的位置,并限制在屏幕 90% 范围内 if (savedBounds.x !== undefined && savedBounds.y !== undefined) { Object.assign(windowOptions, { x: Math.min(savedBounds.x, maxX), y: Math.min(savedBounds.y, maxY), center: false, }) } } // tab 模式使用隐藏标题栏 + titleBarOverlay if (!isWindowMode) { const titleBarOverlay = { height: webTabHeight } if (nativeTheme.shouldUseDarkColors) { Object.assign(titleBarOverlay, { color: '#3B3B3D', symbolColor: '#C5C5C5', }) } Object.assign(windowOptions, { titleBarStyle: 'hidden', titleBarOverlay: titleBarOverlay, }) } const webTabWindow = new BrowserWindow(windowOptions) // 保存窗口ID到窗口对象 webTabWindow.webTabWindowId = windowId const originalClose = webTabWindow.close webTabWindow.close = function() { webTabClosedByShortcut.set(windowId, true) return originalClose.apply(this, arguments) } webTabWindow.on('resize', () => { resizeWebTabInWindow(windowId, 0) }) webTabWindow.on('enter-full-screen', () => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'enter-full-screen', }).then(_ => { }) }) webTabWindow.on('leave-full-screen', () => { utils.onDispatchEvent(webTabWindow.webContents, { event: 'leave-full-screen', }).then(_ => { }) }) webTabWindow.on('close', event => { if (webTabClosedByShortcut.get(windowId)) { webTabClosedByShortcut.set(windowId, false) if (!isWillQuitApp()) { closeWebTabInWindow(windowId, 0) event.preventDefault() return } } // 只有 tab 模式才保存 bounds const windowData = webTabWindows.get(windowId) if (windowData && windowData.mode !== 'window') { userConf?.set('webTabWindow', webTabWindow.getBounds()) } }) webTabWindow.on('closed', () => { const windowData = webTabWindows.get(windowId) if (windowData) { windowData.views.forEach(({ view, name }) => { // 清理 name 映射 if (name) { webTabNameMap.delete(name) } try { view.webContents.close() } catch (e) { // } }) webTabWindows.delete(windowId) } webTabClosedByShortcut.delete(windowId) }) webTabWindow.once('ready-to-show', () => { onShowWindow(webTabWindow) }) webTabWindow.webContents.once('dom-ready', () => { onShowWindow(webTabWindow) }) webTabWindow.webContents.on('before-input-event', (event, input) => { if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') { reloadWebTabInWindow(windowId, 0) event.preventDefault() } else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') { webTabClosedByShortcut.set(windowId, true) } else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') { devToolsWebTabInWindow(windowId, 0) } else { const item = currentWebTabInWindow(windowId) if (item) { navigation.handleInput(event, input, item.view.webContents) } } }) // 设置鼠标侧键和触控板手势导航 navigation.setupWindowEvents(webTabWindow, () => { const item = currentWebTabInWindow(windowId) return item ? item.view.webContents : null }) // tab 模式加载标签栏界面,window 模式不需要 if (!isWindowMode) { webTabWindow.loadFile(path.join(__dirname, '..', 'render', 'tabs', 'index.html'), { query: { windowId: String(windowId) } }).then(_ => { }).catch(_ => { }) } return webTabWindow } /** * 创建 WebTab 视图 * @param windowId * @param args * @returns {WebContentsView} */ function createWebTabView(windowId, args) { const windowData = webTabWindows.get(windowId) if (!windowData) return null const webTabWindow = windowData.window const isWindowMode = windowData.mode === 'window' const effectiveTabHeight = isWindowMode ? 0 : webTabHeight const electronMenu = getElectronMenu() const serverUrl = getServerUrl() // 尝试复用预加载 view(本地站点且无特殊配置) let browserView = null let isPreloaded = false const isLocalUrl = !args.url || !args.url.startsWith('http') || utils.getDomain(args.url) === utils.getDomain(serverUrl) const hasCustomPreferences = args.webPreferences && Object.keys(args.webPreferences).length > 0 if (isLocalUrl && !hasCustomPreferences) { browserView = getPreloadedView() if (browserView) isPreloaded = true } if (!browserView) { const viewOptions = { webPreferences: Object.assign({ preload: path.join(__dirname, '..', 'electron-preload.js'), nodeIntegration: true, contextIsolation: true }, args.webPreferences || {}) } if (!viewOptions.webPreferences.contextIsolation) { delete viewOptions.webPreferences.preload } browserView = new WebContentsView(viewOptions) } if (args.backgroundColor) { browserView.setBackgroundColor(args.backgroundColor) } else if (isWindowMode) { browserView.setBackgroundColor(utils.getDefaultBackgroundColor()) } else if (nativeTheme.shouldUseDarkColors) { browserView.setBackgroundColor('#575757') } else { browserView.setBackgroundColor('#FFFFFF') } browserView.setBounds({ x: 0, y: effectiveTabHeight, width: webTabWindow.getContentBounds().width || 1280, height: (webTabWindow.getContentBounds().height || 800) - effectiveTabHeight, }) browserView.webTabWindowId = windowId browserView.tabName = args.name || null browserView.titleFixed = args.titleFixed || false if (!isPreloaded && args.userAgent) { const originalUA = browserView.webContents.getUserAgent() browserView.webContents.setUserAgent( originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0 " + args.userAgent ) } browserView.webContents.on('destroyed', () => { if (browserView.tabName) { webTabNameMap.delete(browserView.tabName) } if (browserView._loadingChecker) { clearInterval(browserView._loadingChecker) browserView._loadingChecker = null } closeWebTabInWindow(windowId, browserView.webContents.id) }) browserView.webContents.setWindowOpenHandler(({ url }) => { if (allowedCalls.test(url)) { renderer.openExternal(url).catch(() => {}) } else if (isWindowMode) { // window 模式下打开外部浏览器 utils.onBeforeOpenWindow(browserView.webContents, url).then(() => { renderer.openExternal(url).catch(() => {}) }) } else if (url && url !== 'about:blank') { // tab 模式下创建新标签 createWebTabWindow({ url, afterId: browserView.webContents.id, windowId }) } return { action: 'deny' } }) browserView.webContents.on('page-title-updated', (_, title) => { // titleFixed 时不更新标题 if (browserView.titleFixed) { return } // 使用动态窗口ID,支持标签在窗口间转移 const currentWindowId = browserView.webTabWindowId const wd = webTabWindows.get(currentWindowId) if (!wd || !wd.window) return // 根据模式更新标题 if (wd.mode === 'window') { // window 模式下直接设置窗口标题 wd.window.setTitle(title) } else { // tab 模式下通知标签栏更新标题 utils.onDispatchEvent(wd.window.webContents, { event: 'title', id: browserView.webContents.id, title: title, url: browserView.webContents.getURL(), }).then(_ => { }) } }) browserView.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { if (!errorDescription) { return } // 主框架加载失败时,展示内置的错误页面 if (isMainFrame) { const originalUrl = validatedURL || args.url || '' const filePath = path.join(__dirname, '..', 'render', 'tabs', 'error.html') browserView.webContents.loadFile(filePath, { query: { id: String(browserView.webContents.id), url: originalUrl, code: String(errorCode), desc: errorDescription, } }).then(_ => { }).catch(_ => { }) return } // 使用动态窗口ID,支持标签在窗口间转移 const currentWindowId = browserView.webTabWindowId const wd = webTabWindows.get(currentWindowId) if (!wd || !wd.window) return utils.onDispatchEvent(wd.window.webContents, { event: 'title', id: browserView.webContents.id, title: errorDescription, url: browserView.webContents.getURL(), }).then(_ => { }) }) browserView.webContents.on('page-favicon-updated', async (_, favicons) => { // 使用动态窗口ID,支持标签在窗口间转移 const currentWindowId = browserView.webTabWindowId const wd = webTabWindows.get(currentWindowId) if (!wd || !wd.window) return const tabId = browserView.webContents.id const faviconUrl = favicons[favicons.length - 1] || '' const pageUrl = browserView.webContents.getURL() // 使用缓存模块获取 favicon(先查缓存,无则下载并缓存) const base64Favicon = await faviconCache.fetchAndCache(faviconUrl, pageUrl) // 保存验证后的 favicon 到视图对象 const viewItem = wd.views.find(v => v.id === tabId) if (viewItem) { viewItem.favicon = base64Favicon || '' } // 发送验证后的 favicon 给前端 utils.onDispatchEvent(wd.window.webContents, { event: 'favicon', id: tabId, favicon: base64Favicon || '' }).then(_ => { }) }) // 页面加载状态管理,忽略SPA路由切换(isSameDocument) browserView._loadingActive = false browserView._loadingChecker = null const dispatchLoading = (event) => { const wd = webTabWindows.get(browserView.webTabWindowId) if (wd && wd.window) { utils.onDispatchEvent(wd.window.webContents, { event, id: browserView.webContents.id, }).then(_ => { }) } } const startLoading = () => { if (browserView._loadingActive) return browserView._loadingActive = true dispatchLoading('start-loading') if (!browserView._loadingChecker) { browserView._loadingChecker = setInterval(() => { if (browserView.webContents.isDestroyed() || !browserView.webContents.isLoading()) { stopLoading() } }, 3000) } } const stopLoading = () => { if (browserView._loadingChecker) { clearInterval(browserView._loadingChecker) browserView._loadingChecker = null } if (!browserView._loadingActive) return browserView._loadingActive = false dispatchLoading('stop-loading') } browserView.webContents.on('did-start-navigation', (_, _url, _isInPlace, isMainFrame, _frameProcessId, _frameRoutingId, _navigationId, isSameDocument) => { if (isMainFrame && !isSameDocument) { startLoading() } }) browserView.webContents.on('did-stop-loading', _ => { stopLoading() if (nativeTheme.shouldUseDarkColors) { browserView.setBackgroundColor('#FFFFFF') } }) browserView.webContents.on('before-input-event', (event, input) => { // 使用动态窗口ID,支持标签在窗口间转移 const currentWindowId = browserView.webTabWindowId if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'r') { browserView.webContents.reload() event.preventDefault() } else if (utils.isMetaOrControl(input) && input.key.toLowerCase() === 'w') { webTabClosedByShortcut.set(currentWindowId, true) } else if (utils.isMetaOrControl(input) && input.shift && input.key.toLowerCase() === 'i') { browserView.webContents.toggleDevTools() } else { navigation.handleInput(event, input, browserView.webContents) } }) if (!isPreloaded) { const originalUA = browserView.webContents.session.getUserAgent() || browserView.webContents.getUserAgent() browserView.webContents.setUserAgent(originalUA + " SubTaskWindow/" + process.platform + "/" + os.arch() + "/1.0") } electronMenu?.webContentsMenu(browserView.webContents, true) // 加载业务路由(预加载 view 通过 __initializeApp 触发路由切换) if (isPreloaded) { const targetUrl = args.url || '' browserView.webContents.executeJavaScript( `window.__initializeApp && window.__initializeApp('${targetUrl.replace(/'/g, "\\'")}')` ).catch(() => { utils.loadContentUrl(browserView.webContents, serverUrl, args.url) }) } else { utils.loadContentUrl(browserView.webContents, serverUrl, args.url) } browserView.setVisible(true) webTabWindow.contentView.addChildView(browserView) return browserView } /** * 获取当前内置浏览器标签 * @returns {object|undefined} */ function currentWebTab() { for (const [windowId, windowData] of webTabWindows) { if (windowData.window && !windowData.window.isDestroyed()) { return currentWebTabInWindow(windowId) } } return undefined } /** * 获取指定窗口的当前内置浏览器标签 * @param windowId * @returns {object|undefined} */ function currentWebTabInWindow(windowId) { const windowData = webTabWindows.get(windowId) if (!windowData || !windowData.window) return undefined const webTabView = windowData.views const webTabWindow = windowData.window // 优先级:可见标签 > 聚焦标签 > 最上层视图 try { const item = webTabView.find(({ view }) => view?.getVisible && view.getVisible()) if (item) return item } catch (e) {} try { const focused = require('electron').webContents.getFocusedWebContents?.() if (focused) { const item = webTabView.find(it => it.id === focused.id) if (item) return item } } catch (e) {} const children = webTabWindow.contentView?.children || [] for (let i = children.length - 1; i >= 0; i--) { const id = children[i]?.webContents?.id const item = webTabView.find(it => it.id === id) if (item) { return item } } return undefined } /** * 根据 tabId 查找所属窗口ID * @param tabId * @returns {number|null} */ function findWindowIdByTabId(tabId) { for (const [windowId, windowData] of webTabWindows) { if (windowData.views.some(v => v.id === tabId)) { return windowId } } return null } /** * 重新加载内置浏览器标签 * @param windowId * @param id */ function reloadWebTabInWindow(windowId, id) { const windowData = webTabWindows.get(windowId) if (!windowData) return const item = id === 0 ? currentWebTabInWindow(windowId) : windowData.views.find(item => item.id == id) if (!item) { return } item.view.webContents.reload() } /** * 内置浏览器标签打开开发者工具 * @param windowId * @param id */ function devToolsWebTabInWindow(windowId, id) { const windowData = webTabWindows.get(windowId) if (!windowData) return const item = id === 0 ? currentWebTabInWindow(windowId) : windowData.views.find(item => item.id == id) if (!item) { return } item.view.webContents.toggleDevTools() } /** * 调整内置浏览器标签尺寸 * @param windowId * @param id */ function resizeWebTabInWindow(windowId, id) { const windowData = webTabWindows.get(windowId) if (!windowData || !windowData.window) return const webTabWindow = windowData.window const isWindowMode = windowData.mode === 'window' const effectiveTabHeight = isWindowMode ? 0 : webTabHeight const item = id === 0 ? currentWebTabInWindow(windowId) : windowData.views.find(item => item.id == id) if (!item) { return } item.view.setBounds({ x: 0, y: effectiveTabHeight, width: webTabWindow.getContentBounds().width || 1280, height: (webTabWindow.getContentBounds().height || 800) - effectiveTabHeight, }) } /** * 切换内置浏览器标签 * @param windowId * @param id */ function activateWebTabInWindow(windowId, id) { const windowData = webTabWindows.get(windowId) if (!windowData || !windowData.window) return const webTabView = windowData.views const webTabWindow = windowData.window const item = id === 0 ? currentWebTabInWindow(windowId) : webTabView.find(item => item.id == id) if (!item) { return } windowData.activeTabId = item.id webTabView.forEach(({ id: vid, view }) => { view.setVisible(vid === item.id) }) resizeWebTabInWindow(windowId, item.id) item.view.webContents.focus() utils.onDispatchEvent(webTabWindow.webContents, { event: 'switch', id: item.id, }).then(_ => { }) } /** * 关闭内置浏览器标签 * @param windowId * @param id */ function closeWebTabInWindow(windowId, id) { const windowData = webTabWindows.get(windowId) if (!windowData || !windowData.window) return const webTabView = windowData.views const webTabWindow = windowData.window const isWindowMode = windowData.mode === 'window' const userConf = getUserConf() const item = id === 0 ? currentWebTabInWindow(windowId) : webTabView.find(item => item.id == id) if (!item) { return } // window 模式下直接关闭整个窗口 if (isWindowMode) { webTabView.forEach(({ name }) => { if (name) webTabNameMap.delete(name) }) webTabWindow.destroy() return } if (webTabView.length === 1) { webTabWindow.hide() } webTabWindow.contentView.removeChildView(item.view) // 清理 name 映射 if (item.name) { webTabNameMap.delete(item.name) } try { item.view.webContents.close() } catch (e) { // } const index = webTabView.findIndex(({ id }) => item.id == id) if (index > -1) { webTabView.splice(index, 1) } utils.onDispatchEvent(webTabWindow.webContents, { event: 'close', id: item.id, }).then(_ => { }) if (webTabView.length === 0) { userConf?.set('webTabWindow', webTabWindow.getBounds()) webTabWindow.destroy() } else { activateWebTabInWindow(windowId, 0) } } /** * 分离标签到新窗口 * @param windowId 源窗口ID * @param tabId 标签ID * @param screenX 屏幕X坐标 * @param screenY 屏幕Y坐标 * @returns {number|null} 新窗口ID */ function detachWebTab(windowId, tabId, screenX, screenY) { const sourceWindowData = webTabWindows.get(windowId) if (!sourceWindowData) return null const tabIndex = sourceWindowData.views.findIndex(v => v.id === tabId) if (tabIndex === -1) return null const tabItem = sourceWindowData.views[tabIndex] const view = tabItem.view const favicon = tabItem.favicon || '' const tabName = tabItem.name || null const sourceWindow = sourceWindowData.window const userConf = getUserConf() // 从源窗口移除视图 sourceWindow.contentView.removeChildView(view) sourceWindowData.views.splice(tabIndex, 1) // 通知源窗口标签已关闭 utils.onDispatchEvent(sourceWindow.webContents, { event: 'close', id: tabId, }).then(_ => { }) // 创建新窗口,使用源窗口的尺寸 const sourceBounds = sourceWindow.getBounds() const newWindowId = webTabWindowIdCounter++ // 先创建窗口实例 const newWindow = createWebTabWindowInstance(newWindowId, { width: sourceBounds.width, height: sourceBounds.height, }) // 计算窗口位置,让鼠标大致在标签区域 const navAreaWidth = isMac ? 280 : 200 const targetX = Math.round(screenX - navAreaWidth) const targetY = Math.round(screenY - Math.floor(webTabHeight / 2)) // 显示窗口前先设置位置 newWindow.setPosition(targetX, targetY) const newWindowData = { window: newWindow, views: [{ id: tabId, name: tabName, view, favicon }], activeTabId: tabId, mode: 'tab' } webTabWindows.set(newWindowId, newWindowData) // 更新视图所属窗口 view.webTabWindowId = newWindowId // 更新 name 映射中的 windowId if (tabName) { webTabNameMap.set(tabName, { windowId: newWindowId, tabId: tabId }) } // 添加视图到新窗口 newWindow.contentView.addChildView(view) view.setBounds({ x: 0, y: webTabHeight, width: newWindow.getContentBounds().width || 1280, height: (newWindow.getContentBounds().height || 800) - webTabHeight, }) view.setVisible(true) // 显示新窗口 newWindow.show() // 再次确保位置正确 newWindow.setPosition(targetX, targetY) newWindow.focus() // 通知新窗口创建标签(传递完整状态信息) newWindow.webContents.once('dom-ready', () => { const isLoading = view.webContents.isLoading() utils.onDispatchEvent(newWindow.webContents, { event: 'create', id: tabId, url: view.webContents.getURL(), windowId: newWindowId, title: view.webContents.getTitle(), state: isLoading ? 'loading' : 'loaded', favicon, }).then(_ => { }) utils.onDispatchEvent(newWindow.webContents, { event: 'switch', id: tabId, }).then(_ => { }) }) // 处理源窗口 if (sourceWindowData.views.length === 0) { // 源窗口没有标签了,关闭它 userConf?.set('webTabWindow', sourceWindow.getBounds()) sourceWindow.destroy() } else { // 激活源窗口的下一个标签 activateWebTabInWindow(windowId, 0) } return newWindowId } /** * 将标签附加到目标窗口 * @param sourceWindowId 源窗口ID * @param tabId 标签ID * @param targetWindowId 目标窗口ID * @param insertIndex 插入位置(可选) * @returns {boolean} 是否成功 */ function attachWebTab(sourceWindowId, tabId, targetWindowId, insertIndex) { if (sourceWindowId === targetWindowId) return false const sourceWindowData = webTabWindows.get(sourceWindowId) const targetWindowData = webTabWindows.get(targetWindowId) if (!sourceWindowData || !targetWindowData) return false const tabIndex = sourceWindowData.views.findIndex(v => v.id === tabId) if (tabIndex === -1) return false const tabItem = sourceWindowData.views[tabIndex] const view = tabItem.view const favicon = tabItem.favicon || '' const tabName = tabItem.name || null const sourceWindow = sourceWindowData.window const targetWindow = targetWindowData.window const userConf = getUserConf() // 从源窗口移除视图 sourceWindow.contentView.removeChildView(view) sourceWindowData.views.splice(tabIndex, 1) // 通知源窗口标签已关闭 utils.onDispatchEvent(sourceWindow.webContents, { event: 'close', id: tabId, }).then(_ => { }) // 更新视图所属窗口 view.webTabWindowId = targetWindowId // 更新 name 映射中的 windowId if (tabName) { webTabNameMap.set(tabName, { windowId: targetWindowId, tabId: tabId }) } // 确定插入位置 const actualInsertIndex = typeof insertIndex === 'number' ? Math.max(0, Math.min(insertIndex, targetWindowData.views.length)) : targetWindowData.views.length // 添加到目标窗口,保留 name 信息 targetWindowData.views.splice(actualInsertIndex, 0, { id: tabId, name: tabName, view, favicon }) targetWindow.contentView.addChildView(view) // 调整视图尺寸 view.setBounds({ x: 0, y: webTabHeight, width: targetWindow.getContentBounds().width || 1280, height: (targetWindow.getContentBounds().height || 800) - webTabHeight, }) // 通知目标窗口创建标签(传递完整状态信息) const isLoading = view.webContents.isLoading() utils.onDispatchEvent(targetWindow.webContents, { event: 'create', id: tabId, url: view.webContents.getURL(), windowId: targetWindowId, title: view.webContents.getTitle(), insertIndex: actualInsertIndex, state: isLoading ? 'loading' : 'loaded', favicon, }).then(_ => { }) // 激活新添加的标签 activateWebTabInWindow(targetWindowId, tabId) // 聚焦目标窗口 targetWindow.focus() // 处理源窗口 if (sourceWindowData.views.length === 0) { userConf?.set('webTabWindow', sourceWindow.getBounds()) sourceWindow.destroy() } else { activateWebTabInWindow(sourceWindowId, 0) } return true } /** * 获取所有 webTab 窗口信息(用于跨窗口拖拽检测) * @returns {Array} */ function getAllWebTabWindowsInfo() { const result = [] for (const [windowId, windowData] of webTabWindows) { if (windowData.window && !windowData.window.isDestroyed()) { const bounds = windowData.window.getBounds() result.push({ windowId, bounds, tabBarBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: webTabHeight }, tabCount: windowData.views.length }) } } return result } /** * 从事件发送者获取窗口ID * @param sender * @returns {number|null} */ function getWindowIdFromSender(sender) { const win = BrowserWindow.fromWebContents(sender) if (win && win.webTabWindowId) { return win.webTabWindowId } return null } // ============================================================ // 辅助函数 // ============================================================ // 窗口显示状态管理 let showState = {} function onShowWindow(win) { try { if (typeof showState[win.webContents.id] === 'undefined') { showState[win.webContents.id] = true win.show() } } catch (e) { // loger.error(e) } } // ============================================================ // 对外接口 // ============================================================ /** * 获取所有 webTab 窗口(用于主题更新等) * @returns {Map} */ function getWebTabWindows() { return webTabWindows } /** * 销毁所有 webTab 窗口 */ function destroyAll() { for (const [, windowData] of webTabWindows) { if (windowData.window && !windowData.window.isDestroyed()) { windowData.window.destroy() } } } /** * 关闭所有 webTab 窗口 * 通过逐个关闭标签实现,当最后一个标签关闭时窗口自动销毁 */ function closeAll() { // 复制 windowId 列表,避免遍历时 Map 被修改 const windowIds = [...webTabWindows.keys()] for (const windowId of windowIds) { const windowData = webTabWindows.get(windowId) if (windowData && windowData.window && !windowData.window.isDestroyed()) { // 复制 tabId 列表 const tabIds = windowData.views.map(v => v.id) // 逐个关闭标签,最后一个关闭时窗口自动销毁 for (const tabId of tabIds) { closeWebTabInWindow(windowId, tabId) } } } } /** * 关闭所有 mode='window' 的窗口 */ function closeAllWindowMode() { for (const [, data] of webTabWindows) { if (data.mode === 'window' && data.window && !data.window.isDestroyed()) { data.window.close() } } } /** * 销毁所有 mode='window' 的窗口 */ function destroyAllWindowMode() { for (const [, data] of webTabWindows) { if (data.mode === 'window' && data.window && !data.window.isDestroyed()) { data.window.destroy() } } } // ============================================================ // IPC 注册 // ============================================================ /** * 注册所有 webTab 相关的 IPC 事件 */ function registerIPC() { const electronMenu = getElectronMenu() /** * 获取路由窗口信息(从 webTabWindows 中查找 mode='window' 的窗口) */ ipcMain.handle('getChildWindow', (event, args) => { let windowData, viewItem if (!args) { // 通过发送者查找 const sender = event.sender for (const [, data] of webTabWindows) { if (data.mode === 'window') { const found = data.views.find(v => v.view.webContents === sender) if (found) { windowData = data viewItem = found break } } } } else { // 通过名称查找 const location = webTabNameMap.get(args) if (location) { windowData = webTabWindows.get(location.windowId) if (windowData && windowData.mode === 'window') { viewItem = windowData.views.find(v => v.id === location.tabId) } } } if (windowData && viewItem) { return { name: viewItem.name, id: viewItem.view.webContents.id, url: viewItem.view.webContents.getURL() } } return null }) /** * 统一窗口打开接口 */ ipcMain.on('openWindow', (event, args) => { createWebTabWindow(args) event.returnValue = "ok" }) /** * 更新当前窗口/标签页的 URL 和名称 */ ipcMain.on('updateWindow', (event, args) => { if (!args) { event.returnValue = "ok" return } if (!utils.isJson(args)) { args = { path: args } } const sender = event.sender let windowId, windowData, viewItem // 通过发送者查找窗口和视图 for (const [id, data] of webTabWindows) { const found = data.views.find(v => v.view.webContents === sender) if (found) { windowId = id windowData = data viewItem = found break } } if (!windowData || !viewItem) { event.returnValue = "ok" return } // 更新 URL if (args.path) { utils.loadContentUrl(viewItem.view.webContents, getServerUrl(), args.path) } // 更新名称 if (args.name && args.name !== viewItem.name) { const oldName = viewItem.name viewItem.name = args.name // 更新 webTabNameMap if (oldName) { webTabNameMap.delete(oldName) } webTabNameMap.set(args.name, { windowId: windowId, tabId: viewItem.id }) } event.returnValue = "ok" }) /** * 内置浏览器 - 激活标签 */ ipcMain.on('webTabActivate', (event, args) => { let windowId, tabId if (typeof args === 'object' && args !== null) { windowId = args.windowId tabId = args.tabId } else { tabId = args windowId = findWindowIdByTabId(tabId) } if (windowId) { activateWebTabInWindow(windowId, tabId) } event.returnValue = "ok" }) /** * 内置浏览器 - 重排标签顺序 */ ipcMain.on('webTabReorder', (event, args) => { let windowId, newOrder if (Array.isArray(args)) { newOrder = args if (newOrder.length > 0) { windowId = findWindowIdByTabId(newOrder[0]) } } else if (typeof args === 'object' && args !== null) { windowId = args.windowId newOrder = args.newOrder } if (!windowId || !Array.isArray(newOrder) || newOrder.length === 0) { event.returnValue = "ok" return } const windowData = webTabWindows.get(windowId) if (!windowData) { event.returnValue = "ok" return } windowData.views.sort((a, b) => { const indexA = newOrder.indexOf(a.id) const indexB = newOrder.indexOf(b.id) return indexA - indexB }) event.returnValue = "ok" }) /** * 内置浏览器 - 关闭标签 */ ipcMain.on('webTabClose', (event, args) => { let windowId, tabId if (typeof args === 'object' && args !== null) { windowId = args.windowId tabId = args.tabId } else { tabId = args windowId = findWindowIdByTabId(tabId) } if (windowId) { closeWebTabInWindow(windowId, tabId) } event.returnValue = "ok" }) /** * 内置浏览器 - 在外部浏览器打开 */ ipcMain.on('webTabExternal', (event) => { const item = currentWebTab() if (!item) { return } renderer.openExternal(item.view.webContents.getURL()).catch(() => {}) event.returnValue = "ok" }) /** * 内置浏览器 - 显示更多菜单 */ ipcMain.on('webTabShowMenu', (event, args) => { const windowId = args?.windowId const tabId = args?.tabId const windowData = windowId ? webTabWindows.get(windowId) : null const webTabWindow = windowData?.window if (!webTabWindow || webTabWindow.isDestroyed()) { event.returnValue = "ok" return } const item = currentWebTabInWindow(windowId) const webContents = item?.view?.webContents const currentUrl = webContents?.getURL() || '' const canBrowser = !utils.isLocalHost(currentUrl) const menuTemplate = [ { label: electronMenu?.language?.reload || 'Reload', click: () => { if (webContents && !webContents.isDestroyed()) { webContents.reload() } } }, { label: electronMenu?.language?.copyLinkAddress || 'Copy Link', enabled: canBrowser, click: () => { if (currentUrl) { clipboard.writeText(currentUrl) } } }, { label: electronMenu?.language?.openInDefaultBrowser || 'Open in Browser', enabled: canBrowser, click: () => { if (currentUrl) { renderer.openExternal(currentUrl).catch(() => {}) } } }, { type: 'separator' }, { label: electronMenu?.language?.moveToNewWindow || 'Move to New Window', enabled: windowData?.views?.length > 1, click: () => { if (tabId) { const bounds = webTabWindow.getBounds() detachWebTab(windowId, tabId, bounds.x + 50, bounds.y + 50) } } }, { type: 'separator' }, { label: electronMenu?.language?.print || 'Print', click: () => { if (webContents && !webContents.isDestroyed()) { webContents.print() } } } ] const menu = Menu.buildFromTemplate(menuTemplate) menu.popup({ window: webTabWindow, x: args?.x, y: args?.y }) event.returnValue = "ok" }) /** * 内置浏览器 - 打开开发者工具 */ ipcMain.on('webTabOpenDevTools', (event) => { const item = currentWebTab() if (!item) { return } item.view.webContents.openDevTools() event.returnValue = "ok" }) /** * 内置浏览器 - 销毁所有标签及窗口 */ ipcMain.on('webTabDestroyAll', (event) => { destroyAll() event.returnValue = "ok" }) /** * 内置浏览器 - 延迟发送导航状态 */ function notifyNavigationState(item) { setTimeout(() => { const wd = webTabWindows.get(item.view.webTabWindowId) if (wd && wd.window) { utils.onDispatchEvent(wd.window.webContents, { event: 'navigation-state', id: item.id, canGoBack: item.view.webContents.navigationHistory.canGoBack(), canGoForward: item.view.webContents.navigationHistory.canGoForward() }).then(_ => { }) } }, 100) } /** * 内置浏览器 - 后退 */ ipcMain.on('webTabGoBack', (event, args) => { const windowId = args?.windowId || getWindowIdFromSender(event.sender) const item = windowId ? currentWebTabInWindow(windowId) : currentWebTab() if (!item) { event.returnValue = "ok" return } if (item.view.webContents.navigationHistory.canGoBack()) { item.view.webContents.navigationHistory.goBack() notifyNavigationState(item) } event.returnValue = "ok" }) /** * 内置浏览器 - 前进 */ ipcMain.on('webTabGoForward', (event, args) => { const windowId = args?.windowId || getWindowIdFromSender(event.sender) const item = windowId ? currentWebTabInWindow(windowId) : currentWebTab() if (!item) { event.returnValue = "ok" return } if (item.view.webContents.navigationHistory.canGoForward()) { item.view.webContents.navigationHistory.goForward() notifyNavigationState(item) } event.returnValue = "ok" }) /** * 内置浏览器 - 刷新 */ ipcMain.on('webTabReload', (event, args) => { const windowId = args?.windowId || getWindowIdFromSender(event.sender) const item = windowId ? currentWebTabInWindow(windowId) : currentWebTab() if (!item) { event.returnValue = "ok" return } item.view.webContents.reload() event.returnValue = "ok" }) /** * 内置浏览器 - 停止加载 */ ipcMain.on('webTabStop', (event, args) => { const windowId = args?.windowId || getWindowIdFromSender(event.sender) const item = windowId ? currentWebTabInWindow(windowId) : currentWebTab() if (!item) { event.returnValue = "ok" return } item.view.webContents.stop() event.returnValue = "ok" }) /** * 内置浏览器 - 获取导航状态 */ ipcMain.on('webTabGetNavigationState', (event, args) => { const windowId = args?.windowId || getWindowIdFromSender(event.sender) const item = windowId ? currentWebTabInWindow(windowId) : currentWebTab() if (!item) { event.returnValue = "ok" return } const canGoBack = item.view.webContents.navigationHistory.canGoBack() const canGoForward = item.view.webContents.navigationHistory.canGoForward() const wd = webTabWindows.get(item.view.webTabWindowId) if (wd && wd.window) { utils.onDispatchEvent(wd.window.webContents, { event: 'navigation-state', id: item.id, canGoBack, canGoForward }).then(_ => { }) } event.returnValue = "ok" }) /** * 内置浏览器 - 分离标签到新窗口 */ ipcMain.on('webTabDetach', (event, args) => { const { windowId, tabId, screenX, screenY } = args detachWebTab(windowId, tabId, screenX, screenY) event.returnValue = "ok" }) /** * 内置浏览器 - 将标签附加到目标窗口 */ ipcMain.on('webTabAttach', (event, args) => { const { sourceWindowId, tabId, targetWindowId, insertIndex } = args attachWebTab(sourceWindowId, tabId, targetWindowId, insertIndex) event.returnValue = "ok" }) /** * 内置浏览器 - 获取所有窗口信息 */ ipcMain.handle('webTabGetAllWindows', () => { return getAllWebTabWindowsInfo() }) /** * 关闭窗口(或关闭 tab) */ ipcMain.on('windowClose', (event) => { const tabId = event.sender.id const windowId = findWindowIdByTabId(tabId) if (windowId !== null) { closeWebTabInWindow(windowId, tabId) } else { const win = BrowserWindow.fromWebContents(event.sender) win?.close() } event.returnValue = "ok" }) /** * 销毁窗口(或销毁 tab) */ ipcMain.on('windowDestroy', (event) => { const tabId = event.sender.id const windowId = findWindowIdByTabId(tabId) if (windowId !== null) { closeWebTabInWindow(windowId, tabId) } else { const win = BrowserWindow.fromWebContents(event.sender) win?.destroy() } event.returnValue = "ok" }) } // ============================================================ // 导出 // ============================================================ module.exports = { // 初始化 init, registerIPC, // 核心功能 createWebTabWindow, closeWebTabInWindow, activateWebTabInWindow, findWindowIdByTabId, // 预加载 warmupPreloadPool, clearPreloadPool, // 对外接口 getWebTabWindows, closeAll, destroyAll, closeAllWindowMode, destroyAllWindowMode, }