feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+513
View File
@@ -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");
}
}