Plugin Authoring Guide
适用范围:ECHO Next 本地插件系统,当前宿主支持 apiVersion 1 和 2,推荐新插件使用 apiVersion: 2。
这份文档写给插件作者,也写给第一次打开“插件”页面、心里还没底的人。它会先帮你判断“这个想法适不适合做成插件”,再带你做一个能跑起来的最小插件,最后再讲 manifest、权限、API、面板、provider、导入导出和调试。
目标不是教插件突破宿主限制,而是教你在 ECHO 的安全边界内做出稳定、轻量、不会拖慢播放的扩展。插件应该像一个可靠的小工具:用户知道它要什么权限,出错时能看懂日志,播放音乐时也不会被它拖住。
插件接口只是技术扩展点,不代表 ECHO 官方提供、背书或验证第三方音源。ECHO 不提供任何用于获取音乐内容的下载功能,也不承担第三方插件、脚本、接口、账号、URL 或内容来源产生的法律责任。完整声明见 Download And Plugin Source Boundaries。
如果你正在让 AI 帮你写插件,建议把本文的“让 AI 帮你写插件时怎么说”和“常见新手错误”两节一起发给它。那两节把插件类型、权限、manifest、运行边界和 AI 常见错误整理成了更适合模型执行的清单。
Steam users should enable Workshop sandbox plugins from Steam Workshop after clicking Use. To publish Workshop content, use the Workshop SDK tutorial. That sandbox is narrower than the older main-process plugin API below. Do not treat this authoring guide as a Workshop packaging tutorial.
ECHO 插件是放在用户数据目录 plugins/ 下的本地文件夹。宿主读取 echo.plugin.json,在受控 VM 沙箱里运行 plugin.js,按用户确认的权限暴露一个有限的全局 echo API,并把 panel.html 当作 sandbox iframe 显示。
插件可以做:
- 注册命令,让用户手动运行小工具。
- 读取当前播放状态,做轻量记录或展示。
- 分页读取曲库公开字段。
- 返回元数据、歌词、封面候选,交给宿主和用户决定是否采用。
- 提供自定义音源搜索候选,并在用户触发播放时返回显式
http/https音频 URL。这不是官方平台音源,也不等于接入网易云、Spotify 一类流媒体。 - 使用插件自己的设置、存储、日志和面板。
- 在
apiVersion: 2下通过宿主受控网络 API 访问http/https。
插件不能做:
- 直接访问 Node、Electron、SQLite、主应用 DOM、原生音频 host、解码器、DSP 或输出设备。
- Hook 播放热路径、修改音频 buffer、控制 WASAPI/ASIO/native host 细节。
- 任意读写本机文件。
- 自动写入曲库记录或改源音频文件。
- 后台全库扫描、持续高频轮询、长时间同步阻塞。
ECHO 的核心原则是:插件能扩展体验,但不能牺牲播放稳定性。
先判断你的想法适不适合做插件
Section titled “先判断你的想法适不适合做插件”写代码前先停一分钟,问自己五个问题:
| 问题 | 如果答案是“是” | 建议 |
|---|---|---|
| 只是想加一个按钮、菜单动作或小工具吗 | 是 | 从命令插件开始 |
| 需要显示一块自己的界面吗 | 是 | 用 Panel + Command,面板只负责 UI |
| 需要补充元数据、歌词、封面或音源候选吗 | 是 | 用对应 provider,把最终选择交给 ECHO |
| 需要读曲库但不改文件吗 | 是 | 申请 library:read,分页读取 |
| 需要改播放链、DSP、数据库、任意本机文件或主界面 DOM 吗 | 是 | 这不是普通插件能做的事,应改 ECHO 主程序或重新设计需求 |
一个好插件通常从很小的版本开始:先能启动,再能跑一个命令,再加权限,最后才加面板或网络。不要一开始就把“搜索、下载、改标签、写文件、自动播放、复杂 UI”全塞进第一版。
推荐创作路线
Section titled “推荐创作路线”| 阶段 | 你要产出的东西 | 完成标准 |
|---|---|---|
| 1. 描述想法 | 一句话写清楚插件要帮用户做什么 | 不提实现细节也能听懂 |
| 2. 选类型 | 命令、主题、面板、metadata、lyrics、cover、source provider | 知道它主要入口在哪里 |
| 3. 定权限 | permissions 只写真的会用到的权限 | 启用时用户不会被无关权限吓到 |
| 4. 写最小版 | echo.plugin.json + plugin.js | 插件页能看到、能启用、日志能看到启动信息 |
| 5. 加真实能力 | 读取播放状态、曲库分页、网络请求或 provider 返回候选 | 每一步都能单独重载验证 |
| 6. 收尾发布 | README、错误提示、导出包、发布前检查 | 别人拿到也知道怎么启用、怎么排错 |
如果你只是想先感受一下系统,不要从空白文件开始。ECHO 插件页内置了示例:播放状态面板、命令工具、曲库脚本、自定义音源、主题预设。先点“新建”,跑通后再改成自己的插件,会比盯着空白编辑器舒服很多。
最快、最不容易迷路的方式是这样:
- 打开 ECHO 的“插件”页面。
- 点“打开目录”,确认真实插件目录。目录通常是 Electron
userData/plugins,但不要硬猜路径,以插件页打开的目录为准。 - 如果你还没想好结构,先在插件页点一个示例插件的“新建”。
- 打开示例目录,看
echo.plugin.json声明了什么,再看plugin.js注册了什么。 - 每次只改一小段,保存后回到插件页点“重载”;如果改了 manifest,再点“刷新”。
- 启用插件时认真看权限确认。权限越少,用户越容易信任。
- 出错先看插件详情里的日志,不要马上扩大改动。把代码删回最小能启动的状态,再一段一段加回来。
如果你更想从零开始,下一节可以直接照抄。
零基础照着做第一个插件
Section titled “零基础照着做第一个插件”这一节按“完全没写过 ECHO 插件”的用户来写。你只要会新建文件、复制文本、保存文件,就能先跑起来一个插件。
你需要准备什么
Section titled “你需要准备什么”| 工具 | 用来做什么 |
|---|---|
| ECHO NEXT | 打开插件页面、创建示例、启用插件、看日志 |
| 一个文本编辑器 | 记事本也行,VS Code 更舒服 |
| 一个小音乐库 | 用来测试播放状态、曲库读取、provider 结果 |
建议先用一个只有几十首歌的小曲库试插件。插件写错了通常不会伤到主程序,但大库、网络请求和 provider 组合在一起时,排错会变得很吵。
不要一上来就改 ECHO 主程序源码。普通插件只需要放进 ECHO 打开的 plugins/ 目录里。你要交付给别人的也是这个插件文件夹或导出的插件包,不是 ECHO 源码改动。
第 1 步:找到插件目录
Section titled “第 1 步:找到插件目录”- 打开 ECHO NEXT。
- 进入
Plugins/ “插件”页面。 - 点击“打开目录”。
- 系统会打开一个文件夹,这就是插件目录。
- 以后所有插件文件夹都放在这里。
不要自己猜路径。不同系统、便携版、开发版的用户数据目录可能不一样,以 ECHO 打开的目录为准。
第 2 步:新建插件文件夹
Section titled “第 2 步:新建插件文件夹”在刚才打开的插件目录里,新建一个文件夹:
echo.hello-plugin文件夹名建议和插件 id 一样。插件 id 只能用小写字母、数字、.、_、-,并且要用小写字母或数字开头。新手直接照这个格式写:
echo.你的插件名例如:
echo.my-toolecho.playback-noteecho.aurora-theme第 3 步:写 echo.plugin.json
Section titled “第 3 步:写 echo.plugin.json”进入 echo.hello-plugin 文件夹,新建文件:
echo.plugin.json把下面内容完整复制进去:
{ "id": "echo.hello-plugin", "name": "Hello Plugin", "version": "0.0.1", "apiVersion": 2, "entry": "plugin.js", "permissions": [], "contributes": { "commands": [ { "id": "hello", "title": "Hello" } ] }}这个文件告诉 ECHO:
| 字段 | 你现在先这样理解 |
|---|---|
id | 插件的唯一名字,不能和别的插件重复 |
name | 插件页面显示给人看的名字 |
version | 插件版本,先写 0.0.1 |
apiVersion | 新插件写 2 |
entry | 插件启动时执行哪个 JS 文件 |
permissions | 插件要什么权限;这个 Hello 插件不需要权限 |
contributes.commands | 告诉 UI:这个插件有一个叫 hello 的命令 |
第 4 步:写 plugin.js
Section titled “第 4 步:写 plugin.js”同一个文件夹里再新建文件:
plugin.js把下面内容完整复制进去:
console.log('hello plugin loaded');
echo.commands.register('hello', { title: 'Hello' }, async () => { await echo.ui.notify('Hello from ECHO plugin'); return { ok: true, message: 'Hello from ECHO plugin' };});这段代码做了三件事:
- 插件启动时写一条日志。
- 注册一个叫
hello的命令。 - 用户运行命令时,发一个通知,并返回一段 JSON。
注意:echo.plugin.json 里的命令 id 和 plugin.js 里的命令 id 必须一样。这里都叫 hello。
第 5 步:确认文件结构
Section titled “第 5 步:确认文件结构”现在你的插件目录应该长这样:
plugins/ echo.hello-plugin/ echo.plugin.json plugin.js如果文件名写成下面这样,ECHO 可能找不到:
echo.plugin.json.txtplugin.js.txtEcho.Plugin.JsonPlugin.JSWindows 记事本容易把文件保存成 .txt。如果你看不到扩展名,先在资源管理器里打开“显示文件扩展名”。
第 6 步:回到 ECHO 刷新
Section titled “第 6 步:回到 ECHO 刷新”- 回到 ECHO 的插件页面。
- 点击“刷新”。
- 你应该能看到
Hello Plugin。 - 如果看不到,先检查文件夹名、
echo.plugin.json文件名、JSON 逗号有没有写错。
第 7 步:启用插件
Section titled “第 7 步:启用插件”- 点开
Hello Plugin。 - 点击“启用”。
- 这个插件没有权限,所以不需要额外信任危险权限。
- 启用后看插件日志,应该有
hello plugin loaded。
如果启用时报错,先看插件详情里的日志。ECHO 会把启动错误写在那里。
第 8 步:运行命令
Section titled “第 8 步:运行命令”插件启用后,在插件详情里找到命令 Hello,点击运行。你应该看到:
- 插件通知:
Hello from ECHO plugin - 日志里有命令运行记录。
到这里,第一个插件已经成功了。
如果通知没出来但插件没有报错,先刷新日志;如果日志里出现 plugin_command_not_found,说明 manifest 声明的命令 id 和 plugin.js 注册的命令 id 不一致;如果出现 plugin_command_timeout,说明命令执行超过约 2 秒,需要把耗时逻辑拆小。
第 9 步:修改插件后怎么生效
Section titled “第 9 步:修改插件后怎么生效”你改了 plugin.js 或 echo.plugin.json 之后:
- 保存文件。
- 回到插件页面。
- 点击这个插件的“重载”。
- 如果改了 manifest 但页面没变,点击“刷新”。
不要一边改文件一边期待 ECHO 自动立刻发现。插件系统当前按“刷新/重载”更新。
从这里开始,每次只加一种能力:
| 下一步想做什么 | 先加什么 | 先验证什么 |
|---|---|---|
| 读播放状态 | permissions: ["playback:read"],再调用 echo.playback.getStatus() | 命令能返回当前状态 |
| 读曲库 | permissions: ["library:read"],用分页读取 | pageSize 不超过 100 |
| 做面板 | 增加 panel.html 和 contributes.panels | 面板能通过 plugin:getSummary 收到响应 |
| 访问网络 | apiVersion: 2 + network 权限,使用 echo.net.fetchJson/fetchText | 超时、失败状态能写日志 |
| 做 provider | manifest 声明 provider,plugin.js 注册同 id provider | 搜索或候选结果能被 ECHO 收到 |
最小主题插件
Section titled “最小主题插件”如果你只是想做主题,不需要写复杂 JS。主题插件主要写 manifest,plugin.js 可以只放一行日志。
文件结构:
plugins/ echo.simple-theme/ echo.plugin.json plugin.jsecho.plugin.json:
{ "id": "echo.simple-theme", "name": "Simple Theme", "version": "0.0.1", "apiVersion": 2, "entry": "plugin.js", "permissions": [], "contributes": { "themePresets": [ { "id": "simple-blue", "title": "Simple Blue", "description": "一个最小主题示例。", "basePreset": "classic", "preview": "linear-gradient(135deg, #10243a 0%, #5cc8dc 100%)", "swatches": ["#10243a", "#5cc8dc", "#ffffff"], "light": { "appBg": "#eef8ff", "panel": "#ffffff", "accent": "#257f96", "text": "#234150" }, "dark": { "appBg": "#08111f", "panel": "#142234", "accent": "#5cc8dc", "text": "#c8dce8" } } ] }}plugin.js:
console.log('simple theme plugin loaded');启用插件后,进入 Settings / “设置” > “外观”,找到“插件主题”,点击主题卡片。ECHO 会把它导入到“我的主题”,之后你还可以继续微调颜色、透明度、圆角和动效。
主题插件常见错误:
| 错误 | 结果 | 正确写法 |
|---|---|---|
颜色写 red | 会被忽略 | 写 #ff0000 |
颜色写 #fff | 会被忽略 | 写 6 位 #ffffff |
| 写任意 CSS | 不会生效 | 只写结构化字段 |
没有 light 也没有 dark | 主题会被丢弃 | 至少写一组 |
preview 里写 url(...) | 预览会被丢弃 | 只用纯色或 linear-gradient(...) |
不知道该做哪种插件时先看这里
Section titled “不知道该做哪种插件时先看这里”先按“用户怎么触发它”来选类型,不要按代码复杂度选。
| 你想做什么 | 第一版先做成 | 需要权限吗 | 先别做什么 |
|---|---|---|---|
| 点一下按钮,弹个提示、复制文本或保存一点小状态 | 命令插件 | 通常不需要 | 不要先做面板 |
| 显示当前播放状态 | 命令插件,跑通后再加面板 | playback:read | 不要高频轮询 |
| 控制播放、暂停、跳转 | 命令插件 | playback:control | 不要自动连续 seek 或抢用户操作 |
| 统计曲库里有多少歌缺标签 | 命令插件 | library:read | 不要一次读完整曲库 |
| 给歌曲提供候选标签 | Metadata Provider | library:read | 不要直接写入曲库 |
| 给歌曲提供候选歌词 | Lyrics Provider | library:read | 不要返回超大歌词包 |
| 给歌曲提供候选封面 | Cover Provider | library:read,可能还要 network | 不要下载大图塞进结果 |
| 接入一个第三方音乐搜索源 | Source Provider | sources:provide,可能还要 network | 不要返回不明确来源的播放 URL |
| 做一个可导入主题 | Theme Preset | 不需要 | 不要写任意 CSS 或脚本注入 |
| 做一个复杂界面 | Panel + Command | 按命令实际用到的 API 申请 | 不要在面板里直接访问 echo |
新手推荐顺序:
- 先做命令插件,因为它最容易看日志、最容易确认成败。
- 再做主题插件,因为它几乎不需要权限,适合理解 manifest 的贡献点。
- 再做读取曲库的命令,练习分页和权限。
- 再做 metadata、lyrics、cover 或 source provider,练习“返回候选,不直接替用户决定”。
- 最后再做面板。面板体验更好,但多了
postMessage通信,排错成本更高。
记住一个原则:插件应该把“危险动作”交给 ECHO 或用户确认。候选、展示、轻量命令很适合插件;直接改播放链、改数据库、改源文件,不适合普通插件。
让 AI 帮你写插件时怎么说
Section titled “让 AI 帮你写插件时怎么说”你可以直接把下面这段发给 AI,然后把你的需求补进去。越具体,AI 越不容易生成越界代码。
请按 ECHO Next 插件系统写一个本地插件。先阅读 docs/ECHO_NEXT_PLUGINS.md 和 docs/plugin-sdk/ForAIReadme.md;如果需要核对真实接口,再看 src/shared/types/plugins.ts、src/main/plugins/PluginManifest.ts、src/main/plugins/PluginService.ts、src/renderer/pages/PluginsPage.tsx。不要修改 ECHO 主程序源码,只生成插件文件夹内的文件。使用 apiVersion: 2。权限最小化,不要申请无关权限。插件目录名和 id 使用 echo.my-plugin 这种格式。需要提供 echo.plugin.json、plugin.js、README.md。如果需要面板,再提供 panel.html,并通过 plugin:runCommand 调用命令。plugin.js 不要使用 require/import/process/window/document/fetch。网络访问必须通过 echo.net,并声明 network 权限。命令和事件 handler 要轻量,超过 2 秒的任务要拆小或返回“已排队”。请先给出文件结构、manifest、权限理由、使用步骤、调试步骤,再给代码。我的需求是:在这里写清楚用户怎么触发、要读什么、要展示什么、失败时怎么提示。如果 AI 生成了代码,你要检查:
- 它有没有让你改
src/main/...或src/renderer/...。普通插件不应该改这些。 - 它有没有写
require、import、process、window、document、fetch。 - 它有没有一次申请很多权限。
- 它有没有告诉你把文件放进 ECHO 插件页打开的目录。
- 它有没有写清楚怎么刷新、启用、看日志。
- 它有没有把面板写成“直接调用
echo”。面板不能直接拿到echo,要通过postMessage请求plugin:runCommand。 - 它有没有把长任务写在
playback:status事件里。播放状态事件应该很轻,不要在里面做网络请求、全库查询或大 JSON 写入。 - 它有没有直接采纳第三方返回的数据并写入曲库。普通插件应该返回候选,让 ECHO 和用户决定。
如果 AI 写得太大,先让它缩成“只包含一个命令、一个日志、一种权限”的版本。插件开发里,小而能跑比大而玄学更值钱。
常见新手错误
Section titled “常见新手错误”| 现象 | 最可能原因 | 怎么修 |
|---|---|---|
| 插件页看不到插件 | 文件夹没放进插件目录,或 echo.plugin.json 文件名错 | 点“打开目录”,确认结构 |
| 插件显示 manifest 错误 | JSON 少逗号、多逗号、引号错 | 用 JSON 校验器检查 |
id must use lowercase... | 插件 id 不符合规则 | 用 echo.my-plugin 这种小写格式 |
apiVersion must be between 1 and 2 | apiVersion 写错或写成字符串 | 新插件写数字 2 |
| entry 或 panel 不生效 | 写了子目录、绝对路径或错误扩展名 | entry 写根目录 .js 文件名,panel 写根目录 .html 文件名 |
| 启用后立刻报错 | plugin.js 顶层代码抛错 | 看插件日志,先删到最小代码 |
| 命令不出现 | manifest 里声明了,但 plugin.js 没注册 | contributes.commands[].id 和 echo.commands.register 保持一致 |
| 命令点击没反应 | handler 抛错或超时 | 看日志,减少代码,先返回 { ok: true } |
| 权限不足 | manifest 没写对应权限,或启用时没信任 | 补权限,刷新,再重新启用 |
面板里找不到 echo | 面板本来就没有 echo | 面板用 postMessage 调 plugin:runCommand |
| 网络请求失败 | 用了 fetch 或没申请 network | 用 echo.net.fetchJson/fetchText |
| 网络请求被拒绝 | 方法、header、URL 或响应大小不符合宿主限制 | 只用 GET / POST,只传必要 header,控制响应体 |
| 曲库读取很慢 | 一次读太多 | 分页,pageSize <= 100 |
| provider 有时没结果 | 返回字段过大、数量太多或 handler 超时 | 控制候选数量,先返回小结果,再加缓存 |
| 插件突然被宿主禁用 | 10 分钟内连续启动失败达到隔离阈值 | 修好启动错误后再启用,先用最小代码确认能启动 |
| 导出包里带了缓存 | 手动塞了 plugin-storage.json | 删除运行缓存再发布 |
插件目录推荐形态:
plugins/ echo.my-plugin/ echo.plugin.json plugin.js panel.html README.md echo-plugin.d.ts运行中可能出现这些宿主文件:
plugins/ plugin-state.json echo.my-plugin/ plugin-storage.json plugin-settings.json这些文件是运行状态,不应当手动写入发布包。ECHO 导出插件包时也会排除它们。
| 文件 | 是否必需 | 作用 |
|---|---|---|
echo.plugin.json | 必需 | 插件 manifest,声明 id、版本、入口、权限和贡献点 |
plugin.js | 通常必需 | 插件入口脚本,在受控 VM 沙箱运行 |
panel.html | 可选 | 插件面板,作为 sandbox iframe 显示 |
echo-plugin.d.ts | 可选 | SDK 类型提示,来自 docs/plugin-sdk/echo-plugin.d.ts |
README.md | 可选 | 给自己或用户看的说明 |
.css / .txt / .json | 可选 | 静态资源或配置,导出包只支持根目录单文件 |
当前导入导出只处理插件根目录下的单文件,不递归子目录。可导出的扩展名是 .js、.mjs、.cjs、.html、.css、.json、.md、.txt。
编辑器类型提示
Section titled “编辑器类型提示”如果你用 VS Code 或支持 JS 类型检查的编辑器,可以把仓库的 SDK 类型复制到插件目录:
docs/plugin-sdk/echo-plugin.d.ts -> plugins/echo.my-plugin/echo-plugin.d.ts再放一个 jsconfig.json:
{ "compilerOptions": { "checkJs": true, "types": ["./echo-plugin"] }}这样 plugin.js 里访问 echo.playback.getStatus()、echo.metadata.registerProvider() 等 API 时会有提示。
Manifest 基础
Section titled “Manifest 基础”最小插件:
{ "id": "echo.my-plugin", "name": "我的插件", "version": "0.0.1", "apiVersion": 2, "entry": "plugin.js", "permissions": []}带面板、命令、provider 和插件设置的完整形态:
{ "id": "echo.metadata-helper", "name": "Metadata Helper", "version": "0.1.0", "apiVersion": 2, "minEchoVersion": "26.5.29", "entry": "plugin.js", "panel": "panel.html", "permissions": ["library:read", "network"], "contributes": { "commands": [ { "id": "lookup-current-track", "title": "查询当前曲目" } ], "metadataProviders": [ { "id": "tags", "title": "标签候选" } ], "lyricsProviders": [ { "id": "lyrics", "title": "歌词候选" } ], "coverProviders": [ { "id": "covers", "title": "封面候选" } ], "panels": [ { "id": "main", "title": "Metadata Helper", "path": "panel.html" } ], "settings": [ { "id": "provider-base-url", "title": "Provider URL", "type": "string", "defaultValue": "https://example.com/api" }, { "id": "enable-extra-lookup", "title": "Extra lookup", "type": "boolean", "defaultValue": false } ] }}字段说明:
| 字段 | 规则 |
|---|---|
id | 插件唯一 id,2 到 64 个字符,小写字母或数字开头,可含小写字母、数字、.、_、- |
name | 显示名称,最多约 80 字符 |
version | 插件版本字符串,最多约 40 字符 |
apiVersion | 当前支持 1 到 2,新插件推荐 2 |
minEchoVersion | 可选,仅作为兼容性展示和作者提示 |
entry | 入口脚本文件名,必须是插件根目录内 .js 文件,不能写子目录 |
panel | 可选面板文件名,必须是插件根目录内 .html 文件 |
permissions | 插件请求权限,用户启用时确认 |
contributes.commands | 插件命令声明,UI 可以展示 |
contributes.panels | 面板入口声明 |
contributes.metadataProviders | 元数据候选 provider |
contributes.sourceProviders | 自定义音源 provider |
contributes.lyricsProviders | 歌词候选 provider |
contributes.coverProviders | 封面候选 provider |
contributes.themePresets | 可导入的自定义主题预设 |
contributes.settings | 插件自己的设置表单 |
注意:manifest 里的贡献点用于展示和声明。真正可运行的命令/provider 仍然要在 plugin.js 里注册。
插件可以通过 contributes.themePresets 声明可导入的主题。主题贡献不需要权限,也不需要在 plugin.js 里注册逻辑;启用插件后,它会出现在“设置 > 外观”的插件主题区域。用户点击后,ECHO 会把它导入到“我的主题”,之后仍可继续微调、导出或删除。
主题插件只能提供结构化主题参数,不能注入任意 CSS。颜色只接受 #RRGGBB,数值会被宿主夹在安全范围内,preview 只接受纯色或 linear-gradient(...) 预览。每个主题至少要提供 light 或 dark 其中一组覆盖。
每个插件最多贡献 12 个主题。light / dark 可覆盖的颜色字段包括 appBg、appBg2、appBg3、panel、panelSoft、accent、accentStrong、secondary、heading、text、muted、border、onAccent、buttonText、titlebar、sidebar、player、field、row、rowHover、rowActive、chip、focus、danger、success、warning。
可覆盖的数值字段:panelOpacityPercent 40-100,glassPercent 0-80,shadowPercent 0-100,cornerRadiusPx 0-28,panelBlurPx 0-32,saturationPercent 60-140,motionEnabled 布尔值,motionSpeedSeconds 0.12-8,motionIntensityPercent 0-160。
{ "id": "echo.aurora-theme", "name": "Aurora Theme", "version": "0.1.0", "apiVersion": 2, "entry": "plugin.js", "permissions": [], "contributes": { "themePresets": [ { "id": "aurora-glass", "title": "Aurora Glass", "description": "高透明玻璃、冷色背景和暖色强调。", "basePreset": "classic", "preview": "linear-gradient(135deg, #08111f 0%, #183b56 48%, #f0b35b 100%)", "swatches": ["#08111f", "#183b56", "#f0b35b", "#e8f8ff"], "light": { "appBg": "#eef8ff", "panel": "#ffffff", "accent": "#257f96", "text": "#234150", "panelOpacityPercent": 78, "glassPercent": 26, "cornerRadiusPx": 10, "panelBlurPx": 18, "saturationPercent": 108 }, "dark": { "appBg": "#08111f", "panel": "#142234", "accent": "#5cc8dc", "text": "#c8dce8", "panelOpacityPercent": 72, "glassPercent": 34, "cornerRadiusPx": 10, "panelBlurPx": 22, "motionIntensityPercent": 90 } } ] }}API 版本选择
Section titled “API 版本选择”推荐直接使用 apiVersion: 2。
apiVersion: 1 的行为:
echo.settings.get()读取应用设置快照。echo.settings.set(patch)写应用设置 patch,需要settings:write,风险高。echo.net不可用。- 仍兼容早期示例插件。
apiVersion: 2 的行为:
echo.settings.get(key)/getAll()/set(...)只读写本插件自己的设置,不再写全局应用设置。echo.net.fetchJson()/fetchText()可用,但必须声明并被用户信任network权限。- 可以声明
lyricsProviders、coverProviders、settings。
除非你在维护旧插件,否则不要用 v1 写应用全局设置。新插件的配置应放在 contributes.settings 里。
插件默认禁用。启用时用户必须确认 manifest 里请求的所有权限。缺少信任权限时,API 会抛出 plugin_permission_denied:*。
写权限时把自己当成用户:如果一个插件说“我只是显示当前播放”,却申请了 network、settings:write、sources:provide,用户很难放心启用。权限不是能力清单越多越专业,而是越少越可信。
推荐写法是“用到什么,申请什么,并在 README 里解释为什么”:
权限说明:- playback:read:读取当前播放状态,用来显示正在播放的歌曲。- network:访问我配置的歌词 API,只在用户点击“查询歌词”时触发。不推荐写法:
"permissions": ["playback:read", "playback:control", "library:read", "settings:write", "network"]除非每个权限都有明确功能,否则这种写法会让用户和维护者都很难判断风险。
| 权限 | 状态 | 风险 | 说明 |
|---|---|---|---|
playback:read | 已开放 | 低 | 读取当前播放状态、曲目 id、进度、音频状态快照 |
playback:control | 已开放 | 中 | 播放、暂停、停止、跳转 |
library:read | 已开放 | 中 | 分页读取曲库摘要和公开曲目字段,也用于 metadata、lyrics、cover provider |
sources:provide | 已开放 | 中 | 注册自定义音源搜索和播放解析 |
settings:read | 已开放 | 中 | v1 读取应用设置;v2 插件设置不需要它 |
settings:write | 已开放 | 高 | v1 写应用设置 patch;新插件尽量不要申请 |
network | 已开放 | 高 | v2 通过宿主受控 API 访问 http / https |
fs:plugin | 受限 | 中 | 不开放任意文件 API,插件存储请用 echo.storage |
library:write | 预留 | 高 | 当前不提供实际曲库写入 API |
权限最小化建议:
- 只展示播放状态:只申请
playback:read。 - 控制播放:再加
playback:control。 - 做曲库统计、元数据、歌词、封面候选:申请
library:read。 - 做自定义音源:申请
sources:provide。 - 访问第三方 API:使用
apiVersion: 2并申请network。 - 不要为了“以后可能用”提前申请高风险权限。
权限改动后,要回到插件页刷新并重新确认启用。用户已经信任过的旧权限,不代表新权限会自动被信任。
plugin.js 运行环境
Section titled “plugin.js 运行环境”plugin.js 在 Node vm 沙箱中运行,但不是普通 Node 脚本。
可用全局对象:
echoconsole.log/console.warn/console.errorsetTimeoutclearTimeout
不可用:
requireimportprocesswindowdocument- Node 文件系统、网络、数据库、Electron 模块
入口脚本同步启动阶段最多运行约 1 秒。不要在文件顶层做重 CPU 工作。网络、曲库查询、批处理都应放进命令或 provider handler 里,并保持短小。
最小入口:
console.log('plugin loaded');
echo.commands.register('hello', { title: 'Hello' }, async () => { await echo.ui.notify('Hello from plugin'); return { ok: true };});公开 API 总览
Section titled “公开 API 总览”| API | 权限 | 用途 |
|---|---|---|
echo.events.on(eventName, handler) | 视事件而定 | 监听宿主事件 |
echo.commands.register(id, options, handler) | 无固定权限 | 注册可由宿主或面板触发的命令 |
echo.playback.getStatus() | playback:read | 获取播放状态 |
echo.playback.play/pause/stop/seek() | playback:control | 控制播放 |
echo.library.getSummary() | library:read | 获取曲库摘要 |
echo.library.getTracks(query) | library:read | 分页读取公开曲目字段 |
echo.metadata.registerProvider(...) | library:read | 返回元数据候选 |
echo.lyrics.registerProvider(...) | library:read | 返回歌词候选 |
echo.covers.registerProvider(...) | library:read | 返回封面候选 |
echo.sources.registerProvider(...) | sources:provide | 返回音源候选和播放 URL |
echo.settings.get/getAll/set | v2 为插件设置 | 读写插件自己的设置 |
echo.net.fetchJson/fetchText | network + v2 | 宿主受控网络请求 |
echo.storage.get/set | 无任意 FS | 读写插件自己的小型 JSON 存储 |
echo.ui.notify(message) | 无固定权限 | 写插件日志通知 |
当前开放事件:
| 事件 | 权限 | 频率与含义 |
|---|---|---|
playback:status | playback:read | 播放状态合并推送,约 500ms 节流,也就是最多约 2Hz |
library:changed | library:read | 曲库变化信号,payload 不保证长期稳定,只当刷新信号用 |
示例:
const unsubscribe = echo.events.on('playback:status', async (status) => { await echo.storage.set('lastStatus', { state: status.state, trackId: status.currentTrackId, positionSeconds: Math.round(status.positionSeconds || 0) });});
echo.commands.register('stop-listening', { title: '停止监听' }, () => { unsubscribe();});事件 handler 最多约 2 秒,超时会记录 plugin_event_handler_timeout。不要在 playback:status 里做网络请求、全库查询或大 JSON 写入。
命令适合用户手动触发的动作,例如“记录当前播放”“查询当前曲目”“导出一个小摘要”。
echo.commands.register('copy-now-playing', { title: '记录当前播放' }, async () => { const status = await echo.playback.getStatus(); await echo.storage.set('lastCommandResult', { trackId: status.currentTrackId, state: status.state, savedAt: new Date().toISOString() }); await echo.ui.notify('已记录当前播放状态。'); return { ok: true };});命令限制:
- 参数 JSON 最大约 64 KB。
- 返回 JSON 最大约 256 KB。
- 执行超时约 2 秒。
- 失败会写入插件日志。
如果任务超过 2 秒,应拆成多次手动命令,或只返回“已排队”的轻量结果。当前插件系统不适合做长驻后台任务。
播放状态与播放控制
Section titled “播放状态与播放控制”读取状态:
const status = await echo.playback.getStatus();console.log(status.state, status.currentTrackId, status.positionSeconds);控制播放:
await echo.playback.pause();await echo.playback.seek(60);await echo.playback.play();播放控制是中风险能力。插件不要自动根据高频事件连续 seek() 或 play/pause(),否则会破坏用户操作和播放稳定性。
曲库 API 永远要分页。
const page = await echo.library.getTracks({ page: 1, pageSize: 50, search: 'artist or title', sort: 'recent', sourceProvider: 'local', fields: ['id', 'title', 'artist', 'album', 'duration', 'coverThumb']});限制:
pageSize最大 100,默认 50。search最大约 120 字符。- 默认字段:
id、mediaType、path、title、artist、album、duration、coverThumb、unavailable。 - 可选字段以
docs/plugin-sdk/echo-plugin.d.ts和src/shared/types/plugins.ts为准。
分页批处理建议:
echo.commands.register('count-missing-album', { title: '统计缺少专辑的曲目' }, async () => { let page = 1; let missing = 0;
while (page <= 20) { const result = await echo.library.getTracks({ page, pageSize: 100, fields: ['id', 'title', 'album'] });
missing += result.items.filter((track) => !track.album).length; if (!result.hasMore) break; page += 1;
await new Promise((resolve) => setTimeout(resolve, 0)); }
await echo.ui.notify(`前 ${page} 页里有 ${missing} 首缺少专辑。`); return { missing, scannedPages: page };});不要一次拉完整曲库。大型曲库会跨进程传输大量 JSON,影响 UI 和播放响应。
元数据 Provider
Section titled “元数据 Provider”Metadata Provider 返回候选标签,不直接写曲库。宿主会裁剪字段、展示候选,并由用户决定是否采用。
Manifest:
{ "permissions": ["library:read"], "contributes": { "metadataProviders": [ { "id": "tags", "title": "标签候选" } ] }}plugin.js:
echo.metadata.registerProvider('tags', { title: '标签候选' }, async ({ track }) => { if (!track.title || !track.artist) { return { candidates: [] }; }
return { candidates: [ { title: track.title, artist: track.artist, album: track.album, genre: 'Alternative', year: 2026, confidence: 0.8, source: 'My Plugin', sourceUrl: 'https://example.com' } ] };});候选字段:
titleartistalbumalbumArtistgenreyeartrackNodiscNobpmconfidence,范围 0 到 1sourcesourceUrl
限制:
- 单插件最多 8 个 metadata provider。
- 单 provider 每次最多 5 个候选。
- 请求最大约 32 KB,返回最大约 64 KB。
- provider 超时约 2.5 秒。
- 不返回二进制封面,不写文件,不写 SQLite。
歌词 Provider
Section titled “歌词 Provider”歌词 Provider 返回歌词候选,宿主决定是否预览、应用或缓存。
Manifest:
{ "apiVersion": 2, "permissions": ["library:read"], "contributes": { "lyricsProviders": [ { "id": "lyrics", "title": "歌词候选" } ] }}plugin.js:
echo.lyrics.registerProvider('lyrics', { title: '歌词候选' }, async ({ track }) => { if (!track.title) { return { candidates: [] }; }
return { candidates: [ { title: track.title, language: 'zh', lrc: '[00:00.00]示例歌词', source: 'My Lyrics Provider', confidence: 0.7 } ] };});候选字段:
titlelanguagelrctextsourcesourceUrlconfidence
限制:
- 单插件最多 4 个 lyrics provider。
- 单 provider 每次最多 5 个候选。
lrc/text会被裁剪到约 80 KB。- 请求最大约 32 KB,返回最大约 128 KB。
- provider 超时约 2.5 秒。
封面 Provider
Section titled “封面 Provider”Cover Provider 返回图片 URL 候选。候选必须是 http / https 图片 URL,宿主负责后续缓存、裁剪、写库决策。
Manifest:
{ "apiVersion": 2, "permissions": ["library:read"], "contributes": { "coverProviders": [ { "id": "covers", "title": "封面候选" } ] }}plugin.js:
echo.covers.registerProvider('covers', { title: '封面候选' }, async ({ track }) => { if (!track.album && !track.title) { return { candidates: [] }; }
return { candidates: [ { imageUrl: 'https://example.com/cover.jpg', title: track.album || track.title, source: 'My Cover Provider', width: 1200, height: 1200, confidence: 0.75 } ] };});限制:
- 单插件最多 4 个 cover provider。
- 单 provider 每次最多 8 个候选。
imageUrl必须是http/https。- 请求最大约 32 KB,返回最大约 128 KB。
- provider 超时约 2.5 秒。
自定义音源 Provider
Section titled “自定义音源 Provider”Source Provider 用于“插件音源”。它只返回搜索候选,并在用户触发播放时解析成显式音频 URL。
它不是远程库同步 provider,也不能写入远程曲库、DSP、解码器或输出链路。Source Provider 也不是下载接口、破解接口或官方音源背书;插件作者必须确认返回的候选和播放 URL 合法可访问,相关法律责任由插件作者、使用者或服务提供方自行承担。
Manifest:
{ "apiVersion": 2, "permissions": ["sources:provide"], "contributes": { "sourceProviders": [ { "id": "direct-url", "title": "Direct URL Demo" } ] }}plugin.js:
const demoTracks = [ { providerTrackId: 'demo-stream', title: 'Demo stream', artist: 'Local plugin', album: 'Custom source', duration: null, playable: true, source: 'Direct URL Demo', url: 'https://example.com/audio/demo.mp3' }];
echo.sources.registerProvider('direct-url', { title: 'Direct URL Demo' }, { search: async ({ query }) => { const needle = String(query || '').toLowerCase(); return { tracks: demoTracks .filter((track) => !needle || `${track.title} ${track.artist}`.toLowerCase().includes(needle)) .map(({ url, ...track }) => track), total: demoTracks.length, hasMore: false }; }, resolvePlayback: async ({ providerTrackId }) => { const track = demoTracks.find((item) => item.providerTrackId === providerTrackId); if (!track) { throw new Error('plugin_source_track_not_found'); } return { url: track.url, mimeType: 'audio/mpeg', supportsRange: true }; }});搜索候选字段:
providerTrackId,必填title,必填artistalbumalbumArtistdurationcoverUrlwebUrlplayableunavailableReasonsource
播放解析字段:
url,必填,必须是http/httpsexpiresAtmimeTypebitratesampleRatebitDepthcodecheadersrequiresProxysupportsRange
限制:
- 单插件最多 4 个 source provider。
- 单 provider 每次最多 25 个搜索候选。
- 搜索请求最大约 32 KB,搜索返回最大约 128 KB。
- 播放解析请求最大约 16 KB,播放解析返回最大约 32 KB。
- provider 超时约 2.5 秒。
resolvePlayback只应在用户真的要播放时做必要解析,不要在search里预拉所有播放 URL。
v2 插件设置由 manifest 声明,宿主在插件详情页渲染表单,并保存到 plugin-settings.json。
支持类型:
stringselectbooleannumbersecret
示例:
{ "contributes": { "settings": [ { "id": "base-url", "title": "API URL", "description": "第三方 API 地址", "type": "string", "defaultValue": "https://example.com" }, { "id": "quality", "title": "Quality", "type": "select", "defaultValue": "high", "options": [ { "label": "High", "value": "high" }, { "label": "Low", "value": "low" } ] }, { "id": "enabled", "title": "Enabled", "type": "boolean", "defaultValue": false }, { "id": "limit", "title": "Limit", "type": "number", "defaultValue": 5, "min": 1, "max": 25 }, { "id": "api-key", "title": "API Key", "type": "secret" } ] }}读取设置:
const baseUrl = await echo.settings.get('base-url');const allSettings = await echo.settings.getAll();写入设置:
await echo.settings.set('enabled', true);await echo.settings.set({ limit: 10 });注意:
- v2 设置是插件自己的命名空间,不写应用全局 settings。
- 宿主会按 manifest 过滤和裁剪设置值。
secret只是 UI 上用密码框显示,当前不是系统凭据保险箱。不要保存高价值长期密钥。- 单个设置 patch 最大约 32 KB。
- 插件设置总量最大约 128 KB。
- 插件包导出不包含
plugin-settings.json。
网络访问只在 apiVersion: 2 生效,并且必须申请 network 权限。
Manifest:
{ "apiVersion": 2, "permissions": ["network"]}请求 JSON:
const data = await echo.net.fetchJson({ url: 'https://example.com/api/search?q=test', method: 'GET', headers: { accept: 'application/json' }, timeoutMs: 3000});请求文本:
const text = await echo.net.fetchText('https://example.com/lyrics.txt');限制:
- 只允许
http/httpsURL。 - 只允许
GET/POST。 - 请求 JSON 最大约 64 KB。
- 响应最大约 512 KB。
- 默认和最大超时约 5 秒。
- 允许的请求 header:
accept、accept-language、content-type、user-agent。 authorization、cookie、set-cookie、x-api-key、x-auth-token等敏感 header 会被过滤。- 非 2xx 响应会抛出
plugin_network_http_<status>。
网络 provider 编写建议:
- 把网络请求放到用户触发的命令或 provider handler 中。
- 对同一首歌的结果做插件 storage 缓存,但控制大小。
- 不要在
playback:status事件里请求网络。 - 不要用短间隔轮询。
- 对失败返回空候选,并写清楚日志。
echo.storage 用于保存插件自己的小型 JSON 数据。
await echo.storage.set('lastLookup', { title: 'Song', savedAt: new Date().toISOString()});
const lastLookup = await echo.storage.get('lastLookup');限制:
- key 最大约 96 字符。
- 单个 value 最大约 64 KB。
- 单插件 storage 总量最大约 256 KB。
- 存储文件是
plugin-storage.json。 - 插件包导出不包含 storage。
storage 适合保存缓存索引、上次操作状态、小型配置。不要保存整页曲库、图片二进制、歌词大集合或长日志。
面板 panel.html
Section titled “面板 panel.html”面板作为 sandbox iframe 运行。它不接触主应用 DOM,也不能直接访问 plugin.js 里的 echo 对象。
面板要和宿主交互,只能通过受控 postMessage bridge:
parent.postMessage({ channel: 'echo:plugin-panel', version: 1, type: 'request', requestId: 'request-1', pluginId: 'echo.my-plugin', action: 'plugin:getSummary'}, '*');响应:
window.addEventListener('message', (event) => { const message = event.data; if (message?.channel !== 'echo:plugin-panel' || message.type !== 'response') { return; } if (message.ok) { console.log(message.result); } else { console.error(message.error); }});当前 panel action:
| action | payload | 作用 |
|---|---|---|
plugin:getSummary | 无 | 返回当前插件摘要、权限、活动、安全信息 |
plugin:getLogs | 无 | 返回当前插件日志 |
plugin:runCommand | { "commandId": "...", "args": [] } | 执行当前插件命令 |
面板想做有权限的事,应在 plugin.js 里注册命令,再由面板触发 plugin:runCommand。不要假设面板可以直接读曲库或控制播放。
最小面板:
<!doctype html><meta charset="utf-8"><button id="refresh">刷新</button><pre id="output">等待中...</pre><script>const pluginId = 'echo.my-plugin';const channel = 'echo:plugin-panel';const pending = new Map();const output = document.getElementById('output');
window.addEventListener('message', (event) => { const message = event.data; if (!message || message.channel !== channel || message.type !== 'response') return; const resolve = pending.get(message.requestId); if (!resolve) return; pending.delete(message.requestId); resolve(message);});
function requestHost(action, payload) { return new Promise((resolve) => { const requestId = `${Date.now()}-${Math.random()}`; pending.set(requestId, resolve); parent.postMessage({ channel, version: 1, type: 'request', requestId, pluginId, action, payload }, '*'); });}
document.getElementById('refresh').addEventListener('click', async () => { output.textContent = JSON.stringify(await requestHost('plugin:getSummary'), null, 2);});</script>导入、导出与发布
Section titled “导入、导出与发布”插件页可以导出 .json 插件包。包结构:
{ "type": "echo-next-plugin-package", "version": 1, "exportedAt": "2026-05-29T00:00:00.000Z", "manifest": {}, "files": [ { "path": "plugin.js", "content": "..." } ]}导出规则:
- 包最大约 2 MB。
- 最多 32 个文件。
- 单文件最大约 512 KB。
- 只导出插件根目录文件,不递归子目录。
- 排除
plugin-state.json、plugin-storage.json、plugin-settings.json。 - 排除
.echo-plugin.json包文件,避免递归打包。
导入规则:
- 必须是
type: "echo-next-plugin-package"和version: 1。 - 目标插件 id 已存在时,普通 UI 导入会拒绝覆盖。
- 导入后默认禁用,需要用户确认权限再启用。
- 宿主记录来源、导入时间、包版本和 checksum。
发布前清单:
echo.plugin.json使用apiVersion: 2,除非维护旧插件。- 权限最小化。
- README 写清用途、权限原因、第三方服务边界。
- README 写清“安装到哪里、怎么启用、怎么重载、怎么卸载”。
- 不包含个人 token、cookie、运行缓存。
- 不依赖本机绝对路径。
- 不使用高频轮询。
- 大数据都分页。
- 错误路径有清晰日志。
- 在播放音乐时试一次插件主流程,确认没有明显卡顿。
- 导出包后用另一个空插件目录导入一次,确认没有漏文件。
发布包里不要承诺 ECHO 没开放的能力。比如“直接改源音频文件”“自动写曲库”“注入播放器 UI”“接管 DSP 链路”都不是普通插件能力。
插件页会显示:
- manifest 解析错误。
- 启用状态。
- 权限风险。
- 面板 sandbox 状态。
- 命令/provider 数量。
- 活动摘要,例如命令次数、事件次数、网络次数、storage 写入次数、错误次数。
- 插件日志。
console.log / console.warn / console.error 会进入插件日志:
console.log('lookup started');console.warn('provider returned no result');console.error('lookup failed', error.message);常用排查顺序:
- manifest 是否能被插件页识别。
- 插件是否已启用,权限是否全部确认。
plugin.js顶层是否抛错。- 命令是否注册,id 是否一致。
- provider 是否申请了正确权限。
- 返回 JSON 是否超出大小限制。
- 网络是否缺少
network权限或被 header 限制挡住。 - 面板
pluginId、channel、requestId是否正确。
排错时别一次改很多地方。先把 plugin.js 改成只输出一行日志,再确认启用;再注册一个只返回 { ok: true } 的命令;最后才把真实逻辑加回来。这样最快,也最不容易把一个小 typo 误判成系统问题。
连续启动失败保护:
- 10 分钟内连续 3 次启动失败,宿主会自动禁用插件。
- 日志里会出现
plugin_disabled_after_repeated_errors。 - 修复文件后,可以手动重新启用。
性能与播放安全
Section titled “性能与播放安全”ECHO 是播放器,插件必须默认把播放体验放在第一位。
必须遵守:
- 不在顶层做重 CPU 工作。
- 不在
playback:status里做网络请求、全库查询或大写入。 - 不高频调用
seek()、play()、pause()。 - 曲库读取永远分页。
- Provider handler 保持 2.5 秒内完成。
- 网络超时设置短一点,失败返回空候选。
- 大任务拆成手动命令,不要自启动后台扫库。
- storage 只保存小型 JSON。
- source provider 的
search只返回候选,resolvePlayback只在播放时解析。 - 对第三方 API 失败、限流、空结果保持安静,不弹出连续噪声。
推荐模式:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function scanSomePages(maxPages) { for (let page = 1; page <= maxPages; page += 1) { const result = await echo.library.getTracks({ page, pageSize: 100 }); // do small work if (!result.hasMore) break; await sleep(0); }}不推荐模式:
// 不要这样:事件太高频,还叠加曲库和网络。echo.events.on('playback:status', async () => { const tracks = await echo.library.getTracks({ pageSize: 100 }); await echo.net.fetchJson('https://example.com/update'); await echo.storage.set('huge', tracks);});| 错误码 | 含义与处理 |
|---|---|
plugin_permission_confirmation_required | 启用时没有确认全部请求权限 |
plugin_permission_denied:* | 调用了未被信任的能力 |
plugin_manifest_invalid | manifest 解析失败 |
apiVersion must be between 1 and 2 | API 版本不兼容当前宿主 |
plugin_not_enabled | 插件未启用或已被宿主禁用 |
plugin_command_not_found | 命令未注册或 id 写错 |
plugin_command_timeout | 命令超过约 2 秒 |
plugin_command_args_too_large | 命令参数超过约 64 KB |
plugin_command_result_too_large | 命令返回超过约 256 KB |
plugin_event_not_supported:* | 监听了未开放事件 |
plugin_event_handler_limit | 同插件事件 handler 太多 |
plugin_event_handler_timeout | 异步事件 handler 超过约 2 秒 |
plugin_metadata_provider_invalid | metadata provider 注册参数不合法 |
plugin_metadata_provider_limit | metadata provider 超过 8 个 |
plugin_metadata_provider_timeout | metadata provider 超过约 2.5 秒 |
plugin_metadata_request_too_large | metadata 请求超过约 32 KB |
plugin_metadata_result_too_large | metadata 返回超过约 64 KB |
plugin_lyrics_provider_invalid | lyrics provider 注册参数不合法 |
plugin_lyrics_provider_limit | lyrics provider 超过 4 个 |
plugin_lyrics_provider_timeout | lyrics provider 超过约 2.5 秒 |
plugin_cover_provider_invalid | cover provider 注册参数不合法 |
plugin_cover_provider_limit | cover provider 超过 4 个 |
plugin_cover_provider_timeout | cover provider 超过约 2.5 秒 |
plugin_source_provider_invalid | source provider 注册参数不合法 |
plugin_source_provider_limit | source provider 超过 4 个 |
plugin_source_provider_timeout | source provider 超过约 2.5 秒 |
plugin_source_provider_not_playable | source provider 没有 resolvePlayback |
plugin_source_playback_url_invalid | 播放 URL 不是合法 http / https |
plugin_source_search_request_too_large | source 搜索请求超过约 32 KB |
plugin_source_search_result_too_large | source 搜索返回超过约 128 KB |
plugin_source_playback_request_too_large | source 播放解析请求超过约 16 KB |
plugin_source_playback_result_too_large | source 播放解析返回超过约 32 KB |
plugin_storage_value_too_large | 单个 storage value 超过约 64 KB |
plugin_storage_quota_exceeded | 插件 storage 总量超过约 256 KB |
plugin_settings_patch_too_large | 设置 patch 超过约 32 KB |
plugin_setting_value_too_large | 插件设置单次写入过大 |
plugin_settings_quota_exceeded | 插件设置总量超过约 128 KB |
plugin_network_requires_api_v2 | v1 插件调用了网络 API |
plugin_network_url_invalid | 网络 URL 不合法 |
plugin_network_method_not_allowed | 网络方法不是 GET / POST |
plugin_network_request_too_large | 网络请求超过约 64 KB |
plugin_network_response_too_large | 网络响应超过约 512 KB |
plugin_network_http_<status> | 第三方服务返回非 2xx |
plugin_package_invalid | 导入文件不是 ECHO 插件包 |
plugin_package_too_large | 插件包超过约 2 MB |
plugin_package_file_limit_exceeded | 插件包文件超过 32 个 |
plugin_package_file_too_large | 单个包文件超过约 512 KB |
plugin_import_target_exists | 目标插件 id 已存在,普通导入拒绝覆盖 |
plugin_disabled_after_repeated_errors | 插件连续启动失败,被宿主自动隔离 |
完整示例:网络元数据候选插件
Section titled “完整示例:网络元数据候选插件”echo.plugin.json:
{ "id": "echo.demo-metadata", "name": "Demo Metadata", "version": "0.1.0", "apiVersion": 2, "entry": "plugin.js", "panel": "panel.html", "permissions": ["library:read", "network"], "contributes": { "commands": [ { "id": "test-lookup", "title": "测试查询" } ], "metadataProviders": [ { "id": "tags", "title": "Demo 标签候选" } ], "settings": [ { "id": "base-url", "title": "API URL", "type": "string", "defaultValue": "https://example.com" } ], "panels": [ { "id": "main", "title": "Demo Metadata", "path": "panel.html" } ] }}plugin.js:
async function lookup(track) { const baseUrl = await echo.settings.get('base-url'); if (!baseUrl || !track.title) { return []; }
try { const url = `${String(baseUrl).replace(/\/$/, '')}/search?title=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist || '')}`; const data = await echo.net.fetchJson({ url, headers: { accept: 'application/json' }, timeoutMs: 3000 });
if (!Array.isArray(data?.items)) { return []; }
return data.items.slice(0, 3).map((item) => ({ title: item.title || track.title, artist: item.artist || track.artist, album: item.album, genre: item.genre, year: Number(item.year) || undefined, confidence: Math.max(0, Math.min(1, Number(item.confidence) || 0.5)), source: 'Demo Metadata', sourceUrl: item.url })); } catch (error) { console.warn('lookup failed', error.message); return []; }}
echo.metadata.registerProvider('tags', { title: 'Demo 标签候选' }, async ({ track }) => ({ candidates: await lookup(track)}));
echo.commands.register('test-lookup', { title: '测试查询' }, async () => { const page = await echo.library.getTracks({ page: 1, pageSize: 1, sort: 'recent', fields: ['id', 'title', 'artist', 'album'] });
const track = page.items[0]; if (!track) { await echo.ui.notify('曲库为空。'); return { candidates: [] }; }
const candidates = await lookup(track); await echo.ui.notify(`找到 ${candidates.length} 个候选。`); return { track, candidates };});panel.html:
<!doctype html><meta charset="utf-8"><style> body { font: 14px system-ui; margin: 16px; color: #1f2937; } button { padding: 6px 10px; } pre { white-space: pre-wrap; border: 1px solid #d1d5db; padding: 12px; }</style><button id="run">测试查询</button><pre id="output">等待操作...</pre><script>const pluginId = 'echo.demo-metadata';const channel = 'echo:plugin-panel';const pending = new Map();const output = document.getElementById('output');
window.addEventListener('message', (event) => { const message = event.data; if (!message || message.channel !== channel || message.type !== 'response') return; const resolve = pending.get(message.requestId); if (!resolve) return; pending.delete(message.requestId); resolve(message);});
function requestHost(action, payload) { return new Promise((resolve) => { const requestId = `${Date.now()}-${Math.random()}`; pending.set(requestId, resolve); parent.postMessage({ channel, version: 1, type: 'request', requestId, pluginId, action, payload }, '*'); });}
document.getElementById('run').addEventListener('click', async () => { const response = await requestHost('plugin:runCommand', { commandId: 'test-lookup' }); output.textContent = JSON.stringify(response, null, 2);});</script>作者检查清单
Section titled “作者检查清单”写插件前:
- 明确插件是命令、provider、面板,还是三者组合。
- 列出必须权限,删掉“可能用得上”的权限。
- 判断是否需要
network。如果需要,使用apiVersion: 2。 - 判断是否真的需要面板。简单工具优先做命令。
写插件时:
- 顶层只注册 handler,不做重工作。
- 所有曲库操作分页。
- 所有网络请求有短超时。
- 所有 provider 返回候选,不直接写库。
- 所有错误都能返回空结果或清晰日志。
- 不把 token、cookie、用户缓存打进发布包。
发布前:
- 新装导入后默认禁用是正常行为。
- 启用权限说明能让用户看懂。
- 插件连续启动失败不会让主程序坏掉。
- 导出包里没有
plugin-storage.json、plugin-settings.json、plugin-state.json。 - 在播放音乐时试一次插件主流程,确认没有明显卡顿。
主要契约位置:
src/shared/types/plugins.tsdocs/plugin-sdk/echo-plugin.d.tssrc/main/plugins/PluginManifest.tssrc/main/plugins/PluginService.tssrc/main/ipc/pluginIpc.tssrc/renderer/pages/PluginsPage.tsx
如果文档和代码不一致,以这些源码文件为准。