Skip to content

开发要点与避坑指南 ​

收集插件开发中最容易踩的坑,帮助第三方开发者避免重复犯错。

渲染机制与脚本执行 ​

内联渲染如何工作 ​

插件有两种渲染模式,由 manifest 的 render 字段决定(默认 iframe):

模式触发渲染方式脚本执行时机
iframe 沙箱(默认)缺省或 "render": "iframe"<iframe sandbox="allow-scripts"> + srcdocsrcdoc 解析时自动执行
内联渲染显式 "render": "inline"剥壳后注入主界面容器激活加载时立即执行

L2 iframe 沙箱 ​

  • 与主界面完全隔离(独立 window),脚本随 srcdoc 自动执行
  • 支持全部 __PLUGIN_API__ 方法、.p-* 样式、registerMethod/callPlugin(经主窗口中转)
  • 插件脚本无法直接访问主界面 DOM(安全隔离),document.getElementById('root') 是插件自己的内容

内联渲染 ​

启动器把插件 HTML 的 <!DOCTYPE>/<html>/<head>/<body> 剥掉后,注入到主界面的一个容器里,并追加插件 API 桥接脚本。

关键结论:

  1. 脚本在插件激活加载时立即执行。container.innerHTML = ... 本身不会执行 <script>,启动器会把容器临时挂载到隐藏宿主节点(保证已连接 document)后,再逐个 replaceWith 重新解析脚本节点触发执行。因此:
    • 库插件(如 MarkdownLib)激活即可 registerMethod,无需用户进入插件页面
    • 依赖方插件启动时即可 callPlugin 到已激活的前置插件方法
  2. sourceMap 归属以最后加载的内联插件为准:多个内联插件共享主 window,sourceMap 会把最后激活的插件 id 关联到 window,可能导致归属错位。涉及多个插件同时调 API 时,注意确认插件 id 正确。

脚本内的 document 访问 ​

插件默认运行在 iframe 沙箱(opaque origin,独立 document);仅显式声明 "render": "inline" 才走内联渲染。内联模式下插件脚本与主界面同 window 上下文,PluginPage 会把 document.getElementById('root') 临时劫持为插件容器。所以:

  • 不要依赖全局 document 查询主界面元素
  • 用 document.getElementById('root') 获取自己的容器(当前实现下可行,内联模式)
  • 若依赖主界面 DOM,需显式 "render": "inline";否则请用 iframe 隔离

TIP

需要隔离环境、常驻脚本(AI 助手、工具面板)→ 用默认 iframe 沙箱。纯 UI/依赖主界面 DOM 的轻量插件 → 显式声明 "render": "inline"。

API 调用注意事项 ​

函数不能经 postMessage 传输 ​

插件 API 跨窗口调用使用 postMessage(结构化克隆),函数不可克隆。因此:

  • 只传可序列化数据(字符串、数字、对象、数组)
  • registerMethod 的 fn 保留在插件所在 window 本地,消息里只传方法名
  • proxyFetchStream 的 req.signal(AbortSignal)在发送前被自动剥离,改走专用 abort 消息,不要依赖把 AbortSignal 放进 req 直接传

proxyFetchStream 的完成判定 ​

js
await __PLUGIN_API__.proxyFetchStream(req, {
  onChunk(c) { /* 处理 SSE data 行 */ },
  onError(e) { /* 流中断 */ }
})
  • 返回的 Promise 在流结束 resolve,但 chunk 回调可能在 resolve 之后仍触发
  • 建议在 chunk 里检测 SSE 的 [DONE] 标记或业务结束标志来判断完成,不要只用 Promise resolve

相对路径 vs 绝对 URL ​

  • 插件文件(HTML/JS/CSS)里的相对资源路径会被重写为 {API_BASE}/plugins/{id}/files/...,不要硬编码 /api/plugins/... 相对路径(打包后无 Vite proxy)
  • 外部 CDN 资源受 CSP 限制:图片/字体可用完整 https:// URL;CSS/JS 会被拦截,必须随包分发(详见 CSP 资源放行对照表)

