From 7760d2f1eddd517df627650522c41b7e742d9c46 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Sun, 16 Nov 2025 11:43:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=9A=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/changeWindowSize.js | 2 +- src/main.ts | 43 ++++++-- src/main/ipc.ts | 13 +++ src/main/tab-manager.ts | 137 +++++++++++++++++++++++++ src/preload.ts | 31 +++++- src/router/index.ts | 4 +- src/views/browser/BrowserLayout.vue | 130 ++++++++++++++++++++++++ src/views/browser/README.md | 149 ++++++++++++++++++++++++++++ 8 files changed, 494 insertions(+), 15 deletions(-) create mode 100644 src/main/ipc.ts create mode 100644 src/main/tab-manager.ts create mode 100644 src/views/browser/BrowserLayout.vue create mode 100644 src/views/browser/README.md diff --git a/src/controller/changeWindowSize.js b/src/controller/changeWindowSize.js index 72fc974..8029fcd 100644 --- a/src/controller/changeWindowSize.js +++ b/src/controller/changeWindowSize.js @@ -12,7 +12,7 @@ ipcMain.on('window-max', (event) => { const webContent = event.sender const win = BrowserWindow.fromWebContents(webContent) if (win.isMaximized()) { - win.restore() + win.unmaximize() } else { win.maximize() } diff --git a/src/main.ts b/src/main.ts index eb26101..3514daf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,26 +1,31 @@ -import { app, BrowserWindow, ipcMain } from "electron"; +import { app, BrowserWindow, ipcMain, shell } from "electron"; import path from "node:path"; import started from "electron-squirrel-startup"; +import { TabManager } from './main/tab-manager' +import { registerTabIpc } from './main/ipc' // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (started) { app.quit(); } -// const inDevelopment = process.env.NODE_ENV === "development"; +const isDev = !!MAIN_WINDOW_VITE_DEV_SERVER_URL; const createWindow = () => { // Create the browser window. const mainWindow = new BrowserWindow({ width: 900, height: 670, autoHideMenuBar: true, - resizable: false, // 禁止拖拽放大缩小 - maximizable: false, // 禁止最大化 - minimizable: true, // 允许最小化 + frame: false, + windowButtonVisibility: false, + resizable: true, + maximizable: true, + minimizable: true, webPreferences: { - // devTools: inDevelopment, - // nodeIntegration: true, - // contextIsolation: false, + devTools: isDev, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, preload: path.join(__dirname, "preload.js"), }, }); @@ -29,6 +34,18 @@ const createWindow = () => { mainWindow.loadURL("https://www.baidu.com") }) + ipcMain.handle("external-open", (_event, url: string) => { + try { + const allowed = /^https:\/\/(www\.)?(baidu\.com|\w+[\.-]?\w+\.[a-z]{2,})(\/.*)?$/i; + if (typeof url === "string" && allowed.test(url)) { + return shell.openExternal(url); + } + throw new Error("URL not allowed"); + } catch (e) { + return Promise.reject(e); + } + }); + // and load the index.html of the app. if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL); @@ -38,8 +55,13 @@ const createWindow = () => { ); } - // Open the DevTools. - mainWindow.webContents.openDevTools(); + if (isDev) { + mainWindow.webContents.openDevTools(); + } + + const tabs = new TabManager(mainWindow) + registerTabIpc(tabs) + tabs.create('about:blank') }; // This method will be called when Electron has finished @@ -66,3 +88,4 @@ app.on("activate", () => { // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and import them here. +import "./controller/changeWindowSize.js"; diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..c7a10f9 --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,13 @@ +import { ipcMain } from 'electron' +import { TabManager } from './tab-manager' + +export const registerTabIpc = (tabs: TabManager) => { + ipcMain.handle('tab:create', (_e, url?: string) => tabs.create(url)) + ipcMain.handle('tab:list', () => tabs.list()) + ipcMain.handle('tab:navigate', (_e, payload: { tabId: string; url: string }) => tabs.navigate(payload.tabId, payload.url)) + ipcMain.handle('tab:reload', (_e, tabId: string) => tabs.reload(tabId)) + ipcMain.handle('tab:back', (_e, tabId: string) => tabs.goBack(tabId)) + ipcMain.handle('tab:forward', (_e, tabId: string) => tabs.goForward(tabId)) + ipcMain.handle('tab:switch', (_e, tabId: string) => tabs.switch(tabId)) + ipcMain.handle('tab:close', (_e, tabId: string) => tabs.close(tabId)) +} \ No newline at end of file diff --git a/src/main/tab-manager.ts b/src/main/tab-manager.ts new file mode 100644 index 0000000..0b9707f --- /dev/null +++ b/src/main/tab-manager.ts @@ -0,0 +1,137 @@ +import { BrowserView, BrowserWindow } from 'electron' +import { randomUUID } from 'crypto' + +type TabId = string +type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean } + +const UI_HEIGHT = 88 + +export class TabManager { + private win: BrowserWindow + private views: Map = new Map() + private activeId: TabId | null = null + private skipNextNavigate: Map = new Map() + + constructor(win: BrowserWindow) { + this.win = win + this.win.on('resize', () => this.updateActiveBounds()) + } + + list(): TabInfo[] { + return Array.from(this.views.entries()).map(([id, view]) => this.info(id, view)) + } + + create(url?: string): TabInfo { + const id = randomUUID() + const view = new BrowserView({ webPreferences: { sandbox: true } }) + this.views.set(id, view) + this.attach(id) + const target = url && url.length > 0 ? url : 'about:blank' + view.webContents.loadURL(target) + this.bindEvents(id, view) + const info = this.info(id, view) + this.win.webContents.send('tab-created', info) + return info + } + + switch(tabId: TabId): void { + if (!this.views.has(tabId)) return + this.attach(tabId) + this.win.webContents.send('tab-switched', { tabId }) + } + + close(tabId: TabId): void { + const view = this.views.get(tabId) + if (!view) return + if (this.activeId === tabId) { + this.win.removeBrowserView(view) + this.activeId = null + } + view.webContents.destroy() + this.views.delete(tabId) + this.win.webContents.send('tab-closed', { tabId }) + const next = this.views.keys().next().value as TabId | undefined + if (next) this.switch(next) + } + + navigate(tabId: TabId, url: string): void { + const view = this.views.get(tabId) + if (!view) return + this.skipNextNavigate.set(tabId, true) + view.webContents.loadURL(url) + } + + reload(tabId: TabId): void { + const view = this.views.get(tabId) + if (!view) return + view.webContents.reload() + } + + goBack(tabId: TabId): void { + const view = this.views.get(tabId) + if (!view) return + if (view.webContents.canGoBack()) view.webContents.goBack() + } + + goForward(tabId: TabId): void { + const view = this.views.get(tabId) + if (!view) return + if (view.webContents.canGoForward()) view.webContents.goForward() + } + + private attach(tabId: TabId): void { + const view = this.views.get(tabId) + if (!view) return + if (this.activeId && this.views.get(this.activeId)) { + const prev = this.views.get(this.activeId)! + this.win.removeBrowserView(prev) + } + this.activeId = tabId + this.win.addBrowserView(view) + this.updateActiveBounds() + } + + private updateActiveBounds(): void { + if (!this.activeId) return + const view = this.views.get(this.activeId) + if (!view) return + const [width, height] = this.win.getContentSize() + view.setBounds({ x: 0, y: UI_HEIGHT, width, height: Math.max(0, height - UI_HEIGHT) }) + } + + private bindEvents(id: TabId, view: BrowserView): void { + const send = () => this.win.webContents.send('tab-updated', this.info(id, view)) + view.webContents.on('did-start-loading', send) + view.webContents.on('did-stop-loading', send) + view.webContents.on('did-finish-load', send) + view.webContents.on('page-title-updated', send) + view.webContents.on('did-navigate', send) + view.webContents.on('did-navigate-in-page', send) + + view.webContents.on('will-navigate', (event, url) => { + if (this.skipNextNavigate.get(id)) { + this.skipNextNavigate.set(id, false) + return + } + event.preventDefault() + this.create(url) + }) + + view.webContents.setWindowOpenHandler(({ url }) => { + this.create(url) + return { action: 'deny' } + }) + } + + private info(id: TabId, view: BrowserView): TabInfo { + const wc = view.webContents + return { + id, + url: wc.getURL(), + title: wc.getTitle(), + isLoading: wc.isLoading(), + canGoBack: wc.canGoBack(), + canGoForward: wc.canGoForward() + } + } +} \ No newline at end of file diff --git a/src/preload.ts b/src/preload.ts index f765e76..29cdd92 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,5 +1,32 @@ -const { contextBridge, ipcRenderer } = require('electron'); +import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('electronAPI', { openBaidu: () => ipcRenderer.invoke('open-baidu') -}); \ No newline at end of file +}) + +contextBridge.exposeInMainWorld('api', { + versions: process.versions, + external: { + open: (url: string) => ipcRenderer.invoke('external-open', url) + }, + window: { + minimize: () => ipcRenderer.send('window-min'), + maximize: () => ipcRenderer.send('window-max'), + close: () => ipcRenderer.send('window-close') + }, + tabs: { + create: (url?: string) => ipcRenderer.invoke('tab:create', url), + list: () => ipcRenderer.invoke('tab:list'), + navigate: (tabId: string, url: string) => ipcRenderer.invoke('tab:navigate', { tabId, url }), + reload: (tabId: string) => ipcRenderer.invoke('tab:reload', tabId), + back: (tabId: string) => ipcRenderer.invoke('tab:back', tabId), + forward: (tabId: string) => ipcRenderer.invoke('tab:forward', tabId), + switch: (tabId: string) => ipcRenderer.invoke('tab:switch', tabId), + close: (tabId: string) => ipcRenderer.invoke('tab:close', tabId), + on: (event: 'tab-updated' | 'tab-created' | 'tab-closed' | 'tab-switched', handler: (payload: any) => void) => { + const listener = (_e: any, payload: any) => handler(payload) + ipcRenderer.on(event, listener) + return () => ipcRenderer.removeListener(event, listener) + } + } +}) \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index 6c2909d..e33ec75 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -8,8 +8,8 @@ const routes = [ // }, { path: "/", - name: "Home", - component: () => import("@/views/home/index.vue"), + name: "Browser", + component: () => import("@/views/browser/BrowserLayout.vue"), }, { path: "/about", diff --git a/src/views/browser/BrowserLayout.vue b/src/views/browser/BrowserLayout.vue new file mode 100644 index 0000000..cd892a3 --- /dev/null +++ b/src/views/browser/BrowserLayout.vue @@ -0,0 +1,130 @@ + + + + + \ No newline at end of file diff --git a/src/views/browser/README.md b/src/views/browser/README.md new file mode 100644 index 0000000..58ef366 --- /dev/null +++ b/src/views/browser/README.md @@ -0,0 +1,149 @@ +# Electron 多标签浏览器实现计划(高性能方案) + +## 目标 +- 在现有 Electron + Vue + TypeScript + TailwindCSS 项目中实现类似 Chrome 的多标签页浏览体验:创建、切换、关闭、前进、后退、刷新、地址输入、拓展插件入口、收藏入口。 +- 采用高性能与安全优先的架构:主进程 `BrowserView` 管理;渲染层仅通过受控 API;IPC 类型明确;持久化与插件机制可扩展。 + +## 当前项目结构与集成点 +- 主进程入口:`src/main.ts`(窗口创建与 IPC 注册,参考 `src/main.ts:11-57`) +- 预加载:`src/preload.ts`(已暴露 `window.api`,参考 `src/preload.ts:7-17`) +- 渲染层入口:`index.html` + `src/renderer.ts`(Vue 应用挂载,参考 `src/renderer.ts:35-45`) +- 现有 IPC 控制器:`src/controller/changeWindowSize.js`(窗口操作) + +## 高性能技术方案 +- 选型:使用 `BrowserView` 每个标签一个 `BrowserView`,由主进程统一管理。 + - 原因:`BrowserView` 与主窗口同进程但独立渲染,API 完整(`webContents` 导航、生命周期事件),相较 `` 更易管控、性能与兼容性更好。 +- 视图复用与生命周期: + - 活跃标签:将其 `BrowserView` attach 到主窗口;非活跃标签:detach(保留引用与状态),避免多视图同时渲染占用资源。 + - 限制最大同时 attach 数量(默认 1),保障 GPU/CPU 负载稳定。 +- 会话与隔离: + - 默认共享 `session`(`partition: 'persist:main'`),减少多会话开销。 + - 可按需支持隔离会话(隐私标签):`partition: 'tab:'`。 +- 导航性能与安全: + - 导航统一通过主进程 `view.webContents.loadURL(url)`;前进/后退/刷新使用 `goBack/goForward/reload`。 + - 外链打开统一通过 `shell.openExternal(url)`,白名单校验(已存在 `external-open` 通道,参考 `src/main.ts:33-43`)。 +- 地址栏与状态: + - 渲染层地址栏仅发起 IPC;主进程执行导航并广播当前标签的 `url/title/loading/historyState`。 +- 书签与持久化: + - 存储方案优先使用 `electron-store`(JSON,轻量、跨平台);高并发或大数据可切换 `better-sqlite3`。 +- 插件入口: + - 设计内部插件机制(非 Chrome 扩展):定义 `onTabCreated/onNavigate/beforeLoad/afterLoad` 等钩子;插件注册于主进程。 + - Chrome 扩展仅作为可选(`session.loadExtension`),受限于兼容性与审核,不作为默认方案。 + +## 模块设计 +### 主进程(`src/main/`) +- `TabManager`:管理 `BrowserView` 生命周期与状态 + - 方法:`create(url?)`、`switch(tabId)`、`close(tabId)`、`navigate(tabId, url)`、`reload(tabId)`、`goBack(tabId)`、`goForward(tabId)`、`list()`。 + - 事件:`tab-updated`(title/url/loading)、`tab-created`、`tab-closed`、`tab-switched`。 +- `IPCRegistry`:集中注册通道 + - `tab:create | tab:switch | tab:close | tab:navigate | tab:reload | tab:back | tab:forward | tab:list` + - `bookmark:add | bookmark:remove | bookmark:list | bookmark:folders` + - `plugin:invoke(hook, payload)`(内部插件钩子转发) +- `BookmarkStore`:封装 `electron-store` 或 SQLite(可插拔) +- `PluginHost`:插件注册与钩子执行(有序、可熔断) + +### 预加载(`src/preload.ts`) +- 扩展现有 `window.api`: + - `tabs.*`:与上述 IPC 一一对应,统一 `invoke` 调用;`tabs.on(event, handler)` 用于订阅主进程广播(通过 `ipcRenderer.on` 包装)。 + - `bookmarks.*`:增删改查接口。 + - `plugins.invoke(hook, payload)`:触发插件钩子。 + +### 渲染层(Vue) +- 布局页面:`BrowserLayout`(地址栏、标签条、控制区、书签/插件入口) +- 组件: + - `TabBar`:显示标签列表,支持新建/切换/关闭、拖拽排序(后续) + - `AddressBar`:URL 输入与状态展示(加载中、锁标识、HTTPS) + - `Controls`:后退/前进/刷新/新建标签按钮 + - `BookmarksPane`:收藏夹入口(侧栏或菜单) + - `PluginsMenu`:插件入口(菜单或面板) +- 路由:`/browser` 作为应用主界面;初始打开一个空白或主页标签。 + +## IPC 通道与类型(示例) +```ts +type TabId = string; +interface TabInfo { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean } + +// 请求 +tab:create(url?: string) => TabInfo +tab:switch(tabId: TabId) => void +tab:close(tabId: TabId) => void +tab:navigate({ tabId, url }: { tabId: TabId, url: string }) => void +tab:reload(tabId: TabId) => void +tab:back(tabId: TabId) => void +tab:forward(tabId: TabId) => void +tab:list() => TabInfo[] + +// 广播 +tab-updated: TabInfo +tab-created: TabInfo +tab-closed: { tabId: TabId } +tab-switched: { tabId: TabId } +``` + +## 数据模型 +- `Tab`:`{ id, view, url, title, isLoading, createdAt }` +- `Bookmark`:`{ id, title, url, folderId?, createdAt }` +- `Folder`:`{ id, name, parentId? }` +- `Plugin`:`{ id, name, hooks }` + +## 性能优化要点 +- 仅 attach 当前活跃 `BrowserView` 到窗口;对非活跃标签 `detach` 保留状态。 +- 控制最大标签数(例如 20),超过时启用 LRU 释放策略(可配置)。 +- 启用硬件加速,保持默认;避免禁用 GPU。 +- 监听 `did-stop-loading`/`did-finish-load` 更新状态,减少渲染层轮询。 +- 对地址栏与状态广播使用节流(例如 100ms)。 +- 持久化异步批量写入(书签、会话恢复)。 + +## 安全策略 +- 保持 `contextIsolation: true`、`sandbox: true`、`nodeIntegration: false`(已在 `src/main.ts:20-26`) +- 外链统一 `shell.openExternal` + 白名单(已在 `src/main.ts:33-43`) +- 严格 CSP 保留于渲染层 `index.html`;`BrowserView` 加载外域不受该 CSP 限制,但需根据业务限制域名。 + +## 迭代计划(三阶段) +1. 核心能力(主进程 + IPC + 预加载) + - 实现 `TabManager` 与全部导航方法 + - 注册 IPC(tabs/bookmarks/plugins)与广播 + - 预加载扩展 `window.api.tabs/*`、事件订阅封装 +2. 渲染层界面 + - 新建 `BrowserLayout` 与基础组件(TabBar、AddressBar、Controls) + - 打通地址栏与导航;同步标题与加载状态 + - 书签入口基础增删与列表展示 +3. 插件与高级能力 + - `PluginHost` 与钩子机制;内置示例插件(如:拦截导航统计) + - 标签拖拽排序、会话恢复、快捷键(Ctrl/Cmd+T/W、Ctrl/Cmd+L、Ctrl/Cmd+R、Alt+←/→) + +## 集成步骤(细化) +- 主进程:新增 `src/main/tab-manager.ts`、`src/main/ipc.ts`、`src/main/bookmark-store.ts`、`src/main/plugin-host.ts`;在 `src/main.ts` 初始化与注册。 +- 预加载:扩展 `window.api`,新增 `tabs`、`bookmarks`、`plugins` 命名空间。 +- 渲染层:新增 `/browser` 页面与组件,替换应用首页或通过路由进入。 + +## 决策点(需确认) +1. 标签视图方案:仅 `BrowserView`(推荐),允许 `` 兼容模式 +2. 最大标签数量与释放策略:默认 20,接受LRU 释放 +3. 会话策略:全部共享 `session` ,支持隐私标签独立 `partition` +4. 书签存储:默认 `electron-store` ,暂不考虑 SQLite(`better-sqlite3`) +5. 插件能力边界:需要支持加载 Chrome 扩展(`session.loadExtension`) +6. 首页与地址栏行为:默认主页 URL、空白页(`about:blank`),可自定义欢迎页 +7. 路由集成:将 `/browser` 作为默认首页,保留现有 `/about` 等页面 + +## 验收与测试 +- 开发启动:`npm run start`,验证创建/切换/关闭/导航/刷新基本路径 +- 事件广播:在 Vue 中订阅 `tab-updated`,确保标题/加载进度实时更新 +- 书签:新增/删除/持久化验证与重启恢复 +- 性能:在 10+ 标签场景观察 CPU/GPU 占用与 UI 响应 +- 安全:尝试非法域名导航,验证白名单拦截与错误提示 + +--- +后续我将基于以上计划逐步实现。在“决策点”中的问题请先确认,我将据此微调实现(例如是否支持隐私标签、是否引入 SQLite、是否兼容 ``)。 + + + + + +**待反馈决策** +- 顶部 UI 高度与 `BrowserView` 边界 + - 当前使用固定偏移 `64px` 对齐顶部 UI。是否改为渲染层动态上报高度(例如在布局变化时通过 IPC 设置),以适配不同分辨率与主题? +- Chrome 扩展加载 + - 文档中确认“需要支持加载 Chrome 扩展”。建议作为后续阶段实现,采用 `session.loadExtension` 并隔离到特定 `partition`,以减少对主会话的影响;请确认是否需要默认加载的扩展清单或仅提供入口。 + +如果以上决策点确认,我将继续第二阶段:完善 UI 高度动态设置与扩展加载入口,同时补充书签持久化(基于 `userData` 目录 JSON 文件)与插件钩子框架。 \ No newline at end of file