首页/开发者文档/AI 插件生成提示词

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.storageget / set / remove / clear / keys
toolbox.storage.encryptedset / get / remove / clear / keys(设备级密钥加密,密码/令牌这类敏感内容用它)
toolbox.filewrite / read / delete / list / exists / download / writeBinary / readBinary / exportFile / share
toolbox.uitoast / alert / confirm / prompt / loading.show / loading.hide
toolbox.clipboardcopy / read
toolbox.deviceinfo / vibrate / shake / motion / compass
toolbox.imagepick / save
toolbox.notifyschedule / cancel / list / pickRingtone
toolbox.sharetext / url
toolbox.networkfetch(仅 https,60 次/分钟)
toolbox.intakeshared(取用户从别的应用分享进来的文字)
toolbox.systemopenExternal(用系统浏览器打开链接)
toolbox.securityderiveKey(安全中心解锁后派生的插件专用密钥)

六、返回值形状(真机逐条实测,照抄即可)

最容易踩的坑:以为所有读取都返回 {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 首次会请用户确认)。

【本次需求】

(在这里写:插件叫什么、解决什么问题、有哪些功能点、数据记什么、界面大概什么样)

MATRIX MODE: ON — 再输入一次或按 ESC 退出