依赖与激活 ​

库插件必须声明 entry.frontend ​

activatePlugin 只在 entry.frontend 存在时才激活插件(渲染、注册方法、标记 active)。省略 frontend 的插件不会被激活,方法无法注册。库插件即使不做 UI 也需提供一个空页面。

激活取决于 entry.frontend,而非 layers ​

插件安装后初始状态为 installed,实际激活取决于两点:① entry.frontend 存在(activatePlugin 仅在 entry.frontend 存在时才渲染并标记 active);② 用户在插件列表中启用。与 layers 声明无关——纯 ["l3"] 插件若有 entry.frontend 照样会激活(默认 iframe 沙箱,仅 "render":"inline" 走内联)。

激活顺序已自动处理 ​

启动器用拓扑排序保证被依赖插件先激活,无需关心 manifest 声明顺序。

状态切换需重启 ​

插件启用/禁用开关的改动重启启动器后生效(PUT /api/plugins/{id}/state),运行中不会热激活。用户测试新插件时注意提示。

manifest 注意事项 ​

  • minLauncherVersion 当前未校验,但请填写以兼容未来
  • 后端对 permissions 仅存储不校验;前端运行时按 METHOD_PERMISSIONS 表校验,manifest 缺权限 → API 调用报 Permission denied
  • contributes.commands 用于注册键盘快捷键(如 devtools:toggle),见 PluginEventBridge.tsx;downloadSources 当前未使用,勿依赖;settingsPages 已实装(「设置 → 插件 → 插件设置」,见 manifest)
  • 安装接口(upload)不做依赖检查;目录安装(install)才检查必装依赖

后端行为 ​

  • POST /api/plugins/upload 文件字段名为 plugin(multipart)
  • proxyFetch 有 SSRF 防护:禁止内网/保留地址。本地调试代理时注意(localhost 会被拦截)
  • 插件配置/缓存按插件 id 隔离,路径 {数据目录}/plugins/{id}/
  • 覆盖安装会先删旧目录(plugins/{id}/)再复制

调试建议 ​

  • 插件页面脚本错误在启动器 DevTools(Ctrl+Shift+I)控制台可见,日志带 [plugin:inline] / [sandbox] 前缀
  • API 调用失败信息会在控制台输出([API] 前缀),含状态码与错误码
  • 检查插件是否真的激活:插件列表的状态;已激活插件 state === 'active'
  • 检查依赖:sortByDependencies 会跳过缺失依赖并 warn 日志

环境与构建避坑(实战) ​

Tauri/WebView2 框架环境(与普通浏览器不同) ​

插件运行在 Tauri(WebView2,Chromium 内核)里,不是普通浏览器,环境差异直接影响"哪些资源能用":

维度普通浏览器启动器插件环境
页面 originhttps://...tauri://localhost(release)/ http://localhost:1420(dev)
后端 API / 插件资源—http://localhost:5000(插件文件与 API 都从这取)
外部网络自由经插件 API proxyFetch(后端代理,自带 SSRF 防护)
本地文件受限用插件文件 API(readText/readBytes 等)
CSP站点自定Tauri 在 release 注入固定 CSP;dev 不注入

dev 与 release 的关键差异(最常见翻车点):

  • npm run tauri dev / 纯浏览器预览:无 CSP,外部 CDN 的 CSS/JS 能自由加载 → 很多问题 dev 里测不出来
  • release 安装包:Tauri 注入 CSP,外部资源按 CSP 拦截 → 常见的"dev 正常、release 白板 / 缺样式 / 图标不显示 / worker 失效"几乎都是这个原因

CSP 资源放行对照表(当前启动器默认值) ​

