{T}

插件化架构

现代桌面应用开发中,插件化架构已成为提升应用可扩展性和可维护性的核心技术。通过插件化,可以将功能模块解耦,实现独立开发、部署和更新,类似于 VS Code 的扩展生态。构建主程序提供核心 API,插件在此基础上独立实现 UI 和业务逻辑的架构模式。

架构概览

code
┌─────────────────────────────────────────────────────────────────────┐
│                           主应用 (Main App)                          │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │                      主进程 (Main Process)                    │   │
│  │  ┌────────────┐  ┌────────────┐  ┌────────────────────────┐  │   │
│  │  │ 窗口管理    │  │ 插件管理器  │  │     IPC 处理器         │  │  │
│  │  │ WindowMgr  │  │ PluginMgr  │  │  openAPI Handlers     │  │  │
│  │  └────────────┘  └─────┬──────┘  └────────────────────────┘  │  │
│  └─────────────────────────┼─────────────────────────────────────┘  │
│                            │                                        │
│                            ▼                                        │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                   BrowserWindow (主窗口)                      │  │
│  │  ┌─────────────────────────────────────────────────────────┐  │  │
│  │  │                    主 UI 区域                            │  │  │
│  │  └─────────────────────────────────────────────────────────┘  │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐   │  │
│  │  │ BrowserView │  │ BrowserView │  │   BrowserView       │   │  │
│  │  │  插件 A     │  │  插件 B     │  │    插件 C           │   │  │
│  │  │ (独立进程)  │  │ (独立进程)  │  │   (独立进程)        │   │  │
│  │  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘   │  │
│  │         │                │                    │               │  │
│  └─────────┼────────────────┼────────────────────┼───────────────┘  │
│            │                │                    │                   │
│            └────────────────┼────────────────────┘                   │
│                             ▼                                        │
│            ┌────────────────────────────────────┐                   │
│            │         openAPI 桥接层             │                   │
│            │   (preload + contextBridge)        │                   │
│            └────────────────────────────────────┘                   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

核心设计原则

原则说明
进程隔离每个插件运行在独立的 BrowserView 进程中,互不影响
权限最小化插件只能通过 openAPI 访问受限的系统能力
统一通信所有插件通过统一的 IPC 机制与主进程通信
生命周期管理完整的安装、加载、卸载流程管理

插件加载机制对比

要在 Electron 中加载插件(本质上是独立的 Web 内容),有三种主流技术方案:<webview> 标签、<iframe> 标签和 BrowserView

<webview> 标签

<webview>Electron 早期提供的用于嵌入 Web 内容的标签。它运行在一个独立的进程中,提供了较好的隔离性。

启用方式:

javascript
const win = new BrowserWindow({
  webPreferences: {
    webviewTag: true // 必须显式开启
  }
})

使用方式:

html
<webview id="plugin-view" src="file:///path/to/plugin/index.html"></webview>

缺点:

  • 稳定性问题: Electron 官方已不推荐使用 <webview>,因为它所依赖的 Chromium webview 正在经历重大架构变革,导致其稳定性、导航和事件路由等方面存在诸多 bug 和性能问题
  • 功能限制: 默认情况下,<webview> 中的页面没有 Node.js 集成,与主进程的通信也相对繁琐
  • 性能滞后: 作为 DOM 元素,它的加载和渲染受制于渲染进程,导致插件启动时机滞后

<iframe> 标签

<iframe>HTML 标准标签,兼容性好,使用简单

使用方式:

html
<iframe id="plugin-frame" src="https://example.com"></iframe>

缺点:

  • 安全与集成限制: <iframe> 存在严格的同源策略限制。要加载本地插件,需要移除 Content-Security-Policy,这会带来安全风险
  • 功能孱弱: <iframe> 内的页面无法直接访问 Node.js 能力,也无法直接加载 preload 脚本。所有与主应用的交互都必须通过 postMessage 进行,这极大地限制了插件的能力上限
  • 进程内渲染: <iframe> 与宿主页面共享同一个渲染进程,隔离性差,插件的崩溃可能会影响整个应用的稳定性

BrowserView

BrowserViewElectron 官方推荐的 <webview> 替代方案。它被设计为一个“子窗口”,直接附加到 BrowserWindow 上,但在操作系统的窗口层级中独立存在

