Files
BlueArchiveToolkit/infrastructure/src/official_launcher.rs
T
nyaKazuha 4192d7ee4c fix: classify official download failures
Distinguish terminal HTTP errors from retryable CDN/network failures, record quarantined resources, and report failed download progress.

Fixes #7
2026-07-14 01:30:12 +08:00

593 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Official Yostar JP launcher API bootstrap.
//!
//! This models the PC launcher update API used by the official JP launcher.
//! It is separate from Unity resource `server-info`: the launcher API can
//! discover the latest Windows client package and manifest, while resource
//! updates still come from the game client's server-info flow.
use crate::curl_transfer::run_curl_with_retry as run_curl_command_with_retry;
use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
use md5::{Digest, Md5};
use serde::Deserialize;
use std::path::Path;
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
/// Official JP launcher API root.
pub const YOSTAR_JP_LAUNCHER_API_ROOT: &str = "https://api-launcher-jp.yo-star.com";
/// Salt embedded in the official JP launcher for request signing.
pub const YOSTAR_JP_LAUNCHER_AUTH_SALT: &str = "DE7108E9B2842FD460F4777702727869";
/// Official primary JP PC launcher package CDN host.
pub const YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST: &str = "launcher-pkg-ba-jp.yo-star.com";
/// 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";
const DEFAULT_LAUNCHER_RETRY_ATTEMPTS: usize = 3;
/// Latest official PC client metadata returned by `/api/launcher/game/config`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherGameConfig {
/// Latest Windows client version.
pub game_latest_version: String,
/// Latest manifest basis path.
pub game_latest_file_path: String,
/// Lowest allowed installed client version.
#[serde(default)]
pub game_lowest_version: Option<String>,
/// Executable name without `.exe`.
#[serde(default)]
pub game_start_exe_name: Option<String>,
/// Launcher arguments.
#[serde(default)]
pub game_start_params: Vec<String>,
/// Displayed decompressed size.
#[serde(default)]
pub decompression_size: Option<String>,
}
/// Official launcher CDN roots used for Windows client package files.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherCdnConfig {
/// Primary CDN root.
pub primary_cdn: String,
/// Backup CDN root.
pub back_up_cdn: String,
}
/// Official launcher manifest URL response returned by
/// `/api/launcher/game/config/json`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherManifestUrl {
/// Remote manifest URL on the official launcher package CDN.
pub url: String,
}
/// Remote PC launcher manifest used to compute install/update diffs.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct YostarJpLauncherRemoteManifest {
/// Optional manifest source field.
#[serde(default)]
pub source: Option<String>,
/// Remote package files.
#[serde(default, rename = "file")]
pub files: Vec<YostarJpLauncherManifestFile>,
}
/// Signed official launcher API fetcher using `curl`.
#[derive(Debug, Clone)]
pub struct OfficialLauncherBootstrapService {
curl_command: String,
launcher_version: String,
retry_attempts: usize,
}
impl OfficialLauncherBootstrapService {
/// Creates a fetcher using `curl` from `PATH`.
pub fn new(launcher_version: impl Into<String>) -> Self {
Self::with_curl_command(launcher_version, "curl")
}
/// Creates a fetcher with an explicit `curl` command.
pub fn with_curl_command(
launcher_version: impl Into<String>,
curl_command: impl Into<String>,
) -> Self {
Self {
curl_command: curl_command.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.
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?;
let config: YostarJpLauncherGameConfig = serde_json::from_value(envelope.data)
.map_err(|error| format!("解析官方启动器 game config 数据失败:{error}"))?;
if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() {
return Err("官方启动器 game config 缺少最新版本或基准路径".into());
}
Ok(config)
}
/// Fetches official CDN config for PC client package downloads.
pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, String> {
let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?;
serde_json::from_value(envelope.data)
.map_err(|error| format!("解析官方启动器 CDN config 数据失败:{error}"))
}
/// Fetches the official remote manifest URL for a PC client version and
/// basis path.
pub fn fetch_manifest_url(
&self,
version: &str,
file_path: &str,
) -> Result<YostarJpLauncherManifestUrl, String> {
if version.is_empty() || file_path.is_empty() {
return Err("启动器 manifest 版本和文件路径不能为空".into());
}
let path = format!(
"/api/launcher/game/config/json?version={}&file_path={}",
percent_encode_query_value(version),
percent_encode_query_value(file_path)
);
let envelope = self.fetch_launcher_envelope(&path)?;
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data)
.map_err(|error| format!("解析官方启动器 manifest URL 数据失败:{error}"))?;
if !is_official_launcher_package_url(&manifest_url.url) {
return Err(format!(
"官方启动器 API 返回了非官方 manifest URL{}",
manifest_url.url
));
}
Ok(manifest_url)
}
/// Fetches and parses an official remote PC launcher manifest.
pub fn fetch_remote_manifest(
&self,
manifest_url: &str,
) -> Result<YostarJpLauncherRemoteManifest, String> {
if !is_official_launcher_package_url(manifest_url) {
return Err(format!("拒绝拉取非官方启动器 manifest URL{manifest_url}"));
}
let output = self
.run_curl_with_retry(manifest_url, || {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--url")
.arg(manifest_url);
command
})
.map_err(|error| format!("curl 拉取启动器 manifest 失败 {manifest_url}{error}"))?;
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("解析官方启动器 manifest 失败:{error}"))?;
if manifest.files.is_empty() {
return Err("官方启动器远端 manifest 没有文件条目".into());
}
Ok(manifest)
}
/// Fetches latest PC game config, then follows the official manifest URL.
pub fn fetch_latest_remote_manifest(
&self,
) -> Result<
(
YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl,
YostarJpLauncherRemoteManifest,
),
String,
> {
let game_config = self.fetch_game_config()?;
let manifest_url = self.fetch_manifest_url(
&game_config.game_latest_version,
&game_config.game_latest_file_path,
)?;
let manifest = self.fetch_remote_manifest(&manifest_url.url)?;
Ok((game_config, manifest_url, manifest))
}
/// Fetches a signed official launcher API endpoint and parses the generic
/// response envelope.
pub fn fetch_launcher_envelope(&self, path: &str) -> Result<LauncherEnvelope, String> {
let url = launcher_api_url(path)?;
let authorization = launcher_authorization_header(&self.launcher_version, "", None)?;
let output = self
.run_curl_with_retry(&url, || {
let mut command = Command::new(&self.curl_command);
command
.arg("--fail")
.arg("--location")
.arg("--silent")
.arg("--show-error")
.arg("--header")
.arg("Content-Type: application/json;charset=UTF-8")
.arg("--header")
.arg(format!("Authorization: {authorization}"))
.arg("--url")
.arg(&url);
command
})
.map_err(|error| format!("curl 拉取失败 {url}{error}"))?;
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("解析官方启动器 API 响应失败:{error}"))?;
if envelope.code != 200 {
return Err(format!(
"官方启动器 API 返回错误码 {}{}",
envelope.code,
envelope
.message
.as_deref()
.or(envelope.msg.as_deref())
.unwrap_or("<无消息>")
));
}
Ok(envelope)
}
fn run_curl_with_retry(
&self,
url: &str,
mut build_command: impl FnMut() -> Command,
) -> Result<Output, String> {
run_curl_command_with_retry(
Path::new(&self.curl_command),
url,
None,
self.retry_attempts,
&mut build_command,
)
.map_err(|error| error.to_string())
}
}
/// Generic official launcher API response envelope.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct LauncherEnvelope {
/// Numeric response code. Official launcher treats `200` as success.
pub code: i64,
/// Optional message field.
#[serde(default)]
pub message: Option<String>,
/// Optional alternate message field.
#[serde(default)]
pub msg: Option<String>,
/// Response payload.
pub data: serde_json::Value,
}
/// Builds an official launcher API URL from a path.
pub fn launcher_api_url(path: &str) -> Result<String, String> {
if !path.starts_with('/') || path.contains("..") || path.contains('\\') {
return Err(format!("官方启动器 API 路径无效:{path}"));
}
Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}"))
}
/// Returns true when a URL points to an official JP PC launcher package host.
pub fn is_official_launcher_package_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let host = rest.split('/').next().unwrap_or_default();
matches!(
host,
YOSTAR_JP_LAUNCHER_PRIMARY_CDN_HOST | YOSTAR_JP_LAUNCHER_BACKUP_CDN_HOST
)
}
/// Builds an official launcher package URL from an official CDN root and a
/// relative package path.
pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result<String, String> {
if !is_official_launcher_package_url(cdn_root) {
return Err(format!("启动器 CDN 根地址不是官方地址:{cdn_root}"));
}
validate_launcher_package_path(file_path)?;
Ok(format!(
"{}/{}",
cdn_root.trim_end_matches('/'),
normalize_relative_path(file_path)
))
}
fn percent_encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
}
fn validate_launcher_package_path(path: &str) -> Result<(), String> {
if path.is_empty() || path.starts_with('/') || path.starts_with('\\') {
return Err(format!("启动器包路径无效:{path}"));
}
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(format!("启动器包路径无效:{path}"));
}
if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
return Err(format!("启动器包路径无效:{path}"));
}
}
Ok(())
}
/// Builds the official launcher `Authorization` header.
///
/// This matches the official launcher logic:
/// `md5(JSON(head) + body + salt)`, where `head` contains `game_tag`,
/// Unix timestamp, and launcher version.
pub fn launcher_authorization_header(
launcher_version: &str,
body: &str,
timestamp: Option<u64>,
) -> Result<String, String> {
if launcher_version.is_empty() {
return Err("启动器版本不能为空".into());
}
let timestamp = match timestamp {
Some(timestamp) => timestamp,
None => SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("系统时间早于 Unix epoch{error}"))?
.as_secs(),
};
let head = serde_json::json!({
"game_tag": YOSTAR_JP_GAME_TAG,
"time": timestamp,
"version": launcher_version,
});
let head_json = serde_json::to_string(&head)
.map_err(|error| format!("序列化启动器认证 head 失败:{error}"))?;
let mut hasher = Md5::new();
hasher.update(head_json.as_bytes());
hasher.update(body.as_bytes());
hasher.update(YOSTAR_JP_LAUNCHER_AUTH_SALT.as_bytes());
let sign = hex::encode(hasher.finalize());
serde_json::to_string(&serde_json::json!({
"head": head,
"sign": sign,
}))
.map_err(|error| format!("序列化启动器认证 header 失败:{error}"))
}
#[cfg(test)]
mod tests {
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]
fn builds_official_launcher_api_url() {
assert_eq!(
launcher_api_url("/api/launcher/game/config").unwrap(),
"https://api-launcher-jp.yo-star.com/api/launcher/game/config"
);
assert!(launcher_api_url("api/launcher/game/config").is_err());
assert!(launcher_api_url("/api/../secret").is_err());
}
#[test]
fn identifies_official_launcher_package_urls_only() {
assert!(is_official_launcher_package_url(
"https://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json"
));
assert!(is_official_launcher_package_url(
"https://launcher-pkg-ba-jp-bk.yo-star.com/prod/manifest.json"
));
assert!(!is_official_launcher_package_url(
"https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json"
));
assert!(!is_official_launcher_package_url(
"http://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json"
));
}
#[test]
fn builds_official_launcher_package_url() {
assert_eq!(
launcher_package_url(
"https://launcher-pkg-ba-jp.yo-star.com",
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
)
.unwrap(),
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
);
assert!(launcher_package_url(
"https://launcher-pkg-ba-jp.bluearchive.cafe",
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip"
)
.is_err());
}
#[test]
fn encodes_launcher_manifest_query_values() {
assert_eq!(
percent_encode_query_value("prod/ZIP_TEMP/BlueArchive_JP_TEMP/game.zip"),
"prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2Fgame.zip"
);
}
#[test]
fn builds_stable_launcher_authorization_header() {
let header = launcher_authorization_header("1.7.2", "", Some(1_700_000_000)).unwrap();
let value: serde_json::Value = serde_json::from_str(&header).unwrap();
assert_eq!(value["head"]["game_tag"], "BlueArchive_JP");
assert_eq!(value["head"]["time"], 1_700_000_000);
assert_eq!(value["head"]["version"], "1.7.2");
assert_eq!(value["sign"], "b457b5bc3d7796d7a37040d4b7990cb9");
}
#[test]
fn parses_launcher_game_config_payload() {
let config: YostarJpLauncherGameConfig = serde_json::from_str(
r#"{
"game_latest_version": "1.70.0",
"game_latest_file_path": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"game_lowest_version": "1.69.0",
"game_start_exe_name": "xldr_BlueArchiveOnline_JP_loader_x64",
"game_start_params": ["BlueArchive.exe"]
}"#,
)
.unwrap();
assert_eq!(config.game_latest_version, "1.70.0");
assert_eq!(
config.game_start_exe_name.as_deref(),
Some("xldr_BlueArchiveOnline_JP_loader_x64")
);
assert_eq!(config.game_start_params, vec!["BlueArchive.exe"]);
}
#[test]
fn parses_launcher_manifest_url_payload() {
let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_str(
r#"{
"url": "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json"
}"#,
)
.unwrap();
assert!(is_official_launcher_package_url(&manifest_url.url));
}
#[test]
fn parses_remote_manifest_payload() {
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_str(
r#"{
"source": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"file": [
{
"path": "/BlueArchive.exe",
"size": "100",
"hash": "123",
"vc": "abc"
}
]
}"#,
)
.unwrap();
assert_eq!(manifest.files.len(), 1);
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"
);
}
}