mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:15:14 +08:00
@@ -19,6 +19,8 @@
|
|||||||
- 官方同步正式入口统一为 `bat`,新增 `--daemon` 后台模式和基于 `bat.sock` 的 Unix socket JSON-RPC 控制面,管理命令覆盖 `status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor`、`clean-stable`
|
- 官方同步正式入口统一为 `bat`,新增 `--daemon` 后台模式和基于 `bat.sock` 的 Unix socket JSON-RPC 控制面,管理命令覆盖 `status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor`、`clean-stable`
|
||||||
- 新增 daemon 控制锁 `bat-control.lock`、daemon 与前台同资源目录写入互斥,以及失效/损坏 PID 和锁文件恢复逻辑
|
- 新增 daemon 控制锁 `bat-control.lock`、daemon 与前台同资源目录写入互斥,以及失效/损坏 PID 和锁文件恢复逻辑
|
||||||
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlink,daemon 状态文件默认使用 `0600` 权限
|
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlink,daemon 状态文件默认使用 `0600` 权限
|
||||||
|
- 新增官方资源原子发布布局:非 dry-run 下载先进入 `.staging/<id>`,校验和 manifest/snapshot 写入完成后发布到 `versions/<id>`,再原子切换 `current` symlink
|
||||||
|
- 新增 daemon 可观测性:`bat-events.jsonl` 结构化 JSONL 日志、日志轮转、status 中的当前下载进度、最后成功时间、下次检查时间和最后错误摘要
|
||||||
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
|
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
|
||||||
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
|
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
|
||||||
- 新增 Addressables 当前真实形态 fixture/golden 测试
|
- 新增 Addressables 当前真实形态 fixture/golden 测试
|
||||||
|
|||||||
+5
-4
@@ -17,13 +17,14 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
1. 首次运行可以通过 `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 `app-version`、`connection-group` 和 `server-info`。
|
1. 首次运行可以通过 `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 `app-version`、`connection-group` 和 `server-info`。
|
||||||
2. 不安装、不启动、不依赖已安装官方启动器。
|
2. 不安装、不启动、不依赖已安装官方启动器。
|
||||||
3. 默认平台为 `Windows + Android`。
|
3. 默认平台为 `Windows + Android`。
|
||||||
4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json`。
|
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
||||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。
|
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。
|
||||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。
|
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。
|
||||||
7. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner 和 progress log,需要机器输出时使用 `--json --no-progress`。
|
7. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner 和 progress log,需要机器输出时使用 `--json --no-progress`。
|
||||||
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||||
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;该目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||||
10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||||
|
11. `bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、结构化日志路径和轮转日志路径。
|
||||||
|
|
||||||
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
|
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
2. 不要指向已有游戏客户端目录。
|
2. 不要指向已有游戏客户端目录。
|
||||||
3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。
|
3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。
|
||||||
4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。
|
4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。
|
||||||
5. 推荐生产形态是 systemd 直接托管前台 `bat --watch`,unit 模板位于 `deployments/systemd/bluearchive-toolkit-official-sync.service`,稳定 binary 路径为 `/opt/bluearchive-toolkit/bin/bat`,资源目录为 `/var/lib/bluearchive-toolkit/official`,日志通过 `journalctl -u bluearchive-toolkit-official-sync.service` 查看;不使用 systemd 时也可用 `bat --daemon` 自托管,daemon 状态和 `bat-daemon.log` 建议放在 `/var/lib/bluearchive-toolkit/daemon-state`。定时检查逻辑已经在 Rust 内部,正常检查默认 1 小时,失败重试默认 60 秒,每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会强制执行一次自动刷新。
|
5. 推荐生产形态是 systemd 直接托管前台 `bat --watch`,unit 模板位于 `deployments/systemd/bluearchive-toolkit-official-sync.service`,稳定 binary 路径为 `/opt/bluearchive-toolkit/bin/bat`,资源发布根目录为 `/var/lib/bluearchive-toolkit/official`,生产读取方读取 `/var/lib/bluearchive-toolkit/official/current`,日志通过 `journalctl -u bluearchive-toolkit-official-sync.service` 查看;不使用 systemd 时也可用 `bat --daemon` 自托管,daemon 状态、`bat-daemon.log` 和 `bat-events.jsonl` 建议放在 `/var/lib/bluearchive-toolkit/daemon-state`。定时检查逻辑已经在 Rust 内部,正常检查默认 1 小时,失败重试默认 60 秒,每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会强制执行一次自动刷新。
|
||||||
|
|
||||||
详细运行说明见 `docs/guides/deployment.md` 和 `docs/guides/official-resource-test-pull.md`。
|
详细运行说明见 `docs/guides/deployment.md` 和 `docs/guides/official-resource-test-pull.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。
|
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。
|
||||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、官方资源 pull/update 服务。
|
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、官方资源 pull/update 服务。
|
||||||
- `bat`:官方资源自动发现、全量拉取、本地 manifest audit/repair、`.part` 断点续传、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。
|
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。
|
||||||
- `bat-ffi` 粗粒度 JSON 接口:Manifest inspect 和官方 sync plan。
|
- `bat-ffi` 粗粒度 JSON 接口:Manifest inspect 和官方 sync plan。
|
||||||
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--error-retry 60s
|
--error-retry 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期的 `bat-control.lock`;前三者用于快照和故障排查,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh` 等控制命令:
|
后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh` 等控制命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p bat-infrastructure --bin bat -- \
|
cargo run -p bat-infrastructure --bin bat -- \
|
||||||
@@ -96,6 +96,8 @@ cargo run -p bat-infrastructure --bin bat -- stop
|
|||||||
|
|
||||||
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||||
|
|
||||||
|
非 dry-run 同步不会把新文件直接写进生产可读目录。资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。
|
||||||
|
|
||||||
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,7 +109,7 @@ cargo run -p bat-infrastructure --bin bat -- doctor
|
|||||||
cargo run -p bat-infrastructure --bin bat -- clean-stable
|
cargo run -p bat-infrastructure --bin bat -- clean-stable
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify` 会以只读方式检查当前官方计划、本地 download manifest 的 size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;发现缺失、远端变化或本地损坏会返回非 0。`repair` 会在异常资源存在时复用当前同步链路重新下载必要文件。`clean-stable` 只清理 `.part`、临时状态文件、失效或损坏的 PID/锁/socket,不删除正式资源。
|
`verify` 会以只读方式检查当前官方计划、`current` 指向的 active release 中 download manifest 的 size+BLAKE3、ZIP 结构,以及本地已有官方 seed `.bytes/.hash` 对的 xxHash32;发现缺失、远端变化或本地损坏会返回非 0。`repair` 会在异常资源存在时复用当前同步链路重新下载必要文件。`clean-stable` 只清理 `.part`、临时状态文件、失效或损坏的 PID/锁/socket,不删除正式资源。
|
||||||
|
|
||||||
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载第 N/总数个 URL 等;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`。
|
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载第 N/总数个 URL 等;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`。
|
||||||
|
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ cas/
|
|||||||
```
|
```
|
||||||
官方 metadata → GameMainConfig → server-info → discovery endpoints
|
官方 metadata → GameMainConfig → server-info → discovery endpoints
|
||||||
↓
|
↓
|
||||||
seed catalog → inventory → pull plan → downloader → output directory
|
seed catalog → inventory → pull plan → staging → versions/<id>
|
||||||
↓
|
↓
|
||||||
official-sync-snapshot.json + official-download-manifest.json
|
current symlink → official-sync-snapshot.json + official-download-manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
**已具备特性**:
|
**已具备特性**:
|
||||||
@@ -118,7 +118,8 @@ official-sync-snapshot.json + official-download-manifest.json
|
|||||||
- `--auto-discover` 自动获取 `app-version`、`connection-group` 和 `server-info`。
|
- `--auto-discover` 自动获取 `app-version`、`connection-group` 和 `server-info`。
|
||||||
- `--watch` / `--daemon` 常驻检查,正常检查默认 1 小时,失败重试默认 60 秒,每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制刷新一次。
|
- `--watch` / `--daemon` 常驻检查,正常检查默认 1 小时,失败重试默认 60 秒,每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制刷新一次。
|
||||||
- `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash;`repair` 尝试修复异常资源。
|
- `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash;`repair` 尝试修复异常资源。
|
||||||
- `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control plane;PID、状态和日志文件是快照与 fallback。
|
- 非 dry-run 同步先写 `.staging/<id>`,校验完成后发布 `versions/<id>` 并原子切换 `current` symlink。
|
||||||
|
- `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control plane;PID、状态和日志文件是快照与 fallback,`bat-events.jsonl` 是结构化轮转日志。
|
||||||
- `status`、`logs`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
|
- `status`、`logs`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
|
||||||
- 远端 marker 无变化且本地 manifest clean 时不下载。
|
- 远端 marker 无变化且本地 manifest clean 时不下载。
|
||||||
- 本地文件损坏时 repair。
|
- 本地文件损坏时 repair。
|
||||||
|
|||||||
@@ -120,15 +120,15 @@
|
|||||||
工作方式:
|
工作方式:
|
||||||
|
|
||||||
1. 检查 URL 是否属于官方 host。
|
1. 检查 URL 是否属于官方 host。
|
||||||
2. 把 URL 映射到本地输出路径。
|
2. 把 URL 映射到当前工作根目录下的本地输出路径;在自动更新服务中,读侧根目录是 `current` 指向的 versioned 目录,写侧根目录是 staging。
|
||||||
3. 读取输出目录下的 `official-download-manifest.json`。
|
3. 读取工作根目录下的 `official-download-manifest.json`。
|
||||||
4. 已存在目标文件只有在本地清单中的相对路径、size、BLAKE3 都匹配时才跳过;`.zip` 文件还必须通过 ZIP central directory / local header 结构校验。
|
4. 已存在目标文件只有在本地清单中的相对路径、size、BLAKE3 都匹配时才跳过;`.zip` 文件还必须通过 ZIP central directory / local header 结构校验。
|
||||||
5. 缺少清单、清单不匹配、ZIP 结构无效或文件损坏时重新下载。
|
5. 缺少清单、清单不匹配、ZIP 结构无效或文件损坏时重新下载。
|
||||||
6. `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` 总是刷新并用官方 `.hash` 强校验;该 `.hash` 是 `xxHash32(seed=0)` 的十进制文本。
|
6. `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` 总是刷新并用官方 `.hash` 强校验;该 `.hash` 是 `xxHash32(seed=0)` 的十进制文本。
|
||||||
7. `catalog_*.hash` 当前只作为 Addressables catalog 变更标记,不作为 zip/JSON 内容校验算法;Unity Addressables/SBP builder 对 JSON/bin catalog 使用 `HashingMethods.Calculate` 生成 `Hash128` 文本,运行时用它判断 remote catalog cache 是否过期,它不能套用 seed catalog 的 `xxHash32` 规则。
|
7. `catalog_*.hash` 当前只作为 Addressables catalog 变更标记,不作为 zip/JSON 内容校验算法;Unity Addressables/SBP builder 对 JSON/bin catalog 使用 `HashingMethods.Calculate` 生成 `Hash128` 文本,运行时用它判断 remote catalog cache 是否过期,它不能套用 seed catalog 的 `xxHash32` 规则。
|
||||||
8. 官方 seed `.hash` 校验失败会让当前下载失败,并移除对应 data/hash URL 的本地 manifest 条目,避免失败产物在下一轮被本地 BLAKE3 audit 误判为健康缓存。
|
8. 官方 seed `.hash` 校验失败会让当前下载失败,并移除对应 data/hash URL 的本地 manifest 条目,避免失败产物在下一轮被本地 BLAKE3 audit 误判为健康缓存。
|
||||||
9. 存在 `.part` 临时文件时通过 `curl --continue-at -` 尝试断点续传。
|
9. 存在 `.part` 临时文件时通过 `curl --continue-at -` 尝试断点续传。
|
||||||
10. 新下载写入 `.part`,成功并通过必要校验后原子 rename 到最终路径;断点续传后的 `.zip` 如果结构无效,会删除 `.part` 并重新全量下载。
|
10. 新下载写入 `.part`,成功并通过必要校验后原子 rename 到 staging 内最终路径;断点续传后的 `.zip` 如果结构无效,会删除 `.part` 并重新全量下载。
|
||||||
11. 成功下载后更新本地下载清单。
|
11. 成功下载后更新本地下载清单。
|
||||||
12. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。
|
12. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。
|
||||||
13. 非官方 URL 直接拒绝。
|
13. 非官方 URL 直接拒绝。
|
||||||
@@ -190,10 +190,12 @@
|
|||||||
6. 每轮都会基于最新 seed catalog 构建当前 pull plan,并检查输出目录是否已有当前 plan 的 manifest 条目或目标文件。
|
6. 每轮都会基于最新 seed catalog 构建当前 pull plan,并检查输出目录是否已有当前 plan 的 manifest 条目或目标文件。
|
||||||
7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。
|
7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。
|
||||||
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
||||||
9. 远端变化、本地 audit 发现 repair_needed,或首次空目录运行时,下载并校验官方 URL。
|
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
||||||
10. 下载成功后写回新的 snapshot。
|
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件。
|
||||||
|
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。
|
||||||
|
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
||||||
|
|
||||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output` 和 `--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||||
|
|
||||||
对应实现主要在:
|
对应实现主要在:
|
||||||
|
|
||||||
@@ -231,7 +233,7 @@ Linux 生产路径:
|
|||||||
- pull plan 会同时包含 discovery URLs 和 content URLs
|
- pull plan 会同时包含 discovery URLs 和 content URLs
|
||||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改
|
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度和调度状态
|
||||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||||
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
|
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
|
||||||
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
||||||
|
|||||||
+17
-11
@@ -173,11 +173,13 @@ sudo install -d -o bat -g bat -m 0750 \
|
|||||||
|
|
||||||
`/var/lib/bluearchive-toolkit/official/` 会保存:
|
`/var/lib/bluearchive-toolkit/official/` 会保存:
|
||||||
|
|
||||||
- `official-sync-snapshot.json`
|
- `current`:指向当前可读 release 的 symlink
|
||||||
|
- `versions/<id>/official-sync-snapshot.json`
|
||||||
|
- `versions/<id>/official-download-manifest.json`
|
||||||
|
- `versions/<id>/...` 下载得到的官方资源文件
|
||||||
|
- `.staging/<id>`:下载和校验中的临时 release
|
||||||
- `official-bootstrap-cache.json`
|
- `official-bootstrap-cache.json`
|
||||||
- `official-download-manifest.json`
|
|
||||||
- `.official-sync.lock`
|
- `.official-sync.lock`
|
||||||
- 下载得到的官方资源文件
|
|
||||||
|
|
||||||
不要把输出目录设为:
|
不要把输出目录设为:
|
||||||
|
|
||||||
@@ -228,11 +230,11 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor \
|
|||||||
--state-dir /run/bluearchive-toolkit
|
--state-dir /run/bluearchive-toolkit
|
||||||
```
|
```
|
||||||
|
|
||||||
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查,并且每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会强制执行一次自动刷新。固定时间刷新会中断普通 interval 的 sleep,该轮注入 `force=true`;如果失败,会按失败重试周期继续重试。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出人类可读摘要。下载、发现或校验失败时默认 60 秒后重试,可显式加 `BAT_ERROR_RETRY=60s` 或调整 service `ExecStart`。默认平台是 `Windows,Android`,无需显式传 `--platforms`;需要覆盖时用 systemd drop-in 重写 `ExecStart`。默认资源目录是 `./bat-resources`,生产 service 显式使用 `/var/lib/bluearchive-toolkit/official`。
|
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查,并且每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会强制执行一次自动刷新。固定时间刷新会中断普通 interval 的 sleep,该轮注入 `force=true`;如果失败,会按失败重试周期继续重试。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出人类可读摘要。下载、发现或校验失败时默认 60 秒后重试,可显式加 `BAT_ERROR_RETRY=60s` 或调整 service `ExecStart`。默认平台是 `Windows,Android`,无需显式传 `--platforms`;需要覆盖时用 systemd drop-in 重写 `ExecStart`。默认资源目录是 `./bat-resources`,生产 service 显式使用 `/var/lib/bluearchive-toolkit/official`。生产读取方应读取 `/var/lib/bluearchive-toolkit/official/current`;同步中的文件只会进入 `.staging/<id>`,校验完成后才发布为 `versions/<id>` 并切换 `current`。
|
||||||
|
|
||||||
### 可选模式:CLI 自托管 `--daemon`
|
### 可选模式:CLI 自托管 `--daemon`
|
||||||
|
|
||||||
不使用 systemd 时,可以直接后台运行。生产中不要使用默认 `/tmp/bat-pid`,建议使用持久状态目录,这样 `bat-daemon.log` 不会随 reboot 或 tmp 清理丢失:
|
不使用 systemd 时,可以直接后台运行。生产中不要使用默认 `/tmp/bat-pid`,建议使用持久状态目录,这样 `bat-daemon.log` 和 `bat-events.jsonl` 不会随 reboot 或 tmp 清理丢失:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat \
|
sudo -u bat /opt/bluearchive-toolkit/bin/bat \
|
||||||
@@ -248,7 +250,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc
|
|||||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state
|
sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state
|
||||||
```
|
```
|
||||||
|
|
||||||
`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。
|
`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。
|
||||||
|
|
||||||
不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。
|
不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。
|
||||||
|
|
||||||
@@ -257,14 +259,18 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchi
|
|||||||
systemd 模式:
|
systemd 模式:
|
||||||
|
|
||||||
- 进程日志:`journalctl -u bluearchive-toolkit-official-sync.service`
|
- 进程日志:`journalctl -u bluearchive-toolkit-official-sync.service`
|
||||||
- 资源状态:`/var/lib/bluearchive-toolkit/official/official-sync-snapshot.json`
|
- 当前可读 release:`/var/lib/bluearchive-toolkit/official/current`
|
||||||
- 下载 manifest:`/var/lib/bluearchive-toolkit/official/official-download-manifest.json`
|
- 资源状态:`/var/lib/bluearchive-toolkit/official/current/official-sync-snapshot.json`
|
||||||
|
- 下载 manifest:`/var/lib/bluearchive-toolkit/official/current/official-download-manifest.json`
|
||||||
|
- 历史 release:`/var/lib/bluearchive-toolkit/official/versions/<id>`
|
||||||
|
- 同步 staging:`/var/lib/bluearchive-toolkit/official/.staging/<id>`
|
||||||
- 资源写锁:`/var/lib/bluearchive-toolkit/official/.official-sync.lock`
|
- 资源写锁:`/var/lib/bluearchive-toolkit/official/.official-sync.lock`
|
||||||
- 运行期目录:`/run/bluearchive-toolkit/`
|
- 运行期目录:`/run/bluearchive-toolkit/`
|
||||||
|
|
||||||
standalone `--daemon` 模式:
|
standalone `--daemon` 模式:
|
||||||
|
|
||||||
- 进程日志:`<state-dir>/bat-daemon.log`
|
- 进程日志:`<state-dir>/bat-daemon.log`
|
||||||
|
- 结构化事件日志:`<state-dir>/bat-events.jsonl`
|
||||||
- RPC socket:`<state-dir>/bat.sock`
|
- RPC socket:`<state-dir>/bat.sock`
|
||||||
- PID 文件:`<state-dir>/bat.pid`
|
- PID 文件:`<state-dir>/bat.pid`
|
||||||
- daemon 状态:`<state-dir>/bat-status.json`
|
- daemon 状态:`<state-dir>/bat-status.json`
|
||||||
@@ -327,14 +333,14 @@ sudo systemctl start bluearchive-toolkit-official-sync.service
|
|||||||
journalctl -u bluearchive-toolkit-official-sync.service -n 100 --no-pager
|
journalctl -u bluearchive-toolkit-official-sync.service -n 100 --no-pager
|
||||||
```
|
```
|
||||||
|
|
||||||
当前官方资源同步的本地状态位于 `/var/lib/bluearchive-toolkit/official`。普通 binary 回滚不需要回滚资源目录;如果要测试可能改动 manifest/schema 的版本,先备份资源状态文件:
|
当前官方资源同步的本地状态位于 `/var/lib/bluearchive-toolkit/official`,生产读取入口是 `/var/lib/bluearchive-toolkit/official/current`。普通 binary 回滚不需要回滚资源目录;如果要测试可能改动 manifest/schema 的版本,先备份当前 release 状态文件和 bootstrap cache:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
|
sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
|
||||||
-czf /var/lib/bluearchive-toolkit/official-state-backup.tgz \
|
-czf /var/lib/bluearchive-toolkit/official-state-backup.tgz \
|
||||||
official-sync-snapshot.json \
|
|
||||||
official-bootstrap-cache.json \
|
official-bootstrap-cache.json \
|
||||||
official-download-manifest.json
|
current/official-sync-snapshot.json \
|
||||||
|
current/official-download-manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
如果业务层需要热更新、热重载或发布新资源,应该由上层服务在观察到 `--json` report 或 snapshot 变化后决定。Rust 同步进程只负责拉取、校验和维护本地状态。
|
如果业务层需要热更新、热重载或发布新资源,应该由上层服务在观察到 `--json` report 或 snapshot 变化后决定。Rust 同步进程只负责拉取、校验和维护本地状态。
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ target/release/bat \
|
|||||||
--watch
|
--watch
|
||||||
```
|
```
|
||||||
|
|
||||||
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh` 等控制命令,其他文件是快照和故障排查用。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。资源输出目录是发布根目录:非 dry-run 同步先写 `<output>/.staging/<id>`,校验完成后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
||||||
|
|
||||||
同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ Linux 生产运行时链路只走官方日服 HTTP 资源,不安装、不启
|
|||||||
2. 请求官方 `server-info`。
|
2. 请求官方 `server-info`。
|
||||||
3. 生成 Windows + Android 的官方资源 discovery 端点。
|
3. 生成 Windows + Android 的官方资源 discovery 端点。
|
||||||
4. 拉取 seed catalog,生成完整官方 pull plan。
|
4. 拉取 seed catalog,生成完整官方 pull plan。
|
||||||
5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL。
|
5. dry-run 只输出 URL;非 dry-run 下载全部官方 URL 到 staging,验收完成后原子发布到 `current`。
|
||||||
|
|
||||||
`--auto-discover` 会下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。
|
`--auto-discover` 会下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装官方启动器,也不会执行官方启动器进程。`--launcher-bootstrap` 只是旧命名兼容别名,新流程不要再推荐使用。
|
||||||
|
|
||||||
@@ -137,13 +137,13 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
|
|
||||||
- 只接受官方 URL
|
- 只接受官方 URL
|
||||||
- 下载完整官方资源集合
|
- 下载完整官方资源集合
|
||||||
- 下载成功后在输出目录维护 `official-download-manifest.json`
|
- 下载成功后在 release 目录维护 `official-download-manifest.json`
|
||||||
- 已存在目标文件只有在本地下载清单中的 size 和 BLAKE3 校验通过时才会跳过;`.zip` 文件还必须通过 ZIP central directory / local header 结构校验
|
- 已存在目标文件只有在本地下载清单中的 size 和 BLAKE3 校验通过时才会跳过;`.zip` 文件还必须通过 ZIP central directory / local header 结构校验
|
||||||
- `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` 会用官方 `.hash` 做强校验;该 `.hash` 是 `xxHash32(seed=0)` 的十进制文本
|
- `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` 会用官方 `.hash` 做强校验;该 `.hash` 是 `xxHash32(seed=0)` 的十进制文本
|
||||||
- `catalog_*.hash` 是 Unity Addressables/SBP 写出的 catalog 变更标记,来源是 `HashingMethods.Calculate(json/bin catalog)` 生成的 `Hash128` 文本;它不是 seed catalog 的 `xxHash32` 规则,目前不作为 zip/JSON 内容强校验
|
- `catalog_*.hash` 是 Unity Addressables/SBP 写出的 catalog 变更标记,来源是 `HashingMethods.Calculate(json/bin catalog)` 生成的 `Hash128` 文本;它不是 seed catalog 的 `xxHash32` 规则,目前不作为 zip/JSON 内容强校验
|
||||||
- 存在 `.part` 临时文件时会尝试断点续传
|
- 存在 `.part` 临时文件时会尝试断点续传
|
||||||
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
|
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
|
||||||
- 把结果写入 `--output`
|
- 把结果发布到 `--output/current`
|
||||||
|
|
||||||
## 5. 自动更新检查
|
## 5. 自动更新检查
|
||||||
|
|
||||||
@@ -151,23 +151,23 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
|
|
||||||
- 每轮都会先拉取轻量官方 metadata 和 `.hash` marker。
|
- 每轮都会先拉取轻量官方 metadata 和 `.hash` marker。
|
||||||
- URL 没变但 `.hash` / marker 内容变更时,也会判定为需要更新。
|
- URL 没变但 `.hash` / marker 内容变更时,也会判定为需要更新。
|
||||||
- 即使远端 snapshot 未变化,只要输出目录没有任何当前 pull plan 的本地 manifest 条目或目标文件,也会按首次运行处理并执行全量拉取。
|
- 即使远端 snapshot 未变化,只要 active release 没有任何当前 pull plan 的本地 manifest 条目或目标文件,也会按首次运行处理并执行全量拉取。
|
||||||
- 远端无变化时,默认执行本地 `official-download-manifest.json` audit。
|
- 远端无变化时,默认执行 active release 中的 `official-download-manifest.json` audit。
|
||||||
- 本地文件缺失、路径不一致、size 不一致、BLAKE3 不一致或 ZIP 结构无效时,默认进入 repair 并重下必要文件。
|
- 本地文件缺失、路径不一致、size 不一致、BLAKE3 不一致或 ZIP 结构无效时,默认进入 repair 并重下必要文件。
|
||||||
- 官方 seed `.hash` 校验失败会让本轮失败,并清理对应本地 manifest 条目;下一轮会继续把这类文件视为需要 repair,而不是把失败产物当作健康缓存复用。
|
- 官方 seed `.hash` 校验失败会让本轮失败,并清理对应本地 manifest 条目;下一轮会继续把这类文件视为需要 repair,而不是把失败产物当作健康缓存复用。
|
||||||
- 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。
|
- 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。
|
||||||
- 有远端变化或本地 repair:生成 pull plan,下载完整官方资源,成功后更新 snapshot。
|
- 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`。
|
||||||
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
||||||
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
||||||
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
||||||
- 校验报告分层输出 `official_seed_hash_verified_count`、`local_manifest_verified_count`、`addressables_marker_checked_count`、`unverified_marker_count`。
|
- 校验报告分层输出 `official_seed_hash_verified_count`、`local_manifest_verified_count`、`addressables_marker_checked_count`、`unverified_marker_count`。
|
||||||
- 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。
|
- 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。
|
||||||
|
|
||||||
资源同步状态文件默认位于 `--output` 下:
|
资源同步状态文件默认分布如下:
|
||||||
|
|
||||||
- `official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
- `<output>/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
||||||
- `official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||||
- `official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
||||||
|
|
||||||
先 dry-run:
|
先 dry-run:
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
|||||||
cargo run -p bat-infrastructure --bin bat -- stop
|
cargo run -p bat-infrastructure --bin bat -- stop
|
||||||
```
|
```
|
||||||
|
|
||||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、文本日志路径、结构化日志路径和轮转日志路径。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||||
|
|
||||||
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`:
|
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`:
|
||||||
|
|
||||||
@@ -258,9 +258,9 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--error-retry 60s
|
--error-retry 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、server-info、marker、catalog、audit、download 和 snapshot 阶段;命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||||
|
|
||||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||||
|
|
||||||
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
open_append_file, read_file_no_symlink, validate_output_root, validate_runtime_state_dir,
|
lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root,
|
||||||
write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress,
|
validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||||
OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, OfficialVerificationSummary,
|
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
||||||
PRIVATE_FILE_MODE,
|
OfficialVerificationSummary, PRIVATE_FILE_MODE,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -27,9 +27,13 @@ const DEFAULT_DAEMON_STATE_DIR: &str = "/tmp/bat-pid";
|
|||||||
const DAEMON_PID_FILE: &str = "bat.pid";
|
const DAEMON_PID_FILE: &str = "bat.pid";
|
||||||
const DAEMON_STATUS_FILE: &str = "bat-status.json";
|
const DAEMON_STATUS_FILE: &str = "bat-status.json";
|
||||||
const DAEMON_LOG_FILE: &str = "bat-daemon.log";
|
const DAEMON_LOG_FILE: &str = "bat-daemon.log";
|
||||||
|
const DAEMON_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl";
|
||||||
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
||||||
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
|
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
|
||||||
const DAEMON_STATUS_VERSION: u32 = 1;
|
const DAEMON_STATUS_VERSION: u32 = 1;
|
||||||
|
const STRUCTURED_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||||
|
const STRUCTURED_LOG_ROTATE_KEEP: usize = 3;
|
||||||
|
const OFFICIAL_CURRENT_LINK: &str = "current";
|
||||||
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||||
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
|
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
|
||||||
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
|
const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60];
|
||||||
@@ -244,6 +248,9 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
let service = OfficialUpdateService::new();
|
let service = OfficialUpdateService::new();
|
||||||
let mut logger = ProgressLogger::new(options.progress);
|
let mut logger = ProgressLogger::new(options.progress);
|
||||||
let daemon_state_dir = options.state_dir.clone();
|
let daemon_state_dir = options.state_dir.clone();
|
||||||
|
if options.daemon_child {
|
||||||
|
logger.attach_structured_log(daemon_structured_log_path(&daemon_state_dir));
|
||||||
|
}
|
||||||
let daemon_control = if options.daemon_child {
|
let daemon_control = if options.daemon_child {
|
||||||
Some(new_daemon_control())
|
Some(new_daemon_control())
|
||||||
} else {
|
} else {
|
||||||
@@ -315,13 +322,23 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
last_update_status: None,
|
last_update_status: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_retry_seconds: None,
|
next_retry_seconds: None,
|
||||||
|
last_success_unix_seconds: None,
|
||||||
|
next_check_unix_seconds: None,
|
||||||
pending_scheduled_force,
|
pending_scheduled_force,
|
||||||
next_forced_refresh_at,
|
next_forced_refresh_at,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
match service.run_with_progress_and_cancellation(
|
match service.run_with_progress_and_cancellation(
|
||||||
&iteration_config,
|
&iteration_config,
|
||||||
|event| logger.log(event),
|
|event| {
|
||||||
|
record_daemon_progress(
|
||||||
|
options.daemon_child,
|
||||||
|
&daemon_state_dir,
|
||||||
|
&mut logger,
|
||||||
|
&event,
|
||||||
|
);
|
||||||
|
logger.log(event);
|
||||||
|
},
|
||||||
|| daemon_control_stop_requested(daemon_control.as_ref()),
|
|| daemon_control_stop_requested(daemon_control.as_ref()),
|
||||||
) {
|
) {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
@@ -342,6 +359,8 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
last_update_status: Some(report.update_status.as_str().to_string()),
|
last_update_status: Some(report.update_status.as_str().to_string()),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_retry_seconds: Some(sleep_for.as_secs()),
|
next_retry_seconds: Some(sleep_for.as_secs()),
|
||||||
|
last_success_unix_seconds: Some(unix_seconds_now()),
|
||||||
|
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
|
||||||
pending_scheduled_force,
|
pending_scheduled_force,
|
||||||
next_forced_refresh_at,
|
next_forced_refresh_at,
|
||||||
},
|
},
|
||||||
@@ -369,6 +388,8 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
last_update_status: None,
|
last_update_status: None,
|
||||||
last_error: Some(localize_error_message(&error.to_string())),
|
last_error: Some(localize_error_message(&error.to_string())),
|
||||||
next_retry_seconds: Some(sleep_for.as_secs()),
|
next_retry_seconds: Some(sleep_for.as_secs()),
|
||||||
|
last_success_unix_seconds: None,
|
||||||
|
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
|
||||||
pending_scheduled_force,
|
pending_scheduled_force,
|
||||||
next_forced_refresh_at,
|
next_forced_refresh_at,
|
||||||
},
|
},
|
||||||
@@ -431,6 +452,8 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
last_update_status: None,
|
last_update_status: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_retry_seconds: None,
|
next_retry_seconds: None,
|
||||||
|
last_success_unix_seconds: None,
|
||||||
|
next_check_unix_seconds: None,
|
||||||
pending_scheduled_force,
|
pending_scheduled_force,
|
||||||
next_forced_refresh_at,
|
next_forced_refresh_at,
|
||||||
},
|
},
|
||||||
@@ -450,22 +473,46 @@ struct DaemonStatusFile {
|
|||||||
resource_output_root: PathBuf,
|
resource_output_root: PathBuf,
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
log_path: PathBuf,
|
log_path: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
structured_log_path: Option<PathBuf>,
|
||||||
started_unix_seconds: u64,
|
started_unix_seconds: u64,
|
||||||
updated_unix_seconds: u64,
|
updated_unix_seconds: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
last_success_unix_seconds: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
next_check_unix_seconds: Option<u64>,
|
||||||
last_update_status: Option<String>,
|
last_update_status: Option<String>,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
next_retry_seconds: Option<u64>,
|
next_retry_seconds: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
current_stage: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
current_message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
download_progress: Option<DaemonDownloadProgress>,
|
||||||
pending_scheduled_force: bool,
|
pending_scheduled_force: bool,
|
||||||
next_forced_refresh_unix_seconds: Option<u64>,
|
next_forced_refresh_unix_seconds: Option<u64>,
|
||||||
command: Vec<String>,
|
command: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct DaemonDownloadProgress {
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
|
url: String,
|
||||||
|
status: Option<String>,
|
||||||
|
bytes: Option<u64>,
|
||||||
|
transferred_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct DaemonStatusUpdate<'a> {
|
struct DaemonStatusUpdate<'a> {
|
||||||
state: &'a str,
|
state: &'a str,
|
||||||
last_update_status: Option<String>,
|
last_update_status: Option<String>,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
next_retry_seconds: Option<u64>,
|
next_retry_seconds: Option<u64>,
|
||||||
|
last_success_unix_seconds: Option<u64>,
|
||||||
|
next_check_unix_seconds: Option<u64>,
|
||||||
pending_scheduled_force: bool,
|
pending_scheduled_force: bool,
|
||||||
next_forced_refresh_at: SystemTime,
|
next_forced_refresh_at: SystemTime,
|
||||||
}
|
}
|
||||||
@@ -557,6 +604,8 @@ fn record_daemon_status(
|
|||||||
update.last_update_status,
|
update.last_update_status,
|
||||||
update.last_error,
|
update.last_error,
|
||||||
update.next_retry_seconds,
|
update.next_retry_seconds,
|
||||||
|
update.last_success_unix_seconds,
|
||||||
|
update.next_check_unix_seconds,
|
||||||
update.pending_scheduled_force,
|
update.pending_scheduled_force,
|
||||||
update.next_forced_refresh_at,
|
update.next_forced_refresh_at,
|
||||||
) {
|
) {
|
||||||
@@ -564,6 +613,21 @@ fn record_daemon_status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_daemon_progress(
|
||||||
|
enabled: bool,
|
||||||
|
state_dir: &Path,
|
||||||
|
logger: &mut ProgressLogger,
|
||||||
|
event: &OfficialUpdateProgress,
|
||||||
|
) {
|
||||||
|
if !enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = update_daemon_progress(state_dir, event) {
|
||||||
|
logger.log_text("daemon", format!("更新后台进度失败:{error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct DaemonRpcServer {
|
struct DaemonRpcServer {
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
@@ -1216,6 +1280,7 @@ struct DaemonStartReport {
|
|||||||
pid_path: PathBuf,
|
pid_path: PathBuf,
|
||||||
status_path: PathBuf,
|
status_path: PathBuf,
|
||||||
log_path: PathBuf,
|
log_path: PathBuf,
|
||||||
|
structured_log_path: PathBuf,
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,12 +1299,19 @@ struct DaemonStatusReport {
|
|||||||
stale_pid_file: bool,
|
stale_pid_file: bool,
|
||||||
stale_socket: bool,
|
stale_socket: bool,
|
||||||
log_path: Option<PathBuf>,
|
log_path: Option<PathBuf>,
|
||||||
|
structured_log_path: Option<PathBuf>,
|
||||||
|
rotated_structured_log_paths: Vec<PathBuf>,
|
||||||
started_unix_seconds: Option<u64>,
|
started_unix_seconds: Option<u64>,
|
||||||
updated_unix_seconds: Option<u64>,
|
updated_unix_seconds: Option<u64>,
|
||||||
|
last_success_unix_seconds: Option<u64>,
|
||||||
|
next_check_unix_seconds: Option<u64>,
|
||||||
daemon_state: Option<String>,
|
daemon_state: Option<String>,
|
||||||
last_update_status: Option<String>,
|
last_update_status: Option<String>,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
next_retry_seconds: Option<u64>,
|
next_retry_seconds: Option<u64>,
|
||||||
|
current_stage: Option<String>,
|
||||||
|
current_message: Option<String>,
|
||||||
|
download_progress: Option<DaemonDownloadProgress>,
|
||||||
pending_scheduled_force: Option<bool>,
|
pending_scheduled_force: Option<bool>,
|
||||||
next_forced_refresh_unix_seconds: Option<u64>,
|
next_forced_refresh_unix_seconds: Option<u64>,
|
||||||
command: Option<Vec<String>>,
|
command: Option<Vec<String>>,
|
||||||
@@ -1289,6 +1361,7 @@ fn start_daemon_with_args(
|
|||||||
let pid_path = daemon_pid_path(&state_dir);
|
let pid_path = daemon_pid_path(&state_dir);
|
||||||
let status_path = daemon_status_path(&state_dir);
|
let status_path = daemon_status_path(&state_dir);
|
||||||
let log_path = daemon_log_path(&state_dir);
|
let log_path = daemon_log_path(&state_dir);
|
||||||
|
let structured_log_path = daemon_structured_log_path(&state_dir);
|
||||||
let socket_path = daemon_socket_path(&state_dir);
|
let socket_path = daemon_socket_path(&state_dir);
|
||||||
|
|
||||||
match classify_pid_lock_file(&pid_path)? {
|
match classify_pid_lock_file(&pid_path)? {
|
||||||
@@ -1339,11 +1412,17 @@ fn start_daemon_with_args(
|
|||||||
resource_output_root: resource_output_root.clone(),
|
resource_output_root: resource_output_root.clone(),
|
||||||
state_dir: state_dir.clone(),
|
state_dir: state_dir.clone(),
|
||||||
log_path: log_path.clone(),
|
log_path: log_path.clone(),
|
||||||
|
structured_log_path: Some(structured_log_path.clone()),
|
||||||
started_unix_seconds: unix_seconds_now(),
|
started_unix_seconds: unix_seconds_now(),
|
||||||
updated_unix_seconds: unix_seconds_now(),
|
updated_unix_seconds: unix_seconds_now(),
|
||||||
|
last_success_unix_seconds: None,
|
||||||
|
next_check_unix_seconds: None,
|
||||||
last_update_status: None,
|
last_update_status: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_retry_seconds: None,
|
next_retry_seconds: None,
|
||||||
|
current_stage: None,
|
||||||
|
current_message: None,
|
||||||
|
download_progress: None,
|
||||||
pending_scheduled_force: false,
|
pending_scheduled_force: false,
|
||||||
next_forced_refresh_unix_seconds: None,
|
next_forced_refresh_unix_seconds: None,
|
||||||
command,
|
command,
|
||||||
@@ -1359,6 +1438,7 @@ fn start_daemon_with_args(
|
|||||||
pid_path,
|
pid_path,
|
||||||
status_path,
|
status_path,
|
||||||
log_path,
|
log_path,
|
||||||
|
structured_log_path,
|
||||||
socket_path,
|
socket_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1504,6 +1584,12 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
|||||||
let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available;
|
let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available;
|
||||||
let default_log_path = daemon_log_path(state_dir);
|
let default_log_path = daemon_log_path(state_dir);
|
||||||
let default_log_exists = path_exists_no_follow(&default_log_path)?;
|
let default_log_exists = path_exists_no_follow(&default_log_path)?;
|
||||||
|
let structured_log_path = status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| status.structured_log_path.clone())
|
||||||
|
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
|
||||||
|
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
|
||||||
|
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
|
||||||
|
|
||||||
Ok(DaemonStatusReport {
|
Ok(DaemonStatusReport {
|
||||||
status: if running { "running" } else { "stopped" },
|
status: if running { "running" } else { "stopped" },
|
||||||
@@ -1530,12 +1616,20 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|status| status.log_path.clone())
|
.map(|status| status.log_path.clone())
|
||||||
.or_else(|| default_log_exists.then_some(default_log_path)),
|
.or_else(|| default_log_exists.then_some(default_log_path)),
|
||||||
|
structured_log_path: structured_log_exists.then_some(structured_log_path),
|
||||||
|
rotated_structured_log_paths,
|
||||||
started_unix_seconds: status_file
|
started_unix_seconds: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|status| status.started_unix_seconds),
|
.map(|status| status.started_unix_seconds),
|
||||||
updated_unix_seconds: status_file
|
updated_unix_seconds: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|status| status.updated_unix_seconds),
|
.map(|status| status.updated_unix_seconds),
|
||||||
|
last_success_unix_seconds: status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| status.last_success_unix_seconds),
|
||||||
|
next_check_unix_seconds: status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| status.next_check_unix_seconds),
|
||||||
daemon_state: status_file.as_ref().map(|status| status.state.clone()),
|
daemon_state: status_file.as_ref().map(|status| status.state.clone()),
|
||||||
last_update_status: status_file
|
last_update_status: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1546,6 +1640,15 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
|||||||
next_retry_seconds: status_file
|
next_retry_seconds: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|status| status.next_retry_seconds),
|
.and_then(|status| status.next_retry_seconds),
|
||||||
|
current_stage: status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| status.current_stage.clone()),
|
||||||
|
current_message: status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| status.current_message.clone()),
|
||||||
|
download_progress: status_file
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|status| status.download_progress.clone()),
|
||||||
pending_scheduled_force: status_file
|
pending_scheduled_force: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|status| status.pending_scheduled_force),
|
.map(|status| status.pending_scheduled_force),
|
||||||
@@ -1776,12 +1879,19 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
|||||||
print_json_field(value, "stale_pid_file", "失效 PID");
|
print_json_field(value, "stale_pid_file", "失效 PID");
|
||||||
print_json_field(value, "stale_socket", "失效 socket");
|
print_json_field(value, "stale_socket", "失效 socket");
|
||||||
print_json_field(value, "last_update_status", "上次同步");
|
print_json_field(value, "last_update_status", "上次同步");
|
||||||
|
print_json_field(value, "last_success_unix_seconds", "最后成功时间");
|
||||||
print_json_field(value, "last_error", "上次错误");
|
print_json_field(value, "last_error", "上次错误");
|
||||||
print_json_field(value, "next_retry_seconds", "下次重试秒数");
|
print_json_field(value, "next_retry_seconds", "下次重试秒数");
|
||||||
|
print_json_field(value, "next_check_unix_seconds", "下次检查时间");
|
||||||
|
print_json_field(value, "current_stage", "当前阶段");
|
||||||
|
print_json_field(value, "current_message", "当前消息");
|
||||||
|
print_json_field(value, "download_progress", "下载进度");
|
||||||
print_json_field(value, "resource_output_root", "资源目录");
|
print_json_field(value, "resource_output_root", "资源目录");
|
||||||
print_json_field(value, "state_dir", "状态目录");
|
print_json_field(value, "state_dir", "状态目录");
|
||||||
print_json_field(value, "socket_path", "socket");
|
print_json_field(value, "socket_path", "socket");
|
||||||
print_json_field(value, "log_path", "日志");
|
print_json_field(value, "log_path", "日志");
|
||||||
|
print_json_field(value, "structured_log_path", "结构化日志");
|
||||||
|
print_json_field(value, "rotated_structured_log_paths", "轮转日志");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
|
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
|
||||||
@@ -1868,6 +1978,14 @@ fn print_list(label: &str, values: &[String], limit: usize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
|
||||||
|
let status = progress.status.as_deref().unwrap_or("running");
|
||||||
|
format!(
|
||||||
|
"{}/{} {} {}",
|
||||||
|
progress.index, progress.total, status, progress.url
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn print_verification_summary(summary: &OfficialVerificationSummary) {
|
fn print_verification_summary(summary: &OfficialVerificationSummary) {
|
||||||
println!(" 校验摘要:");
|
println!(" 校验摘要:");
|
||||||
for line in verification_summary_lines(summary) {
|
for line in verification_summary_lines(summary) {
|
||||||
@@ -1946,6 +2064,11 @@ impl HumanReport for OfficialUpdateReport {
|
|||||||
print_field("本地审计", format_bool(self.audit_local));
|
print_field("本地审计", format_bool(self.audit_local));
|
||||||
print_field("自动修复", format_bool(self.repair));
|
print_field("自动修复", format_bool(self.repair));
|
||||||
print_field("dry-run", format_bool(self.dry_run));
|
print_field("dry-run", format_bool(self.dry_run));
|
||||||
|
print_path_field("输出根目录", &self.output_root);
|
||||||
|
print_path_field("active release", &self.active_resource_root);
|
||||||
|
print_path_field("current", &self.current_path);
|
||||||
|
print_optional_path_field("staging", self.staging_path.as_ref());
|
||||||
|
print_optional_path_field("published", self.published_version_path.as_ref());
|
||||||
print_path_field("snapshot", &self.snapshot_path);
|
print_path_field("snapshot", &self.snapshot_path);
|
||||||
print_path_field("manifest", &self.download_manifest);
|
print_path_field("manifest", &self.download_manifest);
|
||||||
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
|
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
|
||||||
@@ -1990,6 +2113,7 @@ impl HumanReport for DaemonStartReport {
|
|||||||
print_path_field("状态目录", &self.state_dir);
|
print_path_field("状态目录", &self.state_dir);
|
||||||
print_path_field("socket", &self.socket_path);
|
print_path_field("socket", &self.socket_path);
|
||||||
print_path_field("日志", &self.log_path);
|
print_path_field("日志", &self.log_path);
|
||||||
|
print_path_field("结构化日志", &self.structured_log_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2006,12 +2130,26 @@ impl HumanReport for DaemonStatusReport {
|
|||||||
print_field("失效 PID", format_bool(self.stale_pid_file));
|
print_field("失效 PID", format_bool(self.stale_pid_file));
|
||||||
print_field("失效 socket", format_bool(self.stale_socket));
|
print_field("失效 socket", format_bool(self.stale_socket));
|
||||||
print_optional_field("上次同步", self.last_update_status.as_deref());
|
print_optional_field("上次同步", self.last_update_status.as_deref());
|
||||||
|
print_optional_field("最后成功时间", self.last_success_unix_seconds);
|
||||||
print_optional_field("上次错误", self.last_error.as_deref());
|
print_optional_field("上次错误", self.last_error.as_deref());
|
||||||
print_optional_field("下次重试秒数", self.next_retry_seconds);
|
print_optional_field("下次重试秒数", self.next_retry_seconds);
|
||||||
|
print_optional_field("下次检查时间", self.next_check_unix_seconds);
|
||||||
|
print_optional_field("当前阶段", self.current_stage.as_deref());
|
||||||
|
print_optional_field("当前消息", self.current_message.as_deref());
|
||||||
|
if let Some(progress) = self.download_progress.as_ref() {
|
||||||
|
print_field("下载进度", format_daemon_download_progress(progress));
|
||||||
|
}
|
||||||
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
|
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
|
||||||
print_path_field("状态目录", &self.state_dir);
|
print_path_field("状态目录", &self.state_dir);
|
||||||
print_path_field("socket", &self.socket_path);
|
print_path_field("socket", &self.socket_path);
|
||||||
print_optional_path_field("日志", self.log_path.as_ref());
|
print_optional_path_field("日志", self.log_path.as_ref());
|
||||||
|
print_optional_path_field("结构化日志", self.structured_log_path.as_ref());
|
||||||
|
let rotated = self
|
||||||
|
.rotated_structured_log_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
print_list("轮转日志", &rotated, 5);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2051,6 +2189,7 @@ impl HumanReport for VerifyCommandReport {
|
|||||||
print_field("状态", self.status);
|
print_field("状态", self.status);
|
||||||
print_field("健康", format_bool(self.healthy));
|
print_field("健康", format_bool(self.healthy));
|
||||||
print_field("远端状态", &self.remote_update_status);
|
print_field("远端状态", &self.remote_update_status);
|
||||||
|
print_path_field("校验资源目录", &self.verified_resource_root);
|
||||||
print_optional_field("计划 URL 数", self.planned_url_count);
|
print_optional_field("计划 URL 数", self.planned_url_count);
|
||||||
print_field("计划异常数", self.expected_plan_failure_count);
|
print_field("计划异常数", self.expected_plan_failure_count);
|
||||||
print_field("本地 manifest 项", self.local_manifest_entry_count);
|
print_field("本地 manifest 项", self.local_manifest_entry_count);
|
||||||
@@ -2166,6 +2305,7 @@ struct VerifyCommandReport {
|
|||||||
message: &'static str,
|
message: &'static str,
|
||||||
healthy: bool,
|
healthy: bool,
|
||||||
remote_update_status: String,
|
remote_update_status: String,
|
||||||
|
verified_resource_root: PathBuf,
|
||||||
planned_url_count: Option<usize>,
|
planned_url_count: Option<usize>,
|
||||||
expected_plan_failure_count: usize,
|
expected_plan_failure_count: usize,
|
||||||
local_manifest_entry_count: usize,
|
local_manifest_entry_count: usize,
|
||||||
@@ -2189,8 +2329,9 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
|||||||
let mut logger = ProgressLogger::new(options.progress);
|
let mut logger = ProgressLogger::new(options.progress);
|
||||||
let update_report =
|
let update_report =
|
||||||
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
|
OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?;
|
||||||
|
let verified_resource_root = active_official_resource_root(&config.output_root)?;
|
||||||
let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command(
|
let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command(
|
||||||
&config.output_root,
|
&verified_resource_root,
|
||||||
&config.curl_command,
|
&config.curl_command,
|
||||||
)
|
)
|
||||||
.verify_local_download_manifest()
|
.verify_local_download_manifest()
|
||||||
@@ -2229,6 +2370,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
|||||||
},
|
},
|
||||||
healthy,
|
healthy,
|
||||||
remote_update_status: update_report.update_status.as_str().to_string(),
|
remote_update_status: update_report.update_status.as_str().to_string(),
|
||||||
|
verified_resource_root,
|
||||||
planned_url_count: update_report.download_url_count,
|
planned_url_count: update_report.download_url_count,
|
||||||
expected_plan_failure_count: update_report.local_manifest_repair_needed_count,
|
expected_plan_failure_count: update_report.local_manifest_repair_needed_count,
|
||||||
local_manifest_entry_count: verification.items.len(),
|
local_manifest_entry_count: verification.items.len(),
|
||||||
@@ -2693,6 +2835,8 @@ fn update_daemon_status(
|
|||||||
last_update_status: Option<String>,
|
last_update_status: Option<String>,
|
||||||
last_error: Option<String>,
|
last_error: Option<String>,
|
||||||
next_retry_seconds: Option<u64>,
|
next_retry_seconds: Option<u64>,
|
||||||
|
last_success_unix_seconds: Option<u64>,
|
||||||
|
next_check_unix_seconds: Option<u64>,
|
||||||
pending_scheduled_force: bool,
|
pending_scheduled_force: bool,
|
||||||
next_forced_refresh_at: SystemTime,
|
next_forced_refresh_at: SystemTime,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
@@ -2704,11 +2848,17 @@ fn update_daemon_status(
|
|||||||
resource_output_root: OfficialUpdateConfig::default().output_root,
|
resource_output_root: OfficialUpdateConfig::default().output_root,
|
||||||
state_dir: state_dir.to_path_buf(),
|
state_dir: state_dir.to_path_buf(),
|
||||||
log_path: daemon_log_path(state_dir),
|
log_path: daemon_log_path(state_dir),
|
||||||
|
structured_log_path: Some(daemon_structured_log_path(state_dir)),
|
||||||
started_unix_seconds: unix_seconds_now(),
|
started_unix_seconds: unix_seconds_now(),
|
||||||
updated_unix_seconds: unix_seconds_now(),
|
updated_unix_seconds: unix_seconds_now(),
|
||||||
|
last_success_unix_seconds: None,
|
||||||
|
next_check_unix_seconds: None,
|
||||||
last_update_status: None,
|
last_update_status: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_retry_seconds: None,
|
next_retry_seconds: None,
|
||||||
|
current_stage: None,
|
||||||
|
current_message: None,
|
||||||
|
download_progress: None,
|
||||||
pending_scheduled_force: false,
|
pending_scheduled_force: false,
|
||||||
next_forced_refresh_unix_seconds: None,
|
next_forced_refresh_unix_seconds: None,
|
||||||
command: env::args().collect(),
|
command: env::args().collect(),
|
||||||
@@ -2719,11 +2869,44 @@ fn update_daemon_status(
|
|||||||
status.last_update_status = last_update_status;
|
status.last_update_status = last_update_status;
|
||||||
status.last_error = last_error;
|
status.last_error = last_error;
|
||||||
status.next_retry_seconds = next_retry_seconds;
|
status.next_retry_seconds = next_retry_seconds;
|
||||||
|
if last_success_unix_seconds.is_some() {
|
||||||
|
status.last_success_unix_seconds = last_success_unix_seconds;
|
||||||
|
}
|
||||||
|
status.next_check_unix_seconds = next_check_unix_seconds;
|
||||||
|
if state != "running" {
|
||||||
|
status.current_stage = None;
|
||||||
|
status.current_message = None;
|
||||||
|
status.download_progress = None;
|
||||||
|
}
|
||||||
status.pending_scheduled_force = pending_scheduled_force;
|
status.pending_scheduled_force = pending_scheduled_force;
|
||||||
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
|
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
|
||||||
write_daemon_status_file(&status_path, &status)
|
write_daemon_status_file(&status_path, &status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> anyhow::Result<()> {
|
||||||
|
let status_path = daemon_status_path(state_dir);
|
||||||
|
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
status.pid = std::process::id();
|
||||||
|
status.updated_unix_seconds = unix_seconds_now();
|
||||||
|
status.current_stage = Some(event.stage.to_string());
|
||||||
|
status.current_message = Some(event.message.clone());
|
||||||
|
status.download_progress = match (event.download_index, event.download_total) {
|
||||||
|
(Some(index), Some(total)) => Some(DaemonDownloadProgress {
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
url: event.download_url.clone().unwrap_or_default(),
|
||||||
|
status: event.download_status.clone(),
|
||||||
|
bytes: event.download_bytes,
|
||||||
|
transferred_bytes: event.download_transferred_bytes,
|
||||||
|
}),
|
||||||
|
_ if event.stage != "download" => None,
|
||||||
|
_ => status.download_progress,
|
||||||
|
};
|
||||||
|
write_daemon_status_file(&status_path, &status)
|
||||||
|
}
|
||||||
|
|
||||||
fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> {
|
fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> {
|
||||||
let status_path = daemon_status_path(state_dir);
|
let status_path = daemon_status_path(state_dir);
|
||||||
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
||||||
@@ -2757,6 +2940,25 @@ fn daemon_log_path(output_root: &Path) -> PathBuf {
|
|||||||
output_root.join(DAEMON_LOG_FILE)
|
output_root.join(DAEMON_LOG_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn daemon_structured_log_path(output_root: &Path) -> PathBuf {
|
||||||
|
output_root.join(DAEMON_STRUCTURED_LOG_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf {
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or(DAEMON_STRUCTURED_LOG_FILE);
|
||||||
|
path.with_file_name(format!("{file_name}.{index}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotated_structured_log_paths(path: &Path) -> Vec<PathBuf> {
|
||||||
|
(1..=STRUCTURED_LOG_ROTATE_KEEP)
|
||||||
|
.map(|index| rotated_structured_log_path(path, index))
|
||||||
|
.filter(|path| path_exists_no_follow(path).unwrap_or(false))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn daemon_socket_path(output_root: &Path) -> PathBuf {
|
fn daemon_socket_path(output_root: &Path) -> PathBuf {
|
||||||
output_root.join(DAEMON_SOCKET_FILE)
|
output_root.join(DAEMON_SOCKET_FILE)
|
||||||
}
|
}
|
||||||
@@ -2765,6 +2967,30 @@ fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
|
|||||||
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_official_resource_root(output_root: &Path) -> anyhow::Result<PathBuf> {
|
||||||
|
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
|
||||||
|
let metadata = match fs::symlink_metadata(¤t_path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(output_root.to_path_buf());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if !metadata.file_type().is_symlink() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"current 已存在但不是 symlink:{}",
|
||||||
|
current_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let target = fs::read_link(¤t_path)?;
|
||||||
|
let target = if target.is_absolute() {
|
||||||
|
target
|
||||||
|
} else {
|
||||||
|
output_root.join(target)
|
||||||
|
};
|
||||||
|
lexical_absolute(&target).map_err(anyhow::Error::msg)
|
||||||
|
}
|
||||||
|
|
||||||
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
let config = &options.config;
|
let config = &options.config;
|
||||||
@@ -2882,6 +3108,10 @@ fn unix_seconds_now() -> u64 {
|
|||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unix_seconds_after(duration: Duration) -> u64 {
|
||||||
|
unix_seconds_now().saturating_add(duration.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
fn system_time_to_unix_seconds(time: SystemTime) -> Option<u64> {
|
fn system_time_to_unix_seconds(time: SystemTime) -> Option<u64> {
|
||||||
time.duration_since(UNIX_EPOCH)
|
time.duration_since(UNIX_EPOCH)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -3026,6 +3256,7 @@ fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration {
|
|||||||
struct ProgressLogger {
|
struct ProgressLogger {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
|
structured: Option<RotatingStructuredLogger>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProgressLogger {
|
impl ProgressLogger {
|
||||||
@@ -3033,23 +3264,175 @@ impl ProgressLogger {
|
|||||||
Self {
|
Self {
|
||||||
enabled,
|
enabled,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
|
structured: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn attach_structured_log(&mut self, path: PathBuf) {
|
||||||
|
self.structured = Some(RotatingStructuredLogger::new(
|
||||||
|
path,
|
||||||
|
STRUCTURED_LOG_MAX_BYTES,
|
||||||
|
STRUCTURED_LOG_ROTATE_KEEP,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn log(&mut self, event: OfficialUpdateProgress) {
|
fn log(&mut self, event: OfficialUpdateProgress) {
|
||||||
self.log_text(event.stage, event.message);
|
self.log_event(&event);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
|
fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
|
||||||
|
self.log_text_inner(stage, message.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_event(&mut self, event: &OfficialUpdateProgress) {
|
||||||
|
if self.enabled {
|
||||||
|
eprintln!(
|
||||||
|
"[+{} 信息] [{}] {}",
|
||||||
|
format_duration(self.started_at.elapsed()),
|
||||||
|
localized_stage(event.stage),
|
||||||
|
event.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(structured) = self.structured.as_mut() {
|
||||||
|
let _ = structured.write_event(self.started_at.elapsed(), event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_text_inner(&mut self, stage: &str, message: &str) {
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
|
if let Some(structured) = self.structured.as_mut() {
|
||||||
|
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
|
||||||
|
let _ = structured.write_event(self.started_at.elapsed(), &event);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[+{} 信息] [{}] {}",
|
"[+{} 信息] [{}] {}",
|
||||||
format_duration(self.started_at.elapsed()),
|
format_duration(self.started_at.elapsed()),
|
||||||
localized_stage(stage),
|
localized_stage(stage),
|
||||||
message.as_ref()
|
message
|
||||||
);
|
);
|
||||||
|
if let Some(structured) = self.structured.as_mut() {
|
||||||
|
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
|
||||||
|
let _ = structured.write_event(self.started_at.elapsed(), &event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RotatingStructuredLogger {
|
||||||
|
path: PathBuf,
|
||||||
|
max_bytes: u64,
|
||||||
|
keep: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RotatingStructuredLogger {
|
||||||
|
fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
max_bytes,
|
||||||
|
keep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_event(
|
||||||
|
&mut self,
|
||||||
|
elapsed: Duration,
|
||||||
|
event: &OfficialUpdateProgress,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"timestamp_unix_seconds": unix_seconds_now(),
|
||||||
|
"elapsed_ms": elapsed.as_millis(),
|
||||||
|
"level": "info",
|
||||||
|
"stage": event.stage,
|
||||||
|
"stage_label": localized_stage(event.stage),
|
||||||
|
"message": event.message.as_str(),
|
||||||
|
"download": event.download_index.map(|index| serde_json::json!({
|
||||||
|
"index": index,
|
||||||
|
"total": event.download_total.unwrap_or(index),
|
||||||
|
"url": event.download_url.as_deref(),
|
||||||
|
"status": event.download_status.as_deref(),
|
||||||
|
"bytes": event.download_bytes,
|
||||||
|
"transferred_bytes": event.download_transferred_bytes,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
let mut line = serde_json::to_vec(&payload)?;
|
||||||
|
line.push(b'\n');
|
||||||
|
self.rotate_if_needed(line.len() as u64)?;
|
||||||
|
let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
file.write_all(&line)?;
|
||||||
|
file.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> {
|
||||||
|
let current_len = match fs::symlink_metadata(&self.path) {
|
||||||
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"结构化日志不能是 symlink:{}",
|
||||||
|
self.path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Ok(metadata) if metadata.is_file() => metadata.len(),
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"结构化日志已存在但不是普通文件:{}",
|
||||||
|
self.path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if current_len.saturating_add(incoming_bytes) <= self.max_bytes {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for index in (1..=self.keep).rev() {
|
||||||
|
let from = if index == 1 {
|
||||||
|
self.path.clone()
|
||||||
|
} else {
|
||||||
|
rotated_structured_log_path(&self.path, index - 1)
|
||||||
|
};
|
||||||
|
let to = rotated_structured_log_path(&self.path, index);
|
||||||
|
if !path_exists_no_follow(&from)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path_exists_no_follow(&to)? {
|
||||||
|
fs::remove_file(&to)?;
|
||||||
|
}
|
||||||
|
fs::rename(&from, &to)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_to_static(stage: &str) -> &'static str {
|
||||||
|
match stage {
|
||||||
|
"start" => "start",
|
||||||
|
"lock" => "lock",
|
||||||
|
"bootstrap" => "bootstrap",
|
||||||
|
"launcher" => "launcher",
|
||||||
|
"bootstrap-cache" => "bootstrap-cache",
|
||||||
|
"game-main-config" => "game-main-config",
|
||||||
|
"metadata" => "metadata",
|
||||||
|
"server-info" => "server-info",
|
||||||
|
"discovery" => "discovery",
|
||||||
|
"markers" => "markers",
|
||||||
|
"marker" => "marker",
|
||||||
|
"catalog" => "catalog",
|
||||||
|
"local-state" => "local-state",
|
||||||
|
"audit" => "audit",
|
||||||
|
"decision" => "decision",
|
||||||
|
"plan" => "plan",
|
||||||
|
"download" => "download",
|
||||||
|
"snapshot" => "snapshot",
|
||||||
|
"publish" => "publish",
|
||||||
|
"finish" => "finish",
|
||||||
|
"watch" => "watch",
|
||||||
|
"daemon" => "daemon",
|
||||||
|
"dry-run" => "dry-run",
|
||||||
|
_ => "log",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3075,6 +3458,7 @@ fn localized_stage(stage: &str) -> &str {
|
|||||||
"audit" => "审计",
|
"audit" => "审计",
|
||||||
"dry-run" => "试运行",
|
"dry-run" => "试运行",
|
||||||
"download" => "下载",
|
"download" => "下载",
|
||||||
|
"publish" => "发布",
|
||||||
"resource" => "资源",
|
"resource" => "资源",
|
||||||
"finish" => "完成",
|
"finish" => "完成",
|
||||||
"watch" => "常驻",
|
"watch" => "常驻",
|
||||||
@@ -3477,8 +3861,10 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Sync:");
|
eprintln!("Sync:");
|
||||||
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
|
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
|
||||||
eprintln!(" --output <DIR> Resource output dir (default: ./bat-resources)");
|
eprintln!(
|
||||||
eprintln!(" --snapshot <PATH> Snapshot path (default: <output>/official-sync-snapshot.json)");
|
" --output <DIR> Resource publish root (default: ./bat-resources)"
|
||||||
|
);
|
||||||
|
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
|
||||||
eprintln!(" --curl <PATH> curl executable (default: curl)");
|
eprintln!(" --curl <PATH> curl executable (default: curl)");
|
||||||
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
|
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
|
||||||
eprintln!(" --dry-run Do not write sync state");
|
eprintln!(" --dry-run Do not write sync state");
|
||||||
@@ -3506,8 +3892,8 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Defaults:");
|
eprintln!("Defaults:");
|
||||||
eprintln!(" platforms: Windows,Android");
|
eprintln!(" platforms: Windows,Android");
|
||||||
eprintln!(" resource output: ./bat-resources");
|
eprintln!(" resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)");
|
||||||
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log)");
|
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)");
|
||||||
eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}");
|
eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3877,6 +4263,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn active_official_resource_root_uses_current_symlink() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let output = temp.path().join("resources");
|
||||||
|
let version = output.join("versions").join("v1");
|
||||||
|
fs::create_dir_all(&version).unwrap();
|
||||||
|
symlink(Path::new("versions").join("v1"), output.join("current")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
active_official_resource_root(&output).unwrap(),
|
||||||
|
lexical_absolute(&version).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn daemon_rpc_rejects_socket_symlink() {
|
fn daemon_rpc_rejects_socket_symlink() {
|
||||||
@@ -4028,6 +4431,80 @@ mod tests {
|
|||||||
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daemon_status_tracks_progress_success_and_next_check() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let state_dir = temp.path().join("state");
|
||||||
|
let output_root = temp.path().join("resources");
|
||||||
|
fs::create_dir_all(&state_dir).unwrap();
|
||||||
|
fs::create_dir_all(&output_root).unwrap();
|
||||||
|
write_daemon_status_file(
|
||||||
|
&daemon_status_path(&state_dir),
|
||||||
|
&test_daemon_status_file(&state_dir, &output_root),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture");
|
||||||
|
event.download_index = Some(2);
|
||||||
|
event.download_total = Some(19);
|
||||||
|
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into());
|
||||||
|
update_daemon_progress(&state_dir, &event).unwrap();
|
||||||
|
|
||||||
|
let report = build_daemon_status_report(&state_dir).unwrap();
|
||||||
|
assert_eq!(report.current_stage.as_deref(), Some("download"));
|
||||||
|
assert_eq!(report.download_progress.as_ref().unwrap().index, 2);
|
||||||
|
assert_eq!(report.download_progress.as_ref().unwrap().total, 19);
|
||||||
|
|
||||||
|
let next_check = unix_seconds_after(Duration::from_secs(30));
|
||||||
|
update_daemon_status(
|
||||||
|
&state_dir,
|
||||||
|
"sleeping",
|
||||||
|
Some("downloaded".to_string()),
|
||||||
|
None,
|
||||||
|
Some(30),
|
||||||
|
Some(123456),
|
||||||
|
Some(next_check),
|
||||||
|
false,
|
||||||
|
SystemTime::now() + Duration::from_secs(3600),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let report = build_daemon_status_report(&state_dir).unwrap();
|
||||||
|
assert_eq!(report.last_success_unix_seconds, Some(123456));
|
||||||
|
assert_eq!(report.next_check_unix_seconds, Some(next_check));
|
||||||
|
assert!(report.current_stage.is_none());
|
||||||
|
assert!(report.download_progress.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_logger_writes_jsonl_and_rotates() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = temp.path().join("bat-events.jsonl");
|
||||||
|
let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2);
|
||||||
|
let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture");
|
||||||
|
event.download_index = Some(1);
|
||||||
|
event.download_total = Some(2);
|
||||||
|
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into());
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
logger
|
||||||
|
.write_event(Duration::from_millis(42), &event)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(rotated_structured_log_path(&path, 1).exists());
|
||||||
|
let line = fs::read_to_string(&path)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(value["stage"], "download");
|
||||||
|
assert_eq!(value["download"]["index"], 1);
|
||||||
|
assert_eq!(value["download"]["total"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn read_pid_file_rejects_symlink() {
|
fn read_pid_file_rejects_symlink() {
|
||||||
@@ -4295,11 +4772,17 @@ mod tests {
|
|||||||
resource_output_root: output_root.to_path_buf(),
|
resource_output_root: output_root.to_path_buf(),
|
||||||
state_dir: state_dir.to_path_buf(),
|
state_dir: state_dir.to_path_buf(),
|
||||||
log_path: daemon_log_path(state_dir),
|
log_path: daemon_log_path(state_dir),
|
||||||
|
structured_log_path: Some(daemon_structured_log_path(state_dir)),
|
||||||
started_unix_seconds: unix_seconds_now(),
|
started_unix_seconds: unix_seconds_now(),
|
||||||
updated_unix_seconds: unix_seconds_now(),
|
updated_unix_seconds: unix_seconds_now(),
|
||||||
|
last_success_unix_seconds: Some(unix_seconds_now()),
|
||||||
|
next_check_unix_seconds: Some(unix_seconds_after(Duration::from_secs(60))),
|
||||||
last_update_status: Some("up_to_date".to_string()),
|
last_update_status: Some("up_to_date".to_string()),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
next_retry_seconds: Some(60),
|
next_retry_seconds: Some(60),
|
||||||
|
current_stage: None,
|
||||||
|
current_message: None,
|
||||||
|
download_progress: None,
|
||||||
pending_scheduled_force: false,
|
pending_scheduled_force: false,
|
||||||
next_forced_refresh_unix_seconds: None,
|
next_forced_refresh_unix_seconds: None,
|
||||||
command: vec!["bat".to_string(), "--daemon-child".to_string()],
|
command: vec!["bat".to_string(), "--daemon-child".to_string()],
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ use std::io::Write;
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Current official update snapshot schema version.
|
/// Current official update snapshot schema version.
|
||||||
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
|
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
|
||||||
/// Current official bootstrap cache schema version.
|
/// Current official bootstrap cache schema version.
|
||||||
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
|
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
|
||||||
|
const OFFICIAL_CURRENT_LINK: &str = "current";
|
||||||
|
const OFFICIAL_VERSIONS_DIR: &str = "versions";
|
||||||
|
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
||||||
|
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
||||||
|
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
|
||||||
|
|
||||||
/// Server-info input for an official update run.
|
/// Server-info input for an official update run.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -379,6 +385,16 @@ pub struct OfficialUpdateReport {
|
|||||||
pub addressables_root: String,
|
pub addressables_root: String,
|
||||||
/// Platform set used by the run.
|
/// Platform set used by the run.
|
||||||
pub platforms: Vec<PatchPlatform>,
|
pub platforms: Vec<PatchPlatform>,
|
||||||
|
/// Managed publish root containing `current`, `versions`, and staging.
|
||||||
|
pub output_root: PathBuf,
|
||||||
|
/// Active resource root used for local audit before this run.
|
||||||
|
pub active_resource_root: PathBuf,
|
||||||
|
/// Atomic `current` pointer path.
|
||||||
|
pub current_path: PathBuf,
|
||||||
|
/// Versioned release directory after a successful publish.
|
||||||
|
pub published_version_path: Option<PathBuf>,
|
||||||
|
/// Staging directory used during download before publish.
|
||||||
|
pub staging_path: Option<PathBuf>,
|
||||||
/// Sync snapshot path.
|
/// Sync snapshot path.
|
||||||
pub snapshot_path: PathBuf,
|
pub snapshot_path: PathBuf,
|
||||||
/// Whether a previous snapshot existed.
|
/// Whether a previous snapshot existed.
|
||||||
@@ -446,6 +462,18 @@ pub struct OfficialUpdateProgress {
|
|||||||
pub stage: &'static str,
|
pub stage: &'static str,
|
||||||
/// Human-readable status line.
|
/// Human-readable status line.
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// One-based download index when the event represents URL download work.
|
||||||
|
pub download_index: Option<usize>,
|
||||||
|
/// Total URL count for the current download plan.
|
||||||
|
pub download_total: Option<usize>,
|
||||||
|
/// Current official URL for download progress.
|
||||||
|
pub download_url: Option<String>,
|
||||||
|
/// Stable pull status label when a URL finished.
|
||||||
|
pub download_status: Option<String>,
|
||||||
|
/// Final local byte count for the URL when known.
|
||||||
|
pub download_bytes: Option<u64>,
|
||||||
|
/// Bytes transferred in this run for the URL when known.
|
||||||
|
pub download_transferred_bytes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OfficialUpdateProgress {
|
impl OfficialUpdateProgress {
|
||||||
@@ -454,8 +482,192 @@ impl OfficialUpdateProgress {
|
|||||||
Self {
|
Self {
|
||||||
stage,
|
stage,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
|
download_index: None,
|
||||||
|
download_total: None,
|
||||||
|
download_url: None,
|
||||||
|
download_status: None,
|
||||||
|
download_bytes: None,
|
||||||
|
download_transferred_bytes: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_download_progress(mut self, event: &OfficialResourcePullProgress) -> Self {
|
||||||
|
self.download_index = Some(event.index);
|
||||||
|
self.download_total = Some(event.total);
|
||||||
|
self.download_url = Some(event.url.clone());
|
||||||
|
self.download_status = event.status.map(|status| status.as_str().to_string());
|
||||||
|
self.download_bytes = event.bytes;
|
||||||
|
self.download_transferred_bytes = event.transferred_bytes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct OfficialPublishLayout {
|
||||||
|
root: PathBuf,
|
||||||
|
current_path: PathBuf,
|
||||||
|
versions_dir: PathBuf,
|
||||||
|
staging_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct OfficialPublishPlan {
|
||||||
|
id: String,
|
||||||
|
staging_path: PathBuf,
|
||||||
|
version_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfficialPublishLayout {
|
||||||
|
fn new(root: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
current_path: root.join(OFFICIAL_CURRENT_LINK),
|
||||||
|
versions_dir: root.join(OFFICIAL_VERSIONS_DIR),
|
||||||
|
staging_dir: root.join(OFFICIAL_STAGING_DIR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_resource_root(&self) -> Result<PathBuf, String> {
|
||||||
|
if let Some(current_target) = self.current_target()? {
|
||||||
|
return Ok(current_target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback for directories produced before atomic publishing.
|
||||||
|
// The next non-dry-run update will seed staging from this tree and
|
||||||
|
// publish it under `versions/<id>` before switching `current`.
|
||||||
|
Ok(self.root.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_target(&self) -> Result<Option<PathBuf>, String> {
|
||||||
|
let metadata = match fs::symlink_metadata(&self.current_path) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"读取 current 指针失败 {}:{error}",
|
||||||
|
self.current_path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !metadata.file_type().is_symlink() {
|
||||||
|
return Err(format!(
|
||||||
|
"current 必须是指向 versioned 目录的 symlink:{}",
|
||||||
|
self.current_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = fs::read_link(&self.current_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"读取 current symlink 目标失败 {}:{error}",
|
||||||
|
self.current_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let target = if target.is_absolute() {
|
||||||
|
target
|
||||||
|
} else {
|
||||||
|
self.root.join(target)
|
||||||
|
};
|
||||||
|
ensure_path_within_root(&self.root, &target)?;
|
||||||
|
ensure_safe_directory_path(&target, "current versioned 目录")?;
|
||||||
|
Ok(Some(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_current_pointer(&self) -> Result<bool, String> {
|
||||||
|
Ok(self.current_target()?.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan(&self, snapshot: &OfficialUpdateSnapshot) -> OfficialPublishPlan {
|
||||||
|
let id = publish_id(snapshot);
|
||||||
|
OfficialPublishPlan {
|
||||||
|
staging_path: self.staging_dir.join(&id),
|
||||||
|
version_path: self.versions_dir.join(&id),
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_staging(&self, plan: &OfficialPublishPlan) -> Result<(), String> {
|
||||||
|
validate_output_root(&self.root)?;
|
||||||
|
ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?;
|
||||||
|
fs::create_dir_all(&self.staging_dir).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"创建官方资源 staging 根目录失败 {}:{error}",
|
||||||
|
self.staging_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?;
|
||||||
|
|
||||||
|
if path_exists_no_follow(&plan.staging_path)? {
|
||||||
|
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
|
||||||
|
fs::remove_dir_all(&plan.staging_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"清理官方资源 staging 目录失败 {}:{error}",
|
||||||
|
plan.staging_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_exists_no_follow(&plan.version_path)? {
|
||||||
|
return Err(format!(
|
||||||
|
"versioned 目录已存在,拒绝覆盖:{}",
|
||||||
|
plan.version_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(&plan.staging_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"创建官方资源 staging 目录失败 {}:{error}",
|
||||||
|
plan.staging_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_staging_from_active(
|
||||||
|
&self,
|
||||||
|
active_root: &Path,
|
||||||
|
staging_root: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if active_root == self.root && !self.legacy_manifest_exists()? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !path_exists_no_follow(active_root)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_manifest_exists(&self) -> Result<bool, String> {
|
||||||
|
path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(&self, plan: &OfficialPublishPlan) -> Result<PathBuf, String> {
|
||||||
|
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
|
||||||
|
fs::create_dir_all(&self.versions_dir).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"创建官方资源 versions 根目录失败 {}:{error}",
|
||||||
|
self.versions_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
|
||||||
|
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
|
||||||
|
if path_exists_no_follow(&plan.version_path)? {
|
||||||
|
return Err(format!(
|
||||||
|
"versioned 目录已存在,拒绝覆盖:{}",
|
||||||
|
plan.version_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&plan.staging_path, &plan.version_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"发布官方资源版本目录失败 {} -> {}:{error}",
|
||||||
|
plan.staging_path.display(),
|
||||||
|
plan.version_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
switch_current_symlink(&self.root, &self.current_path, &plan.id)?;
|
||||||
|
Ok(plan.version_path.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Official update runner.
|
/// Official update runner.
|
||||||
@@ -517,16 +729,24 @@ impl OfficialUpdateService {
|
|||||||
};
|
};
|
||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
|
|
||||||
|
let publish_layout = OfficialPublishLayout::new(&config.output_root);
|
||||||
|
let active_resource_root = publish_layout
|
||||||
|
.active_resource_root()
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let has_current_pointer = publish_layout
|
||||||
|
.has_current_pointer()
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
let default_platforms = default_official_platforms();
|
let default_platforms = default_official_platforms();
|
||||||
let platforms = config
|
let platforms = config
|
||||||
.platforms
|
.platforms
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or(default_platforms.as_slice());
|
.unwrap_or(default_platforms.as_slice());
|
||||||
let fetcher = OfficialResourcePullService::with_curl_command(
|
let fetcher = OfficialResourcePullService::with_curl_command(
|
||||||
&config.output_root,
|
&active_resource_root,
|
||||||
&config.curl_command,
|
&config.curl_command,
|
||||||
);
|
);
|
||||||
let snapshot_path = config.effective_snapshot_path();
|
let snapshot_path = snapshot_path_for(config, &active_resource_root);
|
||||||
let bootstrap_cache_path = config.bootstrap_cache_path();
|
let bootstrap_cache_path = config.bootstrap_cache_path();
|
||||||
|
|
||||||
let bootstrap = if config.auto_discover {
|
let bootstrap = if config.auto_discover {
|
||||||
@@ -750,7 +970,9 @@ impl OfficialUpdateService {
|
|||||||
.map(|audit| !audit.is_clean())
|
.map(|audit| !audit.is_clean())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let initial_pull_needed = !has_local_resources;
|
let initial_pull_needed = !has_local_resources;
|
||||||
let should_download = remote_should_download || repair_needed || initial_pull_needed;
|
let publish_required = !has_current_pointer;
|
||||||
|
let should_download =
|
||||||
|
remote_should_download || repair_needed || initial_pull_needed || publish_required;
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"audit",
|
"audit",
|
||||||
format!(
|
format!(
|
||||||
@@ -765,6 +987,12 @@ impl OfficialUpdateService {
|
|||||||
should_download, repair_needed, initial_pull_needed
|
should_download, repair_needed, initial_pull_needed
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
if publish_required {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"publish",
|
||||||
|
"尚未存在 current 原子发布指针;本轮会发布 versioned 目录并切换 current",
|
||||||
|
));
|
||||||
|
}
|
||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
let mut report = OfficialUpdateReport {
|
let mut report = OfficialUpdateReport {
|
||||||
update_status: if should_download {
|
update_status: if should_download {
|
||||||
@@ -777,6 +1005,11 @@ impl OfficialUpdateService {
|
|||||||
bundle_version: current_snapshot.bundle_version.clone(),
|
bundle_version: current_snapshot.bundle_version.clone(),
|
||||||
addressables_root: current_snapshot.addressables_root.clone(),
|
addressables_root: current_snapshot.addressables_root.clone(),
|
||||||
platforms: platforms.to_vec(),
|
platforms: platforms.to_vec(),
|
||||||
|
output_root: config.output_root.clone(),
|
||||||
|
active_resource_root: active_resource_root.clone(),
|
||||||
|
current_path: publish_layout.current_path.clone(),
|
||||||
|
published_version_path: None,
|
||||||
|
staging_path: None,
|
||||||
snapshot_path: snapshot_path.clone(),
|
snapshot_path: snapshot_path.clone(),
|
||||||
previous_snapshot_present: previous_snapshot.is_some(),
|
previous_snapshot_present: previous_snapshot.is_some(),
|
||||||
decision: format!("{:?}", sync_plan.decision),
|
decision: format!("{:?}", sync_plan.decision),
|
||||||
@@ -850,11 +1083,35 @@ impl OfficialUpdateService {
|
|||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
|
|
||||||
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
|
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
|
||||||
|
let publish_plan = publish_layout.plan(¤t_update_snapshot);
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"publish",
|
||||||
|
format!(
|
||||||
|
"准备 staging={} versioned={}",
|
||||||
|
publish_plan.staging_path.display(),
|
||||||
|
publish_plan.version_path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
publish_layout
|
||||||
|
.prepare_staging(&publish_plan)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
publish_layout
|
||||||
|
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let staging_fetcher = OfficialResourcePullService::with_curl_command(
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&config.curl_command,
|
||||||
|
);
|
||||||
|
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
|
||||||
|
report.staging_path = Some(publish_plan.staging_path.clone());
|
||||||
|
report.snapshot_path = staging_snapshot_path.clone();
|
||||||
|
report.download_manifest = staging_fetcher.download_manifest_path();
|
||||||
|
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"download",
|
"download",
|
||||||
format!("下载或复用 {download_url_count} 个官方 URL"),
|
format!("下载或复用 {download_url_count} 个官方 URL"),
|
||||||
));
|
));
|
||||||
let pull_report = fetcher
|
let pull_report = staging_fetcher
|
||||||
.pull_with_progress_and_cancellation(
|
.pull_with_progress_and_cancellation(
|
||||||
&pull_plan,
|
&pull_plan,
|
||||||
|event| {
|
|event| {
|
||||||
@@ -878,16 +1135,37 @@ impl OfficialUpdateService {
|
|||||||
"执行最终本地 manifest 审计",
|
"执行最终本地 manifest 审计",
|
||||||
));
|
));
|
||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
let final_audit = fetcher
|
let final_audit = staging_fetcher
|
||||||
.audit_local_manifest(&pull_plan)
|
.audit_local_manifest(&pull_plan)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"snapshot",
|
"snapshot",
|
||||||
format!("写入快照 {}", snapshot_path.display()),
|
format!("写入快照 {}", staging_snapshot_path.display()),
|
||||||
));
|
));
|
||||||
write_snapshot(&snapshot_path, ¤t_update_snapshot)?;
|
if config.snapshot_path.is_none() {
|
||||||
|
write_snapshot(&staging_snapshot_path, ¤t_update_snapshot)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"publish",
|
||||||
|
format!(
|
||||||
|
"校验完成,发布 versioned 目录并切换 current -> {}",
|
||||||
|
publish_plan.version_path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
let published_version_path = publish_layout
|
||||||
|
.publish(&publish_plan)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let final_snapshot_path = snapshot_path_for(config, &published_version_path);
|
||||||
|
if config.snapshot_path.is_some() {
|
||||||
|
write_snapshot(&final_snapshot_path, ¤t_update_snapshot)?;
|
||||||
|
}
|
||||||
|
|
||||||
report.update_status = OfficialUpdateStatus::Downloaded;
|
report.update_status = OfficialUpdateStatus::Downloaded;
|
||||||
|
report.active_resource_root = published_version_path.clone();
|
||||||
|
report.published_version_path = Some(published_version_path.clone());
|
||||||
|
report.snapshot_path = final_snapshot_path.clone();
|
||||||
|
report.download_manifest = published_version_path.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE);
|
||||||
report.resource_count = Some(pull_report.items.len());
|
report.resource_count = Some(pull_report.items.len());
|
||||||
report.downloaded_count = pull_report.downloaded_count();
|
report.downloaded_count = pull_report.downloaded_count();
|
||||||
report.resumed_count = pull_report.resumed_count();
|
report.resumed_count = pull_report.resumed_count();
|
||||||
@@ -903,7 +1181,7 @@ impl OfficialUpdateService {
|
|||||||
pull_report.official_hash_verified_count(),
|
pull_report.official_hash_verified_count(),
|
||||||
final_audit.zip_structure_verified_count(),
|
final_audit.zip_structure_verified_count(),
|
||||||
);
|
);
|
||||||
report.snapshot_written = Some(snapshot_path);
|
report.snapshot_written = Some(final_snapshot_path);
|
||||||
|
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"finish",
|
"finish",
|
||||||
@@ -1079,7 +1357,8 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
|
|||||||
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
|
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
|
||||||
"download",
|
"download",
|
||||||
format!("({}/{}) 开始 {}", event.index, event.total, event.url),
|
format!("({}/{}) 开始 {}", event.index, event.total, event.url),
|
||||||
),
|
)
|
||||||
|
.with_download_progress(&event),
|
||||||
OfficialResourcePullProgressKind::Finished => {
|
OfficialResourcePullProgressKind::Finished => {
|
||||||
let status = event.status.map(localized_pull_status).unwrap_or("未知");
|
let status = event.status.map(localized_pull_status).unwrap_or("未知");
|
||||||
OfficialUpdateProgress::new(
|
OfficialUpdateProgress::new(
|
||||||
@@ -1094,6 +1373,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
|
|||||||
event.url
|
event.url
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.with_download_progress(&event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1143,6 +1423,195 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
|
||||||
|
config
|
||||||
|
.snapshot_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists_no_follow(path: &Path) -> Result<bool, String> {
|
||||||
|
match fs::symlink_metadata(path) {
|
||||||
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||||
|
Err(format!("路径不能是 symlink:{}", path.display()))
|
||||||
|
}
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(error) => Err(format!("检查路径失败 {}:{error}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_tree_no_symlink(
|
||||||
|
source: &Path,
|
||||||
|
destination: &Path,
|
||||||
|
skip_publish_management_entries: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
ensure_safe_directory_path(source, "官方资源发布源目录")?;
|
||||||
|
ensure_safe_directory_path(destination, "官方资源 staging 目录")?;
|
||||||
|
fs::create_dir_all(destination).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"创建官方资源 staging 子目录失败 {}:{error}",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for entry in fs::read_dir(source)
|
||||||
|
.map_err(|error| format!("读取官方资源发布源目录失败 {}:{error}", source.display()))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|error| {
|
||||||
|
format!("读取官方资源发布源目录项失败 {}:{error}", source.display())
|
||||||
|
})?;
|
||||||
|
let source_path = entry.path();
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
if skip_publish_management_entries {
|
||||||
|
if let Some(name) = file_name.to_str() {
|
||||||
|
if matches!(
|
||||||
|
name,
|
||||||
|
OFFICIAL_STAGING_DIR
|
||||||
|
| OFFICIAL_VERSIONS_DIR
|
||||||
|
| OFFICIAL_CURRENT_LINK
|
||||||
|
| ".official-sync.lock"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if destination.starts_with(&source_path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let destination_path = destination.join(file_name);
|
||||||
|
let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"读取官方资源发布源元数据失败 {}:{error}",
|
||||||
|
source_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err(format!(
|
||||||
|
"官方资源发布源不能包含 symlink:{}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if metadata.is_dir() {
|
||||||
|
copy_tree_no_symlink(&source_path, &destination_path, false)?;
|
||||||
|
} else if metadata.is_file() {
|
||||||
|
if let Some(name) = source_path.file_name().and_then(|value| value.to_str()) {
|
||||||
|
if name.ends_with(".part") || name.ends_with(".tmp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
|
||||||
|
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
||||||
|
format!(
|
||||||
|
"复制官方资源到 staging 失败 {} -> {}:{copy_error}",
|
||||||
|
source_path.display(),
|
||||||
|
destination_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"官方资源发布源包含非普通文件:{}",
|
||||||
|
source_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn switch_current_symlink(
|
||||||
|
root: &Path,
|
||||||
|
current_path: &Path,
|
||||||
|
publish_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temporary = root.join(format!(".current.{}.tmp", std::process::id()));
|
||||||
|
if path_exists_no_follow(&temporary)? {
|
||||||
|
fs::remove_file(&temporary).map_err(|error| {
|
||||||
|
format!("清理临时 current 指针失败 {}:{error}", temporary.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let relative_target = Path::new(OFFICIAL_VERSIONS_DIR).join(publish_id);
|
||||||
|
symlink(&relative_target, &temporary).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"创建临时 current 指针失败 {} -> {}:{error}",
|
||||||
|
temporary.display(),
|
||||||
|
relative_target.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Ok(metadata) = fs::symlink_metadata(current_path) {
|
||||||
|
if !metadata.file_type().is_symlink() {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
return Err(format!(
|
||||||
|
"current 已存在但不是 symlink:{}",
|
||||||
|
current_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&temporary, current_path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"切换 current 指针失败 {} -> {}:{error}",
|
||||||
|
temporary.display(),
|
||||||
|
current_path.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn switch_current_symlink(
|
||||||
|
_root: &Path,
|
||||||
|
_current_path: &Path,
|
||||||
|
_publish_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Err("原子 current symlink 发布目前只支持 Unix/Linux 平台".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_id(snapshot: &OfficialUpdateSnapshot) -> String {
|
||||||
|
let bundle = snapshot
|
||||||
|
.bundle_version
|
||||||
|
.as_deref()
|
||||||
|
.map(sanitize_publish_segment)
|
||||||
|
.unwrap_or_else(|| "no-bundle".to_string());
|
||||||
|
format!(
|
||||||
|
"{}-{}-{}-{}-{}",
|
||||||
|
sanitize_publish_segment(&snapshot.app_version),
|
||||||
|
bundle,
|
||||||
|
snapshot.addressables_marker_checked_count(),
|
||||||
|
unix_seconds_now(),
|
||||||
|
std::process::id()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_publish_segment(value: &str) -> String {
|
||||||
|
let sanitized = value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
|
||||||
|
{
|
||||||
|
'_'
|
||||||
|
} else {
|
||||||
|
ch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_seconds_now() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads an official update snapshot from disk, accepting legacy v1 snapshots.
|
/// Reads an official update snapshot from disk, accepting legacy v1 snapshots.
|
||||||
pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
|
pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
|
||||||
let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)?
|
let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)?
|
||||||
|
|||||||
@@ -229,6 +229,27 @@ fn official_update_first_run_pulls_without_pre_audit() {
|
|||||||
assert!(events
|
assert!(events
|
||||||
.iter()
|
.iter()
|
||||||
.any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
|
.any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
|
||||||
|
|
||||||
|
let published = report.published_version_path.as_ref().unwrap();
|
||||||
|
assert!(published.exists());
|
||||||
|
assert!(report
|
||||||
|
.staging_path
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|path| !path.exists()));
|
||||||
|
assert!(config.output_root.join("current").exists());
|
||||||
|
assert!(fs::symlink_metadata(config.output_root.join("current"))
|
||||||
|
.unwrap()
|
||||||
|
.file_type()
|
||||||
|
.is_symlink());
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.output_root
|
||||||
|
.join("official-download-manifest.json")
|
||||||
|
.exists(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert!(published.join("official-download-manifest.json").exists());
|
||||||
|
assert!(published.join("official-sync-snapshot.json").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -262,6 +283,11 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
|
|||||||
assert!(events.iter().any(|event| {
|
assert!(events.iter().any(|event| {
|
||||||
event.stage == "decision" && event.message.contains("首次拉取=false")
|
event.stage == "decision" && event.message.contains("首次拉取=false")
|
||||||
}));
|
}));
|
||||||
|
assert!(config.output_root.join("current").exists());
|
||||||
|
assert!(fs::symlink_metadata(config.output_root.join("current"))
|
||||||
|
.unwrap()
|
||||||
|
.file_type()
|
||||||
|
.is_symlink());
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestHarness {
|
struct TestHarness {
|
||||||
|
|||||||
Reference in New Issue
Block a user