mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
Compare commits
3
Commits
3f5d2a8da7
...
102b49b666
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102b49b666
|
||
|
|
ecda08ed97
|
||
|
|
a729615a48
|
+1
-1
@@ -5,7 +5,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bat
|
||||
/bat
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
+2
-2
@@ -9,10 +9,10 @@
|
||||
### 新增
|
||||
- Addressables catalog 提取 `m_Crc`(bundle IEEE CRC-32):`ResourceEntry` 新增 `crc` 字段(compact/expanded 两种形态均解析),SQLite 持久化并对旧库幂等迁移补列;core 新增 `crc32_ieee` 与 `ResourceEntry::verify_downloaded_bytes`(按声明的 size/CRC 校验字节)(issue #2)
|
||||
- UnityFS 解析新增目录条目越界校验:directory 的 `offset+size` 必须落在解压数据区内,截断/损坏 bundle 的越界目录条目不再被静默接受(issue #3)
|
||||
- 官方资源下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17)
|
||||
- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17)
|
||||
|
||||
### 修复
|
||||
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s),并发下载时对官方 CDN 更礼貌
|
||||
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s)
|
||||
|
||||
### 计划
|
||||
- [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19)
|
||||
|
||||
+12
-11
@@ -10,7 +10,7 @@
|
||||
|
||||
## 1. 总体判断
|
||||
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go CLI/API/Web 仍未形成产品入口** 阶段。
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go `bat-api` 已有 Rust daemon RPC client 但 CLI/API/Web 仍未形成产品入口** 阶段。
|
||||
|
||||
Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
@@ -19,14 +19,14 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
3. 默认平台为 `Windows + Android`。
|
||||
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency`/`BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),daemon 子进程会透传该并发配置;manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载执行保持顺序处理;manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。
|
||||
7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy <URL>` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv,`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret`(`0600`)且 `clean-stable` 会清除。
|
||||
8. `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、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。
|
||||
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||
10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||
14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
||||
|
||||
仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
|
||||
@@ -198,12 +198,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
### Go / API / Web
|
||||
|
||||
状态:**Go 产品入口仍未完成,仅有可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
|
||||
状态:**Go 产品入口仍未完成;`bat-api` 已有 typed Rust daemon RPC client、可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
|
||||
|
||||
当前情况:
|
||||
|
||||
- `internal/backendrpc` 已提供 Go typed Unix socket JSON-RPC client,作为 `bat-api` 调用 Rust daemon 的默认路径。
|
||||
- `internal/ffi/ffi.go` 已存在。
|
||||
- Go CLI 的稳定集成方向仍应优先通过 Rust `bat --json` 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
|
||||
- Go CLI 的稳定集成方向仍应优先通过 Rust daemon RPC 或 Rust `bat --json` one-shot 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
|
||||
- `cmd/`、`pkg/`、`api/`、`web/` 仍无可用产品入口,`cmd/bat` 目前只覆盖 `doctor`、`manifest inspect`、`sync plan` 这类最小演示能力。
|
||||
- `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁。
|
||||
|
||||
@@ -234,7 +235,7 @@ git status --short --branch
|
||||
未执行:
|
||||
|
||||
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md` 和 `scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。
|
||||
- Go CLI 端到端测试,因为 Go 产品入口尚未完成。
|
||||
- Go CLI 端到端测试,因为 Go 产品入口尚未完成;`internal/backendrpc` 已有 fake transport 单测覆盖。
|
||||
- Web/API 测试,因为 Web/API 尚未实现。
|
||||
|
||||
---
|
||||
@@ -264,12 +265,12 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
## 6. 当前阻塞项
|
||||
|
||||
GitHub issue 状态:当前 open 的是 #1(P1)、#2(P2)、#3(P2)、#17(P2)、#19(P2)。其中 #17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。
|
||||
GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。其他 open issue 的实时标签以 GitHub 为准。
|
||||
|
||||
下一阶段必须优先完成:
|
||||
|
||||
1. Issue #1(P1,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh)、`resource.*`(state/sync/verify/manifest)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||
2. `cmd/bat` Go CLI 骨架:当前只实现 `doctor`、`manifest inspect` 和 `sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。
|
||||
1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||
2. `bat-api` Go 侧:`internal/backendrpc` typed RPC client 已起步,`cmd/bat` 当前只实现 `doctor`、`manifest inspect` 和 `sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。
|
||||
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
|
||||
4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。
|
||||
5. Issue #2(P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
||||
@@ -283,7 +284,7 @@ GitHub issue 状态:当前 open 的是 #1(P1)、#2(P2)、#3(P2)、
|
||||
|
||||
立即任务:
|
||||
|
||||
1. Issue #1 收尾:协议基础设施、最小方法集及 `catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进。
|
||||
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`task.*`、`resource.repair`、任务持久化、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)以及 `task.create`、`daemon.restart`、`daemon.clean-stable` 的设计边界确认。
|
||||
2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`。
|
||||
3. 跟进官方同步长期运行测试,收集并归档运行报告。
|
||||
4. 开始 AssetBundle parser 的 UnityFS header/block/directory(issue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
|
||||
@@ -291,5 +292,5 @@ GitHub issue 状态:当前 open 的是 #1(P1)、#2(P2)、#3(P2)、
|
||||
---
|
||||
|
||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go 产品入口、`bat-api`、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go `bat-api` 已有 Rust daemon RPC client,但产品级 CLI/API、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
|
||||
- **下一工程里程碑**:Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。
|
||||
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 文档索引
|
||||
|
||||
- **更新时间**:2026-07-20
|
||||
- **更新时间**:2026-07-24
|
||||
- **说明**:本索引用于快速定位当前权威文档和历史资料。
|
||||
|
||||
---
|
||||
@@ -14,6 +14,7 @@
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `CHANGELOG.md`:版本变更记录。
|
||||
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。
|
||||
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
- `docs/architecture/README.md`:总体架构设计。
|
||||
- `docs/api/README.md`:API 设计入口。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `docs/guides/development.md`:开发指南。
|
||||
- `docs/guides/deployment.md`:部署指南。
|
||||
- `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。
|
||||
@@ -78,12 +80,13 @@
|
||||
3. `docs/guides/official-resource-test-pull.md`
|
||||
4. `docs/guides/official-full-pull-smoke.md`
|
||||
5. `docs/architecture/official-resource-backend.md`
|
||||
6. `docs/reports/CURRENT_GAPS.md`
|
||||
7. `docs/guides/baseline.md`
|
||||
8. `docs/architecture/README.md`
|
||||
9. `docs/guides/development.md`
|
||||
10. `CONTRIBUTING.md`
|
||||
11. `AGENTS.md`
|
||||
6. `docs/reference/rpc-backend-api.md`
|
||||
7. `docs/reports/CURRENT_GAPS.md`
|
||||
8. `docs/guides/baseline.md`
|
||||
9. `docs/architecture/README.md`
|
||||
10. `docs/guides/development.md`
|
||||
11. `CONTRIBUTING.md`
|
||||
12. `AGENTS.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -98,6 +101,7 @@
|
||||
- CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。
|
||||
- 文档整理和路线图重制。
|
||||
- Rust 官方资源同步闭环:`bat`、`--auto-discover`、`--watch`、`--daemon`、Unix socket JSON-RPC 后台控制、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor`、`clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。
|
||||
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
|
||||
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
|
||||
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
|
||||
- Addressables 当前真实形态 fixture/golden 覆盖。
|
||||
|
||||
+5
-5
@@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
||||
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
||||
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable` 运维命令。
|
||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`);`restart` 和 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
||||
9. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
|
||||
|
||||
@@ -40,7 +40,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。
|
||||
2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
|
||||
3. Go CLI/API/SDK 仍没有产品级入口;当前只有 `cmd/bat` 与 `internal/ffi` 的试验/兼容骨架。
|
||||
3. Go CLI/API/SDK 仍没有产品级入口;当前 `bat-api` module 只有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架与 `internal/ffi` 兼容包装。
|
||||
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
||||
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
|
||||
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
**目标**:完成可长期使用的 Content Addressable Storage。
|
||||
|
||||
**当前状态**:已完成 CAS V1。Go CLI 产品入口尚未完成,当前仅存在 `cmd/bat` 的试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
|
||||
**当前状态**:已完成 CAS V1。Go CLI/API 产品入口尚未完成;`bat-api` 已有 `internal/backendrpc` typed RPC client 和 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
|
||||
|
||||
交付物:
|
||||
|
||||
@@ -174,7 +174,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
|
||||
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
|
||||
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch`、`--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
|
||||
5. Go 产品入口:**未完成**。当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。
|
||||
5. Go 产品入口:**未完成**。`internal/backendrpc` 已提供 Go 调 Rust daemon 的 typed RPC client;当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
|
||||
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
||||
@@ -380,7 +380,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 6. 近期具体任务
|
||||
|
||||
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,Go `cmd/bat` 仍只是试验骨架,`bat-api` 是独立的 Go HTTP 服务目标(issue #19 / G-009):
|
||||
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,`bat-api/internal/backendrpc` 已提供 Go 到 Rust daemon 的本机 RPC client,Go `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009):
|
||||
|
||||
1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
|
||||
2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
||||
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go 侧目前只有试验性的 `cmd/bat` 骨架和 `internal/ffi` 兼容包装,产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go module 名为 `bat-api`,目前包含试验性的 `cmd/bat` 骨架、`internal/backendrpc` Rust daemon RPC client 和 `internal/ffi` 兼容包装;产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验。
|
||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、多线程下载、下载 quarantine 诊断、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` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`)。
|
||||
- `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
|
||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
|
||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||
@@ -80,7 +81,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--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-events.jsonl` 和短生命周期的 `bat-control.lock`;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`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/repair` 等控制命令:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- \
|
||||
@@ -94,7 +95,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
cargo run -p bat-infrastructure --bin bat -- stop
|
||||
```
|
||||
|
||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
|
||||
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
|
||||
@@ -156,6 +157,7 @@ BlueArchiveToolkit/
|
||||
│ ├── bat-assetbundle/
|
||||
│ ├── bat-patch/
|
||||
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层
|
||||
├── internal/backendrpc/ # Go -> Rust daemon 的 typed JSON-RPC client
|
||||
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
|
||||
├── cmd/ # Go CLI 试验骨架与后续产品入口
|
||||
├── pkg/ # Go SDK 包,尚未实现
|
||||
|
||||
+14
-7
@@ -84,7 +84,6 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
|
||||
| `--no-proxy` | 强制直连 |
|
||||
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
||||
| `--download-concurrency <N>` | 并行下载数,`1..=256`(默认 `4`)。并行仅作用于实际网络下载;manifest/quarantine 簿记与 seed `.hash` 校验仍串行,`fail-fast` 与「不发布不完整资源」不变量保留。可下载失败重试带指数退避以对官方 CDN 礼貌 |
|
||||
| `--dry-run` | 不写同步状态 |
|
||||
| `--plan` | dry-run 时输出计划中的 URL |
|
||||
| `--force` | 强制下载/刷新 |
|
||||
@@ -127,7 +126,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
|
||||
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。
|
||||
- 语法:每行 `KEY=VALUE`;`#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。
|
||||
- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
||||
- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
||||
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时 `.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。
|
||||
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`。
|
||||
- 设 `BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。
|
||||
@@ -240,7 +239,9 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
|
||||
## 6. Daemon RPC 接口
|
||||
|
||||
`bat --daemon` 在后台状态目录下创建 `bat.sock`(Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI。
|
||||
稳定 contract 以 `docs/reference/rpc-backend-api.md` 为准,本节保留常用说明和命令行示例。
|
||||
|
||||
`bat --daemon` 在后台状态目录下创建 `bat.sock`(Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh`/`repair` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI 或执行 `bat` binary 后再解析 stdout。
|
||||
|
||||
### 传输与 envelope
|
||||
|
||||
@@ -272,10 +273,12 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
| `daemon.stop` | ✅ | 请求停止(`accepted`) |
|
||||
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) |
|
||||
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) |
|
||||
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
|
||||
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
||||
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
|
||||
| `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` |
|
||||
| `resource.manifest` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) |
|
||||
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
||||
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) |
|
||||
| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) |
|
||||
| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
|
||||
| `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL) |
|
||||
@@ -284,14 +287,14 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
| `task.list` | ✅ | 列出全部任务(最新在前) |
|
||||
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
|
||||
| `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) |
|
||||
| `resource.repair` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);repair 待引擎支持独立修复模式,patch/unityfs 待引擎实现 |
|
||||
| `daemon.restart` / `daemon.clean-stable` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);restart/clean-stable 仍由 CLI 侧按进程生命周期显式执行,patch/unityfs 待引擎实现,task.create 暂不开放通用任务入口 |
|
||||
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
||||
|
||||
只读查询(`resource.state` / `resource.manifest` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
||||
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
||||
|
||||
### 任务模型
|
||||
|
||||
`resource.sync` / `resource.verify` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
||||
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
||||
|
||||
```json
|
||||
{ "id": "task-1234-1", "kind": "resource.sync",
|
||||
@@ -325,4 +328,8 @@ printf '{"jsonrpc":"2.0","id":4,"method":"catalog.versions"}\n' \
|
||||
# 分页读取当前版本的下载 manifest
|
||||
printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
|
||||
# 触发本地资源审计+修复任务
|
||||
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
```
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runDoctor() error {
|
||||
fmt.Println("bat doctor: ok")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "bat-api/internal/ffi"
|
||||
|
||||
func InspectManifest(rawJSON string) (string, error) {
|
||||
return ffi.InspectManifest(rawJSON)
|
||||
}
|
||||
|
||||
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
||||
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
||||
}
|
||||
|
||||
func batVersion() (string, error) {
|
||||
return ffi.Version()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "doctor":
|
||||
err = runDoctor()
|
||||
case "manifest":
|
||||
err = runManifest(os.Args[2:])
|
||||
case "sync":
|
||||
err = runSync(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("bat - BlueArchiveToolkit CLI")
|
||||
fmt.Println()
|
||||
fmt.Println("Usage:")
|
||||
fmt.Println(" bat doctor")
|
||||
fmt.Println(" bat manifest inspect <file>")
|
||||
fmt.Println(" bat sync plan <current-json> [previous-json]")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func runManifest(args []string) error {
|
||||
if len(args) < 2 || args[0] != "inspect" {
|
||||
return fmt.Errorf("usage: bat manifest inspect <file>")
|
||||
}
|
||||
|
||||
path := args[1]
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := InspectManifest(string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(result)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func runSync(args []string) error {
|
||||
if len(args) < 2 || args[0] != "plan" {
|
||||
return fmt.Errorf("usage: bat sync plan <current-json> [previous-json]")
|
||||
}
|
||||
|
||||
current := args[1]
|
||||
previous := ""
|
||||
if len(args) > 2 {
|
||||
previous = args[2]
|
||||
}
|
||||
|
||||
result, err := BuildSyncPlan(current, previous)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, result)
|
||||
return nil
|
||||
}
|
||||
@@ -189,7 +189,7 @@
|
||||
|
||||
集成边界:
|
||||
|
||||
1. 当前生产集成路径是运行 `bat --json` 并消费结构化 report;未来 Go CLI 若继续作为产品入口,也应优先使用该进程边界或 daemon RPC。
|
||||
1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api` 应优先通过 `internal/backendrpc` 调用 daemon RPC,one-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。
|
||||
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
|
||||
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
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-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` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 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` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 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 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
|
||||
对应实现主要在:
|
||||
|
||||
@@ -246,7 +246,7 @@ Linux 生产路径:
|
||||
- pull plan 会同时包含 discovery URLs 和 content URLs
|
||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--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` 中暴露下载进度、失败类型、HTTP 状态和调度状态
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list`、`catalog.*`、`task.*`);`restart` 与 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
|
||||
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
||||
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
||||
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
|
||||
@@ -280,6 +280,7 @@ Linux 生产路径:
|
||||
|
||||
daemon(`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket
|
||||
JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。
|
||||
稳定方法、schema 和错误语义以 `docs/reference/rpc-backend-api.md` 为准。
|
||||
|
||||
### 7.1 协议契约
|
||||
|
||||
@@ -289,22 +290,24 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
- `error` 为统一 `ApiError`:`code`(`BAT-ERR-<6 位>`)、`kind`、
|
||||
`domain`、`location`、`message`、`retryable`。码表以
|
||||
`core/src/error_code.rs` 为准。
|
||||
- 长任务(`resource.sync` / `resource.verify` / `catalog.refresh`)
|
||||
- 长任务(`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh`)
|
||||
入队即返回 `task_id`,经 `task.status` / `task.list` / `task.logs`
|
||||
轮询,`task.cancel` 协作式取消。任务执行器是单 worker FIFO,与
|
||||
watch 循环经进程内锁互斥。任务历史持久化于 `<state-dir>/bat-tasks.json`
|
||||
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
|
||||
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。
|
||||
- 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6:
|
||||
`daemon.*` / `resource.*` / `catalog.*` / `task.*` 已实现;
|
||||
`patch.*` / `unityfs.*` 待引擎;`task.create` / `resource.repair`
|
||||
按设计暂缓。
|
||||
`daemon.status/logs/stop/reload/refresh/doctor`、`resource.state/sync/verify/repair/manifest/list`、
|
||||
`catalog.*` 与 `task.status/list/cancel/logs` 已实现;`patch.*` / `unityfs.*`
|
||||
待引擎;`task.create` 按设计暂不开放通用任务入口;`daemon.restart` /
|
||||
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
|
||||
### 7.2 Go 层职责边界
|
||||
|
||||
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
|
||||
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个
|
||||
JSON-RPC 请求/响应)。当前 Go 产品入口尚未完成,`cmd/bat` 仍是试验骨架。
|
||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon
|
||||
(连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口
|
||||
尚未完成,`cmd/bat` 仍是试验骨架。
|
||||
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
|
||||
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
|
||||
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
|
||||
|
||||
@@ -43,8 +43,8 @@ go vet ./...
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前已有 `cmd/bat` 和 `internal/ffi` 骨架,但没有 Go 产品级测试覆盖;`go test ./...` 出现 `[no test files]` 不代表 CLI/API 已完成。
|
||||
2. 如果后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
1. 当前已有 `internal/backendrpc` fake socket 单测、`cmd/bat` 试验骨架和 `internal/ffi` 兼容包装;这些不代表 CLI/API 产品入口已完成。
|
||||
2. 后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。
|
||||
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
||||
|
||||
|
||||
@@ -250,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
|
||||
```
|
||||
|
||||
`--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`。
|
||||
`--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` 和默认形态的 `repair` 会优先连接 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。
|
||||
|
||||
@@ -287,7 +287,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor --output /var/lib/bluearchiv
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat clean-stable --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit
|
||||
```
|
||||
|
||||
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 等显式参数时,`refresh` 会作为一次性前台同步运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件,但同样不能和 live daemon 并行写同一资源目录;`clean-stable` 只清理 `.part`、`.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
|
||||
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;默认形态的 `repair` 会通过 `resource.repair` RPC 入队本地 manifest 审计+修复任务并返回 `task_id`。带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl、unzip 或其它显式同步参数时,`refresh` / `repair` 会作为一次性前台命令运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`clean-stable` 只清理 `.part`、`.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
|
||||
|
||||
### 升级
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Go 产品入口尚未完成,但仓库已有 `cmd/bat` 与 `internal/ffi` 骨架。提交前应运行 `go test ./...` 和 `go vet ./...`;目前输出可能只有 `[no test files]`,这代表缺少 Go 产品测试覆盖,不代表 Go CLI 已完成。
|
||||
Go 产品入口尚未完成,但仓库已有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架与 `internal/ffi` 兼容包装。提交前应运行 `go test ./...` 和 `go vet ./...`;`internal/backendrpc` 使用 fake transport 覆盖 JSON-RPC envelope、错误和 typed helper,不能把这些测试误认为 Go 产品级 CLI/API 已完成。
|
||||
|
||||
### 常用聚焦命令
|
||||
|
||||
@@ -160,7 +160,7 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets --
|
||||
|
||||
官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure` 和 `bat` 二进制测试。
|
||||
|
||||
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`;未来 Go 产品入口和生产同步默认应通过 Rust `bat --json` 或 daemon RPC 进程边界集成。
|
||||
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`;未来 Go 产品入口和生产同步默认应通过 `internal/backendrpc` 调用 daemon RPC,或在 one-shot/fallback 场景使用 Rust `bat --json` 进程边界。
|
||||
|
||||
### 集成测试
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ target/release/bat \
|
||||
--watch
|
||||
```
|
||||
|
||||
默认资源输出目录是 `./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`。
|
||||
默认资源输出目录是 `./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/repair` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`。
|
||||
|
||||
同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
|
||||
@@ -265,7 +265,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
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/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||
`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||
|
||||
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`:
|
||||
|
||||
@@ -308,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--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,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,已完成计数在并发下载时保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。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。
|
||||
默认平台是 `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,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,下载执行保持顺序处理,已完成计数保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。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 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Rust Resource Backend RPC API
|
||||
|
||||
本文档冻结本机 Rust Resource Backend API 的稳定调用边界。Go 项目
|
||||
`bat-api`、Go 服务层、运维脚本和 `bat` CLI 都应以这里的 JSON-RPC
|
||||
contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主路径。
|
||||
|
||||
## 传输
|
||||
|
||||
- 传输:Unix domain socket。
|
||||
- 默认 socket:`/tmp/bat-pid/bat.sock`。
|
||||
- 协议:JSON-RPC 2.0,每行一个 request,每行一个 response。
|
||||
- 编码:UTF-8 JSON。
|
||||
- 访问控制:依赖本机文件权限和状态目录权限;不要把 socket 暴露到公网。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"resource.repair","params":null}
|
||||
```
|
||||
|
||||
成功响应的 JSON-RPC 顶层 `result` 一律是应用层 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"status": "accepted",
|
||||
"data": {"task_id": "task-1234-1", "kind": "resource.repair"},
|
||||
"request_id": "req-1234-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
应用层失败也放在 `result` 的 envelope 中:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "BAT-ERR-700003",
|
||||
"kind": "not_implemented",
|
||||
"domain": "rpc",
|
||||
"location": "rpc.dispatch",
|
||||
"message": "方法尚未实现:daemon.clean-stable",
|
||||
"retryable": false
|
||||
},
|
||||
"request_id": "req-1234-2"
|
||||
}
|
||||
```
|
||||
|
||||
只有 JSON 解析失败等传输层错误使用 JSON-RPC 顶层 `error`。
|
||||
|
||||
## Envelope
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `ok` | bool | 应用层是否成功。 |
|
||||
| `status` | string | `ok`、`accepted` 或 `error`。 |
|
||||
| `data` | object/null | 成功结果。失败时省略。 |
|
||||
| `error` | object/null | `ApiError`。成功时省略。 |
|
||||
| `request_id` | string | daemon 进程内请求 ID,用于日志关联。 |
|
||||
|
||||
`ApiError` 结构以 `core/src/error_code.rs` 码表为准:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `code` | string | `BAT-ERR-<6位>`。 |
|
||||
| `kind` | string | 错误类别。 |
|
||||
| `domain` | string | 错误域。 |
|
||||
| `location` | string | Rust 侧出错位置。 |
|
||||
| `message` | string | 可诊断错误信息。 |
|
||||
| `retryable` | bool | 调用方是否可以按策略重试。 |
|
||||
|
||||
## 方法
|
||||
|
||||
### daemon
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
|
||||
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
|
||||
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
|
||||
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
|
||||
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
|
||||
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
|
||||
| `daemon.restart` | 保留 | `null` | live RPC 不执行;由 CLI 生命周期入口处理。 |
|
||||
| `daemon.clean-stable` | 保留 | `null` | live RPC 不执行;由 CLI 离线清理入口处理。 |
|
||||
|
||||
`bat.status`、`bat.stop`、`bat.reload`、`bat.refresh`、`bat.logs`、
|
||||
`bat.doctor`、`bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`。
|
||||
|
||||
### resource
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `resource.state` | 已实现 | `null` | 资源发布根、版本状态、上次同步结果。 |
|
||||
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
||||
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
||||
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
||||
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
|
||||
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
|
||||
|
||||
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`。
|
||||
`limit` 范围是 `1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
||||
|
||||
### catalog
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `catalog.status` | 已实现 | `null` | 当前已发布 catalog 概览。 |
|
||||
| `catalog.versions` | 已实现 | `null` | current / in_progress / previous / failed。 |
|
||||
| `catalog.diff` | 已实现 | `null` | 当前 snapshot 相对上一可用版本的差异。 |
|
||||
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
|
||||
|
||||
只读查询在没有可用版本时返回 `ok=true` 且 `data.available=false`。
|
||||
|
||||
### task
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `task.status` | 已实现 | `{ "task_id": "..." }` | 单个任务记录。 |
|
||||
| `task.list` | 已实现 | `null` | `{ "tasks": [...] }`。 |
|
||||
| `task.cancel` | 已实现 | `{ "task_id": "..." }` | cancel ack。 |
|
||||
| `task.logs` | 已实现 | `{ "task_id": "..." }` | `{ "task_id": "...", "lines": [...] }`。 |
|
||||
| `task.create` | 保留 | object | 不开放通用任务入口;由语义方法创建任务。 |
|
||||
|
||||
任务记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "task-1234-1",
|
||||
"kind": "resource.repair",
|
||||
"status": "queued",
|
||||
"stage": null,
|
||||
"message": null,
|
||||
"created_at": 1780000000,
|
||||
"updated_at": 1780000000,
|
||||
"started_at": null,
|
||||
"finished_at": null,
|
||||
"error": null,
|
||||
"result": null
|
||||
}
|
||||
```
|
||||
|
||||
`status` 取值:`queued`、`running`、`succeeded`、`failed`、`cancelled`。
|
||||
daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为
|
||||
`failed`,错误码为 `BAT-ERR-700005`。
|
||||
|
||||
### patch / unityfs
|
||||
|
||||
`patch.*` 和 `unityfs.*` 是已规划命名空间,目前返回
|
||||
`BAT-ERR-700003`。它们依赖后续 `bat-patch`、`bat-assetbundle`
|
||||
引擎,不作为 issue #1 的关闭阻塞项。
|
||||
|
||||
## Go 调用边界
|
||||
|
||||
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
|
||||
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
|
||||
在 daemon 可用时也会作为 RPC client 调用同一个 socket。
|
||||
|
||||
禁止事项:
|
||||
|
||||
- Go 服务层不直接读写 `bat-status.json`、`bat-tasks.json` 等 daemon 内部状态文件。
|
||||
- Go 服务层不扩展 `bat-ffi` 为主控制面。
|
||||
- Go 服务层不通过 stdout 解析 `bat status --json` 作为常规调用路径。
|
||||
|
||||
@@ -177,14 +177,14 @@
|
||||
现象:
|
||||
|
||||
- 当前可用的用户同步/运维入口是 Rust `bat` binary。
|
||||
- `cmd/bat` 已存在,但仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力;`doctor` 只输出固定 `ok`,`manifest`/`sync` 依赖可选 CGO/FFI helper。
|
||||
- `internal/backendrpc` 已提供 Go 到 Rust daemon 的 typed JSON-RPC client;`cmd/bat` 已存在,但仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力;`doctor` 只输出固定 `ok`,`manifest`/`sync` 依赖可选 CGO/FFI helper。
|
||||
- Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试。
|
||||
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md`、`PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
|
||||
|
||||
验收:
|
||||
|
||||
- `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。
|
||||
- `cmd/bat sync` 能通过 Rust `bat --json` 或 RPC 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
|
||||
- `cmd/bat sync` 能通过 `internal/backendrpc` daemon RPC 或 Rust `bat --json` one-shot/fallback 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
|
||||
- human/json 输出、退出码和错误码与 Rust `bat` 契约一致。
|
||||
- `go test ./...`、`go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。
|
||||
|
||||
@@ -470,11 +470,13 @@
|
||||
|
||||
## 6. 当前关闭顺序建议
|
||||
|
||||
1. issue #17/#20/#21/#22 验收并关闭或更新范围:多线程下载与指数退避实现已合入,daemon 子进程并发参数透传和单调进度上报已补齐,但 GitHub issue 仍 open。
|
||||
2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
|
||||
3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
|
||||
4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
|
||||
5. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`(issue #19)。
|
||||
6. G-012 / G-006:翻译系统、Patch 引擎。
|
||||
1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。
|
||||
2. issue #1:Rust daemon/backend API 边界收口;`resource.repair`、`resource.list`、`daemon.doctor`、`internal/backendrpc` Go client 和稳定 RPC reference 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。
|
||||
3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。
|
||||
4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
|
||||
5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
|
||||
6. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
|
||||
7. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`(issue #19)。
|
||||
8. G-012 / G-006:翻译系统、Patch 引擎。
|
||||
|
||||
这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按“不引入托管 CI”决策关闭。
|
||||
|
||||
@@ -641,13 +641,18 @@ struct DaemonRpcAck {
|
||||
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
||||
const RPC_METHOD_STATUS: &str = "daemon.status";
|
||||
const RPC_METHOD_STOP: &str = "daemon.stop";
|
||||
const RPC_METHOD_RESTART: &str = "daemon.restart";
|
||||
const RPC_METHOD_RELOAD: &str = "daemon.reload";
|
||||
const RPC_METHOD_REFRESH: &str = "daemon.refresh";
|
||||
const RPC_METHOD_LOGS: &str = "daemon.logs";
|
||||
const RPC_METHOD_DOCTOR: &str = "daemon.doctor";
|
||||
const RPC_METHOD_CLEAN_STABLE: &str = "daemon.clean-stable";
|
||||
const RPC_METHOD_RESOURCE_STATE: &str = "resource.state";
|
||||
const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync";
|
||||
const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify";
|
||||
const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair";
|
||||
const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest";
|
||||
const RPC_METHOD_RESOURCE_LIST: &str = "resource.list";
|
||||
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
|
||||
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
|
||||
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
||||
@@ -667,6 +672,7 @@ const MAX_TASK_LOG_LINES: usize = 200;
|
||||
enum TaskKind {
|
||||
Sync,
|
||||
Verify,
|
||||
Repair,
|
||||
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
||||
Refresh,
|
||||
}
|
||||
@@ -676,6 +682,7 @@ impl TaskKind {
|
||||
match self {
|
||||
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
||||
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
||||
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
|
||||
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
||||
}
|
||||
}
|
||||
@@ -695,6 +702,12 @@ impl TaskKind {
|
||||
config.repair = false;
|
||||
config.force = false;
|
||||
}
|
||||
Self::Repair => {
|
||||
config.dry_run = false;
|
||||
config.audit_local = true;
|
||||
config.repair = true;
|
||||
config.force = false;
|
||||
}
|
||||
Self::Refresh => {
|
||||
config.dry_run = true;
|
||||
config.plan = true;
|
||||
@@ -800,6 +813,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> {
|
||||
match kind {
|
||||
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
|
||||
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
|
||||
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
|
||||
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
|
||||
_ => None,
|
||||
}
|
||||
@@ -1233,21 +1247,28 @@ fn canonical_rpc_method(method: &str) -> &str {
|
||||
match method {
|
||||
"bat.status" => RPC_METHOD_STATUS,
|
||||
"bat.stop" => RPC_METHOD_STOP,
|
||||
"bat.restart" => RPC_METHOD_RESTART,
|
||||
"bat.reload" => RPC_METHOD_RELOAD,
|
||||
"bat.refresh" => RPC_METHOD_REFRESH,
|
||||
"bat.logs" => RPC_METHOD_LOGS,
|
||||
"bat.doctor" => RPC_METHOD_DOCTOR,
|
||||
"bat.clean-stable" => RPC_METHOD_CLEAN_STABLE,
|
||||
RPC_METHOD_RESOURCE_LIST => RPC_METHOD_RESOURCE_MANIFEST,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
|
||||
fn is_pending_rpc_method(method: &str) -> bool {
|
||||
// task.create:任务统一由 resource.sync / resource.verify / catalog.refresh
|
||||
// task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh
|
||||
// 等语义方法创建,通用创建接口暂不开放。
|
||||
// resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。
|
||||
// daemon.restart / daemon.clean-stable:CLI 侧按进程生命周期处理;
|
||||
// live RPC 内不做自重启或在线清理。
|
||||
// patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。
|
||||
matches!(method, "task.create" | "resource.repair")
|
||||
|| method.starts_with("patch.")
|
||||
matches!(
|
||||
method,
|
||||
"task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE
|
||||
) || method.starts_with("patch.")
|
||||
|| method.starts_with("unityfs.")
|
||||
}
|
||||
|
||||
@@ -1831,6 +1852,12 @@ fn dispatch_rpc_method(
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_DOCTOR => rpc_envelope_from_result(
|
||||
request_id,
|
||||
"daemon.doctor",
|
||||
build_doctor_report(state_dir, &tasks.base_config)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
),
|
||||
RPC_METHOD_STOP => {
|
||||
daemon_control_mark_stop_requested(control);
|
||||
let _ = update_daemon_state_only(state_dir, "stopping");
|
||||
@@ -1879,6 +1906,9 @@ fn dispatch_rpc_method(
|
||||
RPC_METHOD_RESOURCE_VERIFY => {
|
||||
enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id)
|
||||
}
|
||||
RPC_METHOD_RESOURCE_REPAIR => {
|
||||
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
|
||||
}
|
||||
RPC_METHOD_RESOURCE_MANIFEST => {
|
||||
let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
|
||||
Ok(page) => page,
|
||||
@@ -3006,19 +3036,36 @@ struct CommandReport<T> {
|
||||
}
|
||||
|
||||
fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
|
||||
if refresh_should_use_daemon_rpc(options, command_name)
|
||||
&& daemon_rpc_available(&options.state_dir)
|
||||
run_sync_command_with_rpc(options, command_name, daemon_rpc_available, daemon_rpc_call)
|
||||
}
|
||||
|
||||
fn run_sync_command_with_rpc(
|
||||
options: &CliOptions,
|
||||
command_name: &'static str,
|
||||
rpc_available: impl Fn(&Path) -> bool,
|
||||
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(rpc_method) =
|
||||
sync_command_rpc_method(options, command_name).filter(|_| rpc_available(&options.state_dir))
|
||||
{
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = daemon_rpc_call(
|
||||
&options.state_dir,
|
||||
RPC_METHOD_REFRESH,
|
||||
Some(serde_json::json!({ "force": options.config.force })),
|
||||
)?;
|
||||
let params = if rpc_method == RPC_METHOD_REFRESH {
|
||||
Some(serde_json::json!({ "force": options.config.force }))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let report = rpc_call(&options.state_dir, rpc_method, params)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_sync_command_foreground(options, command_name)
|
||||
}
|
||||
|
||||
fn run_sync_command_foreground(
|
||||
options: &CliOptions,
|
||||
command_name: &'static str,
|
||||
) -> anyhow::Result<()> {
|
||||
assert_no_live_daemon_output_conflict(options, command_name)?;
|
||||
let mut config = options.config.clone();
|
||||
if command_name == "repair" {
|
||||
@@ -3044,13 +3091,9 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
|
||||
if command_name != "refresh" {
|
||||
return false;
|
||||
}
|
||||
fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> {
|
||||
let defaults = OfficialUpdateConfig::default();
|
||||
options.command == CliCommand::Refresh
|
||||
&& !options.watch
|
||||
let default_daemon_shape = !options.watch
|
||||
&& !options.daemon
|
||||
&& !options.daemon_child
|
||||
&& !options.output_explicit
|
||||
@@ -3068,7 +3111,19 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo
|
||||
&& !options.config.dry_run
|
||||
&& !options.config.plan
|
||||
&& options.config.audit_local == defaults.audit_local
|
||||
&& options.config.repair == defaults.repair
|
||||
&& options.config.repair == defaults.repair;
|
||||
if !default_daemon_shape {
|
||||
return None;
|
||||
}
|
||||
match (options.command, command_name) {
|
||||
(CliCommand::Refresh, "refresh") => Some(RPC_METHOD_REFRESH),
|
||||
(CliCommand::Repair, "repair") if !options.config.force => Some(RPC_METHOD_RESOURCE_REPAIR),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
|
||||
sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH)
|
||||
}
|
||||
|
||||
fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
|
||||
@@ -3806,30 +3861,29 @@ struct DoctorReport {
|
||||
checks: Vec<DoctorCheck>,
|
||||
}
|
||||
|
||||
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
fn build_doctor_report(
|
||||
state_dir: &Path,
|
||||
config: &OfficialUpdateConfig,
|
||||
) -> anyhow::Result<DoctorReport> {
|
||||
let mut checks = vec![
|
||||
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
|
||||
path_check(
|
||||
"output_root",
|
||||
&options.config.output_root,
|
||||
"资源输出目录可用",
|
||||
),
|
||||
path_check("state_dir", state_dir, "后台状态目录可用"),
|
||||
path_check("output_root", &config.output_root, "资源输出目录可用"),
|
||||
safety_check(
|
||||
"output_root_safety",
|
||||
validate_output_root(&options.config.output_root),
|
||||
validate_output_root(&config.output_root),
|
||||
"资源输出目录安全边界通过",
|
||||
),
|
||||
safety_check(
|
||||
"state_dir_safety",
|
||||
validate_runtime_state_dir(&options.state_dir),
|
||||
validate_runtime_state_dir(state_dir),
|
||||
"后台状态目录安全边界通过",
|
||||
),
|
||||
command_check("curl", &options.config.curl_command),
|
||||
proxy_check(&options.config.curl_proxy),
|
||||
command_check("unzip", &options.config.unzip_command),
|
||||
command_check("curl", &config.curl_command),
|
||||
proxy_check(&config.curl_proxy),
|
||||
command_check("unzip", &config.unzip_command),
|
||||
];
|
||||
|
||||
let pid_path = daemon_pid_path(&options.state_dir);
|
||||
let pid_path = daemon_pid_path(state_dir);
|
||||
let daemon_running = read_pid_file(&pid_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -3857,9 +3911,9 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
}),
|
||||
}
|
||||
|
||||
let socket_path = daemon_socket_path(&options.state_dir);
|
||||
let socket_path = daemon_socket_path(state_dir);
|
||||
let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false);
|
||||
let socket_available = daemon_rpc_available(&options.state_dir);
|
||||
let socket_available = daemon_rpc_available(state_dir);
|
||||
checks.push(DoctorCheck {
|
||||
name: "daemon_rpc",
|
||||
ok: if daemon_running {
|
||||
@@ -3884,7 +3938,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
},
|
||||
});
|
||||
|
||||
let lock_path = options.config.lock_path();
|
||||
let lock_path = config.lock_path();
|
||||
checks.push(match classify_pid_lock_file(&lock_path)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
@@ -3917,7 +3971,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
},
|
||||
});
|
||||
|
||||
let control_lock_path = daemon_control_lock_path(&options.state_dir);
|
||||
let control_lock_path = daemon_control_lock_path(state_dir);
|
||||
checks.push(match classify_pid_lock_file(&control_lock_path)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
@@ -3950,7 +4004,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
},
|
||||
});
|
||||
|
||||
let status_path = daemon_status_path(&options.state_dir);
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
// ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。
|
||||
let daemon_status_result = read_daemon_status_file(&status_path);
|
||||
checks.push(DoctorCheck {
|
||||
@@ -3964,7 +4018,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
});
|
||||
|
||||
let healthy = checks.iter().all(|check| check.ok);
|
||||
let report = DoctorReport {
|
||||
Ok(DoctorReport {
|
||||
command: "doctor",
|
||||
status: if healthy { "ok" } else { "issues_found" },
|
||||
message: if healthy {
|
||||
@@ -3974,7 +4028,12 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
},
|
||||
healthy,
|
||||
checks,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
let report = build_doctor_report(&options.state_dir, &options.config)?;
|
||||
let healthy = report.healthy;
|
||||
print_report(options.output_format, &report)?;
|
||||
Ok(healthy)
|
||||
}
|
||||
@@ -4471,8 +4530,6 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||
}
|
||||
args.push("--curl".to_string());
|
||||
args.push(config.curl_command.to_string_lossy().to_string());
|
||||
args.push("--download-concurrency".to_string());
|
||||
args.push(config.download_concurrency.to_string());
|
||||
match config.curl_proxy.mode() {
|
||||
CurlProxyMode::Auto if options.proxy_option_explicit => {
|
||||
args.push("--proxy".to_string());
|
||||
@@ -4959,9 +5016,6 @@ BAT_AUTO_DISCOVER=1
|
||||
# 正常检查间隔与失败重试间隔(秒)
|
||||
#BAT_INTERVAL_SECONDS=3600
|
||||
#BAT_ERROR_RETRY_SECONDS=60
|
||||
# 下载并发度(并行网络下载数,1~256;默认 4,对官方 CDN 礼貌)
|
||||
#BAT_DOWNLOAD_CONCURRENCY=4
|
||||
|
||||
# ---- 网络 ----
|
||||
# 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。
|
||||
# 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。
|
||||
@@ -5145,9 +5199,6 @@ fn apply_bat_env_overrides(
|
||||
if let Some(v) = value("BAT_UNZIP") {
|
||||
options.config.unzip_command = PathBuf::from(v);
|
||||
}
|
||||
if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") {
|
||||
options.config.download_concurrency = parse_download_concurrency(&v)?;
|
||||
}
|
||||
if let Some(v) = value("BAT_PROXY") {
|
||||
options.config.curl_proxy = parse_proxy_config(&v)?;
|
||||
}
|
||||
@@ -5325,11 +5376,6 @@ fn parse_args_with_env(
|
||||
"--unzip" => {
|
||||
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--download-concurrency" => {
|
||||
let value = next_option_value(&mut args, &flag)?;
|
||||
options.config.download_concurrency = parse_download_concurrency(&value)?;
|
||||
options.sync_option_explicit = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
options.config.dry_run = true;
|
||||
options.sync_option_explicit = true;
|
||||
@@ -5668,7 +5714,6 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
|
||||
eprintln!(" --no-proxy Force direct curl connections");
|
||||
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
|
||||
eprintln!(" --download-concurrency <N> Parallel downloads, 1..=256 (default: 4)");
|
||||
eprintln!(" --dry-run Do not write sync state");
|
||||
eprintln!(" --plan Include planned URLs in dry-run");
|
||||
eprintln!(" --force Force download/refresh");
|
||||
@@ -5743,17 +5788,6 @@ fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 解析下载并发度:正整数,钳制到 `1..=256`。
|
||||
fn parse_download_concurrency(value: &str) -> anyhow::Result<usize> {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?;
|
||||
if parsed == 0 {
|
||||
return Err(anyhow::anyhow!("下载并发度必须大于 0"));
|
||||
}
|
||||
Ok(parsed.min(256))
|
||||
}
|
||||
|
||||
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"windows" | "win" => Ok(PatchPlatform::Windows),
|
||||
@@ -5944,11 +5978,13 @@ mod tests {
|
||||
"404",
|
||||
));
|
||||
});
|
||||
let repair_id = registry.create(TaskKind::Repair);
|
||||
registry.update(&repair_id, |record| record.status = "succeeded");
|
||||
drop(registry);
|
||||
|
||||
// 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。
|
||||
let (registry, summary) = TaskRegistry::with_persistence(state_dir);
|
||||
assert!(summary.contains("恢复任务历史 3 条"), "{summary}");
|
||||
assert!(summary.contains("恢复任务历史 4 条"), "{summary}");
|
||||
assert!(summary.contains("标记中断 1 条"), "{summary}");
|
||||
let finished = registry.get(&finished_id).unwrap();
|
||||
assert_eq!(finished.status, "succeeded");
|
||||
@@ -5969,16 +6005,19 @@ mod tests {
|
||||
failed.error.as_ref().unwrap().code(),
|
||||
ErrorCode::HTTP_NOT_FOUND
|
||||
);
|
||||
let repair = registry.get(&repair_id).unwrap();
|
||||
assert_eq!(repair.kind, "resource.repair");
|
||||
assert_eq!(repair.status, "succeeded");
|
||||
|
||||
// seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。
|
||||
let new_id = registry.create(TaskKind::Sync);
|
||||
assert!(
|
||||
[&finished_id, &running_id, &failed_id]
|
||||
[&finished_id, &running_id, &failed_id, &repair_id]
|
||||
.iter()
|
||||
.all(|id| **id != new_id),
|
||||
"新任务 ID {new_id} 与历史撞号"
|
||||
);
|
||||
assert_eq!(registry.list().len(), 4);
|
||||
assert_eq!(registry.list().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6036,51 +6075,6 @@ mod tests {
|
||||
assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_download_concurrency_flag_and_env() {
|
||||
// 默认值(与 OfficialUpdateConfig 默认一致)。
|
||||
assert_eq!(
|
||||
parse(&["bat"]).unwrap().config.download_concurrency,
|
||||
OfficialUpdateConfig::default().download_concurrency
|
||||
);
|
||||
// CLI 显式设置 + 上限钳制。
|
||||
assert_eq!(
|
||||
parse(&["bat", "--download-concurrency", "16"])
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
16
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["bat", "--download-concurrency", "9999"])
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
256
|
||||
);
|
||||
// 0 与非数字报错。
|
||||
assert!(parse(&["bat", "--download-concurrency", "0"]).is_err());
|
||||
assert!(parse(&["bat", "--download-concurrency", "abc"]).is_err());
|
||||
// 环境变量(含 .env)设置默认值;CLI 覆盖之。
|
||||
assert_eq!(
|
||||
parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")])
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
parse_with_env(
|
||||
&["bat", "--download-concurrency", "3"],
|
||||
&[("BAT_DOWNLOAD_CONCURRENCY", "12")]
|
||||
)
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_auto_discover_sync_args() {
|
||||
let options = parse(&[
|
||||
@@ -6326,8 +6320,6 @@ mod tests {
|
||||
"http://127.0.0.1:7890",
|
||||
"--unzip",
|
||||
"/usr/bin/unzip",
|
||||
"--download-concurrency",
|
||||
"40",
|
||||
"--interval",
|
||||
"30m",
|
||||
"--error-retry",
|
||||
@@ -6350,9 +6342,6 @@ mod tests {
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--platforms", "Windows,Android"]));
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--download-concurrency", "40"]));
|
||||
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
|
||||
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
|
||||
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
|
||||
@@ -6443,8 +6432,13 @@ mod tests {
|
||||
#[test]
|
||||
fn canonical_rpc_method_resolves_aliases() {
|
||||
assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS);
|
||||
assert_eq!(canonical_rpc_method("bat.doctor"), RPC_METHOD_DOCTOR);
|
||||
assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH);
|
||||
assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS);
|
||||
assert_eq!(
|
||||
canonical_rpc_method("resource.list"),
|
||||
RPC_METHOD_RESOURCE_MANIFEST
|
||||
);
|
||||
assert_eq!(canonical_rpc_method("resource.state"), "resource.state");
|
||||
assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method");
|
||||
}
|
||||
@@ -6454,17 +6448,20 @@ mod tests {
|
||||
assert!(is_pending_rpc_method("patch.apply"));
|
||||
assert!(is_pending_rpc_method("unityfs.inspect"));
|
||||
assert!(is_pending_rpc_method("task.create"));
|
||||
assert!(is_pending_rpc_method("resource.repair"));
|
||||
// sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现,
|
||||
// 不再是 pending。
|
||||
assert!(is_pending_rpc_method("daemon.restart"));
|
||||
assert!(is_pending_rpc_method("daemon.clean-stable"));
|
||||
// sync/verify/repair、task.cancel/logs、catalog.* 与 resource.manifest 已实现。
|
||||
assert!(!is_pending_rpc_method("resource.sync"));
|
||||
assert!(!is_pending_rpc_method("resource.verify"));
|
||||
assert!(!is_pending_rpc_method("resource.repair"));
|
||||
assert!(!is_pending_rpc_method("resource.manifest"));
|
||||
assert!(!is_pending_rpc_method("resource.list"));
|
||||
assert!(!is_pending_rpc_method("catalog.status"));
|
||||
assert!(!is_pending_rpc_method("catalog.refresh"));
|
||||
assert!(!is_pending_rpc_method("task.cancel"));
|
||||
assert!(!is_pending_rpc_method("task.logs"));
|
||||
assert!(!is_pending_rpc_method("daemon.status"));
|
||||
assert!(!is_pending_rpc_method("daemon.doctor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6577,6 +6574,36 @@ mod tests {
|
||||
assert_eq!(value["error"]["code"], "BAT-ERR-700004");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_daemon_doctor_returns_report() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
let mut base_config = OfficialUpdateConfig::default();
|
||||
base_config.output_root = output_root;
|
||||
let (queue, _rx) = mpsc::channel::<TaskJob>();
|
||||
let context = DaemonTaskContext {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
};
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("daemon.doctor", None),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&context,
|
||||
"req-doctor-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["status"], "ok");
|
||||
assert_eq!(value["data"]["command"], "doctor");
|
||||
assert!(value["data"]["healthy"].is_boolean());
|
||||
let checks = value["data"]["checks"].as_array().unwrap();
|
||||
assert!(checks.iter().any(|check| check["name"] == "daemon_rpc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_resource_sync_enqueues_task() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
@@ -6629,6 +6656,48 @@ mod tests {
|
||||
assert_eq!(status_value["data"]["status"], "queued");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_resource_repair_enqueues_repair_task() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let control = new_daemon_control();
|
||||
// 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。
|
||||
let (queue, rx) = mpsc::channel::<TaskJob>();
|
||||
let mut base_config = OfficialUpdateConfig::default();
|
||||
base_config.force = true;
|
||||
base_config.dry_run = true;
|
||||
base_config.audit_local = false;
|
||||
base_config.repair = false;
|
||||
let context = DaemonTaskContext {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
};
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("resource.repair", None),
|
||||
temp.path(),
|
||||
&control,
|
||||
&context,
|
||||
"req-repair-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["status"], "accepted");
|
||||
assert_eq!(value["data"]["kind"], "resource.repair");
|
||||
let task_id = value["data"]["task_id"].as_str().unwrap();
|
||||
let record = context.registry.get(task_id).unwrap();
|
||||
assert_eq!(record.kind, "resource.repair");
|
||||
assert_eq!(record.status, "queued");
|
||||
|
||||
// repair 任务强制开启本地审计+修复,但不会继承 force/dry-run。
|
||||
let job = rx.try_recv().unwrap();
|
||||
assert_eq!(job.id, task_id);
|
||||
assert!(!job.config.dry_run);
|
||||
assert!(job.config.audit_local);
|
||||
assert!(job.config.repair);
|
||||
assert!(!job.config.force);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_registry_create_update_and_prune() {
|
||||
let registry = TaskRegistry::new();
|
||||
@@ -7167,21 +7236,79 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_rpc_selection_only_for_default_daemon_shape() {
|
||||
fn sync_command_rpc_selection_only_for_default_daemon_shape() {
|
||||
let options = parse(&["bat", "refresh"]).unwrap();
|
||||
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
|
||||
assert_eq!(
|
||||
sync_command_rpc_method(&options, "refresh"),
|
||||
Some(RPC_METHOD_REFRESH)
|
||||
);
|
||||
|
||||
let options = parse(&["bat", "refresh", "--force"]).unwrap();
|
||||
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
|
||||
assert_eq!(
|
||||
sync_command_rpc_method(&options, "refresh"),
|
||||
Some(RPC_METHOD_REFRESH)
|
||||
);
|
||||
|
||||
let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap();
|
||||
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
|
||||
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
|
||||
|
||||
let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap();
|
||||
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
|
||||
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
|
||||
|
||||
let options = parse(&["bat", "repair"]).unwrap();
|
||||
assert!(!refresh_should_use_daemon_rpc(&options, "repair"));
|
||||
assert_eq!(
|
||||
sync_command_rpc_method(&options, "repair"),
|
||||
Some(RPC_METHOD_RESOURCE_REPAIR)
|
||||
);
|
||||
|
||||
let options = parse(&["bat", "repair", "--output", "/tmp/other"]).unwrap();
|
||||
assert_eq!(sync_command_rpc_method(&options, "repair"), None);
|
||||
|
||||
let options = parse(&["bat", "repair", "--force"]).unwrap();
|
||||
assert_eq!(sync_command_rpc_method(&options, "repair"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_command_uses_rpc_when_default_daemon_shape() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let seen = Arc::new(Mutex::new(Vec::<(String, Option<serde_json::Value>)>::new()));
|
||||
let seen_calls = Arc::clone(&seen);
|
||||
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"repair",
|
||||
"--json",
|
||||
"--state-dir",
|
||||
state_dir.to_str().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
run_sync_command_with_rpc(
|
||||
&options,
|
||||
"repair",
|
||||
|_| true,
|
||||
move |_state_dir, method, params| {
|
||||
seen_calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((method.to_string(), params.clone()));
|
||||
Ok(serde_json::json!({
|
||||
"task_id": "task-fake-repair",
|
||||
"kind": "resource.repair",
|
||||
}))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_REPAIR);
|
||||
assert_eq!(seen[0].1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7666,17 +7793,34 @@ mod tests {
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], false);
|
||||
assert_eq!(value["error"]["code"], "BAT-ERR-700002");
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"resource.list",
|
||||
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
"req-man-3".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["data"]["available"], true);
|
||||
let entries = value["data"]["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0]["destination"], "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_resource_repair_reports_not_implemented() {
|
||||
fn dispatch_daemon_clean_stable_reports_not_implemented() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("resource.repair", None),
|
||||
&rpc_request("daemon.clean-stable", None),
|
||||
temp.path(),
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
"req-rep-1".to_string(),
|
||||
"req-clean-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], false);
|
||||
|
||||
@@ -445,8 +445,8 @@ fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
|
||||
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
|
||||
///
|
||||
/// - `ETXTBSY`(fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
|
||||
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),并发下载
|
||||
/// 时对官方 CDN 更礼貌,避免 N 个连接失败后同时立即重发。
|
||||
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免
|
||||
/// 连续失败后立即重发。
|
||||
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
|
||||
if busy {
|
||||
return 5 * attempt as u64;
|
||||
|
||||
@@ -16,9 +16,6 @@ use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
|
||||
@@ -64,10 +61,6 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
|
||||
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
|
||||
const DEFAULT_RETRY_ATTEMPTS: usize = 3;
|
||||
/// 默认下载并发度。保守取值,对官方 CDN 礼貌;可经配置调到 1..=256。
|
||||
pub(crate) const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 4;
|
||||
/// 下载并发度上限。
|
||||
const MAX_DOWNLOAD_CONCURRENCY: usize = 256;
|
||||
|
||||
/// Outcome for one official resource pull item.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -493,7 +486,6 @@ pub struct OfficialResourcePullService {
|
||||
curl_command: PathBuf,
|
||||
curl_proxy: CurlProxyConfig,
|
||||
retry_attempts: usize,
|
||||
download_concurrency: usize,
|
||||
}
|
||||
|
||||
impl OfficialResourcePullService {
|
||||
@@ -512,7 +504,6 @@ impl OfficialResourcePullService {
|
||||
curl_command: curl_command.into(),
|
||||
curl_proxy: CurlProxyConfig::default(),
|
||||
retry_attempts: DEFAULT_RETRY_ATTEMPTS,
|
||||
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,17 +519,6 @@ impl OfficialResourcePullService {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置下载并发度(并行执行的网络下载数),钳制到 `1..=256`。
|
||||
pub fn with_download_concurrency(mut self, concurrency: usize) -> Self {
|
||||
self.download_concurrency = concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回当前下载并发度。
|
||||
pub fn download_concurrency(&self) -> usize {
|
||||
self.download_concurrency
|
||||
}
|
||||
|
||||
/// Returns the output root used for downloaded files.
|
||||
pub fn output_root(&self) -> &Path {
|
||||
&self.output_root
|
||||
@@ -625,11 +605,8 @@ impl OfficialResourcePullService {
|
||||
});
|
||||
}
|
||||
|
||||
// Phase B:并发下载 need-download 项(各 URL 目标/`.part` 相互独立,
|
||||
// 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回
|
||||
// 主线程;manifest/quarantine 簿记与进度回调全部在主线程串行完成,无需
|
||||
// 加锁。首个失败或 `should_cancel` 会置 cancel 标志,其余 worker 在下一
|
||||
// 个任务边界停止,保持 fail-fast 与「不发布不完整资源」不变量。
|
||||
// Phase B:顺序下载 need-download 项。每个 URL 的目标和 `.part` 都是独立
|
||||
// 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。
|
||||
let download_indices: Vec<usize> = planned
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -640,113 +617,68 @@ impl OfficialResourcePullService {
|
||||
(0..planned.len()).map(|_| None).collect();
|
||||
let mut completed_count = 0usize;
|
||||
|
||||
if !download_indices.is_empty() {
|
||||
let concurrency = self.download_concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
|
||||
let cursor = AtomicUsize::new(0);
|
||||
let cancel = AtomicBool::new(false);
|
||||
let (sender, receiver) = mpsc::channel::<WorkerMessage>();
|
||||
|
||||
thread::scope(|scope| {
|
||||
for _ in 0..concurrency.min(download_indices.len()) {
|
||||
let sender = sender.clone();
|
||||
let cursor = &cursor;
|
||||
let cancel = &cancel;
|
||||
let download_indices = &download_indices;
|
||||
let planned = &planned;
|
||||
let service = &*self;
|
||||
scope.spawn(move || loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let slot = cursor.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(&plan_index) = download_indices.get(slot) else {
|
||||
break;
|
||||
};
|
||||
let item = &planned[plan_index];
|
||||
if sender.send(WorkerMessage::Started { plan_index }).is_err() {
|
||||
break;
|
||||
}
|
||||
let result = service.pull_one(&item.url, &item.destination);
|
||||
if result.is_err() {
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if sender
|
||||
.send(WorkerMessage::Done { plan_index, result })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
// 主线程持有的 sender 副本必须丢弃,否则 receiver 永不结束。
|
||||
drop(sender);
|
||||
|
||||
for message in receiver {
|
||||
match message {
|
||||
WorkerMessage::Started { plan_index } => {
|
||||
let item = &planned[plan_index];
|
||||
progress(OfficialResourcePullProgress::started(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
));
|
||||
// 停止请求:置 cancel,让 worker 在下个任务边界退出。
|
||||
if should_cancel() {
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
WorkerMessage::Done { plan_index, result } => {
|
||||
if let Ok(pull_result) = &result {
|
||||
let item = &planned[plan_index];
|
||||
// manifest/quarantine 簿记在主线程串行执行。
|
||||
if let Err(error) = self
|
||||
.clear_quarantine_entry(&item.url)
|
||||
.and_then(|_| {
|
||||
self.record_download_manifest_entry(
|
||||
&mut manifest,
|
||||
&item.url,
|
||||
&item.destination,
|
||||
)
|
||||
})
|
||||
.and_then(|_| self.write_download_manifest(&manifest))
|
||||
{
|
||||
// 簿记失败:记为该项错误并触发 fail-fast。
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
download_results[plan_index] = Some(Err(PullOneError::plain(
|
||||
format!("记录下载 manifest 失败:{error}"),
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
completed_count += 1;
|
||||
progress(OfficialResourcePullProgress::finished(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
pull_result.status,
|
||||
pull_result.bytes,
|
||||
pull_result.transferred_bytes,
|
||||
));
|
||||
}
|
||||
download_results[plan_index] = Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if should_cancel() && download_results.iter().any(Option::is_none) {
|
||||
for &plan_index in &download_indices {
|
||||
let item = &planned[plan_index];
|
||||
progress(OfficialResourcePullProgress::started(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
));
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
|
||||
// fail-fast:按 plan 顺序取首个失败项,记 quarantine、发 failed 进度并中止。
|
||||
for &plan_index in &download_indices {
|
||||
if let Some(Some(Err(error))) = download_results.get(plan_index) {
|
||||
let item = &planned[plan_index];
|
||||
self.record_quarantine_entry(&item.url, &item.destination, error)?;
|
||||
let result = self.pull_one(&item.url, &item.destination);
|
||||
match result {
|
||||
Ok(pull_result) => {
|
||||
if let Err(error) = self
|
||||
.clear_quarantine_entry(&item.url)
|
||||
.and_then(|_| {
|
||||
self.record_download_manifest_entry(
|
||||
&mut manifest,
|
||||
&item.url,
|
||||
&item.destination,
|
||||
)
|
||||
})
|
||||
.and_then(|_| self.write_download_manifest(&manifest))
|
||||
{
|
||||
let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
|
||||
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
|
||||
progress(OfficialResourcePullProgress::failed(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
&error,
|
||||
));
|
||||
return Err(DownloadError::new(
|
||||
error.error_code(),
|
||||
format!(
|
||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={};{}",
|
||||
item.url,
|
||||
self.download_quarantine_path().display(),
|
||||
error.message
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
completed_count += 1;
|
||||
progress(OfficialResourcePullProgress::finished(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
pull_result.status,
|
||||
pull_result.bytes,
|
||||
pull_result.transferred_bytes,
|
||||
));
|
||||
download_results[plan_index] = Some(Ok(pull_result));
|
||||
}
|
||||
Err(error) => {
|
||||
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
|
||||
progress(OfficialResourcePullProgress::failed(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
error,
|
||||
&error,
|
||||
));
|
||||
return Err(DownloadError::new(
|
||||
error.error_code(),
|
||||
@@ -1771,17 +1703,6 @@ struct PlannedDownload {
|
||||
existing: Option<PullOneResult>,
|
||||
}
|
||||
|
||||
/// worker 线程经 mpsc 送回主线程的消息。
|
||||
enum WorkerMessage {
|
||||
/// worker 已领取某计划项、即将下载(主线程据此发 started 进度)。
|
||||
Started { plan_index: usize },
|
||||
/// 某计划项下载结束(成功或失败)。
|
||||
Done {
|
||||
plan_index: usize,
|
||||
result: Result<PullOneResult, PullOneError>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PullOneResult {
|
||||
bytes: u64,
|
||||
@@ -3084,16 +3005,14 @@ exit 22
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_run_concurrently_and_each_url_reports_once() {
|
||||
fn downloads_run_sequentially_and_each_url_reports_once() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
let bin_dir = TempDir::new().unwrap();
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
write_fake_curl(&curl_path);
|
||||
|
||||
// 并发度 8:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
|
||||
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
|
||||
.with_download_concurrency(8);
|
||||
assert_eq!(service.download_concurrency(), 8);
|
||||
// 顺序下载:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
|
||||
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
||||
let plan = build_official_pull_plan_for_platforms(
|
||||
discovery_plan(),
|
||||
inventory(),
|
||||
@@ -3127,24 +3046,6 @@ exit 22
|
||||
assert_eq!(manifest.entries.len(), all_urls.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_concurrency_is_clamped() {
|
||||
let service = OfficialResourcePullService::with_curl_command("/tmp/unused", "curl");
|
||||
assert_eq!(service.download_concurrency(), DEFAULT_DOWNLOAD_CONCURRENCY);
|
||||
assert_eq!(
|
||||
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
|
||||
.with_download_concurrency(0)
|
||||
.download_concurrency(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
|
||||
.with_download_concurrency(9999)
|
||||
.download_concurrency(),
|
||||
MAX_DOWNLOAD_CONCURRENCY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_transient_download_failures() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -96,8 +96,6 @@ pub struct OfficialUpdateConfig {
|
||||
pub audit_local: bool,
|
||||
/// Repair local files when the local manifest audit fails.
|
||||
pub repair: bool,
|
||||
/// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。
|
||||
pub download_concurrency: usize,
|
||||
}
|
||||
|
||||
impl Default for OfficialUpdateConfig {
|
||||
@@ -119,7 +117,6 @@ impl Default for OfficialUpdateConfig {
|
||||
force: false,
|
||||
audit_local: true,
|
||||
repair: true,
|
||||
download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,8 +898,7 @@ impl OfficialUpdateService {
|
||||
&active_resource_root,
|
||||
&config.curl_command,
|
||||
)
|
||||
.with_proxy_config(config.curl_proxy.clone())
|
||||
.with_download_concurrency(config.download_concurrency);
|
||||
.with_proxy_config(config.curl_proxy.clone());
|
||||
let snapshot_path = snapshot_path_for(config, &active_resource_root);
|
||||
let bootstrap_cache_path = config.bootstrap_cache_path();
|
||||
|
||||
@@ -1341,8 +1337,7 @@ impl OfficialUpdateService {
|
||||
&publish_plan.staging_path,
|
||||
&config.curl_command,
|
||||
)
|
||||
.with_proxy_config(config.curl_proxy.clone())
|
||||
.with_download_concurrency(config.download_concurrency);
|
||||
.with_proxy_config(config.curl_proxy.clone());
|
||||
report.staging_path = Some(publish_plan.staging_path.clone());
|
||||
report.snapshot_path = staging_snapshot_path.clone();
|
||||
report.download_manifest = staging_fetcher.download_manifest_path();
|
||||
|
||||
@@ -330,6 +330,75 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
|
||||
assert!(version_state.in_progress_version.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_update_reuses_failed_staging_after_interrupted_download() {
|
||||
let harness = TestHarness::new();
|
||||
let bootstrap = harness.fetch_bootstrap();
|
||||
let fetcher = harness.fetcher();
|
||||
let server_info_url = bootstrap
|
||||
.game_main_config
|
||||
.server_info_data_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let server_info_bytes = fetcher.fetch_bytes(&server_info_url).unwrap();
|
||||
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap();
|
||||
let discovery = server_info
|
||||
.discovery_plan(
|
||||
bootstrap
|
||||
.game_main_config
|
||||
.default_connection_group
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
&bootstrap.game_config.game_latest_version,
|
||||
&verified_official_platforms(),
|
||||
)
|
||||
.unwrap();
|
||||
let inventory = fetch_platform_inventory(&fetcher, &discovery);
|
||||
let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory);
|
||||
let all_urls = plan.all_urls().unwrap();
|
||||
let resume_url = all_urls
|
||||
.iter()
|
||||
.find(|url| url.ends_with("/Windows_PatchPack/catalog_StandaloneWindows64.zip"))
|
||||
.expect("fixture plan should contain Windows addressables catalog zip")
|
||||
.clone();
|
||||
let resources_assets_path = harness.temp.path().join("resources.assets");
|
||||
let zip_fixture = harness.temp.path().join("downloaded.zip");
|
||||
let curl_failure_state = harness.temp.path().join("curl-failure.state");
|
||||
write_executable(
|
||||
&harness.curl_script,
|
||||
&official_curl_script(
|
||||
&harness.curl_log,
|
||||
&zip_fixture,
|
||||
&resources_assets_path,
|
||||
fs::metadata(&resources_assets_path).unwrap().len() as usize,
|
||||
TEST_LAUNCHER_MANIFEST_SOURCE,
|
||||
Some(&curl_failure_state),
|
||||
),
|
||||
);
|
||||
|
||||
let config = harness.sync_config("failed-staging-output");
|
||||
let first_error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||
assert!(first_error.to_string().contains("quarantine"));
|
||||
assert!(first_error.to_string().contains("simulated failure"));
|
||||
|
||||
let first_log = fs::read_to_string(&harness.curl_log).unwrap();
|
||||
assert!(first_log.contains(&resume_url));
|
||||
|
||||
let report = OfficialUpdateService::new().run(&config).unwrap();
|
||||
assert_eq!(report.update_status, OfficialUpdateStatus::Downloaded);
|
||||
|
||||
let second_log = fs::read_to_string(&harness.curl_log).unwrap();
|
||||
assert_eq!(second_log.matches(&resume_url).count(), 1);
|
||||
|
||||
let version_state = read_version_state(&report.version_state_path)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(version_state.failed_versions.is_empty());
|
||||
assert!(version_state.in_progress_version.is_none());
|
||||
assert!(version_state.current_completed_version.is_some());
|
||||
}
|
||||
|
||||
struct TestHarness {
|
||||
temp: TempDir,
|
||||
curl_script: std::path::PathBuf,
|
||||
@@ -362,6 +431,7 @@ impl TestHarness {
|
||||
&resources_assets_path,
|
||||
resources_assets.len(),
|
||||
manifest_source,
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -478,11 +548,16 @@ fn official_curl_script(
|
||||
resources_assets_fixture: &Path,
|
||||
resources_assets_size: usize,
|
||||
manifest_source: &str,
|
||||
failure_state: Option<&Path>,
|
||||
) -> String {
|
||||
let failure_state = failure_state
|
||||
.map(shell_quote)
|
||||
.unwrap_or_else(|| "''".to_string());
|
||||
format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
log={}
|
||||
failure_state={failure_state}
|
||||
printf '%s\n' "$*" >> "$log"
|
||||
output=""
|
||||
url=""
|
||||
@@ -522,6 +597,23 @@ emit_resources_assets_fixture() {{
|
||||
cp {} "$output"
|
||||
}}
|
||||
|
||||
maybe_fail_once() {{
|
||||
if [[ -z "$failure_state" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local attempt=0
|
||||
if [[ -f "$failure_state" ]]; then
|
||||
attempt="$(cat "$failure_state")"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
mkdir -p "$(dirname "$failure_state")"
|
||||
printf '%s' "$attempt" > "$failure_state"
|
||||
if [[ "$attempt" -le 3 ]]; then
|
||||
echo "simulated failure for $url attempt=$attempt" >&2
|
||||
exit 22
|
||||
fi
|
||||
}}
|
||||
|
||||
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
|
||||
cat <<'JSON'
|
||||
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
|
||||
@@ -573,6 +665,7 @@ elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes
|
||||
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
|
||||
emit_text "{android_media_catalog_hash}"
|
||||
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
|
||||
maybe_fail_once
|
||||
filename="${{url##*/}}"
|
||||
if [[ "$filename" == *.zip ]]; then
|
||||
emit_zip_fixture
|
||||
@@ -604,6 +697,7 @@ fi
|
||||
windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
|
||||
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
|
||||
android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
|
||||
failure_state = failure_state,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
// Package backendrpc is the Go client for the local Rust Resource Backend.
|
||||
//
|
||||
// The package talks directly to bat.sock over newline-delimited JSON-RPC 2.0.
|
||||
// It is the default Go integration path for bat-api; do not shell out to the
|
||||
// bat binary for normal service calls.
|
||||
package backendrpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const jsonRPCVersion = "2.0"
|
||||
|
||||
// Client calls the local Rust daemon through a Unix domain socket.
|
||||
type Client struct {
|
||||
SocketPath string
|
||||
Timeout time.Duration
|
||||
|
||||
// DialContext exists for tests and alternative local transports.
|
||||
DialContext func(ctx context.Context, network string, address string) (net.Conn, error)
|
||||
|
||||
seq atomic.Uint64
|
||||
}
|
||||
|
||||
// New returns a client for the given bat.sock path.
|
||||
func New(socketPath string) *Client {
|
||||
return &Client{SocketPath: socketPath, Timeout: 30 * time.Second}
|
||||
}
|
||||
|
||||
// APIError mirrors the Rust ApiError envelope payload.
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Kind string `json:"kind"`
|
||||
Domain string `json:"domain"`
|
||||
Location string `json:"location"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil api error>"
|
||||
}
|
||||
if e.Code == "" {
|
||||
return e.Message
|
||||
}
|
||||
return fmt.Sprintf("%s %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// JSONRPCError is a transport-level JSON-RPC error.
|
||||
type JSONRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *JSONRPCError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil json-rpc error>"
|
||||
}
|
||||
return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Envelope is the application-level payload carried in JSON-RPC result.
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID uint64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID uint64 `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *JSONRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Call invokes method and decodes the envelope data into out when out is non-nil.
|
||||
func (c *Client) Call(ctx context.Context, method string, params any, out any) (*Envelope, error) {
|
||||
envelope, err := c.callEnvelope(ctx, method, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !envelope.OK {
|
||||
if envelope.Error != nil {
|
||||
return envelope, envelope.Error
|
||||
}
|
||||
return envelope, errors.New("backend rpc returned ok=false without error payload")
|
||||
}
|
||||
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return envelope, fmt.Errorf("decode %s response data: %w", method, err)
|
||||
}
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*Envelope, error) {
|
||||
if c.SocketPath == "" {
|
||||
return nil, errors.New("backend rpc socket path is empty")
|
||||
}
|
||||
conn, err := c.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if deadline, ok := c.deadline(ctx); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
req := rpcRequest{
|
||||
JSONRPC: jsonRPCVersion,
|
||||
ID: c.seq.Add(1),
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
if err := json.NewEncoder(conn).Encode(&req); err != nil {
|
||||
return nil, fmt.Errorf("write backend rpc request %s: %w", method, err)
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read backend rpc response %s: %w", method, err)
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode backend rpc response %s: %w", method, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, resp.Error
|
||||
}
|
||||
if len(resp.Result) == 0 {
|
||||
return nil, fmt.Errorf("backend rpc %s response missing result", method)
|
||||
}
|
||||
var envelope Envelope
|
||||
if err := json.Unmarshal(resp.Result, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode backend rpc envelope %s: %w", method, err)
|
||||
}
|
||||
return &envelope, nil
|
||||
}
|
||||
|
||||
func (c *Client) dial(ctx context.Context) (net.Conn, error) {
|
||||
if c.DialContext != nil {
|
||||
return c.DialContext(ctx, "unix", c.SocketPath)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "unix", c.SocketPath)
|
||||
}
|
||||
|
||||
func (c *Client) deadline(ctx context.Context) (time.Time, bool) {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
return deadline, true
|
||||
}
|
||||
if c.Timeout > 0 {
|
||||
return time.Now().Add(c.Timeout), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
type boolParam struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type pageParam struct {
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type tailParam struct {
|
||||
Tail int `json:"tail"`
|
||||
}
|
||||
|
||||
type taskIDParam struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
// Ack is returned by accepted daemon control methods.
|
||||
type Ack struct {
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
StateDir string `json:"state_dir"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
Force *bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
// TaskAccepted is returned when an async backend task is queued.
|
||||
type TaskAccepted struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// TaskRecord is the pollable task state.
|
||||
type TaskRecord struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status"`
|
||||
Stage *string `json:"stage,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
CreatedAt uint64 `json:"created_at"`
|
||||
UpdatedAt uint64 `json:"updated_at"`
|
||||
StartedAt *uint64 `json:"started_at,omitempty"`
|
||||
FinishedAt *uint64 `json:"finished_at,omitempty"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
Tasks []TaskRecord `json:"tasks"`
|
||||
}
|
||||
|
||||
type TaskCancelResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
CancelRequested bool `json:"cancel_requested"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type TaskLogs struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
|
||||
type DoctorCheck struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type DoctorReport struct {
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Checks []DoctorCheck `json:"checks"`
|
||||
}
|
||||
|
||||
type LogsReport struct {
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
LogPath string `json:"log_path"`
|
||||
Exists bool `json:"exists"`
|
||||
Empty bool `json:"empty"`
|
||||
Bytes int `json:"bytes"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
ReturnedLines int `json:"returned_lines"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type DaemonStatusReport struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Running bool `json:"running"`
|
||||
PID *int `json:"pid,omitempty"`
|
||||
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
|
||||
StateDir string `json:"state_dir"`
|
||||
SocketPath string `json:"socket_path"`
|
||||
RPCAvailable bool `json:"rpc_available"`
|
||||
CurrentStage *string `json:"current_stage,omitempty"`
|
||||
CurrentMessage *string `json:"current_message,omitempty"`
|
||||
}
|
||||
|
||||
type ResourceState struct {
|
||||
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
|
||||
VersionState json.RawMessage `json:"version_state,omitempty"`
|
||||
LastUpdateStatus *string `json:"last_update_status,omitempty"`
|
||||
LastSuccessUnixSeconds *uint64 `json:"last_success_unix_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type ResourceManifestEntry struct {
|
||||
URL string `json:"url"`
|
||||
Destination string `json:"destination"`
|
||||
Bytes *uint64 `json:"bytes,omitempty"`
|
||||
BLAKE3 string `json:"blake3,omitempty"`
|
||||
}
|
||||
|
||||
type ResourceManifestPage struct {
|
||||
Available bool `json:"available"`
|
||||
ResourceRoot string `json:"resource_root,omitempty"`
|
||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||
TotalEntries int `json:"total_entries,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Entries []ResourceManifestEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
|
||||
var out DaemonStatusReport
|
||||
_, err := c.Call(ctx, "daemon.status", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonLogs(ctx context.Context, tail int) (*LogsReport, error) {
|
||||
var out LogsReport
|
||||
_, err := c.Call(ctx, "daemon.logs", tailParam{Tail: tail}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
|
||||
var out Ack
|
||||
_, err := c.Call(ctx, "daemon.stop", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
|
||||
var out Ack
|
||||
_, err := c.Call(ctx, "daemon.reload", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonRefresh(ctx context.Context, force bool) (*Ack, error) {
|
||||
var out Ack
|
||||
_, err := c.Call(ctx, "daemon.refresh", boolParam{Force: force}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) DaemonDoctor(ctx context.Context) (*DoctorReport, error) {
|
||||
var out DoctorReport
|
||||
_, err := c.Call(ctx, "daemon.doctor", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ResourceState(ctx context.Context) (*ResourceState, error) {
|
||||
var out ResourceState
|
||||
_, err := c.Call(ctx, "resource.state", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ResourceSync(ctx context.Context, force bool) (*TaskAccepted, error) {
|
||||
var out TaskAccepted
|
||||
_, err := c.Call(ctx, "resource.sync", boolParam{Force: force}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ResourceVerify(ctx context.Context) (*TaskAccepted, error) {
|
||||
var out TaskAccepted
|
||||
_, err := c.Call(ctx, "resource.verify", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
|
||||
var out TaskAccepted
|
||||
_, err := c.Call(ctx, "resource.repair", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
|
||||
var out ResourceManifestPage
|
||||
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
|
||||
var out ResourceManifestPage
|
||||
_, err := c.Call(ctx, "resource.list", pageParam{Offset: offset, Limit: limit}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "catalog.status", nil)
|
||||
}
|
||||
|
||||
func (c *Client) CatalogVersions(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "catalog.versions", nil)
|
||||
}
|
||||
|
||||
func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
|
||||
return c.rawData(ctx, "catalog.diff", nil)
|
||||
}
|
||||
|
||||
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
|
||||
var out TaskAccepted
|
||||
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
|
||||
var out TaskRecord
|
||||
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) TaskList(ctx context.Context) (*TaskList, error) {
|
||||
var out TaskList
|
||||
_, err := c.Call(ctx, "task.list", nil, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) TaskCancel(ctx context.Context, taskID string) (*TaskCancelResult, error) {
|
||||
var out TaskCancelResult
|
||||
_, err := c.Call(ctx, "task.cancel", taskIDParam{TaskID: taskID}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) TaskLogs(ctx context.Context, taskID string) (*TaskLogs, error) {
|
||||
var out TaskLogs
|
||||
_, err := c.Call(ctx, "task.logs", taskIDParam{TaskID: taskID}, &out)
|
||||
return &out, err
|
||||
}
|
||||
|
||||
func (c *Client) rawData(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
envelope, err := c.Call(ctx, method, params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package backendrpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type testEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error any `json:"error,omitempty"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
type testResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *JSONRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) testResponse) *Client {
|
||||
t.Helper()
|
||||
client := New("bat.sock")
|
||||
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req testRequest
|
||||
if err := json.Unmarshal(line, &req); err != nil {
|
||||
return
|
||||
}
|
||||
resp := handler(t, req)
|
||||
if len(resp.ID) == 0 {
|
||||
resp.ID = req.ID
|
||||
}
|
||||
if resp.JSONRPC == "" {
|
||||
resp.JSONRPC = jsonRPCVersion
|
||||
}
|
||||
_ = json.NewEncoder(conn).Encode(resp)
|
||||
}(serverConn)
|
||||
return clientConn, nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func TestResourceRepairQueuesTask(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "resource.repair" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "accepted",
|
||||
RequestID: "req-test-1",
|
||||
Data: map[string]any{
|
||||
"task_id": "task-1",
|
||||
"kind": "resource.repair",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
task, err := client.ResourceRepair(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResourceRepair error: %v", err)
|
||||
}
|
||||
if task.TaskID != "task-1" || task.Kind != "resource.repair" {
|
||||
t.Fatalf("unexpected task: %#v", task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceListSendsPagination(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "resource.list" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
var params pageParam
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.Offset != 2 || params.Limit != 1 {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
bytes := uint64(42)
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
RequestID: "req-test-2",
|
||||
Data: map[string]any{
|
||||
"available": true,
|
||||
"resource_root": "/tmp/resources/current",
|
||||
"manifest_version": 1,
|
||||
"total_entries": 3,
|
||||
"offset": 2,
|
||||
"limit": 1,
|
||||
"entries": []map[string]any{
|
||||
{
|
||||
"url": "https://example.invalid/a.zip",
|
||||
"destination": "a.zip",
|
||||
"bytes": bytes,
|
||||
"blake3": "abc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
page, err := client.ResourceList(context.Background(), 2, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ResourceList error: %v", err)
|
||||
}
|
||||
if !page.Available || page.TotalEntries != 3 || len(page.Entries) != 1 {
|
||||
t.Fatalf("unexpected page: %#v", page)
|
||||
}
|
||||
if page.Entries[0].Bytes == nil || *page.Entries[0].Bytes != 42 {
|
||||
t.Fatalf("unexpected entry bytes: %#v", page.Entries[0].Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationErrorReturnsAPIError(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "task.status" {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: false,
|
||||
Status: "error",
|
||||
Error: APIError{
|
||||
Code: "BAT-ERR-700004",
|
||||
Kind: "task_not_found",
|
||||
Domain: "rpc",
|
||||
Location: "task.status",
|
||||
Message: "任务不存在:missing",
|
||||
Retryable: false,
|
||||
},
|
||||
RequestID: "req-test-3",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
_, err := client.TaskStatus(context.Background(), "missing")
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T %v", err, err)
|
||||
}
|
||||
if apiErr.Code != "BAT-ERR-700004" {
|
||||
t.Fatalf("code = %s", apiErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportErrorReturnsJSONRPCError(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
return testResponse{
|
||||
Error: &JSONRPCError{
|
||||
Code: -32700,
|
||||
Message: "parse error",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
_, err := client.DaemonStatus(context.Background())
|
||||
var rpcErr *JSONRPCError
|
||||
if !errors.As(err, &rpcErr) {
|
||||
t.Fatalf("expected JSONRPCError, got %T %v", err, err)
|
||||
}
|
||||
if rpcErr.Code != -32700 {
|
||||
t.Fatalf("code = %d", rpcErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextDeadlineIsApplied(t *testing.T) {
|
||||
client := New("bat.sock")
|
||||
client.Timeout = time.Millisecond
|
||||
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.DaemonStatus(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected dial error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user