第 42 章 插件系统设计
本章目标
- 区分"Tauri 官方插件"与"你的应用级插件"。
- CloudTone 的应用级插件架构:脚本(JS/WASM)或独立进程。
- 给出一个最小安全沙箱方案:WebWorker + Structured Clone 通信。
一、两种插件观
- Tauri Plugin:用 Rust 写,编译进 App,深度访问系统。不适合第三方,因为需要重编。
- 应用级插件:运行时加载的脚本/配置,能力有限但可动态安装。
CloudTone 面向用户,取第 2 种。
二、插件规范
plugin-dir/
├── manifest.json
├── main.js
└── icon.png
{
"id": "lyric-translator",
"name": "歌词翻译",
"version": "0.1.0",
"permissions": ["network"], // 可选权限
"hooks": ["onSongLoad", "onLyrics"]
}
三、加载器
// core/plugins/mod.rs
use std::path::Path;
pub struct PluginMeta {
pub id: String,
pub name: String,
pub path: std::path::PathBuf,
pub permissions: Vec<String>,
pub hooks: Vec<String>,
}
pub fn scan_plugins(dir: &Path) -> std::io::Result<Vec<PluginMeta>> {
let mut out = vec![];
if !dir.exists() { return Ok(out); }
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let mf = entry.path().join("manifest.json");
if !mf.exists() { continue; }
let raw = std::fs::read_to_string(&mf)?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
out.push(PluginMeta {
id: v["id"].as_str().unwrap_or("").into(),
name: v["name"].as_str().unwrap_or("").into(),
path: entry.path(),
permissions: v["permissions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
hooks: v["hooks"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default(),
});
}
}
Ok(out)
}
四、前端沙箱
把插件 main.js 丢进 Web Worker:它没有 DOM 和大部分 API,通信靠 postMessage。
// src/features/plugins/host.ts
export class PluginHost {
private worker: Worker;
constructor(private meta: PluginMeta) {
const code = `
importScripts("${meta.mainUrl}");
self.onmessage = async (e) => {
try {
const fn = self[e.data.hook];
if (typeof fn === "function") {
const result = await fn(e.data.payload);
self.postMessage({ id: e.data.id, result });
}
} catch (err) {
self.postMessage({ id: e.data.id, error: String(err) });
}
};
`;
const blob = new Blob([code], { type: "application/javascript" });
this.worker = new Worker(URL.createObjectURL(blob));
}
call<T>(hook: string, payload: any): Promise<T> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
const handler = (e: MessageEvent) => {
if (e.data.id !== id) return;
this.worker.removeEventListener("message", handler);
if (e.data.error) reject(new Error(e.data.error));
else resolve(e.data.result);
};
this.worker.addEventListener("message", handler);
this.worker.postMessage({ id, hook, payload });
});
}
destroy() { this.worker.terminate(); }
}
五、注册 Hook
export class PluginRegistry {
hosts = new Map<string, PluginHost>();
async load(meta: PluginMeta) {
this.hosts.set(meta.id, new PluginHost(meta));
}
async onSongLoad(song: Song) {
for (const host of this.hosts.values()) {
try { await host.call("onSongLoad", song); } catch (e) { console.warn(e); }
}
}
}
六、示例插件:歌词翻译
// main.js (用户写的插件)
globalThis.onLyrics = async ({ lines, target }) => {
const translated = await fetch("https://translate.example/api", {
method: "POST",
body: JSON.stringify({ lines: lines.map(l => l.text), target }),
}).then(r => r.json());
return lines.map((l, i) => ({ ...l, translated: translated[i] }));
};
CloudTone 主程序在加载歌词后调用:
const translated = await plugins.call("onLyrics", { lines, target: "zh-CN" });
setLyrics(translated);
七、权限与隔离
Worker 默认没有 fetch 限制,但你可以:
- 拦截
fetch:在注入的 bootstrap 脚本里覆盖globalThis.fetch为带白名单的版本。 - 只允许
manifest.permissions声明的能力(如network、storage)。 - 敏感 API(读文件、播放控制)由 Host 提供给 Worker,走
postMessage显式授权。
// bootstrap.js
const allowNet = {{JSON.stringify(meta.permissions.includes("network"))}};
const origFetch = self.fetch;
self.fetch = allowNet ? origFetch : () => { throw new Error("No network permission"); };
八、生命周期
- 启动时扫描
$APPDATA/cloudtone/plugins/并加载。 - 用户禁用时
destroy()。 - Hot reload:开发模式监听文件变化重建 Worker。
九、WASM 方案(进阶)
如需更强隔离,用 wasmtime 或 wasmer。插件导出 onSongLoad(ptr, len),Host 在 Rust 层调用。学习成本高但更安全,适合给陌生开发者开放。
本章小结
- Web Worker 是前端插件最轻量的沙箱。
- Manifest 驱动权限能缩小攻击面。
- 插件系统让产品长期活跃,用户参与感强。
动手时刻
- 写一个 "onSongLoad" 插件,打印歌名到 console。
- 给插件加"禁用/启用"开关。
下一章:自动更新。