插件开发指南
沙箱安全模型、启动时序、window.toolbox API 全览、错误处理与平台限制
插件开发指南
宇宙核的插件是沙箱内运行的 Web 页面(HTML/CSS/JS),由本地静态服务器伺服,通过 window.toolbox 桥接访问系统能力。插件不获得本机文件系统或网络直连权限。
安全模型
- 插件运行在独立 WebView 沙箱;所有能力经
window.toolbox显式调用,首次调用弹原生授权框,用户同意后永久授权(可在「权限管理」页随时调整,每次调用记录使用日志) - 页面由本地伺服器下发(随机端口 + 会话 Cookie),响应带
Cache-Control: no-store(插件更新即生效)与 CSP(可引用 https 资源;禁止 http 明文、禁止被 iframe 嵌套) - 顶层导航仅允许 https(data:/blob: 禁行)
- 诱饵空间内,非隔离工具拿不到真实数据密钥——相关调用 fail-closed 返回锁定错误
启动时序(必须幂等)
最大的坑:window.toolbox 是宿主在页面加载完成之后才注入的,而插件自己的启动脚本早就跑完了——启动时调用任何宿主能力,那时 window.toolbox 还是 undefined,代码会静悄悄什么都不做。“启动时读存储”会读到空,用户以为数据丢了。
宿主注入 window.toolbox 后会同步广播 yzhToolboxReady 事件(并置 window.__yzhToolboxReady = true),这是唯一可靠的“桥已就绪”信号。老事件 toolboxReady 只在存储确实有数据时派发(现于桥注入时也会补发一次作兼容别名),不要用它做初始化门控。推荐模式:
// 等桥就绪再干活(whenToolbox 见 example-code.md),loadAll 必须可重入
whenToolbox(3000).then(function(){ loadAll(); render(); });
// 存储水合完成后宿主会再发一次 toolboxReady,监听它把已落数据读回来(幂等无害)
window.addEventListener('toolboxReady', loadAll);
数据存储(核心)
插件不做加密,宿主做。 storage.* 数据落盘于 plugin_data/<pluginId>/storage.json,整文件 AES-256-GCM 加密,密钥来自托管密钥链,档位跟随权限页的「密码保护」开关:
| 密码保护 | 数据 | 插件可用性 |
|---|---|---|
| 关(默认) | 设备 KEK 档加密 | 免密可用,没设安全密码也加密 |
| 开 | 密码档加密 | 进插件需密码;安全中心锁定时 storage 调用返回 {error:'locked', locked:true} |
两个历史行为,不处理必然出 bug(完整模板见示例代码):
- 读取返回
{value: 数据}包装,不存在的键是{value: null}——直接x?.value ?? x会把包装对象当数据; storage.set(key, "合法JSON字符串")时宿主会自动解析成对象落盘,读回的value可能是字符串也可能是对象——只按字符串JSON.parse会在二次读取时崩。
window.toolbox API 全览
所有方法返回 Promise,失败返回 {error: '...'}(不 reject,必须检查 res.error 并反馈给用户)。
存储(storage 权限)
| API | 说明 |
|---|---|
storage.set(key, value) |
value 传 JSON 字符串;整文件上限 10MB;串行队列保证并发安全 |
storage.get(key) / keys() / remove(key) / clear() |
读取 / 枚举 / 删除 / 清空;锁定态返回 locked 错误 |
storage.encrypted.* |
遗留加密存储 API(新插件不要使用,见插件数据管理) |
文件(file 权限)
| API | 说明 |
|---|---|
file.exportFile(name, base64) |
导出首选:写入 exports/ 目录并自动弹出系统分享面板,返回 {success, path} |
file.share(name) |
再次分享 exports 中已导出的文件 |
file.list(dir?) |
列目录;dir='exports' 列导出记录 {files:[{name,size,modified}]} |
file.delete(name, dir?) |
删除文件;dir='exports' 删导出文件 |
file.write/read/exists / writeBinary/readBinary |
插件根目录小文件(保留名 storage.json 被拒绝) |
file.download(url, filename) |
下载 https 资源到导出目录 |
- 导出文件在 App 沙箱
exports/目录——分享面板是用户取出文件的唯一途径(安卓限制)。 - 文件名禁止
/、\、..、前导.;单文件写入/导出上限 50MB。
图片 / 分享 / 剪贴板 / UI
| API | 说明 | 权限 |
|---|---|---|
image.pick(source?) |
系统相册/相机选图,返回 {success, data(base64), mimeType, ...};取消时 success:false |
image_pick |
image.save(data, filename) |
base64 图片存相册 | image_save |
share.text(text) / share.url(url, title?) |
系统分享 | share |
clipboard.copy(text) / read() |
原生 navigator.clipboard 已被禁用并抛错 | clipboard_write / read |
ui.toast / alert / confirm / prompt / loading.show / hide |
原生渲染;confirm/prompt 返回用户选择 | ui_dialog |
⚠️ App 内
<input type="file">无效(WebView 未实现文件选择器)——选图必须用image.pick;浏览器预览环境可用window.__yzhPreview判断后降级为 input。
网络(network 权限)
const r = await window.toolbox.network.fetch({ url, method:'GET', headers:{}, body:'...' });
// r = {statusCode, headers, body} 或 {error}
- 仅 HTTPS;SSRF 防护(私网/回环拦截);最多 5 跳重定向;响应 20MB 截断;限速 60 次/分钟。
window.fetch已被 polyfill 自动走此通道。
其他
| API | 说明 | 权限 |
|---|---|---|
device.info() / vibrate(ms) |
设备信息 / 震动 | vibrate |
device.compass(cb) / shake(cb) / motion(cb) |
传感器流,返回 {stop()} |
sensors |
notify.schedule({at?, dailyAt?, key?, title?, body?}) / cancel(key) / list() |
本地通知(页面关闭后宿主照常触发) | notification |
workflow.trigger {name, data} |
触发用户工作流 | workflow |
ai.prompt {text} |
调用宿主 AI | ai |
navigate.back {} |
请求后退(无历史则关闭页面) | 无 |
security.deriveKey() |
遗留:仅用于旧版自行加密数据的迁移,新插件禁止使用 | security |
底层调用方式
未提供便捷封装的能力,可经桥直接调用:
const result = await window.flutter_inappwebview.callHandler('toolbox', 'navigate.back', {});