mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:15:14 +08:00
fix: harden official resource sync retries
This commit is contained in:
+2
-2
@@ -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 小时检查一次;远端和本地一致时默认静默。
|
7. `bat-official-sync --watch` 可常驻运行,正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试。
|
||||||
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 尚未记录在仓库文档中。
|
||||||
@@ -241,7 +241,7 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
|||||||
2. 不要指向已有游戏客户端目录。
|
2. 不要指向已有游戏客户端目录。
|
||||||
3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。
|
3. 不要指向 `/home/wanye/D/BlueArchive` 这类开发或人工维护资源目录。
|
||||||
4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。
|
4. `--auto-discover` 可以下载官方 metadata,并按官方 manifest 临时获取 `resources.assets` 以解析 `GameMainConfig`;旧 ZIP manifest 才会下载临时 game zip。该流程不会安装或启动官方 launcher。
|
||||||
5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部。
|
5. systemd service、容器或 Go 进程可以负责守护 `bat-official-sync --watch`,但定时检查逻辑已经在 Rust 内部;正常检查默认 1 小时,失败重试默认 60 秒。
|
||||||
|
|
||||||
详细运行说明见 `docs/guides/official-resource-test-pull.md`。
|
详细运行说明见 `docs/guides/official-resource-test-pull.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -71,14 +71,15 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
|||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
常驻自动更新,默认每 1 小时检查一次;远端和本地一致时静默:
|
常驻自动更新,正常情况下默认每 1 小时检查一次;远端和本地一致时静默。下载或发现失败时默认 60 秒后重试,可用 `--error-retry 60s` 调整:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
||||||
--auto-discover \
|
--auto-discover \
|
||||||
--platforms Windows,Android \
|
--platforms Windows,Android \
|
||||||
--output /tmp/ba-official-update \
|
--output /tmp/ba-official-update \
|
||||||
--watch
|
--watch \
|
||||||
|
--error-retry 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。
|
生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Official JP download inventory extraction.
|
//! Official JP download inventory extraction.
|
||||||
|
|
||||||
use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
|
use super::yostar_jp::{verified_official_platforms, PatchPlatform, YostarJpResourceRoot};
|
||||||
use std::collections::{BTreeSet, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
|
|
||||||
/// Download inventory extracted from the official JP catalog bytes.
|
/// Download inventory extracted from the official JP catalog bytes.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -79,7 +79,7 @@ impl YostarJpPlatformDownloadInventory {
|
|||||||
platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
|
platform_catalogs: Vec<YostarJpPlatformCatalogInventory>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
|
table_file_names: extract_table_file_names(table_catalog),
|
||||||
platform_catalogs: merge_platform_catalogs(platform_catalogs),
|
platform_catalogs: merge_platform_catalogs(platform_catalogs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ impl YostarJpDownloadInventory {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
|
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
|
||||||
table_file_names: extract_file_names(table_catalog, &["db", "zip"]),
|
table_file_names: extract_table_file_names(table_catalog),
|
||||||
media_file_names: extract_file_names(
|
media_file_names: extract_file_names(
|
||||||
media_catalog,
|
media_catalog,
|
||||||
&["zip", "mp4", "png", "ogg", "wav"],
|
&["zip", "mp4", "png", "ogg", "wav"],
|
||||||
@@ -297,6 +297,24 @@ fn extract_full_patch_pack_names(data: &[u8]) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_table_file_names(data: &[u8]) -> Vec<String> {
|
||||||
|
let mut counts = BTreeMap::<String, usize>::new();
|
||||||
|
|
||||||
|
// Real TableCatalog bytes list top-level downloadable table files twice,
|
||||||
|
// while package-internal stage/resource .zip names normally appear once
|
||||||
|
// next to rawdata paths and are not direct TableBundles URLs.
|
||||||
|
for string in extract_printable_strings(data, 4) {
|
||||||
|
for name in candidate_file_names(&string, &["db", "zip"]) {
|
||||||
|
*counts.entry(name).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, count)| (count >= 2).then_some(name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
|
fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
|
||||||
let mut names = BTreeSet::new();
|
let mut names = BTreeSet::new();
|
||||||
|
|
||||||
@@ -422,7 +440,7 @@ mod tests {
|
|||||||
fn extracts_download_names_from_synthetic_bytes() {
|
fn extracts_download_names_from_synthetic_bytes() {
|
||||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||||
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
|
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
|
||||||
b"GameData\\Table\\ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip8",
|
b"GameData\\Table\\ExcelDB.db\0ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip\0Battle.zip8",
|
||||||
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
|
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -443,11 +461,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_only_top_level_table_files_from_table_catalog() {
|
||||||
|
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
||||||
|
b"ExcelDB.db\0ExcelDB.db\0TablePatchPack_Prologue_GroundStage_1.zip\0TablePatchPack_Prologue_GroundStage_1.zip|1011101_01_s1_01_mainstreet_p01_d.zip\0rawdata/ground/stage/bytes/1011101_01_s1_01_mainstreet_p01_d.bytes",
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
inventory.table_file_names,
|
||||||
|
vec![
|
||||||
|
"ExcelDB.db".to_string(),
|
||||||
|
"TablePatchPack_Prologue_GroundStage_1.zip".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_direct_download_urls() {
|
fn builds_direct_download_urls() {
|
||||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||||
b"FullPatch_000.zip FullPatch_001.zip",
|
b"FullPatch_000.zip FullPatch_001.zip",
|
||||||
b"ExcelDB.db Battle.zip",
|
b"ExcelDB.db ExcelDB.db Battle.zip Battle.zip",
|
||||||
b"JP_Airi.zip JP_Akane.zip",
|
b"JP_Airi.zip JP_Akane.zip",
|
||||||
);
|
);
|
||||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||||
@@ -469,7 +503,7 @@ mod tests {
|
|||||||
fn builds_direct_download_urls_for_verified_platforms() {
|
fn builds_direct_download_urls_for_verified_platforms() {
|
||||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||||
b"FullPatch_000.zip",
|
b"FullPatch_000.zip",
|
||||||
b"ExcelDB.db",
|
b"ExcelDB.db ExcelDB.db",
|
||||||
b"JP_Airi.zip",
|
b"JP_Airi.zip",
|
||||||
);
|
);
|
||||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||||
@@ -499,7 +533,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() {
|
fn builds_platform_specific_download_urls_without_cross_mixing_catalogs() {
|
||||||
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
||||||
b"ExcelDB.db",
|
b"ExcelDB.db ExcelDB.db",
|
||||||
vec![
|
vec![
|
||||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||||
PatchPlatform::Windows,
|
PatchPlatform::Windows,
|
||||||
@@ -556,7 +590,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
|
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
|
||||||
assert_eq!(inventory.table_file_names.len(), 6351);
|
assert!(inventory.table_file_names.len() < 1000);
|
||||||
assert_eq!(inventory.media_file_names.len(), 1887);
|
assert_eq!(inventory.media_file_names.len(), 1887);
|
||||||
assert!(inventory
|
assert!(inventory
|
||||||
.bundle_patch_pack_names
|
.bundle_patch_pack_names
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ official-sync-snapshot.json + official-download-manifest.json
|
|||||||
- 不安装、不执行官方 launcher。
|
- 不安装、不执行官方 launcher。
|
||||||
- 默认平台 `Windows + Android`。
|
- 默认平台 `Windows + Android`。
|
||||||
- `--auto-discover` 自动获取 `app-version`、`connection-group` 和 `server-info`。
|
- `--auto-discover` 自动获取 `app-version`、`connection-group` 和 `server-info`。
|
||||||
- `--watch` 常驻检查,默认 1 小时。
|
- `--watch` 常驻检查,正常检查默认 1 小时,失败重试默认 60 秒。
|
||||||
- 远端 marker 无变化且本地 manifest clean 时不下载。
|
- 远端 marker 无变化且本地 manifest clean 时不下载。
|
||||||
- 本地文件损坏时 repair。
|
- 本地文件损坏时 repair。
|
||||||
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
|
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
|
||||||
|
|||||||
@@ -190,7 +190,7 @@
|
|||||||
7. 远端变化或本地 audit 发现 repair_needed 时生成 pull plan,下载并校验官方 URL。
|
7. 远端变化或本地 audit 发现 repair_needed 时生成 pull plan,下载并校验官方 URL。
|
||||||
8. 下载成功后写回新的 snapshot。
|
8. 下载成功后写回新的 snapshot。
|
||||||
|
|
||||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary 支持 `--watch` 常驻模式,默认每 1 小时执行一次检查;远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary 支持 `--watch` 常驻模式,正常情况下默认每 1 小时执行一次检查;远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||||
|
|
||||||
对应实现主要在:
|
对应实现主要在:
|
||||||
|
|
||||||
@@ -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` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录
|
- `bat-official-sync` 输出稳定 JSON report,支持 `--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。
|
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。下载、发现或校验失败时默认 60 秒后重试,可显式加 `--error-retry 60s` 或 `--error-retry-seconds 60` 调整。
|
||||||
|
|
||||||
### systemd service 示例
|
### systemd service 示例
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=bat
|
User=bat
|
||||||
Group=bat
|
Group=bat
|
||||||
ExecStart=/opt/bluearchive-toolkit/bin/bat-official-sync --auto-discover --platforms Windows,Android --output /var/lib/bluearchive-toolkit/official --watch
|
ExecStart=/opt/bluearchive-toolkit/bin/bat-official-sync --auto-discover --platforms Windows,Android --output /var/lib/bluearchive-toolkit/official --watch --error-retry 60s
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
StateDirectory=bluearchive-toolkit
|
StateDirectory=bluearchive-toolkit
|
||||||
|
|||||||
@@ -225,10 +225,11 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
|
|||||||
--platforms Windows,Android \
|
--platforms Windows,Android \
|
||||||
--output /tmp/ba-official-update \
|
--output /tmp/ba-official-update \
|
||||||
--watch \
|
--watch \
|
||||||
--interval 30m
|
--interval 30m \
|
||||||
|
--error-retry 60s
|
||||||
```
|
```
|
||||||
|
|
||||||
命令成功时 stdout 输出稳定 JSON report;错误时 stderr 输出 JSON error。普通错误 exit `1`,状态目录锁冲突 exit `75`。
|
`--interval` 是正常检查周期,默认 `1h`;`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。命令成功时 stdout 输出稳定 JSON report;错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期。普通错误 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`,防止并发写同一状态目录。
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
- `--auto-discover` 通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
- `--auto-discover` 通过官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
||||||
- 默认平台是 `Windows + Android`。
|
- 默认平台是 `Windows + Android`。
|
||||||
- 真实下载会维护 `official-download-manifest.json`,并使用 size + BLAKE3、本地 audit/repair 和官方 seed `.hash` 校验。
|
- 真实下载会维护 `official-download-manifest.json`,并使用 size + BLAKE3、本地 audit/repair 和官方 seed `.hash` 校验。
|
||||||
- `--watch` 是 Rust 内部常驻检查模式,默认 1 小时;外部 systemd/container 只负责守护进程。
|
- `--watch` 是 Rust 内部常驻检查模式,正常检查默认 1 小时,失败重试默认 60 秒;外部 systemd/container 只负责守护进程。
|
||||||
|
|
||||||
## 2. 现在不要误解的点
|
## 2. 现在不要误解的点
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::time::Duration;
|
|||||||
const EXIT_ERROR: i32 = 1;
|
const EXIT_ERROR: i32 = 1;
|
||||||
const EXIT_LOCKED: i32 = 75;
|
const EXIT_LOCKED: i32 = 75;
|
||||||
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
|
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
|
||||||
|
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
match run() {
|
match run() {
|
||||||
@@ -64,6 +65,7 @@ struct CliOptions {
|
|||||||
config: OfficialUpdateConfig,
|
config: OfficialUpdateConfig,
|
||||||
watch: bool,
|
watch: bool,
|
||||||
interval: Duration,
|
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,
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ impl Default for CliOptions {
|
|||||||
config: OfficialUpdateConfig::default(),
|
config: OfficialUpdateConfig::default(),
|
||||||
watch: false,
|
watch: false,
|
||||||
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
|
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_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,
|
||||||
}
|
}
|
||||||
@@ -87,8 +90,14 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
if options.interval.is_zero() {
|
if options.interval.is_zero() {
|
||||||
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
|
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
|
||||||
}
|
}
|
||||||
|
if options.error_retry_interval.is_zero() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"watch error retry interval must be greater than zero"
|
||||||
|
));
|
||||||
|
}
|
||||||
let service = OfficialUpdateService::new();
|
let service = OfficialUpdateService::new();
|
||||||
loop {
|
loop {
|
||||||
|
let mut sleep_for = options.interval;
|
||||||
match service.run(&options.config) {
|
match service.run(&options.config) {
|
||||||
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) {
|
||||||
@@ -96,17 +105,18 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
sleep_for = options.error_retry_interval;
|
||||||
let payload = ErrorReport {
|
let payload = ErrorReport {
|
||||||
status: "error",
|
status: "error",
|
||||||
exit_code: EXIT_ERROR,
|
exit_code: EXIT_ERROR,
|
||||||
error: error.to_string(),
|
error: error.to_string(),
|
||||||
next_retry_seconds: Some(options.interval.as_secs()),
|
next_retry_seconds: Some(sleep_for.as_secs()),
|
||||||
};
|
};
|
||||||
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
|
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
thread::sleep(options.interval);
|
thread::sleep(sleep_for);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +215,16 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
|
|||||||
.map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?;
|
.map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?;
|
||||||
options.interval = Duration::from_secs(seconds);
|
options.interval = Duration::from_secs(seconds);
|
||||||
}
|
}
|
||||||
|
"--error-retry" => {
|
||||||
|
options.error_retry_interval =
|
||||||
|
parse_duration(&next_option_value(&mut args, &flag)?)?;
|
||||||
|
}
|
||||||
|
"--error-retry-seconds" => {
|
||||||
|
let seconds = next_option_value(&mut args, &flag)?
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?;
|
||||||
|
options.error_retry_interval = Duration::from_secs(seconds);
|
||||||
|
}
|
||||||
"--quiet-up-to-date" => {
|
"--quiet-up-to-date" => {
|
||||||
options.quiet_up_to_date = true;
|
options.quiet_up_to_date = true;
|
||||||
options.quiet_up_to_date_explicit = true;
|
options.quiet_up_to_date_explicit = true;
|
||||||
@@ -227,6 +247,11 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
|
|||||||
if options.interval.is_zero() {
|
if options.interval.is_zero() {
|
||||||
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
|
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
|
||||||
}
|
}
|
||||||
|
if options.error_retry_interval.is_zero() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"watch error retry interval must be greater than zero"
|
||||||
|
));
|
||||||
|
}
|
||||||
if options.watch && !options.quiet_up_to_date_explicit {
|
if options.watch && !options.quiet_up_to_date_explicit {
|
||||||
options.quiet_up_to_date = true;
|
options.quiet_up_to_date = true;
|
||||||
}
|
}
|
||||||
@@ -248,7 +273,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] [--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]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +362,10 @@ mod tests {
|
|||||||
options.interval,
|
options.interval,
|
||||||
Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS)
|
Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.error_retry_interval,
|
||||||
|
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
|
||||||
|
);
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -385,10 +414,38 @@ mod tests {
|
|||||||
|
|
||||||
assert!(options.watch);
|
assert!(options.watch);
|
||||||
assert_eq!(options.interval, Duration::from_secs(30 * 60));
|
assert_eq!(options.interval, Duration::from_secs(30 * 60));
|
||||||
|
assert_eq!(
|
||||||
|
options.error_retry_interval,
|
||||||
|
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
|
||||||
|
);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_explicit_watch_error_retry_interval() {
|
||||||
|
let options = parse(&[
|
||||||
|
"bat-official-sync",
|
||||||
|
"--watch",
|
||||||
|
"--interval",
|
||||||
|
"1h",
|
||||||
|
"--error-retry",
|
||||||
|
"45s",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(options.interval, Duration::from_secs(3600));
|
||||||
|
assert_eq!(options.error_retry_interval, Duration::from_secs(45));
|
||||||
|
|
||||||
|
let options = parse(&[
|
||||||
|
"bat-official-sync",
|
||||||
|
"--watch",
|
||||||
|
"--error-retry-seconds",
|
||||||
|
"30",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(options.error_retry_interval, Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
|
||||||
#[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(&[
|
||||||
@@ -413,6 +470,10 @@ mod tests {
|
|||||||
let error =
|
let error =
|
||||||
parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err();
|
parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err();
|
||||||
assert!(error.to_string().contains("greater than zero"));
|
assert!(error.to_string().contains("greater than zero"));
|
||||||
|
|
||||||
|
let error =
|
||||||
|
parse(&["bat-official-sync", "--watch", "--error-retry-seconds", "0"]).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("error retry interval"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -329,6 +329,21 @@ impl OfficialResourcePullService {
|
|||||||
return Err(format!("Refusing to fetch non-official URL: {url}"));
|
return Err(format!("Refusing to fetch non-official URL: {url}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let attempts = self.retry_attempts.max(1);
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 1..=attempts {
|
||||||
|
match self.fetch_bytes_once(url) {
|
||||||
|
Ok(bytes) => return Ok(bytes),
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(format!("attempt {attempt}/{attempts}: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_bytes_once(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
let output = Command::new(&self.curl_command)
|
let output = Command::new(&self.curl_command)
|
||||||
.arg("--fail")
|
.arg("--fail")
|
||||||
.arg("--location")
|
.arg("--location")
|
||||||
@@ -1078,7 +1093,7 @@ mod tests {
|
|||||||
fn inventory() -> YostarJpDownloadInventory {
|
fn inventory() -> YostarJpDownloadInventory {
|
||||||
YostarJpDownloadInventory::from_catalog_bytes(
|
YostarJpDownloadInventory::from_catalog_bytes(
|
||||||
b"FullPatch_000.zip",
|
b"FullPatch_000.zip",
|
||||||
b"ExcelDB.db",
|
b"ExcelDB.db ExcelDB.db",
|
||||||
b"JP_Airi.zip",
|
b"JP_Airi.zip",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1680,6 +1695,31 @@ fi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_transient_fetch_bytes_failures() {
|
||||||
|
let out_dir = TempDir::new().unwrap();
|
||||||
|
let bin_dir = TempDir::new().unwrap();
|
||||||
|
let curl_path = bin_dir.path().join("curl");
|
||||||
|
write_fake_flaky_curl(&curl_path);
|
||||||
|
|
||||||
|
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
|
||||||
|
.with_retry_attempts(2);
|
||||||
|
let bytes = service
|
||||||
|
.fetch_bytes(
|
||||||
|
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(bytes).unwrap(),
|
||||||
|
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
|
||||||
|
"2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_non_official_urls() {
|
fn rejects_non_official_urls() {
|
||||||
let service = OfficialResourcePullService::new("/tmp/unused");
|
let service = OfficialResourcePullService::new("/tmp/unused");
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const DEFAULT_BOOTSTRAP_DOWNLOAD_RETRY_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
/// Combined official bootstrap data for the latest client package.
|
/// Combined official bootstrap data for the latest client package.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OfficialGameMainConfigBootstrap {
|
pub struct OfficialGameMainConfigBootstrap {
|
||||||
@@ -113,6 +115,21 @@ impl OfficialGameMainConfigBootstrapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> {
|
fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> {
|
||||||
|
let attempts = DEFAULT_BOOTSTRAP_DOWNLOAD_RETRY_ATTEMPTS;
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 1..=attempts {
|
||||||
|
match self.download_file_once(url, destination) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(format!("attempt {attempt}/{attempts}: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_file_once(&self, url: &str, destination: &Path) -> Result<(), String> {
|
||||||
let output = Command::new(&self.curl_command)
|
let output = Command::new(&self.curl_command)
|
||||||
.arg("--fail")
|
.arg("--fail")
|
||||||
.arg("--location")
|
.arg("--location")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
|
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::process::Command;
|
use std::process::{Command, Output};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Official JP launcher API root.
|
/// Official JP launcher API root.
|
||||||
@@ -23,6 +23,8 @@ pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-sta
|
|||||||
/// Official backup JP PC launcher package CDN host.
|
/// Official backup JP PC launcher package CDN host.
|
||||||
pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com";
|
pub const YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST: &str = "launcher-pkg-ba-jp-bk.yo-star.com";
|
||||||
|
|
||||||
|
const DEFAULT_LAUNCHER_RETRY_ATTEMPTS: usize = 3;
|
||||||
|
|
||||||
/// Latest official PC client metadata returned by `/api/launcher/game/config`.
|
/// Latest official PC client metadata returned by `/api/launcher/game/config`.
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
pub struct YostarJpLauncherGameConfig {
|
pub struct YostarJpLauncherGameConfig {
|
||||||
@@ -77,6 +79,7 @@ pub struct YostarJpLauncherRemoteManifest {
|
|||||||
pub struct OfficialLauncherBootstrapService {
|
pub struct OfficialLauncherBootstrapService {
|
||||||
curl_command: String,
|
curl_command: String,
|
||||||
launcher_version: String,
|
launcher_version: String,
|
||||||
|
retry_attempts: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OfficialLauncherBootstrapService {
|
impl OfficialLauncherBootstrapService {
|
||||||
@@ -93,9 +96,16 @@ impl OfficialLauncherBootstrapService {
|
|||||||
Self {
|
Self {
|
||||||
curl_command: curl_command.into(),
|
curl_command: curl_command.into(),
|
||||||
launcher_version: launcher_version.into(),
|
launcher_version: launcher_version.into(),
|
||||||
|
retry_attempts: DEFAULT_LAUNCHER_RETRY_ATTEMPTS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the number of attempts for each launcher `curl` transfer.
|
||||||
|
pub fn with_retry_attempts(mut self, retry_attempts: usize) -> Self {
|
||||||
|
self.retry_attempts = retry_attempts.max(1);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches latest official PC game config.
|
/// Fetches latest official PC game config.
|
||||||
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
|
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
|
||||||
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
|
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
|
||||||
@@ -161,28 +171,26 @@ impl OfficialLauncherBootstrapService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = Command::new(&self.curl_command)
|
let output = self.run_curl_with_retry(
|
||||||
|
|| {
|
||||||
|
let mut command = Command::new(&self.curl_command);
|
||||||
|
command
|
||||||
.arg("--fail")
|
.arg("--fail")
|
||||||
.arg("--location")
|
.arg("--location")
|
||||||
.arg("--silent")
|
.arg("--silent")
|
||||||
.arg("--show-error")
|
.arg("--show-error")
|
||||||
.arg("--url")
|
.arg("--url")
|
||||||
.arg(manifest_url)
|
.arg(manifest_url);
|
||||||
.output()
|
command
|
||||||
.map_err(|error| {
|
},
|
||||||
format!(
|
|output| {
|
||||||
"Failed to launch curl command {}: {error}",
|
|
||||||
self.curl_command
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(format!(
|
format!(
|
||||||
"curl failed for launcher manifest {manifest_url}: {}",
|
"curl failed for launcher manifest {manifest_url}: {}",
|
||||||
stderr.trim()
|
stderr.trim()
|
||||||
));
|
)
|
||||||
}
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
|
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
|
||||||
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?;
|
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?;
|
||||||
@@ -220,7 +228,10 @@ impl OfficialLauncherBootstrapService {
|
|||||||
let url = launcher_api_url(path)?;
|
let url = launcher_api_url(path)?;
|
||||||
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
|
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
|
||||||
|
|
||||||
let output = Command::new(&self.curl_command)
|
let output = self.run_curl_with_retry(
|
||||||
|
|| {
|
||||||
|
let mut command = Command::new(&self.curl_command);
|
||||||
|
command
|
||||||
.arg("--fail")
|
.arg("--fail")
|
||||||
.arg("--location")
|
.arg("--location")
|
||||||
.arg("--silent")
|
.arg("--silent")
|
||||||
@@ -230,19 +241,14 @@ impl OfficialLauncherBootstrapService {
|
|||||||
.arg("--header")
|
.arg("--header")
|
||||||
.arg(format!("Authorization: {authorization}"))
|
.arg(format!("Authorization: {authorization}"))
|
||||||
.arg("--url")
|
.arg("--url")
|
||||||
.arg(&url)
|
.arg(&url);
|
||||||
.output()
|
command
|
||||||
.map_err(|error| {
|
},
|
||||||
format!(
|
|output| {
|
||||||
"Failed to launch curl command {}: {error}",
|
|
||||||
self.curl_command
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(format!("curl failed for {url}: {}", stderr.trim()));
|
format!("curl failed for {url}: {}", stderr.trim())
|
||||||
}
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
|
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
|
||||||
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
|
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
|
||||||
@@ -261,6 +267,34 @@ impl OfficialLauncherBootstrapService {
|
|||||||
|
|
||||||
Ok(envelope)
|
Ok(envelope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_curl_with_retry(
|
||||||
|
&self,
|
||||||
|
mut build_command: impl FnMut() -> Command,
|
||||||
|
failure_message: impl Fn(&Output) -> String,
|
||||||
|
) -> Result<Output, String> {
|
||||||
|
let attempts = self.retry_attempts.max(1);
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 1..=attempts {
|
||||||
|
match build_command().output() {
|
||||||
|
Ok(output) if output.status.success() => return Ok(output),
|
||||||
|
Ok(output) => {
|
||||||
|
last_error = Some(format!(
|
||||||
|
"attempt {attempt}/{attempts}: {}",
|
||||||
|
failure_message(&output)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(format!(
|
||||||
|
"attempt {attempt}/{attempts}: Failed to launch curl command {}: {error}",
|
||||||
|
self.curl_command
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| format!("curl command {} did not run", self.curl_command)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic official launcher API response envelope.
|
/// Generic official launcher API response envelope.
|
||||||
@@ -394,6 +428,58 @@ pub fn launcher_authorization_header(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write_shell_script(path: &Path, script: &str) {
|
||||||
|
fs::write(path, script).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut permissions = fs::metadata(path).unwrap().permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
fs::set_permissions(path, permissions).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_flaky_launcher_curl_script() -> &'static str {
|
||||||
|
r#"#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
url=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--url)
|
||||||
|
url="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
state="$0.state"
|
||||||
|
count=0
|
||||||
|
if [ -f "$state" ]; then
|
||||||
|
count="$(cat "$state")"
|
||||||
|
fi
|
||||||
|
count=$((count + 1))
|
||||||
|
printf '%s' "$count" > "$state"
|
||||||
|
if [ "$count" -eq 1 ]; then
|
||||||
|
printf '%s\n' "temporary launcher failure" >&2
|
||||||
|
exit 35
|
||||||
|
fi
|
||||||
|
case "$url" in
|
||||||
|
*/api/launcher/game/config)
|
||||||
|
printf '%s' '{"code":200,"data":{"game_latest_version":"1.70.436321","game_latest_file_path":"prod/manifest/BlueArchive_JP_TEMP","game_lowest_version":"1.69.0","game_start_params":[]}}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '%s\n' "unexpected url: $url" >&2
|
||||||
|
exit 22
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_official_launcher_api_url() {
|
fn builds_official_launcher_api_url() {
|
||||||
@@ -510,4 +596,25 @@ mod tests {
|
|||||||
assert_eq!(manifest.files.len(), 1);
|
assert_eq!(manifest.files.len(), 1);
|
||||||
assert_eq!(manifest.files[0].path, "/BlueArchive.exe");
|
assert_eq!(manifest.files[0].path, "/BlueArchive.exe");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retries_transient_launcher_api_failures() {
|
||||||
|
let bin_dir = TempDir::new().unwrap();
|
||||||
|
let curl_path = bin_dir.path().join("curl");
|
||||||
|
write_shell_script(&curl_path, fake_flaky_launcher_curl_script());
|
||||||
|
|
||||||
|
let service = OfficialLauncherBootstrapService::with_curl_command(
|
||||||
|
"1.7.2",
|
||||||
|
curl_path.to_string_lossy().to_string(),
|
||||||
|
)
|
||||||
|
.with_retry_attempts(2);
|
||||||
|
|
||||||
|
let config = service.fetch_game_config().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.game_latest_version, "1.70.436321");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
|
||||||
|
"2"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,14 +185,14 @@ mod tests {
|
|||||||
fn inventory() -> YostarJpDownloadInventory {
|
fn inventory() -> YostarJpDownloadInventory {
|
||||||
YostarJpDownloadInventory::from_catalog_bytes(
|
YostarJpDownloadInventory::from_catalog_bytes(
|
||||||
b"FullPatch_000.zip",
|
b"FullPatch_000.zip",
|
||||||
b"ExcelDB.db",
|
b"ExcelDB.db ExcelDB.db",
|
||||||
b"JP_Airi.zip",
|
b"JP_Airi.zip",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
|
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
|
||||||
YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
||||||
b"ExcelDB.db",
|
b"ExcelDB.db ExcelDB.db",
|
||||||
vec![
|
vec![
|
||||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||||
PatchPlatform::Windows,
|
PatchPlatform::Windows,
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.zip" ]]
|
|||||||
elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]]; then
|
elif [[ "$url" == "{addressables_root}/Android_PatchPack/catalog_Android.hash" ]]; then
|
||||||
emit_text "catalog-android-hash"
|
emit_text "catalog-android-hash"
|
||||||
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.bytes" ]]; then
|
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.bytes" ]]; then
|
||||||
emit_text "ExcelDB.db"
|
emit_text "ExcelDB.db ExcelDB.db"
|
||||||
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.hash" ]]; then
|
elif [[ "$url" == "{addressables_root}/TableBundles/TableCatalog.hash" ]]; then
|
||||||
emit_text "{table_catalog_hash}"
|
emit_text "{table_catalog_hash}"
|
||||||
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes" ]]; then
|
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes" ]]; then
|
||||||
@@ -361,7 +361,7 @@ fi
|
|||||||
server_info_url = TEST_SERVER_INFO_URL,
|
server_info_url = TEST_SERVER_INFO_URL,
|
||||||
connection_group = TEST_CONNECTION_GROUP,
|
connection_group = TEST_CONNECTION_GROUP,
|
||||||
addressables_root = TEST_ADDRESSABLES_ROOT,
|
addressables_root = TEST_ADDRESSABLES_ROOT,
|
||||||
table_catalog_hash = xxhash32(b"ExcelDB.db"),
|
table_catalog_hash = xxhash32(b"ExcelDB.db ExcelDB.db"),
|
||||||
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"),
|
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"),
|
||||||
windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"),
|
windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"),
|
||||||
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
|
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
|
||||||
|
|||||||
Reference in New Issue
Block a user