Files
soft_quay/docs/04-architecture.md
T

224 lines
19 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.
# 架构设计
> 本文讲"怎么把技术栈搭起来":系统结构、职责划分、数据模型、技术难点、开发顺序。
> 具体用了哪些框架 / 库 / 工具链,见 [技术栈](03-tech-stack.md);清单、软件包、许可证等协议字段见 [协议合约](api.md)。
## 一、系统结构
```text
用户
|
v
SoftBox.exe(Gio 桌面客户端,现代版 / Win7 遗留版)
|
├─> core 业务核心(清单、下载、安装、更新、授权、存储)
| |
| ├─> HTTPS:签名 Catalog 清单 + ZIP 软件包(对象存储 / CDN)
| └─> 本地文件:SoftBoxData(apps / data / licenses / cache / downloads / staging / backups / logs)
|
├─> platform/windows(进程、版本、图标、机器指纹、托盘、目录选择)
|
└─> SoftBoxUpdater.exe(盒子自更新助手)
```
真实组件:
- 客户端 UI:Gio,入口 `app-modern/cmd/softbox` 与 `app-win7/cmd/softbox`。
- 业务核心:`core/`(独立 go.mod,Go 1.20 兼容,无 UI、无 Windows 强依赖)。
- 外部服务:仅静态的签名清单与软件包下载(HTTPS);无自建在线 API、无账号服务。
- 存储:JSON 文件 + 原子替换;无数据库。
## 二、分层与职责划分
```text
domain 纯模型、状态和规则
↓
application 用例、任务调度、事件总线
↓
infrastructure 网络、存储、清单、下载、安装、授权
↓
platform/windows 进程、版本、图标、机器信息、更新助手
↓
ui/gio Gio 控件、布局和交互
↓
cmd/softbox 依赖装配和程序入口
```
硬约束:
- domain 不依赖 Gio、SQLite 或 Windows API。
- application 只依赖接口(下载、存储、时间、进程、系统信息均注入)。
- infrastructure 不操作 UI。
- platform/windows 通过接口暴露能力,并提供非 Windows stub,保证 core 可在 CI 无头测试;仅 Win10 存在的 API 必须动态加载、失败降级,不进 EXE 导入表。
- ui/gio 只提交用例并消费事件;Layout 中不读磁盘、不访问网络、不算哈希。
- cmd 负责现代版和 Win7 版的具体装配。
UI 固定交互模式:
1. 每帧先 drain 点击事件 → 2. 向 application runtime 提交任务 → 3. 保存 RequestID 与软件 ID → 4. 后台 goroutine 执行 → 5. runtime 发布 application.Event → 6. UI 的 ApplyEvent 更新 ViewModel → 7. `Window.Invalidate` 请求重绘。
控件状态按**软件 ID**保存,不按列表序号;列表用惰性 `layout.List`;图标走内存 + 磁盘缓存。
T-203 已把共享列表状态落在 `core/application.CatalogListModel`:源快照、搜索、单分类、all/installed/updates 视图和 selected app ID 都是无 IO 纯内存状态。两个 Gio 适配分别保存 Editor、`layout.List` 与以 app ID 为键的 Clickable;主循环或后台用例通过 `SetItems` 替换准备好的快照,Layout 不扫描 installed-app.json、不获取 Catalog。T-608 在两个隔离 workspace 以同场景交互契约验证 Editor/Clickable 经 `Layout`/`drainInput` 更新共享 model、重排后行点击仍按 AppID、关闭详情保留筛选与列表位置、500 项只布局 `layout.List.Position.Count` 所示可见子集,并通过语义树区分空 Catalog 与过滤无结果;不重复 ViewModel 纯逻辑。T-609 让每次实际 refilter 在局部新 backing array 完整构造后发布 `VisibleItems` generation,旧 generation 可安全保留到后续帧且每帧读取不复制;返回值严格只读,model 仍由单 owner goroutine 串行操作,不承诺并发安全。
T-610 在两个隔离 `ui/gio` package 内采用相同文件职责:`shell.go` 只保存 AppShell 状态/生命周期和根编排,`shell_header.go` 保存 header/navigation,`shell_catalog.go` 保存 content/list/row/icon/empty state,`shell_detail.go` 保存详情,`shell_style.go` 保存主题与绘制 helper。该拆分没有增加 package/API/状态边界,modern 与 Win7 的 Gio 版本特有布局继续分别实现;适配器交互契约仍负责证明两端事件接线和可见行为一致。
T-204/T-606/T-607 图标链路为 `Catalog icon digest + DPI → 32 MiB/256-key memory LRU → verified disk → 流式 IconFetcher(maxBytes+1) → SHA-256/图片资源限制校验 → 原子磁盘缓存 → 后台 DecodeIcon → IconReady/IconFailed application event → bounded FIFO relay + Window.Invalidate → Frame/UI ApplyEvent → ApplyIcon(paint.ImageOp)`。同一 key 由一个 in-flight leader 去重,不同 key 的磁盘/网络工作并行;全局锁只保护 memory/LRU/in-flight 元数据。relay 队列满时无损背压且可由 context/close 取消,后台从不修改 shell map。UI 只接受当前 app 最新且 icon_ref/DPI 匹配的 request_id;删除 app、替换 IconRef 或取消会使迟到结果失效,替换 IconRef 同时清除旧 ImageOp。磁盘与远端都重新校验,断网只使用已验证磁盘缓存;详情右栏只读取 `CatalogListModel.SelectedItem` 与内存 ImageOp,关闭详情不清空筛选或列表位置。
T-611 固定该链路的 fail-closed 诊断:磁盘 entry 为 symlink/非普通文件时 `IconCache` 不读取、不删除且不 fetch,`IconEventDelivery` 只发布安全分类 `unsafe_cache`,同时把包含 `ErrIconCacheUnsafe` 的错误留给后台调用方。双端 shell 在 UI owner goroutine 保存完整失败 identity + code,只对当前选中软件显示 code/app ID/`<digest>-<dpi>.icon` locator 与人工恢复提示;生命周期与最新请求、reference/DPI、ready、取消和 app 删除绑定。正式安装目标 root 为 `%LOCALAPPDATA%\OwnSoftBox\cache\icons\`,但当前生产 `cmd` 尚未装配 IconCache/Delivery/Fetcher,constructor root 才是代码事实;人工步骤见 [故障排查](troubleshooting.md)。
## 三、仓库目录结构
```text
soft_quay/
├─ core/ # 共享业务核心(go.mod,Go 1.20 兼容)
│ ├─ domain/
│ ├─ application/
│ ├─ catalog/ # 清单获取、验签、缓存、过滤
│ ├─ downloader/ # 下载队列、断点续传、任务持久化
│ ├─ internal/safepath/ # 跨 catalog/installer/storage 的 Windows 安全相对路径规则
│ ├─ installer/ # ZIP 校验、安全解压、staging/backup/回滚
│ ├─ licensing/ # Ed25519 许可证验证、machine_hash
│ ├─ storage/ # JSON 原子读写、目录布局
│ └─ updater/ # 盒子自更新编排
├─ app-modern/ # 现代版(go.mod,Go 1.25 + Gio v0.10.1)
│ ├─ cmd/softbox/
│ ├─ ui/gio/ # shell 根编排 + header/catalog/detail/style 职责文件
│ └─ platform/windows/
├─ app-win7/ # Win7 遗留版(go.mod,Go 1.20 + Gio v0.6.0)
│ ├─ cmd/softbox/
│ ├─ ui/gio/ # 同名职责文件,保留 Legacy Gio 实现差异
│ └─ platform/windows/
├─ schemas/ # manifest / app.json / files.json / license JSON Schema
├─ scripts/ # 构建、打包、签名、校验脚本
├─ testdata/ # 测试用清单、ZIP、许可证样例(全部为假数据)
├─ docs/ # 本文档集
└─ go.work
```
依赖方向:`app-modern` → `core` ← `app-win7`,不得反向。
## 四、数据模型
### 4.1 远端只读数据(签名后消费)
| 数据 | 来源 | 说明 |
| --- | --- | --- |
| Catalog 清单 | `manifest-modern.json` / `manifest-win7.json`(HTTPS) | 软件列表、版本、packages(url/size/sha256)、Ed25519 签名;字段见 [api.md](api.md) |
| ZIP 软件包 | 清单中的 `packages[arch].url` | 内含 `app.json` + `files.json` + `payload/`;协议见 [api.md](api.md) |
| 撤销名单 | 签名的 revocation 列表 | 网络失败时使用缓存并保留宽限期 |
关键事实:
- 软件主键是永久稳定的 `id`(小写英文/数字/短横线),不用名称;下架用 `status`,不用名称前缀。
- 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。
- Catalog 正式加载顺序为 HTTPS 获取 → 验签 → 严格字段/Schema/channel 校验 → 缓存替换 → 目标过滤;签名正确但结构或目标通道不匹配的远端内容不能挤掉最后可消费缓存。
- 清单解析拒绝未知字段、重复字段/软件 ID、尾随 JSON、`-0`/非整数数字、非法 Unicode surrogate、非唯一 Base64 signature、非 HTTPS URL 与 architectures/packages 映射不一致;签名域为只移除顶层 `signature` 的受限规范 JSON。对象键 Unicode 排序、字符串 JSON 转义、原始大整数 token 和静态跨实现向量是同一协议的一部分,细节见 [api.md](api.md)。
- Catalog `entry_exe` 与后续 app.json/files.json 路径必须复用 `core/internal/safepath`,不能由各协议解析器分别维护 Windows 路径规则。
- channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。
- `category` 提供稳定单分类,`tags` 用于搜索和多标签展示;hidden 项从远端目录隐藏,deprecated 与不兼容项保留可见原因但不提供安装包操作。
### 4.2 本地动态数据(JSON + 原子写入)
安装模式根目录 `%LOCALAPPDATA%/OwnSoftBox/`(便携模式为程序目录 `.softbox/`,V2):
```text
├─ app/ # 盒子自身程序
├─ apps/<id>/ # current/ staging/ backup/ installed-app.json
├─ data/<id>/ # 子软件用户数据(更新永不覆盖)
├─ licenses/ # 许可证(更新永不覆盖)
├─ cache/ # 清单缓存
│ └─ icons/ # 正式装配的目标图标缓存根;当前以 NewIconCache(root, ...) 实参为事实
├─ downloads/ # 下载临时文件 + 任务元数据
├─ staging/ # 盒子自更新暂存
├─ backups/ # 盒子自更新备份
└─ logs/
```
软件本地状态机(domain 层枚举):
`not_installed → queued → downloading → verifying → extracting → installing → installed`,
另有 `update_available`、`running`、`failed`、`rollback_pending`、`incompatible`。
每次状态写入使用临时文件 + 原子替换;崩溃后可识别 staging、backup 和未完成事务。
T-202 将本地识别拆为两层:
- `core/storage`:严格读写 `installed-app.json` v1,读取主文件或中断遗留 backup,并只读检测安装 transaction/journal 是否存在;`files[].path` 与 ZIP/Catalog 共用 Windows 安全相对路径规则。
- `core/domain`:纯 SemVer 2.0.0 比较与 `ResolveAppStatus`,按“恢复事务 → 活跃操作 → 运行 → 不兼容 → 是否安装 → 是否有更新”推导单一状态。
磁盘扫描结果必须在进入 Gio Layout 前准备好;UI 不直接读取 installed-app.json。完整字段见 [api.md](api.md),Schema 为 `schemas/installed-app.schema.json`。
T-301 下载队列:
- `core/downloader.Queue` 默认并发 2,用 request_id 主键和 app 非终态唯一约束串行化 enqueue/pause/resume/cancel/retry;网络/磁盘 IO 与事件发布都在全局锁外。
- `.part` / `.download` / metadata 路径只由受限 request_id 派生。元数据通过 `core/storage.DownloadTaskStore` 临时文件 + backup 原子替换。
- Range 必须绑定 strong ETag 或 Last-Modified 并发送 If-Range;无 validator 从 0 重下,`200` 忽略 Range 时创建新 attempt。进度只在 request_id+attempt 内单调。
- pause/cancel/close 先取消 generation context,等待 body 关闭、part sync/close 和旧 worker 完全退出,再发布终态/释放并发槽,阻断迟到 progress。
- cancel 一旦先于 completion 取得线性化点,即使 body close/sync 报错也记录后继续精确清理;若 completion 先完成,后续 cancel 明确拒绝。known-total 完整 part 在同进程 resume/retry 与启动恢复中都直接 finalize,不发送 offset==total 的 Range。
- 写入句柄的文件身份贯穿 sync/close 与 rename 前后核对,防止活跃 `.part` 路径被替换后发布错误文件。崩溃恢复对账 metadata、part、final 三份事实:完整 part 可 finalize,已 rename 的 final 可补 completed metadata;缺 final 的 completed、part+final、超出 expected/unknown 上限均 fail closed。
- 事件投递失败通过 `OnObserverError` 显式报告,不改变 durable transfer 结果;application/UI 启动或重连后用 `Queue.Tasks()` 对账终态,避免 DownloadCompleted 等一次性通知丢失后永久停链。
- DownloadCompleted 仅证明传输字节完整落盘。下载元数据和 `.download` 可被本地篡改,T-302 的 `core/application/install.InstallService` 不得把它们当信任根,而是接收已验签/过滤 Catalog `Entry` + architecture,确认其与 `App.Packages[architecture]` 精确对应。它将该 Catalog size/SHA-256 交给 `Extractor.ExtractVerifiedFile`,后者以同一打开的普通完成文件先 size、再 SHA-256、再 ZIP 预扫描/解析;package `signature` 仅由外层 Catalog 签名覆盖,当前没有独立包级验签域。
### 4.3 事件模型
后台任务只发布事件(`DownloadStarted / DownloadProgress / DownloadPaused / DownloadCompleted / DownloadFailed` 等),UI 按 RequestID 和软件 ID 回填,见 [api.md](api.md) 事件合约。
## 五、关键安全流程
安装/更新一款软件的强制顺序:
```text
读取已签名/过滤 Catalog → 选择并交叉核对 OS/架构匹配的 Package → 下载到 downloads
→ 以同一普通完成文件核对实际长度 = Catalog size → 以同句柄校验 SHA-256
→ 有界 EOCD/ZIP64/中央目录预扫描 → 用同句柄构造 ZIP reader → 有界严格读取根 app.json
→ 比对 ID/版本/通道/系统/架构/入口/管理员标记 → 检查 ZIP 路径与解压上限 → 解压 payload 到 staging 并记录实际文件 hash → 校验 entry_exe
→ 恢复旧 transaction(如有)→ current 改名 backup → staging 原子切换为 current
→ 必需 health check → 原子写 installed-app.json → 成功延迟清理 backup / 任一失败恢复 backup
```
必须防止:绝对路径、`../` 与 Windows dot-space 归一化穿越、首尾空格/尾随句点路径别名、DOS 设备名、符号链接逃逸、写入其他软件目录、覆盖 data 与 licenses、运行中强替换 EXE、未验证包被执行、解压数量/体积/压缩比无上限、包内自动执行脚本。
Phase 1 ZIP 原型采用“两阶段解压”:第 0 阶段在任何 `zip.Reader` 构造前,从同一普通文件句柄核对 `expectedPackageSize` 与实际长度,并只读有界 EOCD 尾部及固定 ZIP64 end 记录以限制原始包、中央目录和声明条目数;第 1 阶段才由标准库解析完整中央目录,继续预检协议顶层、共享 Windows 安全路径、类型、重复项、entrypoint 与展开资源上限,再规划并确认所有 native 输出路径仍在 destination 内。全部通过后才创建新的 staging 并只写 `payload/`;任一复制/CRC 失败删除本次 staging。T-302 把这一原型封装为同句柄 `size → SHA-256 → scan → app.json → extract` 的生产安装链:app.json 读取有 1 MiB 上限并严格对齐可信 Catalog,实际写入文件 hash 进入 installed-app record;原型默认限制见 [api.md](api.md),真实包分布复核仍是本任务验收的一部分。T-303 在严格验证和 extraction 之间加入唯一的 pre-extract 边界:提取器只输出已规划 payload 的 bytes/files 与安全 entrypoint,`core/application/install` 注入容量/目标状态 checker 并在创建 staging 前要求 `payload bytes + 64 MiB` 可用空间及目标未运行;checker 故障 fail closed。T-615 进一步将 ZIP entry 输入的 open/read/CRC/close 与 staging 输出的创建/write/sync/close 分开:后者保留底层错误链,由必需的、平台注入的 `StorageFailureClassifier` 仅对 `ErrStagingOutput` 判断 `disk_full`,其他输出 I/O 稳定为 `install_failed`;清理失败不吞掉,下一次 Recover 仅在已验证 app layout 内删除残留 staging。core 不包含 Windows API、进程枚举、等待、强杀或启动,具体 Toolhelp 适配与进程退出协议由 T-401 在 `platform/windows`/命令装配时实现。
Phase 1 原子切换原型把 `install-transaction.json` 与目录现实共同作为恢复依据。阶段写入顺序为 `prepared → current_backed_up → staging_activated → committed`,健康失败写 `rollback_required`;崩溃恢复不自动信任未健康检查的新 current,而是恢复旧 backup 或撤销首次安装。日志结构见 [api.md](api.md)。
T-613 已把该状态机的代码层耐久顺序收敛为:payload 的 CRC/长度检查后 `Sync`/`Close` → staging 子目录到根及其父目录同步 → prepared journal 的临时文件 `Sync`/`Close` 与 root 栅栏 → 每次目录 rename 的 root 栅栏与下一 phase journal → committed journal 的 root 栅栏 → backup/journal 清理的 root 栅栏。所有栅栏通过 installer 内部接口复用;非 Windows 使用目录 `File.Sync`,Windows 用 Win7 已有的 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` 读写目录句柄和 `FlushFileBuffers`,失败一律 fail closed。测试可注入失败并验证状态机保留可恢复 journal,但这仍不是物理掉电证明。
真实断电时的硬件/驱动缓存、杀毒软件/文件锁干扰和目标文件系统行为仍需 T-601 在 Windows VM/真机做故障注入;T-302 的代码级链路与单元测试不替代硬件级断电验证。
盒子自更新由独立 `SoftBoxUpdater.exe` 完成(传入 PID、暂存目录、目标目录;等待退出→备份→切换→启动新版→失败恢复)。
授权:平台层采集多个稳定硬件标识 → 清洗生成 machine_hash(不保存原始序列号/MAC)→ 服务端 Ed25519 私钥签发许可证 → 客户端内置公钥离线验签;许可证与程序文件、用户配置分开保存;子软件必须独立再次验证,不能只信盒子。
## 六、关键技术难点
| 难点 | 说明 | 应对 |
| --- | --- | --- |
| 清单签名与缓存回退 | 验签、缓存、拒绝逻辑错了等于远程代码执行 | Phase 1 先做最小原型 + 恶意样例测试(testdata 伪造清单) |
| ZIP 安全解压 | 路径穿越/符号链接/zip bomb | 独立模块 + 攻击样例表驱动测试 |
| 原子切换与回滚 | Windows 文件锁、断电恢复 | staging/backup 状态机 + 崩溃恢复测试;更新前确认进程退出 |
| 双 Gio 版本 API 差异 | v0.6.0 与 v0.10.1 控件 API 不同 | 共享 ViewModel 与交互语义,Gio 适配层各自实现 |
| Win7 API 兼容 | 新 API 进导入表则 Win7 无法启动 | 动态加载 + 降级;Toolhelp32/GetFileVersionInfo 等 Win7 已有 API 优先 |
| 断点续传与任务恢复 | 重启后恢复未完成任务 | 任务元数据持久化 + HTTP Range;超时指数退避 |
| 机器指纹稳定性 | 硬件变动导致正版误判 | 多标识加权 + 容错 + 换绑申诉流程 |
高风险模块先做最小原型(Phase 1),不要等整个系统搭完才验证。
## 七、推荐开发顺序
1. 工程骨架:core / app-modern / app-win7 + go.work + 空 Gio 窗口 + 平台 stub + CI 双目标编译。
2. 最高风险原型:签名清单验签与缓存回退、ZIP 安全解压、staging/backup 原子切换。
3. 核心用户流程:清单 → 列表/搜索 → 下载队列 → 安装 → 启动。
4. 更新与自更新:子软件更新、SoftBoxUpdater、健康检查与回滚。
5. 授权:机器指纹、许可证验证、试用/换绑/导入。
6. Win7 加固与发布:API 降级、真机矩阵、双通道发布流水线。
## 八、架构纪律
- 业务事实和协议变化必须同步更新本文与 [api.md](api.md)。
- 不在代码里发明文档没有的接口、字段和状态。
- 清单缓存、用户数据、许可证、下载临时文件分目录存放,不混在一个模型里。
- 高风险模块先单独验证,再接入完整页面或流程。
- core 的 Go 1.20 兼容性由 CI 用 Go 1.20 工具链编译检查,不靠口头约定。