优势:

  • 独立进程与高性能: 每个 BrowserView 实例都在独立的渲染进程中运行,提供最佳的性能和隔离性,其运行速度与 Chrome 标签页相当
  • 完全的 webContents 访问: BrowserView 拥有自己的 webContents 实例,可以像 BrowserWindow 一样加载 preload 脚本、开启 Node.js 集成,并进行精细的权限控制
  • 灵活的窗口管理: 开发者可以精确控制 BrowserView 的位置和大小,实现复杂的 UI 布局

结论: 综合考虑性能、隔离性、功能和官方支持,BrowserView 是构建 Electron 插件化架构的最理想选择

基于 BrowserView 的插件化实现

标准插件目录结构

一个标准的插件包应包含以下文件:

bash
my-plugin/
├── index.html       # 插件的 UI 入口文件
├── index.js         # 插件的业务逻辑脚本
├── preload.js       # 预加载脚本,用于访问 Node.js 和 openAPI
└── package.json     # 插件元信息,如名称、版本、入口等

插件加载流程

在主进程中可以创建一个函数来加载和管理插件 BrowserView

javascript
// main/pluginLoader.js
import { BrowserView } from "electron"
import path from "path"

function loadPlugin(plugin, parentWindow) {
  const { indexPath, preloadPath } = plugin // 从插件信息中获取路径

  // 1. 创建 BrowserView 实例
  const view = new BrowserView({
    webPreferences: {
      preload: preloadPath, // 指定插件自己的 preload 脚本
      nodeIntegration: true, // 为插件开启 Node.js 能力
      contextIsolation: true, // 推荐开启上下文隔离以增强安全性
      devTools: true // 开发时开启 DevTools
    }
  })

  // 2. 将 BrowserView 附加到父窗口
  parentWindow.addBrowserView(view)

  // 3. 设置视图的位置和大小
  // 初始可以先隐藏,加载完成后再显示
  const bounds = parentWindow.getBounds()
  view.setBounds({ x: 0, y: 60, width: bounds.width, height: bounds.height - 60 })
  view.setAutoResize({ width: true, height: true })

  // 4. 加载插件的入口 HTML
  view.webContents.loadFile(indexPath)

  // 5. 监听加载完成事件
  view.webContents.on("dom-ready", () => {
    console.log(`Plugin "${plugin.name}" loaded successfully!`)
    // 此处可以执行显示视图、传递初始化数据等操作
  })

  return view
}

通信机制: openAPI 桥接

为了让插件能够安全地调用主应用提供的原生能力,需要建立一个通信桥梁。这主要通过 preload 脚本和 ElectronIPC (进程间通信) 机制实现。其核心思想是:主应用不直接暴露危险的 Node.js 模块,而是提供封装好的、权限受控的 API

openAPI 调用流程解析

当插件调用 window.rubick.someApi() 时,内部的数据流转和进程通信遵循以下步骤:

  1. 插件渲染进程 (BrowserView):

    • 调用 window.rubick.someApi()
    • 这个 rubick 对象是由 contextBridge 安全暴露的,它并非一个真正的 API 实现,而是一个通往主应用的“信使”。
  2. contextBridge (预加载脚本):

    • contextBridge 接收到调用请求。
    • 它将调用转发给 ipcRenderer,例如 ipcRenderer.invoke('api-name', ...args)contextBridge 确保了插件代码无法直接访问 ipcRenderer 或其他 Node.js 模块,只能调用我们预先定义好的接口。
  3. IPC (进程间通信):

    • ipcRenderer 将请求通过 ElectronIPC 通道发送给主进程。
  4. 主进程 (ipcMain):

    • ipcMain 监听到对应的频道(如 'api-name')。
    • 执行注册的处理器函数,该函数拥有完整的 Node.js 环境访问权限,可以执行文件操作、调用原生对话框、访问数据库等。
  5. 返回结果 (可选):

    • 主进程的处理器函数执行完毕后,将结果 return
    • IPC 通道将结果传回给发起请求的 ipcRenderer
  6. Promise 解析 (插件渲染进程):

    • ipcRenderer.invoke 返回的 Promise 被解析,插件的 await 表达式拿到最终结果。

这个流程确保了插件在一个受限的环境中运行,所有特权操作都必须通过主进程代理,由主应用集中管控,从而实现了安全与功能的平衡。

步骤 1: 在主应用中定义 openAPI

