写一个云取插件
插件是一段 JavaScript:不用编译、不用打包 APK、不用发版 —— 写一个 .js 文件装进应用,它就能认链接、拼请求头、算签名。这一页给想动手的人:能力边界在哪、文档在哪、最小示例长什么样、怎么签名发布。
三条边界
能算什么
四类事:认链接(解析器)、改提交前的请求(pre-hook)、任务结束后收尾(post-hook)、看引擎事件(进度 / 完成 / 失败)。
在这些里可以发 HTTP 请求、算摘要与 HMAC、编解码 base64/hex、读写自己的小存储、打日志、用定时器。
不能做什么
不经手下载数据流:没有流式 API、没有 body 句柄、没有分块回调;host.http.request 给的是一份完整的(有上限的)响应体,downloadToFile 给的是一个已经写完的文件。
也不能注册下载后端(返回 unsupported),不能在运行时给自己加权限 —— 实际授予 = 脚本声明 ∩ 加载器上限 ∩ 用户已确认,没有任何 API 能改变它。
为什么这么定
分片与字节面留在 Kotlin 里,脚本就永远不会站在数据路径上。否则一个写得不好的插件(或者干脆是恶意的)会变成整条下载链路的带宽瓶颈 —— 而且每个数据块都要跨一次 JS 边界,代价比下载本身还大。
所以插件的定位是「算 URL / 拼头 / 签名 / 解析分享链接」,不是「自己实现一个下载器」。
这条边界的理由:脚本若站在数据路径上,一个写得不好的插件就是整条链路的瓶颈
所以等级只影响提示强度,不影响校验 —— 四级都要验签名
四份文档
四类扩展点
清单里填的 category 决定插件能挂到哪里。四类之间不通用:解析器改不了请求头,钩子也认不了链接。
分类来自清单字段,不是客户端写死的
最小示例插件
这个插件认 demo:// 链接,把它变成一条可下载的 https 请求。整段复制到一个 .js 文件里就能用 —— 和《写一个插件》里的那段是同一份代码。
/*
* 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 时间)。为什么这么写,逐条解释在文档里。
签名与发布的最短路径
签名的对象是脚本文件的原始字节:不剥 BOM、不做行尾归一化、不重新编码、不是「先算 sha256 再签摘要」—— sha256 与 signature 覆盖的是同一串字节,所以两条检查互相印证。工具在源仓库的 tools/sign_plugin.py 里,Ed25519 零依赖可用。
# 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 访问的静态目录,里面放索引、公钥和脚本就行。
两个仓库
YunGet-Plugins 是插件源:索引、插件源码、公钥、签名工具都在那里,插件的问题(含发现可疑插件)提到它的 Issue。
TurboDL 是下载引擎(纯 Kotlin/JVM),插件框架与 ABI 由它提供 —— 所以引擎的改进可以单独升级,也可以被别的应用复用。ABI 的完整清单在本站就有:插件 ABI 参考。
块数约为连接数的四倍:慢连接不会独占最后剩下的那点工作
同一版本但字节变了会被索引直接拒绝 —— 已发布版本的字节不可改写