云取 YunGet

写一个云取插件

插件是一段 JavaScript:不用编译、不用打包 APK、不用发版 —— 写一个 .js 文件装进应用,它就能认链接、拼请求头、算签名。这一页给想动手的人:能力边界在哪、文档在哪、最小示例长什么样、怎么签名发布。

三条边界

能算什么

四类事:认链接(解析器)、改提交前的请求(pre-hook)、任务结束后收尾(post-hook)、看引擎事件(进度 / 完成 / 失败)。

在这些里可以发 HTTP 请求、算摘要与 HMAC、编解码 base64/hex、读写自己的小存储、打日志、用定时器。

不能做什么

不经手下载数据流:没有流式 API、没有 body 句柄、没有分块回调;host.http.request 给的是一份完整的(有上限的)响应体,downloadToFile 给的是一个已经写完的文件。

也不能注册下载后端(返回 unsupported),不能在运行时给自己加权限 —— 实际授予 = 脚本声明 ∩ 加载器上限 ∩ 用户已确认,没有任何 API 能改变它。

为什么这么定

分片与字节面留在 Kotlin 里,脚本就永远不会站在数据路径上。否则一个写得不好的插件(或者干脆是恶意的)会变成整条下载链路的带宽瓶颈 —— 而且每个数据块都要跨一次 JS 边界,代价比下载本身还大。

所以插件的定位是「算 URL / 拼头 / 签名 / 解析分享链接」,不是「自己实现一个下载器」。

声明了 http 就意味着它能上网、能把你的链接发出去。用户装之前会看到这份权限清单 —— 所以只声明真正用到的能力。
这套 ABI 的边界脚本侧
能http 请求 · 摘要与 HMAC · base64/hex · 自己的小存储 · 日志 · 定时器
能注册解析器 / pre-hook / post-hook,并订阅引擎事件
不能接触下载数据流(无流式 API、无 body 句柄、无分块回调)
不能注册下载后端 —— 返回 unsupported
不能运行时给自己加权限:实际授予 = 声明 ∩ 加载器上限 ∩ 用户确认
沙箱每个脚本一个 QuickJS runtime 与 context

这条边界的理由:脚本若站在数据路径上,一个写得不好的插件就是整条链路的瓶颈

签名能证明什么以及不能证明什么
能证明脚本字节与某个密钥持有者发布的一致(sha256 + 签名)
能证明内容被改过一个字节,校验就失败并拒绝安装
不能证明作者是好人、脚本没有恶意行为、没有 bug
不能证明声明的权限是「必要的」而不是「能拿就拿」
覆盖范围只覆盖脚本原始字节,不覆盖它对网络的后续行为

所以等级只影响提示强度,不影响校验 —— 四级都要验签名

四份文档

写一个插件

插件是一段 JavaScript:能做什么、不能做什么,以及从零写一个能跑的解析器。

docs/PLUGIN-DEV.md

插件 ABI 参考

脚本能用的每一样东西:plugin / host 两个对象、能力、错误码与所有上限。

docs/PLUGIN-API.md

打包与发布

清单怎么写、签名怎么算、怎么提 PR 进官方源、怎么自建一个源、怎么发新版本。

docs/PLUGIN-PUBLISH.md

插件安全模型

信任四级、校验链,以及这份机制不做(也不打算假装做了)的那些承诺。

docs/PLUGIN-SECURITY.md

四类扩展点

清单里填的 category 决定插件能挂到哪里。四类之间不通用:解析器改不了请求头,钩子也认不了链接。

挂载点四类
turbodl-parser认链接 → 一条或多条下载请求
turbodl-hook提交前改写请求 / 任务结束后收尾
turbodl-backend不支持 —— 字节面留在 Kotlin 一侧
turbodl-loader加载别的语言写的插件(JS 加载器属于这一类)

分类来自清单字段,不是客户端写死的

最小示例插件

这个插件认 demo:// 链接,把它变成一条可下载的 https 请求。整段复制到一个 .js 文件里就能用 —— 和《写一个插件》里的那段是同一份代码。

parser.demo.js —— 从 docs/PLUGIN-DEV.md 读取
/*
 * parser.demo - 最小可用示例:demo://<path>[?name=<文件名>]
 *   demo://share/hello.bin           -> https://example.com/share/hello.bin
 *   demo://share/hello.bin?name=a.txt -> 同一个 URL,保存为 a.txt
 * 其它链接返回 null(不是我的,交给下一个解析器)。
 */

// 1) 声明要用到的能力。没声明的能力在运行时不可用,而且"声明了但宿主没授予"会让插件加载失败。
//    这里只声明真正用到的:crypto 用来算 stableKey。加一条网络查询就再补 'http'。
plugin.requires({ permissions: ['crypto'], abiMajor: 1 });

// 2) 自报身份。id 会被云取用来定位插件,version 是市场更新判据,name 是界面上显示的名字。
plugin.defineMeta({ id: 'parser.demo', name: 'Demo 解析器', version: '1.0.0' });

var SCHEME = 'demo://';
var BASE = 'https://example.com';

