Files
soft_quay/docs/api.md
T

410 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 协议合约(Catalog / 软件包 / 许可证 / 事件 / CLI)
> 本项目没有自建在线 API;盒子只消费静态签名文件。本文定义所有跨边界数据结构和交互合约的目标形状。
> 实现前可细化,但不要在代码里另起一套不兼容结构。字段变化必须同步更新本文、[04-architecture.md](04-architecture.md) 和 `schemas/` 下的 JSON Schema。
## 通用约定
- 传输:HTTPS;所有 JSON 使用 UTF-8。
- 时间格式:ISO 8601(UTC)。
- 签名:Ed25519;客户端只内置公钥;示例中的 `"signature": "..."` 均为占位符。
- 版本号:语义化版本(SemVer)。
- 软件 ID:`^[a-z0-9-]+$`,永久稳定,发布后不得更改。
- 软件包控制的路径统一使用 UTF-8 与 `/` 分隔,并由客户端共享的 Windows 安全相对路径策略校验。JSON Schema 只表达基础形状;运行时还逐段拒绝空段、`.`/`..`、首尾 ASCII 空格、尾随句点、控制字符、Windows 禁止字符和 DOS 设备名及其兼容变体。
## 1. Catalog 清单(远端 → 盒子)
现代版与 Win7 版使用不同 channel 文件:`manifest-modern.json` / `manifest-win7.json`。更新器必须校验 channel,禁止 Win7 版下载现代版包。
```json
{
"schema_version": 1,
"channel": "modern",
"generated_at": "2026-07-16T00:00:00Z",
"min_box_version": "1.0.0",
"apps": [
{
"id": "json-parser",
"name": "JSON解析工具",
"description": "示例简介",
"version": "1.2.0",
"channel": "stable",
"status": "active",
"category": "开发工具",
"tags": ["工具", "JSON"],
"icon": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"homepage": "https://example.com",
"tutorial": "https://example.com/tutorial",
"min_os": "windows-7-sp1",
"architectures": ["386", "amd64"],
"entry_exe": "JsonParser.exe",
"requires_admin": false,
"packages": {
"amd64": {
"url": "https://download.example.com/json-parser-1.2.0-amd64.zip",
"size": 12345678,
"sha256": "...",
"signature": "..."
}
}
}
],
"signature": "..."
}
```
行为要求:
- 网络成功:按“HTTPS 获取 → 验签 → Schema/字段/目标 channel 校验 → 替换缓存”处理;任一步失败都**拒绝**新内容,继续用最后一次验证且客户端可消费的缓存。
- 网络失败:用缓存;清单过期给提示,但保留已安装软件的启动能力。
- 下架:显式 `status: deprecated | hidden`,不用名称前缀。
- 分类:`category` 是单一稳定分类,`tags` 是搜索/多标签展示数据;两者都不得为空。
- 过滤:先校验 manifest `channel`,再按 `min_os`、`architectures` 和 `packages` 过滤;不兼容软件可见说明但不可下载。
- 状态:`active` 可安装;`deprecated` 可见但不可新装/更新;`hidden` 不进入目录结果,但本地已安装记录仍由本地状态模块保留。
- URL:Catalog、package、homepage、tutorial 只接受无用户信息、无 fragment 的绝对 HTTPS URL。
- 架构:MVP Schema 接受 `386` / `amd64`;一个 app 的 `architectures` 必须与 `packages` 键一一对应。当前两个客户端发布目标仍是 amd64。
- 系统版本:`min_os` v1 只允许 `windows-7-sp1`、`windows-10`、`windows-11`;更高系统可消费更低最低版本的软件。
- Schema:客户端协议文件为 `schemas/manifest.schema.json`;标准包元数据为 `schemas/app.schema.json`。运行时还会执行 JSON Schema 难以表达的重复 ID、架构映射和 channel 目标一致性检查。
### 1.1 Catalog 签名域
客户端采用以下签名域,供发布器实现对齐:
1. 输入必须是单个 UTF-8 JSON object;重复字段、尾随 JSON、浮点/指数数字、`-0`、前导零和非法 Unicode surrogate 直接拒绝。JSON 字符串中的 `\u` high surrogate 必须立刻与一个 low surrogate 配对;孤立/不匹配 surrogate 不得替换为 U+FFFD 后继续处理。
2. 读取顶层 `signature`(标准 Base64 编码的 64 字节 Ed25519 签名),然后从对象中移除该字段。
3. 对剩余值递归规范化:对象键按 Unicode 字符串升序排列;数组保持原顺序;字符串按 JSON 转义;数字只接受 `0`、正整数或负的非零整数,并保持其原始合法十进制 token(包括大于 IEEE-754 安全整数的值);不保留无意义空白。只删除**顶层** `signature`,任何嵌套 package `signature` 仍属于 signed payload。
4. Ed25519 直接签名/验证上述规范 JSON 字节。
`signature` 文本必须是唯一的标准 padded Base64 表示:严格解码为 64 字节后重新编码必须逐字节等于输入,因此 CR/LF、其他空白、缺失/额外 padding 都拒绝。固定跨实现 corpus 位于 `testdata/catalog/canonical-vectors.json`;客户端与 `softbox-catalog` 发布端必须读取其中的静态 canonical bytes、测试公钥和签名,不得以自身 canonicalizer 重新生成期望值。密钥 ID/轮换字段尚未定稿,在单公钥协议升级前不得另造签名域。
### 1.2 图标内容引用与本地缓存
Catalog `icon` v1 是 `sha256:<64 hex>` 内容引用,不是可直接请求的 URL。最终分发字段仍由发布端定稿;客户端通过注入的 IconFetcher 把引用解析为图标字节,不得在 UI 中拼接或猜测 URL。
客户端缓存合约:
1. 请求键为 `(icon digest, DPI)`;DPI 接受 48~768 的整数值。
2. 加载顺序为有界 memory LRU → 磁盘 → 注入的流式 Fetcher;磁盘文件名为 `<digest>-<dpi>.icon`。memory 默认同时限制为 32 MiB 与 256 个 key,命中提升 LRU recency。
3. 同一 key 的并发 miss 由一个 in-flight leader 执行 disk/fetch/validate/store,等待者复用结果;不同 key 不互相串行。等待者取消只结束自身等待,leader 失败/取消后必须释放 key 供后续重试。
4. Fetcher 返回与请求 context 绑定的 `io.ReadCloser` 和可选声明长度。声明长度超过 2 MiB 默认上限时不读 body;未知或伪造长度仍只读 `maxBytes+1`,所有成功/失败路径都关闭 body。
5. 远端和磁盘字节都必须复核 SHA-256,并通过图片完整解码、2 MiB 默认字节上限与 2048×2048 默认尺寸上限。
6. 只有验证成功的远端字节可用同目录临时文件原子写入磁盘;损坏的普通缓存文件删除后可重新获取,symlink/非普通文件按不安全布局拒绝。
7. 新进程断网时可读取再次验证成功的磁盘缓存;缓存损坏且远端不可用时返回 `no valid icon available`,UI 使用稳定占位图。
8. `IconEventDelivery` 在调用方拥有的后台 context 中完成 `IconCache.Load` 与 `DecodeIcon`,成功发布 `IconReady`,失败只发布稳定分类的 `IconFailed`;取消直接结束且不发布迟到失败。事件身份为 request_id + app_id + icon_ref + DPI,不得把原始 URL/query 或 Gio 类型放进 payload。
9. application event relay 是有界 FIFO,队列满时执行可取消的 lossless backpressure,不静默丢图标结果。后台 pump 成功入队后只调用并发安全的 `Window.Invalidate`;Gio Frame/UI goroutine 在 Layout 前 drain 并执行 `ApplyEvent`/`ApplyIcon`。
10. shell 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef、DPI 变化或取消请求后丢弃迟到 ready/failed。同一 AppID 更换 IconRef 时先清除旧 ImageOp,Layout 始终只复用内存 `paint.ImageOp`。
11. symlink、目录和其他非普通 cache entry 必须返回 `ErrIconCacheUnsafe` 并立即停止;不得读取/跟随、删除、改写或重命名该 entry,不得回退 Fetcher。该错误由 delivery 映射成唯一稳定码 `unsafe_cache`,后台返回链仍保留 `ErrIconCacheUnsafe` 供调用方分类。
12. shell 只为当前最新身份保存 `IconEventIdentity + IconFailureCode`;仅 `unsafe_cache` 在选中详情显示安全警告、稳定 code、app ID 与 `<digest>-<dpi>.icon` locator。不得显示绝对 cache root、原始 error、URL/query、token 或 link target;人工处理遵循 [故障排查](troubleshooting.md),不提供自动清理/隔离/重试操作。
当前生产 `cmd` 尚未装配 `NewIconCache`、真实 `IconFetcher` 或 `IconEventDelivery.LoadAndPublish` 调用方。以上是已由 core/双端适配器测试冻结的安全契约,不是生产网络图标链已经启用的声明。
## 2. 标准软件包协议 v1(ZIP)
```text
json-parser_1.4.2_windows_amd64.zip
├─ app.json # 身份、版本、入口、兼容性
├─ files.json # 解压后文件清单(v1.1 强制,v1 推荐)
└─ payload/ # 实际安装到 current/ 的程序文件
```
### 2.1 app.json
```json
{
"schema_version": 1,
"id": "json-parser",
"name": "JSON解析工具",
"vendor": "MyCompany",
"version": "1.4.2",
"channel": "stable",
"min_os": "windows-7-sp1",
"architecture": "amd64",
"entrypoint": "JsonParser.exe",
"working_directory": ".",
"product_id": "product-json-parser",
"supports_trial": true,
"requires_admin": false,
"data_policy": "local-app-data",
"update_policy": "managed-by-softbox"
}
```
校验规则:`entrypoint`/`working_directory` 必须通过共享 Windows 安全相对路径策略(`working_directory` 额外允许 `.`),并位于 payload 内;`id`、`version`、`channel`、`architecture` 必须与 Catalog 记录一致;`schema_version` 高于盒子支持范围时拒绝安装并提示升级盒子(盒子始终支持当前与前一个 Schema)。
### 2.2 files.json
```json
{
"schema_version": 1,
"files": [
{ "path": "JsonParser.exe", "size": 3456789, "sha256": "..." }
]
}
```
完整 ZIP 的 SHA-256 由签名 Catalog 保存(不写入包内部);files.json 用于解压后复核关键文件与修复功能。
### 2.3 安全限制(必须拒绝)
绝对路径;`../` 与 Windows `".. "` 等归一化穿越;首尾 ASCII 空格/尾随句点造成的路径别名;DOS 设备名;符号链接/重解析点逃出 staging;写入其他软件或盒子目录;覆盖 `data/` 与 `licenses/`;包内自动执行脚本(install.bat/PowerShell 钩子);未验证 SHA-256/签名的包被执行;解压文件数、总体积或压缩比无上限;entrypoint 指向 payload 之外。
T-102 Phase 1 原型进一步固定:
- ZIP 名称只接受 UTF-8 `/` 分隔的规范 Windows 安全相对路径;逐段拒绝反斜杠、盘符、冒号/NTFS ADS、NUL/控制字符、Windows 禁止字符、`.`/`..`、首尾 ASCII 空格、尾随句点、DOS 设备名及大小写折叠后的重复输出路径。
- 顶层只允许必需的 `app.json`、可选 `files.json` 与 `payload/`;只把 `payload/` 内容写入全新的 staging。
- 拒绝符号链接、设备/管道等特殊文件和加密条目。
- T-302 的 `core/application/install.InstallService` 只接收已验签、严格解析并按目标过滤后的 Catalog `Entry` + architecture 与 `.download` 候选路径。它必须确认 entry/package 与 `App.Packages[architecture]` 精确对应;不得从 T-301 task、`DownloadCompleted` payload 或本地 metadata 取得 app/version/size/hash。外层 Catalog Ed25519 签名覆盖嵌套 package 的 `size`、`sha256` 与 `signature` 文本;当前协议未定义 package `signature` 的独立待签名字节/公钥域,客户端不得臆造第二套包级验签。
- `Extractor.ExtractVerifiedFile` 必须接收该 Catalog package 的 `size`、`sha256` 与 app identity expectation。它以一次 `Lstat → open → fstat → Lstat` 取得普通 `.download` 文件,并在**同一打开句柄**上先精确核对 `expectedPackageSize`、再计算/常量时间比较 SHA-256;任一失败时不得构造 ZIP reader、读取 app.json 或创建 staging。T-301 的 known-total 完成文件已经以 Catalog size 限长,但 T-302 仍须执行上述重新对账,不能信任可篡改的下载 metadata。
- 构造 `zip.Reader` 前只读取文件尾部至多 65,557 字节以定位 EOCD,并按需读取固定的 ZIP64 locator/EOCD 记录;校验单磁盘、中央目录 offset/size/entries 的边界及 entries/中央目录大小硬上限。当前默认上限为:原始包 4 GiB、中央目录 64 MiB、10,000 个条目、总展开 4 GiB、单条及总体压缩比 200:1。大小不一致、原始包超限、中央目录超限、声明条目超限分别保留 `ErrArchiveSizeMismatch`、`ErrArchiveTooLarge`、`ErrCentralDirectoryTooLarge`、`ErrTooManyEntries` 错误链;格式、截断、跨盘或不一致 ZIP64 归入 `ErrInvalidArchive`。
- SHA-256 通过后,预扫描继续使用**同一文件句柄**和已核对的长度创建 `zip.NewReader`;完整中央目录/路径/entrypoint/类型/CRC/展开量预检仍是第二道防线。合法 ZIP64 被支持,不因 32 位 EOCD 哨兵值误拒绝。T-302 按真实包体分布复核上述暂定限额后再冻结。
- entrypoint 使用 payload 内相对路径表示,不得自带 `payload/` 前缀,且必须精确对应 ZIP 中的普通文件。
- 所有输出路径在创建 staging 前完成规划,并在逐段名称校验后再次验证 native `filepath.Join` 结果仍位于 destination 内;包含性检查是纵深防御,不能替代 Windows 名称规则。
- `app.json` 必须是 ZIP 根目录唯一普通文件,读取上限 1 MiB;严格 JSON 解析拒绝未知字段和尾随值,完整 v1 字段/常量必须通过运行时校验。`id`、`version`、`channel`、`min_os`、`architecture`、`entrypoint`、`requires_admin` 必须与可信 Catalog selection 一致;只有这一比对成功后才可创建 staging 并提取 payload。
- T-615 将 ZIP 输入与 staging 输出错误分开:ZIP entry 的 open/read/CRC/close 失败保留为 `ErrArchiveCorrupt`;创建目录/文件、write、sync、close 与 staging-tree sync 失败保留 `ErrStagingOutput` 和底层错误链,不能误报为 ZIP 损坏。提取失败后的本次 staging 清理必须可观察;主失败和清理失败同时发生时,两者都可由 `errors.Is` 识别。
### 2.4 安装记录 installed-app.json(本地)
记录实际安装的软件 ID、版本、架构、channel、已验证的启动元数据和文件清单;与 `current/`、`staging/`、`backup/` 同级存放于 `apps/<id>/`。
```json
{
"schema_version": 1,
"id": "json-parser",
"version": "1.4.2",
"architecture": "amd64",
"channel": "stable",
"entrypoint": "JsonParser.exe",
"working_directory": ".",
"min_os": "windows-7-sp1",
"requires_admin": false,
"files": [
{
"path": "JsonParser.exe",
"size": 3456789,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
]
}
```
规则:
- Schema 位于 `schemas/installed-app.schema.json`;未知字段、非法 SemVer、ID/目录不匹配、非 `386|amd64` 架构、非 stable channel、共享 Windows 安全相对路径以外的文件名、大小写折叠重复路径和非法 SHA-256 均拒绝。
- `files` 必须是数组;v1 可以为空。T-302 从实际已验证、CRC/长度检查后写入 staging 的每个 payload 文件计算路径、size 和 SHA-256 并写入完整清单;可选 `files.json` 不是新的信任根,仍保留给后续修复功能。
- T-401 新安装必须写入 `entrypoint`、`working_directory`、`min_os`、`requires_admin`,其值来自已与可信 Catalog 精确比对的 `app.json`。前 3 个字段沿用共享安全相对路径/已知系统版本规则(working directory 额外允许 `.`);entrypoint 必须也是 `files` 内的普通文件。为保持 v1 可读,历史记录可缺少这些可选字段,但启动用例不得猜测默认 EXE、工作目录或最低系统,必须 fail closed。
- 写入使用 app 目录内临时文件 + `installed-app.json.backup` 原子替换;主文件缺失时可读取中断遗留 backup,但所有读取都重新严格校验。
- SemVer 比较遵循 2.0.0:major/minor/patch 与 prerelease 参与 precedence,build metadata 不影响更新判断。
### 2.4.1 本地可见状态推导
状态事实由后台/存储层预先收集,UI Layout 不扫描磁盘。单一可见状态按以下优先级推导:
1. 存在 `install-transaction.json` 或其 backup → `rollback_pending`。
2. 存在活跃任务状态 → `queued|downloading|verifying|extracting|installing|failed`。
3. 已检测到进程运行 → `running`。
4. Catalog 判定不可兼容 → `incompatible`。
5. 无 installed-app.json → `not_installed`。
6. 本地 SemVer 低于 Catalog → `update_available`;否则 → `installed`。Catalog 中已隐藏/下架且无可比较版本时,保留 `installed`。
### 2.5 安装切换事务(Phase 1 原型)
`apps/<id>/install-transaction.json` 用于断电恢复:
```json
{
"schema_version": 1,
"phase": "staging_activated",
"had_current": true
}
```
phase 只允许:`prepared`、`current_backed_up`、`staging_activated`、`rollback_required`、`committed`。日志使用临时文件 + 同目录 backup 原子替换;恢复时同时检查日志与 current/staging/backup 实际状态。未完成健康检查的 current 不视为可信:有旧版时恢复 backup,首次安装则撤销 current。
T-613 为该原型建立了 fail-closed 的耐久顺序:每个 payload 先完成 CRC/长度检查、`Sync`、`Close`;staging 目录按子目录→staging 根→父目录同步。每次 journal 写入均为临时文件内容 `Sync`/`Close` 后 rename,并在 transaction/backup rename、删除后同步 app root。`current → backup`、`staging → current`、rollback/recovery rename 和受控目录删除也必须先同步 app root,才可写下一 phase 或触发测试步骤;只有 `committed` journal 完成该栅栏后才清理 backup 和 journal。非 Windows 打开目录后 `File.Sync`;Windows 以 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 打开读写目录句柄并调用 `FlushFileBuffers`,任一打开、flush 或 close 失败均返回安装耐久错误,不得静默降级。
T-302 在 Switcher 的 health 阶段先运行必需的注入 health check,再原子写入新 `installed-app.json`;health 或记录写失败都必须触发既有 rollback,使旧 current/记录保持可用。只有 health 与记录均成功后才写 committed 并清理 backup/journal。
若无 transaction 但存在残留 `staging/`,Recover 只会在 app root 已通过真实目录布局校验后删除该受控同级目录并返回 `aborted`;`staging` 不是目录或布局不安全时 fail closed。此恢复不得覆盖 `current/`、`installed-app.json`、`data/` 或 `licenses/`。
这些栅栏与注入失败测试只证明代码层面的调用顺序和 fail-closed 行为,不证明断电后硬件/驱动缓存、网络文件系统、文件锁或杀毒软件的物理表现。T-302 已完成代码整合;T-601 仍须在目标 Windows VM/真机执行断电与干扰故障注入。
### 2.6 安装预检与失败码
在同句柄 size/SHA、ZIP 预扫描和严格 `app.json` 身份比对均通过后,提取器会在创建 `staging/` 前提供已规划 payload 的准确展开字节数、普通文件数和安全 entrypoint。安装 use case 必须以 `payload_bytes + 64 MiB` 查询 app root 所在卷的可用空间;可用空间不足时不创建 staging。预检不能替代写入、同步、切换或回滚阶段的 fail-closed I/O 错误处理。
安装 use case 同时在上述位置检查当前 `current/<entrypoint>` 是否正在运行。容量、storage failure classifier 与运行状态均通过 core 接口注入,且均为必需依赖;classifier 只对已带 `ErrStagingOutput` 的原始 I/O 链识别磁盘满,core 不导入 Windows API。检查失败按不可安全继续处理。T-401 又在存在旧 `current` 的更新中、`current → backup` rename 紧邻前复查同一目标;明确运行中清理本次 staging 并返回 `app_running`,检测错误返回 `target_state_unavailable`,其他 switch/rename 错误仍为原有安装失败归因。首次安装不做第二次检查。v1 不强杀、不等待进程退出。
安装结果面向调用方的错误码为下表的稳定英文枚举;UI 负责本地化,原始错误只保留给 `errors.Is`、日志和诊断,不得进入 UI payload。
| code | 含义 |
| --- | --- |
| `hash_mismatch` | 完成文件长度或 SHA-256 与已验签 Catalog selection 不符 |
| `zip_path_escape` | ZIP/app manifest 路径违反共享 Windows 安全相对路径规则 |
| `zip_corrupt` | ZIP 结构、CRC 或受限读取/提取不完整,不能作为有效包 |
| `package_invalid` | package/app manifest 身份或协议不符合可信 selection |
| `disk_full` | 可用空间小于 `payload_bytes + 64 MiB`,或平台 classifier 识别 staging write/sync/close 的底层 I/O 为磁盘满 |
| `disk_check_failed` | 无法可靠取得可用空间或得到非法容量值 |
| `app_running` | 当前目标程序仍在运行,更新不能替换 |
| `target_state_unavailable` | 无法可靠取得目标运行状态 |
| `install_failed` | 其他未细分的安装、健康、记录、切换、回滚或非磁盘满 staging 输出 I/O 失败 |
上述任一预检或验证失败都发生在 switch 前;已有版本的 `current` 和 `installed-app.json` 必须保持可用,首次安装不得留下 executable `current`。
### 2.6.1 受控启动与失败码
`core/application/launch` 的请求仅接受 app ID。它从本地记录取得启动元数据,确认 `current` 与工作目录是真实受控目录、entrypoint 是 `current` 内列入 `files` 的普通非 symlink 文件,再依次检查最低系统、授权和该精确 entrypoint 的运行状态,最后调用平台启动器。请求或 UI 不得提供 EXE、路径、参数、URL 或 shell command;授权 checker 是必需注入边界,许可证策略仍由 T-501~T-503 实现。
Windows 平台用 Toolhelp32 快照枚举,并以 `QueryFullProcessImageName` 的规范绝对路径作最终身份匹配;同名 EXE 只可作为查询优化,不能成为运行结论。快照/枚举/候选路径查询失败必须返回错误,不能当作“不在运行”。启动器只接收已验证的绝对 entrypoint 和工作目录:普通启动不经命令 shell 或 PATH 搜索;`requires_admin` 使用固定 `runas` Windows 动词且不传参数。非 Windows stub 对兼容、进程检测和启动均返回明确不支持错误。
| code | 含义 |
| --- | --- |
| `not_installed` | 没有可读取的安装记录 |
| `launch_metadata_invalid` | 历史记录缺少完整启动元数据,或元数据不安全/不一致 |
| `launch_target_unsafe` | `current`、工作目录或 entrypoint 布局不是受控真实对象 |
| `entrypoint_missing` | 记录中的 entrypoint 文件不存在 |
| `compatibility_unavailable` / `app_incompatible` | 无法可靠判定系统,或系统不满足最低版本 |
| `authorization_unavailable` / `not_authorized` | 授权边界不可用,或当前 app 未获授权 |
| `app_running` / `target_state_unavailable` | 精确 entrypoint 已运行,或不能可靠判断其状态 |
| `launch_failed` | 平台拒绝或未能创建受控进程 |
### 2.7 下载任务元数据 download-task.json(本地)
每个任务以稳定 `request_id` 为主键,元数据位于 `downloads/tasks/<request_id>.json`,字节文件位于 `downloads/files/<request_id>.part|.download`。本地路径只由客户端从 request_id 派生,不接受 URL 或 Content-Disposition 提供的文件名。
```json
{
"schema_version": 1,
"request_id": "download-json-parser-001",
"app_id": "json-parser",
"url": "https://download.example.com/json-parser.zip",
"status": "paused",
"attempt": 2,
"done": 1048576,
"total_known": true,
"total": 12345678,
"validator": {
"etag": "\"release-1.4.2\""
},
"error_code": "",
"created_at": "2026-07-16T00:00:00Z"
}
```
规则:
- Schema 为 `schemas/download-task.schema.json`;内部状态只允许 queued/downloading/paused/failed/completed。paused 对 UI 映射为 queued + DownloadPaused 事件,不增加第 13 个 AppStatus。
- 默认最多 2 个 downloading;同一 request_id 幂等,身份/URL/size 不同则冲突;同一 app 同时只允许一个非 completed 任务。
- 暂停保留 `.part` 和元数据;失败 retry 保留 `.part`;取消固定删除该 request_id 精确派生的 part/final/metadata,不使用递归删除。取消已线性化后,body close/sync 等非致命错误只记录日志,不能把取消反转成 failed 或跳过清理。
- 续传只在存在 strong ETag 或合法 Last-Modified 时发送 Range + If-Range;无可靠 validator 时从 0 重下。请求固定 `Accept-Encoding: identity`,所有 redirect 重新校验 HTTPS。
- `206` 必须严格匹配 Content-Range 起点、长度、total 与期望 size;已有 part 但服务端返回 `200` 时截断并创建新 attempt,不能 old+new 拼接。
- known total 使用已验签 Catalog package.size 作为精确传输长度;unknown total 使用显式 `total_known: false` 并受 4 GiB 默认硬上限保护。
- known total 的 `.part` 已达到精确 total 时,同进程 resume/retry 与重启恢复都直接 sync、核对身份并 finalize,不得再请求 `Range: bytes=<total>-`。
- 完成顺序为 part sync/close → 核对实际写入文件身份 → rename `.download` → metadata completed → DownloadCompleted。rename 前后必须仍是同一普通文件;恢复时以普通文件实际长度对账,不信任 metadata.done。
- 事件投递是 best-effort,失败不得反向把已完成/已取消的持久任务改成失败。队列必须把投递错误交给 `OnObserverError` 记录;application/UI 在启动或事件通道重连后必须用 `Queue.Tasks()` 对账持久状态,不能只依赖某一次终态事件。
- `.download` 仍是**不可信隔离字节**。T-302 必须重新从已验签/过滤 Catalog 取得并交叉核对 app/version/arch/size/SHA-256(及已由 Catalog 解析器校验格式、被外层签名覆盖的 package signature 文本),在同一普通文件句柄完成 size+SHA-256 后才可读取 app.json 或解压。
## 3. 许可证(服务端签发 → 本地离线验证)
```json
{
"schema_version": 1,
"license_id": "lic-...",
"machine_hash": "...",
"products": ["product-json-parser"],
"issued_at": "2026-07-16T00:00:00Z",
"perpetual": true,
"update_policy": "updates-until-2027-12-31",
"rebind_policy": "self-service-1-per-90d",
"signature": "..."
}
```
- machine_hash 由平台层多个稳定硬件标识清洗生成;许可证中**不保存**原始序列号和 MAC。
- 客户端用内置 Ed25519 公钥离线验签;许可证保存于 `licenses/`,与程序文件、用户配置分离;更新不得覆盖。
- 撤销名单同样签名并缓存,网络失败保留宽限期。
- 盒子负责导入/展示/管理;**子软件必须用 sdk 的 licensing 逻辑独立再验证**(签名 + machine_hash + product_id),决定正式版/试用版/授权错误。
## 4. application 事件合约(core → UI)
后台任务不直接修改 Gio 控件,只发布事件;UI 按 `request_id` + `app_id` 更新 ViewModel 并 `Invalidate`。
| 事件 | 触发时机 | 负载 | 结果 |
| --- | --- | --- | --- |
| CatalogRefreshed | 清单验签并缓存成功 | catalog 摘要、generated_at | 列表刷新 |
| CatalogRejected | 清单验签失败 | 原因码 | 提示 + 继续用缓存 |
| DownloadStarted | 一个下载 attempt 开始/因安全重下而重置 | request_id, app_id, attempt, done, total_known, total | 状态 → downloading |
| DownloadProgress | 当前 attempt 进度更新 | request_id, app_id, attempt, done, total_known, total, speed | 进度条刷新;done 在同 attempt 内单调 |
| DownloadPaused | 用户暂停且旧 worker 已完全退出 | request_id, app_id, attempt, done | 状态 → queued(暂停态) |
| DownloadCompleted | 字节完整落盘,尚未通过 SHA/签名 | request_id, app_id, attempt, done, path | 状态 → verifying;T-302 接管信任校验 |
| DownloadFailed | 传输/Range/存储失败 | request_id, app_id, attempt, done, error_code | 状态 → failed + 可重试 |
| DownloadCanceled | 取消清理完成且旧 attempt 不再发事件 | request_id, app_id, attempt | 从任务视图移除并回到本地基础状态 |
| InstallCompleted | 原子切换成功 + 健康检查通过 | app_id, version | 状态 → installed |
| InstallRolledBack | 切换失败恢复 backup | app_id, error_code | 状态 → rollback 完成提示 |
| AppStarted / AppExited | 进程启动/退出检测 | app_id, pid | 状态 → running / installed |
| LicenseChanged | 许可证导入/撤销 | products | 授权视图刷新 |
| IconReady | 图标已完成可信加载与后台解码 | request_id, app_id, icon_ref, DPI, image.Image | 有界 relay 唤醒窗口;UI Frame 验证仍为最新请求后创建 ImageOp |
| IconFailed | 图标加载、校验或解码失败(取消不发布) | request_id, app_id, icon_ref, DPI, error_code | UI 记录诊断;仅保留同 icon_ref/DPI 的既有可信图标,否则继续占位 |
错误码为稳定英文枚举(如 `hash_mismatch`, `zip_path_escape`, `disk_full`, `app_running`, `signature_invalid`),UI 负责本地化文案。图标事件只使用 `unavailable`、`invalid_content`、`unsafe_cache`,原始网络错误只返回后台调用方/日志,不得进入 UI payload。`unsafe_cache` 详情只使用 event 中已经验证的 app ID、icon_ref 与 DPI 生成 `<digest>-<dpi>.icon` locator;不得把 cache root、原始错误或 link target 补进 event/UI。
## 5. CLI 参数合约
### 5.1 SoftBox.exe
```bash
SoftBox.exe # 正常启动
SoftBox.exe --open-app <id> # 打开并定位到指定软件
SoftBox.exe --update-app <id> # 触发指定软件更新流程
SoftBox.exe --repair-app <id> # 按 files.json 修复安装(V1.1)
```
子软件的「检查更新」按钮调用以上参数,不自建下载器;盒子未运行时子软件可启动它。
### 5.2 SoftBoxUpdater.exe(盒子自更新助手)
```bash
SoftBoxUpdater.exe --pid <主程序PID> --staging <暂存目录> --target <目标目录>
```
等待主进程退出 → 备份旧版 → 切换新版 → 启动新版 SoftBox → 失败时恢复备份。退出码:0 成功;非 0 失败并写日志。
### 5.3 子软件推荐参数(v1 推荐,非强制)
```bash
<App>.exe --softbox-info # 输出软件 ID、版本、架构、协议版本 JSON,退出码 0
<App>.exe --softbox-health # 基本环境自检,成功退出码 0
```
产品自定义参数不得占用 `--softbox-` 前缀。
## 6. 进程退出协议(更新前)
v1:进程快照判断运行 → 提示用户保存关闭 → 等待正常退出 → 超时取消更新,**不默认强杀**。
V1.1:命名管道 `\\.\pipe\softbox.<app-id>`,盒子发送 `{"command": "prepare_update", "request_id": "..."}`,子软件保存数据回复 `ready` 后自行退出。
### 6.1 子软件更新编排 v1
`core/application/update` 只接受外层已经从验证/过滤 Catalog 取得的 `install.InstallRequest`,并再次要求 package 与 architecture 精确匹配。它先读取受控本地 `installed-app.json` + `current`,要求旧记录有完整且安全的启动元数据,并且 Catalog 目标版本严格高于本地 SemVer;不得由 UI 或下载事件提供 EXE、路径、参数、URL、版本或 hash。
若旧 entrypoint 未运行,编排直接委托 `InstallService`;若运行,则顺序固定为:后台取得关闭确认 → 按配置的 1 秒至 10 分钟上限等待**自然退出** → 委托 `InstallService`。等待器仅轮询 T-401 的完整 entrypoint 身份,支持 context 取消;不能调用 kill/TerminateProcess、shell、脚本或 IPC。等待完成后安装器仍在 staging 前和 `current → backup` 紧邻前复查运行状态,因此用户重新启动旧版本会安全返回既有 `app_running`,不覆盖 current。
| code | 含义 |
| --- | --- |
| `not_installed` | 没有受控安装记录 |
| `update_metadata_invalid` / `update_target_unsafe` | 历史记录缺启动元数据,或旧 entrypoint/current 布局不安全 |
| `update_not_available` | Catalog 版本与本地相同或更低 |
| `target_state_unavailable` | 无法可靠判定旧 entrypoint 是否运行 |
| `close_confirmation_unavailable` / `close_declined` | 无法取得关闭确认,或用户拒绝关闭 |
| `exit_wait_canceled` / `exit_wait_timeout` / `exit_wait_unavailable` | 等待被取消、达到上限,或进程快照等待不可用 |
| `install_failed` | 已委托安装器但安装失败;原始 `InstallError` 链仍保留其 stage/code |
更新编排自身不写 `current`、`backup`、transaction、`data/` 或 `licenses/`。production cmd 目前没有可信 Catalog、许可证和 completed-download 消费来源,也没有关闭确认 UI,因此尚未装配该用例;不得用 allow-all 授权或测试 fake 宣称更新闭环已经启用。
## 待实现时确认
- 清单密钥 ID/轮换字段定稿后同步 `schemas/`。
- 错误码完整枚举表。
- 图标资源从内容哈希到下载位置/分辨率变体的发布端映射格式。
- 撤销名单的结构与宽限期时长。
- machine_hash 的标识来源清单与加权算法(平台层内部文档)。