mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
feat: add official sync progress logging
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json`。
|
4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json`。
|
||||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。
|
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。
|
||||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。
|
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。
|
||||||
7. `bat-official-sync --watch` 可常驻运行,正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试。
|
7. `bat-official-sync --watch` 可常驻运行,正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stderr 输出人类可读 progress log。
|
||||||
8. 非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一状态目录。
|
8. 非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一状态目录。
|
||||||
|
|
||||||
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
|
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
|||||||
--error-retry 60s
|
--error-retry 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
CLI 默认会把人类可读的阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载第 N/总数个 URL 等;成功后的稳定 JSON report 仍写到 stdout。需要给上层程序保留纯机器输出时可加 `--no-progress`。
|
||||||
|
|
||||||
生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。
|
生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ Linux 生产路径:
|
|||||||
- pull plan 会同时包含 discovery URLs 和 content URLs
|
- pull plan 会同时包含 discovery URLs 和 content URLs
|
||||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||||
- `bat-official-sync` 输出稳定 JSON report,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录
|
- `bat-official-sync` 默认向 stderr 输出人类可读 progress log,成功 JSON report 保持 stdout;支持 `--no-progress` 关闭进度日志,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录
|
||||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||||
- 下载层能在本地文件 size/BLAKE3/path 或 manifest 不匹配时重新下载
|
- 下载层能在本地文件 size/BLAKE3/path 或 manifest 不匹配时重新下载
|
||||||
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ target/release/bat-official-sync
|
|||||||
--watch
|
--watch
|
||||||
```
|
```
|
||||||
|
|
||||||
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。下载、发现或校验失败时默认 60 秒后重试,可显式加 `--error-retry 60s` 或 `--error-retry-seconds 60` 调整。
|
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。下载、发现或校验失败时默认 60 秒后重试,可显式加 `--error-retry 60s` 或 `--error-retry-seconds 60` 调整。默认进度日志写到 stderr,成功 JSON report 写到 stdout;如果由上层服务严格解析 stderr/stdout,可加 `--no-progress`。
|
||||||
|
|
||||||
### systemd service 示例
|
### systemd service 示例
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
|||||||
--error-retry 60s
|
--error-retry 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
`--interval` 是正常检查周期,默认 `1h`;`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。命令成功时 stdout 输出稳定 JSON report;错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期。普通错误 exit `1`,状态目录锁冲突 exit `75`。
|
`--interval` 是正常检查周期,默认 `1h`;`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认把人类可读的阶段进度日志写到 stderr,包括自动发现、server-info、marker、catalog、audit、download 和 snapshot 阶段;成功时 stdout 输出稳定 JSON report。需要纯机器输出时加 `--no-progress`,需要显式开启则用 `--progress`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有进度日志。普通错误 exit `1`,状态目录锁冲突 exit `75`。
|
||||||
|
|
||||||
生产可以直接运行 `--watch`,也可以用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一状态目录。
|
生产可以直接运行 `--watch`,也可以用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一状态目录。
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateService, OfficialUpdateStatus,
|
OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateService,
|
||||||
|
OfficialUpdateStatus,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const EXIT_ERROR: i32 = 1;
|
const EXIT_ERROR: i32 = 1;
|
||||||
const EXIT_LOCKED: i32 = 75;
|
const EXIT_LOCKED: i32 = 75;
|
||||||
@@ -43,7 +44,10 @@ fn run() -> anyhow::Result<()> {
|
|||||||
if options.watch {
|
if options.watch {
|
||||||
run_watch(options)?;
|
run_watch(options)?;
|
||||||
} else {
|
} else {
|
||||||
let report = OfficialUpdateService::new().run(&options.config)?;
|
let mut logger = ProgressLogger::new(options.progress);
|
||||||
|
let report = OfficialUpdateService::new().run_with_progress(&options.config, |event| {
|
||||||
|
logger.log(event);
|
||||||
|
})?;
|
||||||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,7 @@ struct CliOptions {
|
|||||||
error_retry_interval: Duration,
|
error_retry_interval: Duration,
|
||||||
quiet_up_to_date: bool,
|
quiet_up_to_date: bool,
|
||||||
quiet_up_to_date_explicit: bool,
|
quiet_up_to_date_explicit: bool,
|
||||||
|
progress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CliOptions {
|
impl Default for CliOptions {
|
||||||
@@ -79,6 +84,7 @@ impl Default for CliOptions {
|
|||||||
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS),
|
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS),
|
||||||
quiet_up_to_date: false,
|
quiet_up_to_date: false,
|
||||||
quiet_up_to_date_explicit: false,
|
quiet_up_to_date_explicit: false,
|
||||||
|
progress: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,16 +102,34 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let service = OfficialUpdateService::new();
|
let service = OfficialUpdateService::new();
|
||||||
|
let mut logger = ProgressLogger::new(options.progress);
|
||||||
loop {
|
loop {
|
||||||
let mut sleep_for = options.interval;
|
let mut sleep_for = options.interval;
|
||||||
match service.run(&options.config) {
|
logger.log_text("watch", "starting watch iteration");
|
||||||
|
match service.run_with_progress(&options.config, |event| logger.log(event)) {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||||
}
|
}
|
||||||
|
logger.log_text(
|
||||||
|
"watch",
|
||||||
|
format!(
|
||||||
|
"iteration finished with status={}; next check in {}",
|
||||||
|
report.update_status.as_str(),
|
||||||
|
format_duration(sleep_for)
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
sleep_for = options.error_retry_interval;
|
sleep_for = options.error_retry_interval;
|
||||||
|
logger.log_text(
|
||||||
|
"watch",
|
||||||
|
format!(
|
||||||
|
"iteration failed; retrying in {}: {}",
|
||||||
|
format_duration(sleep_for),
|
||||||
|
error
|
||||||
|
),
|
||||||
|
);
|
||||||
let payload = ErrorReport {
|
let payload = ErrorReport {
|
||||||
status: "error",
|
status: "error",
|
||||||
exit_code: EXIT_ERROR,
|
exit_code: EXIT_ERROR,
|
||||||
@@ -120,6 +144,37 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ProgressLogger {
|
||||||
|
enabled: bool,
|
||||||
|
started_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressLogger {
|
||||||
|
fn new(enabled: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&mut self, event: OfficialUpdateProgress) {
|
||||||
|
self.log_text(event.stage, event.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
|
||||||
|
if !self.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[+{} INFO] [{}] {}",
|
||||||
|
format_duration(self.started_at.elapsed()),
|
||||||
|
stage,
|
||||||
|
message.as_ref()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
|
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
|
||||||
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
|
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
|
||||||
}
|
}
|
||||||
@@ -233,6 +288,12 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
|
|||||||
options.quiet_up_to_date = false;
|
options.quiet_up_to_date = false;
|
||||||
options.quiet_up_to_date_explicit = true;
|
options.quiet_up_to_date_explicit = true;
|
||||||
}
|
}
|
||||||
|
"--progress" => {
|
||||||
|
options.progress = true;
|
||||||
|
}
|
||||||
|
"--no-progress" => {
|
||||||
|
options.progress = false;
|
||||||
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
print_usage(&binary);
|
print_usage(&binary);
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
@@ -273,7 +334,7 @@ fn print_usage(binary: &str) {
|
|||||||
[--app-version 1.70.0] [--connection-group <name>] [--launcher-version 1.7.2] \
|
[--app-version 1.70.0] [--connection-group <name>] [--launcher-version 1.7.2] \
|
||||||
[--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \
|
[--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \
|
||||||
[--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
|
[--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
|
||||||
[--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date]"
|
[--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date] [--progress|--no-progress]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +365,14 @@ fn parse_duration(value: &str) -> anyhow::Result<Duration> {
|
|||||||
Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
|
Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_duration(duration: Duration) -> String {
|
||||||
|
let total_millis = duration.as_millis();
|
||||||
|
let minutes = total_millis / 60_000;
|
||||||
|
let seconds = (total_millis / 1_000) % 60;
|
||||||
|
let millis = total_millis % 1_000;
|
||||||
|
format!("{minutes:02}:{seconds:02}.{millis:03}")
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||||
value
|
value
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -368,6 +437,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!options.quiet_up_to_date);
|
assert!(!options.quiet_up_to_date);
|
||||||
assert!(!options.quiet_up_to_date_explicit);
|
assert!(!options.quiet_up_to_date_explicit);
|
||||||
|
assert!(options.progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -446,6 +516,18 @@ mod tests {
|
|||||||
assert_eq!(options.error_retry_interval, Duration::from_secs(30));
|
assert_eq!(options.error_retry_interval, Duration::from_secs(30));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_progress_flags() {
|
||||||
|
let options = parse(&["bat-official-sync"]).unwrap();
|
||||||
|
assert!(options.progress);
|
||||||
|
|
||||||
|
let options = parse(&["bat-official-sync", "--no-progress"]).unwrap();
|
||||||
|
assert!(!options.progress);
|
||||||
|
|
||||||
|
let options = parse(&["bat-official-sync", "--no-progress", "--progress"]).unwrap();
|
||||||
|
assert!(options.progress);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_no_quiet_up_to_date_overrides_watch_default() {
|
fn explicit_no_quiet_up_to_date_overrides_watch_default() {
|
||||||
let options = parse(&[
|
let options = parse(&[
|
||||||
@@ -485,6 +567,12 @@ mod tests {
|
|||||||
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
|
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_progress_durations() {
|
||||||
|
assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007");
|
||||||
|
assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quiet_up_to_date_suppresses_only_clean_reports() {
|
fn quiet_up_to_date_suppresses_only_clean_reports() {
|
||||||
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
|
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ pub use cas::FileSystemCasRepository;
|
|||||||
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
|
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
|
||||||
pub use official_download::{
|
pub use official_download::{
|
||||||
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
||||||
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullReport,
|
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||||
OfficialResourcePullService,
|
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||||
|
OfficialResourcePullStatus,
|
||||||
};
|
};
|
||||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||||
pub use official_launcher::{
|
pub use official_launcher::{
|
||||||
@@ -47,8 +48,8 @@ pub use official_update::{
|
|||||||
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
|
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
|
||||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
|
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
|
||||||
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
|
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
|
||||||
OfficialUpdateConfig, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||||
OfficialUpdateStatus, ResolvedBootstrap,
|
OfficialUpdateSnapshot, OfficialUpdateStatus, ResolvedBootstrap,
|
||||||
};
|
};
|
||||||
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,88 @@ pub enum OfficialResourcePullStatus {
|
|||||||
Downloaded,
|
Downloaded,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OfficialResourcePullStatus {
|
||||||
|
/// Returns a stable label for reports and progress logs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::SkippedExisting => "skipped_existing",
|
||||||
|
Self::Resumed => "resumed",
|
||||||
|
Self::Downloaded => "downloaded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress event kind emitted while executing an official pull plan.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OfficialResourcePullProgressKind {
|
||||||
|
/// A URL is about to be checked or downloaded.
|
||||||
|
Started,
|
||||||
|
/// A URL finished as skipped, resumed, or downloaded.
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfficialResourcePullProgressKind {
|
||||||
|
/// Returns a stable label for progress logs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Started => "started",
|
||||||
|
Self::Finished => "finished",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress for one URL in an official pull plan.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OfficialResourcePullProgress {
|
||||||
|
/// Progress event kind.
|
||||||
|
pub kind: OfficialResourcePullProgressKind,
|
||||||
|
/// One-based URL index in the pull plan.
|
||||||
|
pub index: usize,
|
||||||
|
/// Total URL count in the pull plan.
|
||||||
|
pub total: usize,
|
||||||
|
/// Official URL currently being processed.
|
||||||
|
pub url: String,
|
||||||
|
/// Finished pull status, when `kind` is `Finished`.
|
||||||
|
pub status: Option<OfficialResourcePullStatus>,
|
||||||
|
/// Final local file size, when `kind` is `Finished`.
|
||||||
|
pub bytes: Option<u64>,
|
||||||
|
/// Bytes transferred during this run, when `kind` is `Finished`.
|
||||||
|
pub transferred_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfficialResourcePullProgress {
|
||||||
|
fn started(index: usize, total: usize, url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: OfficialResourcePullProgressKind::Started,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
url,
|
||||||
|
status: None,
|
||||||
|
bytes: None,
|
||||||
|
transferred_bytes: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finished(
|
||||||
|
index: usize,
|
||||||
|
total: usize,
|
||||||
|
url: String,
|
||||||
|
status: OfficialResourcePullStatus,
|
||||||
|
bytes: u64,
|
||||||
|
transferred_bytes: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: OfficialResourcePullProgressKind::Finished,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
url,
|
||||||
|
status: Some(status),
|
||||||
|
bytes: Some(bytes),
|
||||||
|
transferred_bytes: Some(transferred_bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Official hash algorithm used by a verified resource sidecar.
|
/// Official hash algorithm used by a verified resource sidecar.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum OfficialResourceHashAlgorithm {
|
pub enum OfficialResourceHashAlgorithm {
|
||||||
@@ -240,6 +322,16 @@ impl OfficialResourcePullService {
|
|||||||
pub fn pull(
|
pub fn pull(
|
||||||
&self,
|
&self,
|
||||||
plan: &OfficialResourcePullPlan,
|
plan: &OfficialResourcePullPlan,
|
||||||
|
) -> Result<OfficialResourcePullReport, String> {
|
||||||
|
self.pull_with_progress(plan, |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the plan and calls `progress` for every URL before and after it
|
||||||
|
/// is checked or downloaded.
|
||||||
|
pub fn pull_with_progress(
|
||||||
|
&self,
|
||||||
|
plan: &OfficialResourcePullPlan,
|
||||||
|
mut progress: impl FnMut(OfficialResourcePullProgress),
|
||||||
) -> Result<OfficialResourcePullReport, String> {
|
) -> Result<OfficialResourcePullReport, String> {
|
||||||
fs::create_dir_all(&self.output_root).map_err(|error| {
|
fs::create_dir_all(&self.output_root).map_err(|error| {
|
||||||
format!(
|
format!(
|
||||||
@@ -251,8 +343,17 @@ impl OfficialResourcePullService {
|
|||||||
let official_hash_pairs = official_seed_hash_pairs(plan);
|
let official_hash_pairs = official_seed_hash_pairs(plan);
|
||||||
let force_refresh_urls = official_hash_refresh_urls(&official_hash_pairs);
|
let force_refresh_urls = official_hash_refresh_urls(&official_hash_pairs);
|
||||||
let mut manifest = self.read_download_manifest()?;
|
let mut manifest = self.read_download_manifest()?;
|
||||||
|
let urls = plan.all_urls()?;
|
||||||
|
let total = urls.len();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for url in plan.all_urls()? {
|
for (offset, url) in urls.into_iter().enumerate() {
|
||||||
|
let index = offset + 1;
|
||||||
|
progress(OfficialResourcePullProgress::started(
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
url.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
if !is_official_yostar_jp_url(&url) {
|
if !is_official_yostar_jp_url(&url) {
|
||||||
return Err(format!("Refusing to download non-official URL: {url}"));
|
return Err(format!("Refusing to download non-official URL: {url}"));
|
||||||
}
|
}
|
||||||
@@ -282,6 +383,14 @@ impl OfficialResourcePullService {
|
|||||||
result
|
result
|
||||||
};
|
};
|
||||||
|
|
||||||
|
progress(OfficialResourcePullProgress::finished(
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
url.clone(),
|
||||||
|
result.status,
|
||||||
|
result.bytes,
|
||||||
|
result.transferred_bytes,
|
||||||
|
));
|
||||||
items.push(OfficialResourcePullItem {
|
items.push(OfficialResourcePullItem {
|
||||||
url,
|
url,
|
||||||
destination,
|
destination,
|
||||||
@@ -1610,6 +1719,41 @@ fi
|
|||||||
assert!(manifest.entries.contains_key(&report.items[0].url));
|
assert!(manifest.entries.contains_key(&report.items[0].url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_emits_started_and_finished_progress_events() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
||||||
|
let plan = build_official_pull_plan_for_platforms(
|
||||||
|
discovery_plan(),
|
||||||
|
inventory(),
|
||||||
|
&[PatchPlatform::Windows],
|
||||||
|
);
|
||||||
|
let expected_urls = plan.all_urls().unwrap().len();
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
let report = service
|
||||||
|
.pull_with_progress(&plan, |event| events.push(event))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.items.len(), expected_urls);
|
||||||
|
assert_eq!(events.len(), expected_urls * 2);
|
||||||
|
assert_eq!(events[0].kind, OfficialResourcePullProgressKind::Started);
|
||||||
|
assert_eq!(events[0].index, 1);
|
||||||
|
assert_eq!(events[0].total, expected_urls);
|
||||||
|
assert_eq!(events[1].kind, OfficialResourcePullProgressKind::Finished);
|
||||||
|
assert_eq!(
|
||||||
|
events[1].status,
|
||||||
|
Some(OfficialResourcePullStatus::Downloaded)
|
||||||
|
);
|
||||||
|
assert!(events
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.url.ends_with("/TableBundles/ExcelDB.db")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retries_transient_download_failures() {
|
fn retries_transient_download_failures() {
|
||||||
let out_dir = TempDir::new().unwrap();
|
let out_dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
|
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
|
||||||
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
|
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
|
||||||
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullService,
|
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullProgress,
|
||||||
YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
|
OfficialResourcePullProgressKind, OfficialResourcePullService, YostarJpLauncherGameConfig,
|
||||||
|
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
|
||||||
};
|
};
|
||||||
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
||||||
use bat_adapters::official::inventory::{
|
use bat_adapters::official::inventory::{
|
||||||
@@ -385,6 +386,25 @@ pub struct OfficialUpdateReport {
|
|||||||
pub snapshot_written: Option<PathBuf>,
|
pub snapshot_written: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Human-readable progress emitted while one official update run is executing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct OfficialUpdateProgress {
|
||||||
|
/// Stable progress stage label.
|
||||||
|
pub stage: &'static str,
|
||||||
|
/// Human-readable status line.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfficialUpdateProgress {
|
||||||
|
/// Creates a progress event.
|
||||||
|
pub fn new(stage: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
stage,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Official update runner.
|
/// Official update runner.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct OfficialUpdateService;
|
pub struct OfficialUpdateService;
|
||||||
@@ -397,10 +417,40 @@ impl OfficialUpdateService {
|
|||||||
|
|
||||||
/// Executes one official update run.
|
/// Executes one official update run.
|
||||||
pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> {
|
pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> {
|
||||||
|
self.run_with_progress(config, |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes one official update run and emits human-readable progress
|
||||||
|
/// events. Structured reports are still returned separately.
|
||||||
|
pub fn run_with_progress(
|
||||||
|
&self,
|
||||||
|
config: &OfficialUpdateConfig,
|
||||||
|
mut progress: impl FnMut(OfficialUpdateProgress),
|
||||||
|
) -> anyhow::Result<OfficialUpdateReport> {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"start",
|
||||||
|
format!(
|
||||||
|
"starting official resource sync: output={} dry_run={} auto_discover={}",
|
||||||
|
config.output_root.display(),
|
||||||
|
config.dry_run,
|
||||||
|
config.auto_discover
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
let _lock = if config.dry_run {
|
let _lock = if config.dry_run {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"lock",
|
||||||
|
"dry-run: skipping state lock",
|
||||||
|
));
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(OfficialUpdateLock::acquire(config)?)
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"lock",
|
||||||
|
format!("acquiring state lock {}", config.lock_path().display()),
|
||||||
|
));
|
||||||
|
let lock = OfficialUpdateLock::acquire(config)?;
|
||||||
|
progress(OfficialUpdateProgress::new("lock", "state lock acquired"));
|
||||||
|
Some(lock)
|
||||||
};
|
};
|
||||||
|
|
||||||
let default_platforms = default_official_platforms();
|
let default_platforms = default_official_platforms();
|
||||||
@@ -416,14 +466,27 @@ impl OfficialUpdateService {
|
|||||||
let bootstrap_cache_path = config.bootstrap_cache_path();
|
let bootstrap_cache_path = config.bootstrap_cache_path();
|
||||||
|
|
||||||
let bootstrap = if config.auto_discover {
|
let bootstrap = if config.auto_discover {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"bootstrap",
|
||||||
|
format!(
|
||||||
|
"auto-discover enabled; launcher_version={} cache={}",
|
||||||
|
config.launcher_version,
|
||||||
|
bootstrap_cache_path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
Some(resolve_bootstrap(
|
Some(resolve_bootstrap(
|
||||||
&config.launcher_version,
|
&config.launcher_version,
|
||||||
&config.curl_command,
|
&config.curl_command,
|
||||||
&config.unzip_command,
|
&config.unzip_command,
|
||||||
&bootstrap_cache_path,
|
&bootstrap_cache_path,
|
||||||
!config.dry_run,
|
!config.dry_run,
|
||||||
|
&mut progress,
|
||||||
)?)
|
)?)
|
||||||
} else {
|
} else {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"bootstrap",
|
||||||
|
"auto-discover disabled; using explicit metadata inputs",
|
||||||
|
));
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -451,8 +514,24 @@ impl OfficialUpdateService {
|
|||||||
"missing connection group; pass it explicitly or enable auto-discover"
|
"missing connection group; pass it explicitly or enable auto-discover"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"metadata",
|
||||||
|
format!(
|
||||||
|
"using app_version={} connection_group={} platforms={}",
|
||||||
|
app_version,
|
||||||
|
connection_group,
|
||||||
|
platforms_label(platforms)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
|
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"server-info",
|
||||||
|
format!(
|
||||||
|
"loading server-info from {}",
|
||||||
|
server_info_source_label(source)
|
||||||
|
),
|
||||||
|
));
|
||||||
load_server_info(source, &fetcher)?
|
load_server_info(source, &fetcher)?
|
||||||
} else {
|
} else {
|
||||||
let bootstrap = bootstrap.as_ref().ok_or_else(|| {
|
let bootstrap = bootstrap.as_ref().ok_or_else(|| {
|
||||||
@@ -467,19 +546,47 @@ impl OfficialUpdateService {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl")
|
anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl")
|
||||||
})?;
|
})?;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"server-info",
|
||||||
|
format!("fetching discovered server-info {url}"),
|
||||||
|
));
|
||||||
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
|
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"server-info",
|
||||||
|
format!("parsing server-info bytes={}", server_info_bytes.len()),
|
||||||
|
));
|
||||||
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
|
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
|
||||||
let current_snapshot = server_info
|
let current_snapshot = server_info
|
||||||
.sync_snapshot(&connection_group, &app_version, platforms)
|
.sync_snapshot(&connection_group, &app_version, platforms)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
let endpoint_markers = collect_endpoint_markers(&fetcher, ¤t_snapshot)?;
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"discovery",
|
||||||
|
format!(
|
||||||
|
"resolved addressables_root={} endpoints={}",
|
||||||
|
current_snapshot.addressables_root,
|
||||||
|
current_snapshot.endpoints.len()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"markers",
|
||||||
|
format!(
|
||||||
|
"checking {} remote marker endpoints",
|
||||||
|
marker_endpoint_count(¤t_snapshot)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
let endpoint_markers =
|
||||||
|
collect_endpoint_markers(&fetcher, ¤t_snapshot, &mut progress)?;
|
||||||
let current_update_snapshot = OfficialUpdateSnapshot::new(
|
let current_update_snapshot = OfficialUpdateSnapshot::new(
|
||||||
current_snapshot.clone(),
|
current_snapshot.clone(),
|
||||||
endpoint_markers,
|
endpoint_markers,
|
||||||
bootstrap.as_ref(),
|
bootstrap.as_ref(),
|
||||||
);
|
);
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"snapshot",
|
||||||
|
format!("reading previous snapshot {}", snapshot_path.display()),
|
||||||
|
));
|
||||||
let previous_snapshot = read_snapshot(&snapshot_path)?;
|
let previous_snapshot = read_snapshot(&snapshot_path)?;
|
||||||
let previous_base_snapshot = previous_snapshot
|
let previous_base_snapshot = previous_snapshot
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -491,18 +598,48 @@ impl OfficialUpdateService {
|
|||||||
let changed_urls = changed_endpoint_urls(&sync_plan.delta);
|
let changed_urls = changed_endpoint_urls(&sync_plan.delta);
|
||||||
let remote_should_download =
|
let remote_should_download =
|
||||||
config.force || sync_plan.should_download() || extended_delta.has_changes();
|
config.force || sync_plan.should_download() || extended_delta.has_changes();
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"decision",
|
||||||
|
format!(
|
||||||
|
"remote decision={:?} force={} remote_should_download={}",
|
||||||
|
sync_plan.decision, config.force, remote_should_download
|
||||||
|
),
|
||||||
|
));
|
||||||
let pull_plan = if config.audit_local || remote_should_download || config.plan {
|
let pull_plan = if config.audit_local || remote_should_download || config.plan {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"plan",
|
||||||
|
"building official pull plan from seed catalogs",
|
||||||
|
));
|
||||||
Some(build_pull_plan(
|
Some(build_pull_plan(
|
||||||
&server_info,
|
&server_info,
|
||||||
&connection_group,
|
&connection_group,
|
||||||
&app_version,
|
&app_version,
|
||||||
platforms,
|
platforms,
|
||||||
&fetcher,
|
&fetcher,
|
||||||
|
&mut progress,
|
||||||
)?)
|
)?)
|
||||||
} else {
|
} else {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"plan",
|
||||||
|
"pull plan not needed for clean remote state and disabled plan output",
|
||||||
|
));
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
if let Some(pull_plan) = pull_plan.as_ref() {
|
||||||
|
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"plan",
|
||||||
|
format!("pull plan contains {url_count} official URLs"),
|
||||||
|
));
|
||||||
|
}
|
||||||
let local_audit = if config.audit_local {
|
let local_audit = if config.audit_local {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"audit",
|
||||||
|
format!(
|
||||||
|
"auditing local download manifest {}",
|
||||||
|
fetcher.download_manifest_path().display()
|
||||||
|
),
|
||||||
|
));
|
||||||
Some(
|
Some(
|
||||||
fetcher
|
fetcher
|
||||||
.audit_local_manifest(
|
.audit_local_manifest(
|
||||||
@@ -513,6 +650,10 @@ impl OfficialUpdateService {
|
|||||||
.map_err(anyhow::Error::msg)?,
|
.map_err(anyhow::Error::msg)?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"audit",
|
||||||
|
"local manifest audit disabled",
|
||||||
|
));
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let local_manifest_verified_count = local_audit
|
let local_manifest_verified_count = local_audit
|
||||||
@@ -529,6 +670,20 @@ impl OfficialUpdateService {
|
|||||||
.map(|audit| !audit.is_clean())
|
.map(|audit| !audit.is_clean())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let should_download = remote_should_download || repair_needed;
|
let should_download = remote_should_download || repair_needed;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"audit",
|
||||||
|
format!(
|
||||||
|
"local manifest verified={} repair_needed={}",
|
||||||
|
local_manifest_verified_count, local_manifest_repair_needed_count
|
||||||
|
),
|
||||||
|
));
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"decision",
|
||||||
|
format!(
|
||||||
|
"final should_download={} repair_needed={}",
|
||||||
|
should_download, repair_needed
|
||||||
|
),
|
||||||
|
));
|
||||||
let mut report = OfficialUpdateReport {
|
let mut report = OfficialUpdateReport {
|
||||||
update_status: if should_download {
|
update_status: if should_download {
|
||||||
OfficialUpdateStatus::WouldDownload
|
OfficialUpdateStatus::WouldDownload
|
||||||
@@ -572,6 +727,10 @@ impl OfficialUpdateService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !should_download {
|
if !should_download {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"finish",
|
||||||
|
"up to date; no download needed",
|
||||||
|
));
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,18 +745,61 @@ impl OfficialUpdateService {
|
|||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
report.download_url_count = Some(urls.len());
|
report.download_url_count = Some(urls.len());
|
||||||
report.download_urls = urls;
|
report.download_urls = urls;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"dry-run",
|
||||||
|
format!(
|
||||||
|
"dry-run plan generated {} URLs",
|
||||||
|
report.download_url_count.unwrap_or(0)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"dry-run",
|
||||||
|
"dry-run detected pending download; full URL plan disabled",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
report.update_status = OfficialUpdateStatus::WouldDownload;
|
report.update_status = OfficialUpdateStatus::WouldDownload;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"finish",
|
||||||
|
"dry-run finished without writing state",
|
||||||
|
));
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pull_plan = pull_plan
|
let pull_plan = pull_plan
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
|
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
|
||||||
let pull_report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?;
|
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"download",
|
||||||
|
format!("downloading or reusing {download_url_count} official URLs"),
|
||||||
|
));
|
||||||
|
let pull_report = fetcher
|
||||||
|
.pull_with_progress(pull_plan, |event| {
|
||||||
|
progress(progress_from_pull_event(event));
|
||||||
|
})
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"download",
|
||||||
|
format!(
|
||||||
|
"download phase finished: downloaded={} resumed={} skipped={} transferred_bytes={}",
|
||||||
|
pull_report.downloaded_count(),
|
||||||
|
pull_report.resumed_count(),
|
||||||
|
pull_report.skipped_count(),
|
||||||
|
pull_report.transferred_bytes()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"audit",
|
||||||
|
"running final local manifest audit",
|
||||||
|
));
|
||||||
let final_audit = fetcher
|
let final_audit = fetcher
|
||||||
.audit_local_manifest(pull_plan)
|
.audit_local_manifest(pull_plan)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"snapshot",
|
||||||
|
format!("writing snapshot {}", snapshot_path.display()),
|
||||||
|
));
|
||||||
write_snapshot(&snapshot_path, ¤t_update_snapshot)?;
|
write_snapshot(&snapshot_path, ¤t_update_snapshot)?;
|
||||||
|
|
||||||
report.update_status = OfficialUpdateStatus::Downloaded;
|
report.update_status = OfficialUpdateStatus::Downloaded;
|
||||||
@@ -612,6 +814,14 @@ impl OfficialUpdateService {
|
|||||||
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
|
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
|
||||||
report.snapshot_written = Some(snapshot_path);
|
report.snapshot_written = Some(snapshot_path);
|
||||||
|
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"finish",
|
||||||
|
format!(
|
||||||
|
"sync finished: resources={} official_hashes_verified={}",
|
||||||
|
report.resource_count.unwrap_or(0),
|
||||||
|
report.official_seed_hash_verified_count
|
||||||
|
),
|
||||||
|
));
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,11 +832,25 @@ fn build_pull_plan(
|
|||||||
app_version: &str,
|
app_version: &str,
|
||||||
platforms: &[PatchPlatform],
|
platforms: &[PatchPlatform],
|
||||||
fetcher: &OfficialResourcePullService,
|
fetcher: &OfficialResourcePullService,
|
||||||
|
progress: &mut dyn FnMut(OfficialUpdateProgress),
|
||||||
) -> anyhow::Result<OfficialResourcePullPlan> {
|
) -> anyhow::Result<OfficialResourcePullPlan> {
|
||||||
let discovery = server_info
|
let discovery = server_info
|
||||||
.discovery_plan(connection_group, app_version, platforms)
|
.discovery_plan(connection_group, app_version, platforms)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery)?;
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"plan",
|
||||||
|
format!(
|
||||||
|
"discovery plan resolved: connection_group={} app_version={} endpoints={}",
|
||||||
|
discovery.connection_group_name,
|
||||||
|
discovery.app_version,
|
||||||
|
discovery.endpoints.len()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery, progress)?;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"inventory",
|
||||||
|
"parsing seed catalogs into download inventory",
|
||||||
|
));
|
||||||
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
|
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
|
||||||
Ok(build_official_pull_plan_for_platform_inventory(
|
Ok(build_official_pull_plan_for_platform_inventory(
|
||||||
discovery, inventory, platforms,
|
discovery, inventory, platforms,
|
||||||
@@ -652,6 +876,7 @@ fn load_server_info(
|
|||||||
fn collect_endpoint_markers(
|
fn collect_endpoint_markers(
|
||||||
fetcher: &OfficialResourcePullService,
|
fetcher: &OfficialResourcePullService,
|
||||||
snapshot: &YostarJpSyncSnapshot,
|
snapshot: &YostarJpSyncSnapshot,
|
||||||
|
progress: &mut dyn FnMut(OfficialUpdateProgress),
|
||||||
) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> {
|
) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> {
|
||||||
let mut markers = Vec::new();
|
let mut markers = Vec::new();
|
||||||
|
|
||||||
@@ -659,6 +884,15 @@ fn collect_endpoint_markers(
|
|||||||
let Some(role) = marker_role(endpoint.kind) else {
|
let Some(role) = marker_role(endpoint.kind) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"marker",
|
||||||
|
format!(
|
||||||
|
"fetching {} marker{} {}",
|
||||||
|
endpoint_kind_label(endpoint.kind),
|
||||||
|
platform_suffix(endpoint.platform),
|
||||||
|
endpoint.url
|
||||||
|
),
|
||||||
|
));
|
||||||
let bytes = fetcher
|
let bytes = fetcher
|
||||||
.fetch_bytes(&endpoint.url)
|
.fetch_bytes(&endpoint.url)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
@@ -689,6 +923,78 @@ fn marker_role(kind: YostarJpResourceEndpointKind) -> Option<OfficialEndpointMar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
|
||||||
|
snapshot
|
||||||
|
.endpoints
|
||||||
|
.iter()
|
||||||
|
.filter(|endpoint| marker_role(endpoint.kind).is_some())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
|
||||||
|
match source {
|
||||||
|
OfficialServerInfoSource::LocalPath(path) => format!("local path {}", path.display()),
|
||||||
|
OfficialServerInfoSource::OfficialFile(file_name) => {
|
||||||
|
format!("official file {file_name}")
|
||||||
|
}
|
||||||
|
OfficialServerInfoSource::OfficialUrl(url) => format!("official URL {url}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platforms_label(platforms: &[PatchPlatform]) -> String {
|
||||||
|
platforms
|
||||||
|
.iter()
|
||||||
|
.map(|platform| platform.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
|
||||||
|
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
|
||||||
|
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
|
||||||
|
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
|
||||||
|
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
|
||||||
|
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
|
||||||
|
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
|
||||||
|
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_suffix(platform: Option<PatchPlatform>) -> String {
|
||||||
|
platform
|
||||||
|
.map(|platform| format!(" ({})", platform.as_str()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpdateProgress {
|
||||||
|
match event.kind {
|
||||||
|
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
|
||||||
|
"download",
|
||||||
|
format!("({}/{}) started {}", event.index, event.total, event.url),
|
||||||
|
),
|
||||||
|
OfficialResourcePullProgressKind::Finished => {
|
||||||
|
let status = event
|
||||||
|
.status
|
||||||
|
.map(|status| status.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
OfficialUpdateProgress::new(
|
||||||
|
"download",
|
||||||
|
format!(
|
||||||
|
"({}/{}) finished status={} bytes={} transferred_bytes={} {}",
|
||||||
|
event.index,
|
||||||
|
event.total,
|
||||||
|
status,
|
||||||
|
event.bytes.unwrap_or(0),
|
||||||
|
event.transferred_bytes.unwrap_or(0),
|
||||||
|
event.url
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Computes the extended snapshot delta.
|
/// Computes the extended snapshot delta.
|
||||||
pub fn diff_extended_snapshot(
|
pub fn diff_extended_snapshot(
|
||||||
current: &OfficialUpdateSnapshot,
|
current: &OfficialUpdateSnapshot,
|
||||||
@@ -817,21 +1123,41 @@ fn resolve_bootstrap(
|
|||||||
unzip_command: &Path,
|
unzip_command: &Path,
|
||||||
cache_path: &Path,
|
cache_path: &Path,
|
||||||
write_cache: bool,
|
write_cache: bool,
|
||||||
|
progress: &mut dyn FnMut(OfficialUpdateProgress),
|
||||||
) -> anyhow::Result<ResolvedBootstrap> {
|
) -> anyhow::Result<ResolvedBootstrap> {
|
||||||
let launcher = OfficialLauncherBootstrapService::with_curl_command(
|
let launcher = OfficialLauncherBootstrapService::with_curl_command(
|
||||||
launcher_version,
|
launcher_version,
|
||||||
curl_command.to_string_lossy().to_string(),
|
curl_command.to_string_lossy().to_string(),
|
||||||
);
|
);
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"launcher",
|
||||||
|
"fetching official launcher game config and remote manifest",
|
||||||
|
));
|
||||||
let (game_config, manifest_url, manifest) = launcher
|
let (game_config, manifest_url, manifest) = launcher
|
||||||
.fetch_latest_remote_manifest()
|
.fetch_latest_remote_manifest()
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
let launcher_metadata =
|
let launcher_metadata =
|
||||||
launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest);
|
launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest);
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"launcher",
|
||||||
|
format!(
|
||||||
|
"launcher metadata resolved: latest_version={} manifest_files={}",
|
||||||
|
launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"bootstrap-cache",
|
||||||
|
format!("checking bootstrap cache {}", cache_path.display()),
|
||||||
|
));
|
||||||
if let Some(cache) = read_bootstrap_cache(cache_path)? {
|
if let Some(cache) = read_bootstrap_cache(cache_path)? {
|
||||||
if let Some(game_main_config) =
|
if let Some(game_main_config) =
|
||||||
cached_game_main_config_for_metadata(&cache, &launcher_metadata)
|
cached_game_main_config_for_metadata(&cache, &launcher_metadata)
|
||||||
{
|
{
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"bootstrap-cache",
|
||||||
|
"cache hit; reusing parsed GameMainConfig",
|
||||||
|
));
|
||||||
return Ok(ResolvedBootstrap {
|
return Ok(ResolvedBootstrap {
|
||||||
launcher_metadata,
|
launcher_metadata,
|
||||||
game_main_config,
|
game_main_config,
|
||||||
@@ -840,6 +1166,10 @@ fn resolve_bootstrap(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"game-main-config",
|
||||||
|
"cache miss; fetching resources.assets and parsing GameMainConfig",
|
||||||
|
));
|
||||||
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
|
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
|
||||||
launcher_version,
|
launcher_version,
|
||||||
curl_command.to_path_buf(),
|
curl_command.to_path_buf(),
|
||||||
@@ -867,6 +1197,15 @@ fn resolve_bootstrap(
|
|||||||
game_main_config: game_main_config.clone(),
|
game_main_config: game_main_config.clone(),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"bootstrap-cache",
|
||||||
|
format!("bootstrap cache written {}", cache_path.display()),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"bootstrap-cache",
|
||||||
|
"dry-run: bootstrap cache not written",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ResolvedBootstrap {
|
Ok(ResolvedBootstrap {
|
||||||
@@ -879,6 +1218,7 @@ fn resolve_bootstrap(
|
|||||||
fn fetch_seed_catalogs(
|
fn fetch_seed_catalogs(
|
||||||
fetcher: &OfficialResourcePullService,
|
fetcher: &OfficialResourcePullService,
|
||||||
discovery: &YostarJpResourceDiscoveryPlan,
|
discovery: &YostarJpResourceDiscoveryPlan,
|
||||||
|
progress: &mut dyn FnMut(OfficialUpdateProgress),
|
||||||
) -> anyhow::Result<SeedCatalogs> {
|
) -> anyhow::Result<SeedCatalogs> {
|
||||||
let mut catalogs = SeedCatalogs::default();
|
let mut catalogs = SeedCatalogs::default();
|
||||||
|
|
||||||
@@ -892,6 +1232,15 @@ fn fetch_seed_catalogs(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"catalog",
|
||||||
|
format!(
|
||||||
|
"fetching seed catalog {}{} {}",
|
||||||
|
endpoint_kind_label(endpoint.kind),
|
||||||
|
platform_suffix(endpoint.platform),
|
||||||
|
endpoint.url
|
||||||
|
),
|
||||||
|
));
|
||||||
let bytes = fetcher
|
let bytes = fetcher
|
||||||
.fetch_bytes(&endpoint.url)
|
.fetch_bytes(&endpoint.url)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use bat_adapters::official::yostar_jp::{
|
|||||||
};
|
};
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
|
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
|
||||||
OfficialResourcePullService,
|
OfficialResourcePullService, OfficialUpdateConfig, OfficialUpdateProgress,
|
||||||
|
OfficialUpdateService, OfficialUpdateStatus,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -151,6 +152,39 @@ fn official_pipeline_can_dry_run_and_pull_without_local_client() {
|
|||||||
assert!(!curl_log.contains("bluearchive.cafe"));
|
assert!(!curl_log.contains("bluearchive.cafe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_update_service_emits_human_progress_events() {
|
||||||
|
let harness = TestHarness::new();
|
||||||
|
let config = OfficialUpdateConfig {
|
||||||
|
auto_discover: true,
|
||||||
|
launcher_version: TEST_LAUNCHER_VERSION.to_string(),
|
||||||
|
output_root: harness.temp.path().join("sync-output"),
|
||||||
|
curl_command: harness.curl_script.clone(),
|
||||||
|
unzip_command: harness.unzip_script.clone(),
|
||||||
|
dry_run: true,
|
||||||
|
plan: true,
|
||||||
|
..OfficialUpdateConfig::default()
|
||||||
|
};
|
||||||
|
let mut events = Vec::<OfficialUpdateProgress>::new();
|
||||||
|
|
||||||
|
let report = OfficialUpdateService::new()
|
||||||
|
.run_with_progress(&config, |event| events.push(event))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.update_status, OfficialUpdateStatus::WouldDownload);
|
||||||
|
assert_eq!(report.download_url_count, Some(19));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "start"));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "launcher"));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "server-info"));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "marker"));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "catalog"));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "dry-run"));
|
||||||
|
assert!(events.iter().any(|event| event.stage == "finish"));
|
||||||
|
assert!(events.iter().any(|event| event
|
||||||
|
.message
|
||||||
|
.contains("pull plan contains 19 official URLs")));
|
||||||
|
}
|
||||||
|
|
||||||
struct TestHarness {
|
struct TestHarness {
|
||||||
temp: TempDir,
|
temp: TempDir,
|
||||||
curl_script: std::path::PathBuf,
|
curl_script: std::path::PathBuf,
|
||||||
|
|||||||
Reference in New Issue
Block a user