在主应用的 preload 脚本中,通过 contextBridge 暴露一个全局的 API 对象给渲染进程(包括 BrowserView

javascript
// main/preload.js
import { contextBridge, ipcRenderer } from "electron"

const openAPI = {
  // 异步调用示例:显示系统通知
  showNotification: (body) => ipcRenderer.invoke("show-notification", body),

  // 异步调用并返回数据:选择一个目录
  selectDirectory: () => ipcRenderer.invoke("select-directory"),

  // 同步调用示例:获取应用版本号
  getAppVersion: () => ipcRenderer.sendSync("get-app-version"),

  // 监听主进程消息示例
  onShowMessage: (callback) =>
    ipcRenderer.on("show-message", (_event, value) => callback(value))
}

// 将 API 安全地暴露到 window 对象
contextBridge.exposeInMainWorld("rubick", openAPI)

步骤 2: 在主进程中处理 API 调用

在主进程中,使用 ipcMain 监听并处理来自插件的请求

javascript
// main/index.js
import { ipcMain, Notification, dialog } from "electron"
import { version } from "../package.json"

// 处理 'show-notification'
ipcMain.handle("show-notification", (event, body) => {
  // event.sender 来自于发起请求的 webContents
  // 可以通过它找到对应的 BrowserView 或 BrowserWindow
  if (Notification.isSupported()) {
    new Notification({ title: "来自主应用的消息", body }).show()
  }
})

// 处理 'select-directory'
ipcMain.handle("select-directory", async (event) => {
  const { canceled, filePaths } = await dialog.showOpenDialog({
    properties: ["openDirectory"]
  })
  if (canceled) {
    return null
  } else {
    return filePaths[0]
  }
})

// 处理同步请求
ipcMain.on("get-app-version", (event) => {
  event.returnValue = version
})

// 主动向插件发送消息
function showMessageToPlugin(pluginView, message) {
  pluginView.webContents.send("show-message", message)
}

步骤 3: 在插件中使用 openAPI

现在,插件的渲染进程代码可以像调用本地 JavaScript 方法一样使用这些 API

javascript
// my-plugin/index.js

// 调用 API 选择目录
document.getElementById("select-dir-btn").addEventListener("click", async () => {
  const dirPath = await window.rubick.selectDirectory()
  if (dirPath) {
    console.log("Selected directory:", dirPath)
    await window.rubick.showNotification(`你选择的目录是: ${dirPath}`)
  }
})

// 调用同步 API
const version = window.rubick.getAppVersion()
console.log("App Version:", version)

// 监听主进程消息
window.rubick.onShowMessage((message) => {
  alert(`Received message from main app: ${message}`)
})

插件生命周期管理

一个完整的插件化系统不仅要能加载插件,还必须有效地管理它们的整个生命周期,从安装、更新到最终的卸载。这通常由一个专门的插件管理器来协调,其核心围绕以下几个方面展开:

  • 插件存储:

    • 位置: 已安装的插件通常存储在用户数据目录中,例如 app.getPath('userData')/plugins
    • 结构: 每个插件都是一个独立的子目录,例如 .../plugins/my-plugin/
  • 插件清单:

    • 主应用需要维护一个清单文件(如 plugins.json),记录所有已安装插件的元信息,包括:
      • name: 插件唯一标识符
      • version: 版本号
      • path: 插件的本地绝对路径
      • enabled: 插件是否启用
  • 安装流程:

    1. 用户通过插件市场或本地文件选择一个插件包(如 .zip 文件)
    2. 主应用下载并校验插件包的完整性(例如通过 SHA256 校验和)
    3. 将插件包解压到 plugins 目录下的一个新文件夹
    4. 读取插件的 package.json,获取元信息
    5. 将新插件的信息更新到插件清单中
    6. 动态加载新安装的插件
  • 卸载流程:

    1. 用户请求卸载插件
    2. 主应用首先需要卸载/销毁正在运行的插件实例(对应的 BrowserView
    3. 从插件清单中移除该插件的条目
    4. 从文件系统中安全地删除插件目录
  • 更新流程:

    1. 检测到新版本(通过轮询插件市场或启动时检查)
    2. 遵循与安装类似的流程,下载并解压新版插件包到一个临时目录
    3. 卸载旧版本插件
    4. 将新版本插件从临时目录移动到正式的插件目录
    5. 更新插件清单中的版本号等信息
    6. 重新加载插件

注意: 在进行文件操作时,需要确保操作的原子性和安全性,避免因更新失败等原因导致插件损坏。详细的实现可以参考 rubick 项目中的 runner.ts 和插件管理相关模块。

最佳实践与安全注意事项

  • 上下文隔离 (contextIsolation): 始终保持 contextIsolation: true。这是 Electron 安全的核心建议,可以有效防止插件的 JavaScript 代码直接访问 Electron 内部 APINode.js 全局变量,避免原型链污染

  • 沙箱模式 (sandbox): 对于不需要 Node.js 能力的纯 UI 插件,可以开启沙箱模式 (sandbox: true),以最小权限原则运行插件,最大程度地降低安全风险

  • 权限控制: 不应将整个 fschild_process 等危险模块直接暴露给插件。应根据插件需求,封装粒度更细、权限更小的 API。例如,提供 selectDirectory() API 而不是直接暴露 fs

  • 校验插件来源: 在加载插件前,应对其来源和完整性进行校验(如通过 SHA256 校验和),确保插件未被篡改

  • 网络请求限制 (webRequest): 可以使用 session.webRequest API 拦截和审查插件发出的网络请求,防止恶意数据外泄。例如,你可以为插件创建一个独立的 session 并只允许它请求特定的域名:

    javascript
    // main/pluginLoader.js
    const view = new BrowserView({
      webPreferences: {
        // ...
        partition: `persist:plugin_${plugin.name}` // 为插件创建独立的 session
      }
    })
    
    // ...
    
    // 拦截网络请求
    view.webContents.session.webRequest.onBeforeRequest((details, callback) => {
      const requestUrl = new URL(details.url)
      const allowedDomains = ["api.trusted.com", "resources.trusted.com"]
    
      if (
        requestUrl.protocol === "file:" ||
        allowedDomains.includes(requestUrl.hostname)
      ) {
        callback({ cancel: false }) // 允许本地文件和受信任的域名
      } else {
        console.warn(
          `[Security] Blocked unauthorized request from plugin '${plugin.name}' to: ${requestUrl.hostname}`
        )
        callback({ cancel: true }) // 阻止所有其他请求
      }
    })

通信机制流程图

code
┌──────────────────────────────────────────────────────────────────────────┐
│                          openAPI 调用流程                                 │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ① 插件调用 API                                                          │
│  ┌─────────────────────────────────────────┐                            │
│  │  插件渲染进程 (BrowserView)             │                            │
│  │                                         │                            │
│  │  const result = await window.rubick    │                            │
│  │    .selectDirectory()                  │                            │
│  └───────────────────┬─────────────────────┘                            │
│                      │                                                   │
│                      ▼                                                   │
│  ② contextBridge 转发                                                    │
│  ┌─────────────────────────────────────────┐                            │
│  │  preload.js                             │                            │
│  │                                         │                            │
│  │  contextBridge.exposeInMainWorld({     │                            │
│  │    selectDirectory: () =>              │                            │
│  │      ipcRenderer.invoke('select-dir')  │                            │
│  │  })                                     │                            │
│  └───────────────────┬─────────────────────┘                            │
│                      │                                                   │
│                      ▼                                                   │
│  ③ IPC 通道传输                                                          │
│  ════════════════════════════════════════                               │
│                      │                                                   │
│                      ▼                                                   │
│  ④ 主进程处理                                                            │
│  ┌─────────────────────────────────────────┐                            │
│  │  主进程 (Main Process)                  │                            │
│  │                                         │                            │
│  │  ipcMain.handle('select-dir', async    │                            │
│  │    (event) => {                        │                            │
│  │      const result = await dialog       │                            │
│  │        .showOpenDialog(...)            │                            │
│  │      return result.filePaths[0]        │                            │
│  │  })                                     │                            │
│  └───────────────────┬─────────────────────┘                            │
│                      │                                                   │
│                      ▼                                                   │
│  ⑤ 返回结果                                                              │
│  ════════════════════════════════════════                               │
│                      │                                                   │
│                      ▼                                                   │
│  ⑥ Promise 解析                                                          │
│  ┌─────────────────────────────────────────┐                            │
│  │  插件渲染进程                            │                            │
│  │                                         │                            │
│  │  // result = "/selected/path"          │                            │
│  │  console.log(result)                   │                            │
│  └─────────────────────────────────────────┘                            │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

openAPI 接口说明

文件系统 API

typescript
// 预加载脚本中定义
const fileAPI = {
  // 选择文件
  selectFile: (options?: OpenDialogOptions) => 
    ipcRenderer.invoke('file:select', options),
  
  // 选择目录
  selectDirectory: () => 
    ipcRenderer.invoke('file:selectDir'),
  
  // 读取文件
  readFile: (path: string) => 
    ipcRenderer.invoke('file:read', path),
  
  // 写入文件
  writeFile: (path: string, content: string) => 
    ipcRenderer.invoke('file:write', path, content),
  
  // 删除文件
  deleteFile: (path: string) => 
    ipcRenderer.invoke('file:delete', path),
  
  // 获取文件信息
  getFileInfo: (path: string) => 
    ipcRenderer.invoke('file:info', path)
}

系统交互 API

typescript
const systemAPI = {
  // 显示通知
  showNotification: (options: NotificationOptions) => 
    ipcRenderer.invoke('notification:show', options),
  
  // 显示消息框
  showMessage: (options: MessageBoxOptions) => 
    ipcRenderer.invoke('dialog:message', options),
  
  // 打开外部链接
  openExternal: (url: string) => 
    ipcRenderer.invoke('shell:openExternal', url),
  
  // 获取剪贴板内容
  getClipboardText: () => 
    ipcRenderer.invoke('clipboard:readText'),
  
  // 设置剪贴板内容
  setClipboardText: (text: string) => 
    ipcRenderer.invoke('clipboard:writeText', text),
  
  // 获取系统信息
  getSystemInfo: () => 
    ipcRenderer.invoke('system:info')
}

窗口控制 API

typescript
const windowAPI = {
  // 最小化窗口
  minimize: () => 
    ipcRenderer.invoke('window:minimize'),
  
  // 最大化窗口
  maximize: () => 
    ipcRenderer.invoke('window:maximize'),
  
  // 关闭窗口
  close: () => 
    ipcRenderer.invoke('window:close'),
  
  // 设置窗口大小
  setSize: (width: number, height: number) => 
    ipcRenderer.invoke('window:setSize', width, height),
  
  // 设置窗口位置
  setPosition: (x: number, y: number) => 
    ipcRenderer.invoke('window:setPosition', x, y)
}

数据存储 API

typescript
const storageAPI = {
  // 存储数据
  set: (key: string, value: any) => 
    ipcRenderer.invoke('storage:set', key, value),
  
  // 获取数据
  get: (key: string) => 
    ipcRenderer.invoke('storage:get', key),
  
  // 删除数据
  delete: (key: string) => 
    ipcRenderer.invoke('storage:delete', key),
  
  // 清空数据
  clear: () => 
    ipcRenderer.invoke('storage:clear')
}

完整插件开发示例

插件目录结构

code
my-plugin/
├── package.json          # 插件元信息
├── index.html            # UI 入口
├── src/
│   ├── index.js          # 主逻辑
│   ├── style.css         # 样式
│   └── components/       # 组件
└── preload.js            # 预加载脚本(可选)

package.json

json
{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "一个示例插件",
  "author": "Developer",
  "main": "./index.html",
  "pluginName": "我的插件",
  "logo": "./logo.png",
  "pluginType": "ui",
  "features": [
    {
      "explain": "启动插件",
      "cmds": ["my-plugin", "我的插件", "demo"]
    }
  ]
}

index.html

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>我的插件</title>
  <link rel="stylesheet" href="./src/style.css">
</head>
<body>
  <div id="app">
    <h1>文件管理器</h1>
    <div class="toolbar">
      <button id="selectBtn">选择文件</button>
      <button id="saveBtn">保存内容</button>
    </div>
    <div class="content">
      <textarea id="editor" placeholder="文件内容将显示在这里..."></textarea>
    </div>
    <div id="status"></div>
  </div>
  <script src="./src/index.js"></script>
</body>
</html>

src/index.js

javascript
// 插件主逻辑
class FileManager {
  constructor() {
    this.currentFile = null
    this.initUI()
    this.bindEvents()
  }

  initUI() {
    this.editor = document.getElementById('editor')
    this.statusEl = document.getElementById('status')
    this.selectBtn = document.getElementById('selectBtn')
    this.saveBtn = document.getElementById('saveBtn')
  }

  bindEvents() {
    // 选择文件
    this.selectBtn.addEventListener('click', async () => {
      try {
        const filePath = await window.rubick.selectFile()
        if (filePath) {
          await this.loadFile(filePath)
        }
      } catch (error) {
        this.showStatus(`错误: ${error.message}`, 'error')
      }
    })

    // 保存文件
    this.saveBtn.addEventListener('click', async () => {
      if (!this.currentFile) {
        const filePath = await window.rubick.selectFile({ 
          action: 'save' 
        })
        if (filePath) {
          this.currentFile = filePath
        } else {
          return
        }
      }
      await this.saveFile()
    })
  }

  async loadFile(filePath) {
    const content = await window.rubick.readFile(filePath)
    this.editor.value = content
    this.currentFile = filePath
    this.showStatus(`已加载: ${filePath}`, 'success')
  }

  async saveFile() {
    const content = this.editor.value
    await window.rubick.writeFile(this.currentFile, content)
    this.showStatus(`已保存: ${this.currentFile}`, 'success')
    
    // 发送通知
    await window.rubick.showNotification({
      title: '保存成功',
      body: `文件已保存到 ${this.currentFile}`
    })
  }

  showStatus(message, type = 'info') {
    this.statusEl.textContent = message
    this.statusEl.className = `status ${type}`
    setTimeout(() => {
      this.statusEl.textContent = ''
    }, 3000)
  }
}

// 初始化插件
new FileManager()

src/style.css

css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  padding: 16px;
  background: #f5f5f5;
}

#app {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

h1 {
  font-size: 18px;
  margin-bottom: 12px;
}

.toolbar {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  background: #1890ff;
  color: white;
  cursor: pointer;
  transition: background 0.3s;
}

button:hover {
  background: #40a9ff;
}

.content {
  flex: 1;
  display: flex;
}

#editor {
  width: 100%;
  height: 100%;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-family: monospace;
  font-size: 14px;
  resize: none;
}

