feat: improve bat daemon cli output

This commit is contained in:
2026-07-12 23:04:13 +08:00
parent 264e78c440
commit f7255f7c04
8 changed files with 3231 additions and 344 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ authors.workspace = true
license.workspace = true
[[bin]]
name = "bat-official-sync"
name = "bat"
path = "src/bin/bat_official_sync.rs"
[dependencies]
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -24,9 +24,9 @@ pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use official_download::{
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
OfficialResourcePullStatus,
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
OfficialResourcePullService, OfficialResourcePullStatus,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
+186 -85
View File
@@ -226,6 +226,19 @@ impl OfficialLocalManifestAuditStatus {
pub fn is_verified(self) -> bool {
self == Self::Verified
}
/// Returns a stable status label for CLI and JSON reports.
pub fn as_str(self) -> &'static str {
match self {
Self::Verified => "verified",
Self::MissingManifestEntry => "missing_manifest_entry",
Self::UrlMismatch => "url_mismatch",
Self::DestinationMismatch => "destination_mismatch",
Self::MissingFile => "missing_file",
Self::SizeMismatch => "size_mismatch",
Self::Blake3Mismatch => "blake3_mismatch",
}
}
}
/// Local download-manifest audit result for one expected official URL.
@@ -276,6 +289,43 @@ impl OfficialLocalManifestAuditReport {
}
}
/// Full local verification report for all entries currently recorded in the
/// official download manifest.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OfficialLocalVerificationReport {
/// Per-entry manifest audit results.
pub items: Vec<OfficialLocalManifestAuditItem>,
/// Official seed hash pairs found in the local manifest.
pub official_hash_pair_count: usize,
/// Official seed hash pairs that passed xxHash32 verification.
pub official_hash_verified_count: usize,
/// Official seed hash verification failures.
pub official_hash_errors: Vec<String>,
}
impl OfficialLocalVerificationReport {
/// Returns true when every local manifest entry and every local official
/// seed hash pair passed verification.
pub fn is_clean(&self) -> bool {
self.items.iter().all(|item| item.status.is_verified())
&& self.official_hash_errors.is_empty()
&& self.official_hash_verified_count == self.official_hash_pair_count
}
/// Returns the number of manifest entries that passed verification.
pub fn verified_count(&self) -> usize {
self.items
.iter()
.filter(|item| item.status.is_verified())
.count()
}
/// Returns the number of manifest entries that need attention.
pub fn failure_count(&self) -> usize {
self.items.len().saturating_sub(self.verified_count())
}
}
/// Existing local state for an official pull plan.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OfficialLocalResourceState {
@@ -362,7 +412,7 @@ impl OfficialResourcePullService {
) -> Result<OfficialResourcePullReport, String> {
fs::create_dir_all(&self.output_root).map_err(|error| {
format!(
"Failed to create output root {}: {error}",
"创建资源输出目录失败 {}{error}",
self.output_root.display()
)
})?;
@@ -378,7 +428,7 @@ impl OfficialResourcePullService {
let mut processed_urls = HashSet::<String>::new();
for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() {
return Err("official resource pull interrupted by shutdown request".to_string());
return Err("官方资源拉取已被停止请求中断".to_string());
}
let index = offset + 1;
@@ -389,16 +439,13 @@ impl OfficialResourcePullService {
));
if !is_official_yostar_jp_url(&url) {
return Err(format!("Refusing to download non-official URL: {url}"));
return Err(format!("拒绝下载非官方 URL{url}"));
}
let destination = self.destination_for_url(&url)?;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create destination directory {}: {error}",
parent.display()
)
format!("创建下载目标目录失败 {}{error}", parent.display())
})?;
}
@@ -443,7 +490,7 @@ impl OfficialResourcePullService {
}
if should_cancel() {
return Err("official resource pull interrupted by shutdown request".to_string());
return Err("官方资源拉取已被停止请求中断".to_string());
}
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
@@ -473,6 +520,42 @@ impl OfficialResourcePullService {
Ok(OfficialLocalManifestAuditReport { items })
}
/// Verifies every entry currently present in the local download manifest.
///
/// This is intentionally network-free. It validates each recorded file
/// using the local manifest's size and BLAKE3 digest, then verifies every
/// local seed `.bytes`/`.hash` pair that is present using the official
/// decimal xxHash32 rule.
pub fn verify_local_download_manifest(
&self,
) -> Result<OfficialLocalVerificationReport, String> {
let manifest = self.read_download_manifest()?;
let urls = manifest.entries.keys().cloned().collect::<Vec<_>>();
let mut items = Vec::with_capacity(urls.len());
for url in urls {
let destination = self.destination_for_url(&url)?;
items.push(self.audit_one(&url, destination, &manifest)?);
}
let pairs = local_manifest_seed_hash_pairs(&manifest);
let mut official_hash_verified_count = 0;
let mut official_hash_errors = Vec::new();
for pair in &pairs {
match self.verify_official_seed_hash_pair_from_disk(pair) {
Ok(_) => official_hash_verified_count += 1,
Err(error) => official_hash_errors.push(error),
}
}
Ok(OfficialLocalVerificationReport {
items,
official_hash_pair_count: pairs.len(),
official_hash_verified_count,
official_hash_errors,
})
}
/// Returns whether the current output root already contains local state for
/// this plan. This is intentionally cheaper than a full audit and is used
/// to choose between audit-then-repair and first-time full pull flows.
@@ -508,7 +591,7 @@ impl OfficialResourcePullService {
/// `MediaCatalog.bytes`.
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
if !is_official_yostar_jp_url(url) {
return Err(format!("Refusing to fetch non-official URL: {url}"));
return Err(format!("拒绝拉取非官方 URL{url}"));
}
let attempts = self.retry_attempts.max(1);
@@ -517,12 +600,12 @@ impl OfficialResourcePullService {
match self.fetch_bytes_once(url) {
Ok(bytes) => return Ok(bytes),
Err(error) => {
last_error = Some(format!("attempt {attempt}/{attempts}: {error}"));
last_error = Some(format!(" {attempt}/{attempts} 次尝试失败:{error}"));
}
}
}
Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt")))
Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}")))
}
fn fetch_bytes_once(&self, url: &str) -> Result<Vec<u8>, String> {
@@ -536,14 +619,14 @@ impl OfficialResourcePullService {
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
return Err(format!("curl 拉取失败 {url}{}", stderr.trim()));
}
Ok(output.stdout)
@@ -559,9 +642,7 @@ impl OfficialResourcePullService {
let _ = fs::remove_file(&partial);
self.download_one(url, &partial, false)
.map_err(|retry_error| {
format!(
"resume failed for {url}: {error}; clean retry also failed: {retry_error}"
)
format!("续传失败 {url}{error};清理后重新下载也失败:{retry_error}")
})?;
}
} else {
@@ -570,28 +651,20 @@ impl OfficialResourcePullService {
if file_len_if_exists(destination)?.is_some() {
fs::remove_file(destination).map_err(|error| {
format!(
"Failed to replace existing destination {}: {error}",
destination.display()
)
format!("替换已有目标文件失败 {}{error}", destination.display())
})?;
}
fs::rename(&partial, destination).map_err(|error| {
format!(
"Failed to move partial download {} -> {}: {error}",
"移动临时下载文件失败 {} -> {}{error}",
partial.display(),
destination.display()
)
})?;
let bytes = fs::metadata(destination)
.map_err(|error| {
format!(
"Downloaded file missing at {}: {error}",
destination.display()
)
})?
.map_err(|error| format!("下载完成后目标文件缺失 {}{error}", destination.display()))?
.len();
Ok(PullOneResult {
@@ -612,17 +685,13 @@ impl OfficialResourcePullService {
match self.download_one_once(url, destination, resume) {
Ok(()) => return Ok(()),
Err(error) => {
last_error = Some(format!("attempt {attempt}/{attempts}: {error}"));
last_error = Some(format!(" {attempt}/{attempts} 次尝试失败:{error}"));
}
}
}
Err(last_error.unwrap_or_else(|| {
format!(
"curl failed for {url} -> {} without an attempt",
destination.display()
)
}))
Err(last_error
.unwrap_or_else(|| format!("curl 未执行就已失败:{url} -> {}", destination.display())))
}
fn download_one_once(&self, url: &str, destination: &Path, resume: bool) -> Result<(), String> {
@@ -641,7 +710,7 @@ impl OfficialResourcePullService {
let output = command.output().map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
@@ -649,7 +718,7 @@ impl OfficialResourcePullService {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"curl failed for {url} -> {}: {}",
"curl 下载失败 {url} -> {}{}",
destination.display(),
stderr.trim()
));
@@ -687,7 +756,7 @@ impl OfficialResourcePullService {
self.invalidate_download_manifest_hash_pair(manifest, pair)
{
return Err(format!(
"{error}; additionally failed to invalidate local manifest entries for official hash pair: {cleanup_error}"
"{error};同时清理官方 hash 对应的本地 manifest 条目失败:{cleanup_error}"
));
}
@@ -716,13 +785,13 @@ impl OfficialResourcePullService {
let hash_path = self.destination_for_url(&pair.hash_url)?;
let data = fs::read(&data_path).map_err(|error| {
format!(
"Failed to read official hash data file {}: {error}",
"读取官方 hash 对应数据文件失败 {}{error}",
data_path.display()
)
})?;
let hash = fs::read(&hash_path).map_err(|error| {
format!(
"Failed to read official hash sidecar {}: {error}",
"读取官方 hash sidecar 失败 {}{error}",
hash_path.display()
)
})?;
@@ -748,7 +817,7 @@ impl OfficialResourcePullService {
for pair in pairs {
if !verified_hash_urls.contains(&pair.hash_url) {
return Err(format!(
"official hash pair was not verified for {} using {}",
"官方 hash 对未完成校验:数据={} hash={}",
pair.data_url, pair.hash_url
));
}
@@ -766,23 +835,18 @@ impl OfficialResourcePullService {
}
Err(error) => {
return Err(format!(
"Failed to read download manifest {}: {error}",
"读取下载 manifest 失败 {}{error}",
path.display()
));
}
};
let manifest: OfficialDownloadManifest =
serde_json::from_slice(&bytes).map_err(|error| {
format!(
"Failed to parse download manifest {}: {error}",
path.display()
)
})?;
let manifest: OfficialDownloadManifest = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析下载 manifest 失败 {}{error}", path.display()))?;
if manifest.version != DOWNLOAD_MANIFEST_VERSION {
return Err(format!(
"Unsupported download manifest version {} in {}",
"不支持的下载 manifest 版本 {},文件 {}",
manifest.version,
path.display()
));
@@ -795,29 +859,22 @@ impl OfficialResourcePullService {
let path = self.download_manifest_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create download manifest directory {}: {error}",
parent.display()
)
format!("创建下载 manifest 目录失败 {}{error}", parent.display())
})?;
}
let temporary = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(manifest).map_err(|error| {
format!(
"Failed to serialize download manifest {}: {error}",
path.display()
)
})?;
let bytes = serde_json::to_vec_pretty(manifest)
.map_err(|error| format!("序列化下载 manifest 失败 {}{error}", path.display()))?;
fs::write(&temporary, bytes).map_err(|error| {
format!(
"Failed to write temporary download manifest {}: {error}",
"写入临时下载 manifest 失败 {}{error}",
temporary.display()
)
})?;
fs::rename(&temporary, &path).map_err(|error| {
format!(
"Failed to move download manifest {} -> {}: {error}",
"移动下载 manifest 失败 {} -> {}{error}",
temporary.display(),
path.display()
)
@@ -871,12 +928,7 @@ impl OfficialResourcePullService {
destination: &Path,
) -> Result<(), String> {
let bytes = fs::metadata(destination)
.map_err(|error| {
format!(
"Failed to inspect downloaded file {}: {error}",
destination.display()
)
})?
.map_err(|error| format!("读取已下载文件信息失败 {}{error}", destination.display()))?
.len();
let digest = blake3_file_hex(destination)?;
let relative_destination = self.relative_destination(destination)?;
@@ -899,7 +951,7 @@ impl OfficialResourcePullService {
.strip_prefix(&self.output_root)
.map_err(|error| {
format!(
"Destination {} is not under output root {}: {error}",
"目标路径 {} 不在输出目录 {} 下:{error}",
destination.display(),
self.output_root.display()
)
@@ -1013,15 +1065,15 @@ impl OfficialResourcePullService {
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
if !is_official_yostar_jp_url(url) {
return Err(format!("URL is not an official JP host: {url}"));
return Err(format!("URL 不是官方 JP host{url}"));
}
let rest = url
.strip_prefix("https://")
.ok_or_else(|| format!("Official URL must use https: {url}"))?;
.ok_or_else(|| format!("官方 URL 必须使用 https{url}"))?;
let (host, path) = rest
.split_once('/')
.ok_or_else(|| format!("Official URL has no path: {url}"))?;
.ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
let mut destination = PathBuf::from(sanitize_segment(host));
for segment in path.split('/') {
@@ -1029,7 +1081,7 @@ impl OfficialResourcePullService {
continue;
}
if segment == "." || segment == ".." {
return Err(format!("Official URL has unsafe path segment: {url}"));
return Err(format!("官方 URL 包含不安全路径片段:{url}"));
}
let segment = segment.split(['?', '#']).next().unwrap_or(segment);
@@ -1060,7 +1112,7 @@ pub fn verify_official_seed_catalog_hash(
if actual != expected {
return Err(format!(
"official hash mismatch for {data_url}: expected {expected} from {hash_url}, actual {actual}"
"官方 hash 校验失败 {data_url}:期望 {expected}(来自 {hash_url}),实际 {actual}"
));
}
@@ -1097,6 +1149,28 @@ fn official_seed_hash_pairs(plan: &OfficialResourcePullPlan) -> Vec<OfficialSeed
.collect()
}
fn local_manifest_seed_hash_pairs(
manifest: &OfficialDownloadManifest,
) -> Vec<OfficialSeedHashPair> {
manifest
.entries
.keys()
.filter_map(|data_url| {
let hash_url = data_url
.strip_suffix(".bytes")
.map(|prefix| format!("{prefix}.hash"))?;
if !manifest.entries.contains_key(&hash_url) {
return None;
}
Some(OfficialSeedHashPair {
data_url: data_url.clone(),
hash_url,
})
})
.collect()
}
fn official_hash_refresh_urls(pairs: &[OfficialSeedHashPair]) -> HashSet<String> {
let mut urls = HashSet::new();
for pair in pairs {
@@ -1166,12 +1240,9 @@ struct PullOneResult {
fn file_len_if_exists(path: &Path) -> Result<Option<u64>, String> {
match fs::metadata(path) {
Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())),
Ok(_) => Err(format!(
"Expected file path but found non-file: {}",
path.display()
)),
Ok(_) => Err(format!("期望文件路径,但实际不是文件:{}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("Failed to inspect {}: {error}", path.display())),
Err(error) => Err(format!("读取路径信息失败 {}{error}", path.display())),
}
}
@@ -1183,14 +1254,14 @@ fn partial_path_for(destination: &Path) -> PathBuf {
fn blake3_file_hex(path: &Path) -> Result<String, String> {
let mut file =
File::open(path).map_err(|error| format!("Failed to open {}: {error}", path.display()))?;
File::open(path).map_err(|error| format!("打开文件失败 {}{error}", path.display()))?;
let mut hasher = blake3::Hasher::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
.map_err(|error| format!("读取文件失败 {}{error}", path.display()))?;
if read == 0 {
break;
}
@@ -1202,11 +1273,10 @@ fn blake3_file_hex(path: &Path) -> Result<String, String> {
fn parse_official_xxhash32_decimal(hash_url: &str, bytes: &[u8]) -> Result<u32, String> {
let text = std::str::from_utf8(bytes)
.map_err(|error| format!("Official hash sidecar is not UTF-8 at {hash_url}: {error}"))?
.map_err(|error| format!("官方 hash sidecar 不是 UTF-8{hash_url}{error}"))?
.trim();
text.parse::<u32>().map_err(|error| {
format!("Official hash sidecar is not decimal xxHash32 at {hash_url}: {error}")
})
text.parse::<u32>()
.map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32{hash_url}{error}"))
}
fn xxhash32(bytes: &[u8]) -> u32 {
@@ -1649,6 +1719,37 @@ fi
assert!(service.audit_local_manifest(&plan).unwrap().is_clean());
}
#[test]
fn full_local_verification_checks_manifest_and_official_hash_pairs() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan(discovery_plan(), inventory());
service.pull(&plan).unwrap();
let verification = service.verify_local_download_manifest().unwrap();
assert!(verification.is_clean());
assert_eq!(verification.items.len(), 7);
assert_eq!(verification.verified_count(), 7);
assert_eq!(verification.official_hash_pair_count, 1);
assert_eq!(verification.official_hash_verified_count, 1);
let data_path = service
.destination_for_url(
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
)
.unwrap();
fs::write(data_path, b"corrupted").unwrap();
let corrupted = service.verify_local_download_manifest().unwrap();
assert!(!corrupted.is_clean());
assert_eq!(corrupted.failure_count(), 1);
assert_eq!(corrupted.official_hash_errors.len(), 1);
}
#[test]
fn refreshes_official_hash_pairs_and_skips_manifest_verified_content() {
let out_dir = TempDir::new().unwrap();
@@ -1833,7 +1934,7 @@ fi
};
let error = service.pull(&plan).unwrap_err();
assert!(error.contains("official hash mismatch"));
assert!(error.contains("官方 hash 校验失败"));
let manifest = service.read_download_manifest().unwrap();
assert!(manifest.entries.is_empty());
+22 -26
View File
@@ -83,10 +83,9 @@ impl OfficialGameMainConfigBootstrapService {
.source
.clone()
.filter(|value| !value.is_empty())
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
.ok_or_else(|| "官方启动器远端 manifest 缺少 source".to_string())?;
let cdn_config = self.launcher.fetch_cdn_config()?;
let temp_dir = TempDir::new()
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
let temp_dir = TempDir::new().map_err(|error| format!("创建临时目录失败:{error}"))?;
let (game_zip_url, resources_assets) = self.fetch_resources_assets(
&game_config,
&manifest,
@@ -121,12 +120,12 @@ impl OfficialGameMainConfigBootstrapService {
match self.download_file_once(url, destination) {
Ok(()) => return Ok(()),
Err(error) => {
last_error = Some(format!("attempt {attempt}/{attempts}: {error}"));
last_error = Some(format!(" {attempt}/{attempts} 次尝试失败:{error}"));
}
}
}
Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt")))
Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}")))
}
fn download_file_once(&self, url: &str, destination: &Path) -> Result<(), String> {
@@ -142,14 +141,14 @@ impl OfficialGameMainConfigBootstrapService {
.output()
.map_err(|error| {
format!(
"Failed to launch curl command {}: {error}",
"启动 curl 命令失败 {}{error}",
self.curl_command.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("curl failed for {url}: {}", stderr.trim()));
return Err(format!("curl 拉取失败 {url}{}", stderr.trim()));
}
Ok(())
@@ -168,7 +167,7 @@ impl OfficialGameMainConfigBootstrapService {
let backup_url = launcher_package_url(backup_cdn_root, relative_path)?;
self.download_file(&backup_url, destination).map_err(|backup_error| {
format!(
"failed to download official game ZIP from primary ({primary_error}) and backup ({backup_error})"
"下载官方游戏资源失败:主地址失败({primary_error}),备用地址也失败({backup_error}"
)
})
}
@@ -195,11 +194,10 @@ impl OfficialGameMainConfigBootstrapService {
)?;
let extract_root = temp_root.join("extract");
fs::create_dir_all(&extract_root)
.map_err(|error| format!("Failed to create extract root: {error}"))?;
.map_err(|error| format!("创建解压目录失败:{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 resources_assets = find_resources_assets(&extract_root)
.ok_or_else(|| "官方启动器包内没有找到 resources.assets".to_string())?;
Ok((game_zip_url, resources_assets))
}
GameMainConfigSource::ManifestFile { source_dir, file } => {
@@ -228,7 +226,7 @@ impl OfficialGameMainConfigBootstrapService {
.output()
.map_err(|error| {
format!(
"Failed to launch unzip command {}: {error}",
"启动 unzip 命令失败 {}{error}",
self.unzip_command.display()
)
})?;
@@ -236,7 +234,7 @@ impl OfficialGameMainConfigBootstrapService {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"unzip failed for {}: {}",
"解压失败 {}{}",
archive_path.display(),
stderr.trim()
));
@@ -271,7 +269,7 @@ fn select_game_main_config_source<'a>(
});
}
return Err(format!(
"Official launcher manifest directory source has no BlueArchive_Data/resources.assets entry: {manifest_source}"
"官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}"
));
}
@@ -282,7 +280,7 @@ fn select_game_main_config_source<'a>(
}
Err(format!(
"Official launcher metadata has no usable game archive path: source={manifest_source}, game_latest_file_path={}",
"官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}",
game_config.game_latest_file_path
))
}
@@ -318,9 +316,9 @@ fn launcher_manifest_file_relative_path(
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}"))?;
.ok_or_else(|| format!("官方启动器 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_or_else(|| format!("官方启动器 manifest 文件路径无效:{file_path}"))?;
Ok(format!("{source}/{file}"))
}
@@ -358,18 +356,16 @@ 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 expected_size = file
.size
.parse::<u64>()
.map_err(|error| format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path))?;
let actual_size = fs::metadata(path)
.map_err(|error| format!("Failed to stat downloaded {}: {error}", path.display()))?
.map_err(|error| format!("读取已下载文件信息失败 {}{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
));
}
+27 -33
View File
@@ -109,13 +109,11 @@ impl OfficialLauncherBootstrapService {
/// 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}")
})?;
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("Official launcher game config is missing latest version or basis".into());
return Err("官方启动器 game config 缺少最新版本或基准路径".into());
}
Ok(config)
@@ -125,7 +123,7 @@ impl OfficialLauncherBootstrapService {
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}"))
.map_err(|error| format!("解析官方启动器 CDN config 数据失败:{error}"))
}
/// Fetches the official remote manifest URL for a PC client version and
@@ -136,7 +134,7 @@ impl OfficialLauncherBootstrapService {
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());
return Err("启动器 manifest 版本和文件路径不能为空".into());
}
let path = format!(
@@ -146,13 +144,11 @@ impl OfficialLauncherBootstrapService {
);
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}")
})?;
.map_err(|error| format!("解析官方启动器 manifest URL 数据失败:{error}"))?;
if !is_official_launcher_package_url(&manifest_url.url) {
return Err(format!(
"Official launcher API returned non-official manifest URL: {}",
"官方启动器 API 返回了非官方 manifest URL{}",
manifest_url.url
));
}
@@ -166,9 +162,7 @@ impl OfficialLauncherBootstrapService {
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}"
));
return Err(format!("拒绝拉取非官方启动器 manifest URL{manifest_url}"));
}
let output = self.run_curl_with_retry(
@@ -186,16 +180,16 @@ impl OfficialLauncherBootstrapService {
|output| {
let stderr = String::from_utf8_lossy(&output.stderr);
format!(
"curl failed for launcher manifest {manifest_url}: {}",
"curl 拉取启动器 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}"))?;
.map_err(|error| format!("解析官方启动器 manifest 失败:{error}"))?;
if manifest.files.is_empty() {
return Err("Official launcher remote manifest has no files".into());
return Err("官方启动器远端 manifest 没有文件条目".into());
}
Ok(manifest)
@@ -246,22 +240,22 @@ impl OfficialLauncherBootstrapService {
},
|output| {
let stderr = String::from_utf8_lossy(&output.stderr);
format!("curl failed for {url}: {}", stderr.trim())
format!("curl 拉取失败 {url}{}", stderr.trim())
},
)?;
let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("Failed to parse official launcher API response: {error}"))?;
.map_err(|error| format!("解析官方启动器 API 响应失败:{error}"))?;
if envelope.code != 200 {
return Err(format!(
"Official launcher API returned code {}: {}",
"官方启动器 API 返回错误码 {}{}",
envelope.code,
envelope
.message
.as_deref()
.or(envelope.msg.as_deref())
.unwrap_or("<no message>")
.unwrap_or("<无消息>")
));
}
@@ -280,20 +274,20 @@ impl OfficialLauncherBootstrapService {
Ok(output) if output.status.success() => return Ok(output),
Ok(output) => {
last_error = Some(format!(
"attempt {attempt}/{attempts}: {}",
" {attempt}/{attempts} 次尝试失败:{}",
failure_message(&output)
));
}
Err(error) => {
last_error = Some(format!(
"attempt {attempt}/{attempts}: Failed to launch curl command {}: {error}",
" {attempt}/{attempts} 次尝试失败:启动 curl 命令失败 {}{error}",
self.curl_command
));
}
}
}
Err(last_error.unwrap_or_else(|| format!("curl command {} did not run", self.curl_command)))
Err(last_error.unwrap_or_else(|| format!("curl 命令未执行:{}", self.curl_command)))
}
}
@@ -315,7 +309,7 @@ pub struct LauncherEnvelope {
/// 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}"));
return Err(format!("官方启动器 API 路径无效:{path}"));
}
Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}"))
@@ -337,7 +331,7 @@ pub fn is_official_launcher_package_url(url: &str) -> bool {
/// 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}"));
return Err(format!("启动器 CDN 根地址不是官方地址:{cdn_root}"));
}
validate_launcher_package_path(file_path)?;
@@ -368,15 +362,15 @@ fn normalize_relative_path(path: &str) -> String {
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}"));
return Err(format!("启动器包路径无效:{path}"));
}
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(format!("Invalid launcher package path: {path}"));
return Err(format!("启动器包路径无效:{path}"));
}
if segment.contains('\\') || segment.contains('?') || segment.contains('#') {
return Err(format!("Invalid launcher package path: {path}"));
return Err(format!("启动器包路径无效:{path}"));
}
}
@@ -394,14 +388,14 @@ pub fn launcher_authorization_header(
timestamp: Option<u64>,
) -> Result<String, String> {
if launcher_version.is_empty() {
return Err("Launcher version must not be empty".into());
return Err("启动器版本不能为空".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}"))?
.map_err(|error| format!("系统时间早于 Unix epoch{error}"))?
.as_secs(),
};
@@ -411,7 +405,7 @@ pub fn launcher_authorization_header(
"version": launcher_version,
});
let head_json = serde_json::to_string(&head)
.map_err(|error| format!("Failed to serialize launcher auth head: {error}"))?;
.map_err(|error| format!("序列化启动器认证 head 失败:{error}"))?;
let mut hasher = Md5::new();
hasher.update(head_json.as_bytes());
hasher.update(body.as_bytes());
@@ -422,7 +416,7 @@ pub fn launcher_authorization_header(
"head": head,
"sign": sign,
}))
.map_err(|error| format!("Failed to serialize launcher auth header: {error}"))
.map_err(|error| format!("序列化启动器认证 header 失败:{error}"))
}
#[cfg(test)]
+72 -82
View File
@@ -87,7 +87,7 @@ impl Default for OfficialUpdateConfig {
launcher_version: "1.7.2".to_string(),
auto_discover: false,
platforms: None,
output_root: PathBuf::from("official_update_output"),
output_root: PathBuf::from("./bat-resources"),
snapshot_path: None,
curl_command: PathBuf::from("curl"),
unzip_command: PathBuf::from("unzip"),
@@ -442,7 +442,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"start",
format!(
"starting official resource sync: output={} dry_run={} auto_discover={}",
"开始官方资源同步:资源目录={} 试运行={} 自动发现={}",
config.output_root.display(),
config.dry_run,
config.auto_discover
@@ -451,18 +451,15 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?;
let _lock = if config.dry_run {
progress(OfficialUpdateProgress::new(
"lock",
"dry-run: skipping state lock",
));
progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁"));
None
} else {
progress(OfficialUpdateProgress::new(
"lock",
format!("acquiring state lock {}", config.lock_path().display()),
format!("获取资源目录状态锁 {}", config.lock_path().display()),
));
let lock = OfficialUpdateLock::acquire(config)?;
progress(OfficialUpdateProgress::new("lock", "state lock acquired"));
progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
Some(lock)
};
check_shutdown_requested(&mut should_cancel)?;
@@ -483,7 +480,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"bootstrap",
format!(
"auto-discover enabled; launcher_version={} cache={}",
"启用自动发现;启动器版本={} 缓存={}",
config.launcher_version,
bootstrap_cache_path.display()
),
@@ -500,7 +497,7 @@ impl OfficialUpdateService {
} else {
progress(OfficialUpdateProgress::new(
"bootstrap",
"auto-discover disabled; using explicit metadata inputs",
"未启用自动发现;使用显式元数据输入",
));
None
};
@@ -515,7 +512,7 @@ impl OfficialUpdateService {
.map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone())
})
.ok_or_else(|| {
anyhow::anyhow!("missing app version; pass it explicitly or enable auto-discover")
anyhow::anyhow!("缺少应用版本;请显式传入 --app-version 或启用 --auto-discover")
})?;
let connection_group = config
.connection_group
@@ -526,14 +523,12 @@ impl OfficialUpdateService {
})
})
.ok_or_else(|| {
anyhow::anyhow!(
"missing connection group; pass it explicitly or enable auto-discover"
)
anyhow::anyhow!("缺少连接组;请显式传入 --connection-group 或启用 --auto-discover")
})?;
progress(OfficialUpdateProgress::new(
"metadata",
format!(
"using app_version={} connection_group={} platforms={}",
"使用应用版本={} 连接组={} 平台={}",
app_version,
connection_group,
platforms_label(platforms)
@@ -543,35 +538,30 @@ impl OfficialUpdateService {
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
progress(OfficialUpdateProgress::new(
"server-info",
format!(
"loading server-info from {}",
server_info_source_label(source)
),
format!("{} 读取服务器信息", server_info_source_label(source)),
));
load_server_info(source, &fetcher)?
} else {
let bootstrap = bootstrap.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"missing server-info source; pass one explicitly or enable auto-discover"
"缺少服务器信息来源;请显式传入 --server-info-* 或启用 --auto-discover"
)
})?;
let url = bootstrap
.game_main_config
.server_info_data_url
.as_deref()
.ok_or_else(|| {
anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl")
})?;
.ok_or_else(|| anyhow::anyhow!("官方 GameMainConfig 中没有 ServerInfoDataUrl"))?;
progress(OfficialUpdateProgress::new(
"server-info",
format!("fetching discovered server-info {url}"),
format!("拉取自动发现的服务器信息 {url}"),
));
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
};
progress(OfficialUpdateProgress::new(
"server-info",
format!("parsing server-info bytes={}", server_info_bytes.len()),
format!("解析服务器信息,字节数={}", server_info_bytes.len()),
));
check_shutdown_requested(&mut should_cancel)?;
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
@@ -581,7 +571,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"discovery",
format!(
"resolved addressables_root={} endpoints={}",
"解析到 Addressables={} endpoint={}",
current_snapshot.addressables_root,
current_snapshot.endpoints.len()
),
@@ -589,7 +579,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"markers",
format!(
"checking {} remote marker endpoints",
"检查 {} 个远端标记 endpoint",
marker_endpoint_count(&current_snapshot)
),
));
@@ -607,7 +597,7 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("reading previous snapshot {}", snapshot_path.display()),
format!("读取上次快照 {}", snapshot_path.display()),
));
let previous_snapshot = read_snapshot(&snapshot_path)?;
let previous_base_snapshot = previous_snapshot
@@ -623,13 +613,13 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"decision",
format!(
"remote decision={:?} force={} remote_should_download={}",
"远端决策={:?} 强制刷新={} 远端需要下载={}",
sync_plan.decision, config.force, remote_should_download
),
));
progress(OfficialUpdateProgress::new(
"plan",
"building official pull plan from latest seed catalogs",
"根据最新种子目录构建官方拉取计划",
));
let pull_plan = build_pull_plan(
&server_info,
@@ -643,7 +633,7 @@ impl OfficialUpdateService {
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"plan",
format!("pull plan contains {url_count} official URLs"),
format!("拉取计划包含 {url_count} 个官方 URL"),
));
let local_state = fetcher
.local_resource_state(&pull_plan)
@@ -652,7 +642,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"local-state",
format!(
"manifest_entries={} existing_files={} has_local_resources={}",
"manifest 条目={} 已存在文件={} 是否已有本地资源={}",
local_state.manifest_entry_count,
local_state.existing_file_count,
has_local_resources
@@ -663,7 +653,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"audit",
format!(
"auditing local download manifest {}",
"审计本地下载 manifest {}",
fetcher.download_manifest_path().display()
),
));
@@ -675,13 +665,13 @@ impl OfficialUpdateService {
} else if config.audit_local {
progress(OfficialUpdateProgress::new(
"audit",
"no local resources found; skipping local audit before first full pull",
"未发现本地资源;首次全量拉取前跳过本地审计",
));
None
} else {
progress(OfficialUpdateProgress::new(
"audit",
"local manifest audit disabled",
"本地 manifest 审计已关闭",
));
None
};
@@ -703,14 +693,14 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"audit",
format!(
"local manifest verified={} repair_needed={}",
"本地 manifest 已校验={} 需要修复={}",
local_manifest_verified_count, local_manifest_repair_needed_count
),
));
progress(OfficialUpdateProgress::new(
"decision",
format!(
"final should_download={} repair_needed={} initial_pull_needed={}",
"最终决策 需要下载={} 需要修复={} 首次拉取={}",
should_download, repair_needed, initial_pull_needed
),
));
@@ -760,7 +750,7 @@ impl OfficialUpdateService {
if !should_download {
progress(OfficialUpdateProgress::new(
"finish",
"up to date; no download needed",
"资源已是最新;无需下载",
));
return Ok(report);
}
@@ -773,20 +763,20 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"dry-run",
format!(
"dry-run plan generated {} URLs",
"试运行已生成 {} URL",
report.download_url_count.unwrap_or(0)
),
));
} else {
progress(OfficialUpdateProgress::new(
"dry-run",
"dry-run detected pending download; full URL plan disabled",
"试运行检测到需要下载;未启用完整 URL 计划输出",
));
}
report.update_status = OfficialUpdateStatus::WouldDownload;
progress(OfficialUpdateProgress::new(
"finish",
"dry-run finished without writing state",
"试运行完成,未写入状态",
));
return Ok(report);
}
@@ -795,7 +785,7 @@ impl OfficialUpdateService {
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"download",
format!("downloading or reusing {download_url_count} official URLs"),
format!("下载或复用 {download_url_count} 个官方 URL"),
));
let pull_report = fetcher
.pull_with_progress_and_cancellation(
@@ -809,7 +799,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"download",
format!(
"download phase finished: downloaded={} resumed={} skipped={} transferred_bytes={}",
"下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}",
pull_report.downloaded_count(),
pull_report.resumed_count(),
pull_report.skipped_count(),
@@ -818,7 +808,7 @@ impl OfficialUpdateService {
));
progress(OfficialUpdateProgress::new(
"audit",
"running final local manifest audit",
"执行最终本地 manifest 审计",
));
check_shutdown_requested(&mut should_cancel)?;
let final_audit = fetcher
@@ -826,7 +816,7 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("writing snapshot {}", snapshot_path.display()),
format!("写入快照 {}", snapshot_path.display()),
));
write_snapshot(&snapshot_path, &current_update_snapshot)?;
@@ -845,7 +835,7 @@ impl OfficialUpdateService {
progress(OfficialUpdateProgress::new(
"finish",
format!(
"sync finished: resources={} official_hashes_verified={}",
"同步完成:资源数={} 官方 hash 已校验={}",
report.resource_count.unwrap_or(0),
report.official_seed_hash_verified_count
),
@@ -870,7 +860,7 @@ fn build_pull_plan(
progress(OfficialUpdateProgress::new(
"plan",
format!(
"discovery plan resolved: connection_group={} app_version={} endpoints={}",
"发现计划已解析:连接组={} 应用版本={} endpoint={}",
discovery.connection_group_name,
discovery.app_version,
discovery.endpoints.len()
@@ -881,7 +871,7 @@ fn build_pull_plan(
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"inventory",
"parsing seed catalogs into download inventory",
"正在把种子目录解析为下载清单",
));
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
Ok(build_official_pull_plan_for_platform_inventory(
@@ -921,7 +911,7 @@ fn collect_endpoint_markers(
progress(OfficialUpdateProgress::new(
"marker",
format!(
"fetching {} marker{} {}",
"拉取 {} 标记{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
@@ -946,9 +936,7 @@ fn collect_endpoint_markers(
fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> {
if should_cancel() {
return Err(anyhow::anyhow!(
"official update interrupted by shutdown request"
));
return Err(anyhow::anyhow!("官方资源更新已被停止请求中断"));
}
Ok(())
@@ -978,11 +966,11 @@ fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
match source {
OfficialServerInfoSource::LocalPath(path) => format!("local path {}", path.display()),
OfficialServerInfoSource::LocalPath(path) => format!("本地路径 {}", path.display()),
OfficialServerInfoSource::OfficialFile(file_name) => {
format!("official file {file_name}")
format!("官方文件 {file_name}")
}
OfficialServerInfoSource::OfficialUrl(url) => format!("official URL {url}"),
OfficialServerInfoSource::OfficialUrl(url) => format!("官方 URL {url}"),
}
}
@@ -1017,17 +1005,14 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
match event.kind {
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!("({}/{}) started {}", event.index, event.total, event.url),
format!("({}/{}) 开始 {}", event.index, event.total, event.url),
),
OfficialResourcePullProgressKind::Finished => {
let status = event
.status
.map(|status| status.as_str())
.unwrap_or("unknown");
let status = event.status.map(localized_pull_status).unwrap_or("未知");
OfficialUpdateProgress::new(
"download",
format!(
"({}/{}) finished status={} bytes={} transferred_bytes={} {}",
"({}/{}) 完成 状态={} 文件字节={} 本轮传输字节={} {}",
event.index,
event.total,
status,
@@ -1040,6 +1025,14 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
}
}
fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static str {
match status {
crate::OfficialResourcePullStatus::SkippedExisting => "已复用",
crate::OfficialResourcePullStatus::Resumed => "已续传",
crate::OfficialResourcePullStatus::Downloaded => "已下载",
}
}
/// Computes the extended snapshot delta.
pub fn diff_extended_snapshot(
current: &OfficialUpdateSnapshot,
@@ -1074,7 +1067,7 @@ pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapsho
let legacy =
serde_json::from_slice::<YostarJpSyncSnapshot>(&data).map_err(|legacy_error| {
anyhow::anyhow!(
"failed to parse official update snapshot as v2 ({update_error}) or legacy v1 ({legacy_error})"
"无法解析官方更新快照:v2 解析失败 ({update_error})legacy v1 解析也失败 ({legacy_error})"
)
})?;
Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None)))
@@ -1178,7 +1171,7 @@ fn resolve_bootstrap(
);
progress(OfficialUpdateProgress::new(
"launcher",
"fetching official launcher game config and remote manifest",
"正在拉取官方启动器游戏配置和远端 manifest",
));
let (game_config, manifest_url, manifest) = launcher
.fetch_latest_remote_manifest()
@@ -1189,14 +1182,14 @@ fn resolve_bootstrap(
progress(OfficialUpdateProgress::new(
"launcher",
format!(
"launcher metadata resolved: latest_version={} manifest_files={}",
"启动器元数据已解析:最新版本={} manifest 文件数={}",
launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count
),
));
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("checking bootstrap cache {}", cache_path.display()),
format!("检查启动缓存 {}", cache_path.display()),
));
if let Some(cache) = read_bootstrap_cache(cache_path)? {
if let Some(game_main_config) =
@@ -1204,7 +1197,7 @@ fn resolve_bootstrap(
{
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"cache hit; reusing parsed GameMainConfig",
"命中缓存;复用已解析的 GameMainConfig",
));
return Ok(ResolvedBootstrap {
launcher_metadata,
@@ -1216,7 +1209,7 @@ fn resolve_bootstrap(
progress(OfficialUpdateProgress::new(
"game-main-config",
"cache miss; fetching resources.assets and parsing GameMainConfig",
"缓存未命中;拉取 resources.assets 并解析 GameMainConfig",
));
check_shutdown_requested(should_cancel)?;
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
@@ -1249,12 +1242,12 @@ fn resolve_bootstrap(
)?;
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("bootstrap cache written {}", cache_path.display()),
format!("启动缓存已写入 {}", cache_path.display()),
));
} else {
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"dry-run: bootstrap cache not written",
"试运行:不写入启动缓存",
));
}
@@ -1287,7 +1280,7 @@ fn fetch_seed_catalogs(
progress(OfficialUpdateProgress::new(
"catalog",
format!(
"fetching seed catalog {}{} {}",
"拉取种子目录 {}{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
@@ -1310,21 +1303,18 @@ fn build_inventory_from_seed_catalogs(
let table_catalog = catalogs
.table_catalog
.as_deref()
.ok_or_else(|| anyhow::anyhow!("official discovery did not include TableCatalog.bytes"))?;
.ok_or_else(|| anyhow::anyhow!("官方发现结果缺少 TableCatalog.bytes"))?;
let mut platform_catalogs = Vec::new();
for platform in platforms {
let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| {
anyhow::anyhow!(
"official discovery did not include {} BundlePackingInfo.bytes",
"官方发现结果缺少 {} BundlePackingInfo.bytes",
platform.as_str()
)
})?;
let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| {
anyhow::anyhow!(
"official discovery did not include {} MediaCatalog.bytes",
platform.as_str()
)
anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str())
})?;
platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes(
@@ -1375,7 +1365,7 @@ impl SeedCatalogs {
fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<PatchPlatform> {
endpoint.platform.ok_or_else(|| {
anyhow::anyhow!(
"discovery endpoint {:?} has no platform: {}",
"发现 endpoint {:?} 缺少平台:{}",
endpoint.kind,
endpoint.url
)
@@ -1403,13 +1393,13 @@ impl OfficialUpdateLock {
continue;
}
return Err(anyhow::anyhow!(
"official update output is locked: {}",
"官方资源目录已被锁定 (locked){}",
path.display()
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"failed to acquire official update lock {}: {error}",
"获取官方资源目录状态锁失败 {}{error}",
path.display()
));
}
@@ -1417,7 +1407,7 @@ impl OfficialUpdateLock {
}
Err(anyhow::anyhow!(
"failed to acquire official update lock {}",
"获取官方资源目录状态锁失败 {}",
path.display()
))
}
@@ -1639,7 +1629,7 @@ mod tests {
let first = OfficialUpdateLock::acquire(&config).unwrap();
let second = OfficialUpdateLock::acquire(&config).unwrap_err();
assert!(second.to_string().contains("locked"));
assert!(second.to_string().contains("锁定"));
drop(first);
assert!(OfficialUpdateLock::acquire(&config).is_ok());
}
@@ -1681,7 +1671,7 @@ mod tests {
)
.unwrap_err();
assert!(error.to_string().contains("shutdown request"));
assert!(error.to_string().contains("停止请求"));
assert_eq!(checks, 1);
}
}
@@ -180,9 +180,9 @@ fn official_update_service_emits_human_progress_events() {
assert!(events.iter().any(|event| event.stage == "catalog"));
assert!(events.iter().any(|event| event.stage == "dry-run"));
assert!(events.iter().any(|event| event.stage == "finish"));
assert!(events.iter().any(|event| event
.message
.contains("pull plan contains 19 official URLs")));
assert!(events
.iter()
.any(|event| event.message.contains("拉取计划包含 19 个官方 URL")));
}
#[test]
@@ -201,14 +201,14 @@ fn official_update_first_run_pulls_without_pre_audit() {
assert_eq!(report.skipped_count, 0);
assert_eq!(report.local_manifest_repair_needed_count, 0);
assert!(events.iter().any(|event| {
event.stage == "local-state" && event.message.contains("has_local_resources=false")
event.stage == "local-state" && event.message.contains("是否已有本地资源=false")
}));
assert!(events
.iter()
.any(|event| { event.stage == "audit" && event.message.contains("skipping local audit") }));
assert!(events.iter().any(|event| {
event.stage == "decision" && event.message.contains("initial_pull_needed=true")
}));
.any(|event| { event.stage == "audit" && event.message.contains("跳过本地审计") }));
assert!(events
.iter()
.any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
}
#[test]
@@ -227,13 +227,13 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
assert_eq!(report.local_manifest_verified_count, 19);
assert_eq!(report.local_manifest_repair_needed_count, 0);
assert!(events.iter().any(|event| {
event.stage == "local-state" && event.message.contains("has_local_resources=true")
event.stage == "local-state" && event.message.contains("是否已有本地资源=true")
}));
assert!(events.iter().any(|event| {
event.stage == "audit" && event.message.contains("auditing local download manifest")
event.stage == "audit" && event.message.contains("审计本地下载 manifest")
}));
assert!(events.iter().any(|event| {
event.stage == "decision" && event.message.contains("initial_pull_needed=false")
event.stage == "decision" && event.message.contains("首次拉取=false")
}));
}