> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qomicex.top/llms.txt
> Use this file to discover all available pages before exploring further.

# 开发要点与避坑指南

## 开发要点与避坑指南

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

## 渲染机制与脚本执行

### 内联渲染如何工作

插件有两种渲染模式，由 manifest 的 `render` 字段决定（**默认 iframe**）：

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

### L2 iframe 沙箱

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

### 内联渲染

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

**关键结论：**

* **脚本在插件激活加载时立即执行**。`container.innerHTML = ...` 本身不会执行 `<script>`，启动器会把容器临时挂载到隐藏宿主节点（保证已连接 document）后，再逐个 `replaceWith` 重新解析脚本节点触发执行。因此：
* **库插件（如 MarkdownLib）激活即可 `registerMethod`**，无需用户进入插件页面
* 依赖方插件启动时即可 `callPlugin` 到已激活的前置插件方法
* **`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 隔离

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

## API 调用注意事项

### 函数不能经 postMessage 传输

插件 API 跨窗口调用使用 `postMessage`（结构化克隆），**函数不可克隆**。因此：

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

### proxyFetchStream 的完成判定

```js theme={null}
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 资源放行对照表](#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](https://docs.qomicex.top/plugins/manifest.md#settingspages-数组)）
* 安装接口（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 内核）里，**不是普通浏览器**，环境差异直接影响"哪些资源能用"：

| 维度 | 普通浏览器 | 启动器插件环境 |
| - | - | - |
| 页面 origin | `https://...` | `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:` | 本地媒体资产 |

**由此得出的插件开发结论：**

* **插件自己的 CSS/JS 必须随包分发**，从后端 `http://localhost:5000/api/plugins/{id}/files/...` 加载，**不要用 CDN** 的 `<link rel="stylesheet">` 或 `<script src="https://...">`——release 里会被拦截
* 外部 CDN 只能用于**图片**（`img-src` 含 `https:`）和**字体**（`font-src` 含 `https:`）
* 图标字体若走 CDN 的 `<link>`，其 CSS 会被 `style-src` 拦截；应**内联字体 CSS**（`@font-face` + base64）或把字体文件随包放后端
* 需要 iframe 嵌外部网页 → 被 `frame-src` 拦截；改用 `proxyFetch` 取内容渲染，或用悬浮窗（blob URL）
* 依赖外部脚本（如 CDN 的库）→ 下载后随包分发，从后端加载
* 远程 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，**以下放行缺一不可**，否则 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`，国内可达）：

```text theme={null}
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 theme={null}
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 theme={null}
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`，即启动器内置表）：SVG 路径经 `<img>` 渲染固定黑色、不随明暗主题；FA 类名随主题
* 需要新的 FA 图标时，在启动器 `src/components/BuiltinIcons.tsx` 的 entries 表里加条目（含 `fa-brands` 如 `fa-markdown`），再跑 `node scripts/gen-icon-catalog.mjs` 同步到 `@qomicex/plugin-ui` 与 `qomicex-cli`（`pnpm run test:icon-catalog` 会检查漂移）

### 图标不显示 / 只剩一个空位

* **最常见原因：类名不在启动器内置表里。** `fa-floppy-disk`、`fa-skull`、`fa-file-zipper`、`fa-keyboard`、`fa-folder-tree`、`fa-puzzle-piece`、`fa-cog` 这类"看起来很像"的名字都不在表内。表外类名历史上是**静默渲染为空**的。
* 排查：`qomicex verify` 或 `qomicex lint` 会报 error 并给出相近的内置图标建议；运行时 `<Icon>` 未命中会在控制台 `console.warn`；也可以在启动器里调 `await __PLUGIN_API__.call('listIcons')` 拿全量清单。
* 插件页面里的图标应该用 `@qomicex/plugin-ui` 的 `<Glyph>` + 单图标子路径（`@qomicex/plugin-ui/icons/server`），不要自己封装 `getIconSvg` 桥调用——那条路径每个图标一次 IPC，且拼错时没有任何反馈。
* 表里确实没有需要的图标时：用 `contributes.iconTheme` 声明私有图标，或随包附 `.svg` 用 `<img>` 渲染。

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

* 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`。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.