/** 链接里的文件名是不可信输入:只留一个裸文件名,丢掉路径与危险字符。 */
function safeName(raw) {
  var last = String(raw || '').split('/').pop().split('\\').pop();
  var cleaned = '';
  for (var i = 0; i < last.length; i++) {
    var c = last.charAt(i);
    var code = last.charCodeAt(i);
    if (code < 32 || code === 127) continue;
    if ('<>:"|?*/\\'.indexOf(c) >= 0) continue;
    cleaned += c;
  }
  cleaned = cleaned.replace(/^[.\s]+/, '').replace(/[.\s]+$/, '').replace(/\.\./g, '_');
  return cleaned.slice(0, 80);   // 80 个字符,不是字节:中文名也能留下
}

plugin.registerParser({
  priority: 300,
  parse: function (input) {
    // 宿主传进来的是一个对象:{ input: '<原始链接>' }
    var raw = String((input && input.input !== undefined) ? input.input : input).trim();
    if (raw.slice(0, SCHEME.length).toLowerCase() !== SCHEME) return null;  // 不是我的链接

    var rest = raw.slice(SCHEME.length);
    var query = '';
    var q = rest.indexOf('?');
    if (q >= 0) { query = rest.slice(q + 1); rest = rest.slice(0, q); }

    var path = rest.replace(/^\/+/, '');
    if (path === '') return null;

    var wanted = '';
    var pairs = query.split('&');
    for (var i = 0; i < pairs.length; i++) {
      var eq = pairs[i].indexOf('=');
      if (eq > 0 && pairs[i].slice(0, eq) === 'name') {
        wanted = pairs[i].slice(eq + 1).replace(/\+/g, ' ');
        try { wanted = decodeURIComponent(wanted); } catch (e) { /* 编码坏了就用原文 */ }
      }
    }

    var url = BASE + '/' + path;
    return [{
      url: url,
      fileName: safeName(wanted) || safeName(path) || 'demo.bin',
      stableKey: 'demo-' + host.crypto.digest('SHA-256', url).hex.slice(0, 16),
      connections: 8,
      tags: { scheme: 'demo', plugin: 'parser.demo' }
    }];
  }
});

plugin.onInit(function () {
  host.log.info('demo 解析器就绪,id=' + plugin.id);
});

这份代码里的每一点都是必要的:权限声明要保持扁平(读它的是一个小文本扫描器,嵌套对象会让权限预览看不到)、文件名是不可信输入要洗过、自报的 id 只允许 [a-z0-9.-] 且长度 3..64、单次求值预算 5 秒(只计 JS 时间)。为什么这么写,逐条解释在文档里。

想看更完整的写法(含可选的服务端直链解析与失败回退),读官方源仓库里的 parser.example-cloud —— 那份是市场模板,也是本站插件页列出的那一个。

签名与发布的最短路径

签名的对象是脚本文件的原始字节:不剥 BOM、不做行尾归一化、不重新编码、不是「先算 sha256 再签摘要」—— sha256 与 signature 覆盖的是同一串字节,所以两条检查互相印证。工具在源仓库的 tools/sign_plugin.py 里,Ed25519 零依赖可用。

shell
# 1) 生成密钥对(私钥 0600 落盘、绝不进仓库;公钥可以直接进仓库)
python tools/sign_plugin.py keygen --key-id my-id-2026 --out-dir keys

# 2) 签一个脚本,输出能直接粘进 plugins.json 的 JSON 片段
python tools/sign_plugin.py sign plugins/parser.mycloud/parser.mycloud.js \
    --key keys/my-id-2026.key --key-id my-id-2026

# 3) 扫 plugins/*/ 重新生成整个索引(含真实 sha256 与签名,避免手抄出错)
python tools/sign_plugin.py index --key keys/my-id-2026.key --key-id my-id-2026

发布到官方源就是往 YunGet-Plugins 提一个 PR:建目录 plugins/<插件 id>/,放齐脚本、turbodl-plugin.json、market.json,说明它做什么、要哪些权限、为什么需要这些权限。不想进官方源也可以自建一个源:一个能通过 https 访问的静态目录,里面放索引、公钥和脚本就行。

改了脚本却不升版本号,用户永远收不到更新 —— App 的更新判据是 semver 比大小,不看时间戳;而且 index 会因为「同版本但字节变了」直接报错(已发布版本的字节不可改写)。

两个仓库

YunGet-Plugins 是插件源:索引、插件源码、公钥、签名工具都在那里,插件的问题(含发现可疑插件)提到它的 Issue。

TurboDL 是下载引擎(纯 Kotlin/JVM),插件框架与 ABI 由它提供 —— 所以引擎的改进可以单独升级,也可以被别的应用复用。ABI 的完整清单在本站就有:插件 ABI 参考。

分片视图16 片 · 9 片已完成

块数约为连接数的四倍:慢连接不会独占最后剩下的那点工作

从脚本到用户手里这条链上每一步都能核对
写一个 .js 文件 + turbodl-plugin.json + market.json
签名签名工具在 YunGet-Plugins 仓库里
发布往官方源仓库提 PR(也可以自建源)
更新改脚本必须同时升版本号,否则用户收不到更新
安装App 里加源 → 看权限 → 确认 → 下载 → 启用

同一版本但字节变了会被索引直接拒绝 —— 已发布版本的字节不可改写