mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
Distinguish terminal HTTP errors from retryable CDN/network failures, record quarantined resources, and report failed download progress. Fixes #7
593 lines
20 KiB
Rust
593 lines
20 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|