diff --git a/electron/electron.js b/electron/electron.js index d8e2f8dbd..793d2bca2 100644 --- a/electron/electron.js +++ b/electron/electron.js @@ -45,6 +45,7 @@ const { startMCPServer, stopMCPServer } = require("./lib/mcp"); const {onRenderer, renderer} = require("./lib/renderer"); const {onExport} = require("./lib/pdf-export"); const {allowedCalls, isWin, isMac} = require("./lib/other"); +const webTabManager = require("./lib/web-tab-manager"); // 实例初始化 const userConf = new electronConf() @@ -75,35 +76,12 @@ let mainWindow = null, mainTray = null, mediaWindow = null; -// 多窗口 Tab 管理 -// Map -let webTabWindows = new Map(); -let webTabWindowIdCounter = 1; -// 标签名称到标签位置的映射,用于复用已存在的标签 -// Map -let webTabNameMap = new Map(); - // 窗口配置和状态 -let mediaType = null, - webTabHeight = 40, - webTabClosedByShortcut = new Map(); // Map +let mediaType = null; // 开发模式路径 let devloadPath = path.resolve(__dirname, ".devload"); -// 窗口显示状态管理 -let showState = {}, - onShowWindow = (win) => { - try { - if (typeof showState[win.webContents.id] === 'undefined') { - showState[win.webContents.id] = true - win.show(); - } - } catch (e) { - // loger.error(e) - } - } - // 开发模式加载 if (fs.existsSync(devloadPath)) { let devloadContent = fs.readFileSync(devloadPath, 'utf8') @@ -504,1026 +482,6 @@ function createMediaWindow(args, type = 'image') { mediaWindow.webContents.send('load-media', args); }); } - -/** - * 创建内置浏览器窗口(支持多窗口) - * @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'; - - // 如果有 name,先查找是否已存在同名标签/窗口 - 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); - - // force=true 时重新加载 - if (args.force === true && args.url) { - utils.loadContentUrl(viewItem.view.webContents, serverUrl, args.url); - } - return existing.windowId; - } - } - // 标签已失效,清理映射 - webTabNameMap.delete(args.name); - } - } - - // 确定目标窗口ID - let windowId = args.windowId; - let windowData = windowId ? webTabWindows.get(windowId) : null; - let webTabWindow = windowData ? windowData.window : null; - - // 如果没有指定窗口或窗口不存在,查找可用窗口或创建新窗口 - if (!webTabWindow) { - // window 模式总是创建新窗口;tab 模式尝试使用第一个可用的 tab 窗口 - 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++; - // 从 args 中提取窗口尺寸 - 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(); - - // 创建 tab 子视图 - 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)); - } - - // 插入到指定位置,包含 name 信息 - windowData.views.splice(insertIndex, 0, { - id: browserView.webContents.id, - view: browserView, - name: args.name || null - }); - - // 如果有 name,注册到映射 - if (args.name) { - webTabNameMap.set(args.name, { - windowId: windowId, - tabId: browserView.webContents.id - }); - } - - // tab 模式通知标签栏创建标签;window 模式设置窗口标题 - if (isWindowMode) { - // window 模式下,如果传入了 title 参数,设置窗口标题 - if (args.title) { - webTabWindow.setTitle(args.title); - } - } else { - // tab 模式下通知标签栏创建新标签 - utils.onDispatchEvent(webTabWindow.webContents, { - event: 'create', - id: browserView.webContents.id, - url: args.url, - afterId: args.afterId, - windowId: windowId, - title: args.title, - }).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, - }, - }; - - 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 (!willQuitApp) { - 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('./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 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; - } - - const 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, - }); - - // 保存所属窗口ID和元数据 - browserView.webTabWindowId = windowId; - browserView.tabName = args.name || null; - browserView.titleFixed = args.titleFixed || false; - - // 设置自定义 UserAgent - if (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] || ''; - - // 验证并转换 favicon 为 base64 - const base64Favicon = await utils.fetchFaviconAsBase64(faviconUrl); - - // 保存验证后的 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); - } - }); - - 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); - - // 加载地址 - 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) {} - // 第二:使用当前聚焦的 webContents - try { - const focused = require('electron').webContents.getFocusedWebContents?.(); - if (focused) { - const item = webTabView.find(it => it.id === focused.id); - if (item) { - return item; - } - } - } catch (e) {} - // 兜底:根据 children 顺序选择最上层的可用视图 - 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 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; - - // 从源窗口移除视图 - 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 - }; - 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; - - // 从源窗口移除视图 - 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; -} - /** * 监听主题变化 */ @@ -1540,7 +498,7 @@ function monitorThemeChanges() { mainWindow?.setBackgroundColor(backgroundColor); mediaWindow?.setBackgroundColor(backgroundColor); // 更新所有 webTab 窗口背景 - for (const [, windowData] of webTabWindows) { + for (const [, windowData] of webTabManager.getWebTabWindows()) { windowData.window?.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#575757' : '#FFFFFF'); } // 通知所有窗口 @@ -1549,6 +507,16 @@ function monitorThemeChanges() { theme: currentTheme, }); }); + // 通知所有 webTab 视图(WebContentsView 中的网站内容) + for (const [, windowData] of webTabManager.getWebTabWindows()) { + windowData.views?.forEach(({ view }) => { + if (view && !view.webContents.isDestroyed()) { + view.webContents.send('systemThemeChanged', { + theme: currentTheme, + }); + } + }); + } }) } @@ -1562,6 +530,16 @@ if (!getTheLock) { app.on('ready', async () => { isReady = true isWin && app.setAppUserModelId(config.appId) + + // 初始化 webTabManager + webTabManager.init({ + getServerUrl: () => serverUrl, + getUserConf: () => userConf, + isWillQuitApp: () => willQuitApp, + electronMenu: electronMenu, + }) + webTabManager.registerIPC() + // 启动 Web 服务器 try { await startWebServer() @@ -1668,44 +646,6 @@ ipcMain.on('windowQuit', (event) => { app.quit(); }) -/** - * 获取路由窗口信息(从 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; -}); - /** * 打开媒体浏览器 */ @@ -1714,424 +654,6 @@ ipcMain.on('openMediaViewer', (event, args) => { event.returnValue = "ok" }); -/** - * 统一窗口打开接口 - * @param args {url, name, mode, force, config, userAgent, webPreferences, ...} - * - url: 要打开的地址 - * - name: 窗口/标签名称 - * - mode: 'tab' | 'window' - * - 'window': 独立窗口模式(无导航栏) - * - 'tab': 标签页模式(默认,有导航栏) - */ -ipcMain.on('openWindow', (event, args) => { - // 统一使用 createWebTabWindow,通过 mode 区分窗口类型 - createWebTabWindow(args) - event.returnValue = "ok" -}) - -/** - * 更新当前窗口/标签页的 URL 和名称(用于内部导航) - * @param args {path, name} - * - path: 要加载的新路径 - * - name: 可选,新的窗口/标签名称 - */ -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, serverUrl, 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" -}) - -/** - * 内置浏览器 - 激活标签 - * @param args {windowId, tabId} 或 tabId - */ -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" -}) - -/** - * 内置浏览器 - 重排标签顺序 - * @param args {windowId, newOrder} 或 newOrder数组 - */ -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 - } - - // 根据新顺序重排 views 数组 - windowData.views.sort((a, b) => { - const indexA = newOrder.indexOf(a.id) - const indexB = newOrder.indexOf(b.id) - return indexA - indexB - }) - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 关闭标签 - * @param args {windowId, tabId} 或 tabId - */ -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, - click: () => { - if (webContents && !webContents.isDestroyed()) { - webContents.reload() - } - } - }, - { - label: electronMenu.language.copyLinkAddress, - enabled: canBrowser, - click: () => { - if (currentUrl) { - clipboard.writeText(currentUrl) - } - } - }, - { - label: electronMenu.language.openInDefaultBrowser, - enabled: canBrowser, - click: () => { - if (currentUrl) { - renderer.openExternal(currentUrl).catch(() => {}) - } - } - }, - { type: 'separator' }, - { - label: electronMenu.language.moveToNewWindow, - 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, - 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) => { - for (const [, windowData] of webTabWindows) { - if (windowData.window && !windowData.window.isDestroyed()) { - windowData.window.destroy(); - } - } - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 后退 - * @param args {windowId} 可选 - */ -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.canGoBack()) { - item.view.webContents.goBack() - // 导航后更新状态 - 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.canGoBack(), - canGoForward: item.view.webContents.canGoForward() - }).then(_ => { }) - } - }, 100) - } - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 前进 - * @param args {windowId} 可选 - */ -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.canGoForward()) { - item.view.webContents.goForward() - // 导航后更新状态 - 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.canGoBack(), - canGoForward: item.view.webContents.canGoForward() - }).then(_ => { }) - } - }, 100) - } - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 刷新 - * @param args {windowId} 可选 - */ -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() - // 刷新完成后会触发 did-stop-loading 事件,在那里会更新导航状态 - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 停止加载 - * @param args {windowId} 可选 - */ -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" -}) - -/** - * 内置浏览器 - 获取导航状态 - * @param args {windowId} 可选 - */ -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.canGoBack() - const canGoForward = item.view.webContents.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" -}) - -/** - * 从事件发送者获取窗口ID - * @param sender - * @returns {number|null} - */ -function getWindowIdFromSender(sender) { - const win = BrowserWindow.fromWebContents(sender); - if (win && win.webTabWindowId) { - return win.webTabWindowId; - } - return null; -} - -/** - * 内置浏览器 - 分离标签到新窗口 - * @param args {windowId, tabId, screenX, screenY} - */ -ipcMain.on('webTabDetach', (event, args) => { - const {windowId, tabId, screenX, screenY} = args; - detachWebTab(windowId, tabId, screenX, screenY); - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 将标签附加到目标窗口 - * @param args {sourceWindowId, tabId, targetWindowId, insertIndex} - */ -ipcMain.on('webTabAttach', (event, args) => { - const {sourceWindowId, tabId, targetWindowId, insertIndex} = args; - attachWebTab(sourceWindowId, tabId, targetWindowId, insertIndex); - event.returnValue = "ok" -}) - -/** - * 内置浏览器 - 获取所有窗口信息(用于跨窗口拖拽检测) - */ -ipcMain.handle('webTabGetAllWindows', () => { - return getAllWebTabWindowsInfo(); -}) - /** * 隐藏窗口(mac、win隐藏,其他关闭) */ @@ -2145,62 +667,20 @@ ipcMain.on('windowHidden', (event) => { }) /** - * 关闭窗口(或关闭 tab,如果发送者是 tab 中的页面) - */ -ipcMain.on('windowClose', (event) => { - const tabId = event.sender.id; - const windowId = findWindowIdByTabId(tabId); - if (windowId !== null) { - // 发送者是 tab 中的页面,只关闭这个 tab - closeWebTabInWindow(windowId, tabId); - } else { - // 发送者是独立窗口,关闭整个窗口 - const win = BrowserWindow.fromWebContents(event.sender); - win?.close() - } - event.returnValue = "ok" -}) - -/** - * 销毁窗口(或销毁 tab,如果发送者是 tab 中的页面) - */ -ipcMain.on('windowDestroy', (event) => { - const tabId = event.sender.id; - const windowId = findWindowIdByTabId(tabId); - if (windowId !== null) { - // 发送者是 tab 中的页面,只关闭这个 tab - closeWebTabInWindow(windowId, tabId); - } else { - // 发送者是独立窗口,销毁整个窗口 - const win = BrowserWindow.fromWebContents(event.sender); - win?.destroy() - } - event.returnValue = "ok" -}) - -/** - * 关闭所有子窗口(mode='window' 的窗口) + * 关闭所有子窗口(包含所有 webTab 窗口、mediaWindow 和下载窗口) */ ipcMain.on('childWindowCloseAll', (event) => { - for (const [, data] of webTabWindows) { - if (data.mode === 'window' && data.window && !data.window.isDestroyed()) { - data.window.close(); - } - } + webTabManager.closeAll() mediaWindow?.close() electronDown.close() event.returnValue = "ok" }) /** - * 销毁所有子窗口(mode='window' 的窗口) + * 销毁所有子窗口(包含所有 webTab 窗口、mediaWindow 和下载窗口) */ ipcMain.on('childWindowDestroyAll', (event) => { - for (const [, data] of webTabWindows) { - if (data.mode === 'window' && data.window && !data.window.isDestroyed()) { - data.window.destroy(); - } - } + webTabManager.destroyAll() mediaWindow?.destroy() electronDown.destroy() event.returnValue = "ok" @@ -2546,11 +1026,7 @@ ipcMain.on('updateQuitAndInstall', (event, args) => { // 关闭所有子窗口 willQuitApp = true - for (const [, data] of webTabWindows) { - if (data.mode === 'window' && data.window && !data.window.isDestroyed()) { - data.window.destroy(); - } - } + webTabManager.destroyAllWindowMode() mediaWindow?.destroy() electronDown.destroy() diff --git a/electron/lib/web-tab-manager.js b/electron/lib/web-tab-manager.js new file mode 100644 index 000000000..b7a5c41c0 --- /dev/null +++ b/electron/lib/web-tab-manager.js @@ -0,0 +1,1693 @@ +/** + * 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 { renderer } = require('./renderer') + +// ============================================================ +// 状态变量 +// ============================================================ + +// Map +let webTabWindows = new Map() +let webTabWindowIdCounter = 1 + +// 标签名称到标签位置的映射,用于复用已存在的标签 +// Map +let webTabNameMap = new Map() + +// 标签栏高度 +const webTabHeight = 40 + +// 快捷键关闭状态 Map +let webTabClosedByShortcut = new Map() + +// ============================================================ +// 依赖注入 +// ============================================================ + +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 +} + +// ============================================================ +// 核心函数 +// ============================================================ + +/** + * 创建内置浏览器窗口(支持多窗口) + * @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' + + // 如果有 name,先查找是否已存在同名标签/窗口 + 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) + + // force=true 时重新加载 + if (args.force === true && args.url) { + utils.loadContentUrl(viewItem.view.webContents, getServerUrl(), args.url) + } + return existing.windowId + } + } + // 标签已失效,清理映射 + webTabNameMap.delete(args.name) + } + } + + // 确定目标窗口ID + let windowId = args.windowId + let windowData = windowId ? webTabWindows.get(windowId) : null + let webTabWindow = windowData ? windowData.window : null + + // 如果没有指定窗口或窗口不存在,查找可用窗口或创建新窗口 + if (!webTabWindow) { + // window 模式总是创建新窗口;tab 模式尝试使用第一个可用的 tab 窗口 + 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++ + // 从 args 中提取窗口尺寸 + 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() + + // 创建 tab 子视图 + 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)) + } + + // 插入到指定位置,包含 name 信息 + windowData.views.splice(insertIndex, 0, { + id: browserView.webContents.id, + view: browserView, + name: args.name || null + }) + + // 如果有 name,注册到映射 + if (args.name) { + webTabNameMap.set(args.name, { + windowId: windowId, + tabId: browserView.webContents.id + }) + } + + // tab 模式通知标签栏创建标签;window 模式设置窗口标题 + if (isWindowMode) { + // window 模式下,如果传入了 title 参数,设置窗口标题 + if (args.title) { + webTabWindow.setTitle(args.title) + } + } else { + // tab 模式下通知标签栏创建新标签 + utils.onDispatchEvent(webTabWindow.webContents, { + event: 'create', + id: browserView.webContents.id, + url: args.url, + afterId: args.afterId, + windowId: windowId, + title: args.title, + }).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 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 + } + + const 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, + }) + + // 保存所属窗口ID和元数据 + browserView.webTabWindowId = windowId + browserView.tabName = args.name || null + browserView.titleFixed = args.titleFixed || false + + // 设置自定义 UserAgent + if (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] || '' + + // 验证并转换 favicon 为 base64 + const base64Favicon = await utils.fetchFaviconAsBase64(faviconUrl) + + // 保存验证后的 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) + } + }) + + 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) + + // 加载地址 + utils.loadContentUrl(browserView.webContents, getServerUrl(), 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) {} + // 第二:使用当前聚焦的 webContents + try { + const focused = require('electron').webContents.getFocusedWebContents?.() + if (focused) { + const item = webTabView.find(it => it.id === focused.id) + if (item) { + return item + } + } + } catch (e) {} + // 兜底:根据 children 顺序选择最上层的可用视图 + 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" + }) + + /** + * 内置浏览器 - 后退 + */ + 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.canGoBack()) { + item.view.webContents.goBack() + 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.canGoBack(), + canGoForward: item.view.webContents.canGoForward() + }).then(_ => { }) + } + }, 100) + } + 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.canGoForward()) { + item.view.webContents.goForward() + 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.canGoBack(), + canGoForward: item.view.webContents.canGoForward() + }).then(_ => { }) + } + }, 100) + } + 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.canGoBack() + const canGoForward = item.view.webContents.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, + + // 对外接口 + getWebTabWindows, + closeAll, + destroyAll, + closeAllWindowMode, + destroyAllWindowMode, +}