#status {
  margin-top: 12px;
  padding: 8px;
  border-radius: 4px;
  font-size: 12px;
}

#status.success {
  background: #f6ffed;
  color: #52c41a;
  border: 1px solid #b7eb8f;
}

#status.error {
  background: #fff2f0;
  color: #ff4d4f;
  border: 1px solid #ffccc7;
}

常见问题解答

Q1: 插件加载失败怎么办?

检查步骤

  1. 确认插件目录结构正确
  2. 检查 package.json 格式是否正确
  3. 查看 index.html 路径是否存在
  4. 检查 BrowserView 的 webPreferences 配置
javascript
// 添加错误监听
view.webContents.on('did-fail-load', (event, code, desc) => {
  console.error(`插件加载失败: ${code} - ${desc}`)
})

Q2: 插件如何调试?

方法一:通过 DevTools

javascript
// 在主进程中打开插件 DevTools
view.webContents.openDevTools()

方法二:通过快捷键

在开发环境中,可以注册快捷键打开当前插件的 DevTools:

javascript
const { globalShortcut } = require('electron')

globalShortcut.register('CommandOrControl+Shift+I', () => {
  const focusedView = getFocusedBrowserView()
  focusedView?.webContents.openDevTools()
})

Q3: 如何实现插件间通信?

