AI 插件生成前置提示词
经真机实测反复打磨的插件生成前置提示词:复制给任意大模型,按宇宙核沙箱规范直接生成可用的插件
使用方法:点上方按钮复制全文,粘贴给任意大模型(ChatGPT / Claude / Gemini / GLM…), 并在文末【本次需求】处写清你想要的插件功能,AI 即可按宇宙核沙箱规范生成可直接安装使用的插件。
这份提示词由真机实测反复打磨:包含运行架构、生命周期、存储边界、权限、 window.toolbox 全量 API、返回值形状、UI/UX 硬要求、安全红线与自检清单—— 覆盖本地数据、联网、提醒、传感器、分享、图片、文件格式、网址类等全部插件类型的已知坑。
你是资深移动端前端工程师,为「宇宙核」App(隐私优先的本地工具箱)生成一个单文件 HTML 插件。
下面的规则全部来自真机实测,请严格遵守;不确定的 API 不要发明。要做什么见文末【本次需求】。
一、运行架构(决定你能用什么)
- 产物 =
manifest.json+index.html两个文件,CSS 与 JS 全部内联进 index.html - 运行在 App 内置的 Android WebView 里,页面由 App 自己的本地服务器伺服
(形如 http://localhost:<随机端口>/index.html)
- 没有 Node、没有后端、没有原生 API;手机离线可用(联网须申请 network 权限)
- 上限(宿主硬限制,写插件时要心里有数):插件包 ≤500 个文件、解压总量 ≤100MB、单个文件 ≤200MB;
插件自己的目录总量 ≤200MB(写满后 file.download 会报"空间已达配额上限");
storage 整文件 ≤10MB;单次写入/导出 ≤50MB(见下)
- 页面里不能直接
fetch外网(安全策略 connect-src 'self'):联网只能走
toolbox.network.fetch,且只允许 https
⛔ 文件能力的边界(做"编辑器/转换器"类插件前必须读懂,否则整个方案是错的)- toolbox.file.*(read / write / readBinary / writeBinary / list / delete…)
只能读写插件自己的目录。插件碰不到用户的其他文件(相册、Download、音乐目录…),
这是安全边界,不是 bug,也不要想办法绕。
- 单次写入/导出上限 50MB(宿主硬上限,超了返回
{error:'文件内容过大…'})。
做"大文件"插件必须先把这条讲给用户听,并且在导出前先自己判一次大小。
- 文本和二进制是两套 API,别混:
file.write/file.read走的是 UTF-8 文本
(写进去的每个字符按 UTF-8 落盘、读的时候按 UTF-8 解)——拿它写二进制必然损坏字节。
二进制一律 file.writeBinary(名字, base64) / file.readBinary(名字)。
- 因此:
- 要拿到用户的文件(主路径) → 在 manifest 里声明
fileHandlers(写清扩展名/MIME,例如 mp3/flac/ogg),
并监听宿主交文件的事件:
window.onExternalFileLoaded(content, fileName, filePath) 或
window.addEventListener('YuzhouHe:fileLoaded', …)(detail 里有 content/fileName/filePath)。
用户把文件"分享/用宇宙核打开"时宿主就把内容交给插件。注意 content 是文本(二进制按 latin1 传),
需要字节时自己 charCodeAt 还原。
- 页面内选文件(正式入口,2026-09-20 真机已验证) → 页面里放
<input type="file" accept=".mp3,.flac">+FileReader
也能让用户直接选文件:App 的 WebView 会弹系统文件选择器,选中后页面能读出内容。
与上一条的分工:fileHandlers 负责"从别处分享进来",input 负责"用户在插件页面里点按钮选"。
- 要把结果交给用户 → 只能用
toolbox.file.exportFile(名字, base64)导出(会弹系统分享面板,
用户自己保存/替换)。不能覆盖用户的原文件。
- 要拿到用户分享进来的文字/链接 → 除了用
toolbox.intake.shared()取,**manifest 里必须声明
"handlesSharedText": true** —— 不声明的话宿主根本不会把分享内容交给这个插件(用户分享过来只会
看到"没有能处理它的插件")。想按域名/关键词分流,可选写 "sharedTextRoutes": [{ "hostSuffix": "book.qq.com" }]
(可含 hostSuffix / pathPrefix / keyword / keywords / ext / mime / needsUrl;
这些规则默认不生效,用户在「内容路由」页勾选后才参与分发)。
- 不要设计"让用户先把文件放进插件目录"的流程 —— 宿主没有这个入口,用户做不到。
⛔ 不确定就不要声称支持(做"读写某种文件格式"的插件前必须做这个决定)
你(大模型)通常没法运行代码验证。所以对每一种你要碰的文件格式,先问自己一句:
"我能说清它的结构吗?"——
- 说得清(例如纯文本、CSV、JSON、你自己定义的格式)→ 放心做。
- 说不清(例如 MP3/FLAC/OGG/PDF/ZIP 这类二进制容器)→ 只做你确实说得清的那部分:
例如只支持 MP3 就只在 manifest、界面文案、说明里写 MP3,别顺带写上 flac/ogg。
- 宁可少支持一种格式,也不要"声称支持、实际打不开或改坏"。用户的原文件只有一份。
配套的三条硬性要求(改文件内容的插件都适用):
1. 写回前先证明能 100% 原样重建:把"必须原样带走的数据"列出来(音频流、图片数据、
其它元数据块、你不认识的字段……),逐项确认你的代码把它们原样、按原顺序接回去了。
做不到 → 只提供"导出副本",绝不做"覆盖原文件"。
注意分清两类东西:必须原样保留的(音频流、图片、检索表、应用块、你不认识的字段)与
可以重建的(填充/对齐块、你自己要替换的那一两个块)。自检时只比"必须原样"的那些 ——
否则你会因为"填充块数量变了"这种事误报(我实测踩过)。
2. 不认识的东西也要抄回去:读的时候把不认识的字段/帧/块原样存下来,写的时候原样接回去。
只写"你认识的那几个字段"= 静默删掉用户的数据(实测:导出后音轨号/流派/自定义标签全没了)。
但有些结构"原样抄回去"是做不到的(例:ID3 的非同步编码、扩展头、v2.2 的三字节帧 ID)——
这种就明确拒绝并说明原因,不要硬改。
3. 导出后自己核对三个数(写进你的实现说明里):
① 输出字节数 vs 原文件字节数;② 原样带走的那部分数据的字节数是否一致;③ 用户改的字段是否真的变了。
自检必须逐项覆盖,缺一项就等于没有自检 —— 实测事故:有人写了自检,但 FLAC 那支只比"保留的元数据块"、
MP3 那支只比"音频+几个标签",于是音频整个没写进去、5 个标签帧被删掉都能"自检通过"。
必须覆盖的四类:① 载荷(音频流/像素/原始数据)逐字节比;② 你不认识的字段/帧/块——数量与字节都要在;
③ 兜底判据:输出比输入小一大截(超过 5%)直接判失败("音频忘了拼回去"这类错一定会露出来);
④ 用户改的字段真的生效。
自检不通过时不要导出;但也要记住:自检只覆盖你写进去的那些检查项 ——
一个漏项的"全 ✓"比没有自检更危险,因为它会让你和用户都以为没问题。
二、生命周期(最大的坑)
window.toolbox 是页面加载完成之后才注入的 —— 启动时直接调用必然拿到 undefined。
下面这段骨架是"契约"不是"参考":整段原样保留(等桥、TB()、桥调用流水、
诊断按钮及其绑定、三个启动入口一个都不能少),你只往里加业务逻辑。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>插件名</title>
<style>
:root{--bg:#0f1115;--card:#171a21;--fg:#e8eaed;--fg2:#9aa4b2;--accent:#4c8dff;--ok:#33c37d;--bad:#ef5350;--line:#2a2f3a}
@media (prefers-color-scheme: light){:root{--bg:#f4f6fa;--card:#fff;--fg:#1b1f27;--fg2:#5a6371;--line:#dde2ea;--accent:#2f6fe4}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
padding:max(12px,env(safe-area-inset-top)) 12px calc(20px + env(safe-area-inset-bottom));overflow-x:hidden}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:12px;margin-bottom:10px}
button{font:inherit;font-weight:600;background:var(--accent);color:#fff;border:0;border-radius:10px;padding:10px 14px}
</style>
</head>
<body>
<div class="card">
<h2 id="title">插件名</h2>
<button id="btn-do">做点什么</button>
<button id="btn-diag">诊断</button>
<div id="tip" style="color:var(--fg2);font-size:13px;margin-top:8px"></div>
</div>
<textarea id="diagBox" readonly style="display:none;width:100%;height:140px"></textarea>
<script>
// ============ 1) 存储兼容模板:必须原样保留,所有读取都要经过它们 ============
function unwrapResp(r){ return (r && typeof r === 'object' && !Array.isArray(r) && ('value' in r)) ? r.value : r; }
function parseStored(raw){
if (raw===null||raw===undefined||raw==='') return null;
if (typeof raw==='object'){
if (raw && !Array.isArray(raw) && ('value' in raw)) raw = raw.value;
if (raw===null||raw===undefined||raw==='') return null;
if (typeof raw!=='object') return parseStored(raw);
return raw;
}
try { return JSON.parse(raw); } catch(e){ return null; }
}
// ============ 2) 取桥:每次用的时候现取,绝不能提前存起来(见下方铁律)============
function TB(){ return window.toolbox || null; }
function show(text, kind){ var el=document.getElementById('tip'); el.textContent=text;
el.style.color = kind==='ok' ? 'var(--ok)' : (kind==='bad' ? 'var(--bad)' : 'var(--fg2)'); }
// ============ 3) 桥调用流水(诊断按钮要用:记入参 + 宿主原始返回)============
var T0 = Date.now(), LOG = [];
var STATUS = {}; // 每项功能的最终状态:通过 / 失败 / 没测到
function mark(name, state){ STATUS[name] = state; }
function note(s){ LOG.push('+' + (Date.now()-T0) + 'ms ' + s); if (LOG.length > 60) LOG.shift(); }
window.onerror = function(m, src, line, col){ note('页面报错:' + m + ' @' + line + ':' + col); return false; };
window.addEventListener('unhandledrejection', function(e){ note('未处理的 Promise 拒绝:' + (e && e.reason)); });
function wrapBridge(tb){ // 把用到的桥方法包一层,自动记流水
if (!tb || tb.__wrapped) return;
try { tb.__wrapped = true; } catch(e){ return; }
// 这里列的是"本插件用到的全部桥方法"——你用到的别的方法也要加进来,
// 否则第九节要求的"每个功能最后一次调用"就会缺项(加进来只是多几行日志,没别的影响)
[['storage','get'],['storage','set'],['storage','remove'],['storage','keys'],
['clipboard','copy'],['clipboard','read'],['ui','confirm'],['ui','alert'],['ui','prompt'],
['device','vibrate'],['file','exportFile'],['share','text'],['notify','schedule'],
['notify','cancel'],['notify','list'],['network','fetch']].forEach(function(p){
var parent = tb[p[0]]; if (!parent || typeof parent[p[1]] !== 'function') return;
var orig = parent[p[1]];
parent[p[1]] = function(){
var args = [].slice.call(arguments);
note('→ ' + p[0] + '.' + p[1] + '(' + JSON.stringify(args).slice(0, 120) + ')');
return Promise.resolve(orig.apply(parent, args)).then(function(r){
// 注意:r 可能是 undefined(自己写的转发函数常见),JSON.stringify(undefined) 是 undefined,
// 直接 .slice() 会抛 TypeError —— 日志函数自己绝不能成为报错源
note('← ' + JSON.stringify(r === undefined ? '(无返回值)' : r).slice(0, 200)); return r;
}, function(e){ note('✗ ' + p[0] + '.' + p[1] + ' 异常:' + (e && e.message)); throw e; });
};
});
}
// ============ 4) 诊断:把所有上下文拼成纯文本,三层降级复制 ============
function copyDiagnostics(){
var tb = TB();
var statusLines = Object.keys(STATUS).map(function(k){ return ' ' + k + ':' + STATUS[k]; });
var text = [
'=== 诊断(发给开发者即可)===',
'地址:' + location.href,
'UA:' + navigator.userAgent,
'视口:' + window.innerWidth + '×' + window.innerHeight + ' dpr ' + window.devicePixelRatio,
'window.toolbox:' + (tb ? '有' : '无') + ' | __yzhToolboxReady=' + String(window.__yzhToolboxReady),
'--- 功能状态(没列出来的就是"没测到")---'
].concat(statusLines.length ? statusLines : [' (还没用过任何功能)'])
.concat(['--- 最近的调用与报错 ---'], LOG.slice(-30)).join('\n');
function fallback(why){ // 第三层:放进已全选的文本框,长按复制
var b = document.getElementById('diagBox');
b.value = text; b.style.display = 'block';
try { b.focus(); b.select(); b.setSelectionRange(0, text.length); } catch(e){}
show('自动复制不可用(' + why + '),内容已全选,长按复制', 'bad');
}
if (tb && tb.clipboard && tb.clipboard.copy) { // 第一层:宿主剪贴板
return tb.clipboard.copy(text).then(function(r){
if (r && r.error) return fallback(r.error);
show('诊断信息已复制,发给开发者即可', 'ok');
}, function(e){ fallback(e && e.message); });
}
if (navigator.clipboard && navigator.clipboard.writeText) { // 第二层:浏览器剪贴板
return navigator.clipboard.writeText(text).then(function(){ show('诊断信息已复制', 'ok'); },
function(e){ fallback(e && e.message); });
}
fallback('本环境没有可用的剪贴板');
}
// ============ 5) 桥就绪:等信号 + 轮询兜底 + 幂等 ============
function whenToolbox(timeoutMs){
return new Promise(function(resolve){
if (window.toolbox) return resolve(window.toolbox);
var done = false;
function ok(){ if(done) return; done = true; resolve(window.toolbox || null); }
try { window.addEventListener('yzhToolboxReady', ok, {once:true}); } catch(e){}
var waited = 0, step = 100;
var timer = setInterval(function(){
waited += step;
if (window.toolbox || waited >= (timeoutMs||3000)) { clearInterval(timer); ok(); }
}, step);
});
}
// ============ 6) 绑定按钮:绑定期不许取 tb,点击时才取 ============
document.getElementById('btn-do').onclick = function(){
var tb = TB();
if (!tb || !tb.storage) return show('宿主还没就绪,请稍后再点', 'bad');
// 数据一律 JSON 字符串写入、经模板读取
tb.storage.get('demo').then(function(raw){
var v = parseStored(unwrapResp(raw));
var rec = v || { count: 0 };
rec.count++;
return tb.storage.set('demo', JSON.stringify(rec)).then(function(r){
if (r && r.error) return show('保存失败:' + r.error, 'bad');
show('已保存,第 ' + rec.count + ' 次', 'ok');
});
// 桥没就绪/预览环境时调用会 reject,必须接住(只判 {error} 不够)
}).catch(function(e){ show('调用失败:' + (e && e.message), 'bad'); });
};
document.getElementById('btn-diag').onclick = function(){ copyDiagnostics(); };
// ============ 7) 启动(三种触发都接上;loadAll 必须幂等)============
var started = false;
function loadAll(){
if (started) return; // 幂等:boot 与事件可能重复触发
started = true;
wrapBridge(TB()); // 桥已就绪,挂上调用流水(诊断用)
// 启动后要刷新的界面写在这里
}
function boot(){
whenToolbox(3000).then(function(tb){
if (!tb || !tb.storage) return show('请在宇宙核 App 内使用', 'bad');
loadAll();
});
}
window.addEventListener('yzhToolboxReady', loadAll); // 权威就绪信号
window.addEventListener('toolboxReady', loadAll); // 旧宿主兼容别名
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
</script>
</body>
</html>
⛔ 铁律:绑定按钮时不许把 window.toolbox 存进变量
页面脚本执行时桥还不存在,绑定期取到的是 undefined,之后永远是 undefined ——
按钮点了没反应,或报 Cannot read properties of undefined (reading 'file')。
骨架里的 TB() + 点击时才取,就是正确写法;照抄即可。唯一的反面例子:
// ❌ 两种都错:绑定期取 tb,以及"取不到就 return"导致按钮根本没绑上(死按钮)
function bindUI(){ var tb = window.toolbox; if (!tb) return; btn.onclick = function(){ tb.file.exportFile(…); }; }
唯一能在启动时取 tb 的地方是 whenToolbox(...).then(...) 的回调里。
三、数据存储(唯一持久化通道)
骨架的第 1、6 节已经写好了正确用法,只需记住:
- 写:
toolbox.storage.set(key, JSON.stringify(obj))(传 JSON 字符串);
读:parseStored(unwrapResp(await toolbox.storage.get(key)))
- 宿主已经整文件加密,插件不要自己加密
- 宿主的两个历史行为,不处理必然出 bug:
① 不存在的键返回 {value:null} 包装;② 写入的合法 JSON 读回可能是对象也可能是字符串
- 禁止:
localStorage(宿主内只在当次会话有效,退出即丢)、用 file API 存结构化数据、
读写 storage.json / encrypted_storage.json(宿主保留名)、明文存密码令牌
四、权限
- 在 manifest.json 里声明,如
"permissions": ["storage","file","notification"] - 权限名只能用这 16 个(写别的名字不会报错,但等于白写):
storage / file / network / clipboard_read / clipboard_write / vibrate /
image_pick / image_save / share / sensors / ui_dialog / security /
notification / intake / ai / workflow
- 实际怎么生效:新装插件一律默认关闭全部权限,宿主不看 manifest 声明,而是**第一次用到某能力时
弹原生确认框**,用户同意后才永久授权。所以声明写得对不对不影响能不能跑,
但仍要按实际用量如实、稳定地声明(宿主用"声明集合有没有变"判断插件更新是否需要重新授权)。
- ⚠️
ai和workflow没有封装:window.toolbox.ai/window.toolbox.workflow在页面上
不存在。不要设计"用 AI 生成""触发工作流"这类功能,改用下面清单里的 API。
- 失败约定(两层,都要处理):
① 业务失败用返回值表达:{error:'...'},不会 reject —— 必须检查并把原因显示给用户;
② 但桥本身没就绪时(例如在浏览器里预览、或宿主异常),调用会在 5 秒后 reject
(flutter_inappwebview timeout)。所以每个调用都要 .catch(...)(或 try/catch),
只判 {error} 是不够的 —— 否则预览环境里满屏"未处理的 Promise 拒绝"。
五、可用的桥(window.toolbox 完整清单;表里没有的一律不要发明)
| 命名空间 | 方法 |
|---|---|
toolbox.storage | get / set / remove / clear / keys |
toolbox.storage.encrypted | set / get / remove / clear / keys(设备级密钥加密,密码/令牌这类敏感内容用它) |
toolbox.file | write / read / delete / list / exists / download / writeBinary / readBinary / exportFile / share |
toolbox.ui | toast / alert / confirm / prompt / loading.show / loading.hide |
toolbox.clipboard | copy / read |
toolbox.device | info / vibrate / shake / motion / compass |
toolbox.image | pick / save |
toolbox.notify | schedule / cancel / list / pickRingtone |
toolbox.share | text / url |
toolbox.network | fetch(仅 https,60 次/分钟) |
toolbox.intake | shared(取用户从别的应用分享进来的文字) |
toolbox.system | openExternal(用系统浏览器打开链接) |
toolbox.security | deriveKey(安全中心解锁后派生的插件专用密钥) |
六、返回值形状(真机逐条实测,照抄即可)
最容易踩的坑:以为所有读取都返回 {value:...}。其实只有 storage.get / storage.encrypted.get 是。clipboard.read 返回 {text:...}、file.read 返回 {content:...}、file.readBinary 返回 {data:...}、storage.keys / notify.list 返回 {keys:[...]}、intake.shared 的 text 可能整个键都不存在 ——
拿 {value} 或 {data} 去接会永远得到 undefined,表现是"这个功能没反应"。
| 调用 | 成功时返回 | 失败时 |
|---|---|---|
storage.get(k) | {value: <对象/字符串/null>};不存在的键 = {value:null} | {error, locked?}(备份恢复中是 {locked:true}) |
storage.set/remove/clear | {success:true} | {error} |
storage.keys() | {keys:[...]} | {keys:[],error,locked} |
storage.encrypted.get(k) | {value: <值>}(与普通 storage 同形状) | {error}(未解锁安全中心时也可能失败) |
storage.encrypted.keys() | {keys:[...]} | {error} |
ui.toast(m) | {success:true} | — |
ui.alert(title, message) | {success:true}(两个参数) | {error} |
ui.confirm(title) | {confirmed:true/false}(用户的选择在这里) | — |
ui.prompt(title, defaultValue) | {value:<输入>, cancelled:bool} | — |
clipboard.copy(t) | {success:true} | {error} |
clipboard.read() | {text:<剪贴板文本>}(不是 value!) | {error:'剪贴板读取过于频繁…'}(限 20 次/分钟) |
share.text(t) / share.url(url,title) | {success:true} | {error} |
device.vibrate(ms) | {success:true} | {error} |
device.info() | {platform, version} | — |
device.compass(cb) / shake(cb) / motion(cb) | 返回 {stop()}(回调里收传感器数据);不用时调 stop() | {error} |
image.pick(src) | {success:true,name,path,data(base64),mimeType};取消 = {success:false,error:'No image selected'} | {error} |
image.save(data, filename) | {success:true} | {success:false,error,needPermission?,permissionType?} |
file.write(name, text) | {success:true} | {error}(超 50MB / 保留名 / 备份恢复中) |
file.writeBinary(name, base64) | {success:true} | {error}(超 50MB) |
file.read(name) | {content:<文本>}(不是 {value},也不是 {data}) | {content:null, error:'File not found'} |
file.readBinary(name) | {data:<base64>} | {error:'File not found'} |
file.list(dir?) | {files:[{name,size,modified}]}(dir 只能省略或写 'exports') | {error:'Unsupported directory'} |
file.exists(name) | {exists:true/false} | {exists:false, error} |
file.delete(name, dir?) | {success:true} | {error} |
file.download(url, filename) | {success:true, filename, path} | {error}(只允许 https;插件目录有配额上限) |
file.exportFile(name, base64) | {success:true, path:'…/exports/xxx'} | {error}(含体积/文件名校验失败) |
file.share(name) | {success:true, path} | {error:'文件不存在(可能已被删除)'} |
notify.schedule({at?,dailyAt?,key?,title?,body?,repeat?,repeatAfterMinutes?}) | {success,key,notifyId,count,next,strength?} | {error} |
notify.list() | {keys:[...]} | {error} |
notify.cancel(k) | {success:true} | {error} |
notify.pickRingtone() | {uri,title};用户取消 = {cancelled:true} | {error} |
network.fetch(o) | {success:true,statusCode,statusMessage,headers,body};o.asBytes:true 时额外给 bodyBase64 + charset | {error}(http 明文/私网/自签证书一律 {error}) |
intake.shared() | {success:true, text?} —— 取一次即清,没有分享内容时连 text 键都没有 | {success:false, error} |
security.deriveKey() | {key:<密钥>};安全中心没解锁 = {key:null} | {error} |
取用范式(不要假设形状,逐个判空):
var t = await toolbox.clipboard.read();
var text = (t && typeof t.text === 'string') ? t.text : ''; // ✅ 按 text 取
var fr = await toolbox.file.read('a.txt');
var body = (fr && typeof fr.content === 'string') ? fr.content : ''; // ✅ 按 content 取(不是 value)
var ks = await toolbox.storage.keys();
var arr = Array.isArray(ks) ? ks : ((ks && ks.keys) || []); // ✅ 按 keys 取
var cf = await toolbox.ui.confirm('确定删除?');
if (!(cf && cf.confirmed)) return; // ✅ 按 confirmed 判断
var sh = await toolbox.intake.shared();
var shared = (sh && sh.text) ? sh.text : ''; // ✅ text 可能不存在
七、UI/UX 硬要求
- 移动优先;深浅色双主题(骨架里的 CSS 变量 +
prefers-color-scheme已给好) - 顶部/底部留安全区(骨架里的
env(safe-area-inset-*)已给好);窄屏不许横向滚动 - 所有写入有可见反馈("已保存 ✓" / 失败原因);删除、清空等破坏性操作先用
ui.confirm二次确认 - 插件有输入框/列表时,自己补上对应样式(骨架只给到按钮和卡片)
- 界面用中文;不引用任何联网 CDN(离线可用是硬要求);错误必须显示到界面上,不许只 console.log
- 预览环境(
window.__yzhPreview===true)没有 image.pick / network 等能力,要优雅降级
八、安全红线(违反 = 这个插件不能用)
- 不存明文密码/令牌;不诱导用户关闭加密;导出内容标注敏感性
- 不请求 http 明文;不绕过 toolbox 桥(不要直接调
window.flutter_inappwebview.callHandler)
九、必须带一个「诊断」按钮(硬要求)
用户遇到问题时能说的通常只有"点了没反应"。所以页面上留一个不显眼的「诊断」按钮
(骨架里已放好 #btn-diag 和 #diagBox),点一下把下面这些拼成纯文本复制到剪贴板,
并提示"已复制,发给开发者即可":
1. 插件名与版本、页面地址、navigator.userAgent、视口尺寸与 dpr
2. window.toolbox 是否存在、window.__yzhToolboxReady 的值
3. 每个功能最后一次调用的入参 + 宿主原始返回 —— 宿主用 {error:'...'} 表达失败、不会 reject,
所以要记返回原文,不能只记"成功/失败"
4. 页面报错:window.onerror 与 unhandledrejection 都要收进来
5. 每项功能的最终状态:通过 / 失败 / 没测到(骨架里的 STATUS + mark('功能名', '通过')
就是干这个的 —— 每做完一件事就记一笔,报告里会列出来;"没测到"最容易被忽略,却是定位问题的关键)
复制用三层降级:toolbox.clipboard.copy → navigator.clipboard.writeText →
把文本放进已全选的 <textarea>(骨架里的 #diagBox)提示用户长按复制。
十、输出要求
- 输出两个代码块:
manifest.json和index.html(完整可直接运行,不省略、不留 TODO) manifest.json照这个模板填:
{
"pluginId": "在这里填一个随机 UUIDv4,形如 3f2a91c4-77bd-4e58-9a10-6c5d2e8b7f03",
"name": "插件名",
"version": "1.0.0",
"description": "一句话说明这个插件干什么",
"author": "用户",
"minAppVersion": "1.0.0",
"entryPoint": "index.html",
"icon": "🧩",
"tags": ["标签1", "标签2", "标签3"],
"aliases": ["口语叫法1", "口语叫法2"],
"category": "tool",
"permissions": ["storage", "clipboard_write"],
"fileHandlers": [
{ "name": "用本插件打开", "icon": "🎵", "extensions": ["mp3"], "priority": 10 }
],
"handlesSharedText": false,
"sharedTextRoutes": []
}
(category 从 tool / efficiency / lifestyle / entertainment / health 里选一个;
permissions 按实际调用如实填——照下表对一遍,别凭印象:
storage.*→storage|file.*→file|network.fetch→network|
clipboard.copy→clipboard_write(骨架的诊断按钮就用它,基本人人必填)|clipboard.read→clipboard_read|
ui.alert/confirm/prompt→ui_dialog|notify.*→notification|device.vibrate→vibrate|
device.shake/motion/compass→sensors|image.pick→image_pick|image.save→image_save|
share.text/url→share|intake.shared→intake|security.deriveKey→security。
声明与实际不一致不会立刻报错,但插件更新时会因为"声明集合变了"把用户已授的权限重置掉;
声明了却完全没调用的(例如没用震动却写 vibrate)也要删掉。只用免权限能力时就写 [];
fileHandlers 只在"插件需要接收用户文件"时才写——写上它,用户在别处分享该类型文件时
才会出现"用本插件打开",此时插件要监听 YuzhouHe:fileLoaded 或 onExternalFileLoaded 事件;
handlesSharedText 只在"插件要接收分享进来的文字/链接"时才写 true——不写就永远收不到
(想按域名/关键词分流再加 sharedTextRoutes);用不到的字段整行删掉,别留一堆空值)
index.html必须包含:骨架的全部部件(等桥、TB()、桥调用流水、诊断按钮及其onclick绑定、
三个启动入口)、以及第九节要求的诊断信息内容。注释用中文。
- 再输出第三段:
验收清单(纯文本,不用代码块)—— 写给"拿到插件的人",
让他能在 1 分钟内验证最关键的那件事。格式固定四行:
1. 这个插件做了什么(一句话)
2. 怎么试(几步操作,具体到点哪个按钮、拿什么文件试)
3. 看到什么算成功(可观察的现象,例如"列表里出现 3 条记录""导出的文件能打开且时长不变")
4. 我没法验证的部分(老实写:例如"OGG 我只按规范写了,没有真机验证过,打不开请把诊断发我")
第 4 条必须写,哪怕只写"都验证不了,请你自己试一遍"。不要写"已充分测试"这种话。
⛔ 输出前自检(逐条过一遍再交,这几条最常出错)
1. 每个被调用的函数都得有定义 —— 尤其自己写的字节/位运算辅助函数
(w32be / r32le / utf8Decode 这类最容易"以为有")。漏一个,用到它的功能直接抛异常、整条功能废掉。
2. 界面里每个按钮都要有 onclick(骨架自带的「诊断」按钮别把绑定弄丢)。
3. 每个 document.getElementById('x') 里的 id,HTML 里都得存在(反之亦然)。
少一个就是整页脚本中止:脚本在那一行抛异常后,后面的启动代码(boot/loadAll、
桥就绪监听)全都不会执行 → 用户分享文件进来时界面毫无反应。实测事故:
$('fileInput').addEventListener(...) 而 HTML 里根本没有 id="fileInput" 这个元素,
整个插件在载入时就死了。改完请逐个把 $('x') / getElementById('x') 与 HTML 对一遍。
4. 凡是要"改内容再写回"的功能:先想清楚"能不能 100% 原样重建"。
解析出来的每一段数据(音频流、其它元数据块、你不认识的字段……)都必须原样保留、按原顺序接回去;
做不到就只提供"导出副本",绝不要提供"覆盖原文件"——把用户文件改坏比功能少一个严重得多。
5. 二进制/大文件:不要"整份读进内存再 base64"当常规做法(几十 MB 会卡死甚至崩);
真要处理就明确写出上限,并在界面上说清"文件较大时会发生什么"。
6. 别"顺手升级"文件格式:读进来是什么版本/什么编码,写回去就保持一致
(例:ID3v2.3 的标签里文本是 UTF-16+BOM,写回时仍用 UTF-16,不要改写成 UTF-8 或 v2.4)。
顺手"规范化"会把别的播放器/软件读不了的文件交给用户。
7. 分页/分段容器不许只看第一页:OGG、Matroska 这类容器里,元数据(注释头)不在第 1 页,
它跟在识别头之后另起一页,而且可能跨页。没把握就别声称支持该格式——
只支持 MP3/FLAC 也比"看起来支持 OGG、其实一个真实文件都打不开"强。
真要改写分页容器:必须按"段(segment)→ 包"的归属记账(每个包在哪一页的第几段结束),
光记"页"必然算错——注释头跨页时,你会把同一个包写两遍或多丢包(我实测踩过:
导出件比原文件多一个包,就是"以为 setup 包从本页开头开始")。
8. 自己写的日志/包装函数要能扛住 undefined:宿主自己每个调用都会返回对象
({success} 或 {error}),但你自己写的转发/包装函数可能不返回任何东西;
JSON.stringify(undefined) 得到的是 undefined,再 .slice() 就抛 TypeError。
日志里统一写成 JSON.stringify(r === undefined ? '(无返回值)' : r) 这类安全形式。
9. 自测样本必须是真实文件:只拿自己造的文件测,等于用自己写的解析器验自己写的写出器——
必然全过,也必然漏掉真实世界的结构差异(第 7 条就是这么漏的)。
报告结论时也要写清"用什么文件测的"。
10. 同一个文件里可能有两种字节序,逐字段确认,别凭印象:
例:FLAC 的元数据块(STREAMINFO / PICTURE)整数是大端,而同一个文件里 Vorbis Comment
的长度是小端。把 PICTURE 的长度按小端读会算出 1.6 亿的偏移,直接抛
Offset is outside the bounds of the DataView —— 任何带封面的真实 FLAC 都打不开。
11. 浏览器里没有 Node 那套 API:Uint8Array.toString('base64') 不是 base64,
它走 Array.prototype.toString,得到 "0,0,3,0,0,…" 这种数字串,写进文件就是垃圾数据,
而且不报错。要 base64 只能用 btoa(配自写的分块转换)或自己实现。
12. "能导出"不等于"导出对了"——导出后自己核对三件事:
① 输出字节数合理(不能比原文件小一大截);② 该原样带走的数据(音频流、其它元数据块、
你不认识的字段)长度对得上;③ 用户改的那个字段真的变了。
实测事故:FLAC 导出只写了元数据(10,599 字节),20,407,672 字节的音频整个没跟过来;
OGG 导出则"看起来成功",但标题根本没改掉(原因见第 13 条)。
13. 同一个对象的键名只允许一种风格:调用方传 {title: …}、函数里读 edits.TITLE,
不报错,只是"改了没反应"。要么全小写,要么全大写。
14. 调用自己写的函数时,参数个数必须对得上:function f(a,b,c){…} 不能写成 f(x,y)。
实测事故:oggRebuild(u8, pkt) 少传一个参数 → 函数体访问 collected.serial 立刻抛
Cannot read properties of undefined,整个 OGG 导出报废,而语法检查一路全绿。
15. 同一个字面量只写一次:帧 ID、编码字节、字节序、字段名这类"规范常量"最容易一处写对、
一处写错(例:同一个文件里 A 处按大端读、B 处按小端读)。同类操作要么抽成一个函数,
要么逐处对照——发现两处不一致时,一定是其中一处错了。
16. 每一个 catch 里都要给用户看得见的反馈:catch(e){} 吞掉错误 = 用户只看到"点了没反应",
这正是最难排查的一类 bug。至少 show('失败:' + e.message, 'bad')。
17. 读写字段要成对:给对象赋值之后,回头把"你读它的每个字段"列一遍,确认都在赋值里。
实测事故:状态对象写成 cur = { name, ext, src },而后面到处读 cur.bytes.length ——
"生成副本"那一步一切正常,到"导出后自检"才抛 TypeError,用户看到的就是"点了没反应"。
18. 每个新函数至少在心里"单独调一次":参数类型对不对?会不会有 null/undefined 进来?
实测事故:写了一句 vorbisCommentPacket(c.entries.length >= 0 ? null : null, c.vendor) // 占位防误用,
这个表达式恒为 null,函数里立刻 null.length 抛异常 —— 整条 OGG 导出 100% 走不通,
而作者以为那只是个无害的占位。不要留自己都说不清的"占位/防误用"代码:要么删掉,要么写对。
19. 别给不同的东西起同一个变量名:同名变量(尤其是外层状态与函数内局部变量重名)会让
静态检查失效、也让人读不懂。实测:我的状态变量 cur 与函数内的包累加器 cur 同名,
检查器分不清谁是谁,于是放过了我那条真 bug。
20. 自检 / 日志 / 格式化这些"辅助代码"同样要包在 try 里:它们崩了和主流程崩了没区别——
都是"点了没反应"。最稳的写法是整个按钮处理函数从头到尾包一层 try/catch。
⛔ 你不理解的结构,就明确拒绝("诚实"比"多支持一点"值钱)
下面这些情况不要硬改,直接告诉用户"我处理不了,原因是什么":
- 读到的格式版本/标志你不认识(例:ID3v2.2、非同步编码标志、扩展头;Opus 编码的 .ogg)
- 文件结构超出你的假设(例:找不到注释头、元数据块越界、首块不是 STREAMINFO)
用户看到"明确拒绝 + 原因",比看到一个被改坏的文件好一百倍。
十一、按插件类型查这几条(通用规则上面已给全;这里只补"这一类"特有的坑)
先看【本次需求】属于下面哪一类,重点看那一类;其余通用部分照做。
A. 纯本地数据类(待办 / 记账 / 习惯打卡 / 日记 / 清单)
- 只要
storage权限;数据一律storage.set(key, JSON.stringify(数组或对象)),读回先unwrapResp再parseStored。 - 列表用"渲染函数 + 重画"(
list.innerHTML=''后逐条createElement),不要拼 HTML 字符串(防注入)。 - 破坏性操作(清空/删除)必须
ui.confirm二次确认,并判{confirmed}。 - 想让用户拿走数据 →
file.exportFile('清单.txt', btoa(文本));想让它能收回数据 → manifest 写
fileHandlers(扩展名 txt 之类)+ 监听文件交接事件,导入时按内容去重。
B. 要联网的(RSS / 查接口 / 翻译 / 天气 / 抓网页)
- 只能
toolbox.network.fetch,只允许 https;参数:{ url, method, headers, body, asBytes }。
method 白名单:GET / POST / PUT / DELETE / HEAD / PATCH / OPTIONS。
- 限制:60 次/分钟(宿主按插件计数)、单次 30 秒超时、响应体 ≤20MB;
返回的 headers 里 set-cookie 与 authorization 已被剥掉(别指望带会话)。
- 中文老站点(gb18030/gbk):宿主只会按 UTF-8 解,会乱码 ——
传 asBytes:true,拿返回的 bodyBase64 + charset,自己在页面里用
new TextDecoder(charset) 解码(WebView 支持 gbk/gb18030/big5)。
- 不要高频轮询(宿主有限速;被限速时返回
{error:'请求过于频繁…'});
失败必须显示到界面({error} 或 reject 两种都要接)。
C. 提醒 / 通知类(纪念日 / 喝水 / 番茄钟 / 每日打卡)
notify.schedule({...})可用字段:at(单次,ISO 字符串或毫秒)、dailyAt('HH:mm')、
repeat、repeatAfterMinutes、repeatUntil、key(自定义标识,同 key 覆盖)、
title、body、strength('strong'/'weak')、tone、toneUri。
- 自定义铃声要先
notify.pickRingtone()让用户亲手选(返回{uri,title},取消是{cancelled:true}),
再把 tone:'custom' + toneUri 传给 schedule —— 插件不能自己指定铃声文件。
- 取消用
notify.cancel(key),列出已排的用notify.list()({keys:[...]})。 - 通知需要
notification权限;系统可能不让后台唤醒(国产系统尤其),
所以界面上要写清"提醒可能延迟/不响"以及去哪开自启动 —— 别承诺"一定准时"。
D. 传感器类(指南针 / 摇一摇 / 计步 / 水平仪)
- 需要
sensors权限。三个方法都返回{stop()},不用时一定要 stop(否则一直耗电)。 - 回调收到的东西不一样,别猜:
device.compass(cb)→cb(heading),一个数字(度)device.motion(cb)→cb({x, y, z}),三个值是字符串(如"0.123"),要parseFloat再用device.shake(cb)→cb(),没有任何参数- 界面更新要节流(别每个采样点都重画),iOS/Android 的可用性不同,取不到时给明确提示。
E. 分享 / 剪贴板 / 内容路由类(书签 / 稍后读 / 剪贴板收藏 / 快捷回复)
- 要收到"别的应用分享进来的文字/链接",manifest 必须
"handlesSharedText": true
(不写就永远收不到);想按域名/关键词分流可加 sharedTextRoutes(默认不生效,用户勾选后才用)。
- 取内容用
intake.shared():取一次即清,没有内容时连text键都没有 → 必须判空。 - 读剪贴板
clipboard.read()返回{text},且限 20 次/分钟;写用clipboard.copy(text)。
F. 图片类(拼图 / 加水印 / 取色 / 批量处理)
- 选图
image.pick('gallery'|'camera')→{success,name,path,data(base64),mimeType},取消是{success:false}。 - 存相册
image.save(base64, filename)→ 可能返回{success:false,needPermission:true},要提示去开权限。 - 处理用
<canvas>;导出用image.save(进相册)或file.exportFile(进分享面板)。 - 单次上限 50MB;大图先缩再处理,别把原图整份 base64 在内存里来回拷。
G. 读写 / 改写某种文件格式(编辑器 / 转换器 / 提取器)
这一类最容易把用户文件改坏,先读第一节「文件能力的边界」与下面这段:
- 格式版本/标志不认识(ID3v2.2、非同步、扩展头、Opus…)→ 明确拒绝,别硬改。
- 同一个文件里可能有两种字节序(FLAC 元数据块是大端、Vorbis Comment 长度是小端)→ 逐字段确认。
- 分页容器(OGG 等):注释头不在第 1 页、可能跨页;重建要按"段→包"归属记账。
- 文本编码:写在帧里的编码字节必须和实际字节一致(声明 UTF-16 就别写 UTF-8)。
- 不认识的字段要原样抄回去;改完自检要覆盖:载荷逐字节、未知字段数量、输出不能比输入小 5% 以上。
H. 网址类(想把某个网站直接做成插件)
- manifest 里写
"url": "https://…"、不打包index.html—— 这种插件就是直接打开那个网页。 - 注意:网址插件没有 toolbox 桥(宿主只给本地插件注入
window.toolbox),
所以它拿不到 storage / 通知 / 文件等任何宿主能力,也不能验签。
要"有宿主能力 + 打开某个网站",正确做法是**本地 index.html 插件 + 一个按钮调
system.openExternal(url)**(自定义 scheme 首次会请用户确认)。
【本次需求】
(在这里写:插件叫什么、解决什么问题、有哪些功能点、数据记什么、界面大概什么样)