分发包与包结构
一个可装载的包由三部分组成:根部的 pack.json、按档位划分的内容目录、可选的 signature.json。包可以是目录、ZIP 归档,也可以随构建产物内置;三种形态走同一套读取契约,装载器不感知形态差异。
包结构不是格式细节,它承载了档位、依赖、能力、写边与实现位登记。装不装得上,首先取决于这些声明是否与内容一致。
三种包形态
| 形态 | 来源形状 | 说明 |
|---|---|---|
directory | list() 返回文件表,read(path) 读取字节 | 逐文件记录所有者与修改时间;未查得的信息留空,不猜测写入方 |
zip | { size, read(offset, length) } 随机读取 | 只接受 store 与 deflate;拒绝分卷、ZIP64、加密、路径穿越、重名项与越界内容 |
builtin | { files: Record<相对路径, Uint8Array> } | 随构建产物发布,仍按同一读取契约核验 |
三种形态都返回同一个读取器接口:form、files()、read(path),以及可选的 sourceInfo()。上层不需要分支判断「这是目录还是 ZIP」。
分发包与章节包
分发包(Bundle)是一个装载单位,内含零到多个单元。它自己的 pack.json 只有公共字段、kind: "bundle" 和 units 列表。
chapter-01/
├─ pack.json # kind: bundle,units: ["story", "art"]
├─ story/
│ ├─ pack.json # kind: datapack
│ └─ data/script/chapters/ch01/opening.json
└─ art/
├─ pack.json # kind: resourcepack
└─ assets/bg/classroom.png规则如下:
| 规则 | 内容 |
|---|---|
| 根目录只能有什么 | pack.json 与 units 列出的子单元目录 |
| 子单元需要自己的清单吗 | 需要,每个子单元目录根部各有一份 pack.json |
| 能否嵌套分发包 | 不能,分发包内不得再出现 kind: "bundle" |
| 如何判定 | 各单元分别判定、分别装载、分别卸载 |
| 章节包是什么 | 只含无代码单元的分发包;带插件或 Mod 就不再是章节包 |
把多个单元打成一个包,不会让它们共享档位或共享写边声明。每个单元仍按自己的内容与清单接受检查。
单元清单的公共字段
| 字段 | 适用 | 含义 |
|---|---|---|
format | 全部 | 清单格式版本,当前为 1 |
id | 全部 | 稳定 ID,小写字母或数字开头,可含点与连字符 |
version | 全部 | 语义化版本 |
name | 全部 | 显示名 |
publisher | 全部 | 发布者名 |
kind | 全部 | datapack、resourcepack、plugin、mod 或 bundle |
targets | 单元 | 依赖的大插件契约版本,如 scriptplay@1 |
caps | 代码单元 | 能力请求;optional: true 表示缺失时降级而不是拒绝;数据包与资源包必须留空 |
files | 单元 | 相对路径到 sha256: 摘要的映射 |
ir | 数据包、资源包 | 剧本 IR 版本范围,如 ^1 |
entry | 代码单元 | code/ 下唯一的 ESM 入口 |
persistence | 代码单元 | 可清理性:none、closed 或 public;缺失即拒绝 |
flagNamespaces | 可选 | 该包持有的旗标命名空间 |
budget | 代码单元 | 归档大小、文件数、单文件大小与压缩比预算 |
registers | 代码单元 | 实现位登记项 { slot, name };数据包与资源包必须留空 |
writes | 代码单元 | hooks、intercepts、isolateProvides |
files 只覆盖内容文件,不包括 pack.json 与 signature.json。声明多了、少了,或摘要与文件不符,都会导致清单不合法。
完整字段、取值范围与校验规则见包清单与目录;本页只讲结构关系。
目录布局
单元根部只允许三类内容目录:
| 目录 | 允许出现在 | 作用 |
|---|---|---|
data/ | 数据包 | 写入 registry 条目 |
assets/ | 资源包 | 覆盖既有资产路径 |
code/ | 插件、Mod | 存放打包后的单文件 ESM |
这是硬边界:数据包出现 assets/、资源包出现 data/、无代码单元出现 code/,都会以 GUARD_CLASSIFICATION_MISMATCH 拒绝。一个包不能同时是数据包和资源包;需要两者时,把它们做成同一分发包下的两个单元。
数据包:data/ 布局
数据条目的路径决定它写入哪个 registry、哪个条目:
data/<域>/<registry 名>/<命名空间>/<局部名>.json
-> registry "<域>:<registry 名>"
-> 条目 "<命名空间>:<局部名>"例如:
data/script/chapters/prologue/opening.json
-> registry "script:chapters"
-> 条目 "prologue:opening"条目文件只有三个字段:
{
"value": { "nodes": [] },
"strategy": { "/nodes": "append" },
"policy": "public"
}| 字段 | 必填 | 含义 |
|---|---|---|
value | 是 | 条目内容,形状由所属 registry 的 schema 校验 |
strategy | 否 | JSON Pointer 到覆盖方式的映射:replace、merge、append |
policy | 否 | 条目自身的开放度,只取 closed 或 public;未声明时继承所属 registry 的 policy |
覆盖规则要点:
- 未声明
strategy时按根路径replace处理。 merge只合并对象,不允许用null删除字段。append只用于数组。- 既有条目为
closed时拒绝覆盖;覆盖时不得改变原条目的 policy。 core与sui是保留命名空间,单元不得在其中创建条目。
数据包按装载顺序逐层叠加;卸载时移除整层并重算,后续条目的生效值随之更新。
资源包:assets/ 布局
资源包只通过虚拟路径覆盖既有表现资产:
assets/<命名空间>/bg/classroom.png
assets/<命名空间>/audio/bgm/theme.ogg
assets/<命名空间>/lang/zh-CN.json覆盖是路径级整体替换,不做字段合并。普通资产覆盖不引入代码或交互行为。已登记的界面皮肤可通过 assets/<ns>/skin/controls.json 的白名单属性调整控件位置、尺寸、层级与外观,由框架校验、夹回边界并补足可点区域;不能改变事件绑定、键盘顺序或安全关键界面。皮肤内不同控件名之间的属性继承由皮肤引擎负责,不改变资源文件按路径整体覆盖的规则。详见 Platform 皮肤参考。
assets/sui/ 是安全关键界面的保留命名空间(大小写不敏感)。任何单元携带该路径下的文件都会被拒绝,资源包也不例外。
代码单元:code/ 只能有一个入口
plugin-example/
├─ pack.json
├─ signature.json
└─ code/
└─ index.jscode/ 下必须恰好有一个文件,且它的路径等于清单的 entry。这个文件必须是已经打包好的单文件 ESM:
- 包内模块之间的相对
import不被提供,单元需要自行打包; - 宿主只暂存入口文件,不会把整个
code/目录当作模块图加载; - 多出文件、入口不在文件表内、或入口不在
code/下,均以LOADER_MANIFEST_INVALID拒绝。
代码单元还必须声明 persistence 与 budget。前者说明它是否写入持久数据、能否被清理;后者限制归档与解压后的资源占用。
签名与归档 hash
| 项目 | 覆盖范围 | 作用 |
|---|---|---|
signature.json | pack.json 的规范形式 | 证明清单(含 files 表)由持有对应私钥的发布者签署 |
files 表 | 除 pack.json、signature.json 外的全部内容文件 | 把每个内容文件绑定到清单 |
| 归档 hash | 全部文件(含 pack.json 与 signature.json)的路径与摘要表 | 标识整包;文件时间与重打包方式不参与 |
signature.json 位于单元根部,形状为 { alg, publicKey, signature },算法为 ed25519。验签顺序决定了三者的关系:
- 用公钥验证
signature.json对pack.json的签名; - 检查
files表是否覆盖全部内容文件,且每个摘要与文件实际内容一致; - 只有签名有效且文件表完全一致,才判定为「签名有效」。
因此签名不是给单个文件盖章,而是给「清单 + 文件表」盖章;文件表再逐项绑定内容。归档 hash 是另一层身份:它把清单、签名与全部内容一起纳入,用来标识整包,不随修改时间或压缩方式变化。
常见失败对应:
| 情况 | 结果 |
|---|---|
有 signature.json 但无法解析或验签失败 | GUARD_SIGNATURE_INVALID |
签名有效,但内容与 files 表不符 | GUARD_SIGNATURE_INVALID |
未签名,且内容与 files 表不符 | LOADER_MANIFEST_INVALID |
| 发布构建下代码单元未签名 | GUARD_UNSIGNED_IN_RELEASE |
| 发布者密钥已撤销 | GUARD_PUBLISHER_REVOKED |
通过核验的读取器不会把字节一次性缓存到底:每次交付前都会复制,并与私有哈希快照比对。核验之后文件被替换时,签名单元报 GUARD_SIGNATURE_INVALID,未签名单元报 LOADER_MANIFEST_INVALID。
读取、预算与产物
读取器在列举阶段就核对预算:文件数、目录数、单文件大小、总量与路径深度超限时以 GUARD_BUDGET_EXCEEDED 拒绝。目录来源在列举阶段报超限也一样处理,不会先读入再判断;ZIP 条目即使随后被跳过,也要先通过包内安全路径核对。翻译归档读取还会同时核对单条目的压缩后与解压后大小,并按实际读入量与解出量取大者计入输入预算。
Tauri 目录读取在宿主侧按 offset/length 分块,每块最多 64 MiB;前端拼接时核对文件大小没有变化,变化即拒绝。这个分块是宿主传输的峰值控制,不改变上层 PackReader.read(path) 契约。
翻译或其他构建流程生成的产物不会自动出现在已装载列表。它们只是一组符合目录布局的文件:
- 产物先整理成标准目录文件表,并生成自己的
pack.json; - 需要经正常装载入口重新核验,不能沿用生成时的信任状态;
- 未签名产物仍按未签名单元处理,需要用户批准;不伪造签名,也不绕过 guard。把产物打成 ZIP 再分发也一样:形态变了,核验流程不变。
常见误解
常见误解
- 「目录形态可以省掉
pack.json。」 三种形态都要求根部有pack.json,读取契约不因形态而放宽。 - 「代码单元可以把
node_modules一起放进去。」code/只允许一个打包后的 ESM 文件,相对导入不会由宿主解析。 - 「签名只覆盖入口文件。」 签名覆盖
pack.json,其中包含全部内容文件的摘要表。 - 「归档 hash 会随文件修改时间变化。」 归档 hash 只看路径与内容摘要,不看时间戳或压缩方式。
- 「翻译产物生成后会自动装载。」 产物必须重新走装载与核验流程。
- 「一个包可以同时放
data/和assets/。」 这会被判为档位不符;请拆成分发包下的两个单元。