From 6af770619042dd2757e1f8f107bfa59953e4ee50 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 31 Jul 2026 17:01:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E8=A1=A5=E9=BD=90=20bat-api=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E4=B8=8E=E5=90=8E=E7=AB=AF=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CURRENT_STATUS.md | 8 +- README.md | 4 +- USERGUIDE.md | 24 ++- .../architecture/official-resource-backend.md | 12 +- docs/reference/rpc-backend-api.md | 12 +- .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 2 +- docs/reports/CURRENT_GAPS.md | 2 +- docs/reports/GO_STATUS.md | 11 +- infrastructure/src/bin/bat_official_sync.rs | 140 +++++++++++++- internal/api/admin.go | 173 +++++++++++++++++- internal/api/api_test.go | 138 +++++++++++++- internal/api/openapi.go | 39 +++- internal/api/responses.go | 17 +- internal/api/rpc_release.go | 57 ++++++ internal/api/server.go | 2 + internal/backendrpc/client.go | 89 ++++++++- internal/backendrpc/client_test.go | 114 ++++++++++++ 17 files changed, 793 insertions(+), 51 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 0a8a1fc..9e0dda9 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -264,7 +264,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理面板预留),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 生产要求: @@ -284,8 +284,8 @@ GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载 下一阶段必须优先完成: -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/index)、`parse.*`(status/text_units/errors)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `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. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理面板预留、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;剩余为远程服务器全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存和可选持久化。 +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/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`parse.*`(status/text_units/errors)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 +2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;剩余为远程服务器全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存和可选持久化。 3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成并可查询 TextUnit 明细;剩余为真实 Crowdin worker、翻译记忆、Patch 构建和翻译任务状态查询。 4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。 5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 @@ -299,7 +299,7 @@ GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载 立即任务: -1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`task.*`、`resource.repair`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.restart`、`daemon.clean-stable` 的设计边界确认。 +1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`localized.*`、`task.*`、`resource.repair`、`daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.clean-stable` 的设计边界确认。 2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、server-info 和 CDN path;issue #19 剩余)。 3. 跟进官方同步长期运行测试报告。 4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。 diff --git a/README.md b/README.md index 0cdbeda..12aa8f6 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 -- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`)。 +- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`)。 - `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。 -- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理面板预留;`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。 +- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理控制白名单;`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。 - Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。 - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC 可按类型、hash、路径模式分页查询索引。 diff --git a/USERGUIDE.md b/USERGUIDE.md index 9d4ffea..e5afc50 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -109,13 +109,14 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `GET /yostar-serverinfo.bluearchiveyostar.com/server-info.json` | 官方 host/path 形态的 server-info | | `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 | | `GET /openapi.yaml` | bat-api OpenAPI 文档 | -| `GET /admin/` | 管理面板预留入口;当前返回 JSON 链接,未来接入 Web UI | +| `GET /admin/` | 管理控制入口与允许操作列表 | +| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata` 和 `game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false`。`bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。 生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志: -- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer `、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。 +- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer `、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*` 需要此 token。 - `BAT_API_AUTH_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。 - `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。 - `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。 @@ -123,7 +124,21 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn - `BAT_API_ACCESS_LOG`:结构化访问日志,记录 method/path/status/bytes/duration/client_ip/request_id/user_agent,不记录 query string。 - `BAT_API_MAX_RESOURCE_LIMIT`:`/v1/resources` 最大分页上限,默认 `1000`。 -所有动态 JSON(bootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin placeholder 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。 +`POST /admin/control/{action}` 只转发固定白名单内的 Rust RPC,不是任意 RPC proxy: + +| action | Rust RPC | 参数 | 返回 | +|---|---|---|---| +| `reload` | `daemon.reload` | 无 | `202` accepted | +| `refresh` | `daemon.refresh` | 可选 `{ "force": true }` | `202` accepted | +| `restart` | `daemon.restart` | 无 | `202` accepted | +| `sync` | `resource.sync` | 可选 `{ "force": true }` | `202` + task | +| `verify` | `resource.verify` | 无 | `202` + task | +| `repair` | `resource.repair` | 无 | `202` + task | +| `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `202` + task | + +`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。 + +所有动态 JSON(bootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。 CDN path 只服务 manifest 索引内且磁盘存在、size 匹配的文件。响应支持 `GET`、`HEAD`、`Range`、条件请求、ETag、Last-Modified、Accept-Ranges 和长期缓存头;ETag 优先使用 manifest 中的 BLAKE3。`.hash` 以 `text/plain` 返回,其它未知扩展默认为 `application/octet-stream`。 @@ -360,6 +375,7 @@ curl -i -H 'Range: bytes=0-1023' \ | `daemon.stop` | ✅ | 请求停止(`accepted`) | | `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) | | `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) | +| `daemon.restart` | ✅ | 启动 Rust lifecycle controller,并在响应后停止当前 daemon(`accepted`) | | `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) | | `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 | | `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` | @@ -374,7 +390,7 @@ curl -i -H 'Range: bytes=0-1023' \ | `task.list` | ✅ | 列出全部任务(最新在前) | | `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 | | `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) | -| `daemon.restart` / `daemon.clean-stable` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);restart/clean-stable 仍由 CLI 侧按进程生命周期显式执行,patch/unityfs 待引擎实现,task.create 暂不开放通用任务入口 | +| `daemon.clean-stable` / 发布级 `patch.*` / 未开放 `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);clean-stable 仍由 CLI 侧按进程生命周期显式执行,task.create 暂不开放通用任务入口 | | 未知方法 | — | `BAT-ERR-700001`(unknown method) | 只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 3d44e58..2e64e06 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -231,7 +231,7 @@ CAS 诊断入口和更丰富查询。 维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。 -该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。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`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output`、`--localized-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`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,live RPC `restart` 会启动 Rust lifecycle controller 并复用 CLI 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`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output`、`--localized-output` 和 `--state-dir` 配置;官方目录和汉化目录不能相同或互相嵌套。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 对应实现主要在: @@ -269,7 +269,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 live control/backend(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`restart` 与 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 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/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`restart` 通过 Rust lifecycle controller 复用 CLI 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` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引 @@ -321,11 +321,13 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 (版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务 仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。 - 方法命名空间与实现状态、请求/响应示例见 - `docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/reload/refresh/doctor`、 + `docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/restart/reload/refresh/doctor`、 `resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、 `localized.status`、`catalog.*` 与 `task.status/list/cancel/logs` 已实现; - `patch.*` / `unityfs.*` 待引擎;`task.create` 按设计暂不开放通用任务入口; - `daemon.restart` / `daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 + 文件级 `patch.apply` / `unityfs.patch_*` 已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎; + `task.create` 按设计暂不开放通用任务入口; + `daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径; + `daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 ### 7.2 Go 层职责边界 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 8fada1f..52a64e8 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -83,13 +83,17 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主 | `daemon.status` | 已实现 | `null` | 后台状态报告。 | | `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 | | `daemon.stop` | 已实现 | `null` | accepted ack。 | +| `daemon.restart` | 已实现 | `null` | accepted ack;启动 Rust lifecycle controller,并在响应后停止当前 daemon。 | | `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`、 +`daemon.restart` 不在 daemon 线程内手写第二套启动流程;它启动本机 Rust +`bat restart --state-dir ...` lifecycle controller,由既有 CLI restart 路径复用 +保存的启动参数、代理凭据、PID/socket 替换和控制锁。 + +`bat.status`、`bat.stop`、`bat.restart`、`bat.reload`、`bat.refresh`、`bat.logs`、 `bat.doctor`、`bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`。 ### resource @@ -303,7 +307,9 @@ CLI 对应关系: `bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。 `bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair` -在 daemon 可用时也会作为 RPC client 调用同一个 socket。 +在 daemon 可用时也会作为 RPC client 调用同一个 socket。`daemon.restart` +会启动 Rust lifecycle controller 复用同一套 CLI restart 路径,Go 层仍不直接 +`exec` 或解析 `bat` stdout。 人类 CLI 的只读查询命令与 RPC 对应关系如下: diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index 3143480..77c0ab3 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -192,5 +192,5 @@ contract fixture 工作只有在以下条件同时满足时才算完成: ## 当前状态 - Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。 -- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理面板预留。 +- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理控制白名单。 - contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 3ce9045..8d8d17e 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -242,7 +242,7 @@ 已完成: -- `cmd/bat-api`、`internal/api`、`/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、HTTP token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限、OpenAPI、`/admin/` 预留、RPC 周期刷新/诊断、`/readyz`、CDN Range/缓存头、fixture 单测、USERGUIDE 基础章节、systemd bat-api 模板、`make build-go-api` / `test-go-api` +- `cmd/bat-api`、`internal/api`、`/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、HTTP token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限、OpenAPI、`/admin/` 管理控制白名单、RPC 周期刷新/诊断、`/readyz`、CDN Range/缓存头、fixture 单测、USERGUIDE 基础章节、systemd bat-api 模板、`make build-go-api` / `test-go-api` - 进度权威:`docs/reports/GO_STATUS.md` 验收(剩余): diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 9d58fa1..cde595c 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -32,7 +32,7 @@ | 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata | | 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` | | 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info` 和 CDN path,组织给客户端/补丁器使用 | -| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引,只展示 ready、RPC 健康和 release;需要拉取/修复时由外部运维调用 `bat` 或 RPC 任务 | +| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh,不持有或写入同步状态 | 这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。 @@ -72,11 +72,12 @@ | ID | 约定 | |---|---| | K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis | -| L | 管理面 / bootstrap:`/healthz`、`/readyz`、`/v1/bootstrap`、`/v1/release`、`/v1/resources`、`/openapi.yaml`、`/admin/` 预留 | +| L | 管理面 / bootstrap:`/healthz`、`/readyz`、`/v1/bootstrap`、`/v1/release`、`/v1/resources`、`/openapi.yaml`、`/admin/` 控制入口 | | M | CDN:`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 | | N | server-info 可选;**只改 AddressablesCatalogUrlRoot** | | N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap` 与 `/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 | | N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限 | +| N4 | `/admin/control/{action}` 白名单控制面;`restart` 通过 Rust live RPC 启动 lifecycle controller,Go 不直接执行 `bat` binary | ### 工程 @@ -94,8 +95,8 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;fake transport 单测 | -| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理面预留 + `.env` | +| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;覆盖 daemon restart、resource/catalog/task、parse/localized 和文件级 UnityFS patch 调用;fake transport 单测 | +| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README | @@ -132,7 +133,7 @@ make build-go-cli # 产出 bin/bat-go | 项 | 状态 | |---|---| | G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) | -| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理面预留和部署模板,后续远程服务器联调/可选持久化 | +| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单和部署模板,后续远程服务器联调/可选持久化 | | issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** | | G-010 Web | 未开始 | diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index f34f028..4ade446 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -388,6 +388,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { registry, queue: task_tx, base_config: options.config.clone(), + restart_controller: spawn_daemon_restart_controller, }; let server = start_daemon_rpc_server(&daemon_state_dir, Arc::clone(control), context.clone())?; @@ -757,6 +758,8 @@ struct DaemonRpcAck { state_dir: PathBuf, socket_path: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] + controller_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] force: Option, } @@ -1295,8 +1298,11 @@ struct DaemonTaskContext { registry: TaskRegistry, queue: mpsc::Sender, base_config: OfficialUpdateConfig, + restart_controller: DaemonRestartController, } +type DaemonRestartController = fn(&Path) -> anyhow::Result; + /// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。 /// /// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败); @@ -1393,14 +1399,12 @@ fn canonical_rpc_method(method: &str) -> &str { fn is_pending_rpc_method(method: &str) -> bool { // task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh // 等语义方法创建,通用创建接口暂不开放。 - // daemon.restart / daemon.clean-stable:CLI 侧按进程生命周期处理; - // live RPC 内不做自重启或在线清理。 + // daemon.clean-stable:CLI 侧按进程生命周期处理; + // live RPC 内不做在线清理。 // patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂 // UnityFS 语义编辑和 inspect 等子命令仍未开放。 - matches!( - method, - "task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE - ) || (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY) + matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE) + || (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY) || (method.starts_with("unityfs.") && method != RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET && method != RPC_METHOD_UNITYFS_PATCH_STRING_FIELD @@ -1997,7 +2001,10 @@ fn handle_daemon_rpc_client( let mut notify_stop_after_response = false; let response = match serde_json::from_str::(&line) { Ok(request) => { - notify_stop_after_response = request.method == RPC_METHOD_STOP; + notify_stop_after_response = matches!( + canonical_rpc_method(&request.method), + RPC_METHOD_STOP | RPC_METHOD_RESTART + ); handle_daemon_rpc_request(request, &state_dir, &control, &tasks) } Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")), @@ -2081,6 +2088,30 @@ fn dispatch_rpc_method( rpc_ack_value("stop", "后台停止请求已发送", state_dir, None), ) } + RPC_METHOD_RESTART => match (tasks.restart_controller)(state_dir) { + Ok(controller_pid) => { + daemon_control_mark_stop_requested(control); + let _ = update_daemon_state_only(state_dir, "restarting"); + rpc_envelope_ok( + request_id, + "accepted", + rpc_restart_ack_value( + "restart", + "后台重启控制进程已启动;当前 daemon 会在响应后停止并由 Rust 生命周期入口重启", + state_dir, + controller_pid, + ), + ) + } + Err(error) => rpc_envelope_error( + request_id, + ApiError::new( + ErrorCode::INTERNAL, + "daemon.restart", + format!("启动后台重启控制进程失败:{error}"), + ), + ), + }, RPC_METHOD_RELOAD => { daemon_control_request_reload(control); rpc_envelope_ok( @@ -2430,11 +2461,30 @@ fn rpc_ack_value( message, state_dir: state_dir.to_path_buf(), socket_path: daemon_socket_path(state_dir), + controller_pid: None, force, }) .unwrap_or(serde_json::Value::Null) } +fn rpc_restart_ack_value( + command: &'static str, + message: &'static str, + state_dir: &Path, + controller_pid: u32, +) -> serde_json::Value { + serde_json::to_value(DaemonRpcAck { + command, + status: "accepted", + message, + state_dir: state_dir.to_path_buf(), + socket_path: daemon_socket_path(state_dir), + controller_pid: Some(controller_pid), + force: None, + }) + .unwrap_or(serde_json::Value::Null) +} + /// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。 /// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。 fn read_daemon_resource_state( @@ -2613,6 +2663,15 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result { + validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; + fs::create_dir_all(state_dir)?; + let executable = env::current_exe()?; + let log = open_append_file(&daemon_log_path(state_dir), PRIVATE_FILE_MODE, "后台日志") + .map_err(anyhow::Error::msg)?; + let log_for_stdout = log.try_clone()?; + let mut command = Command::new(executable); + command + .arg("restart") + .arg("--state-dir") + .arg(state_dir) + .arg("--json") + .arg("--no-progress") + .arg("--no-banner") + .stdin(Stdio::null()) + .stdout(Stdio::from(log_for_stdout)) + .stderr(Stdio::from(log)); + configure_daemon_command(&mut command); + let child = command.spawn()?; + Ok(child.id()) +} + fn start_daemon_with_args( state_dir: PathBuf, resource_output_root: PathBuf, @@ -8713,10 +8802,10 @@ mod tests { assert!(is_pending_rpc_method("patch.build")); assert!(is_pending_rpc_method("unityfs.inspect")); assert!(is_pending_rpc_method("task.create")); - assert!(is_pending_rpc_method("daemon.restart")); assert!(is_pending_rpc_method("daemon.clean-stable")); - // sync/verify/repair、task.cancel/logs、catalog.*、resource.manifest 和 - // 文件级 patch/unityfs 写入方法已实现。 + // restart、sync/verify/repair、task.cancel/logs、catalog.*、 + // resource.manifest 和文件级 patch/unityfs 写入方法已实现。 + assert!(!is_pending_rpc_method("daemon.restart")); assert!(!is_pending_rpc_method("patch.apply")); assert!(!is_pending_rpc_method("unityfs.patch_text_asset")); assert!(!is_pending_rpc_method("unityfs.patch_string_field")); @@ -8738,6 +8827,28 @@ mod tests { assert!(!is_pending_rpc_method("daemon.doctor")); } + #[test] + fn dispatch_daemon_restart_starts_controller_and_requests_stop() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("daemon.restart", None), + temp.path(), + &control, + &test_task_context(), + "req-restart-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"]["command"], "restart"); + assert_eq!(value["data"]["controller_pid"], 4242); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Stop + ); + } + #[test] fn task_registry_cancel_and_logs() { let registry = TaskRegistry::new(); @@ -8783,9 +8894,14 @@ mod tests { registry: TaskRegistry::new(), queue, base_config, + restart_controller: test_restart_controller, } } + fn test_restart_controller(_state_dir: &Path) -> anyhow::Result { + Ok(4242) + } + #[test] fn dispatch_unknown_method_returns_unknown_error_envelope() { let temp = tempfile::TempDir::new().unwrap(); @@ -8917,6 +9033,7 @@ mod tests { registry: TaskRegistry::new(), queue, base_config, + restart_controller: test_restart_controller, }; let envelope = dispatch_rpc_method( @@ -8945,6 +9062,7 @@ mod tests { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), + restart_controller: test_restart_controller, }; let envelope = dispatch_rpc_method( @@ -9004,6 +9122,7 @@ mod tests { registry: TaskRegistry::new(), queue, base_config, + restart_controller: test_restart_controller, }; let envelope = dispatch_rpc_method( @@ -10197,6 +10316,7 @@ mod tests { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), + restart_controller: test_restart_controller, }; let envelope = dispatch_rpc_method( &rpc_request("catalog.refresh", None), diff --git a/internal/api/admin.go b/internal/api/admin.go index 0e1e5b2..9aee99c 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -1,6 +1,21 @@ package api -import "net/http" +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "bat-api/internal/backendrpc" +) + +const adminControlMaxBodyBytes = 1024 + +type adminControlRequest struct { + Force bool `json:"force"` +} func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { @@ -10,7 +25,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { body := AdminIndexResponse{ Service: "bat-api", Panel: "admin", - Status: "reserved", + Status: "available", Links: []string{ "/healthz", "/readyz", @@ -19,6 +34,15 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/v1/resources", "/openapi.yaml", }, + Controls: []string{ + "/admin/control/reload", + "/admin/control/refresh", + "/admin/control/restart", + "/admin/control/sync", + "/admin/control/verify", + "/admin/control/repair", + "/admin/control/catalog-refresh", + }, } if r.Method == http.MethodHead { w.Header().Set("Cache-Control", "no-store") @@ -27,3 +51,148 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { } writeNoStoreJSON(w, http.StatusOK, body) } + +func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + action := strings.TrimPrefix(r.URL.Path, "/admin/control/") + if action == "" || strings.Contains(action, "/") { + writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") + return + } + request, ok := decodeAdminControlRequest(w, r) + if !ok { + return + } + backend, ok := s.backend.(ControlBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "control_backend_unavailable", "Rust bat control backend is unavailable") + return + } + + var ( + method string + result any + err error + ) + switch action { + case "reload": + if request.Force { + writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "reload does not accept force") + return + } + method = "daemon.reload" + result, err = backend.DaemonReload(r.Context()) + case "refresh": + method = "daemon.refresh" + result, err = backend.DaemonRefresh(r.Context(), request.Force) + case "restart": + if request.Force { + writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "restart does not accept force") + return + } + method = "daemon.restart" + result, err = backend.DaemonRestart(r.Context()) + case "sync": + method = "resource.sync" + result, err = backend.ResourceSync(r.Context(), request.Force) + case "verify": + if request.Force { + writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "verify does not accept force") + return + } + method = "resource.verify" + result, err = backend.ResourceVerify(r.Context()) + case "repair": + if request.Force { + writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "repair does not accept force") + return + } + method = "resource.repair" + result, err = backend.ResourceRepair(r.Context()) + case "catalog-refresh": + method = "catalog.refresh" + result, err = backend.CatalogRefresh(r.Context(), request.Force) + case "stop", "clean-stable": + writeErrorJSON(w, http.StatusForbidden, "control_not_allowed", "control action is not exposed by bat-api") + return + default: + writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") + return + } + if err != nil { + s.writeControlBackendError(w, action, err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: action, + RPCMethod: method, + Status: "accepted", + Result: result, + }) +} + +func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool { + if s.cfg.AuthToken == "" { + writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN") + return false + } + if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) { + w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api-admin"`) + writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token") + return false + } + return true +} + +func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) { + var request adminControlRequest + if r.Body == nil { + return request, true + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminControlMaxBodyBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + if errors.Is(err, io.EOF) { + return request, true + } + writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must be a JSON object with an optional force boolean") + return adminControlRequest{}, false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must contain exactly one JSON object") + return adminControlRequest{}, false + } + return request, true +} + +func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, err error) { + status := http.StatusBadGateway + code := "control_backend_failed" + message := "Rust bat rejected the control request" + switch { + case errors.Is(err, context.DeadlineExceeded): + status = http.StatusGatewayTimeout + code = "control_backend_timeout" + message = "Rust bat control request timed out" + case errors.Is(err, context.Canceled): + status = http.StatusRequestTimeout + code = "control_request_canceled" + message = "control request was canceled" + default: + var apiErr *backendrpc.APIError + if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" { + status = http.StatusNotImplemented + code = "control_not_implemented" + message = "Rust bat does not implement this control action" + } + } + s.logger.Printf("bat-api control action=%s error=%v", action, err) + writeErrorJSON(w, status, code, message) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b68f76c..99950a8 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -548,6 +548,46 @@ func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit in return f.manifest, nil } +type controlBackend struct { + *fakeBackend + calls []string +} + +func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) { + b.calls = append(b.calls, "daemon.reload") + return &backendrpc.Ack{Command: "reload", Status: "accepted"}, nil +} + +func (b *controlBackend) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) { + b.calls = append(b.calls, "daemon.restart") + return &backendrpc.Ack{Command: "restart", Status: "accepted"}, nil +} + +func (b *controlBackend) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) { + b.calls = append(b.calls, "daemon.refresh") + return &backendrpc.Ack{Command: "refresh", Status: "accepted", Force: &force}, nil +} + +func (b *controlBackend) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { + b.calls = append(b.calls, "resource.sync") + return &backendrpc.TaskAccepted{TaskID: "task-sync-1", Kind: "resource.sync"}, nil +} + +func (b *controlBackend) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) { + b.calls = append(b.calls, "resource.verify") + return &backendrpc.TaskAccepted{TaskID: "task-verify-1", Kind: "resource.verify"}, nil +} + +func (b *controlBackend) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) { + b.calls = append(b.calls, "resource.repair") + return &backendrpc.TaskAccepted{TaskID: "task-repair-1", Kind: "resource.repair"}, nil +} + +func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { + b.calls = append(b.calls, "catalog.refresh") + return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil +} + func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { root := fixtureRoot(t) bytes := uint64(20) @@ -974,7 +1014,103 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil { t.Fatal(err) } - if admin.Status != "reserved" { + if admin.Status != "available" { t.Fatalf("admin=%+v", admin) } + if len(admin.Controls) == 0 || admin.Controls[0] != "/admin/control/reload" { + t.Fatalf("admin controls=%v", admin.Controls) + } +} + +func TestAdminControlForwardsAllowlistedActions(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "control-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &controlBackend{fakeBackend: &fakeBackend{}} + s := NewServer(cfg, backend, nil) + + tests := []struct { + name string + action string + body string + rpcMethod string + call string + }{ + {name: "reload", action: "reload", rpcMethod: "daemon.reload", call: "daemon.reload"}, + {name: "restart", action: "restart", rpcMethod: "daemon.restart", call: "daemon.restart"}, + {name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"}, + {name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"}, + {name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body)) + request.Header.Set("Authorization", "Bearer control-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var response AdminControlResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Action != tc.action || response.RPCMethod != tc.rpcMethod || response.Status != "accepted" { + t.Fatalf("response=%+v", response) + } + if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.call { + t.Fatalf("calls=%v", backend.calls) + } + }) + } +} + +func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "control-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &controlBackend{fakeBackend: &fakeBackend{}} + s := NewServer(cfg, backend, nil) + + tests := []struct { + name string + action string + token string + body string + wantStatus int + wantCode string + }{ + {name: "missing token", action: "repair", wantStatus: http.StatusUnauthorized, wantCode: "unauthorized"}, + {name: "dangerous stop", action: "stop", token: "control-token", wantStatus: http.StatusForbidden, wantCode: "control_not_allowed"}, + {name: "unknown action", action: "arbitrary-rpc", token: "control-token", wantStatus: http.StatusNotFound, wantCode: "control_not_found"}, + {name: "invalid parameters", action: "repair", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"}, + {name: "restart invalid parameters", action: "restart", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body)) + if tc.token != "" { + request.Header.Set("Authorization", "Bearer "+tc.token) + } + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != tc.wantStatus { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + var response ErrorResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Error.Code != tc.wantCode { + t.Fatalf("error=%+v", response.Error) + } + }) + } + if len(backend.calls) != 0 { + t.Fatalf("rejected actions reached backend: %v", backend.calls) + } } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 292b24e..69d6282 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -9,7 +9,7 @@ const openAPISpecYAML = `openapi: 3.0.3 info: title: BlueArchive Toolkit bat-api version: 0.1.0 - description: Resource bootstrap and read-only distribution API. + description: Resource bootstrap, read-only distribution, and authenticated Rust bat control proxy. servers: - url: http://127.0.0.1:18080 security: @@ -111,10 +111,43 @@ paths: description: OpenAPI YAML. /admin/: get: - summary: Reserved admin panel entry + summary: Admin control entry responses: "200": - description: Admin panel placeholder and links. + description: Admin links and allowlisted control actions. + /admin/control/{action}: + post: + summary: Forward an allowlisted control action to Rust bat + parameters: + - name: action + in: path + required: true + schema: + type: string + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + force: + type: boolean + responses: + "202": + description: Rust bat accepted the control request. + "400": + description: Invalid action parameters. + "401": + description: Missing or invalid admin token. + "403": + description: Control is not exposed or no admin token is configured. + "501": + description: Rust bat does not implement the requested control action. + "502": + description: Rust bat rejected the control request. /prod-clientpatch.bluearchiveyostar.com/{path}: get: summary: CDN-shaped resource bytes diff --git a/internal/api/responses.go b/internal/api/responses.go index e685ae5..a32869a 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -162,10 +162,19 @@ type LauncherEnvelope[T any] struct { } type AdminIndexResponse struct { - Service string `json:"service"` - Panel string `json:"panel"` - Status string `json:"status"` - Links []string `json:"links"` + Service string `json:"service"` + Panel string `json:"panel"` + Status string `json:"status"` + Links []string `json:"links"` + Controls []string `json:"controls"` +} + +type AdminControlResponse struct { + Service string `json:"service"` + Action string `json:"action"` + RPCMethod string `json:"rpc_method"` + Status string `json:"status"` + Result any `json:"result"` } func writeNoStoreJSON(w http.ResponseWriter, status int, body any) { diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index fa6f755..3db2511 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -23,6 +23,21 @@ type Backend interface { ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) } +// ControlBackend is the explicitly allowlisted mutation subset exposed through +// the authenticated bat-api admin control surface. +// +// It intentionally does not include daemon.stop, cleanup, or generic RPC calls. +// Restart is forwarded only to Rust's lifecycle RPC; Go never execs bat itself. +type ControlBackend interface { + DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) + DaemonReload(ctx context.Context) (*backendrpc.Ack, error) + DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) + ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) + ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) + ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) + CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) +} + // RPCClient adapts *backendrpc.Client to Backend. type RPCClient struct { Client *backendrpc.Client @@ -43,6 +58,48 @@ func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) { func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { return r.Client.ResourceManifest(ctx, offset, limit) } +func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) { + return r.Client.DaemonRestart(ctx) +} +func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) { + return r.Client.DaemonReload(ctx) +} +func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) { + return r.Client.DaemonRefresh(ctx, force) +} +func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { + return r.Client.ResourceSync(ctx, force) +} +func (r RPCClient) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) { + return r.Client.ResourceVerify(ctx) +} +func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) { + return r.Client.ResourceRepair(ctx) +} +func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { + return r.Client.CatalogRefresh(ctx, force) +} +func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { + return r.Client.ParseStatus(ctx) +} +func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { + return r.Client.ParseTextUnits(ctx, query) +} +func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { + return r.Client.ParseErrors(ctx, query) +} +func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { + return r.Client.LocalizedStatus(ctx) +} +func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) { + return r.Client.UnityFSPatchTextAsset(ctx, params) +} +func (r RPCClient) UnityFSPatchStringField(ctx context.Context, params backendrpc.UnityFSStringFieldPatchParams) (json.RawMessage, error) { + return r.Client.UnityFSPatchStringField(ctx, params) +} +func (r RPCClient) UnityFSPatchField(ctx context.Context, params backendrpc.UnityFSFieldPatchParams) (json.RawMessage, error) { + return r.Client.UnityFSPatchField(ctx, params) +} // DiscoverResult is the outcome of talking to the bat daemon. type DiscoverResult struct { diff --git a/internal/api/server.go b/internal/api/server.go index 17f1f83..211b1dd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,6 +61,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap) mux.HandleFunc("/openapi.yaml", s.handleOpenAPI) + mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN) mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN) @@ -124,6 +125,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/" + ServerInfoHost + "/...", "/openapi.yaml", "/admin/", + "/admin/control/{action}", }, }) return diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 0d79035..aa682c9 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -190,14 +190,57 @@ type taskIDParam struct { TaskID string `json:"task_id"` } +type TextUnitQueryParams struct { + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + Destination string `json:"destination,omitempty"` + PathPattern string `json:"path_pattern,omitempty"` + ArchiveEntry string `json:"archive_entry,omitempty"` + PathID *int64 `json:"path_id,omitempty"` + ClassID *int `json:"class_id,omitempty"` + FieldPath string `json:"field_path,omitempty"` + Format string `json:"format,omitempty"` +} + +type UnityFSTextAssetPatchParams struct { + BundlePath string `json:"bundle_path"` + SerializedFilePath string `json:"serialized_file_path"` + PathID int64 `json:"path_id"` + ReplacementPath string `json:"replacement_path"` + TargetPath string `json:"target_path"` + ExpectedName *string `json:"expected_name,omitempty"` +} + +type UnityFSStringFieldPatchParams struct { + BundlePath string `json:"bundle_path"` + SerializedFilePath string `json:"serialized_file_path"` + PathID int64 `json:"path_id"` + FieldPath string `json:"field_path"` + ReplacementText *string `json:"replacement_text,omitempty"` + ReplacementPath string `json:"replacement_path,omitempty"` + TargetPath string `json:"target_path"` + ExpectedValue *string `json:"expected_value,omitempty"` +} + +type UnityFSFieldPatchParams struct { + BundlePath string `json:"bundle_path"` + SerializedFilePath string `json:"serialized_file_path"` + PathID int64 `json:"path_id"` + FieldPath string `json:"field_path"` + Replacement json.RawMessage `json:"replacement"` + TargetPath string `json:"target_path"` + ExpectedValue json.RawMessage `json:"expected_value,omitempty"` +} + // 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"` + Command string `json:"command"` + Status string `json:"status"` + Message string `json:"message"` + StateDir string `json:"state_dir"` + SocketPath string `json:"socket_path"` + ControllerPID *int `json:"controller_pid,omitempty"` + Force *bool `json:"force,omitempty"` } // TaskAccepted is returned when an async backend task is queued. @@ -324,6 +367,12 @@ func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) { return &out, err } +func (c *Client) DaemonRestart(ctx context.Context) (*Ack, error) { + var out Ack + _, err := c.Call(ctx, "daemon.restart", 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) @@ -396,6 +445,34 @@ func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, return &out, err } +func (c *Client) ParseStatus(ctx context.Context) (json.RawMessage, error) { + return c.rawData(ctx, "parse.status", nil) +} + +func (c *Client) ParseTextUnits(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) { + return c.rawData(ctx, "parse.text_units", query) +} + +func (c *Client) ParseErrors(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) { + return c.rawData(ctx, "parse.errors", query) +} + +func (c *Client) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { + return c.rawData(ctx, "localized.status", nil) +} + +func (c *Client) UnityFSPatchTextAsset(ctx context.Context, params UnityFSTextAssetPatchParams) (json.RawMessage, error) { + return c.rawData(ctx, "unityfs.patch_text_asset", params) +} + +func (c *Client) UnityFSPatchStringField(ctx context.Context, params UnityFSStringFieldPatchParams) (json.RawMessage, error) { + return c.rawData(ctx, "unityfs.patch_string_field", params) +} + +func (c *Client) UnityFSPatchField(ctx context.Context, params UnityFSFieldPatchParams) (json.RawMessage, error) { + return c.rawData(ctx, "unityfs.patch_field", params) +} + func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) { var out TaskRecord _, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out) diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 2472e67..7a5d078 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -88,6 +88,120 @@ func TestResourceRepairQueuesTask(t *testing.T) { } } +func TestDaemonRestartSendsControlMethod(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "daemon.restart" { + t.Fatalf("method = %s", req.Method) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "accepted", + RequestID: "req-test-restart", + Data: map[string]any{ + "command": "restart", + "status": "accepted", + "message": "restart accepted", + "state_dir": "/tmp/bat-pid", + "socket_path": "/tmp/bat-pid/bat.sock", + "controller_pid": 4242, + }, + }, + } + }) + + ack, err := client.DaemonRestart(context.Background()) + if err != nil { + t.Fatalf("DaemonRestart error: %v", err) + } + if ack.Command != "restart" || ack.ControllerPID == nil || *ack.ControllerPID != 4242 { + t.Fatalf("unexpected ack: %#v", ack) + } +} + +func TestParseTextUnitsSendsQuery(t *testing.T) { + pathID := int64(7) + classID := 114 + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "parse.text_units" { + t.Fatalf("method = %s", req.Method) + } + var params TextUnitQueryParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.Offset != 3 || params.Limit != 5 || params.Destination != "*Table*" || params.PathID == nil || *params.PathID != pathID || params.ClassID == nil || *params.ClassID != classID { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-parse", + Data: map[string]any{ + "available": true, + "entries": []any{}, + }, + }, + } + }) + + raw, err := client.ParseTextUnits(context.Background(), TextUnitQueryParams{ + Offset: 3, + Limit: 5, + Destination: "*Table*", + PathID: &pathID, + ClassID: &classID, + }) + if err != nil { + t.Fatalf("ParseTextUnits error: %v", err) + } + if !json.Valid(raw) { + t.Fatalf("invalid raw JSON: %s", string(raw)) + } +} + +func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "unityfs.patch_field" { + t.Fatalf("method = %s", req.Method) + } + var params UnityFSFieldPatchParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.BundlePath != "/tmp/source.bundle" || params.FieldPath != "m_Name" || string(params.Replacement) != `{"kind":"string","value":"new text"}` { + t.Fatalf("params = %#v replacement=%s", params, string(params.Replacement)) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-unityfs", + Data: map[string]any{ + "command": "unityfs.patch_field", + "status": "completed", + }, + }, + } + }) + + raw, err := client.UnityFSPatchField(context.Background(), UnityFSFieldPatchParams{ + BundlePath: "/tmp/source.bundle", + SerializedFilePath: "CAB-test", + PathID: 1, + FieldPath: "m_Name", + Replacement: json.RawMessage(`{"kind":"string","value":"new text"}`), + TargetPath: "/tmp/target.bundle", + }) + if err != nil { + t.Fatalf("UnityFSPatchField error: %v", err) + } + if !json.Valid(raw) { + t.Fatalf("invalid raw JSON: %s", string(raw)) + } +} + func TestResourceListSendsPagination(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "resource.list" {