现代桌面应用开发中,插件化架构已成为提升应用可扩展性和可维护性的核心技术。通过插件化,可以将功能模块解耦,实现独立开发、部署和更新,类似于 VS Code 的扩展生态。构建主程序提供核心 API,插件在此基础上独立实现 UI 和业务逻辑的架构模式。
┌─────────────────────────────────────────────────────────────────────┐
│ 主应用 (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 内容的标签。它运行在一个独立的进程中,提供了较好的隔离性。
启用方式:
const win = new BrowserWindow({
webPreferences: {
webviewTag: true // 必须显式开启
}
})使用方式:
<webview id="plugin-view" src="file:///path/to/plugin/index.html"></webview>缺点:
Electron 官方已不推荐使用 <webview>,因为它所依赖的 Chromium webview 正在经历重大架构变革,导致其稳定性、导航和事件路由等方面存在诸多 bug 和性能问题<webview> 中的页面没有 Node.js 集成,与主进程的通信也相对繁琐DOM 元素,它的加载和渲染受制于渲染进程,导致插件启动时机滞后<iframe> 标签<iframe> 是 HTML 标准标签,兼容性好,使用简单
使用方式:
<iframe id="plugin-frame" src="https://example.com"></iframe>缺点:
<iframe> 存在严格的同源策略限制。要加载本地插件,需要移除 Content-Security-Policy,这会带来安全风险<iframe> 内的页面无法直接访问 Node.js 能力,也无法直接加载 preload 脚本。所有与主应用的交互都必须通过 postMessage 进行,这极大地限制了插件的能力上限<iframe> 与宿主页面共享同一个渲染进程,隔离性差,插件的崩溃可能会影响整个应用的稳定性BrowserViewBrowserView 是 Electron 官方推荐的 <webview> 替代方案。它被设计为一个“子窗口”,直接附加到 BrowserWindow 上,但在操作系统的窗口层级中独立存在。(注:截至 2026-09,Electron 自 29 版起已将 BrowserView 标记为废弃,推荐改用 WebContentsView;本文基于历史版本的实现思路仍具参考价值)
优势:
BrowserView 实例都在独立的渲染进程中运行,提供最佳的性能和隔离性,其运行速度与 Chrome 标签页相当webContents 访问: BrowserView 拥有自己的 webContents 实例,可以像 BrowserWindow 一样加载 preload 脚本、开启 Node.js 集成,并进行精细的权限控制BrowserView 的位置和大小,实现复杂的 UI 布局结论: 综合考虑性能、隔离性、功能和官方支持,BrowserView 是构建 Electron 插件化架构的最理想选择
BrowserView 的插件化实现一个标准的插件包应包含以下文件:
my-plugin/
├── index.html # 插件的 UI 入口文件
├── index.js # 插件的业务逻辑脚本
├── preload.js # 预加载脚本,用于访问 Node.js 和 openAPI
└── package.json # 插件元信息,如名称、版本、入口等在主进程中可以创建一个函数来加载和管理插件 BrowserView
// 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 脚本和 Electron 的 IPC (进程间通信) 机制实现。其核心思想是:主应用不直接暴露危险的 Node.js 模块,而是提供封装好的、权限受控的 API。
openAPI 调用流程解析当插件调用 window.rubick.someApi() 时,内部的数据流转和进程通信遵循以下步骤:
插件渲染进程 (BrowserView):
window.rubick.someApi()。rubick 对象是由 contextBridge 安全暴露的,它并非一个真正的 API 实现,而是一个通往主应用的“信使”。contextBridge (预加载脚本):
contextBridge 接收到调用请求。ipcRenderer,例如 ipcRenderer.invoke('api-name', ...args)。contextBridge 确保了插件代码无法直接访问 ipcRenderer 或其他 Node.js 模块,只能调用我们预先定义好的接口。IPC (进程间通信):
ipcRenderer 将请求通过 Electron 的 IPC 通道发送给主进程。主进程 (ipcMain):
ipcMain 监听到对应的频道(如 'api-name')。Node.js 环境访问权限,可以执行文件操作、调用原生对话框、访问数据库等。返回结果 (可选):
return。IPC 通道将结果传回给发起请求的 ipcRenderer。Promise 解析 (插件渲染进程):
ipcRenderer.invoke 返回的 Promise 被解析,插件的 await 表达式拿到最终结果。这个流程确保了插件在一个受限的环境中运行,所有特权操作都必须通过主进程代理,由主应用集中管控,从而实现了安全与功能的平衡。
openAPI在主应用的 preload 脚本中,通过 contextBridge 暴露一个全局的 API 对象给渲染进程(包括 BrowserView)
// 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)API 调用在主进程中,使用 ipcMain 监听并处理来自插件的请求
// 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)
}openAPI现在,插件的渲染进程代码可以像调用本地 JavaScript 方法一样使用这些 API。
// 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: 插件是否启用安装流程:
.zip 文件)SHA256 校验和)plugins 目录下的一个新文件夹package.json,获取元信息卸载流程:
BrowserView)更新流程:
注意: 在进行文件操作时,需要确保操作的原子性和安全性,避免因更新失败等原因导致插件损坏。详细的实现可以参考
rubick项目中的runner.ts和插件管理相关模块。
上下文隔离 (contextIsolation): 始终保持 contextIsolation: true。这是 Electron 安全的核心建议,可以有效防止插件的 JavaScript 代码直接访问 Electron 内部 API 和 Node.js 全局变量,避免原型链污染
沙箱模式 (sandbox): 对于不需要 Node.js 能力的纯 UI 插件,可以开启沙箱模式 (sandbox: true),以最小权限原则运行插件,最大程度地降低安全风险
权限控制: 不应将整个 fs 或 child_process 等危险模块直接暴露给插件。应根据插件需求,封装粒度更细、权限更小的 API。例如,提供 selectDirectory() API 而不是直接暴露 fs
校验插件来源: 在加载插件前,应对其来源和完整性进行校验(如通过 SHA256 校验和),确保插件未被篡改
网络请求限制 (webRequest): 可以使用 session.webRequest API 拦截和审查插件发出的网络请求,防止恶意数据外泄。例如,你可以为插件创建一个独立的 session 并只允许它请求特定的域名:
// 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 }) // 阻止所有其他请求
}
})┌──────────────────────────────────────────────────────────────────────────┐
│ 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) │ │
│ └─────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘// 预加载脚本中定义
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)
}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')
}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)
}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')
}my-plugin/
├── package.json # 插件元信息
├── index.html # UI 入口
├── src/
│ ├── index.js # 主逻辑
│ ├── style.css # 样式
│ └── components/ # 组件
└── preload.js # 预加载脚本(可选){
"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"]
}
]
}<!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>// 插件主逻辑
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()* {
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;
}检查步骤:
package.json 格式是否正确index.html 路径是否存在webPreferences 配置// 添加错误监听
view.webContents.on('did-fail-load', (event, code, desc) => {
console.error(`插件加载失败: ${code} - ${desc}`)
})方法一:通过 DevTools
// 在主进程中打开插件 DevTools
view.webContents.openDevTools()方法二:通过快捷键
在开发环境中,可以注册快捷键打开当前插件的 DevTools:
const { globalShortcut } = require('electron')
globalShortcut.register('CommandOrControl+Shift+I', () => {
const focusedView = getFocusedBrowserView()
focusedView?.webContents.openDevTools()
})可以通过主进程作为中转站:
// 主进程
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)
})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('插件已恢复响应')
})// 可以定期检查插件的资源使用情况
setInterval(() => {
const views = pluginManager.getAllViews()
views.forEach(view => {
view.webContents.getProcessMemoryInfo().then(info => {
if (info.private > 100 * 1024) { // 100MB(private 单位为 KB)
console.warn('插件内存使用过高')
// 可以选择重启或卸载插件
}
})
})
}, 30000) // 每30秒检查一次插件生命周期管理是插件化架构的核心环节,涵盖插件从发布、安装、运行到卸载的完整流程。
┌─────────────────────────────────────────────────────────────────────┐
│ 插件生命周期流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 发布 │───►│ 安装 │───►│ 运行 │───►│ 卸载 │ │
│ │ Publish │ │ Install │ │ Run │ │ Uninstall│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ npm/oss │ │ 下载/解压 │ │ 加载/初始化│ │ 清理资源 │ │
│ │ 上传发布 │ │ 安全校验 │ │ 运行监控 │ │ 删除文件 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ │
│ │ 更新 │ │
│ │ Update │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 版本检查 │ │
│ │ 热更新 │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘插件开发的最终目的是让用户能够方便地获取和使用。为此需要将插件发布到云端,形成一个中心化的插件市场。目前主流的插件发布方式有两种:
将插件打包成特定格式(如 uTools 的 .upx 文件),然后上传到对象存储服务(OSS)上。用户通过下载并安装这些打包文件来使用插件
将每个插件作为独立的 npm 包发布到 npm registry。这种方式在现代插件化架构中越来越受欢迎
发布流程:
发布插件非常简单,只需在插件项目根目录下执行:
$ npm publish注意:在发布前,请务必确认当前的 npm registry 是否符合预期。例如:若要发布到内部网络,需先将 registry 切换至内网源。可以通过以下命令检查当前的 registry:
$ npm config get registry标准的插件项目遵循特定的目录结构,以便主程序能够正确加载和执行
plugin-example/
|-- index.html # 插件的 UI 入口文件
|-- preload.js # 预加载脚本,用于桥接主进程和插件进程
└── package.json # 插件的元数据描述文件package.jsonpackage.json 文件定义插件的所有元数据,除了标准的 npm 字段外,还扩展了一些自定义字段以满足插件系统的需求
标准字段示例:
{
"name": "plugin-demo",
"version": "1.0.0",
"description": "这是一个插件的描述信息。",
"author": "Your Name",
"dependencies": {
// 插件的运行时依赖
}
}扩展字段详解:
{
// ...
"pluginName": "演示插件", // 插件的中文名称,用于在 UI 中展示
"main": "./index.html", // 插件的入口文件路径
"logo": "path/to/logo.png", // 插件的 Logo 图标
"pluginType": "ui", // 插件类型('ui' 或 'system')
"features": [
{
"explain": "启动演示插件", // 功能描述
"cmds": [
"demo", // 激活插件的关键词
"演示插件"
]
}
]
}pluginType:
ui: 带有用户界面的插件,通过 BrowserView 加载system: 在后台运行的系统级插件,无界面features: 定义了用户如何与插件交互。cmds 数组中的关键词可以被主程序捕获,用于激活或调用此插件index.html这是插件的 UI 入口,主程序将通过 BrowserView 加载此文件
// 主程序中创建 BrowserView 的逻辑
import { BrowserView } from "electron"
const createView = (plugin, window) => {
const { indexPath, preload } = plugin
const view = new BrowserView({
webPreferences: {
preload // 指定预加载脚本
// ...
}
})
window.setBrowserView(view)
view.webContents.loadURL(indexPath) // 加载插件的 HTML 文件
}简单的 index.html 示例:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>插件标题</title>
</head>
<body>
<h1>Hello, Plugin!</h1>
</body>
<script>
// 通过 preload 暴露的 API 与主程序或其他插件交互
window.rubick.showNotification("插件加载成功!")
window.pluginAPI.doSomething()
</script>
</html>preload.jspreload.js 是连接插件渲染进程和主进程的桥梁。由于渲染进程运行在沙箱环境中,出于安全考虑,它不能直接访问 Node.js 或 Electron 的 API。preload.js 在拥有 Node.js 环境访问权限的上下文中执行,可以通过 contextBridge 向插件的 window 对象安全地暴露特定 API。
// preload.js
import { contextBridge } from "electron"
const pluginAPI = {
doSomething() {
// 在这里可以安全地调用 Node.js 或 Electron 的 API
console.log("Action from plugin!")
}
}
// 将 API 暴露给插件的渲染进程
contextBridge.exposeInMainWorld("pluginAPI", pluginAPI)基于 npm 的插件管理,可以方便地实现插件的安装、更新和卸载
在 Electron 应用中执行 npm install 命令,最直接的方式是借助 Node.js 的 child_process 模块(或 cross-spawn 这样的跨平台库)来调用系统 Shell
方法一:使用 spawn 执行 npm 命令
import spawn from "cross-spawn"
async function execCommand(cmd, modules, baseDir, registry) {
return new Promise((resolve, reject) => {
const args = [
cmd,
...modules.map((m) => `${m}@latest`),
"--save",
`--registry=${registry}`
]
const npmProcess = spawn("npm", args, { cwd: baseDir })
let output = ""
npmProcess.stdout.on("data", (data) => (output += data.toString()))
npmProcess.stderr.on("data", (data) => (output += data.toString()))
npmProcess.on("close", (code) => {
if (code === 0) {
resolve({ success: true, data: output })
} else {
reject({ success: false, error: output })
}
})
})
}
// 安装 plugin-demo
// execCommand('install', ['plugin-demo'], '/path/to/plugins', 'https://registry.npm.taobao.org');问题:此方法依赖于用户系统中已安装 Node.js 和 npm,否则会因找不到 npm 命令而失败
方法二:编程式使用 npm
为了解决环境依赖问题,可以将 npm 作为一个库直接集成到 Electron 应用中,以编程方式调用其命令。
注意:自 npm v7 以来,官方不再推荐或支持编程式 API,其稳定性无法得到保证。因此选择一个相对稳定且支持该用法的旧版本,如 v6.14.7
import npm from "npm"
import path from "path"
async function execCommand(cmd, modules, baseDir, registry) {
return new Promise((resolve, reject) => {
const config = {
prefix: baseDir,
save: true,
cache: path.join(baseDir, "cache"),
registry: registry
}
npm.load(config, (loadErr) => {
if (loadErr) return reject({ success: false, error: loadErr })
npm.commands[cmd](modules,%20(cmdErr,%20data) => {
if (cmdErr) {
reject({ success: false, error: cmdErr })
} else {
resolve({ success: true, data })
}
})
})
})
}
// 安装 plugin-demo
// execCommand('install', ['plugin-demo@latest'], '/path/to/plugins', 'https://registry.npm.taobao.org');通过这种方式,插件安装不再依赖于用户的本地环境,大大提升了应用的健壮性
插件更新的本质是安装最新版本的 npm 包。实现逻辑如下:
npm install <plugin-name>@latest 命令import axios from "axios"
import fs from "fs"
async function upgradePlugin(name, baseDir, registry) {
try {
// 1. 获取本地版本
const pkgPath = path.join(baseDir, "node_modules", name, "package.json")
const localPkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
const localVersion = localPkg.version
// 2. 获取远程最新版本
const registryUrl = `${registry}/${name}`
const { data } = await axios.get(registryUrl)
const latestVersion = data["dist-tags"].latest
// 3. 对比版本并更新
if (latestVersion > localVersion) {
console.log(`发现新版本 ${latestVersion},正在更新...`)
await execCommand("install", [`${name}@latest`], baseDir, registry)
console.log("插件更新成功!")
} else {
console.log("已是最新版本。")
}
} catch (error) {
console.error("插件更新失败:", error)
}
}卸载插件同样简单,只需调用 npm uninstall 命令即可
async function uninstallPlugin(name, baseDir) {
try {
await execCommand("uninstall", [name], baseDir)
console.log(`插件 ${name} 卸载成功!`)
} catch (error) {
console.error("插件卸载失败:", error)
}
}关于本地调试:
在开发阶段,可以使用 npm link 来调试本地插件。在卸载或安装时,需要区分当前是开发环境还是生产环境,以执行 unlink 或 uninstall
async function uninstall(pluginName, options) {
const command = options.isDev ? "unlink" : "uninstall"
await execCommand(command, [pluginName])
}完整的插件管理实现可以参考:rubick/src/core/plugin-handler/index.ts
┌─────────────────────────────────────────────────────────────────────┐
│ 插件安全校验流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 下载插件包 │ │
│ └───────┬──────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ 失败 ┌──────────────┐ │
│ │ 完整性校验 │───────────►│ 拒绝安装 │ │
│ │ (SHA256) │ │ │ │
│ └───────┬──────┘ └──────────────┘ │
│ │ 通过 │
│ ▼ │
│ ┌──────────────┐ 失败 ┌──────────────┐ │
│ │ 签名验证 │───────────►│ 拒绝安装 │ │
│ │ (RSA/DSA) │ │ │ │
│ └───────┬──────┘ └──────────────┘ │
│ │ 通过 │
│ ▼ │
│ ┌──────────────┐ 失败 ┌──────────────┐ │
│ │ 元数据校验 │───────────►│ 拒绝安装 │ │
│ │ (package.json)│ │ │ │
│ └───────┬──────┘ └──────────────┘ │
│ │ 通过 │
│ ▼ │
│ ┌──────────────┐ │
│ │ 安装插件 │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘// src/main/plugin/validator.ts
import crypto from 'crypto'
import fs from 'fs'
export async function verifyFileIntegrity(
filePath: string,
expectedHash: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256')
const stream = fs.createReadStream(filePath)
stream.on('data', (data) => hash.update(data))
stream.on('end', () => {
const actualHash = hash.digest('hex')
resolve(actualHash === expectedHash)
})
stream.on('error', reject)
})
}
// 使用示例
const isValid = await verifyFileIntegrity(
'/path/to/plugin.zip',
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
)
if (!isValid) {
throw new Error('插件文件完整性校验失败')
}// src/main/plugin/signer.ts
import crypto from 'crypto'
export interface PluginSignature {
signature: string
publicKey: string
timestamp: number
}
export class PluginSigner {
private privateKey: string
private publicKey: string
constructor(privateKey: string, publicKey: string) {
this.privateKey = privateKey
this.publicKey = publicKey
}
// 签名插件
async signPlugin(pluginPath: string): Promise<PluginSignature> {
const content = await fs.promises.readFile(pluginPath)
const sign = crypto.createSign('RSA-SHA256')
sign.update(content)
sign.end()
return {
signature: sign.sign(this.privateKey, 'base64'),
publicKey: this.publicKey,
timestamp: Date.now()
}
}
// 验证签名
async verifySignature(
pluginPath: string,
signature: PluginSignature
): Promise<boolean> {
try {
const content = await fs.promises.readFile(pluginPath)
const verify = crypto.createVerify('RSA-SHA256')
verify.update(content)
verify.end()
return verify.verify(
signature.publicKey,
signature.signature,
'base64'
)
} catch (error) {
console.error('签名验证失败:', error)
return false
}
}
}// src/main/plugin/validator.ts
export interface PluginMetadata {
name: string
version: string
main: string
pluginName: string
pluginType: 'ui' | 'system'
}
export function validatePluginMetadata(
pkg: any
): { valid: boolean; errors: string[] } {
const errors: string[] = []
// 必需字段检查
const requiredFields = ['name', 'version', 'main', 'pluginName', 'pluginType']
for (const field of requiredFields) {
if (!pkg[field]) {
errors.push(`缺少必需字段: ${field}`)
}
}
// 名称格式检查
if (pkg.name && !/^[a-z0-9-]+$/.test(pkg.name)) {
errors.push('插件名称只能包含小写字母、数字和连字符')
}
// 版本格式检查
if (pkg.version && !/^\d+\.\d+\.\d+/.test(pkg.version)) {
errors.push('版本号格式不正确,应为 semver 格式')
}
// 插件类型检查
if (pkg.pluginType && !['ui', 'system'].includes(pkg.pluginType)) {
errors.push('插件类型必须为 ui 或 system')
}
return {
valid: errors.length === 0,
errors
}
}// package.json 中声明权限
{
"name": "my-plugin",
"permissions": [
"file:read",
"file:write",
"clipboard:read",
"notification:show"
]
}// src/main/plugin/permission.ts
export class PermissionManager {
private allowedPermissions = new Map<string, string[]>()
// 定义权限等级
private permissionLevels = {
safe: ['notification:show', 'clipboard:read'],
moderate: ['file:read', 'file:write', 'dialog:open'],
dangerous: ['shell:openExternal', 'process:spawn']
}
// 注册插件权限
registerPlugin(pluginName: string, permissions: string[]) {
this.allowedPermissions.set(pluginName, permissions)
}
// 检查权限
hasPermission(pluginName: string, permission: string): boolean {
const allowed = this.allowedPermissions.get(pluginName) || []
return allowed.includes(permission)
}
// 获取权限等级
getPermissionLevel(permission: string): string {
for (const [level, perms] of Object.entries(this.permissionLevels)) {
if (perms.includes(permission)) {
return level
}
}
return 'unknown'
}
}// src/main/plugin/debug.ts
import { BrowserView } from 'electron'
export function setupPluginDebug(view: BrowserView, pluginName: string) {
// 自动打开 DevTools
if (process.env.NODE_ENV === 'development') {
view.webContents.openDevTools({ mode: 'detach' })
}
// 监听控制台消息
view.webContents.on('console-message', (event, level, message, line, sourceId) => {
const logLevel = ['verbose', 'info', 'warning', 'error'][level]
console.log(`[${pluginName}] [${logLevel}] ${message}`)
})
// 监听加载事件
view.webContents.on('did-finish-load', () => {
console.log(`[${pluginName}] 加载完成`)
})
view.webContents.on('did-fail-load', (event, code, desc) => {
console.error(`[${pluginName}] 加载失败: ${code} - ${desc}`)
})
}// 启用远程调试端口
app.commandLine.appendSwitch('remote-debugging-port', '9222')
// 然后可以通过 chrome://inspect 访问// src/main/plugin/logger.ts
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
export class PluginLogger {
private logDir: string
constructor() {
this.logDir = path.join(app.getPath('userData'), 'plugin-logs')
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true })
}
}
log(pluginName: string, level: string, message: string, data?: any) {
const timestamp = new Date().toISOString()
const logEntry = {
timestamp,
plugin: pluginName,
level,
message,
data
}
const logFile = path.join(this.logDir, `${pluginName}.log`)
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n')
// 同时输出到控制台
console.log(`[${timestamp}] [${pluginName}] [${level}] ${message}`)
}
info(pluginName: string, message: string, data?: any) {
this.log(pluginName, 'INFO', message, data)
}
error(pluginName: string, message: string, data?: any) {
this.log(pluginName, 'ERROR', message, data)
}
warn(pluginName: string, message: string, data?: any) {
this.log(pluginName, 'WARN', message, data)
}
}// src/main/plugin/monitor.ts
export class PluginMonitor {
private metrics = new Map<string, any>()
startMonitoring(pluginName: string, view: BrowserView) {
// 定期收集性能指标
const interval = setInterval(async () => {
try {
const memoryInfo = await view.webContents.getProcessMemoryInfo()
const cpuUsage = process.cpuUsage()
this.metrics.set(pluginName, {
memory: memoryInfo,
cpu: cpuUsage,
timestamp: Date.now()
})
// 内存预警
if (memoryInfo.private > 200 * 1024) { // private 单位为 KB
console.warn(`[${pluginName}] 内存使用超过 200MB`)
}
} catch (error) {
console.error(`[${pluginName}] 监控失败:`, error)
}
}, 10000) // 每 10 秒采集一次
return () => clearInterval(interval)
}
getMetrics(pluginName: string) {
return this.metrics.get(pluginName)
}
}// src/main/plugin/multiWindow.ts
import { BrowserWindow, BrowserView } from 'electron'
export class MultiWindowPluginManager {
private windowPlugins = new Map<number, Map<string, BrowserView>>()
// 在指定窗口加载插件
loadPluginInWindow(
windowId: number,
pluginName: string,
pluginPath: string
): BrowserView {
const win = BrowserWindow.fromId(windowId)
if (!win) {
throw new Error(`Window ${windowId} not found`)
}
// 检查该窗口是否已有此插件
let windowPlugins = this.windowPlugins.get(windowId)
if (!windowPlugins) {
windowPlugins = new Map()
this.windowPlugins.set(windowId, windowPlugins)
}
if (windowPlugins.has(pluginName)) {
return windowPlugins.get(pluginName)!
}
// 创建新的 BrowserView
const view = new BrowserView({
webPreferences: {
preload: path.join(pluginPath, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
win.addBrowserView(view)
view.webContents.loadFile(path.join(pluginPath, 'index.html'))
windowPlugins.set(pluginName, view)
return view
}
// 卸载窗口中的插件
unloadPluginFromWindow(windowId: number, pluginName: string) {
const windowPlugins = this.windowPlugins.get(windowId)
if (!windowPlugins) return
const view = windowPlugins.get(pluginName)
if (view) {
const win = BrowserWindow.fromId(windowId)
win?.removeBrowserView(view)
view.webContents.close()
windowPlugins.delete(pluginName)
}
}
// 获取窗口中的所有插件
getWindowPlugins(windowId: number): string[] {
return Array.from(this.windowPlugins.get(windowId)?.keys() || [])
}
}排查步骤:
package.json 格式是否正确main 字段指向的文件是否存在// 添加详细日志
ipcMain.on('plugin:load-error', (event, error) => {
console.error('插件加载错误:', error)
logger.error('Plugin load error:', error)
})// 使用 npm 的 peerDependencies 声明兼容版本
{
"peerDependencies": {
"vue": "^3.0.0",
"react": "^18.0.0"
},
"peerDependenciesMeta": {
"vue": { "optional": true },
"react": { "optional": true }
}
}
// 主应用检查依赖兼容性
async function checkDependencies(plugin) {
const dependencies = plugin.dependencies || {}
const incompatible = []
for (const [dep, version] of Object.entries(dependencies)) {
const installed = await getInstalledVersion(dep)
if (installed && !semver.satisfies(installed, version)) {
incompatible.push({ dep, required: version, installed })
}
}
return incompatible
}// 监听插件文件变化
import chokidar from 'chokidar'
export class PluginHotReload {
private watchers = new Map<string, FSWatcher>()
watch(pluginPath: string, onChange: () => void) {
const watcher = chokidar.watch(pluginPath, {
ignored: /node_modules/,
ignoreInitial: true
})
watcher.on('change', (filePath) => {
console.log(`文件变化: ${filePath}`)
onChange()
})
this.watchers.set(pluginPath, watcher)
}
unwatch(pluginPath: string) {
const watcher = this.watchers.get(pluginPath)
if (watcher) {
watcher.close()
this.watchers.delete(pluginPath)
}
}
}// 使用 session 隔离网络请求
import { session } from 'electron'
function createPluginSession(pluginName: string) {
const ses = session.fromPartition(`persist:plugin-${pluginName}`)
// 只允许特定域名
ses.webRequest.onBeforeRequest((details, callback) => {
const allowedDomains = ['api.example.com', 'cdn.example.com']
const url = new URL(details.url)
if (allowedDomains.includes(url.hostname)) {
callback({ cancel: false })
} else {
console.warn(`[${pluginName}] 阻止访问: ${url.hostname}`)
callback({ cancel: true })
}
})
return ses
}// 插件健康检查与自动恢复
export class PluginHealthChecker {
private healthStatus = new Map<string, boolean>()
startChecking(pluginName: string, view: BrowserView) {
const interval = setInterval(async () => {
try {
// 发送心跳检测
const response = await view.webContents.executeJavaScript(
'typeof window.__pluginHealthCheck === "function" ? window.__pluginHealthCheck() : true'
)
this.healthStatus.set(pluginName, response)
} catch (error) {
console.warn(`[${pluginName}] 健康检查失败`)
this.healthStatus.set(pluginName, false)
// 自动重启插件
this.restartPlugin(pluginName, view)
}
}, 30000) // 每 30 秒检查一次
return () => clearInterval(interval)
}
async restartPlugin(pluginName: string, view: BrowserView) {
console.log(`[${pluginName}] 正在重启...`)
view.webContents.reload()
}
}| Electron 版本 | Node.js 版本 | Chromium 版本 |
|---|---|---|
| 28.x | 18.18.2 | 120.0.6099.56 |
| 27.x | 18.17.1 | 118.0.5993.54 |
| 26.x | 18.16.1 | 116.0.5845.97 |
| 25.x | 18.15.0 | 114.0.5735.134 |
npm v6.x,因 v7+ 不再官方支持编程式调用// package.json 中锁定 npm 版本
{
"dependencies": {
"npm": "6.14.18"
}
}// src/main/plugin/compatibility.ts
import { app } from 'electron'
import semver from 'semver'
export interface CompatibilityResult {
compatible: boolean
issues: string[]
warnings: string[]
}
export function checkPluginCompatibility(plugin: any): CompatibilityResult {
const issues: string[] = []
const warnings: string[] = []
// 检查 Node.js 版本要求
if (plugin.engines?.node) {
const nodeVersion = process.versions.node
if (!semver.satisfies(nodeVersion, plugin.engines.node)) {
issues.push(
`Node.js 版本不兼容: 要求 ${plugin.engines.node}, 当前 ${nodeVersion}`
)
}
}
// 检查 Electron 版本要求
if (plugin.engines?.electron) {
const electronVersion = process.versions.electron
if (!semver.satisfies(electronVersion, plugin.engines.electron)) {
issues.push(
`Electron 版本不兼容: 要求 ${plugin.engines.electron}, 当前 ${electronVersion}`
)
}
}
// 检查 API 版本
if (plugin.apiVersion && plugin.apiVersion !== CURRENT_API_VERSION) {
warnings.push(
`API 版本不匹配: 插件 ${plugin.apiVersion}, 主程序 ${CURRENT_API_VERSION}`
)
}
return {
compatible: issues.length === 0,
issues,
warnings
}
}