可以通过主进程作为中转站:

javascript
// 主进程
ipcMain.handle('plugin:send', (event, targetPlugin, message) => {
  const targetView = pluginManager.getView(targetPlugin)
  if (targetView) {
    targetView.webContents.send('plugin:message', {
      from: getPluginByWebContents(event.sender),
      message
    })
  }
})

// 插件中发送
await window.rubick.sendToPlugin('other-plugin', { type: 'greeting' })

// 插件中接收
window.rubick.onPluginMessage((data) => {
  console.log('收到消息:', data)
})

Q4: 如何处理插件崩溃?

javascript
view.webContents.on('render-process-gone', (event, details) => {
  console.error(`插件崩溃: ${details.reason}`)
  
  // 自动重启插件
  setTimeout(() => {
    reloadPlugin(plugin)
  }, 1000)
})

view.webContents.on('unresponsive', () => {
  console.warn('插件无响应')
  // 可以选择重新加载
  view.webContents.reload()
})

view.webContents.on('responsive', () => {
  console.log('插件已恢复响应')
})

Q5: 如何限制插件的内存和 CPU 使用?

javascript
// 可以定期检查插件的资源使用情况
setInterval(() => {
  const views = pluginManager.getAllViews()
  views.forEach(view => {
    view.webContents.getProcessMemoryInfo().then(info => {
      if (info.privateBytes > 100 * 1024 * 1024) { // 100MB
        console.warn('插件内存使用过高')
        // 可以选择重启或卸载插件
      }
    })
  })
}, 30000) // 每30秒检查一次