fix official auto discovery for current launcher manifest

This commit is contained in:
2026-07-06 23:02:52 +08:00
parent 5e76ea4ae3
commit e0d43fb994
7 changed files with 483 additions and 54 deletions
+163 -20
View File
@@ -6,8 +6,11 @@
use crate::official_launcher::launcher_package_url;
use crate::official_launcher::OfficialLauncherBootstrapService;
use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig};
use crate::official_launcher::{
YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherRemoteManifest,
};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::launcher::YostarJpLauncherManifestFile;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -24,7 +27,11 @@ pub struct OfficialGameMainConfigBootstrap {
pub manifest_url: String,
/// Remote manifest `source` path, if present.
pub manifest_source: Option<String>,
/// Official game ZIP URL built from the launcher CDN root.
/// Official URL used to obtain `resources.assets`.
///
/// Older launcher manifests point to a single game ZIP. Current manifests
/// may instead point to a directory source plus per-file entries; in that
/// case this is the direct `resources.assets` URL.
pub game_zip_url: String,
/// Number of files declared by the remote manifest.
pub manifest_file_count: usize,
@@ -76,23 +83,15 @@ impl OfficialGameMainConfigBootstrapService {
.filter(|value| !value.is_empty())
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
let cdn_config = self.launcher.fetch_cdn_config()?;
let package_path = select_game_package_path(&game_config, &manifest_source)?;
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?;
let temp_dir = TempDir::new()
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
let archive_path = temp_dir.path().join("official-game.zip");
self.download_file_with_fallback(
&game_zip_url,
&cdn_config.back_up_cdn,
package_path,
&archive_path,
let (game_zip_url, resources_assets) = self.fetch_resources_assets(
&game_config,
&manifest,
&manifest_source,
&cdn_config,
temp_dir.path(),
)?;
let extract_root = temp_dir.path().join("extract");
fs::create_dir_all(&extract_root)
.map_err(|error| format!("Failed to create extract root: {error}"))?;
self.extract_archive(&archive_path, &extract_root)?;
let resources_assets = find_resources_assets(&extract_root)
.ok_or_else(|| "resources.assets not found in official launcher package".to_string())?;
let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?;
Ok(OfficialGameMainConfigBootstrap {
@@ -159,6 +158,50 @@ impl OfficialGameMainConfigBootstrapService {
}
}
fn fetch_resources_assets(
&self,
game_config: &YostarJpLauncherGameConfig,
manifest: &YostarJpLauncherRemoteManifest,
manifest_source: &str,
cdn_config: &YostarJpLauncherCdnConfig,
temp_root: &Path,
) -> Result<(String, PathBuf), String> {
match select_game_main_config_source(game_config, manifest_source, manifest)? {
GameMainConfigSource::Archive(package_path) => {
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?;
let archive_path = temp_root.join("official-game.zip");
self.download_file_with_fallback(
&game_zip_url,
&cdn_config.back_up_cdn,
package_path,
&archive_path,
)?;
let extract_root = temp_root.join("extract");
fs::create_dir_all(&extract_root)
.map_err(|error| format!("Failed to create extract root: {error}"))?;
self.extract_archive(&archive_path, &extract_root)?;
let resources_assets = find_resources_assets(&extract_root).ok_or_else(|| {
"resources.assets not found in official launcher package".to_string()
})?;
Ok((game_zip_url, resources_assets))
}
GameMainConfigSource::ManifestFile { source_dir, file } => {
let relative_path = launcher_manifest_file_relative_path(source_dir, &file.path)?;
let resources_assets_url =
launcher_package_url(&cdn_config.primary_cdn, &relative_path)?;
let resources_assets = temp_root.join("resources.assets");
self.download_file_with_fallback(
&resources_assets_url,
&cdn_config.back_up_cdn,
&relative_path,
&resources_assets,
)?;
verify_manifest_file_size(&resources_assets, file)?;
Ok((resources_assets_url, resources_assets))
}
}
}
fn extract_archive(&self, archive_path: &Path, destination: &Path) -> Result<(), String> {
let output = Command::new(&self.unzip_command)
.arg("-q")
@@ -186,16 +229,39 @@ impl OfficialGameMainConfigBootstrapService {
}
}
fn select_game_package_path<'a>(
enum GameMainConfigSource<'a> {
Archive(&'a str),
ManifestFile {
source_dir: &'a str,
file: &'a YostarJpLauncherManifestFile,
},
}
fn select_game_main_config_source<'a>(
game_config: &'a YostarJpLauncherGameConfig,
manifest_source: &'a str,
) -> Result<&'a str, String> {
manifest: &'a YostarJpLauncherRemoteManifest,
) -> Result<GameMainConfigSource<'a>, String> {
if is_launcher_archive_path(manifest_source) {
return Ok(manifest_source);
return Ok(GameMainConfigSource::Archive(manifest_source));
}
if is_launcher_manifest_directory(manifest_source) {
if let Some(file) = manifest_resource_assets_file(manifest) {
return Ok(GameMainConfigSource::ManifestFile {
source_dir: manifest_source,
file,
});
}
return Err(format!(
"Official launcher manifest directory source has no BlueArchive_Data/resources.assets entry: {manifest_source}"
));
}
if is_launcher_archive_path(&game_config.game_latest_file_path) {
return Ok(&game_config.game_latest_file_path);
return Ok(GameMainConfigSource::Archive(
&game_config.game_latest_file_path,
));
}
Err(format!(
@@ -204,6 +270,16 @@ fn select_game_package_path<'a>(
))
}
fn manifest_resource_assets_file(
manifest: &YostarJpLauncherRemoteManifest,
) -> Option<&YostarJpLauncherManifestFile> {
manifest.files.iter().find(|file| {
normalize_manifest_file_path(&file.path)
.as_deref()
.is_some_and(|path| path.eq_ignore_ascii_case("BlueArchive_Data/resources.assets"))
})
}
fn is_launcher_archive_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
@@ -216,6 +292,73 @@ fn is_launcher_archive_path(path: &str) -> bool {
})
}
fn is_launcher_manifest_directory(path: &str) -> bool {
normalize_manifest_source_dir(path).is_some()
}
fn launcher_manifest_file_relative_path(
source_dir: &str,
file_path: &str,
) -> Result<String, String> {
let source = normalize_manifest_source_dir(source_dir)
.ok_or_else(|| format!("Invalid official launcher manifest source: {source_dir}"))?;
let file = normalize_manifest_file_path(file_path)
.ok_or_else(|| format!("Invalid official launcher manifest file path: {file_path}"))?;
Ok(format!("{source}/{file}"))
}
fn normalize_manifest_source_dir(path: &str) -> Option<String> {
let path = path.trim().trim_start_matches('/');
if path.is_empty() || path.ends_with(".zip") {
return None;
}
normalize_safe_path(path)
}
fn normalize_manifest_file_path(path: &str) -> Option<String> {
let path = path.trim().trim_start_matches('/');
if path.is_empty() {
return None;
}
normalize_safe_path(path)
}
fn normalize_safe_path(path: &str) -> Option<String> {
if path.contains('\\') || path.contains('?') || path.contains('#') {
return None;
}
let mut segments = Vec::new();
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return None;
}
segments.push(segment);
}
Some(segments.join("/"))
}
fn verify_manifest_file_size(
path: &Path,
file: &YostarJpLauncherManifestFile,
) -> Result<(), String> {
let expected_size = file.size.parse::<u64>().map_err(|error| {
format!(
"Official launcher manifest has invalid size for {}: {error}",
file.path
)
})?;
let actual_size = fs::metadata(path)
.map_err(|error| format!("Failed to stat downloaded {}: {error}", path.display()))?
.len();
if actual_size != expected_size {
return Err(format!(
"Downloaded official launcher file size mismatch for {}: expected {}, got {}",
file.path, expected_size, actual_size
));
}
Ok(())
}
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
@@ -29,6 +29,9 @@ const TEST_LAUNCHER_MANIFEST_SOURCE: &str =
"prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
const TEST_LAUNCHER_GAME_ZIP_URL: &str =
"https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-manifest-fixture.zip";
const TEST_LAUNCHER_DIRECTORY_SOURCE: &str = "/BlueArchive_JP-1.70.436321-game";
const TEST_LAUNCHER_RESOURCES_ASSETS_URL: &str =
"https://launcher-pkg-ba-jp.yo-star.com/BlueArchive_JP-1.70.436321-game/BlueArchive_Data/resources.assets";
const TEST_SERVER_INFO_URL: &str =
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json";
const TEST_CONNECTION_GROUP: &str = "Fixture-Audit";
@@ -54,7 +57,7 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
Some(TEST_LAUNCHER_MANIFEST_SOURCE)
);
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_GAME_ZIP_URL);
assert_eq!(bootstrap.manifest_file_count, 1);
assert_eq!(bootstrap.manifest_file_count, 2);
assert_eq!(
bootstrap.game_main_config.server_info_data_url.as_deref(),
Some(TEST_SERVER_INFO_URL)
@@ -76,25 +79,21 @@ fn fetch_bootstrap_uses_official_manifest_source_for_latest_zip() {
}
#[test]
fn fetch_bootstrap_falls_back_to_latest_file_path_when_manifest_source_is_not_zip() {
let harness = TestHarness::new_with_manifest_source("/BlueArchive_JP-1.70.436321-game");
fn fetch_bootstrap_uses_directory_manifest_resources_assets_when_source_is_not_zip() {
let harness = TestHarness::new_with_manifest_source(TEST_LAUNCHER_DIRECTORY_SOURCE);
let bootstrap = harness.fetch_bootstrap();
assert_eq!(
bootstrap.manifest_source.as_deref(),
Some("/BlueArchive_JP-1.70.436321-game")
);
assert_eq!(
bootstrap.game_zip_url,
format!(
"https://launcher-pkg-ba-jp.yo-star.com/{}",
TEST_LAUNCHER_LATEST_FILE_PATH
)
Some(TEST_LAUNCHER_DIRECTORY_SOURCE)
);
assert_eq!(bootstrap.game_zip_url, TEST_LAUNCHER_RESOURCES_ASSETS_URL);
let curl_log = fs::read_to_string(&harness.curl_log).unwrap();
assert!(curl_log.contains(TEST_LAUNCHER_LATEST_FILE_PATH));
assert!(!curl_log.contains(" /BlueArchive_JP-1.70.436321-game"));
assert!(curl_log.contains(TEST_LAUNCHER_RESOURCES_ASSETS_URL));
assert!(!curl_log.contains(&format!(
"https://launcher-pkg-ba-jp.yo-star.com/{TEST_LAUNCHER_LATEST_FILE_PATH}"
)));
}
#[test]
@@ -178,7 +177,13 @@ impl TestHarness {
let curl_script = temp.path().join("fake-curl");
write_executable(
&curl_script,
&official_curl_script(&curl_log, &zip_fixture, manifest_source),
&official_curl_script(
&curl_log,
&zip_fixture,
&resources_assets_path,
resources_assets.len(),
manifest_source,
),
);
let unzip_script = temp.path().join("fake-unzip");
@@ -229,7 +234,13 @@ fn write_executable(path: &Path, content: &str) {
fs::set_permissions(path, permissions).unwrap();
}
fn official_curl_script(curl_log: &Path, zip_fixture: &Path, manifest_source: &str) -> String {
fn official_curl_script(
curl_log: &Path,
zip_fixture: &Path,
resources_assets_fixture: &Path,
resources_assets_size: usize,
manifest_source: &str,
) -> String {
format!(
r#"#!/usr/bin/env bash
set -euo pipefail
@@ -268,6 +279,11 @@ emit_zip_fixture() {{
cp {} "$output"
}}
emit_resources_assets_fixture() {{
mkdir -p "$(dirname "$output")"
cp {} "$output"
}}
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
cat <<'JSON'
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
@@ -282,8 +298,10 @@ elif [[ "$url" == "{launcher_game_config_json_url}" ]]; then
JSON
elif [[ "$url" == "{launcher_manifest_url}" ]]; then
cat <<'JSON'
{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}}]}}
{{"source":"{launcher_manifest_source}","file":[{{"path":"/BlueArchive.exe","size":"100","hash":"123"}},{{"path":"/BlueArchive_Data/resources.assets","size":"{resources_assets_size}","hash":"456"}}]}}
JSON
elif [[ "$url" == "{launcher_resources_assets_url}" ]]; then
emit_resources_assets_fixture
elif [[ "$url" == "{server_info_url}" ]]; then
cat <<'JSON'
{{"ConnectionGroups":[{{"Name":"{connection_group}","AddressablesCatalogUrlRoot":"{addressables_root}","BundleVersion":"s8tloc7lo3","IsLivePublished":true}}]}}
@@ -332,11 +350,14 @@ fi
"#,
shell_quote(curl_log),
shell_quote(zip_fixture),
shell_quote(resources_assets_fixture),
launcher_latest_version = TEST_LAUNCHER_LATEST_VERSION,
launcher_latest_file_path = TEST_LAUNCHER_LATEST_FILE_PATH,
launcher_game_config_json_url = TEST_LAUNCHER_GAME_CONFIG_JSON_URL,
launcher_manifest_url = TEST_LAUNCHER_MANIFEST_URL,
launcher_manifest_source = manifest_source,
launcher_resources_assets_url = TEST_LAUNCHER_RESOURCES_ASSETS_URL,
resources_assets_size = resources_assets_size,
server_info_url = TEST_SERVER_INFO_URL,
connection_group = TEST_CONNECTION_GROUP,
addressables_root = TEST_ADDRESSABLES_ROOT,