指令放行内容对插件的影响
script-src'self' 'unsafe-inline' 'wasm-unsafe-eval' http://localhost:5000 blob: data:后端 JS、内联 <script>、blob/data worker、wasm 可用;外部 CDN <script src="https://..."> 被拦截
worker-src'self' blob: data:Web Worker(pdf.js 等)可用
connect-src'self' http://localhost:5000 ws://localhost:5000 https://api.github.com blob: data:后端 API、GitHub API、fetch blob/data 可用
img-src'self' blob: asset: http://localhost:5000 data: https:后端图片、CDN 图片(https:)可用
style-src'self' 'unsafe-inline' http://localhost:5000内联 <style>、后端 CSS 可用;外部 CDN <link rel="stylesheet">(https:)被拦截
font-src'self' data: https:base64 内联与 CDN 字体(https:)可用
frame-src'self' blob:悬浮窗(blob URL)可用;外部网页 <iframe>(https:)被拦截
media-src'self' asset:本地媒体资产

由此得出的插件开发结论:

  1. 插件自己的 CSS/JS 必须随包分发,从后端 http://localhost:5000/api/plugins/{id}/files/... 加载,不要用 CDN 的 <link rel="stylesheet"> 或 <script src="https://...">——release 里会被拦截
  2. 外部 CDN 只能用于图片(img-src 含 https:)和字体(font-src 含 https:)
  3. 图标字体若走 CDN 的 <link>,其 CSS 会被 style-src 拦截;应内联字体 CSS(@font-face + base64)或把字体文件随包放后端
  4. 需要 iframe 嵌外部网页 → 被 frame-src 拦截;改用 proxyFetch 取内容渲染,或用悬浮窗(blob URL)
  5. 依赖外部脚本(如 CDN 的库)→ 下载后随包分发,从后端加载
  6. 远程 API 一律走 proxyFetch(后端代理),不要直接跨域 fetch(CORS 失败且被 SSRF 防护兜底)

资源被拦截的排查方法 ​

  • 启动器 DevTools(Ctrl+Shift+I)控制台:搜 Refused to load / violates ... Content Security Policy
  • 直接访问 http://localhost:5000/api/plugins/{id}/files/{path} 确认资源可达:若 404 是路径问题;若 200 但仍不显示 → 几乎都是 CSP 拦截
  • dev 正常 + release 异常 → 优先怀疑 CSP(dev 无 CSP)
  • 插件页面空白但控制台无 JS 报错 → 查 #root 是否有内容;空则 JS 未加载(script-src),有内容但无样式 → CSS 被拦(style-src)

Tauri/WebView2 的 blob:/data:/worker 加载 ​

启动器(Tauri + WebView2)的 CSP 默认已放行 blob:、data:、'wasm-unsafe-eval'(script-src / worker-src / connect-src),因此 new Worker(blobUrl)、import(blobUrl)、pdf.js(module worker)等默认可用。

自定义/收紧 CSP 时必须保留

若自行替换启动器 CSP,以下放行缺一不可,否则 release 包中插件会出问题:

  • script-src 需含 'unsafe-inline' http://localhost:5000 blob: data: 'wasm-unsafe-eval'
  • worker-src 需含 blob: data:
  • connect-src 需含 http://localhost:5000 ws://localhost:5000 blob: data:
  • style-src 需含 http://localhost:5000(缺失 → 插件页面完全无样式,见下一节)
  • font-src 需含 https:(CDN 图标字体)
  • frame-src 需含 blob:(悬浮窗/overlay 用 blob URL)

历史背景:旧版 CSP 未放行 blob:/data:,new Worker(blobUrl)、import(blobUrl) 会失败(报 Cannot load script at: blob:...),依赖 Web Worker 的库(如 pdf.js v4)不可用。若确实无法放行,备选方案是把 worker 代码内联进主 bundle,运行时用 data:text/javascript;base64,... 作 workerSrc(pdf.js 需配合 classic worker 版本)。

release 包里插件页面完全无样式(白板) ​

现象:npm run tauri dev 里插件样式正常,但打包成 release(安装包)后插件页面变白板/无样式、文字堆叠、无卡片背景。

