//! 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_with_proxy as run_curl_command_with_retry, CurlProxyConfig, }; use crate::official_download::DownloadError; use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG}; use bat_core::ErrorCode; 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, /// Executable name without `.exe`. #[serde(default)] pub game_start_exe_name: Option, /// Launcher arguments. #[serde(default)] pub game_start_params: Vec, /// Displayed decompressed size. #[serde(default)] pub decompression_size: Option, } /// 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, /// Remote package files. #[serde(default, rename = "file")] pub files: Vec, } /// Signed official launcher API fetcher using `curl`. #[derive(Debug, Clone)] pub struct OfficialLauncherBootstrapService { curl_command: String, curl_proxy: CurlProxyConfig, launcher_version: String, retry_attempts: usize, } impl OfficialLauncherBootstrapService { /// Creates a fetcher using `curl` from `PATH`. pub fn new(launcher_version: impl Into) -> 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, curl_command: impl Into, ) -> Self { Self { curl_command: curl_command.into(), curl_proxy: CurlProxyConfig::default(), launcher_version: launcher_version.into(), retry_attempts: DEFAULT_LAUNCHER_RETRY_ATTEMPTS, } } /// Sets the proxy configuration for launcher `curl` transfers. pub fn with_proxy_config(mut self, curl_proxy: CurlProxyConfig) -> Self { self.curl_proxy = curl_proxy; self } /// 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 { let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?; let config: YostarJpLauncherGameConfig = serde_json::from_value(envelope.data).map_err(|error| { DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, format!("解析官方启动器 game config 数据失败:{error}"), ) })?; if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() { return Err(DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, "官方启动器 game config 缺少最新版本或基准路径", )); } Ok(config) } /// Fetches official CDN config for PC client package downloads. pub fn fetch_cdn_config(&self) -> Result { let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?; serde_json::from_value(envelope.data).map_err(|error| { DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, 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 { if version.is_empty() || file_path.is_empty() { return Err(DownloadError::new( ErrorCode::INVALID_ARGUMENT, "启动器 manifest 版本和文件路径不能为空", )); } 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| { DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, format!("解析官方启动器 manifest URL 数据失败:{error}"), ) })?; if !is_official_launcher_package_url(&manifest_url.url) { return Err(DownloadError::new( ErrorCode::NON_OFFICIAL_URL, 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 { if !is_official_launcher_package_url(manifest_url) { return Err(DownloadError::new( ErrorCode::NON_OFFICIAL_URL, 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 })?; // launcher 链内容问题(API 响应、远端 manifest、包内容)统一归 // LAUNCHER_RESPONSE_INVALID;MANIFEST_PARSE_FAILED 保留给资源侧 // manifest / Addressables catalog。 let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout) .map_err(|error| { DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, format!("解析官方启动器 manifest 失败:{error}"), ) })?; if manifest.files.is_empty() { return Err(DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, "官方启动器远端 manifest 没有文件条目", )); } Ok(manifest) } /// Fetches latest PC game config, then follows the official manifest URL. pub fn fetch_latest_remote_manifest( &self, ) -> Result< ( YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, ), DownloadError, > { 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 { // launcher_version 是用户可控的 CLI 输入(--launcher-version),空值是 // 参数错误而非内部错误,在进入签名流程前显式归 INVALID_ARGUMENT。 if self.launcher_version.is_empty() { return Err(DownloadError::new( ErrorCode::INVALID_ARGUMENT, "启动器版本不能为空", )); } 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 })?; let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout).map_err(|error| { DownloadError::new( ErrorCode::LAUNCHER_RESPONSE_INVALID, format!("解析官方启动器 API 响应失败:{error}"), ) })?; if envelope.code != 200 { return Err(DownloadError::new( ErrorCode::LAUNCHER_API_REJECTED, 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 { run_curl_command_with_retry( Path::new(&self.curl_command), url, None, self.retry_attempts, &self.curl_proxy, &mut build_command, ) .map_err(|error| { // 保留 curl 失败的准确网络域码(403/404/DNS/连接/超时/TLS/代理等), // 进程类失败归 INTERNAL。 let code = error.final_error_code().unwrap_or(ErrorCode::INTERNAL); DownloadError::new(code, format!("curl 拉取失败 {url}:{error}")) }) } } /// 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, /// Optional alternate message field. #[serde(default)] pub msg: Option, /// Response payload. pub data: serde_json::Value, } /// Builds an official launcher API URL from a path. pub fn launcher_api_url(path: &str) -> Result { 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. /// /// 生产调用方传入的 `cdn_root` 来自官方 API 的远端响应(`fetch_cdn_config`), /// `file_path` 来自远端 manifest 内容——两者都是运行时外部输入而非硬编码常量, /// 因此这里的拒绝必须携带准确错误码:非官方根地址是安全边界拒绝 /// (NON_OFFICIAL_URL),非法包路径是远端内容缺陷(LAUNCHER_RESPONSE_INVALID)。 pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result { if !is_official_launcher_package_url(cdn_root) { return Err(DownloadError::new( ErrorCode::NON_OFFICIAL_URL, format!("启动器 CDN 根地址不是官方地址:{cdn_root}"), )); } validate_launcher_package_path(file_path) .map_err(|error| DownloadError::new(ErrorCode::LAUNCHER_RESPONSE_INVALID, error))?; 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, ) -> Result { 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"); } fn fake_launcher_config_response_script(payload: &str) -> String { format!( r#"#!/bin/sh set -eu url="" while [ "$#" -gt 0 ]; do case "$1" in --url) url="$2"; shift 2 ;; *) shift ;; esac done case "$url" in */api/launcher/game/config) printf '%s' '{payload}' ;; *) printf '%s\n' "unexpected url: $url" >&2 exit 22 ;; esac "#, payload = payload ) } fn fake_launcher_http_404_script() -> &'static str { r#"#!/bin/sh set -eu printf 'curl: (22) The requested URL returned error: 404\n' >&2 exit 22 "# } fn launcher_service_with_script(script: &str) -> (TempDir, OfficialLauncherBootstrapService) { let bin_dir = TempDir::new().unwrap(); let curl_path = bin_dir.path().join("curl"); write_shell_script(&curl_path, script); let service = OfficialLauncherBootstrapService::with_curl_command( "1.7.2", curl_path.to_string_lossy().to_string(), ) .with_retry_attempts(1); (bin_dir, service) } #[test] fn launcher_api_non_200_maps_to_launcher_api_rejected() { let (_bin, service) = launcher_service_with_script(&fake_launcher_config_response_script( r#"{"code":403,"message":"version rejected","data":{}}"#, )); let error = service.fetch_game_config().unwrap_err(); assert_eq!(error.code().id(), ErrorCode::LAUNCHER_API_REJECTED.id()); } #[test] fn launcher_api_bad_json_maps_to_launcher_response_invalid() { let (_bin, service) = launcher_service_with_script(&fake_launcher_config_response_script("not-json")); let error = service.fetch_game_config().unwrap_err(); assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id()); } #[test] fn launcher_http_404_preserves_network_error_code() { let (_bin, service) = launcher_service_with_script(fake_launcher_http_404_script()); let error = service.fetch_game_config().unwrap_err(); assert_eq!(error.code().id(), ErrorCode::HTTP_NOT_FOUND.id()); } #[test] fn non_official_manifest_url_maps_to_non_official_url() { let (_bin, service) = launcher_service_with_script(fake_launcher_http_404_script()); let error = service .fetch_remote_manifest("https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json") .unwrap_err(); assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id()); } #[test] fn launcher_package_url_maps_rejections_to_error_codes() { // cdn_root 来自远端 API 响应:非官方根是安全边界拒绝。 let error = launcher_package_url( "https://launcher-pkg-ba-jp.bluearchive.cafe", "prod/game.zip", ) .unwrap_err(); assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id()); // file_path 来自远端 manifest 内容:非法路径是远端内容缺陷。 let error = launcher_package_url( "https://launcher-pkg-ba-jp.yo-star.com", "prod/../escape.zip", ) .unwrap_err(); assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id()); } #[test] fn empty_launcher_version_maps_to_invalid_argument() { // 空 --launcher-version 是用户参数错误,应在发起任何请求前归 // INVALID_ARGUMENT(curl 指向不存在的路径以证明未发起请求)。 let service = OfficialLauncherBootstrapService::with_curl_command( "", "/nonexistent/curl-must-not-run", ); let error = service.fetch_game_config().unwrap_err(); assert_eq!(error.code().id(), ErrorCode::INVALID_ARGUMENT.id()); } #[test] fn launcher_manifest_without_files_maps_to_launcher_response_invalid() { let bin_dir = TempDir::new().unwrap(); let curl_path = bin_dir.path().join("curl"); write_shell_script( &curl_path, r#"#!/bin/sh printf '%s' '{"source":"prod/game.zip","file":[]}' "#, ); let service = OfficialLauncherBootstrapService::with_curl_command( "1.7.2", curl_path.to_string_lossy().to_string(), ); let error = service .fetch_remote_manifest("https://launcher-pkg-ba-jp.yo-star.com/prod/manifest.json") .unwrap_err(); assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id()); } #[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" ); } }