给下载器写插件:一个 manifest.json + 30 行 JavaScript,做一个 GitHub 下载加速解析器

结论先放这 :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 模式下每次调用都重新读文件:

    rust 复制代码
    match (&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 互斥

ephemeralrangeSupported 这一对是我觉得最值得抄的设计。很多签名直链(比如 googlevideo探测一次就作废 ,但它明明支持 Range。如果只有 ephemeral 一个开关,跳过 probe 的同时也就丢掉了 Range 信息,只能保守地单流下载。拆成两个正交字段之后,插件可以说:"别 probe,但你放心开多线程。"

惰性:解析结果不落库

这是另一个关键设计:任务表只存 resolver_plugin_id,从不存解析出来的直链。

于是每次 start / resume 都会重跑一次 resolve()。听起来浪费,但这是唯一正确的做法------网盘直链几小时就过期,如果把直链存进数据库,用户第二天点"继续"必然 404。

界面上对应的提示是任务组详情里那句「惰性续期:每次启动自动重新解析」。

四、宿主给你的 API

QuickJS 里只有两个全局对象:fluxconsole没有 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.1169.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 到怀疑人生的事实,提前说:

  1. 每次调用都是全新的 QuickJS 上下文 。模块级 let cache = {} 永远是空的,跨调用要存东西只能 flux.storage
  2. 不是 ES Moduleimport/export 用不了,入口必须是 globalThis.resolve = ...(顶层 function resolve(ctx){} 声明也行,它天然是全局函数)。
  3. 移动端没有插件系统plugins 是个 Cargo feature,desktop / server 开启,Android / iOS 关闭。
  4. 同时声明 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 里。宿主只注入 fluxconsole 两个全局对象,没有 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.rsFLUX_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 匹配规则怎么写。

相关推荐
传奇开心果编程3 小时前
【Xilem 0.4 基础语法学与练】第一课:从零到计数器
学习·rust·前端框架
Flynt10 小时前
pnpm 12 换上了 Rust 内核,我拿项目实测了一轮构建速度
rust·vite·前端工程化
传奇开心果编程14 小时前
【Xilem基础语法学与练】第8课:条件渲染(one_of)
学习·rust·前端框架
qwsaedca16 小时前
在Mac上跑 Kokoro TTS经验总结
rust·mac·tts
qwsaedca16 小时前
Kokoro TTS v1.1 voices 文件格式逆向分析
rust
老猿讲编程18 小时前
【Eclipse OpenSOVD学习之五】拓扑引擎(Topology)
学习·rust·eclipse·sovd
object not found18 小时前
Nuxt4去掉body中默认的边距
开发语言·后端·rust
梦醒沉醉1 天前
4、Rust参考手册——Crate和源文件
rust
chainbees1 天前
Windows 系统 Rust 运行环境搭建
rust