fix: 完成下载并发与翻译交接链路

This commit is contained in:
2026-08-02 22:34:23 +08:00
parent d533c88108
commit 8b64cc94f3
19 changed files with 2923 additions and 138 deletions
+130 -19
View File
@@ -1,6 +1,11 @@
//! 游戏客户端领域对象
use std::path::PathBuf;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const CLIENT_ROOTS_ENV: &str = "BAT_CLIENT_ROOTS";
/// 游戏区域
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -69,18 +74,58 @@ impl GameClient {
}
}
/// 发现本地安装的客户端
/// 发现显式配置根目录下的本地客户端
///
/// # 返回
/// - 成功:返回找到的所有客户端
/// - 失败:返回错误
///
/// # 注意
/// 此功能将在 Phase 3 实现
/// 默认不扫描系统目录。调用方必须通过 `BAT_CLIENT_ROOTS` 提供一个或
/// 多个路径;路径格式使用平台原生路径分隔符。没有配置时返回空列表。
pub fn discover() -> crate::Result<Vec<GameClient>> {
Err(crate::Error::NotImplemented(
"客户端发现功能将在 Phase 3 实现".to_string(),
))
let Some(value) = env::var_os(CLIENT_ROOTS_ENV) else {
return Ok(Vec::new());
};
let roots = env::split_paths(&value).collect::<Vec<_>>();
Self::discover_in_roots(&roots)
}
/// 在调用方明确提供的隔离根目录下发现客户端。
///
/// 每个根目录只检查根本身和它的直接子目录,不递归扫描用户目录。
/// 当前核心模型的默认发现区域为日本服;其他区域应由适配器提供
/// 专用区域识别策略。
pub fn discover_in_roots(roots: &[PathBuf]) -> crate::Result<Vec<GameClient>> {
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for root in roots {
if !is_real_directory(root)? || has_symlink_component(root)? {
continue;
}
if seen.insert(root.clone()) {
candidates.push(root.clone());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if !is_real_directory(&path)? || has_symlink_component(&path)? {
continue;
}
if seen.insert(path.clone()) {
candidates.push(path);
}
}
}
let mut clients = Vec::new();
for path in candidates {
if client_layout_is_present(&path)? {
clients.push(GameClient::new(path, GameRegion::Japan));
}
}
Ok(clients)
}
/// 验证客户端完整性
@@ -89,12 +134,11 @@ impl GameClient {
/// - true: 客户端完整
/// - false: 客户端损坏
///
/// # 注意
/// 此功能将在 Phase 3 实现
pub fn verify_integrity(&self) -> crate::Result<bool> {
Err(crate::Error::NotImplemented(
"完整性验证将在 Phase 3 实现".to_string(),
))
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
return Ok(false);
}
Ok(client_layout_is_present(&self.install_path)?)
}
/// 获取 StreamingAssets 目录路径
@@ -113,6 +157,8 @@ impl GameClient {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_game_region_code() {
@@ -153,12 +199,77 @@ mod tests {
}
#[test]
fn test_discover_not_implemented() {
let result = GameClient::discover();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
crate::Error::NotImplemented(_)
));
fn test_discover_without_explicit_roots_is_empty() {
// discover() 不得因为测试机或用户 home 中存在目录而扫描它们。
assert!(GameClient::discover_in_roots(&[]).unwrap().is_empty());
}
#[test]
fn test_discover_and_verify_isolated_client_layout() {
let temp = TempDir::new().unwrap();
let client_root = temp.path().join("BlueArchive_JP");
fs::create_dir_all(client_root.join("BlueArchive_Data/StreamingAssets/AssetBundles"))
.unwrap();
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].install_path, client_root);
assert_eq!(clients[0].region, GameRegion::Japan);
assert!(clients[0].verify_integrity().unwrap());
}
#[test]
fn test_integrity_rejects_incomplete_layout() {
let temp = TempDir::new().unwrap();
let client = GameClient::new(temp.path().join("missing"), GameRegion::Japan);
assert!(!client.verify_integrity().unwrap());
}
#[cfg(unix)]
#[test]
fn test_discovery_and_integrity_reject_symlinked_client() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let real = temp.path().join("real");
fs::create_dir_all(real.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
let link = temp.path().join("link");
symlink(&real, &link).unwrap();
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].install_path, real);
assert!(!GameClient::new(link, GameRegion::Japan)
.verify_integrity()
.unwrap());
}
}
fn client_layout_is_present(path: &Path) -> crate::Result<bool> {
Ok(is_real_directory(&path.join("BlueArchive_Data"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets/AssetBundles"))?
&& !has_symlink_component(path)?)
}
fn is_real_directory(path: &Path) -> crate::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn has_symlink_component(path: &Path) -> crate::Result<bool> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
}
}
Ok(false)
}