mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 08:26:44 +08:00
feat: prepare experiment push package
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
//! 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 bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG};
|
||||
use md5::{Digest, Md5};
|
||||
use serde::Deserialize;
|
||||
use std::process::Command;
|
||||
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";
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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!("Failed to parse official launcher game config data: {error}")
|
||||
})?;
|
||||
|
||||
if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() {
|
||||
return Err("Official launcher game config is missing latest version or basis".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!("Failed to parse official launcher CDN config data: {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("Launcher manifest version and file path must not be empty".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!("Failed to parse official launcher manifest URL data: {error}")
|
||||
})?;
|
||||
|
||||
if !is_official_launcher_package_url(&manifest_url.url) {
|
||||
return Err(format!(
|
||||
"Official launcher API returned non-official 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!(
|
||||
"Refusing to fetch non-official launcher manifest URL: {manifest_url}"
|
||||
));
|
||||
}
|
||||
|
||||
let output = Command::new(&self.curl_command)
|
||||
.arg("--fail")
|
||||
.arg("--location")
|
||||
.arg("--silent")
|
||||
.arg("--show-error")
|
||||
.arg("--url")
|
||||
.arg(manifest_url)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Failed to launch curl command {}: {error}",
|
||||
self.curl_command
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"curl failed for launcher manifest {manifest_url}: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?;
|
||||
if manifest.files.is_empty() {
|
||||
return Err("Official launcher remote manifest has no files".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 = Command::new(&self.curl_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)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Failed to launch curl command {}: {error}",
|
||||
self.curl_command
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("curl failed for {url}: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
|
||||
|
||||
if envelope.code != 200 {
|
||||
return Err(format!(
|
||||
"Official launcher API returned code {}: {}",
|
||||
envelope.code,
|
||||
envelope
|
||||
.message
|
||||
.as_deref()
|
||||
.or(envelope.msg.as_deref())
|
||||
.unwrap_or("<no message>")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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!("Invalid official launcher API path: {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!("Launcher CDN root is not official: {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!("Invalid launcher package path: {path}"));
|
||||
}
|
||||
|
||||
for segment in path.split('/') {
|
||||
if segment.is_empty() || segment == "." || segment == ".." {
|
||||
return Err(format!("Invalid launcher package path: {path}"));
|
||||
}
|
||||
if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
|
||||
return Err(format!("Invalid launcher package path: {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("Launcher version must not be empty".into());
|
||||
}
|
||||
|
||||
let timestamp = match timestamp {
|
||||
Some(timestamp) => timestamp,
|
||||
None => SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("System time is before 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!("Failed to serialize launcher auth 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!("Failed to serialize launcher auth header: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user