根因:Tauri 在 release 构建时向 WebView 注入 CSP(dev 模式不注入,所以 dev 正常)。若 CSP 的 style-src 未放行插件资源所在的后端地址(http://localhost:5000),插件 <link rel="stylesheet" href=".../files/assets/index.css"> 会被 CSP 拦截。控制台报 Refused to load the stylesheet ... violates ... "style-src"。JS 通常不受影响(script-src 已含 5000),所以页面"有布局、无样式"。

解决:启动器 CSP 的 style-src 必须包含后端地址,当前启动器已默认含 http://localhost:5000。插件侧无需改动。

排查:dev 正常 + release 白板 → 优先怀疑 CSP;打开启动器 DevTools(Ctrl+Shift+I)控制台查 Refused to load the stylesheet 报错。

目录源(registry)避免走 GitHub raw ​

现象:插件商店列表空白 / 报 HTTP 499。

根因:目录源用 raw.githubusercontent.com,国内被墙/极慢,后端 proxyFetch 15s 超时后返回 499(TaskCanceledException)。

解决:目录源改用 GitHub contents API(api.github.com,国内可达):

https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}

响应体为 { "content": "<base64>", "encoding": "base64" },需 base64 解码(浏览器 atob + TextDecoder)后再解析 JSON。

  • 商店插件(top.qomicex.market)v1.0.1 起已默认走 GitHub API
  • 注意 GitHub 未认证 API 速率限制(60 次/小时),目录拉取不宜过频繁

内联 bundle 注入 HTML 前的危险序列 ​

把打包后的 JS 内联进 <script>...</script> 时,若 JS 里含 <!--、-->、<script、</script>,HTML 解析器会进入 script escaped / double-escaped 状态,可能提前结束 <script> 或吞掉后续内容,导致 JS 截断、方法未注册。

注入前必须转义(JS 里 unicode 转义运行时还原,语义不变):

js
const js = bundle
  .replace(/<!--/g, '<\\u0021--')
  .replace(/-->/g, '--\\u003e')
  .replace(/<script/gi, '\\u003cscript')
  .replace(/<\/script>/g, '<\\/script>')

字符串模板注入别用 String.replace ​

tpl.replace('<!--占位符-->', '<script>' + js + '</script>') 会展开 replacement 里的 $ 模式($&、$'、$`、$1 等),污染注入的 JS。典型事故:bundle 内 xlsx 库的 "0$&" 会被展开成占位符文本,整个 bundle 损坏、registerMethod 未注册。

用 split().join() 替代,不做任何替换展开:

js
const html = tpl.split('<!--占位符-->').join('<script>' + js + '</script>')

插件管理/列表图标:库插件用顶层 manifest.icon ​

  • 插件管理卡片的图标读取顺序为 contributes.menuItems[0].icon ?? manifest.icon
  • 库插件要显示图标,用 manifest 顶层 "icon": "fa-solid fa-file-lines",不要声明 contributes.menuItems(会创建侧边栏入口,并可能把插件页面渲染出来)
  • 图标尽量用 FontAwesome 类名(fa-solid fa-xxx,启动器预置列表见 BuiltinIcons.tsx):SVG 路径经 <img> 渲染固定黑色、不随明暗主题;FA 类名经 <FontAwesomeIcon> 随主题
  • 需要新的 FA 图标时,在启动器 BuiltinIcons.tsx 的 byClass 里加条目(含 fa-brands 如 fa-markdown)

文件拖放拿不到本地路径 ​

  • Tauri 默认 dragDropEnabled 会拦截系统文件拖放,前端原生 drop 事件的 File 对象拿不到 .path(也常收不到 drop)
  • 需要启动器 Rust 侧监听 window.on_window_event,匹配 WindowEvent::DragDrop::Drop { paths, .. },再 emit("file-drop", paths) 到前端
  • 前端(含需要此能力的插件)用 @tauri-apps/api/event 的 listen('file-drop') 接收绝对路径数组;插件 iframe 需由主界面前端经 callPlugin 转发

文件读取 API 是 readText / readBytes,不是 readFile ​

插件桥的文件读取方法名是 readText(UTF-8 文本,返回 { content })和 readBytes(二进制,返回 { contentBase64 }),权限 filesystem:read。不存在 readFile 方法,用错会报 Unknown method: readFile。写入为 writeText / writeBytes,删除为 deleteFile。