结论先放这 :FluxDown 的插件是纯 JavaScript ,跑在嵌入引擎的 QuickJS 里。写一个插件只需要两个文件------manifest.json 声明"我管哪些网址",resolve.js 导出一个函数把网址改写成真直链。不需要 npm、不需要构建、不需要重新编译宿主,改完 .js 存盘就生效。
这篇把完整的心智模型、可直接抄的代码、以及沙箱的全部边界一次讲清。
一、插件到底解决什么问题
下载器的默认行为很朴素:给它一个 URL,它就 GET 那个 URL 存下来。
问题是用户手里的 URL 往往不是文件本身,而是一个页面:
- 一个 GitHub 文件页
github.com/owner/repo/releases/download/...(能下,但走国际线路很慢) - 一个网盘的分享页(HTML,里面才有真直链,而且直链会过期)
- 一个视频播放页(真正的媒体是页面里的某个 m3u8)
下载器不可能内置全世界所有站点的解析规则。所以留了一个口子:在"任务真正发起下载"之前插一段 JS,让它把 URL 改写掉。
这就是 resolver。整个插件系统就两个平面:
| 平面 | 干什么 | 失败了会怎样 |
|---|---|---|
| resolver | 下载前改写 URL | fail-closed :插件抛错/超时/返回非法 → 任务直接失败,绝不偷偷用原始 URL 下 |
| hooks | 任务开始/完成/出错时通知你 | fire-and-forget:抛错只记日志,绝不影响任务 |
第一行的设计取舍值得单独说一下:为什么 resolver 失败不回退到原始链接?因为那样用户会得到一个 5 KB 的 HTML 文件,还以为下载成功了。失败要响亮,比静默给错东西强。(真需要放行时,任务详情面板上有一个「忽略插件重试」按钮作为逃生舱。)
二、最小可用插件
建一个目录,两个文件。
manifest.json
json
{
"identity": "me@hello-plugin",
"name": "Hello Plugin",
"version": "1.0.0",
"minAppVersion": "0.1.60",
"resolvers": [
{
"match": { "urls": ["*://echo.example/*"] },
"entry": "resolve.js",
"timeoutMs": 8000
}
]
}
resolve.js
js
globalThis.resolve = async (ctx) => {
flux.logger.info('[hello] 收到任务', ctx.url);
// 返回 null / undefined = 放行,按原始 URL 下载
return { url: 'https://example.com/real-file.bin' };
};
完事。identity 是永久 ID,格式固定 ^[a-z0-9_-]+@[a-z0-9_-]+$(不能有点号,因为它要当配置键的一段用)。
装上去
设置 → 扩展 → 插件,右上角确认「开发模式」开关是开的(默认就开着)。开发模式打开后,安装区会多出一个目录选择框,占位文字「选择插件目录」,tooltip 是「从目录安装(开发模式)」。选中你的目录,点「安装」。
这里有个细节值得知道:开发模式安装不拷贝任何文件 ,它只是把你的目录绝对路径写进配置键 plugin.dev.<identity>。所以------
调试循环
-
改
resolve.js/hooks.js→ 存盘即生效。因为 dev 模式下每次调用都重新读文件:rustmatch (&self.resolver_entry, self.dev) { (Some(p), true) => tokio::fs::read_to_string(p).await.ok(), // dev:每次调用重读 (Some(_), false) => self.resolver_cache.clone(), // 非 dev:加载时缓存 (None, _) => None, } -
改
manifest.json→ 必须重载:在插件卡片上把开关关掉再打开就行。
日志用 flux.logger.info/warn/error 或者 console.log,都会写进 App 日志文件;任务详情面板的「日志」标签页也能看到执行痕迹。
三、resolve(ctx) 的完整契约
传进来的 ctx
字段全是 camelCase:
| 字段 | 说明 |
|---|---|
taskId |
任务 ID |
url |
原始任务 URL(永远是用户输入的那个,不是上一次改写的结果) |
cookies |
任务携带的 Cookie 串(浏览器扩展接管的任务会带上) |
referrer / userAgent |
同上 |
extraHeaders |
附加请求头,object |
resolverItem |
二段解析标识。初段恒为空字符串;非空说明这是多文件清单里某个条目的二次解析 |
返回什么
返回 null / undefined = 放行不改写。返回对象时的字段:
| 字段 | 说明 |
|---|---|
url |
改写后的直链。scheme 必须 ∈ {http, https, ftp, magnet, ed2k},长度 ≤ 8 KB |
audioUrl |
可选音频直链(DASH 音视频分离场景) |
fileName |
可选文件名。禁止 /、\、.. 与控制字符 |
totalBytes |
已知大小 |
extraHeaders |
下载时要带的额外请求头 |
ephemeral |
true = 这是一次性签名直链,跳过 probe (probe 会作废它)。代价是失去 If-Range 一致性校验 |
rangeSupported |
true = 我担保这个服务支持 Range。与 ephemeral 正交:跳过 probe 的同时仍然按已验证 Range 规划多段并发 |
variants |
画质/格式变体数组,非空时宿主弹选择框让用户选(≤ 50 个) |
defaultVariantIndex |
超时/免打扰/headless 时的默认变体 |
manifest |
多文件清单(网盘文件夹)。与 url / variants / audioUrl 互斥 |
ephemeral 和 rangeSupported 这一对是我觉得最值得抄的设计。很多签名直链(比如 googlevideo)探测一次就作废 ,但它明明支持 Range。如果只有 ephemeral 一个开关,跳过 probe 的同时也就丢掉了 Range 信息,只能保守地单流下载。拆成两个正交字段之后,插件可以说:"别 probe,但你放心开多线程。"
惰性:解析结果不落库
这是另一个关键设计:任务表只存 resolver_plugin_id,从不存解析出来的直链。
于是每次 start / resume 都会重跑一次 resolve()。听起来浪费,但这是唯一正确的做法------网盘直链几小时就过期,如果把直链存进数据库,用户第二天点"继续"必然 404。
界面上对应的提示是任务组详情里那句「惰性续期:每次启动自动重新解析」。
四、宿主给你的 API
QuickJS 里只有两个全局对象:flux 和 console。没有 require、没有 fs、没有 setTimeout、没有 DOM ,也不是 ES Module(用不了 import/export,入口必须挂 globalThis)。
| API | 用途 | 限额 |
|---|---|---|
flux.fetch(opts) |
HTTP 请求 | 仅 http/https;响应体 8 MB 截断;单请求 10 s;全局并发 8;重定向 ≤ 30 跳 |
flux.storage.get/set |
跨调用持久化 | 单值 ≤ 64 KB,单插件 ≤ 100 键 |
flux.fs.writeFile/readFile/remove/list |
插件工作区文件 | 单文件 8 MB,工作区总量 64 MB,最多 100 个文件,扁平目录 |
flux.settings.<key> |
读设置项(类型化,number 就是 number) | --- |
flux.info |
{identity, version, appVersion} |
--- |
flux.logger.* / console.* |
写日志 | 单条 4 KB 截断 |
flux.task.requestRetry({delayMs}) |
请求重试 | 仅 onError 有效 |
flux.task.recordArtifact(name) |
登记衍生产物(删任务时连带删) | 仅 onDone 有效 |
flux.ffmpeg / flux.ffprobe |
转码/探针 | 需 permissions: ["ffmpeg"],仅 onDone |
flux.ytdlp |
调 yt-dlp | 需 permissions: ["ytdlp"],resolve + 全部 hook 可用 |
没授权的能力不是报错,而是根本不存在 ------flux.ffmpeg 会是 undefined。所以要写 if (flux.ffmpeg) { ... }。
沙箱边界(我觉得这部分比 API 本身有意思)
flux.fetch有 SSRF 守卫 ,而且是三处校验:URL 字面量、DNS 解析结果、每一跳重定向 。判定函数只放行"全局可路由的单播地址"------8.8.8.8放行,127.0.0.1和169.254.169.254拒绝。- ffmpeg 参数被封死 :拒绝任何 URL scheme(
http://、file:、concat:、crypto:)、绝对路径、盘符、..。结论是 ffmpeg 在这个沙箱里没有网络出口,也够不到工作区之外的路径。 - yt-dlp 反过来 :放行 URL(联网是它的本职),但拉黑 13 个危险开关------
--exec、--downloader、--config-location、--plugin-dirs、--cookies-from-browser、--batch-file等,并且强制前置注入--ignore-config。
两个外部二进制、两套完全不对称的策略,因为它们的威胁模型不一样:ffmpeg 只需要处理本地产物,yt-dlp 天然要联网。权限设计不能一刀切。
五、声明式设置表单
不用写任何 UI 代码。在 manifest 里声明字段,桌面端和 Web 端各自自动生成表单:
json
"settings": [
{
"key": "preferred",
"title": "加速源",
"description": "自动 = 每次下载前并行测速,选最快且可用的源",
"type": "string",
"widget": "select",
"options": [
{ "value": "auto", "label": "自动(测速优选)" },
{ "value": "direct", "label": "直连(禁用加速)" }
],
"default": "auto"
},
{
"key": "verbose",
"title": "详细日志",
"type": "boolean",
"widget": "toggle",
"default": "false"
}
]
脚本里直接 flux.settings.preferred / flux.settings.verbose 读,类型是对的(boolean 就是 boolean,不用自己 parse)。
type × widget 是一张闭合矩阵,越界直接拒绝安装,不做静默降级:
| widget | 允许的 type |
|---|---|
text / password / textarea / folder / select |
string |
toggle |
boolean |
number |
number |
几个容易踩的点:
default一律是字符串 。number 写"3",boolean 写"true"/"false"。pattern用的是 JS RegExp 语法,不是 Rust regex(这是一处刻意的偏离,因为写插件的人心智模型是 JS)。- 顶层 manifest 是
deny_unknown_fields的:字段名写错不会被忽略,整份 manifest 校验失败。这是好事,省得你调半天发现是拼错了。 - 还有一个
helperScript字段:可以附一段"到目标站点 Console 里跑一下拿到你的 token"的脚本,宿主会在字段旁渲染一个复制按钮。宿主只复制,绝不执行。
六、一个真实例子:GitHub 下载加速
前面都是玩具,来看一个真跑在市场里的插件的 manifest(fluxdown@github-accel):
json
{
"identity": "fluxdown@github-accel",
"name": "GitHub 加速",
"version": "1.0.0",
"minAppVersion": "0.1.60",
"resolvers": [
{
"match": {
"urls": [
"*://github.com/*",
"*://raw.githubusercontent.com/*",
"*://gist.githubusercontent.com/*"
]
},
"entry": "resolve.js",
"timeoutMs": 25000
}
]
}
它的 resolve.js 干的事:对本次要下的这个文件,在若干加速源上并行发 Range 采样请求(64 KB) ------一次探测同时验证「可用性」和「速度」,选最快的那个改写直链;全部不可用就返回 null 回退直连 。探测到 206 就顺手回填 rangeSupported: true,让引擎直接开多线程。
思路值得抄的地方:加速失败绝不能阻断下载。这类插件的正确姿势是"锦上添花",而不是"我挂了你也别想下"。
match.urls 的通配规则很简单:唯一通配符是 * ,没有正则、没有 **。多个插件同时命中同一个 URL 时,identity 字典序最小的胜出(一个刻意选择的、可预测的确定性规则,而不是加载顺序)。
七、跑挂了会怎样
写插件一定会写出死循环和内存泄漏,所以宿主有三重预算:
| 平面 | 超时 | 内存 |
|---|---|---|
| resolve | 10 s(manifest 里 timeoutMs 可下调,但 30 s 硬顶) |
64 MB |
| hook(普通) | 5 s | 32 MB |
| hook(授权 ffmpeg/ytdlp) | 1830 s 墙钟(CPU 中断预算仍 30 s) | 32 MB |
注意最后一行的墙钟与 CPU 预算解耦 :hook 里 await 等 ffmpeg 子进程时不烧 CPU、不触发中断,所以可以给很长的墙钟;但纯 JS 死循环仍然 30 秒内被掐。
再上面还有熔断:连续 3 次超时或内存超限 → 插件被自动禁用,弹 toast「插件「XX」已因连续失败被自动禁用,可在「设置 → 扩展 → 插件」中重新启用」,卡片上挂「已自动禁用」徽章(和手动关闭的「已禁用」区分开)。升级安装会自动解熔断。
还有几条会让你 debug 到怀疑人生的事实,提前说:
- 每次调用都是全新的 QuickJS 上下文 。模块级
let cache = {}永远是空的,跨调用要存东西只能flux.storage。 - 不是 ES Module 。
import/export用不了,入口必须是globalThis.resolve = ...(顶层function resolve(ctx){}声明也行,它天然是全局函数)。 - 移动端没有插件系统 。
plugins是个 Cargo feature,desktop / server 开启,Android / iOS 关闭。 - 同时声明 resolver 又订阅
onMetaProbed是个死订阅------带 resolver 的任务会跳过元数据探测,这个钩子对它自己的任务永远不触发。宿主加载时会给你记一条 warn。
八、分发
打包就是把插件目录打成 zip,扩展名改成 .fxplug (.zip 也认)。manifest.json 必须在 zip 根,或者在唯一一层包裹目录里(会自动剥壳)。上限 50 MB / 200 个条目,解压做了双重 zip-slip 防护。
市场那边是零后端 设计:索引就是一个 Git 仓库里的 index.json,客户端拉下来、按 sequence 做防回滚高水位校验,下载插件包时用 content_hash(sha256)钉住内容、多镜像 failover。
这里必须诚实说一句 :市场 v1 没有作者签名 。sigScheme 是个预留字段,现在恒为 "none",代码里没有任何验签逻辑。信任基座只有三条:内容寻址(sha256 对得上)、TLS、以及 Git 历史本身的 Merkle 链。签名在路线图上,但今天还不能说"已签名"。
九、常见问题
Q:github 下载加速插件的原理是什么? A:在下载真正发起之前,把 github.com/... 这个 URL 改写成某个加速源上的等价地址。关键是改写前要先验证 :对多个候选源并行发一个 64 KB 的 Range 采样请求,一次探测同时拿到「可用性」和「速度」,选最快的;全部不可用就返回 null 走 GitHub 直连。加速失败绝不能阻断下载。
Q:写插件需要会 Rust 吗? A:不需要。插件是纯 JavaScript,跑在引擎内嵌的 QuickJS 里。宿主只注入 flux 和 console 两个全局对象,没有 Node 的 require/fs,也不是 ES Module。
Q:改了插件代码要重启软件吗? A:改 .js 不用,存盘即生效 (开发模式下每次调用重新读文件)。改 manifest.json 需要在插件卡片上把开关关掉再打开。
Q:插件写崩了会不会把下载器搞挂? A:不会。插件跑在独立的线程池上,有内存上限、CPU 中断预算和墙钟超时三重约束;连续 3 次超时或内存超限会被自动禁用并弹提示。resolver 失败只会让该任务失败,任务详情面板上有「忽略插件重试」按钮可以跳过插件重下。
Q:手机端能用插件吗? A:不能。plugins 是一个 Cargo feature,桌面端和服务端开启,Android / iOS 构建关闭。
十、参考来源
- 插件系统实现:
native/engine/src/plugin/(manifest.rs校验器、quickjs.rs运行时、bridge.rs全部限额与沙箱边界、manager.rs生命周期与熔断) - 宿主注入的全部 JS 全局对象:
native/engine/src/plugin/quickjs.rs的FLUX_PRELUDE - 可直接跑的官方示例:
examples/plugins/echo-rewriter/、examples/plugins/manifest-playground/ - 真实上架插件:
fluxdown@github-accel(GitHub 加速)、fluxdown@ytdlp
十一、小结
- 插件 =
manifest.json(声明管哪些 URL)+ 一个导出globalThis.resolve的.js。 - 开发模式安装本地目录,改 JS 存盘即生效;改 manifest 关开一次开关。
- resolver fail-closed ,hook fire-and-forget------两个平面的失败语义刻意相反。
- 解析结果不落库,每次启动重解析,为的是应对会过期的直链。
- 沙箱:SSRF 三处校验、ffmpeg 全封网、yt-dlp 放网但拉黑 13 个开关、三重预算 + 熔断。
- 官方示例插件(可直接跑):仓库
examples/plugins/echo-rewriter/(resolver + 3 个钩子 + 7 种设置控件)与examples/plugins/manifest-playground/(多文件清单) - 插件开发文档:fluxdown.zerx.dev
- 仓库:github.com/zerx-lab/Fl...
想给哪个站点写解析器?评论区说,我可以帮看 URL 匹配规则怎么写。